feat(3112): the swarm-nats container, fail-closed
A swarm has one message queue; this is the container that runs it, in the same shape as every other swarm service. Off by default, and off means absent. The auth_callout block is the interim state and the final one at once. Measured on the pinned nats-server rather than reasoned about: an empty authorization block, and one with an empty users list, both accept an anonymous client and answer PONG. auth_callout sets auth_required and refuses everyone until a responder approves them, so a config whose responder does not exist yet denies by default -- and the responder lands beside it without any of this being swapped out. That matters more here than it would elsewhere because the container shares the host netns, so an unauthenticated interim state would be reachable from every agent container on the hive. No package option, deliberately: nixpkgs' nats module resolves pkgs.nats-server itself and exposes none, so one here would name something it cannot control. No gateway vhost either: NATS speaks its own TCP protocol, nginx cannot front it, and cross-hive reach is the wireguard mesh.
This commit is contained in:
parent
10b2862ad2
commit
467cb347ad
2 changed files with 221 additions and 0 deletions
|
|
@ -24,6 +24,7 @@
|
|||
./otel.nix
|
||||
./swarm-authelia.nix
|
||||
./swarm-ca.nix
|
||||
./swarm-nats.nix
|
||||
./swarm-controller.nix
|
||||
./swarm-snapshot-store.nix
|
||||
./swarm-ui.nix
|
||||
|
|
|
|||
220
nix/host-modules/swarm-nats.nix
Normal file
220
nix/host-modules/swarm-nats.nix
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.swarm.nats;
|
||||
autheliaCfg = config.services.hyperhive.swarm.authelia;
|
||||
autheliaUrl = autheliaCfg.url;
|
||||
|
||||
# The account the callout responder authenticates as, and the account
|
||||
# authorized clients are placed in. Two accounts rather than one: an
|
||||
# account is NATS' isolation boundary, so a responder that shares an
|
||||
# account with its clients can be published to by the things it
|
||||
# authorizes.
|
||||
calloutAccount = "AUTH";
|
||||
clientAccount = "APP";
|
||||
calloutUser = "auth";
|
||||
in
|
||||
{
|
||||
# The swarm's message queue: one NATS server, reached by every hive.
|
||||
#
|
||||
# ⚠️ There is deliberately NO gateway vhost here, and this is the first
|
||||
# swarm service where that is true — the next reader will go looking for
|
||||
# one. NATS speaks its own TCP protocol rather than HTTP, so nginx
|
||||
# cannot front it the way it fronts the forge, matrix and authelia.
|
||||
# Cross-hive reach is the wireguard mesh; `gateway.localNames` and the
|
||||
# per-service vhost pattern do not apply.
|
||||
|
||||
options.services.hyperhive.swarm.nats = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Run the swarm's message queue in a `swarm-nats` container on this
|
||||
host. A swarm has one queue, so this belongs on the same host as
|
||||
the rest of the shared services.
|
||||
|
||||
Off by default, and off means *absent*: no container is created
|
||||
and nothing else in the evaluated config changes.
|
||||
'';
|
||||
};
|
||||
|
||||
# ⚠️ Deliberately NO `package` option, unlike this module's siblings.
|
||||
# `services.nats` upstream does not expose one — it resolves
|
||||
# `pkgs.nats-server` itself — so an option here would either be
|
||||
# ignored or need an overlay to mean anything, and an option that
|
||||
# does not control what it names is worse than its absence. Pin the
|
||||
# build with `nixpkgs.overlays` if you need to.
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 4222;
|
||||
description = ''
|
||||
TCP port the queue listens on. 4222 is upstream's default and
|
||||
sits outside hyperhive's claimed ranges (dashboard 7000, forge
|
||||
3000, matrix 8008, every agent in 8100..8999 via FNV-1a hash).
|
||||
'';
|
||||
};
|
||||
|
||||
clientId = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "swarm-nats";
|
||||
description = ''
|
||||
OAuth2 client id the queue's authentication path identifies
|
||||
itself with. Must match the `id` of the corresponding entry in
|
||||
`services.hyperhive.swarm.authelia.oidc.clients` — which this
|
||||
module contributes for you when both run on this host.
|
||||
'';
|
||||
};
|
||||
|
||||
calloutIssuerPublicKey = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = "ACYR44YO3XZRZBJIYLI5SL6LOPIW37JTD52LNOBHUE34XMH7N5ABMFJH";
|
||||
description = ''
|
||||
Public half of the account nkey whose signature the server
|
||||
accepts on a user JWT minted by the auth-callout responder.
|
||||
|
||||
**A public key, and therefore a value rather than a path** —
|
||||
the deliberate exception to the rule that credentials are
|
||||
`*File` options. It is published to every client that connects
|
||||
and its whole job is to be widely known; the matching *seed* is
|
||||
the secret, is never named here, and reaches only the responder.
|
||||
|
||||
Required when `enable` is set. Without it the server has no
|
||||
issuer to trust and no client can be authorized — which is the
|
||||
fail-closed state described below, but arrived at by accident
|
||||
rather than on purpose, so it fails at eval instead.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
# Fail at EVAL, not at boot: a queue that comes up unable to
|
||||
# authenticate anyone presents as every client hanging, which is
|
||||
# several layers from "the operator never set the issuer".
|
||||
assertion = cfg.calloutIssuerPublicKey != "";
|
||||
message = ''
|
||||
services.hyperhive.swarm.nats.enable requires
|
||||
nats.calloutIssuerPublicKey — the public half of the account
|
||||
nkey that signs user JWTs for this queue.
|
||||
|
||||
It is public and belongs in config; the matching seed is a
|
||||
secret and is delivered to the callout responder instead. See
|
||||
docs/swarm/secrets.md for which is which.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = autheliaUrl != null;
|
||||
message = ''
|
||||
services.hyperhive.swarm.nats.enable requires
|
||||
services.hyperhive.swarm.authelia.url — the queue authenticates
|
||||
clients by validating tokens that authelia issued.
|
||||
|
||||
It defaults to this host's own instance only when this host
|
||||
runs authelia. A hive that federates with a swarm sets it
|
||||
explicitly to wherever that provider lives.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# One declaration, two readers. The queue knows which client id it
|
||||
# authenticates under; making the operator restate it in authelia's
|
||||
# client list would be a second source of truth for a string whose
|
||||
# mismatch is an opaque 401 from the token endpoint.
|
||||
#
|
||||
# `client_credentials` rather than an authorization-code flow: a
|
||||
# queue's clients are daemons with nobody to redirect, so a
|
||||
# non-interactive grant is what makes a hive able to authenticate at
|
||||
# all. No redirect URI exists or is wanted.
|
||||
services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf autheliaCfg.enable [
|
||||
{
|
||||
id = cfg.clientId;
|
||||
description = "HyperHive swarm queue";
|
||||
redirectUris = [ ];
|
||||
}
|
||||
];
|
||||
|
||||
containers.swarm-nats = {
|
||||
autoStart = true;
|
||||
ephemeral = false;
|
||||
# Shared host netns, like every sibling container.
|
||||
#
|
||||
# ⚠️ Which is exactly why the server below must refuse everyone
|
||||
# until the callout responder exists: on this netns the queue is
|
||||
# reachable from every agent container on the hive, so an
|
||||
# unauthenticated interim state would be a hole rather than a
|
||||
# rough edge.
|
||||
privateNetwork = false;
|
||||
config =
|
||||
{ ... }:
|
||||
{
|
||||
system.stateVersion = "26.05";
|
||||
|
||||
# Shared host netns: this container's own firewall.service
|
||||
# would rewrite the HOST ruleset at every boot. The host
|
||||
# firewall owns all filtering.
|
||||
networking.firewall.enable = false;
|
||||
# Keep the host-copied /etc/resolv.conf intact — resolvconf's
|
||||
# host-tracking regenerates it empty, since the host's copy
|
||||
# does not cross the boundary after start.
|
||||
networking.resolvconf.enable = lib.mkForce false;
|
||||
|
||||
services.nats = {
|
||||
enable = true;
|
||||
|
||||
serverName = "swarm-nats";
|
||||
port = cfg.port;
|
||||
settings = {
|
||||
# Two accounts, and the callout user lives in neither of
|
||||
# the accounts it authorizes into.
|
||||
accounts = {
|
||||
${calloutAccount}.users = [ { user = calloutUser; } ];
|
||||
${clientAccount} = { };
|
||||
};
|
||||
|
||||
authorization = {
|
||||
timeout = "2s";
|
||||
# 🔒 THIS BLOCK IS THE FAIL-CLOSED STATE, and it is the
|
||||
# measured one rather than the obvious one.
|
||||
#
|
||||
# Measured on the pinned nats-server 2.14.1: both
|
||||
# `authorization { }` and `authorization { users: [] }`
|
||||
# accept an anonymous client and answer PONG — they read
|
||||
# like "authorize nobody" and are wide open. An
|
||||
# auth_callout block sets `auth_required` and refuses
|
||||
# every client whose credential no responder has
|
||||
# approved, so a config whose responder does not exist
|
||||
# yet denies everyone.
|
||||
#
|
||||
# `nats-server -t` calls all three valid; it parses, it
|
||||
# does not authenticate. Only running them tells the
|
||||
# difference.
|
||||
#
|
||||
# ⇒ this is both the safe interim state and the final
|
||||
# shape. Nothing here has to be swapped out when the
|
||||
# responder lands beside it — it only starts being able
|
||||
# to say yes.
|
||||
auth_callout = {
|
||||
issuer = cfg.calloutIssuerPublicKey;
|
||||
auth_users = [ calloutUser ];
|
||||
account = calloutAccount;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# The server binary, so an operator with a shell in here can
|
||||
# run `nats-server -t` against the generated config. The unit
|
||||
# resolves ExecStart through the store path and puts nothing
|
||||
# on PATH.
|
||||
environment.systemPackages = [ pkgs.nats-server ];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue