diff --git a/Cargo.lock b/Cargo.lock index feee92c3..d0371900 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4925,10 +4925,11 @@ dependencies = [ ] [[package]] -name = "swarm-matrix-minter" +name = "swarm-matrix-ctl" version = "0.1.0" dependencies = [ "anyhow", + "clap", "reqwest", "serde_json", "swarm-secret-client", diff --git a/Cargo.toml b/Cargo.toml index 693a7dee..a4baf59e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ members = [ "swarm-authelia-bridge", "swarm-authelia-bridge-sock", "swarm-controller", - "swarm-matrix-minter", + "swarm-matrix-ctl", "swarm-nats-auth", "swarm-queue-client", "swarm-logs", diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index cc74ce4b..ea23be35 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -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 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/`) go through operator approval — agents can't unilaterally rebuild containers, by design. See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md). diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index 88db2269..8e0abbdc 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -157,7 +157,7 @@ homeserver runs no `admin_execute` for it. 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 -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 hive reads it from there. The name says what it authenticates as — an account the appservice registration brings into being — rather than any diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 4cf13026..965fd2d7 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -54,15 +54,15 @@ strategy for every credential, including the mTLS leaf. -| store path | minter | reader — pulls at runtime, holds in memory | renewal | -| -------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | -| `swarm/agents//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//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//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | -| `swarm/services//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 | -| _(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 | +| store path | minter | reader — pulls at runtime, holds in memory | renewal | +| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- | +| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | +| `swarm/agents//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//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//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | +| `swarm/services//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-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 | diff --git a/flake.nix b/flake.nix index ec910de8..3171895d 100644 --- a/flake.nix +++ b/flake.nix @@ -156,9 +156,9 @@ services.hyperhive.deploy.nats.authPackage = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth; - services.hyperhive.deploy.matrix.minterPackage = + services.hyperhive.deploy.matrix.ctlPackage = 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 = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge; diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 4ba2aa59..a93a2e6e 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -908,12 +908,12 @@ pub async fn sync_agent_standalone(name: &str) { /// and is non-empty. /// /// 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 /// 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 /// 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<()> { use std::os::unix::fs::PermissionsExt; let path = sender_token_path(); @@ -996,19 +996,19 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<() 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. /// /// 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 /// 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 -/// 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 /// 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 -/// 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 /// sweep. /// diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 14f62bf1..b4f1e7e8 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -27,7 +27,7 @@ ./glue-controller-bao-identity.nix ./glue-grafana-oidc-client.nix ./glue-matrix-bao-token.nix - ./glue-matrix-minter-bao-identity.nix + ./glue-matrix-ctl-bao-identity.nix ./glue-queue-agent-credential.nix ./glue-secret-publisher-bao-identity.nix ./glue-swarm-otel-oidc-client.nix diff --git a/nix/host-modules/glue-bao-tls.nix b/nix/host-modules/glue-bao-tls.nix index 88eb78b7..819569bf 100644 --- a/nix/host-modules/glue-bao-tls.nix +++ b/nix/host-modules/glue-bao-tls.nix @@ -148,7 +148,7 @@ in [ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \ ${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 # 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 @@ -158,8 +158,8 @@ in # name and its policy reads the whole store, while this principal may # only write one path — which is the entire point of giving the # container an identity instead of lending it the hive's. - [ -s ${pkiDir}/matrix-minter.pem ] || ${signLeaf} ${pkiDir} matrix-minter \ - ${lib.escapeShellArg deployCfg.bao.matrixMinterCommonName} "" clientAuth + [ -s ${pkiDir}/matrix-ctl.pem ] || ${signLeaf} ${pkiDir} matrix-ctl \ + ${lib.escapeShellArg deployCfg.bao.matrixCtlCommonName} "" clientAuth ''; }; }; diff --git a/nix/host-modules/glue-matrix-minter-bao-identity.nix b/nix/host-modules/glue-matrix-ctl-bao-identity.nix similarity index 73% rename from nix/host-modules/glue-matrix-minter-bao-identity.nix rename to nix/host-modules/glue-matrix-ctl-bao-identity.nix index 2274b4cc..0d725ad4 100644 --- a/nix/host-modules/glue-matrix-minter-bao-identity.nix +++ b/nix/host-modules/glue-matrix-ctl-bao-identity.nix @@ -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 -# minter that takes operator-provided certificate paths, which is what any +# 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 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 # ./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) { services.hyperhive.deploy.matrix = { - minterBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-minter.pem"; - minterBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-minter-key.pem"; + ctlBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-ctl.pem"; + ctlBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-ctl-key.pem"; }; }; } diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 20713724..b2635eea 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -113,7 +113,7 @@ let appserviceCredentialId = "${appserviceId}-appservice.yaml"; appserviceCredentialDir = "/run/credentials/tuwunel.service"; - # ── swarm-matrix-minter ──────────────────────────────────────────────── + # ── swarm-matrix-ctl ──────────────────────────────────────────────── # # The oneshot that publishes the appservice sender account's access token to # 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 # nothing. Same rule ./swarm-secret-publisher.nix's `haveClientIdentity` # states, for a sharper reason. - minterActive = - deployCfg.matrix.minterBaoClientCertFile != null && deployCfg.matrix.minterBaoClientKeyFile != null; + ctlActive = + deployCfg.matrix.ctlBaoClientCertFile != null && deployCfg.matrix.ctlBaoClientKeyFile != null; # 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 - # `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. - 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 # derived rather than assumed, because an operator naming paths by hand is # 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 # comment gives: a re-issued leaf is a new inode, and binding the file would # pin the one the container saw when it started. - minterPkiDirs = lib.optionals minterActive ( + ctlPkiDirs = lib.optionals ctlActive ( lib.unique ( map builtins.dirOf ( [ - deployCfg.matrix.minterBaoClientCertFile - deployCfg.matrix.minterBaoClientKeyFile + deployCfg.matrix.ctlBaoClientCertFile + deployCfg.matrix.ctlBaoClientKeyFile ] ++ lib.optional (deployCfg.bao.serverCaFile != null) deployCfg.bao.serverCaFile ) ) ); - minterBindMounts = lib.genAttrs minterPkiDirs (dir: { + ctlBindMounts = lib.genAttrs ctlPkiDirs (dir: { hostPath = dir; isReadOnly = true; }); # 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 - # `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 # 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 # 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; - defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-minter"; + defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-ctl"; 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 beside it. Same split, and same reason, as {option}`services.hyperhive.deploy.nats.authPackage`. ''; }; - minterBaoClientCertFile = lib.mkOption { + ctlBaoClientCertFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; 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 - {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. ⚠️ **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 ./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. 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; default = null; description = '' Private key for - {option}`services.hyperhive.deploy.matrix.minterBaoClientCertFile`. - Both or neither — the minter unit does not exist unless each is set. + {option}`services.hyperhive.deploy.matrix.ctlBaoClientCertFile`. + Both or neither — the unit does not exist unless each is set. ''; }; }; @@ -1194,10 +1194,10 @@ in }; } // caTrust.bindMount - # The minter's bao client identity, and nothing else of the hive's. See - # `minterPkiDirs` above for why it is a derived set of directories + # matrix-ctl's bao client identity, and nothing else of the hive's. See + # `ctlPkiDirs` above for why it is a derived set of directories # rather than one literal. - // minterBindMounts; + // ctlBindMounts; config = { ... }: { @@ -1394,7 +1394,7 @@ in # Publish the appservice sender account's access token to the swarm # 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 # 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 # set, because the unit having succeeded is not the idempotency # 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"; # Ordered after the homeserver because both of the ladder's arms # are client-server API calls. `wants`, not `requires`: a run that @@ -1414,29 +1414,32 @@ in wantedBy = [ "multi-user.target" ]; serviceConfig = { 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"; RestartSec = 30; # Bounded here rather than left to systemd's default, for the # reason ./swarm-secret-publisher.nix states: a sealed store # answers on the port and never answers the read. TimeoutStartSec = 60; - SyslogIdentifier = "swarm-matrix-minter"; + SyslogIdentifier = "swarm-matrix-ctl"; }; environment = { BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; - BAO_CLIENT_CERT = deployCfg.matrix.minterBaoClientCertFile; - BAO_CLIENT_KEY = deployCfg.matrix.minterBaoClientKeyFile; - MATRIX_MINTER_CERT_ROLE = minterCertRole; + BAO_CLIENT_CERT = deployCfg.matrix.ctlBaoClientCertFile; + BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile; + MATRIX_MINT_CERT_ROLE = ctlCertRole; # Loopback: this container shares the host netns, so the # homeserver it must talk to is the one in this very unit's # 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, # never a value. - MATRIX_MINTER_REGISTRATION = appserviceRegistrationPath; - MATRIX_MINTER_LOCALPART = hiveLocalpart; - MATRIX_MINTER_HOMESERVER = minterHomeserverUrl; + MATRIX_MINT_REGISTRATION = appserviceRegistrationPath; + MATRIX_MINT_LOCALPART = hiveLocalpart; + MATRIX_MINT_HOMESERVER = ctlHomeserverUrl; } // lib.optionalAttrs (deployCfg.bao.serverCaFile != null) { BAO_CACERT = deployCfg.bao.serverCaFile; diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 342f6ead..d2b0e62c 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -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 # every policy under that prefix, and a policy it can rewrite constrains # nothing. - matrixMinterPolicyName = "swarm-matrix-minter"; - matrixMinterCn = baoDeploy.matrixMinterCommonName; + matrixCtlPolicyName = "swarm-matrix-ctl"; + matrixCtlCn = baoDeploy.matrixCtlCommonName; # 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. # # `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 # 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 # narrower grant than the publisher's would have been. - matrixMinterPolicyText = '' + matrixCtlPolicyText = '' path "${credentialMountPath}/data/swarm/services/matrix/sender-token" { capabilities = ["create", "update", "read"] } @@ -688,12 +688,12 @@ in ''; }; - matrixMinterCommonName = lib.mkOption { + matrixCtlCommonName = lib.mkOption { type = lib.types.str; - default = "swarm-matrix-minter"; - example = "swarm-matrix-minter.svc"; + default = "swarm-matrix-ctl"; + example = "swarm-matrix-ctl.svc"; 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 publishes the appservice sender account's access token. @@ -707,7 +707,7 @@ in ⚠️ Same collision as its siblings, and the same answer: ./swarm.nix feeds this value into the guard on {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 # 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 - # 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}`. case "$mounts" in *'"${servicesPkiMountPath}/"'*) ;; @@ -1157,7 +1157,7 @@ in # is a name type this issuer is unconstrained for. # `server_flag` alone because that is what today's leaf carries # (`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`, # 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 # counts as finished, so only ordering plus this unit's own retry # converges. - systemd.services.swarm-bao-matrix-minter-policy = lib.mkIf haveBootstrapToken { - description = "write the swarm matrix minter's bao policy and cert-auth role"; + systemd.services.swarm-bao-matrix-ctl-policy = lib.mkIf haveBootstrapToken { + description = "write swarm-matrix-ctl's bao policy and cert-auth role"; after = [ "container@${cfg.machine}.service" "swarm-bao-controller-policy.service" @@ -1309,16 +1309,16 @@ in BAO_TOKEN="$(cat ${lib.escapeShellArg baoDeploy.bootstrapTokenFile})" export BAO_TOKEN - printf '%s' ${lib.escapeShellArg matrixMinterPolicyText} | - bao policy write ${lib.escapeShellArg matrixMinterPolicyName} - + printf '%s' ${lib.escapeShellArg matrixCtlPolicyText} | + bao policy write ${lib.escapeShellArg matrixCtlPolicyName} - '' + 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 \ - allowed_common_names=${lib.escapeShellArg matrixMinterCn} \ - token_policies=${lib.escapeShellArg matrixMinterPolicyName} \ - display_name=${lib.escapeShellArg matrixMinterCn} + allowed_common_names=${lib.escapeShellArg matrixCtlCn} \ + token_policies=${lib.escapeShellArg matrixCtlPolicyName} \ + display_name=${lib.escapeShellArg matrixCtlCn} ''; }; diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 118bd261..fd1fe9c8 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -46,7 +46,7 @@ let certAuthCns = [ deployCfg.bao.controllerCommonName deployCfg.bao.secretPublisherCommonName - deployCfg.bao.matrixMinterCommonName + deployCfg.bao.matrixCtlCommonName ]; # Public hostnames of the swarm's own services, in declaration order. diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index 4c65a603..c6abcc45 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -152,10 +152,10 @@ let # `Kind::label`, which renders the singular for error text. The singular # spelling evaluates, deploys, and 403s every read with "permission # 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 = let - s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script; + s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script; in lib.hasInfix "path \"secret/data/swarm/services/matrix/sender-token\" {" s && !(lib.hasInfix "secret/data/swarm/services/*" s) @@ -165,30 +165,30 @@ let } { # 🩸 `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 # capability every container restart would mint a second access token and # 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 = let - s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script; + s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script; in lib.hasInfix "capabilities = [\"create\", \"update\", \"read\"]" s - && lib.hasInfix "auth/cert/certs/swarm-matrix-minter" s - && lib.hasInfix "allowed_common_names=swarm-matrix-minter" s; + && lib.hasInfix "auth/cert/certs/swarm-matrix-ctl" s + && lib.hasInfix "allowed_common_names=swarm-matrix-ctl" s; } { # 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 store's container, where it would have neither an identity nor a # 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 = 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 diff --git a/nix/module-eval/bao-matrix-reader.nix b/nix/module-eval/bao-matrix-reader.nix index eb815761..bf16a643 100644 --- a/nix/module-eval/bao-matrix-reader.nix +++ b/nix/module-eval/bao-matrix-reader.nix @@ -39,7 +39,7 @@ let }; # 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 # fixture set its own, as ./lib.nix asks. 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 # missing pairing renders as a container that comes up fine and publishes # 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 = let m = baoWithMatrix; p = m.services.hyperhive.deploy.matrix; in - lib.hasInfix "matrix-minter.pem" m.systemd.services.swarm-bao-pki.script - && p.minterBaoClientCertFile == "/var/lib/swarm-bao-pki/matrix-minter.pem" - && p.minterBaoClientKeyFile == "/var/lib/swarm-bao-pki/matrix-minter-key.pem"; + lib.hasInfix "matrix-ctl.pem" m.systemd.services.swarm-bao-pki.script + && p.ctlBaoClientCertFile == "/var/lib/swarm-bao-pki/matrix-ctl.pem" + && p.ctlBaoClientKeyFile == "/var/lib/swarm-bao-pki/matrix-ctl-key.pem"; } { # 🩸 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 # every secret in the store. Both files exist in the same directory and # 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 = 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; in - env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-minter.pem" - && env.BAO_CLIENT_CERT != hiveLeaf; + env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-ctl.pem" && env.BAO_CLIENT_CERT != hiveLeaf; } { # 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 # merges, and a rewrite that dropped the appservice registration would # 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 = let mounts = baoWithMatrix.containers.hive-matrix.bindMounts; @@ -278,28 +277,43 @@ let # the cert role ./host-modules/swarm-bao.nix writes, and a homeserver # address that is loopback because the container shares the host netns. A # 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 = let 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; in - u.environment.MATRIX_MINTER_CERT_ROLE == "swarm-matrix-minter" - && u.environment.MATRIX_MINTER_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_CERT_ROLE == "swarm-matrix-ctl" + && u.environment.MATRIX_MINT_API_URL == "http://127.0.0.1:${toString port}" + && u.environment.MATRIX_MINT_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml" && 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 # 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 # registration at runtime, so nothing here can be a token and an # 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 = 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 !(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 # with no store identity at all. Without it the unit would exist naming # `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 = let units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services; in - !(units ? swarm-matrix-minter) + !(units ? swarm-matrix-ctl) && !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki"); } { diff --git a/nix/module-eval/name-guards.nix b/nix/module-eval/name-guards.nix index 40775c1b..8716f980 100644 --- a/nix/module-eval/name-guards.nix +++ b/nix/module-eval/name-guards.nix @@ -53,13 +53,13 @@ let }; # 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 # hive that inherited it would not obviously break anything, it would # silently gain the ability to overwrite the swarm's matrix credential. - hiveNamedAfterMinterSubject = hive { + hiveNamedAfterMatrixCtlSubject = hive { deploy.swarm-otel.enable = false; - deploy.bao.matrixMinterCommonName = "mintctl"; + deploy.bao.matrixCtlCommonName = "mintctl"; swarm.hives.mintctl.domain = "m.t.local"; }; @@ -104,12 +104,12 @@ let # element earlier. `certAuthCns` is where a role added beside the others # has to register itself, and nothing but a case per element notices when # 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 = - equalityGuardFired hiveNamedAfterMinterSubject + equalityGuardFired hiveNamedAfterMatrixCtlSubject && lib.any ( 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 diff --git a/nix/packages/default.nix b/nix/packages/default.nix index fd6d1c67..bd7198ba 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -163,12 +163,12 @@ in # rather than every hive's. 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 # 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, # 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 — # same "runs *inside* a container, not on the host" placement as diff --git a/swarm-matrix-minter/Cargo.toml b/swarm-matrix-ctl/Cargo.toml similarity index 64% rename from swarm-matrix-minter/Cargo.toml rename to swarm-matrix-ctl/Cargo.toml index 8d56e641..3d308778 100644 --- a/swarm-matrix-minter/Cargo.toml +++ b/swarm-matrix-ctl/Cargo.toml @@ -1,15 +1,19 @@ [package] -name = "swarm-matrix-minter" +name = "swarm-matrix-ctl" version.workspace = true readme = "README.md" edition.workspace = true [[bin]] -name = "swarm-matrix-minter" +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 diff --git a/swarm-matrix-minter/README.md b/swarm-matrix-ctl/README.md similarity index 51% rename from swarm-matrix-minter/README.md rename to swarm-matrix-ctl/README.md index 525fb51e..65008f53 100644 --- a/swarm-matrix-minter/README.md +++ b/swarm-matrix-ctl/README.md @@ -1,12 +1,30 @@ -# swarm-matrix-minter +# swarm-matrix-ctl -A boot-time oneshot that runs **inside `containers.hive-matrix`**, beside the -homeserver, and puts the appservice sender account's access token into the swarm's secret -store under an identity of its own. +The rust that runs **inside `containers.hive-matrix`**, beside the homeserver. -## 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 rendered appservice registration into it read-only, because that is how tuwunel 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. 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, so "mint it once" needs no lock, no lease and no trigger surface — it -is a property of the thing being minted. +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 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 homeserver when something is already there. Only an empty path reaches the mint ladder: diff --git a/swarm-matrix-minter/src/homeserver.rs b/swarm-matrix-ctl/src/homeserver.rs similarity index 100% rename from swarm-matrix-minter/src/homeserver.rs rename to swarm-matrix-ctl/src/homeserver.rs diff --git a/swarm-matrix-ctl/src/main.rs b/swarm-matrix-ctl/src/main.rs new file mode 100644 index 00000000..0008e919 --- /dev/null +++ b/swarm-matrix-ctl/src/main.rs @@ -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"); + } +} diff --git a/swarm-matrix-minter/src/main.rs b/swarm-matrix-ctl/src/mint.rs similarity index 80% rename from swarm-matrix-minter/src/main.rs rename to swarm-matrix-ctl/src/mint.rs index 9b14068e..20cffeab 100644 --- a/swarm-matrix-minter/src/main.rs +++ b/swarm-matrix-ctl/src/mint.rs @@ -1,25 +1,16 @@ -//! Mint the matrix appservice sender account's homeserver access token, once, and publish it to the -//! swarm's secret store. +//! `swarm-matrix-ctl mint` — publish the appservice sender account's +//! homeserver access token to the swarm's secret store, once. //! //! 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` -//! that authorises the mint is *already* there — the registration tuwunel loads -//! is bind-mounted in — so no second holder of that secret is created. It is -//! this account rather than an agent's because a homeserver has **one** `@hive:` -//! 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. +//! swarm controller. It is this account rather than an agent's because a +//! homeserver has **one** appservice registration and so one sender 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 //! [`already_published`]. On the hive side `hive-c0re`'s //! `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 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 swarm_secret_client::{ @@ -28,25 +19,27 @@ use swarm_secret_client::{ matrix, }; +use crate::{homeserver, registration}; + /// 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 /// 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 /// 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` /// comes from. A path, never a value. -const ENV_REGISTRATION: &str = "MATRIX_MINTER_REGISTRATION"; -/// Localpart of the hive's account. The appservice registration's own +const ENV_REGISTRATION: &str = "MATRIX_MINT_REGISTRATION"; +/// Localpart of the appservice's sender account. The registration's own /// `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 /// 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. -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` /// is: every arm is a misconfiguration an operator reads an error about, and @@ -61,7 +54,7 @@ struct Config { } impl Config { - /// Read the `MATRIX_MINTER_*` variables from the process environment. + /// Read the `MATRIX_MINT_*` variables from the process environment. /// /// # Errors /// Naming the first variable that is unset or empty. @@ -113,15 +106,13 @@ async fn already_published(store: &SecretStore, path: &str) -> bool { } } -#[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(); - +/// Run the verb. +/// +/// # Errors +/// If the environment is incomplete, the store refuses the login or the write, +/// the registration cannot be read, or the homeserver refuses both the +/// registration and the appservice login. +pub async fn run() -> Result<()> { let config = Config::from_env()?; // Explicitly, rather than through `SecretStore::from_env`: a missing or // 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 // appservice's own `sender_localpart`, so the homeserver creates it // 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) .await? } @@ -179,7 +170,7 @@ mod tests { /// A lookup standing in for a fully-configured unit's environment. fn full(k: &str) -> Option { 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_REGISTRATION => { Some("/var/lib/hyperhive/matrix-appservice/hyperhive.yaml".to_owned()) @@ -235,6 +226,26 @@ mod tests { 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] fn the_published_path_is_the_one_the_hive_reads() { // Both ends of this slice's loop resolve the same function, so there is diff --git a/swarm-matrix-minter/src/registration.rs b/swarm-matrix-ctl/src/registration.rs similarity index 100% rename from swarm-matrix-minter/src/registration.rs rename to swarm-matrix-ctl/src/registration.rs