feat(nix): swarm-controller systemd unit, service user and socket
services.hyperhive.swarm.controller.{enable,package,socketPath} plus the
unprivileged swarm-controller user, its runtime and state directories,
and the unit itself.
enable is deliberately not derived from services.hyperhive.enable, unlike
c0re: a swarm has one controller, so turning it on is a statement about
swarm topology rather than about whether hyperhive is installed.
The socket gets its own RuntimeDirectory. nginx reaches a unix upstream
by having the socket's directory bind-mounted into the gateway
container, and the socket is 0666 because connect needs write -- so the
directory is the only access control there is. Sharing one with the host
admin socket would hand that socket to the gateway too. The constraint
is stated at both ends, in the option description and beside the bind,
because it is invisible from either site alone; a test pins the path so
a tidying edit fails rather than reviews cleanly.
RuntimeDirectoryPreserve and the daemon's stale-socket unlink are a
pair: preserving the directory without the unlink means bind fails with
EADDRINUSE after a restart.
This commit is contained in:
parent
f10f8a6bc6
commit
435dfbfb33
4 changed files with 149 additions and 0 deletions
|
|
@ -142,6 +142,9 @@
|
|||
agentBaseToplevel = lib.mkDefault self.packages.x86_64-linux.agent-base-toplevel;
|
||||
managerToplevel = lib.mkDefault self.packages.x86_64-linux.ruth-toplevel;
|
||||
};
|
||||
services.hyperhive.swarm.controller.package =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-controller;
|
||||
services.hyperhive.gateway.swaggerUiTheme =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swagger-ui-theme;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
./hive-priv.nix
|
||||
./hive-tls.nix
|
||||
./otel.nix
|
||||
./swarm-controller.nix
|
||||
./swarm-snapshot-store.nix
|
||||
./swarm-wireguard.nix
|
||||
./swarm.nix
|
||||
|
|
|
|||
117
nix/host-modules/swarm-controller.nix
Normal file
117
nix/host-modules/swarm-controller.nix
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# The swarm-level controller daemon. Per-host opt-in: a swarm has one
|
||||
# controller, so most hives leave this off and point at the hive that
|
||||
# runs it. Distinct from hive-c0re, which every hive runs — c0re owns
|
||||
# the agents on one host, this owns what is true across hives.
|
||||
#
|
||||
# Serves HTTP over a unix socket rather than a TCP port: the gateway's
|
||||
# nginx is the only intended client, it reaches the socket through a
|
||||
# bind-mount, and a socket that is never bound to an address cannot be
|
||||
# reached from off-host by mistake.
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.swarm.controller;
|
||||
in
|
||||
{
|
||||
options.services.hyperhive.swarm.controller = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Run the swarm-controller daemon on this host. Off by default and
|
||||
deliberately not derived from `services.hyperhive.enable`: a swarm
|
||||
has one controller, so enabling it per hive is a decision about
|
||||
swarm topology, not about whether hyperhive is installed.
|
||||
'';
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-controller";
|
||||
description = ''
|
||||
swarm-controller package. Wired by default from this flake's own
|
||||
package set (see `flake.nix`); override to run a different build.
|
||||
'';
|
||||
};
|
||||
|
||||
socketPath = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/run/swarm-controller/controller.sock";
|
||||
description = ''
|
||||
Unix socket the daemon serves on, and the path the gateway's nginx
|
||||
proxies to.
|
||||
|
||||
The **directory** is the access control here, not the socket mode:
|
||||
the socket itself is `0666` (nginx runs as another user, and
|
||||
`connect(2)` needs write), exactly as hive-c0re publishes the
|
||||
per-agent sockets. What keeps that safe is that the directory holds
|
||||
one socket and is bind-mounted into one container. Moving this path
|
||||
under a directory that carries anything else — `/run/hyperhive`,
|
||||
which holds the host admin socket, above all — hands whatever else
|
||||
lives there to every consumer that mounts it.
|
||||
|
||||
Changing this therefore means re-checking the gateway bind-mount,
|
||||
not just the daemon.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) {
|
||||
users.users.swarm-controller = {
|
||||
isSystemUser = true;
|
||||
group = "swarm-controller";
|
||||
description = "hyperhive swarm-controller daemon";
|
||||
};
|
||||
users.groups.swarm-controller = { };
|
||||
|
||||
systemd.services.swarm-controller = {
|
||||
description = "hyperhive swarm-level controller daemon";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = "${cfg.package}/bin/swarm-controller";
|
||||
User = "swarm-controller";
|
||||
Group = "swarm-controller";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
|
||||
# `/run/swarm-controller` — its own directory, holding only the
|
||||
# socket. See `socketPath`'s description for why that is a security
|
||||
# property and not tidiness.
|
||||
RuntimeDirectory = "swarm-controller";
|
||||
# 0751: traverse-only for others, so the gateway's nginx can reach
|
||||
# the socket path without being able to list the directory. Same
|
||||
# shape (and same reason) as hive-c0re's runtime dir.
|
||||
RuntimeDirectoryMode = "0751";
|
||||
# Preserved across restarts so the bind-mount source never vanishes
|
||||
# from under a running gateway container. The daemon unlinks a stale
|
||||
# socket on start, which is what makes preservation safe.
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
|
||||
StateDirectory = "swarm-controller";
|
||||
StateDirectoryMode = "0750";
|
||||
|
||||
# Nothing here needs a writable filesystem, real privileges, or a
|
||||
# view of the rest of the machine; the daemon reads its socket path
|
||||
# from config and serves.
|
||||
PrivateTmp = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
NoNewPrivileges = true;
|
||||
PrivateDevices = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectControlGroups = true;
|
||||
RestrictAddressFamilies = [
|
||||
"AF_UNIX"
|
||||
];
|
||||
};
|
||||
|
||||
environment.SWARM_CONTROLLER_SOCKET = cfg.socketPath;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -87,3 +87,31 @@ async fn main() -> Result<()> {
|
|||
.await
|
||||
.context("serving swarm-controller")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::DEFAULT_SOCKET;
|
||||
use std::path::Path;
|
||||
|
||||
/// The socket must not share a directory with anything else, because
|
||||
/// the socket is `0666` and the directory is therefore the only access
|
||||
/// control it has. `/run/hyperhive` in particular holds hive-c0re's
|
||||
/// **admin** socket, and nginx reaches a unix upstream by mounting the
|
||||
/// socket's whole directory into the gateway container.
|
||||
///
|
||||
/// A test rather than a comment: the failure this guards against is a
|
||||
/// one-word edit that looks tidier and reads fine in review.
|
||||
#[test]
|
||||
fn socket_lives_in_its_own_runtime_dir() {
|
||||
let parent = Path::new(DEFAULT_SOCKET)
|
||||
.parent()
|
||||
.expect("socket path has a parent directory");
|
||||
assert_eq!(
|
||||
parent,
|
||||
Path::new("/run/swarm-controller"),
|
||||
"the socket's directory is its access control — moving it under a shared \
|
||||
directory (notably /run/hyperhive, which holds the host admin socket) \
|
||||
exposes everything else in that directory to the gateway container"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue