swarm-matrix-ctl: one control binary for the matrix container, not one per job

Renames `swarm-matrix-minter` and reshapes it around subcommands. Minting
is now `swarm-matrix-ctl mint`.

Running rust inside `containers.hive-matrix` is not free: it needs its own
store identity, its own cert role and its own bind mounts, and every one of
those is per-*container*, not per-task. A second single-purpose crate would
have had to duplicate that plumbing to add one action, so the next thing
that has to run in there should be a verb here rather than a new crate.
The old name guaranteed the opposite.

`main.rs` is clap dispatch; the minting logic moves to `mint.rs` unchanged.
A bare invocation is refused: `mint` writes a credential, so "no verb"
defaulting to it would make a typo in the unit mint rather than fail.

The environment prefix moves with it, `MATRIX_MINTER_*` → `MATRIX_MINT_*`.
Scoped to the verb and not to the binary, because a binary-scoped prefix is
one the next verb has to share or widen, and a widened one never narrows
again. A test asserts every variable carries the verb's prefix.

The principal renames too. The cert role, bao policy, granting unit, leaf
filename and `certAuthCns` entry all have to spell one string the same way,
so leaving them as `swarm-matrix-minter` would have rebuilt the naming
split this branch exists to remove. Renaming the nix options alongside is
free here: every one of them is introduced by this PR and has never been
released, so no operator config names them yet.

`ExecStart` now names the verb, which is a contract between a nix string
and a clap enum that fails at deploy time with no local signal. Both ends
assert it: `mint_is_spelled_the_way_the_unit_invokes_it` in the crate, and
a new module-eval arm reading the rendered `ExecStart`.

docs/getting-started/setup.md drops the sender token from its "live on the
host" list: setup does not touch this credential, so a setup guide has no
reason to name it.
This commit is contained in:
atlas 2026-09-20 14:29:45 +02:00 committed by mara
commit 67ba28448f
23 changed files with 319 additions and 172 deletions

3
Cargo.lock generated
View file

@ -4925,10 +4925,11 @@ dependencies = [
] ]
[[package]] [[package]]
name = "swarm-matrix-minter" name = "swarm-matrix-ctl"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap",
"reqwest", "reqwest",
"serde_json", "serde_json",
"swarm-secret-client", "swarm-secret-client",

View file

@ -26,7 +26,7 @@ members = [
"swarm-authelia-bridge", "swarm-authelia-bridge",
"swarm-authelia-bridge-sock", "swarm-authelia-bridge-sock",
"swarm-controller", "swarm-controller",
"swarm-matrix-minter", "swarm-matrix-ctl",
"swarm-nats-auth", "swarm-nats-auth",
"swarm-queue-client", "swarm-queue-client",
"swarm-logs", "swarm-logs",

View file

@ -293,7 +293,7 @@ See [`tools/hivectl.md`](../tools/hivectl.md) for every `hivectl` verb.
- **No forge admin token is stored in any agent state dir.** Agents - **No forge admin token is stored in any agent state dir.** Agents
hold a regular agent token in their `forge-token` file; sensitive hold a regular agent token in their `forge-token` file; sensitive
creds (the core token, the matrix sender token) live on the host. creds (the core token) live on the host.
- All config changes (forge PRs on `agent-configs/<name>`) go through - All config changes (forge PRs on `agent-configs/<name>`) go through
operator approval — agents can't unilaterally rebuild containers, by design. operator approval — agents can't unilaterally rebuild containers, by design.
See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md). See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md).

View file

@ -157,7 +157,7 @@ homeserver runs no `admin_execute` for it.
Its access token is the **sender token**, and it's the credential Its access token is the **sender token**, and it's the credential
hive-c0re presents for every homeserver call it makes on the hive's hive-c0re presents for every homeserver call it makes on the hive's
behalf. `swarm-matrix-minter` mints it inside the `hive-matrix` behalf. `swarm-matrix-ctl` mints it inside the `hive-matrix`
container and publishes it to `swarm/services/matrix/sender-token`; the container and publishes it to `swarm/services/matrix/sender-token`; the
hive reads it from there. The name says what it authenticates as — an hive reads it from there. The name says what it authenticates as — an
account the appservice registration brings into being — rather than any account the appservice registration brings into being — rather than any

View file

@ -54,15 +54,15 @@ strategy for every credential, including the mTLS leaf.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
| store path | minter | reader — pulls at runtime, holds in memory | renewal | | store path | minter | reader — pulls at runtime, holds in memory | renewal |
| -------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | | -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- |
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | | `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
| `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated | | `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated |
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | | `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | | `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | | `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated |
| `swarm/services/matrix/sender-token` | `swarm-matrix-minter`, in the `hive-matrix` container | `swarm-matrix-minter` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | | `swarm/services/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated |
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | | _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated |
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->

View file

@ -156,9 +156,9 @@
services.hyperhive.deploy.nats.authPackage = services.hyperhive.deploy.nats.authPackage =
lib.mkDefault lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth; self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth;
services.hyperhive.deploy.matrix.minterPackage = services.hyperhive.deploy.matrix.ctlPackage =
lib.mkDefault lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-matrix-minter; self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-matrix-ctl;
services.hyperhive.deploy.authelia.bridgePackage = services.hyperhive.deploy.authelia.bridgePackage =
lib.mkDefault lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge; self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge;

View file

@ -908,12 +908,12 @@ pub async fn sync_agent_standalone(name: &str) {
/// and is non-empty. /// and is non-empty.
/// ///
/// The token is taken from the **swarm secret store** when it is there: /// The token is taken from the **swarm secret store** when it is there:
/// `swarm-matrix-minter`, the oneshot inside the matrix container, publishes /// `swarm-matrix-ctl`, the oneshot inside the matrix container, publishes
/// it under an identity of its own, and taking it from there is what lets a /// it under an identity of its own, and taking it from there is what lets a
/// hive that holds no `as_token` have an admin at all. The mint ladder below /// hive that holds no `as_token` have an admin at all. The mint ladder below
/// stays as the fallback for a store that is empty, unconfigured or /// stays as the fallback for a store that is empty, unconfigured or
/// unreachable — which is every swarm whose matrix container predates that /// unreachable — which is every swarm whose matrix container predates that
/// minter. /// binary.
pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> { pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let path = sender_token_path(); let path = sender_token_path();
@ -996,19 +996,19 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()
Ok(()) Ok(())
} }
/// Fetch the sender token `swarm-matrix-minter` published, under /// Fetch the sender token `swarm-matrix-ctl` published, under
/// this hive's own store identity. /// this hive's own store identity.
/// ///
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` — /// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
/// the same role string `workers::credential` logs in with, and already in /// the same role string `workers::credential` logs in with, and already in
/// this process's environment, so the store read costs no plumbing through /// this process's environment, so the store read costs no plumbing through
/// [`ensure_all`]. No new grant either: a hive's policy already covers the /// [`ensure_all`]. No new grant either: a hive's policy already covers the
/// whole `swarm/services/*` tree the minter writes into. /// whole `swarm/services/*` tree `swarm-matrix-ctl` writes into.
/// ///
/// `None`, never an error, for every way this can come up empty — no hive /// `None`, never an error, for every way this can come up empty — no hive
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All /// name, no `BAO_*` identity, an unreachable store, nothing at the path. All
/// four mean the same thing to the caller ("mint it the old way"), and three /// four mean the same thing to the caller ("mint it the old way"), and three
/// of them are the ordinary state of a swarm that has not deployed the minter /// of them are the ordinary state of a swarm that has not deployed `swarm-matrix-ctl`
/// yet, so raising would turn a supported deployment into a warning every /// yet, so raising would turn a supported deployment into a warning every
/// sweep. /// sweep.
/// ///

View file

@ -27,7 +27,7 @@
./glue-controller-bao-identity.nix ./glue-controller-bao-identity.nix
./glue-grafana-oidc-client.nix ./glue-grafana-oidc-client.nix
./glue-matrix-bao-token.nix ./glue-matrix-bao-token.nix
./glue-matrix-minter-bao-identity.nix ./glue-matrix-ctl-bao-identity.nix
./glue-queue-agent-credential.nix ./glue-queue-agent-credential.nix
./glue-secret-publisher-bao-identity.nix ./glue-secret-publisher-bao-identity.nix
./glue-swarm-otel-oidc-client.nix ./glue-swarm-otel-oidc-client.nix

View file

@ -148,7 +148,7 @@ in
[ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \ [ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \
${lib.escapeShellArg deployCfg.bao.secretPublisherCommonName} "" clientAuth ${lib.escapeShellArg deployCfg.bao.secretPublisherCommonName} "" clientAuth
# The matrix container's minter. Minted unconditionally like the two # The matrix container's `swarm-matrix-ctl`. Minted unconditionally like the two
# above, and for the third variant of the same reason: the homeserver # above, and for the third variant of the same reason: the homeserver
# is a swarm singleton, so on every hive but the one running it this # is a swarm singleton, so on every hive but the one running it this
# leaf is the file an operator copies rather than a file anything # leaf is the file an operator copies rather than a file anything
@ -158,8 +158,8 @@ in
# name and its policy reads the whole store, while this principal may # name and its policy reads the whole store, while this principal may
# only write one path — which is the entire point of giving the # only write one path — which is the entire point of giving the
# container an identity instead of lending it the hive's. # container an identity instead of lending it the hive's.
[ -s ${pkiDir}/matrix-minter.pem ] || ${signLeaf} ${pkiDir} matrix-minter \ [ -s ${pkiDir}/matrix-ctl.pem ] || ${signLeaf} ${pkiDir} matrix-ctl \
${lib.escapeShellArg deployCfg.bao.matrixMinterCommonName} "" clientAuth ${lib.escapeShellArg deployCfg.bao.matrixCtlCommonName} "" clientAuth
''; '';
}; };
}; };

View file

@ -1,12 +1,12 @@
# Glue: point the matrix container's minter at the bao leaf minted for it. # Glue: point the matrix container's `swarm-matrix-ctl` at the bao leaf minted for it.
# #
# ONE PAIRING PER FILE — minter ← bao, and nothing else. Deleting this leaves a # ONE PAIRING PER FILE — matrix-ctl ← bao, and nothing else. Deleting this leaves a
# minter that takes operator-provided certificate paths, which is what any # binary that takes operator-provided certificate paths, which is what any
# deployment not minting its own already does. # deployment not minting its own already does.
# #
# ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the # ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the
# leaf, because the thing that owns a private key owns issuing from it. What # leaf, because the thing that owns a private key owns issuing from it. What
# belongs here is the pairing: which paths the minter presents. # belongs here is the pairing: which paths matrix-ctl presents.
# #
# ⚠️ Gated on the leaf existing, not on the store being enabled — the same rule # ⚠️ Gated on the leaf existing, not on the store being enabled — the same rule
# ./glue-secret-publisher-bao-identity.nix states, and it bites harder here: a # ./glue-secret-publisher-bao-identity.nix states, and it bites harder here: a
@ -33,8 +33,8 @@ in
{ {
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.matrix.enable && haveMintedPki) { config = lib.mkIf (hyperhiveCfg.enable && deployCfg.matrix.enable && haveMintedPki) {
services.hyperhive.deploy.matrix = { services.hyperhive.deploy.matrix = {
minterBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-minter.pem"; ctlBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-ctl.pem";
minterBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-minter-key.pem"; ctlBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-ctl-key.pem";
}; };
}; };
} }

View file

@ -113,7 +113,7 @@ let
appserviceCredentialId = "${appserviceId}-appservice.yaml"; appserviceCredentialId = "${appserviceId}-appservice.yaml";
appserviceCredentialDir = "/run/credentials/tuwunel.service"; appserviceCredentialDir = "/run/credentials/tuwunel.service";
# ── swarm-matrix-minter ──────────────────────────────────────────────── # ── swarm-matrix-ctl ────────────────────────────────────────────────
# #
# The oneshot that publishes the appservice sender account's access token to # The oneshot that publishes the appservice sender account's access token to
# the swarm's secret store. It runs INSIDE the container, beside tuwunel, # the swarm's secret store. It runs INSIDE the container, beside tuwunel,
@ -127,16 +127,16 @@ let
# "co-located with bao" would leave the intended deployment silently minting # "co-located with bao" would leave the intended deployment silently minting
# nothing. Same rule ./swarm-secret-publisher.nix's `haveClientIdentity` # nothing. Same rule ./swarm-secret-publisher.nix's `haveClientIdentity`
# states, for a sharper reason. # states, for a sharper reason.
minterActive = ctlActive =
deployCfg.matrix.minterBaoClientCertFile != null && deployCfg.matrix.minterBaoClientKeyFile != null; deployCfg.matrix.ctlBaoClientCertFile != null && deployCfg.matrix.ctlBaoClientKeyFile != null;
# The role on the store's `cert` auth mount, and so the single source of the # The role on the store's `cert` auth mount, and so the single source of the
# string both ends must agree on: ./swarm-bao.nix writes the role under # string both ends must agree on: ./swarm-bao.nix writes the role under
# `matrixMinterPolicyName` and this hands it to the binary in the # `matrixCtlPolicyName` and this hands it to the binary in the
# environment, so the binary itself spells no role at all. # environment, so the binary itself spells no role at all.
minterCertRole = "swarm-matrix-minter"; ctlCertRole = "swarm-matrix-ctl";
# Every host directory the minter's bao identity is spread across. Normally # Every host directory matrix-ctl's bao identity is spread across. Normally
# one — ./glue-bao-tls.nix puts all three files in ./glue's PKI dir — but # one — ./glue-bao-tls.nix puts all three files in ./glue's PKI dir — but
# derived rather than assumed, because an operator naming paths by hand is # derived rather than assumed, because an operator naming paths by hand is
# exactly the deployment the gate above is written for. # exactly the deployment the gate above is written for.
@ -144,29 +144,29 @@ let
# Directories rather than the files, for the reason `appserviceDir`'s own # Directories rather than the files, for the reason `appserviceDir`'s own
# comment gives: a re-issued leaf is a new inode, and binding the file would # comment gives: a re-issued leaf is a new inode, and binding the file would
# pin the one the container saw when it started. # pin the one the container saw when it started.
minterPkiDirs = lib.optionals minterActive ( ctlPkiDirs = lib.optionals ctlActive (
lib.unique ( lib.unique (
map builtins.dirOf ( map builtins.dirOf (
[ [
deployCfg.matrix.minterBaoClientCertFile deployCfg.matrix.ctlBaoClientCertFile
deployCfg.matrix.minterBaoClientKeyFile deployCfg.matrix.ctlBaoClientKeyFile
] ]
++ lib.optional (deployCfg.bao.serverCaFile != null) deployCfg.bao.serverCaFile ++ lib.optional (deployCfg.bao.serverCaFile != null) deployCfg.bao.serverCaFile
) )
) )
); );
minterBindMounts = lib.genAttrs minterPkiDirs (dir: { ctlBindMounts = lib.genAttrs ctlPkiDirs (dir: {
hostPath = dir; hostPath = dir;
isReadOnly = true; isReadOnly = true;
}); });
# Where a reader of the published credential is told the token is good for. # Where a reader of the published credential is told the token is good for.
# Empty when this hive serves no vhost: `matrix::Credential.homeserver` is an # Empty when this hive serves no vhost: `matrix::Credential.homeserver` is an
# `Option`, and the minter reads an empty variable as absent rather than as # `Option`, and matrix-ctl reads an empty variable as absent rather than as
# the string "null" — which is what a hive with no gateway host actually # the string "null" — which is what a hive with no gateway host actually
# knows about itself. # knows about itself.
minterHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}"; ctlHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}";
# Every local user this hive may provision — agents, `@hive:` itself, and # Every local user this hive may provision — agents, `@hive:` itself, and
# the operator accounts `hivectl matrix create-user` makes, which is the # the operator accounts `hivectl matrix create-user` makes, which is the
@ -748,26 +748,26 @@ in
''; '';
}; };
minterPackage = lib.mkOption { ctlPackage = lib.mkOption {
type = lib.types.package; type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-minter"; defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-ctl";
description = '' description = ''
The `swarm-matrix-minter` build run inside the matrix container. The `swarm-matrix-ctl` build run inside the matrix container.
Named `minterPackage`, not folded into `package` above: that one is Named `ctlPackage`, not folded into `package` above: that one is
the homeserver, and this is a hyperhive binary that happens to run the homeserver, and this is a hyperhive binary that happens to run
beside it. Same split, and same reason, as beside it. Same split, and same reason, as
{option}`services.hyperhive.deploy.nats.authPackage`. {option}`services.hyperhive.deploy.nats.authPackage`.
''; '';
}; };
minterBaoClientCertFile = lib.mkOption { ctlBaoClientCertFile = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = null; default = null;
description = '' description = ''
Client certificate the matrix container's minter presents to the Client certificate the matrix container's `swarm-matrix-ctl` presents to the
swarm's secret store. Its subject must be swarm's secret store. Its subject must be
{option}`services.hyperhive.deploy.bao.matrixMinterCommonName` cert {option}`services.hyperhive.deploy.bao.matrixCtlCommonName` cert
auth matches on the CN, and the role accepts nothing else. auth matches on the CN, and the role accepts nothing else.
**Not the hive's own leaf**, and that is the whole deliverable of **Not the hive's own leaf**, and that is the whole deliverable of
@ -778,7 +778,7 @@ in
No default: a module that guessed would be holding the CA opinion No default: a module that guessed would be holding the CA opinion
./swarm-bao.nix deliberately does not hold. ./swarm-bao.nix deliberately does not hold.
./glue-matrix-minter-bao-identity.nix points it at the leaf ./glue-matrix-ctl-bao-identity.nix points it at the leaf
./glue-bao-tls.nix mints, where this host mints one. ./glue-bao-tls.nix mints, where this host mints one.
The file and its key are bind-mounted into the container read-only. The file and its key are bind-mounted into the container read-only.
@ -788,13 +788,13 @@ in
''; '';
}; };
minterBaoClientKeyFile = lib.mkOption { ctlBaoClientKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = null; default = null;
description = '' description = ''
Private key for Private key for
{option}`services.hyperhive.deploy.matrix.minterBaoClientCertFile`. {option}`services.hyperhive.deploy.matrix.ctlBaoClientCertFile`.
Both or neither the minter unit does not exist unless each is set. Both or neither the unit does not exist unless each is set.
''; '';
}; };
}; };
@ -1194,10 +1194,10 @@ in
}; };
} }
// caTrust.bindMount // caTrust.bindMount
# The minter's bao client identity, and nothing else of the hive's. See # matrix-ctl's bao client identity, and nothing else of the hive's. See
# `minterPkiDirs` above for why it is a derived set of directories # `ctlPkiDirs` above for why it is a derived set of directories
# rather than one literal. # rather than one literal.
// minterBindMounts; // ctlBindMounts;
config = config =
{ ... }: { ... }:
{ {
@ -1394,7 +1394,7 @@ in
# Publish the appservice sender account's access token to the swarm # Publish the appservice sender account's access token to the swarm
# store, once, under an identity that belongs to this container and # store, once, under an identity that belongs to this container and
# not to the hive. See `minterActive` above for why it runs here. # not to the hive. See `ctlActive` above for why it runs here.
# #
# A `oneshot` with no timer and no retry loop of its own: the whole # A `oneshot` with no timer and no retry loop of its own: the whole
# of "and only once" is the binary's first act, a read of the path it # of "and only once" is the binary's first act, a read of the path it
@ -1402,7 +1402,7 @@ in
# a homeserver still starting; `RemainAfterExit` is deliberately NOT # a homeserver still starting; `RemainAfterExit` is deliberately NOT
# set, because the unit having succeeded is not the idempotency # set, because the unit having succeeded is not the idempotency
# record — the store is, and it outlives this machine. # record — the store is, and it outlives this machine.
systemd.services.swarm-matrix-minter = lib.mkIf minterActive { systemd.services.swarm-matrix-ctl = lib.mkIf ctlActive {
description = "publish the matrix sender token to the swarm secret store"; description = "publish the matrix sender token to the swarm secret store";
# Ordered after the homeserver because both of the ladder's arms # Ordered after the homeserver because both of the ladder's arms
# are client-server API calls. `wants`, not `requires`: a run that # are client-server API calls. `wants`, not `requires`: a run that
@ -1414,29 +1414,32 @@ in
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
ExecStart = "${deployCfg.matrix.minterPackage}/bin/swarm-matrix-minter"; # The verb is part of the contract: `swarm-matrix-ctl` is a
# subcommand binary and refuses a bare invocation, so dropping
# `mint` here fails the unit rather than doing something else.
ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl mint";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 30; RestartSec = 30;
# Bounded here rather than left to systemd's default, for the # Bounded here rather than left to systemd's default, for the
# reason ./swarm-secret-publisher.nix states: a sealed store # reason ./swarm-secret-publisher.nix states: a sealed store
# answers on the port and never answers the read. # answers on the port and never answers the read.
TimeoutStartSec = 60; TimeoutStartSec = 60;
SyslogIdentifier = "swarm-matrix-minter"; SyslogIdentifier = "swarm-matrix-ctl";
}; };
environment = { environment = {
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
BAO_CLIENT_CERT = deployCfg.matrix.minterBaoClientCertFile; BAO_CLIENT_CERT = deployCfg.matrix.ctlBaoClientCertFile;
BAO_CLIENT_KEY = deployCfg.matrix.minterBaoClientKeyFile; BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile;
MATRIX_MINTER_CERT_ROLE = minterCertRole; MATRIX_MINT_CERT_ROLE = ctlCertRole;
# Loopback: this container shares the host netns, so the # Loopback: this container shares the host netns, so the
# homeserver it must talk to is the one in this very unit's # homeserver it must talk to is the one in this very unit's
# netns and needs no name, no vhost and no TLS. # netns and needs no name, no vhost and no TLS.
MATRIX_MINTER_API_URL = "http://127.0.0.1:${toString cfg.httpPort}"; MATRIX_MINT_API_URL = "http://127.0.0.1:${toString cfg.httpPort}";
# The bind-mounted registration, which IS the as_token. A path, # The bind-mounted registration, which IS the as_token. A path,
# never a value. # never a value.
MATRIX_MINTER_REGISTRATION = appserviceRegistrationPath; MATRIX_MINT_REGISTRATION = appserviceRegistrationPath;
MATRIX_MINTER_LOCALPART = hiveLocalpart; MATRIX_MINT_LOCALPART = hiveLocalpart;
MATRIX_MINTER_HOMESERVER = minterHomeserverUrl; MATRIX_MINT_HOMESERVER = ctlHomeserverUrl;
} }
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) { // lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {
BAO_CACERT = deployCfg.bao.serverCaFile; BAO_CACERT = deployCfg.bao.serverCaFile;

View file

@ -236,12 +236,12 @@ let
} }
''; '';
# The identity the matrix container's minter presents. Named outside `hive-*` # The identity the matrix container's `swarm-matrix-ctl` presents. Named outside `hive-*`
# for the reason its two siblings above give — the controller may rewrite # for the reason its two siblings above give — the controller may rewrite
# every policy under that prefix, and a policy it can rewrite constrains # every policy under that prefix, and a policy it can rewrite constrains
# nothing. # nothing.
matrixMinterPolicyName = "swarm-matrix-minter"; matrixCtlPolicyName = "swarm-matrix-ctl";
matrixMinterCn = baoDeploy.matrixMinterCommonName; matrixCtlCn = baoDeploy.matrixCtlCommonName;
# ONE path, and every narrowing in it is load-bearing. # ONE path, and every narrowing in it is load-bearing.
# #
@ -254,12 +254,12 @@ let
# spelled to the leaf for that reason, not for tidiness. # spelled to the leaf for that reason, not for tidiness.
# #
# `read` as well as write, unlike either sibling, and it is what makes "and # `read` as well as write, unlike either sibling, and it is what makes "and
# only once" mechanical: the minter's first act is to read this path back and # only once" mechanical: matrix-ctl's first act is to read this path back and
# stop if something is there, so without the capability every container # stop if something is there, so without the capability every container
# restart would mint a second access token and invalidate the hive's. A read # restart would mint a second access token and invalidate the hive's. A read
# here recovers one secret this principal itself wrote, which is a much # here recovers one secret this principal itself wrote, which is a much
# narrower grant than the publisher's would have been. # narrower grant than the publisher's would have been.
matrixMinterPolicyText = '' matrixCtlPolicyText = ''
path "${credentialMountPath}/data/swarm/services/matrix/sender-token" { path "${credentialMountPath}/data/swarm/services/matrix/sender-token" {
capabilities = ["create", "update", "read"] capabilities = ["create", "update", "read"]
} }
@ -688,12 +688,12 @@ in
''; '';
}; };
matrixMinterCommonName = lib.mkOption { matrixCtlCommonName = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "swarm-matrix-minter"; default = "swarm-matrix-ctl";
example = "swarm-matrix-minter.svc"; example = "swarm-matrix-ctl.svc";
description = '' description = ''
Subject the store's matrix-minter cert-auth role accepts the Subject the store's matrix-ctl cert-auth role accepts the
identity the oneshot inside the matrix container presents when it identity the oneshot inside the matrix container presents when it
publishes the appservice sender account's access token. publishes the appservice sender account's access token.
@ -707,7 +707,7 @@ in
Same collision as its siblings, and the same answer: ./swarm.nix Same collision as its siblings, and the same answer: ./swarm.nix
feeds this value into the guard on feeds this value into the guard on
{option}`services.hyperhive.swarm.hives`, so a hive named after it {option}`services.hyperhive.swarm.hives`, so a hive named after it
fails evaluation rather than silently receiving the minter's grant. fails evaluation rather than silently receiving matrix-ctl's grant.
''; '';
}; };
@ -1133,7 +1133,7 @@ in
# ⚠️ Deliberately EMPTY: nothing here generates an issuer into it, so # ⚠️ Deliberately EMPTY: nothing here generates an issuer into it, so
# the role below can be written but cannot yet issue. That is the # the role below can be written but cannot yet issue. That is the
# whole shape of this step — the mount, its narrowing and its grant # whole shape of this step — the mount, its narrowing and its grant
# exist, and the minter that uses them arrives separately. Undoing it # exist, and the binary that uses them arrives separately. Undoing it
# is `bao secrets disable ${servicesPkiMountPath}`. # is `bao secrets disable ${servicesPkiMountPath}`.
case "$mounts" in case "$mounts" in
*'"${servicesPkiMountPath}/"'*) ;; *'"${servicesPkiMountPath}/"'*) ;;
@ -1157,7 +1157,7 @@ in
# is a name type this issuer is unconstrained for. # is a name type this issuer is unconstrained for.
# `server_flag` alone because that is what today's leaf carries # `server_flag` alone because that is what today's leaf carries
# (`extendedKeyUsage=serverAuth`, ./hive-tls.nix:144), and rsa:4096 # (`extendedKeyUsage=serverAuth`, ./hive-tls.nix:144), and rsa:4096
# because that is the key the current minter generates. # because that is the key the current implementation generates.
# #
# An empty `swarm.serviceDomains` renders an empty `allowed_domains`, # An empty `swarm.serviceDomains` renders an empty `allowed_domains`,
# which with `allow_any_name=false` is a role that issues nothing — # which with `allow_any_name=false` is a role that issues nothing —
@ -1281,8 +1281,8 @@ in
# creates the mounts this one writes into, but a failed oneshot still # creates the mounts this one writes into, but a failed oneshot still
# counts as finished, so only ordering plus this unit's own retry # counts as finished, so only ordering plus this unit's own retry
# converges. # converges.
systemd.services.swarm-bao-matrix-minter-policy = lib.mkIf haveBootstrapToken { systemd.services.swarm-bao-matrix-ctl-policy = lib.mkIf haveBootstrapToken {
description = "write the swarm matrix minter's bao policy and cert-auth role"; description = "write swarm-matrix-ctl's bao policy and cert-auth role";
after = [ after = [
"container@${cfg.machine}.service" "container@${cfg.machine}.service"
"swarm-bao-controller-policy.service" "swarm-bao-controller-policy.service"
@ -1309,16 +1309,16 @@ in
BAO_TOKEN="$(cat ${lib.escapeShellArg baoDeploy.bootstrapTokenFile})" BAO_TOKEN="$(cat ${lib.escapeShellArg baoDeploy.bootstrapTokenFile})"
export BAO_TOKEN export BAO_TOKEN
printf '%s' ${lib.escapeShellArg matrixMinterPolicyText} | printf '%s' ${lib.escapeShellArg matrixCtlPolicyText} |
bao policy write ${lib.escapeShellArg matrixMinterPolicyName} - bao policy write ${lib.escapeShellArg matrixCtlPolicyName} -
'' ''
+ lib.optionalString (baoDeploy.clientCaFile != null) '' + lib.optionalString (baoDeploy.clientCaFile != null) ''
bao write auth/cert/certs/${lib.escapeShellArg matrixMinterPolicyName} \ bao write auth/cert/certs/${lib.escapeShellArg matrixCtlPolicyName} \
certificate=@${tlsDir}/client-ca.pem \ certificate=@${tlsDir}/client-ca.pem \
allowed_common_names=${lib.escapeShellArg matrixMinterCn} \ allowed_common_names=${lib.escapeShellArg matrixCtlCn} \
token_policies=${lib.escapeShellArg matrixMinterPolicyName} \ token_policies=${lib.escapeShellArg matrixCtlPolicyName} \
display_name=${lib.escapeShellArg matrixMinterCn} display_name=${lib.escapeShellArg matrixCtlCn}
''; '';
}; };

View file

@ -46,7 +46,7 @@ let
certAuthCns = [ certAuthCns = [
deployCfg.bao.controllerCommonName deployCfg.bao.controllerCommonName
deployCfg.bao.secretPublisherCommonName deployCfg.bao.secretPublisherCommonName
deployCfg.bao.matrixMinterCommonName deployCfg.bao.matrixCtlCommonName
]; ];
# Public hostnames of the swarm's own services, in declaration order. # Public hostnames of the swarm's own services, in declaration order.

View file

@ -152,10 +152,10 @@ let
# `Kind::label`, which renders the singular for error text. The singular # `Kind::label`, which renders the singular for error text. The singular
# spelling evaluates, deploys, and 403s every read with "permission # spelling evaluates, deploys, and 403s every read with "permission
# denied" and nothing else. # denied" and nothing else.
name = "the matrix minter's grant is the sender token's path and nothing else"; name = "matrix-ctl's grant is the sender token's path and nothing else";
ok = ok =
let let
s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script; s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script;
in in
lib.hasInfix "path \"secret/data/swarm/services/matrix/sender-token\" {" s lib.hasInfix "path \"secret/data/swarm/services/matrix/sender-token\" {" s
&& !(lib.hasInfix "secret/data/swarm/services/*" s) && !(lib.hasInfix "secret/data/swarm/services/*" s)
@ -165,30 +165,30 @@ let
} }
{ {
# 🩸 `read` is load-bearing here and is the one capability neither # 🩸 `read` is load-bearing here and is the one capability neither
# sibling has. The minter's first act is to read this path back and stop # sibling has. matrix-ctl's first act is to read this path back and stop
# if something is there — that read IS "and only once", so without the # if something is there — that read IS "and only once", so without the
# capability every container restart would mint a second access token and # capability every container restart would mint a second access token and
# invalidate the hive's. # invalidate the hive's.
name = "the matrix minter may read back the one path it writes"; name = "matrix-ctl may read back the one path it writes";
ok = ok =
let let
s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script; s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script;
in in
lib.hasInfix "capabilities = [\"create\", \"update\", \"read\"]" s lib.hasInfix "capabilities = [\"create\", \"update\", \"read\"]" s
&& lib.hasInfix "auth/cert/certs/swarm-matrix-minter" s && lib.hasInfix "auth/cert/certs/swarm-matrix-ctl" s
&& lib.hasInfix "allowed_common_names=swarm-matrix-minter" s; && lib.hasInfix "allowed_common_names=swarm-matrix-ctl" s;
} }
{ {
# Same two controls its siblings carry: ordered after the unit that makes # Same two controls its siblings carry: ordered after the unit that makes
# the mounts it writes into, and rendered on the HOST rather than inside # the mounts it writes into, and rendered on the HOST rather than inside
# the store's container, where it would have neither an identity nor a # the store's container, where it would have neither an identity nor a
# route to the store. # route to the store.
name = "the minter's granting unit is ordered after the mounts and rendered on the host"; name = "matrix-ctl's granting unit is ordered after the mounts and rendered on the host";
ok = ok =
lib.elem "swarm-bao-controller-policy.service" ( lib.elem "swarm-bao-controller-policy.service" (
baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.after baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.after
) )
&& !(baoGrantHere.containers.swarm-bao.config.systemd.services ? swarm-bao-matrix-minter-policy); && !(baoGrantHere.containers.swarm-bao.config.systemd.services ? swarm-bao-matrix-ctl-policy);
} }
{ {
# The policy authorising this route lives in another file, and nothing # The policy authorising this route lives in another file, and nothing

View file

@ -39,7 +39,7 @@ let
}; };
# A homeserver on a hive with NO store identity at all — neither a local # A homeserver on a hive with NO store identity at all — neither a local
# store nor a hand-placed leaf. The absence arm for the minter cases below # store nor a hand-placed leaf. The absence arm for the matrix-ctl cases below
# needs it, and defining it here rather than importing keeps each group's # needs it, and defining it here rather than importing keeps each group's
# fixture set its own, as ./lib.nix asks. # fixture set its own, as ./lib.nix asks.
matrixNoBaoIdentity = hive { deploy.matrix.enable = true; }; matrixNoBaoIdentity = hive { deploy.matrix.enable = true; };
@ -230,30 +230,29 @@ let
# see from outside: it is consumed by a unit INSIDE a container, so a # see from outside: it is consumed by a unit INSIDE a container, so a
# missing pairing renders as a container that comes up fine and publishes # missing pairing renders as a container that comes up fine and publishes
# nothing. # nothing.
name = "the store mints a leaf for the matrix minter, and the container is pointed at it"; name = "the store mints a leaf for matrix-ctl, and the container is pointed at it";
ok = ok =
let let
m = baoWithMatrix; m = baoWithMatrix;
p = m.services.hyperhive.deploy.matrix; p = m.services.hyperhive.deploy.matrix;
in in
lib.hasInfix "matrix-minter.pem" m.systemd.services.swarm-bao-pki.script lib.hasInfix "matrix-ctl.pem" m.systemd.services.swarm-bao-pki.script
&& p.minterBaoClientCertFile == "/var/lib/swarm-bao-pki/matrix-minter.pem" && p.ctlBaoClientCertFile == "/var/lib/swarm-bao-pki/matrix-ctl.pem"
&& p.minterBaoClientKeyFile == "/var/lib/swarm-bao-pki/matrix-minter-key.pem"; && p.ctlBaoClientKeyFile == "/var/lib/swarm-bao-pki/matrix-ctl-key.pem";
} }
{ {
# 🩸 The identity separation this whole arrangement buys, stated as the # 🩸 The identity separation this whole arrangement buys, stated as the
# one thing that would silently undo it. The container gets the MINTER's # one thing that would silently undo it. The container gets MATRIX-CTL's
# leaf — whose grant is a single path — and not the hive's, which reads # leaf — whose grant is a single path — and not the hive's, which reads
# every secret in the store. Both files exist in the same directory and # every secret in the store. Both files exist in the same directory and
# both would evaluate, deploy and work. # both would evaluate, deploy and work.
name = "the matrix minter presents its own leaf, never the hive's store-wide one"; name = "matrix-ctl presents its own leaf, never the hive's store-wide one";
ok = ok =
let let
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-minter.environment; env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.environment;
hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile; hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile;
in in
env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-minter.pem" env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-ctl.pem" && env.BAO_CLIENT_CERT != hiveLeaf;
&& env.BAO_CLIENT_CERT != hiveLeaf;
} }
{ {
# The bind mount is what makes the environment above resolvable: without # The bind mount is what makes the environment above resolvable: without
@ -264,7 +263,7 @@ let
# The second arm is the shape guard: `bindMounts` is one literal plus two # The second arm is the shape guard: `bindMounts` is one literal plus two
# merges, and a rewrite that dropped the appservice registration would # merges, and a rewrite that dropped the appservice registration would
# take the homeserver's own credential with it. # take the homeserver's own credential with it.
name = "the matrix container binds the minter's PKI read-only, without losing the appservice registration"; name = "the matrix container binds matrix-ctl's PKI read-only, without losing the appservice registration";
ok = ok =
let let
mounts = baoWithMatrix.containers.hive-matrix.bindMounts; mounts = baoWithMatrix.containers.hive-matrix.bindMounts;
@ -278,28 +277,43 @@ let
# the cert role ./host-modules/swarm-bao.nix writes, and a homeserver # the cert role ./host-modules/swarm-bao.nix writes, and a homeserver
# address that is loopback because the container shares the host netns. A # address that is loopback because the container shares the host netns. A
# vhost here would be a request out through the gateway and back. # vhost here would be a request out through the gateway and back.
name = "the matrix minter is handed the store role and the loopback homeserver"; name = "matrix-ctl is handed the store role and the loopback homeserver";
ok = ok =
let let
m = baoWithMatrix; m = baoWithMatrix;
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-minter; u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl;
port = m.services.hyperhive.swarm.matrix.httpPort; port = m.services.hyperhive.swarm.matrix.httpPort;
in in
u.environment.MATRIX_MINTER_CERT_ROLE == "swarm-matrix-minter" u.environment.MATRIX_MINT_CERT_ROLE == "swarm-matrix-ctl"
&& u.environment.MATRIX_MINTER_API_URL == "http://127.0.0.1:${toString port}" && u.environment.MATRIX_MINT_API_URL == "http://127.0.0.1:${toString port}"
&& u.environment.MATRIX_MINTER_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml" && u.environment.MATRIX_MINT_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml"
&& u.serviceConfig.Type == "oneshot"; && u.serviceConfig.Type == "oneshot";
} }
{
# 🩸 The crate is a `*ctl` with subcommands, so the unit has to name a
# VERB. This is the one end of that contract nix owns: the binary's own
# test pins how `mint` is spelled, but only a rendered `ExecStart` can
# say the unit actually passes it. A bare invocation exits non-zero with
# clap's usage — which is a deploy-time failure with no local signal, and
# exactly what the next verb added here is most likely to disturb.
name = "the unit invokes a verb rather than the bare binary";
ok =
let
exec =
baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.serviceConfig.ExecStart;
in
lib.hasSuffix "/bin/swarm-matrix-ctl mint" exec;
}
{ {
# 🩸 A secret is a path, never a value — checked on the one unit in this # 🩸 A secret is a path, never a value — checked on the one unit in this
# tree whose whole job is an `as_token`. Every variable it is given names # tree whose whole job is an `as_token`. Every variable it is given names
# a file or an address; the token itself is read out of the bind-mounted # a file or an address; the token itself is read out of the bind-mounted
# registration at runtime, so nothing here can be a token and an # registration at runtime, so nothing here can be a token and an
# environment block is world-readable through `systemctl show`. # environment block is world-readable through `systemctl show`.
name = "the matrix minter's environment carries paths and addresses, never a token"; name = "matrix-ctl's environment carries paths and addresses, never a token";
ok = ok =
let let
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-minter.environment; env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.environment;
in in
!(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env)); !(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env));
} }
@ -307,12 +321,12 @@ let
# The absence arm, and the deployment it protects: a homeserver on a hive # The absence arm, and the deployment it protects: a homeserver on a hive
# with no store identity at all. Without it the unit would exist naming # with no store identity at all. Without it the unit would exist naming
# `null` as its certificate, which nixos renders as the literal string. # `null` as its certificate, which nixos renders as the literal string.
name = "a matrix container with no store identity runs no minter and binds no PKI"; name = "a matrix container with no store identity runs no matrix-ctl and binds no PKI";
ok = ok =
let let
units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services; units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services;
in in
!(units ? swarm-matrix-minter) !(units ? swarm-matrix-ctl)
&& !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki"); && !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki");
} }
{ {

View file

@ -53,13 +53,13 @@ let
}; };
# The THIRD element of the same list, colliding on its own so neither of the # The THIRD element of the same list, colliding on its own so neither of the
# two above can carry it. The minter's grant is one path rather than a whole # two above can carry it. matrix-ctl's grant is one path rather than a whole
# prefix, which is exactly why a dead entry here would be easy to miss: a # prefix, which is exactly why a dead entry here would be easy to miss: a
# hive that inherited it would not obviously break anything, it would # hive that inherited it would not obviously break anything, it would
# silently gain the ability to overwrite the swarm's matrix credential. # silently gain the ability to overwrite the swarm's matrix credential.
hiveNamedAfterMinterSubject = hive { hiveNamedAfterMatrixCtlSubject = hive {
deploy.swarm-otel.enable = false; deploy.swarm-otel.enable = false;
deploy.bao.matrixMinterCommonName = "mintctl"; deploy.bao.matrixCtlCommonName = "mintctl";
swarm.hives.mintctl.domain = "m.t.local"; swarm.hives.mintctl.domain = "m.t.local";
}; };
@ -104,12 +104,12 @@ let
# element earlier. `certAuthCns` is where a role added beside the others # element earlier. `certAuthCns` is where a role added beside the others
# has to register itself, and nothing but a case per element notices when # has to register itself, and nothing but a case per element notices when
# one forgets. # one forgets.
name = "a hive named after the matrix minter's subject is refused too"; name = "a hive named after matrix-ctl's subject is refused too";
ok = ok =
equalityGuardFired hiveNamedAfterMinterSubject equalityGuardFired hiveNamedAfterMatrixCtlSubject
&& lib.any ( && lib.any (
a: !a.assertion && lib.hasInfix "'mintctl'" a.message a: !a.assertion && lib.hasInfix "'mintctl'" a.message
) hiveNamedAfterMinterSubject.assertions; ) hiveNamedAfterMatrixCtlSubject.assertions;
} }
{ {
# Without this the case above proves nothing: an arm that fires for every # Without this the case above proves nothing: an arm that fires for every

View file

@ -163,12 +163,12 @@ in
# rather than every hive's. # rather than every hive's.
swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder"; swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder";
# The appservice sender account's access-token minter. Out of `daemonBins` for # The matrix container's control binary; `mint` publishes the sender token. Out of `daemonBins` for
# "runs *inside* a container, not on the host" reason as the responder # "runs *inside* a container, not on the host" reason as the responder
# above, and with a second one: putting it in the core bundle would place # above, and with a second one: putting it in the core bundle would place
# the binary that reads the appservice token on every hive's filesystem, # the binary that reads the appservice token on every hive's filesystem,
# which is the arrangement it exists to end. # which is the arrangement it exists to end.
swarm-matrix-minter = mkBinPackage "swarm-matrix-minter" "hyperhive matrix appservice sender-token minter"; swarm-matrix-ctl = mkBinPackage "swarm-matrix-ctl" "hyperhive matrix container control binary";
# The only process allowed to write swarm-authelia's users database — # The only process allowed to write swarm-authelia's users database —
# same "runs *inside* a container, not on the host" placement as # same "runs *inside* a container, not on the host" placement as

View file

@ -1,15 +1,19 @@
[package] [package]
name = "swarm-matrix-minter" name = "swarm-matrix-ctl"
version.workspace = true version.workspace = true
readme = "README.md" readme = "README.md"
edition.workspace = true edition.workspace = true
[[bin]] [[bin]]
name = "swarm-matrix-minter" name = "swarm-matrix-ctl"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
# One verb today (`mint`), and the reason this crate is a `*ctl` rather than a
# single-purpose binary: the next thing that has to run in the matrix container
# is a subcommand here, not a new crate.
clap.workspace = true
reqwest.workspace = true reqwest.workspace = true
serde_json.workspace = true serde_json.workspace = true
# The agreement this binary is one end of: where the credential lives, what the # The agreement this binary is one end of: where the credential lives, what the

View file

@ -1,12 +1,30 @@
# swarm-matrix-minter # swarm-matrix-ctl
A boot-time oneshot that runs **inside `containers.hive-matrix`**, beside the The rust that runs **inside `containers.hive-matrix`**, beside the homeserver.
homeserver, and puts the appservice sender account's access token into the swarm's secret
store under an identity of its own.
## Why it lives in the matrix container One binary with subcommands rather than one binary per job. Running code in that
container is not free: it needs its own store identity, its own cert role and
its own bind mounts, and every one of those is per-_container_, not per-task. A
second single-purpose crate would have had to duplicate that plumbing to add one
action, so the next thing that has to run in here is a **verb**, not a new
crate.
The credential it mints is authorised by the appservice `as_token`, and the ## Verbs
### `mint`
Puts the appservice sender account's access token into the swarm's secret store,
under an identity of its own. A boot-time oneshot.
Configured entirely by the `MATRIX_MINT_*` environment the unit sets — no flags.
A systemd `Environment=` block is what a nix module can render; a command line
full of paths is not. The prefix is scoped to the verb rather than to the binary
so the next verb brings its own, instead of widening a shared one nobody can
then narrow.
## Why this lives in the matrix container
The credential `mint` writes is authorised by the appservice `as_token`, and the
container already holds that: `nix/host-modules/hive-matrix.nix` bind-mounts the container already holds that: `nix/host-modules/hive-matrix.nix` bind-mounts the
rendered appservice registration into it read-only, because that is how tuwunel rendered appservice registration into it read-only, because that is how tuwunel
itself is handed the registration. Minting anywhere else would mean copying the itself is handed the registration. Minting anywhere else would mean copying the
@ -14,13 +32,13 @@ itself is handed the registration. Minting anywhere else would mean copying the
stops being one. stops being one.
It is not the swarm controller for the same reason, plus a structural one: a It is not the swarm controller for the same reason, plus a structural one: a
homeserver has exactly **one** `@hive:` account and a swarm runs one homeserver has exactly **one** appservice registration and so one sender
homeserver, so "mint it once" needs no lock, no lease and no trigger surface — it account, and a swarm runs one homeserver, so "mint it once" needs no lock, no
is a property of the thing being minted. lease and no trigger surface — it is a property of the thing being minted.
## Idempotency ## Idempotency
The **store** is the key, not the homeserver. A run reads The **store** is the key, not the homeserver. A `mint` run reads
`swarm/services/matrix/sender-token` first and returns without touching the `swarm/services/matrix/sender-token` first and returns without touching the
homeserver when something is already there. Only an empty path reaches the mint homeserver when something is already there. Only an empty path reaches the mint
ladder: ladder:

View file

@ -0,0 +1,96 @@
//! `swarm-matrix-ctl` — the rust that runs *inside* `containers.hive-matrix`.
//!
//! One binary with subcommands rather than one binary per job. The container
//! is an awkward place to put code — it needs its own store identity, its own
//! bind mounts and its own cert role — and all of that is per-*container*, not
//! per-task. A second single-purpose crate would have had to duplicate the
//! identity plumbing to add one action, so the next thing that has to run in
//! here is a verb below, not a new crate.
//!
//! Today that is one verb, [`mint`]: publish the appservice sender account's
//! homeserver access token to the swarm's secret store, once.
//!
//! It lives in the container because the appservice `as_token` that authorises
//! the mint is *already* there — the registration tuwunel loads is bind-mounted
//! in — so no second holder of that secret is created.
//!
//! 🩸 **A secret is a path, never a value.** The only identifier any verb here
//! logs is the store path; see `homeserver`'s module doc for the same rule
//! applied to error messages.
mod homeserver;
mod mint;
mod registration;
use anyhow::Result;
use clap::{Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(
name = "swarm-matrix-ctl",
about = "Act on the swarm's matrix homeserver from inside its container"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Publish the appservice sender account's access token to the swarm
/// secret store, once.
///
/// Configured entirely by the `MATRIX_MINT_*` environment the unit sets —
/// no flags, because a systemd `Environment=` block is what a nix module
/// can render and a command line full of paths is not.
Mint,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
match Cli::parse().command {
Command::Mint => mint::run().await,
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn the_clap_tree_is_well_formed() {
Cli::command().debug_assert();
}
/// The unit's `ExecStart` names a verb, so a rename of it is a deploy-time
/// failure with no local signal. This is that signal.
#[test]
fn mint_is_spelled_the_way_the_unit_invokes_it() {
let cli = Cli::try_parse_from(["swarm-matrix-ctl", "mint"]).expect("`mint` is a verb");
assert!(matches!(cli.command, Command::Mint));
}
/// The control: without it the case above passes on a parser that accepts
/// anything.
#[test]
fn an_unknown_verb_is_refused() {
Cli::try_parse_from(["swarm-matrix-ctl", "conjure"])
.expect_err("only declared verbs are accepted");
}
/// A bare invocation must not silently do something. `mint` writes a
/// credential, so "no verb" defaulting to it would make a typo in the unit
/// mint rather than fail.
#[test]
fn no_verb_at_all_is_refused() {
Cli::try_parse_from(["swarm-matrix-ctl"]).expect_err("a verb is required");
}
}

View file

@ -1,25 +1,16 @@
//! Mint the matrix appservice sender account's homeserver access token, once, and publish it to the //! `swarm-matrix-ctl mint` — publish the appservice sender account's
//! swarm's secret store. //! homeserver access token to the swarm's secret store, once.
//! //!
//! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the //! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the
//! swarm controller. It is in the container because the appservice `as_token` //! swarm controller. It is this account rather than an agent's because a
//! that authorises the mint is *already* there — the registration tuwunel loads //! homeserver has **one** appservice registration and so one sender account,
//! is bind-mounted in — so no second holder of that secret is created. It is //! and a swarm runs one homeserver: "only once" is a property of what is being
//! this account rather than an agent's because a homeserver has **one** `@hive:` //! minted, so there is nothing to lock and no trigger to serve.
//! account and a swarm runs one homeserver: "only once" is a property of what
//! is being minted, so there is nothing to lock and no trigger to serve.
//! //!
//! The store, not the homeserver, is the idempotency key — see //! The store, not the homeserver, is the idempotency key — see
//! [`already_published`]. On the hive side `hive-c0re`'s //! [`already_published`]. On the hive side `hive-c0re`'s
//! `matrix::ensure_hive_user` reads exactly the path written here, which is how //! `matrix::ensure_hive_user` reads exactly the path written here, which is how
//! a hive that holds no `as_token` still gets its matrix account. //! a hive that holds no `as_token` still gets its matrix account.
//!
//! 🩸 A secret is a path, never a value. The only identifier this binary logs is
//! the store path; see `homeserver`'s module doc for the same rule applied to
//! error messages.
mod homeserver;
mod registration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use swarm_secret_client::{ use swarm_secret_client::{
@ -28,25 +19,27 @@ use swarm_secret_client::{
matrix, matrix,
}; };
use crate::{homeserver, registration};
/// Role on the store's `cert` auth mount to log in with. Its policy is what /// Role on the store's `cert` auth mount to log in with. Its policy is what
/// allows the write below; the certificate the `BAO_*` variables name has to /// allows the write below; the certificate the `BAO_*` variables name has to
/// carry the CN that role accepts. /// carry the CN that role accepts.
const ENV_CERT_ROLE: &str = "MATRIX_MINTER_CERT_ROLE"; const ENV_CERT_ROLE: &str = "MATRIX_MINT_CERT_ROLE";
/// Client-server API base of the homeserver beside us — loopback, since the /// Client-server API base of the homeserver beside us — loopback, since the
/// container shares the host netns. /// container shares the host netns.
const ENV_API_URL: &str = "MATRIX_MINTER_API_URL"; const ENV_API_URL: &str = "MATRIX_MINT_API_URL";
/// The bind-mounted appservice registration, which is where the `as_token` /// The bind-mounted appservice registration, which is where the `as_token`
/// comes from. A path, never a value. /// comes from. A path, never a value.
const ENV_REGISTRATION: &str = "MATRIX_MINTER_REGISTRATION"; const ENV_REGISTRATION: &str = "MATRIX_MINT_REGISTRATION";
/// Localpart of the hive's account. The appservice registration's own /// Localpart of the appservice's sender account. The registration's own
/// `sender_localpart`, and `hive-c0re`'s `matrix::HIVE_LOCALPART`. /// `sender_localpart`, and `hive-c0re`'s `matrix::HIVE_LOCALPART`.
const ENV_LOCALPART: &str = "MATRIX_MINTER_LOCALPART"; const ENV_LOCALPART: &str = "MATRIX_MINT_LOCALPART";
/// Public base URL of the homeserver, stored beside the token so a reader can /// Public base URL of the homeserver, stored beside the token so a reader can
/// reconstruct where it is good for. Optional: a swarm with no gateway vhost /// reconstruct where it is good for. Optional: a swarm with no gateway vhost
/// has no such URL, and `matrix::Credential` types the field to say so. /// has no such URL, and `matrix::Credential` types the field to say so.
const ENV_HOMESERVER: &str = "MATRIX_MINTER_HOMESERVER"; const ENV_HOMESERVER: &str = "MATRIX_MINT_HOMESERVER";
/// Everything the unit tells this process, checked before anything is opened. /// Everything the unit tells this verb, checked before anything is opened.
/// ///
/// Separate from the work for the reason `swarm_secret_client::client::Settings` /// Separate from the work for the reason `swarm_secret_client::client::Settings`
/// is: every arm is a misconfiguration an operator reads an error about, and /// is: every arm is a misconfiguration an operator reads an error about, and
@ -61,7 +54,7 @@ struct Config {
} }
impl Config { impl Config {
/// Read the `MATRIX_MINTER_*` variables from the process environment. /// Read the `MATRIX_MINT_*` variables from the process environment.
/// ///
/// # Errors /// # Errors
/// Naming the first variable that is unset or empty. /// Naming the first variable that is unset or empty.
@ -113,15 +106,13 @@ async fn already_published(store: &SecretStore, path: &str) -> bool {
} }
} }
#[tokio::main] /// Run the verb.
async fn main() -> Result<()> { ///
tracing_subscriber::fmt() /// # Errors
.with_env_filter( /// If the environment is incomplete, the store refuses the login or the write,
tracing_subscriber::EnvFilter::try_from_default_env() /// the registration cannot be read, or the homeserver refuses both the
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), /// registration and the appservice login.
) pub async fn run() -> Result<()> {
.init();
let config = Config::from_env()?; let config = Config::from_env()?;
// Explicitly, rather than through `SecretStore::from_env`: a missing or // Explicitly, rather than through `SecretStore::from_env`: a missing or
// misspelled `BAO_*` variable is the most likely thing to be wrong with a // misspelled `BAO_*` variable is the most likely thing to be wrong with a
@ -152,7 +143,7 @@ async fn main() -> Result<()> {
// The expected arm, not an edge case: this account is the // The expected arm, not an edge case: this account is the
// appservice's own `sender_localpart`, so the homeserver creates it // appservice's own `sender_localpart`, so the homeserver creates it
// when it loads the registration — before anything gets to ask. // when it loads the registration — before anything gets to ask.
tracing::info!("the @hive: account exists; logging in as the appservice instead"); tracing::info!("the sender account exists; logging in as the appservice instead");
homeserver::appservice_login(&http, &config.api_url, &config.localpart, &as_token) homeserver::appservice_login(&http, &config.api_url, &config.localpart, &as_token)
.await? .await?
} }
@ -179,7 +170,7 @@ mod tests {
/// A lookup standing in for a fully-configured unit's environment. /// A lookup standing in for a fully-configured unit's environment.
fn full(k: &str) -> Option<String> { fn full(k: &str) -> Option<String> {
match k { match k {
ENV_CERT_ROLE => Some("swarm-matrix-minter".to_owned()), ENV_CERT_ROLE => Some("swarm-matrix-ctl".to_owned()),
ENV_API_URL => Some("http://127.0.0.1:8008".to_owned()), ENV_API_URL => Some("http://127.0.0.1:8008".to_owned()),
ENV_REGISTRATION => { ENV_REGISTRATION => {
Some("/var/lib/hyperhive/matrix-appservice/hyperhive.yaml".to_owned()) Some("/var/lib/hyperhive/matrix-appservice/hyperhive.yaml".to_owned())
@ -235,6 +226,26 @@ mod tests {
assert_eq!(c.homeserver, None); assert_eq!(c.homeserver, None);
} }
/// The environment prefix is a contract with the nix unit, and the crate
/// rename that produced it moved every one of these. A verb-scoped prefix
/// is the point: the next verb brings its own, instead of widening a
/// binary-scoped one nobody can then narrow.
#[test]
fn every_variable_is_scoped_to_the_verb() {
for var in [
ENV_CERT_ROLE,
ENV_API_URL,
ENV_REGISTRATION,
ENV_LOCALPART,
ENV_HOMESERVER,
] {
assert!(
var.starts_with("MATRIX_MINT_"),
"{var} is not scoped to the mint verb"
);
}
}
#[test] #[test]
fn the_published_path_is_the_one_the_hive_reads() { fn the_published_path_is_the_one_the_hive_reads() {
// Both ends of this slice's loop resolve the same function, so there is // Both ends of this slice's loop resolve the same function, so there is