Watch
0
0
Fork
You've already forked hyperhive
0

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

@ -0,0 +1,40 @@
# Glue: point the matrix container's `swarm-matrix-ctl` at the bao leaf minted for it.
#
# ONE PAIRING PER FILE — matrix-ctl ← bao, and nothing else. Deleting this leaves a
# binary that takes operator-provided certificate paths, which is what any
# deployment not minting its own already does.
#
# ⚠️ 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
# belongs here is the pairing: which paths matrix-ctl presents.
#
# ⚠️ 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
# swarm runs ONE homeserver, so the hive hosting it is the one least likely to
# also be the hive hosting the store.
#
# Everything is `mkDefault`. An operator naming their own paths wins.
{
lib,
config,
...
}:
let
hyperhiveCfg = config.services.hyperhive;
deployCfg = hyperhiveCfg.deploy;
baoDeploy = deployCfg.bao;
# Where ./glue-bao-tls.nix puts the leaves, derived from the reader's own path
# rather than repeating that file's directory literal: an operator who moves
# the PKI moves both, and the two cannot drift apart.
haveMintedPki = baoDeploy.clientCertFile != null;
pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null;
in
{
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.matrix.enable && haveMintedPki) {
services.hyperhive.deploy.matrix = {
ctlBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-ctl.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

@ -0,0 +1,27 @@
[package]
name = "swarm-matrix-ctl"
version.workspace = true
readme = "README.md"
edition.workspace = true
[[bin]]
name = "swarm-matrix-ctl"
path = "src/main.rs"
[dependencies]
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
serde_json.workspace = true
# The agreement this binary is one end of: where the credential lives, what the
# object at that path holds, and the `BAO_*` spellings the unit sets.
swarm-secret-client.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
[lints]
workspace = true

View file

@ -0,0 +1,62 @@
# swarm-matrix-ctl
The rust that runs **inside `containers.hive-matrix`**, beside the homeserver.
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.
## 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
rendered appservice registration into it read-only, because that is how tuwunel
itself is handed the registration. Minting anywhere else would mean copying the
`as_token` to a second holder — and the point of this component is that the hive
stops being one.
It is not the swarm controller for the same reason, plus a structural one: a
homeserver has exactly **one** appservice registration and so one sender
account, and a swarm runs one homeserver, so "mint it once" needs no lock, no
lease and no trigger surface — it is a property of the thing being minted.
## Idempotency
The **store** is the key, not the homeserver. A `mint` run reads
`swarm/services/matrix/sender-token` first and returns without touching the
homeserver when something is already there. Only an empty path reaches the mint
ladder:
1. `POST /_matrix/client/v3/register` with `"type": "m.login.application_service"`
— one round trip, no UIAA.
2. `M_USER_IN_USE` (the expected arm on a homeserver that has already loaded the
registration, since the account is the appservice's own `sender_localpart`) →
`POST /_matrix/client/v3/login` as the appservice, same pinned `device_id`, so
the old device is replaced rather than duplicated.
3. Write the result to the store.
A crash between the homeserver call and the store write is recoverable: the next
run takes arm 2.
## 🩸 A secret is a path, never a value
Nothing here logs, prints or interpolates a token. The mint ladder's errors are
built from the homeserver's _status_ and its `errcode`, never its body, because a
`/login` response body is an access token. The one identifier this binary logs is
the store path it wrote.

View file

@ -0,0 +1,286 @@
//! The two calls the mint ladder is made of, against the homeserver next door.
//!
//! 🩸 **Every error in this module is built from the response's `status` and
//! its `errcode`, never its body.** A successful `/register` or `/login` body
//! *is* an access token, and an error body is one malformed response away from
//! being the same bytes — so a `body: {json}` in a message here would put the
//! sender token in the journal.
use anyhow::{Context, Result, bail};
/// Client-server API calls are one round trip each against a homeserver in the
/// same netns; a slow one is a broken one.
const TIMEOUT_SECS: u64 = 10;
/// Bytes of the throwaway password `/register` is given.
///
/// Protocol overhead, and stored nowhere: this account authenticates by access
/// token, and the recovery path when that token is lost is the appservice login
/// below rather than anything a password could open.
const PASSWORD_BYTES: usize = 32;
/// What the homeserver said about a registration attempt.
///
/// An enum rather than a string match on the error text: `M_USER_IN_USE` is the
/// *expected* answer here — the `@hive:` account is the appservice registration's
/// own `sender_localpart`, so the homeserver creates it at startup, before
/// anything gets to ask — and an expected answer should not have to be
/// recovered from a formatted message.
pub enum Registered {
/// A fresh account, and the access token minted with it.
Token(String),
/// The account is already there; it has to be logged into instead.
AlreadyExists,
}
/// An HTTP client with this module's timeout.
///
/// # Errors
/// When the TLS backend will not initialise, which is the only way building a
/// client fails.
pub fn client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(TIMEOUT_SECS))
.build()
.context("building the HTTP client for the homeserver")
}
/// Create `localpart`'s account as the appservice and return its access token.
///
/// One round trip: an appservice-typed registration needs no UIAA stage, so
/// there is no session to carry and no shared registration secret in the
/// picture. The `device_id` is pinned so that a later [`appservice_login`]
/// replaces this device rather than accumulating one per run.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses with anything other than `M_USER_IN_USE` — which is
/// [`Registered::AlreadyExists`] rather than an error.
pub async fn register(
client: &reqwest::Client,
base: &str,
localpart: &str,
as_token: &str,
) -> Result<Registered> {
let body = serde_json::json!({
// What makes this an appservice registration rather than an ordinary
// one: without it the homeserver asks for a UIAA flow even though the
// request carries the as_token.
"type": "m.login.application_service",
"username": localpart,
"password": random_password()?,
"device_id": device_id(localpart),
"initial_device_display_name": format!("hyperhive ({localpart})"),
"inhibit_login": false,
});
let (status, json) = post(
client,
&format!("{base}/_matrix/client/v3/register?kind=user"),
as_token,
&body,
)
.await
.context("POST /register as the appservice")?;
if status.is_success() {
return Ok(Registered::Token(access_token(&json)?));
}
if errcode(&json) == Some("M_USER_IN_USE") {
return Ok(Registered::AlreadyExists);
}
bail!(
"the homeserver refused to register @{localpart}: {}",
why(status, &json)
);
}
/// Log in as an **existing** account using the appservice's authority, and
/// return a fresh access token for it.
///
/// No password: the appservice is authorised for every localpart in its
/// namespace, so it mints a session for one without knowing anything about the
/// account — which is just as well, since an account the homeserver created for
/// its own registration has none.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses.
pub async fn appservice_login(
client: &reqwest::Client,
base: &str,
localpart: &str,
as_token: &str,
) -> Result<String> {
let body = serde_json::json!({
"type": "m.login.application_service",
"identifier": {
"type": "m.id.user",
"user": localpart,
},
// Matching `register`'s, so a re-login REPLACES that device's token
// rather than leaving a second live device behind.
"device_id": device_id(localpart),
"initial_device_display_name": format!("hyperhive ({localpart})"),
});
let (status, json) = post(
client,
&format!("{base}/_matrix/client/v3/login"),
as_token,
&body,
)
.await
.context("POST /login as the appservice")?;
if !status.is_success() {
bail!(
"the homeserver refused to log in @{localpart}: {}",
why(status, &json)
);
}
access_token(&json)
}
/// The device every token this binary mints is pinned to.
fn device_id(localpart: &str) -> String {
format!("hyperhive-{localpart}")
}
/// One authenticated JSON POST, returning the status beside the decoded body.
async fn post(
client: &reqwest::Client,
url: &str,
as_token: &str,
body: &serde_json::Value,
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
let resp = client
.post(url)
.bearer_auth(as_token)
.json(body)
.send()
.await
.context("sending the request")?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
.await
.context("decoding the response as JSON")?;
Ok((status, json))
}
/// Pull `access_token` out of a successful response.
fn access_token(json: &serde_json::Value) -> Result<String> {
json["access_token"]
.as_str()
.map(str::to_owned)
// Not `{json}`: on the success path this object holds the credential,
// and a response missing the field is exactly when a reflex to print it
// would fire.
.context("the homeserver's response carried no `access_token`")
}
/// The matrix error code, when the body is a standard error object.
fn errcode(json: &serde_json::Value) -> Option<&str> {
json["errcode"].as_str()
}
/// Everything about a refusal that is safe to put in a message.
///
/// The `errcode` is a closed vocabulary from the spec and the status is a
/// number; between them they say which of the ladder's arms was taken. The
/// `error` string beside them is free-form homeserver text, so it stays out.
fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String {
match errcode(json) {
Some(code) => format!("HTTP {status}, errcode {code}"),
None => format!("HTTP {status}, no errcode"),
}
}
/// A throwaway password for [`register`], as hex.
///
/// From `/dev/urandom` directly rather than through an RNG crate: this is the
/// one random value the binary needs, and the kernel is already the source any
/// such crate would reach for here.
///
/// # Errors
/// When `/dev/urandom` cannot be read.
fn random_password() -> Result<String> {
use std::io::Read as _;
let mut buf = [0u8; PASSWORD_BYTES];
std::fs::File::open("/dev/urandom")
.context("opening /dev/urandom")?
.read_exact(&mut buf)
.context("reading from /dev/urandom")?;
Ok(buf.iter().fold(String::new(), |mut acc, b| {
use std::fmt::Write as _;
// Infallible: `write!` into a `String` only fails if the formatter
// does, and `{:02x}` of a `u8` has nothing to fail at.
let _ = write!(acc, "{b:02x}");
acc
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_calls_pin_the_same_device_so_a_relogin_replaces_it() {
// The whole reason the recovery arm is safe to take repeatedly: an
// unpinned login mints a NEW device each time, and a homeserver
// accumulating devices for this account is one where revoking the credential
// means finding all of them.
assert_eq!(device_id("hive"), "hyperhive-hive");
}
#[test]
fn a_refusal_is_described_by_its_errcode_and_never_by_its_body() {
// 🩸 The invariant this module exists to keep. `error` is free-form
// homeserver text and `access_token` is the credential itself; a
// message built from the body would carry whichever of them the
// response happened to hold.
let json = serde_json::json!({
"errcode": "M_FORBIDDEN",
"error": "some free-form text",
"access_token": "syt_the_actual_secret",
});
let message = why(reqwest::StatusCode::FORBIDDEN, &json);
assert!(message.contains("M_FORBIDDEN"), "{message}");
assert!(!message.contains("syt_the_actual_secret"), "{message}");
assert!(!message.contains("free-form"), "{message}");
}
#[test]
fn a_refusal_with_no_errcode_still_produces_a_message() {
// A homeserver behind a proxy answers with HTML, not a matrix error
// object. The status is then the only thing there is to say, and
// saying it is better than an empty report.
let message = why(reqwest::StatusCode::BAD_GATEWAY, &serde_json::json!({}));
assert!(message.contains("502"), "{message}");
}
#[test]
fn the_expected_already_exists_answer_is_recognised_by_its_errcode() {
// Matched on the spec's code rather than on message text, because this
// is the arm a healthy homeserver takes every time: the `@hive:` account
// is the appservice registration's own sender, created at startup.
let json = serde_json::json!({ "errcode": "M_USER_IN_USE", "error": "User ID taken" });
assert_eq!(errcode(&json), Some("M_USER_IN_USE"));
}
#[test]
fn a_response_without_a_token_is_an_error_that_does_not_quote_it() {
let e = access_token(&serde_json::json!({ "user_id": "@hive:t.local" }))
.expect_err("no access_token in this object");
assert!(!format!("{e}").contains("@hive:t.local"), "{e}");
}
#[test]
fn a_minted_password_is_hex_of_the_declared_length() {
// The control on the hex fold: a short or non-hex password would be
// accepted by the homeserver and only surface much later, if at all.
let pw = random_password().expect("/dev/urandom is readable");
assert_eq!(pw.len(), PASSWORD_BYTES * 2);
assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex");
}
}

Some files were not shown because too many files have changed in this diff Show more