swarm-controller: restrict its socket directory to nginx's access group
This commit is contained in:
parent
5bb9d7c7e5
commit
51cc093465
4 changed files with 63 additions and 16 deletions
|
|
@ -20,6 +20,12 @@ let
|
||||||
natsCfg = config.services.hyperhive.swarm.nats;
|
natsCfg = config.services.hyperhive.swarm.nats;
|
||||||
swarmCfg = config.services.hyperhive.swarm;
|
swarmCfg = config.services.hyperhive.swarm;
|
||||||
|
|
||||||
|
# Who may connect to the controller's socket: nginx (the gateway's `/api/`
|
||||||
|
# and `/webhook/forge/` locations) and root (`swarmctl`). A group of its own
|
||||||
|
# rather than `swarm-controller`: that group also owns the state directory,
|
||||||
|
# and nginx has no business reading it.
|
||||||
|
clientsGroup = "swarm-controller-clients";
|
||||||
|
|
||||||
# Quick links to swarm services, built from `swarm.*` options alone: every
|
# Quick links to swarm services, built from `swarm.*` options alone: every
|
||||||
# host evaluates the same entries, whichever host runs each service. One
|
# host evaluates the same entries, whichever host runs each service. One
|
||||||
# entry per service; reading a `deploy.*` option here would tie a link to
|
# entry per service; reading a `deploy.*` option here would tie a link to
|
||||||
|
|
@ -469,10 +475,13 @@ in
|
||||||
proxies to.
|
proxies to.
|
||||||
|
|
||||||
The **directory** is the access control here, not the socket mode:
|
The **directory** is the access control here, not the socket mode:
|
||||||
the socket itself is `0666` (nginx runs as another user, and
|
the socket itself is `0666`, exactly as hive-c0re publishes the
|
||||||
`connect(2)` needs write), exactly as hive-c0re publishes the
|
per-agent sockets, and the directory is `0750` owned by the
|
||||||
per-agent sockets. What keeps that safe is that the directory holds
|
`${clientsGroup}` group. Members of that group (the gateway's
|
||||||
exactly one socket and is traverse-only (`0751`) for everyone else.
|
nginx, for `/api/` and `/webhook/forge/`) and root (`swarmctl`,
|
||||||
|
which bypasses mode bits) can reach the socket; every other local
|
||||||
|
user gets `EACCES` at the directory. What keeps `0666` safe is that
|
||||||
|
the directory holds exactly one socket.
|
||||||
Moving this path under a directory that carries anything else —
|
Moving this path under a directory that carries anything else —
|
||||||
`/run/hyperhive`, which holds the host admin socket, above all —
|
`/run/hyperhive`, which holds the host admin socket, above all —
|
||||||
exposes whatever else lives there to the same reachability.
|
exposes whatever else lives there to the same reachability.
|
||||||
|
|
@ -621,6 +630,14 @@ in
|
||||||
description = "hyperhive swarm-controller daemon";
|
description = "hyperhive swarm-controller daemon";
|
||||||
};
|
};
|
||||||
users.groups.swarm-controller = { };
|
users.groups.swarm-controller = { };
|
||||||
|
users.groups.${clientsGroup} = { };
|
||||||
|
|
||||||
|
# Through the unit, not `users.users.nginx.extraGroups`: systemd resolves
|
||||||
|
# a unit's groups at start, and only a changed unit file makes the switch
|
||||||
|
# restart nginx (it does not reload) onto the new group.
|
||||||
|
systemd.services.nginx = lib.mkIf config.services.nginx.enable {
|
||||||
|
serviceConfig.SupplementaryGroups = [ clientsGroup ];
|
||||||
|
};
|
||||||
|
|
||||||
# Installed host-wide, not into the daemon's unit: `swarmctl` is run
|
# 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
|
# by a human on this box and acts as root, so the daemon's sandbox is
|
||||||
|
|
@ -707,10 +724,14 @@ in
|
||||||
# socket. See `socketPath`'s description for why that is a security
|
# socket. See `socketPath`'s description for why that is a security
|
||||||
# property and not tidiness.
|
# property and not tidiness.
|
||||||
RuntimeDirectory = "swarm-controller";
|
RuntimeDirectory = "swarm-controller";
|
||||||
# 0751: traverse-only for others, so the gateway's nginx can reach
|
# 0750 with the group set to `clientsGroup` below: only nginx and
|
||||||
# the socket path without being able to list the directory. Same
|
# root can reach the socket inside.
|
||||||
# shape (and same reason) as hive-c0re's runtime dir.
|
RuntimeDirectoryMode = "0750";
|
||||||
RuntimeDirectoryMode = "0751";
|
# systemd chowns the runtime directory to `User:Group` on every
|
||||||
|
# start, and the daemon is not a member of `clientsGroup`, so it
|
||||||
|
# cannot chgrp the directory itself. `+` runs this as root, after
|
||||||
|
# that chown.
|
||||||
|
ExecStartPre = "+${pkgs.coreutils}/bin/chgrp ${clientsGroup} ${builtins.dirOf deployCfg.swarm-controller.socketPath}";
|
||||||
# Preserved across restarts so the path never vanishes from under a
|
# Preserved across restarts so the path never vanishes from under a
|
||||||
# running nginx. The daemon unlinks a stale socket on start, which
|
# running nginx. The daemon unlinks a stale socket on start, which
|
||||||
# is what makes preservation safe.
|
# is what makes preservation safe.
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,24 @@ let
|
||||||
&& lib.hasInfix socket l."/webhook/forge/".proxyPass
|
&& lib.hasInfix socket l."/webhook/forge/".proxyPass
|
||||||
&& !(lib.hasInfix "auth_request" l."/webhook/forge/".extraConfig);
|
&& !(lib.hasInfix "auth_request" l."/webhook/forge/".extraConfig);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# Only nginx (through the clients group) and root reach the controller
|
||||||
|
# socket: the runtime dir is 0750 with that group set. The last two
|
||||||
|
# clauses are the presence controls — the group exists and is not the
|
||||||
|
# daemon's own, so a misspelled name cannot satisfy the rest.
|
||||||
|
name = "the controller socket's directory is 0750 and group-owned by the group nginx joins";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
sc = allLocal.systemd.services.swarm-controller.serviceConfig;
|
||||||
|
group = "swarm-controller-clients";
|
||||||
|
in
|
||||||
|
sc.RuntimeDirectoryMode == "0750"
|
||||||
|
&& lib.hasPrefix "+" sc.ExecStartPre
|
||||||
|
&& lib.hasInfix "chgrp ${group} /run/swarm-controller" sc.ExecStartPre
|
||||||
|
&& lib.elem group allLocal.systemd.services.nginx.serviceConfig.SupplementaryGroups
|
||||||
|
&& allLocal.users.groups ? ${group}
|
||||||
|
&& sc.Group != group;
|
||||||
|
}
|
||||||
{
|
{
|
||||||
# nginx serves a path from its longest matching prefix location, so that
|
# nginx serves a path from its longest matching prefix location, so that
|
||||||
# one must be the gated `/api/`, and no exact location may take a route
|
# one must be the gated `/api/`, and no exact location may take a route
|
||||||
|
|
|
||||||
|
|
@ -93,8 +93,8 @@ queue, so both survive a restart.
|
||||||
|
|
||||||
## Why a unix socket, not a port
|
## Why a unix socket, not a port
|
||||||
|
|
||||||
The hive-gateway's nginx is the only intended client and reaches the socket
|
The hive-gateway's nginx is the only intended client (`swarmctl` runs as root).
|
||||||
through a bind-mount. A listener that is never bound to an address cannot be
|
A listener that is never bound to an address cannot be
|
||||||
reached from off-host by mistake.
|
reached from off-host by mistake.
|
||||||
|
|
||||||
The socket path is `services.hyperhive.deploy.swarm-controller.socketPath`, default
|
The socket path is `services.hyperhive.deploy.swarm-controller.socketPath`, default
|
||||||
|
|
@ -108,7 +108,12 @@ The socket is `0666`. It has to be: nginx runs as a different user and
|
||||||
sockets, and rests on the same argument — _"the bind source dir is per-agent on
|
sockets, and rests on the same argument — _"the bind source dir is per-agent on
|
||||||
host so blast radius is unchanged."_
|
host so blast radius is unchanged."_
|
||||||
|
|
||||||
What keeps that safe is that the directory holds **one** socket. So:
|
What keeps that safe is that the directory is `0750`, owned by the
|
||||||
|
`swarm-controller-clients` group, and holds **one** socket. nginx is in that
|
||||||
|
group through its unit's `SupplementaryGroups`; root bypasses mode bits, so
|
||||||
|
`swarmctl` needs no membership. Every other local user gets `EACCES` at the
|
||||||
|
directory. The group is not `swarm-controller`'s own, which also owns the
|
||||||
|
state directory. So:
|
||||||
|
|
||||||
> **Never point `socketPath` at a directory that carries anything else.**
|
> **Never point `socketPath` at a directory that carries anything else.**
|
||||||
> `/run/hyperhive` above all — it holds `host.sock`, the host **admin** socket.
|
> `/run/hyperhive` above all — it holds `host.sock`, the host **admin** socket.
|
||||||
|
|
@ -116,7 +121,9 @@ What keeps that safe is that the directory holds **one** socket. So:
|
||||||
> socket within its reach too.
|
> socket within its reach too.
|
||||||
|
|
||||||
nginx is a host service, so nothing narrows what it can reach except the
|
nginx is a host service, so nothing narrows what it can reach except the
|
||||||
directory itself — that is the whole of the access control. A unit test pins
|
directory itself — that is the whole of the access control. The unit sets the
|
||||||
|
group with a root `ExecStartPre` (`chgrp`), because systemd chowns the runtime
|
||||||
|
directory to the unit's own group on every start. A unit test pins
|
||||||
the default path so a tidying edit fails instead of reviewing cleanly.
|
the default path so a tidying edit fails instead of reviewing cleanly.
|
||||||
|
|
||||||
`RuntimeDirectoryPreserve=yes` and the daemon's stale-socket unlink on start are
|
`RuntimeDirectoryPreserve=yes` and the daemon's stale-socket unlink on start are
|
||||||
|
|
|
||||||
|
|
@ -3468,10 +3468,11 @@ async fn main() -> Result<()> {
|
||||||
// 0666 matches how hive-c0re publishes the per-agent sockets
|
// 0666 matches how hive-c0re publishes the per-agent sockets
|
||||||
// (`socket_server::start`), and rests on the same argument: **the
|
// (`socket_server::start`), and rests on the same argument: **the
|
||||||
// containing directory is the access control, not the socket mode.**
|
// containing directory is the access control, not the socket mode.**
|
||||||
// This directory holds one socket and is bind-mounted into exactly
|
// The unit makes the directory `0750`, group `swarm-controller-clients`,
|
||||||
// one container. That is also why it must not be shared with
|
// so only nginx and root reach the socket. The directory holds one
|
||||||
// hive-c0re's `/run/hyperhive` — with a 0666 socket, a directory
|
// socket, which is also why it must not be shared with hive-c0re's
|
||||||
// that carries more than it should is the whole vulnerability.
|
// `/run/hyperhive` — with a 0666 socket, a directory that carries
|
||||||
|
// more than it should is the whole vulnerability.
|
||||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666))
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666))
|
||||||
.with_context(|| format!("chmod {}", path.display()))?;
|
.with_context(|| format!("chmod {}", path.display()))?;
|
||||||
tracing::info!(socket = %path.display(), "swarm-controller listening");
|
tracing::info!(socket = %path.display(), "swarm-controller listening");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue