Watch
0
0
Fork
You've already forked hyperhive
0

run swarm-attic in its own network namespace

the gateway reaches atticd over a unix socket a relay in the container
forwards, and the host unit that creates the cache dials its public name.
This commit is contained in:
atlas 2026-11-02 23:56:41 +01:00 • committed by mara
commit 1d3ab2fae5
8 changed files with 198 additions and 41 deletions

View file

@ -394,9 +394,16 @@ path nobody added or fetched for 14 days goes at the next garbage collection,
which runs every 12 hours (`deploy.attic.retentionPeriod`). The controller which runs every 12 hours (`deploy.attic.retentionPeriod`). The controller
[rebuilds a closure the cache lost](#rebuild-on-a-cache-miss). [rebuilds a closure the cache lost](#rebuild-on-a-cache-miss).
The gateway terminates TLS with a swarm services certificate and forwards to The container sits on the hive bridge in a network namespace of its own. atticd
`127.0.0.1:8910` (`deploy.attic.port`) with no login in front: attic checks its listens on that namespace's loopback (`deploy.attic.port`), and a socket-activated
own tokens, and a nix client can't follow a login redirect. The container logs relay forwards `/run/swarm-attic/atticd.sock` to it, a directory only the
gateway's nginx and the container can open. The gateway terminates TLS with a
swarm services certificate and forwards to that socket with no login in front:
attic checks its own tokens, and a nix client can't follow a login redirect.
The two host units reach the cache through the gateway, at
`https://<swarm.attic.domain>`, like every other client; the host resolves the
name through the hive resolver, and where the gateway serves a self-signed
certificate `swarm-bao-attic-tokens` trusts the swarm's CA. The container logs
to its journal only; its journal doesn't reach the swarm log store. to its journal only; its journal doesn't reach the swarm log store.
### External cache ### External cache

View file

@ -459,8 +459,9 @@ it. For an external cache, override `swarm.attic.{domain,cache}` and don't set
`deploy.attic.enable` anywhere, or set it to `lib.mkForce false` under `deploy.attic.enable` anywhere, or set it to `lib.mkForce false` under
`allSwarmServices`. `allSwarmServices`.
The container shares the host's network namespace and atticd listens on loopback The container sits on the hive bridge in a network namespace of its own, and atticd
only. The gateway serves it at `swarm.attic.domain` (default listens on that namespace's loopback only; the gateway reaches it over a unix socket.
The gateway serves it at `swarm.attic.domain` (default
`attic.<swarm domain>`, on every host) over TLS, with no login in front, because attic checks `attic.<swarm domain>`, on every host) over TLS, with no login in front, because attic checks
its own tokens. Storage is local disk in the container's state. its own tokens. Storage is local disk in the container's state.

View file

@ -31,7 +31,8 @@
url, url,
# The cache's name on the server. # The cache's name on the server.
cache, cache,
# Where the host reaches atticd, scheme included. # Where the host reaches atticd, scheme included: the gateway, which answers
# 502 while atticd is down.
endpoint, endpoint,
# The server's config file, which the host's `atticadm` reads as well. # The server's config file, which the host's `atticadm` reads as well.
configFile, configFile,
@ -110,16 +111,23 @@ in
# tokens are signed here with the key the store holds; the cache is created # tokens are signed here with the key the store holds; the cache is created
# through the API by a token that lives for five minutes and is never stored. # through the API by a token that lives for five minutes and is never stored.
provision = '' provision = ''
# The gateway answers 502 while atticd is down, so only a status below 500
# is atticd answering.
up=0 up=0
code=000
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
if curl -s -o /dev/null --max-time 2 ${q endpoint}; then code="$(curl -s -o /dev/null --max-time 2 -w '%{http_code}' ${q endpoint} || true)"
up=1 case "$code" in
break "" | 000 | 5??) ;;
fi *)
up=1
break
;;
esac
sleep 2 sleep 2
done done
if [ "$up" != 1 ]; then if [ "$up" != 1 ]; then
echo "atticd did not answer at ${endpoint}." >&2 echo "atticd did not answer at ${endpoint} (last status $code)." >&2
exit 1 exit 1
fi fi

View file

@ -10,10 +10,13 @@
# (its address, name and public key), each only while absent # (its address, name and public key), each only while absent
# (./lib/attic-init.nix). Neither needs a manual step. # (./lib/attic-init.nix). Neither needs a manual step.
# #
# The container shares the host's network namespace, as ./swarm-victorialogs.nix # The container has a network namespace of its own on the hive bridge. atticd
# does, and atticd listens on loopback only: the gateway's vhost is the way in, # listens on that namespace's loopback only, because it cannot listen on a unix
# and attic's own tokens are the authentication. Storage is local disk under the # socket. A socket-activated relay in the container forwards a unix socket to it,
# container's state. # and the gateway's nginx proxies to that socket. The host units reach the cache
# as every client does, at its published name through the gateway. Attic's own
# tokens are the authentication. Storage is local disk under the container's
# state.
# #
# The two `swarm.attic` options every host reads (./swarm-attic-options.nix) # The two `swarm.attic` options every host reads (./swarm-attic-options.nix)
# default to this cache's names on every host, whether or not it runs here. # default to this cache's names on every host, whether or not it runs here.
@ -45,8 +48,17 @@ let
secretDir = "/run/swarm-attic-secrets"; secretDir = "/run/swarm-attic-secrets";
envFile = "${secretDir}/server.env"; envFile = "${secretDir}/server.env";
# Where the host units reach atticd: the shared loopback, not the gateway. # Where the host units reach atticd: the gateway, at the published name.
endpoint = "http://127.0.0.1:${toString cfg.port}"; endpoint = "https://${atticCfg.domain}";
# The unix socket the gateway's nginx connects to. The directory's mode is
# what keeps it private, so it holds this one socket.
socketDir = "/run/swarm-attic";
socketPath = "${socketDir}/atticd.sock";
socketGroup = "gateway-nginx";
# Only the number crosses into the container, which knows the group by name.
nginxGid = config.ids.gids.nginx;
relayUnit = "swarm-attic-relay";
configFile = (pkgs.formats.toml { }).generate "swarm-attic.toml" { configFile = (pkgs.formats.toml { }).generate "swarm-attic.toml" {
listen = "127.0.0.1:${toString cfg.port}"; listen = "127.0.0.1:${toString cfg.port}";
@ -77,7 +89,7 @@ let
urlPath = "secret/swarm/services/attic/url"; urlPath = "secret/swarm/services/attic/url";
cachePath = "secret/swarm/services/attic/cache"; cachePath = "secret/swarm/services/attic/cache";
publicKeyPath = "secret/swarm/services/attic/public-key"; publicKeyPath = "secret/swarm/services/attic/public-key";
url = "https://${atticCfg.domain}"; url = endpoint;
cache = atticCfg.cache; cache = atticCfg.cache;
inherit inherit
endpoint endpoint
@ -100,10 +112,10 @@ let
forwarderClientId = atticCfg.otel.clientId; forwarderClientId = atticCfg.otel.clientId;
forwarderEndpoint = "https://${hyperhiveCfg.swarm.otel.domain}/${machine}"; forwarderEndpoint = "https://${hyperhiveCfg.swarm.otel.domain}/${machine}";
forwarderUnit = "swarm-journal-forwarder-oidc"; forwarderUnit = "swarm-journal-forwarder-oidc";
# In the host netns: distinct from every other collector's there. # Loopback ports of the forwarder's own metrics and of its OTLP/HTTP metrics
# receiver, which the cache-size exporter pushes to, in the container's own
# netns.
forwarderTelemetryPort = 8903; forwarderTelemetryPort = 8903;
# The forwarder's OTLP/HTTP metrics receiver, which the cache-size exporter
# pushes to. Not 4318: the hive collector holds that in the host netns.
forwarderOtlpPort = 8905; forwarderOtlpPort = 8905;
# The exporter's unit and query. `V` is attic's stored value for a valid # The exporter's unit and query. `V` is attic's stored value for a valid
@ -144,6 +156,20 @@ let
}; };
in in
{ {
# The tokens unit dials the gateway's name, whose leaf the system store lacks
# when the gateway serves a self-signed one. `hostUnit`: it reads the host's
# CA path and waits on `hive-tls-ca.service`. `enable`: `imports` is
# unconditional, so without it a host that runs no cache gets a bundle unit.
imports = [
(caTrust.trustBundle {
inherit pkgs;
name = "swarm-bao-attic-tokens";
consumers = [ "swarm-bao-attic-tokens" ];
hostUnit = true;
inherit (cfg) enable;
})
];
options.services.hyperhive.deploy.attic = { options.services.hyperhive.deploy.attic = {
package = lib.mkOption { package = lib.mkOption {
type = lib.types.package; type = lib.types.package;
@ -163,11 +189,9 @@ in
type = lib.types.port; type = lib.types.port;
default = 8910; default = 8910;
description = '' description = ''
Loopback port atticd listens on, which the gateway proxies to. Port atticd listens on, on the loopback of the container's own network
namespace. The container's relay forwards the gateway's unix socket to
⚠️ Every swarm container shares the host's network namespace, so this it; nothing else dials it.
is a swarm-wide claim: two modules picking the same number collide at
runtime with no bind error. No repo-wide check catches it.
''; '';
}; };
@ -276,6 +300,9 @@ in
services.hyperhive.gateway.enable = lib.mkDefault true; services.hyperhive.gateway.enable = lib.mkDefault true;
services.hyperhive.gateway.dns.enable = lib.mkDefault true; services.hyperhive.gateway.dns.enable = lib.mkDefault true;
# The hive bridge carries the container's DHCP lease and its resolver.
services.hyperhive.network.enable = lib.mkDefault true;
# `forceSSL`: the tokens ride in a header. No `auth_request`: attic checks # `forceSSL`: the tokens ride in a header. No `auth_request`: attic checks
# its own tokens, and a nix client cannot follow a login redirect. A store # its own tokens, and a nix client cannot follow a login redirect. A store
# path is uploaded as one request that can run for minutes, so the body is # path is uploaded as one request that can run for minutes, so the body is
@ -283,7 +310,7 @@ in
services.hyperhive.gateway.vhosts."${atticCfg.domain}" = { services.hyperhive.gateway.vhosts."${atticCfg.domain}" = {
forceSSL = true; forceSSL = true;
locations."/" = { locations."/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}"; proxyPass = "http://unix:${socketPath}";
extraConfig = '' extraConfig = ''
client_max_body_size 0; client_max_body_size 0;
proxy_request_buffering off; proxy_request_buffering off;
@ -295,8 +322,12 @@ in
# The key's directory is the container's read-only bind source, so it has # The key's directory is the container's read-only bind source, so it has
# to exist before the container starts even when the store does not answer. # to exist before the container starts even when the store does not answer.
# The delivery unit creates it too, which covers a switch. # The delivery unit creates it too, which covers a switch. The socket
systemd.tmpfiles.rules = [ "d ${secretDir} 0700 root root -" ]; # directory is the gateway's way in: its group is nginx's.
systemd.tmpfiles.rules = [
"d ${secretDir} 0700 root root -"
"d ${socketDir} 0770 root ${toString nginxGid} - -"
];
# The signing key, created in the store if it holds none and written where # The signing key, created in the store if it holds none and written where
# the container reads it. Before the container and wanted by it: a store # the container reads it. Before the container and wanted by it: a store
@ -344,9 +375,12 @@ in
# facts are for the hives, which the controller copies them to. # facts are for the hives, which the controller copies them to.
systemd.services.swarm-bao-attic-tokens = { systemd.services.swarm-bao-attic-tokens = {
description = "create the swarm cache, and its tokens and public facts in the secret store, if absent"; description = "create the swarm cache, and its tokens and public facts in the secret store, if absent";
# `nginx.service`: the unit dials the cache through the gateway, whose
# resolver answers the name with the bridge address (`localNames` above).
after = [ after = [
"container@${machine}.service" "container@${machine}.service"
"swarm-bao-attic-secret.service" "swarm-bao-attic-secret.service"
"nginx.service"
]; ];
wants = [ "container@${machine}.service" ]; wants = [ "container@${machine}.service" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
@ -383,12 +417,20 @@ in
extraFlags = lib.mapAttrsToList ( extraFlags = lib.mapAttrsToList (
id: source: "--load-credential=${id}:${source}" id: source: "--load-credential=${id}:${source}"
) forwarderStoreFiles; ) forwarderStoreFiles;
privateNetwork = false; privateNetwork = true;
hostBridge = networkCfg.bridgeName;
bindMounts = { bindMounts = {
${secretDir} = { ${secretDir} = {
hostPath = secretDir; hostPath = secretDir;
isReadOnly = true; isReadOnly = true;
}; };
# A bind mount, not a path both sides know: a container's `/run` is an
# nspawn tmpfs the host cannot see into. Connecting to a socket is a
# write to it.
${socketDir} = {
hostPath = socketDir;
isReadOnly = false;
};
} }
// caTrust.bindMount; // caTrust.bindMount;
@ -416,7 +458,41 @@ in
}) })
]; ];
services.hyperhive.swarmContainer.privateNetwork = false; services.hyperhive.swarmContainer.privateNetwork = true;
# The bridge address comes from the gateway's DHCP. `swarm-bridge-dns`
# owns resolv.conf, so dhcpcd must not write it over that unit.
networking.interfaces.eth0.useDHCP = true;
networking.dhcpcd.extraConfig = "nohook resolv.conf";
# The socket's group is nginx's, so the gateway can connect to it.
users.groups.${socketGroup}.gid = nginxGid;
systemd.sockets.${relayUnit} = {
description = "unix socket the gateway reaches atticd through";
wantedBy = [ "sockets.target" ];
listenStreams = [ socketPath ];
socketConfig = {
SocketMode = "0660";
SocketUser = "root";
SocketGroup = socketGroup;
};
};
systemd.services.${relayUnit} = {
description = "relay the gateway's connections to atticd";
requires = [ "${relayUnit}.socket" ];
after = [ "${relayUnit}.socket" ];
serviceConfig = {
ExecStart = "${pkgs.systemd}/lib/systemd/systemd-socket-proxyd 127.0.0.1:${toString cfg.port}";
DynamicUser = true;
PrivateTmp = true;
PrivateDevices = true;
NoNewPrivileges = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_UNIX"
];
};
};
# Authenticated as the forwarder's own OIDC client, with the cache's # Authenticated as the forwarder's own OIDC client, with the cache's
# store identity: one identity for the whole container. It is a # store identity: one identity for the whole container. It is a

View file

@ -462,7 +462,7 @@ let
&& lib.elem "swarm-bao-attic-policy.service" atticSecret.after && lib.elem "swarm-bao-attic-policy.service" atticSecret.after
&& lib.elem "swarm-bao-attic-policy.service" atticTokens.after && lib.elem "swarm-bao-attic-policy.service" atticTokens.after
&& atticHere.containers.swarm-attic.bindMounts."/run/swarm-attic-secrets".isReadOnly && atticHere.containers.swarm-attic.bindMounts."/run/swarm-attic-secrets".isReadOnly
&& !atticHere.containers.swarm-attic.privateNetwork && atticHere.containers.swarm-attic.privateNetwork
&& atticUnit.serviceConfig.EnvironmentFile == "/run/swarm-attic-secrets/server.env" && atticUnit.serviceConfig.EnvironmentFile == "/run/swarm-attic-secrets/server.env"
&& lib.hasInfix "--mode monolithic" atticUnit.serviceConfig.ExecStart && lib.hasInfix "--mode monolithic" atticUnit.serviceConfig.ExecStart
&& atticDeploy.retentionPeriod == "14 days"; && atticDeploy.retentionPeriod == "14 days";
@ -534,7 +534,7 @@ let
names = m: m.services.hyperhive.swarm.serviceDomains; names = m: m.services.hyperhive.swarm.serviceDomains;
in in
atticVhost.forceSSL atticVhost.forceSSL
&& atticVhost.locations."/".proxyPass == "http://127.0.0.1:8910" && atticVhost.locations."/".proxyPass == "http://unix:/run/swarm-attic/atticd.sock"
&& !(lib.hasInfix "auth_request" atticVhost.locations."/".extraConfig) && !(lib.hasInfix "auth_request" atticVhost.locations."/".extraConfig)
&& lib.hasInfix "client_max_body_size 0" atticVhost.locations."/".extraConfig && lib.hasInfix "client_max_body_size 0" atticVhost.locations."/".extraConfig
&& lib.hasInfix "proxy_request_buffering off" atticVhost.locations."/".extraConfig && lib.hasInfix "proxy_request_buffering off" atticVhost.locations."/".extraConfig

View file

@ -174,8 +174,8 @@ let
# Every service container at once: the services-only host plus the CI # Every service container at once: the services-only host plus the CI
# runner. The CI runner, the review classifier, the notification router, the # runner. The CI runner, the review classifier, the notification router, the
# builder, the claude login minter, the metrics store, the homeserver, # builder, the claude login minter, the metrics store, the homeserver,
# authelia, Grafana and the collector are the ten containers on a netns of # authelia, Grafana, the collector and the cache are the eleven containers on
# their own. # a netns of their own.
serviceContainersWithCi = hive { serviceContainersWithCi = hive {
deploy.hive-controller.enable = false; deploy.hive-controller.enable = false;
deploy.allSwarmServices = true; deploy.allSwarmServices = true;
@ -814,14 +814,14 @@ let
} }
{ {
# Both values come from ../container-modules/swarm-container.nix. # Both values come from ../container-modules/swarm-container.nix.
# Five of the 15 service containers share the host netns (hive-forge, bao, # Four of the 15 service containers share the host netns (hive-forge, bao,
# the queue, the log store, the cache) and ten have one of their own. # the queue, the log store) and eleven have one of their own.
name = "a service container on the host netns runs no firewall or resolvconf of its own"; name = "a service container on the host netns runs no firewall or resolvconf of its own";
ok = ok =
let let
shared = lib.filter (c: !c.privateNetwork) (lib.attrValues serviceContainers); shared = lib.filter (c: !c.privateNetwork) (lib.attrValues serviceContainers);
in in
lib.length shared == 5 lib.length shared == 4
&& lib.all ( && lib.all (
c: !c.config.networking.firewall.enable && !c.config.networking.resolvconf.enable c: !c.config.networking.firewall.enable && !c.config.networking.resolvconf.enable
) shared; ) shared;
@ -992,6 +992,46 @@ let
&& lib.all (lib.hasPrefix "http://unix:${sock}:") forwarded && lib.all (lib.hasPrefix "http://unix:${sock}:") forwarded
&& c.bindMounts.${dir}.hostPath == dir; && c.bindMounts.${dir}.hostPath == dir;
} }
{
# atticd listens on the container's own loopback, a relay there forwards
# the socket nginx connects to, and the host unit that drives the
# TCP-only `attic` client dials the published name, not a loopback. The
# `cache create` clause and the trust-bundle clauses are controls: they
# find something in the same script and unit the absences are claimed of.
# `upstreams != []` keeps an empty vhost from passing.
name = "the cache runs on a netns of its own, the gateway reaches it over a socket and the host unit dials its public name";
ok =
let
host = serviceContainersWithCi;
h = host.services.hyperhive;
c = host.containers.swarm-attic;
dir = "/run/swarm-attic";
sock = "${dir}/atticd.sock";
domain = h.swarm.attic.domain;
upstreams = map (l: l.proxyPass) (lib.attrValues h.gateway.vhosts.${domain}.locations);
relay = c.config.systemd.services.swarm-attic-relay;
tokens = host.systemd.services.swarm-bao-attic-tokens;
in
c.privateNetwork
&& c.hostBridge == h.network.bridgeName
&& c.hostAddress == null
&& c.localAddress == null
&& c.config.networking.interfaces.eth0.useDHCP
&& c.config.networking.firewall.enable
&& upstreams != [ ]
&& lib.all (p: p == "http://unix:${sock}") upstreams
&& c.config.systemd.sockets.swarm-attic-relay.listenStreams == [ sock ]
&& lib.hasSuffix "systemd-socket-proxyd 127.0.0.1:${toString h.deploy.attic.port}" relay.serviceConfig.ExecStart
&& c.bindMounts.${dir}.hostPath == dir
&& lib.elem "d ${dir} 0770 root ${toString host.ids.gids.nginx} - -" host.systemd.tmpfiles.rules
&& lib.hasInfix "cache create" tokens.script
&& lib.hasInfix "https://${domain}" tokens.script
&& !(lib.hasInfix "127.0.0.1" tokens.script)
&& lib.elem "nginx.service" tokens.after
&& h.gateway.useSelfSigned
&& tokens.environment.SSL_CERT_FILE == "/run/swarm-bao-attic-tokens-ca/trust-bundle.pem"
&& lib.elem "swarm-bao-attic-tokens-ca-bundle.service" tokens.requires;
}
{ {
# On the bridge by DHCP, with every receiver on a socket: the hive's, the # On the bridge by DHCP, with every receiver on a socket: the hive's, the
# swarm tier's and each service forwarder's. Every gateway location that # swarm tier's and each service forwarder's. Every gateway location that

View file

@ -18,7 +18,7 @@ let
publicKeyPath = "secret/swarm/services/attic/public-key"; publicKeyPath = "secret/swarm/services/attic/public-key";
url = "http://cache.example"; url = "http://cache.example";
cache = "grid"; cache = "grid";
endpoint = "http://127.0.0.1:8910"; endpoint = "https://cache.example";
configFile = "@DIR@/server.toml"; configFile = "@DIR@/server.toml";
envFile = "@DIR@/secrets/server.env"; envFile = "@DIR@/secrets/server.env";
machine = "swarm-attic"; machine = "swarm-attic";

View file

@ -95,8 +95,14 @@ chmod +x "$dir/bin/"*
cat >"$dir/bin/curl" <<STUB cat >"$dir/bin/curl" <<STUB
#!$BASH #!$BASH
echo curl >>"$calls" echo "curl \$*" >>"$calls"
[ "\${CURL_FAIL:-0}" != 1 ] # A curl that cannot connect prints 000 and exits 7; CURL_CODE is the status of
# a server that answered.
if [ "\${CURL_FAIL:-0}" = 1 ]; then
printf 000
exit 7
fi
printf %s "\${CURL_CODE:-200}"
STUB STUB
printf '#!%s\nexit 0\n' "$BASH" >"$dir/bin/sleep" printf '#!%s\nexit 0\n' "$BASH" >"$dir/bin/sleep"
# A container that is not running has nothing to refresh. # A container that is not running has nothing to refresh.
@ -246,6 +252,25 @@ expect "exit status" $? 1
expect "nothing minted" "$(n '^atticadm')" 0 expect "nothing minted" "$(n '^atticadm')" 0
expect "nothing stored" "$(n '^bao put')" 0 expect "nothing stored" "$(n '^bao put')" 0
case="a 502 from the gateway is not atticd answering"
reset
run "$ENSURE_JWT"
: >"$calls"
run "$PROVISION" CURL_CODE=502
expect "exit status" $? 1
expect "nothing minted" "$(n '^atticadm')" 0
expect "nothing stored" "$(n '^bao put')" 0
expect "the probe asked the gateway's name" "$(n '^curl .*https://cache.example')" 30
case="a status from atticd below 500 is an answer, a 404 included"
reset
run "$ENSURE_JWT"
: >"$calls"
run "$PROVISION" CURL_CODE=404
expect "exit status" $? 0
expect "the cache is created" "$(n '^attic cache create')" 1
expect "one probe" "$(n '^curl')" 1
case="the client reads its token from a file, not from an argument" case="the client reads its token from a file, not from an argument"
reset reset
run "$ENSURE_JWT" run "$ENSURE_JWT"