feat(#3089): add swarmctl and a user-add verb for the swarm's SSO
The swarm-authelia module states that its users database is written by swarm-controller, but nothing ever granted the means. This adds the tool that does it. swarmctl runs as root on the controller's host and acts directly. The rootless alternative was examined and does not work: relocating the users file into a directory the controller owns only turns a write problem into a read problem, because authelia must then reach across the same boundary in the other direction. Making that read work needs either a hand-pinned gid or world-readable password hashes. The user store is two files, one authoritative: users.json is canonical, users.yml is a rendered artifact. That split is what lets the crate work without a YAML parser -- the workspace has none, and adding one costs a crates.io fetch, a lock update and a vendor hash for a schema we fully control and only ever emit. Passwords are generated by authelia rather than passed to it: argv is world-readable, so a password on a command line is readable by any local process for the lifetime of the call. The three derived facts swarmctl needs about the authelia container -- machine, unit and the host-side users path -- become readOnly options on the authelia module rather than literals repeated at the call site.
This commit is contained in:
parent
3a69ad4256
commit
9e44efa01f
11 changed files with 995 additions and 1 deletions
|
|
@ -150,10 +150,54 @@ in
|
|||
is the correct state before anything has provisioned users.
|
||||
'';
|
||||
};
|
||||
|
||||
# Derived facts, exposed for consumers that have to act on this
|
||||
# container **from outside it** — `swarmctl` is the first, and it
|
||||
# needs all three. Read-only options rather than literals repeated at
|
||||
# the call site: the machine and unit names are derived from
|
||||
# `instance` here, so a second copy elsewhere is a second thing to
|
||||
# keep in step, and the one that drifts is the one nobody tests.
|
||||
machine = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
readOnly = true;
|
||||
default = "swarm-authelia";
|
||||
description = ''
|
||||
Name of the nixos-container authelia runs in. Read-only: it is
|
||||
what this module declares, published so callers of
|
||||
`systemctl -M` and `/var/lib/nixos-containers/<name>` do not
|
||||
have to hardcode it.
|
||||
'';
|
||||
};
|
||||
|
||||
unit = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
readOnly = true;
|
||||
default = "${unitName}.service";
|
||||
description = ''
|
||||
authelia's systemd unit *inside* the container. Read-only, and
|
||||
derived from the instance name exactly like the unit itself.
|
||||
'';
|
||||
};
|
||||
|
||||
hostUsersFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
readOnly = true;
|
||||
default = "/var/lib/nixos-containers/${cfg.machine}${cfg.usersFile}";
|
||||
description = ''
|
||||
`usersFile` as seen from the **host** — the container's root
|
||||
prefixed onto the path authelia sees.
|
||||
|
||||
The distinction is load-bearing: the users database is written
|
||||
from the host by a program that does not live in this container,
|
||||
while authelia only ever sees the inner path. Handing the wrong
|
||||
one to either side yields a file nobody reads rather than an
|
||||
error.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
|
||||
containers.swarm-authelia = {
|
||||
containers.${cfg.machine} = {
|
||||
autoStart = true;
|
||||
ephemeral = false;
|
||||
# Shared host netns, like the forge and matrix containers: the
|
||||
|
|
|
|||
|
|
@ -8,12 +8,47 @@
|
|||
# bind-mount, and a socket that is never bound to an address cannot be
|
||||
# reached from off-host by mistake.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.swarm.controller;
|
||||
autheliaCfg = config.services.hyperhive.swarm.authelia;
|
||||
|
||||
# What `swarmctl` needs in order to act on authelia from the host.
|
||||
#
|
||||
# Only set when authelia actually runs **here**: the controller can be
|
||||
# enabled on a host that is not the swarm's SSO host, and in that case
|
||||
# the right behaviour is for `swarmctl user add` to fail saying the
|
||||
# value is unset. A guessed path would resolve cleanly and write a file
|
||||
# nothing reads, which is the failure mode that costs an afternoon.
|
||||
autheliaEnv = lib.optionalAttrs autheliaCfg.enable {
|
||||
# The CONFIGURED authelia, not whatever is on PATH: the argon2
|
||||
# parameters baked into a hash have to match the verifier's.
|
||||
SWARMCTL_AUTHELIA_BIN = "${autheliaCfg.package}/bin/authelia";
|
||||
SWARMCTL_AUTHELIA_USERS_FILE = autheliaCfg.hostUsersFile;
|
||||
SWARMCTL_AUTHELIA_MACHINE = autheliaCfg.machine;
|
||||
SWARMCTL_AUTHELIA_UNIT = autheliaCfg.unit;
|
||||
};
|
||||
|
||||
# Wrapped rather than documented: every one of these values is derived
|
||||
# from an option this deployment already set, so making the operator
|
||||
# re-supply them on the command line would be asking them to repeat the
|
||||
# config back to it — and to get it wrong the day one of them changes.
|
||||
swarmctlConfigured = pkgs.symlinkJoin {
|
||||
name = "swarmctl-configured";
|
||||
paths = [ cfg.swarmctlPackage ];
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
postBuild = ''
|
||||
wrapProgram $out/bin/swarmctl ${
|
||||
lib.concatStringsSep " " (
|
||||
lib.mapAttrsToList (name: value: "--set ${name} ${lib.escapeShellArg value}") autheliaEnv
|
||||
)
|
||||
}
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
options.services.hyperhive.swarm.controller = {
|
||||
|
|
@ -37,6 +72,20 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
swarmctlPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarmctl";
|
||||
description = ''
|
||||
`swarmctl` package — the swarm operator's CLI, installed on this
|
||||
host alongside the daemon and wrapped with the paths it needs.
|
||||
|
||||
A separate option from `package` rather than a second binary in
|
||||
the same derivation: the CLI runs as root and acts directly,
|
||||
the daemon runs unprivileged and serves a socket, and pinning one
|
||||
without the other is a thing an operator may legitimately want.
|
||||
'';
|
||||
};
|
||||
|
||||
socketPath = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/run/swarm-controller/controller.sock";
|
||||
|
|
@ -67,6 +116,11 @@ in
|
|||
};
|
||||
users.groups.swarm-controller = { };
|
||||
|
||||
# Installed host-wide, not into the daemon's unit: `swarmctl` is run
|
||||
# by a human on this box and acts as root, so the daemon's sandbox is
|
||||
# exactly what it must not inherit.
|
||||
environment.systemPackages = [ swarmctlConfigured ];
|
||||
|
||||
systemd.services.swarm-controller = {
|
||||
description = "hyperhive swarm-level controller daemon";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
|
|
|||
Loading…
Reference in a new issue