Each container now runs the journal forwarder the queue's container runs, pushing to a receiver of its own on the swarm collector as the service's existing OIDC client, with the client's secret read inside the container by the fetch unit. Their store identities are system credentials, so the fetch unit loads them too. The three containers drop --link-journal=host, which hid their journals from the forwarder, and the host collector stops reading them.
257 lines
10 KiB
Nix
257 lines
10 KiB
Nix
# The claude login minter, in a `swarm-claude-login` container on the host
|
|
# that sets `deploy.claude-login.enable`, which `deploy.allSwarmServices`
|
|
# defaults on where the host has the minter's store identity. An operator logs
|
|
# an agent in from its card in the swarm UI: swarm-controller relays the start
|
|
# and the pasted code over the queue, and the daemon runs `claude auth login`
|
|
# and writes the credentials to that agent's store leaf.
|
|
# docs/swarm/claude-login.md has the design.
|
|
#
|
|
# Its own network namespace on the bridge, as ./swarm-review-classifier.nix
|
|
# has: it reaches the swarm services by name and nothing on the host's
|
|
# loopback.
|
|
#
|
|
# The container holds one credential, its own store leaf, which nspawn hands in
|
|
# as a system credential with no copy on disk. The daemon reads its queue
|
|
# client secret with it and writes agents' credentials under it.
|
|
{
|
|
pkgs,
|
|
lib,
|
|
config,
|
|
...
|
|
}:
|
|
let
|
|
hyperhiveCfg = config.services.hyperhive;
|
|
|
|
# The `claude-code` the agents run, so this container does not trail them on
|
|
# the CLI; `fallback`, the container's own nixpkgs', only where the hive pins
|
|
# none.
|
|
claudeCode =
|
|
fallback:
|
|
if hyperhiveCfg.c0re.claudeCodePackage != null then
|
|
hyperhiveCfg.c0re.claudeCodePackage
|
|
else
|
|
fallback;
|
|
cfg = hyperhiveCfg.deploy.claude-login;
|
|
baoCfg = hyperhiveCfg.swarm.bao;
|
|
baoDeploy = hyperhiveCfg.deploy.bao;
|
|
natsCfg = hyperhiveCfg.swarm.nats;
|
|
autheliaCfg = hyperhiveCfg.swarm.authelia;
|
|
gatewayCfg = hyperhiveCfg.gateway;
|
|
networkCfg = hyperhiveCfg.network;
|
|
tlsCfg = hyperhiveCfg.deploy.hive-controller.tls;
|
|
|
|
machine = "swarm-claude-login";
|
|
unit = "swarm-claude-login";
|
|
clientId = hyperhiveCfg.swarm.claude-login.clientId;
|
|
|
|
# The container's journal forwarder pushes to its own receiver on the swarm
|
|
# collector, the route `serviceProducers.swarm-claude-login` in
|
|
# ./swarm-otel-service.nix declares, by the collector's gateway name. The
|
|
# port suffix is elided on 443, as in ./swarm-bao.nix.
|
|
swarmOtelCfg = hyperhiveCfg.swarm.otel;
|
|
gatewayPortSuffix = lib.optionalString (
|
|
gatewayCfg.httpsPort != 443
|
|
) ":${toString gatewayCfg.httpsPort}";
|
|
forwarderEndpoint = "https://${swarmOtelCfg.domain}${gatewayPortSuffix}/${machine}";
|
|
forwarderUnit = "swarm-journal-forwarder-oidc";
|
|
# Loopback in the container's own netns, and distinct from the other service
|
|
# containers' forwarders all the same.
|
|
forwarderTelemetryPort = 8893;
|
|
|
|
# The daemon reaches authelia, the store and the queue by name over TLS, and
|
|
# in self-signed mode their leaves chain to the hive CA.
|
|
caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
|
|
caBundleModule = caTrust.trustBundle {
|
|
inherit pkgs;
|
|
name = machine;
|
|
consumers = [
|
|
unit
|
|
# Go: `SSL_CERT_FILE` replaces its trust store, for the swarm
|
|
# collector's and authelia's names.
|
|
"opentelemetry-collector"
|
|
];
|
|
};
|
|
|
|
haveClientIdentity =
|
|
baoDeploy.claudeLoginClientCertFile != null && baoDeploy.claudeLoginClientKeyFile != null;
|
|
|
|
# Credential id → host source, as ./swarm-review-classifier.nix hands its
|
|
# leaf in.
|
|
storeFiles = lib.filterAttrs (_: source: source != null) {
|
|
claude-login-bao-cert = baoDeploy.claudeLoginClientCertFile;
|
|
claude-login-bao-key = baoDeploy.claudeLoginClientKeyFile;
|
|
claude-login-bao-server-ca = baoDeploy.serverCaFile;
|
|
};
|
|
# nixos-container expands `extraFlags` unquoted, so a source path must be
|
|
# one word.
|
|
oneWord = path: builtins.match ".*[[:space:]].*" path == null;
|
|
credential = id: "/run/credentials/${unit}.service/${id}";
|
|
forwarderCredential = id: "/run/credentials/${forwarderUnit}.service/${id}";
|
|
|
|
privateNetwork = true;
|
|
in
|
|
{
|
|
options.services.hyperhive.swarm.claude-login.clientId = lib.mkOption {
|
|
type = lib.types.str;
|
|
readOnly = true;
|
|
default = "swarm-claude-login";
|
|
description = ''
|
|
The OAuth2 client id the claude login minter presents to the swarm
|
|
queue. Read-only: the host running authelia registers it, the queue's
|
|
auth-callout responder grants it the login subject, and the secret
|
|
publisher carries its secret to the store, all from this one value.
|
|
'';
|
|
};
|
|
|
|
options.services.hyperhive.deploy.claude-login.package = lib.mkOption {
|
|
type = lib.types.package;
|
|
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-claude-login";
|
|
description = "The claude login minter daemon package.";
|
|
};
|
|
|
|
config = lib.mkIf cfg.enable {
|
|
assertions = [
|
|
{
|
|
assertion = haveClientIdentity;
|
|
message = ''
|
|
services.hyperhive.deploy.claude-login.enable requires the minter's
|
|
swarm-secret-store client identity: set both
|
|
|
|
services.hyperhive.deploy.bao.claudeLoginClientCertFile
|
|
services.hyperhive.deploy.bao.claudeLoginClientKeyFile
|
|
|
|
The minter reads its queue client secret and writes agents' claude
|
|
credentials with it.
|
|
|
|
Both default to the pair glue-bao-tls.nix mints when
|
|
services.hyperhive.deploy.bao.enable is true (the host that runs the
|
|
store). Otherwise copy claude-login.pem and claude-login-key.pem from
|
|
the store host's /var/lib/swarm-bao-pki/ and name them in those two
|
|
options.
|
|
'';
|
|
}
|
|
{
|
|
assertion = lib.all oneWord (lib.attrValues storeFiles);
|
|
message = ''
|
|
The claude login minter's store identity paths must not contain
|
|
whitespace: nspawn's --load-credential receives them through
|
|
nixos-container's unquoted extra flags.
|
|
'';
|
|
}
|
|
];
|
|
|
|
# The container hangs off the bridge, takes its address from the gateway's
|
|
# DHCP and resolves through its dnsmasq.
|
|
services.hyperhive.network.enable = lib.mkDefault true;
|
|
services.hyperhive.gateway.enable = lib.mkDefault true;
|
|
services.hyperhive.gateway.dns.enable = lib.mkDefault true;
|
|
|
|
# nspawn fails to start the container when a credential source is
|
|
# missing, so where this host mints the leaf the container waits for it.
|
|
systemd.services."container@${machine}" = lib.mkMerge [
|
|
caTrust.containerOrdering
|
|
{ after = [ "swarm-bao-pki.service" ]; }
|
|
];
|
|
|
|
containers.${machine} = {
|
|
autoStart = true;
|
|
# The daemon keeps no state; each login's scratch directory is under
|
|
# PrivateTmp.
|
|
ephemeral = false;
|
|
# No `--link-journal=host`: the journal stays in the guest, where the
|
|
# container's own forwarder reads it (../container-modules/swarm-journal-forwarder.nix).
|
|
extraFlags = lib.mapAttrsToList (id: source: "--load-credential=${id}:${source}") storeFiles;
|
|
inherit privateNetwork;
|
|
hostBridge = networkCfg.bridgeName;
|
|
bindMounts = caTrust.bindMount;
|
|
|
|
config =
|
|
{ pkgs, lib, ... }:
|
|
{
|
|
imports = [
|
|
../container-modules/swarm-container.nix
|
|
../container-modules/swarm-journal-forwarder.nix
|
|
caBundleModule
|
|
];
|
|
|
|
services.hyperhive.swarmContainer = {
|
|
inherit privateNetwork;
|
|
writesOwnResolvConf = false;
|
|
};
|
|
networking.nameservers = [ networkCfg.bridgeIp ];
|
|
networking.interfaces.eth0.useDHCP = true;
|
|
|
|
# `claude-code` is unfree, and this container evaluates its own
|
|
# nixpkgs, so the host's allowance does not reach here.
|
|
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "claude-code" ];
|
|
|
|
users.users.${unit} = {
|
|
isSystemUser = true;
|
|
group = unit;
|
|
};
|
|
users.groups.${unit} = { };
|
|
|
|
# Authenticated as the daemon's own OIDC client, whose secret its
|
|
# store identity already reads: one identity for the whole container.
|
|
# That identity is a system credential, so the fetch unit loads it
|
|
# too; the daemon's `LoadCredential` below is that unit's alone.
|
|
services.hyperhive.swarmJournalForwarder = {
|
|
enable = true;
|
|
serviceName = "claude-login";
|
|
endpoint = forwarderEndpoint;
|
|
inherit clientId;
|
|
tokenUrl = "${autheliaCfg.url}/api/oidc/token";
|
|
telemetryPort = forwarderTelemetryPort;
|
|
store = {
|
|
package = baoDeploy.package;
|
|
addr = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
|
secretPath = "secret/swarm/services/${clientId}/oidc/client";
|
|
clientCertFile = forwarderCredential "claude-login-bao-cert";
|
|
clientKeyFile = forwarderCredential "claude-login-bao-key";
|
|
caFile =
|
|
if storeFiles ? claude-login-bao-server-ca then
|
|
forwarderCredential "claude-login-bao-server-ca"
|
|
else
|
|
null;
|
|
};
|
|
};
|
|
systemd.services.${forwarderUnit}.serviceConfig.LoadCredential = lib.attrNames storeFiles;
|
|
|
|
systemd.services.${unit} = {
|
|
description = "log agents in to claude for the swarm UI";
|
|
wantedBy = [ "multi-user.target" ];
|
|
wants = [ "network-online.target" ];
|
|
after = [ "network-online.target" ];
|
|
environment = {
|
|
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
|
BAO_CLIENT_CERT = credential "claude-login-bao-cert";
|
|
BAO_CLIENT_KEY = credential "claude-login-bao-key";
|
|
SWARM_CLAUDE_LOGIN_NATS_URL = "tls://${natsCfg.domain}:${toString natsCfg.port}";
|
|
SWARM_CLAUDE_LOGIN_OIDC_TOKEN_ENDPOINT = "${autheliaCfg.url}/api/oidc/token";
|
|
SWARM_CLAUDE_LOGIN_OIDC_CLIENT_ID = clientId;
|
|
SWARM_CLAUDE_LOGIN_CLAUDE = "${claudeCode pkgs.claude-code}/bin/claude";
|
|
}
|
|
// lib.optionalAttrs (storeFiles ? claude-login-bao-server-ca) {
|
|
BAO_CACERT = credential "claude-login-bao-server-ca";
|
|
}
|
|
// lib.optionalAttrs caTrust.useSelfSigned {
|
|
SWARM_CLAUDE_LOGIN_OIDC_CA_FILE = caTrust.bundlePathFor machine;
|
|
};
|
|
serviceConfig = {
|
|
ExecStart = "${cfg.package}/bin/swarm-claude-login";
|
|
User = unit;
|
|
Group = unit;
|
|
# Each login's scratch directory, gone with the unit.
|
|
PrivateTmp = true;
|
|
UMask = "0077";
|
|
# Bare ids: the system credentials nspawn handed in.
|
|
LoadCredential = lib.attrNames storeFiles;
|
|
Restart = "on-failure";
|
|
RestartSec = "10s";
|
|
SyslogIdentifier = unit;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|