From 0d88ca5e7fa9031ce46b7e74fc2a4512b81f2e70 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 8 Sep 2026 13:52:53 +0200 Subject: [PATCH] swarm-controller: accept an agent's external matrix account and put it in the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The swarm UI had nowhere to POST an external matrix account to: this daemon had no matrix-account code at all and no `swarm-secret-client` dependency, so the last leg of #3726 — a credential reaching an agent — had no entry point. `PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` writes the credential to the store under the agent's own path and publishes a `CredentialNotice` on that hive's credential subject. All three path names are load-bearing: agent + account locate the secret, hive routes the notice. The account is a path segment rather than a body field so that splitting the 1:1 account-to-agent mapping later is a new route, not a changed payload. Store first, notify second, and the order cannot be swapped: a notice that overtakes its own write reaches a hive that reads nothing, and the hive deliberately does not retry. The publish is followed by a flush for the reason `publish_deploy` flushes — `publish` hands the message to the connection's write buffer and returns, so the response could otherwise outrun the notice it reports as sent. The store client is built per request rather than held in `AppState`, matching what the hive side does inside `deliver`: a login that expires is not worth caching for a route this cold. `swarm_hive` is `declaration_target`'s two name checks, extracted so this handler makes them identically rather than in a second copy free to drift. `declaration_target` still tests the writer first, so a deployment with no queue answers 503 whatever the caller spelled. ## The nix half #4081 minted the controller's leaf and gave it `baoClientCertFile` / `baoClientKeyFile`, deliberately stopping there — the leaf is minted whether or not a controller runs on that host. Nothing consumed those options, so the identity never reached the process. Measured before writing: `git grep baoClientCertFile` returned 5 sites and zero consumers, against a control (`tokenEndpoint`, 4 hits in the same file) proving the search can see consumption where it exists. The unit now gets `BAO_ADDR` / `BAO_CLIENT_CERT` / `BAO_CLIENT_KEY` / `BAO_CACERT` and the matching `LoadCredential` entries, following `hive-c0re/environment.nix`'s `%d` credential shape. The gate is `deploy.swarm-controller.baoClientCertFile`, NOT `deploy.bao.clientCertFile`. The latter is the hive reader's identity and its policy scopes a hive's own secrets; wiring it here would evaluate, deploy, and fail only when the daemon tried to write an agent's credential. Two `module-eval` arms cover exactly that. The presence arm asserts the `LoadCredential` *source path* (`…:/var/lib/swarm-bao-pki/controller.pem`) and not just the `%d` name, because a `%d`-only assertion passes while the daemon holds the wrong policy. The absence arm (`controllerNoStore`) is what makes the presence arm mean anything. `RestrictAddressFamilies` already covers the store client; its own comment asks for the family to be added with the client, and AF_INET/AF_INET6 are present. Contributes to #3726 --- Cargo.lock | 1 + nix/host-modules/swarm-controller.nix | 44 +++++++- nix/module-eval.nix | 36 +++++++ swarm-controller/Cargo.toml | 5 + swarm-controller/src/main.rs | 36 ++++--- swarm-controller/src/matrix_account.rs | 144 +++++++++++++++++++++++++ 6 files changed, 252 insertions(+), 14 deletions(-) create mode 100644 swarm-controller/src/matrix_account.rs diff --git a/Cargo.lock b/Cargo.lock index f5a37f9e..bd738290 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4701,6 +4701,7 @@ dependencies = [ "sha2 0.11.0", "swarm-authelia-bridge-sock", "swarm-queue-client", + "swarm-secret-client", "tokio", "tracing", "tracing-subscriber", diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index a88b483e..509cb7fd 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -18,6 +18,37 @@ let deployCfg = config.services.hyperhive.deploy; autheliaCfg = config.services.hyperhive.swarm.authelia; + # Where the secret store is, and whether this host holds the controller's + # own leaf for it. ⚠️ The controller's pair, NOT `deploy.bao.clientCertFile` + # — that one is the hive reader's, and its policy scopes a hive's own + # secrets. The two are deliberately separate identities. + # + # Gated on the leaf, never on `deploy.bao.enable`: a controller three + # networks away from the store holds a leaf issued out of band and wants + # exactly this wiring. Same rule ./glue-controller-bao-identity.nix states + # for the paths themselves. + baoCfg = config.services.hyperhive.swarm.bao; + haveBaoIdentity = + deployCfg.swarm-controller.baoClientCertFile != null + && deployCfg.swarm-controller.baoClientKeyFile != null; + + # `swarm_secret_client` reads these spellings explicitly rather than + # vaultrs's `VAULT_*` defaults — falling through to those builds a client + # with no identity and fails at the TLS handshake, naming neither. `%d` and + # not the paths themselves: see the `LoadCredential` below. + baoEnv = + lib.optionalAttrs haveBaoIdentity { + BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; + BAO_CLIENT_CERT = "%d/bao-client.pem"; + BAO_CLIENT_KEY = "%d/bao-client-key.pem"; + } + // lib.optionalAttrs (haveBaoIdentity && deployCfg.bao.serverCaFile != null) { + # Absent means the system trust store — right for a real CA, wrong for + # the self-signed one ./glue-bao-tls.nix mints, which is why that file + # names this path rather than leaving it to a default. + BAO_CACERT = "%d/bao-ca.pem"; + }; + # What `swarmctl` needs in order to act on authelia from the host. # # Only set when authelia actually runs **here**: the controller can be @@ -645,7 +676,17 @@ in ] ++ lib.optional ( deployCfg.swarm-controller.forgeTokenFile != null - ) "forge-token:${deployCfg.swarm-controller.forgeTokenFile}"; + ) "forge-token:${deployCfg.swarm-controller.forgeTokenFile}" + # The store identity, same shape and same reason as hive-c0re's: the + # key is root-owned `0600` and this daemon runs as `swarm-controller`, + # so it never gets read access to the original file. + ++ lib.optionals haveBaoIdentity [ + "bao-client.pem:${deployCfg.swarm-controller.baoClientCertFile}" + "bao-client-key.pem:${deployCfg.swarm-controller.baoClientKeyFile}" + ] + ++ lib.optional ( + haveBaoIdentity && deployCfg.bao.serverCaFile != null + ) "bao-ca.pem:${deployCfg.bao.serverCaFile}"; # The placeholder default that makes the above non-fatal. # `LoadCredential=` takes priority over `SetCredential=`, so this is @@ -770,6 +811,7 @@ in // webhookEnv // authBridgeEnv // swarmNameEnv + // baoEnv // otelEnv; }; diff --git a/nix/module-eval.nix b/nix/module-eval.nix index e050c80c..76ee0295 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -570,6 +570,42 @@ let in c.baoClientCertFile == null && c.baoClientKeyFile == null; } + { + # Being *pointed at* a leaf and *being handed* one are different claims, + # and the options above were the first without the second — declared, + # defaulted, and read by nothing. This is the arm that makes them reach + # the process. + # + # ⚠️ The LoadCredential source is asserted, not just the `%d` name: the + # controller's leaf and the hive reader's are two identities with two + # policies, and wiring `deploy.bao.clientCertFile` here would satisfy + # every `%d`-only check while giving the daemon a policy that cannot + # write an agent's credential. + name = "the controller is handed its own store leaf, not the hive reader's"; + ok = + let + s = baoControllerHere.systemd.services; + in + s ? swarm-controller + && (s.swarm-controller.environment ? BAO_ADDR) + && (s.swarm-controller.environment.BAO_CLIENT_CERT or null) == "%d/bao-client.pem" + && (s.swarm-controller.environment.BAO_CLIENT_KEY or null) == "%d/bao-client-key.pem" + && builtins.elem "bao-client.pem:/var/lib/swarm-bao-pki/controller.pem" s.swarm-controller.serviceConfig.LoadCredential + && builtins.elem "bao-client-key.pem:/var/lib/swarm-bao-pki/controller-key.pem" s.swarm-controller.serviceConfig.LoadCredential; + } + { + # Absence arm for the one above, and what makes it mean anything: a + # controller with no leaf gets no store environment at all rather than + # variables naming files this host never receives. + name = "a controller with no store leaf is given no store environment"; + ok = + let + s = controllerNoStore.systemd.services; + in + s ? swarm-controller + && !(s.swarm-controller.environment ? BAO_ADDR) + && !(lib.any (c: lib.hasPrefix "bao-" c) s.swarm-controller.serviceConfig.LoadCredential); + } { # The CN is an interface between two files: the store writes a role that # matches it, the PKI mints a leaf that carries it. They read one option, diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index 32d2ce10..e6980f69 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -85,6 +85,11 @@ swarm-authelia-bridge-sock.workspace = true # not get to declare them privately. # swarm-queue-client = { workspace = true, features = ["kv"] } +# `matrix_account.rs` writes the credential this daemon's route accepts. Same +# crate the hive reads it back with, which is the point: the path, the field +# name and the object's shape are agreements between the two ends, and a +# second spelling here would be a store this hive could not read. +swarm-secret-client.workspace = true # `otel_http_client.rs`'s `AuthenticatedHttpClient` — an # `opentelemetry_http::HttpClient` impl authenticated with this crate's own # `swarm-queue-client` identity. Lives in this crate rather than diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 962a8dac..b7ca8b75 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -45,6 +45,7 @@ mod auth; mod config_pr; mod forge; mod issue_report; +mod matrix_account; mod otel_http_client; mod status; mod vcs_metrics; @@ -747,23 +748,13 @@ fn agent_status_reader( }) } -/// Both handlers below take the same hive name and reject it the same way. +/// A hive name that is shaped like one and names a hive this swarm has. /// /// Reports the status and the detail rather than a rendered /// `ProblemDetails`: that type is 232 bytes, which makes every `Result` in /// this path pay for the error case it usually does not take. The handlers /// render at the boundary, where the body is actually needed. -fn declaration_target( - state: &AppState, - hive: &str, -) -> Result<(Arc, String), (axum::http::StatusCode, String)> { - let writer = state.wanted.clone().ok_or_else(|| { - ( - axum::http::StatusCode::SERVICE_UNAVAILABLE, - "this deployment wired up no swarm queue, so there is nowhere to publish a declaration" - .to_owned(), - ) - })?; +fn swarm_hive(state: &AppState, hive: &str) -> Result { let hive = hive_types::Ident::parse(hive) .map_err(|reason| (axum::http::StatusCode::BAD_REQUEST, reason.to_owned()))? .into_string(); @@ -776,7 +767,25 @@ fn declaration_target( format!("{hive:?} is not a hive in this swarm"), )); } - Ok((writer, hive)) + Ok(hive) +} + +/// Both declaration handlers below take the same hive name and reject it the +/// same way, and need the writer besides. +fn declaration_target( + state: &AppState, + hive: &str, +) -> Result<(Arc, String), (axum::http::StatusCode, String)> { + // Before the name checks, so a deployment with no queue answers 503 + // whatever the caller spelled. + let writer = state.wanted.clone().ok_or_else(|| { + ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "this deployment wired up no swarm queue, so there is nowhere to publish a declaration" + .to_owned(), + ) + })?; + Ok((writer, swarm_hive(state, hive)?)) } /// Declare what this swarm wants of one agent on one hive. @@ -1652,6 +1661,7 @@ fn build_app(state: AppState) -> axum::Router { .routes(routes!(get_agents)) .routes(routes!(get_agents_status)) .routes(routes!(set_agent_state)) + .routes(routes!(matrix_account::put_matrix_account)) .routes(routes!(get_hive_wanted)) .routes(routes!(issue_report::get_repos)) .routes(routes!(issue_report::get_issue_report_all)) diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs new file mode 100644 index 00000000..626cb7f5 --- /dev/null +++ b/swarm-controller/src/matrix_account.rs @@ -0,0 +1,144 @@ +//! Give one agent an external matrix account: put the credential in the +//! swarm's secret store, then tell that agent's hive it is there. +//! +//! The hive end is `hive-c0re/src/workers/credential.rs`, which reads the +//! value under its own identity and writes it into the agent's state dir. The +//! notice carries only names, so the queue never holds the secret — see +//! [`swarm_queue_client::credential_subject`] for why that is a requirement +//! rather than a preference. +//! +//! ⚠️ Store first, notify second, and the order cannot be swapped: a notice +//! that overtakes its own write reaches a hive that reads nothing, and the +//! hive deliberately does not retry. + +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use serde::Deserialize; +use swarm_queue_client::{CredentialNotice, credential_subject}; +use swarm_secret_client::{SecretStore, matrix}; +use utoipa::ToSchema; + +use super::{AppState, error_problem, swarm_hive}; + +/// The cert-auth role this daemon logs into the secret store as. +/// +/// `nix/host-modules/swarm-bao.nix`'s `controllerPolicyName` creates the role, +/// names the policy after it, and `nix/module-eval.nix` pins the literal. +/// +/// ⚠️ Not the certificate's CN. The role *matches on* the CN +/// (`allowed_common_names`), so the two are deliberately different strings. +const CERT_ROLE: &str = "swarm-controller"; + +/// The credential to store for one agent's external matrix account. +#[derive(Debug, Deserialize, ToSchema)] +pub struct PutMatrixAccountRequest { + /// The access token. Never logged, and never returned by this route. + token: String, + /// The account's homeserver, when it is not this swarm's own. + /// + /// Stored beside the token rather than sent on the notice: a notice is a + /// queue message, so a homeserver carried there would exist only in + /// flight, with nowhere to reconstruct it from on a re-delivery. + #[schema(example = "https://matrix.example.org")] + homeserver: Option, +} + +/// Store an agent's external matrix account credential and notify its hive. +/// +/// Idempotent: the store keeps versions, so repeating a call replaces the +/// value the agent will next read rather than adding a second account. +#[utoipa::path( + put, + path = "/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}", + params( + ("hive" = String, Path, description = "hive whose agent receives the credential"), + ("agent" = String, Path, description = "agent the credential is delivered to"), + ("account" = String, Path, description = "the external account this credential authenticates as"), + ), + request_body = PutMatrixAccountRequest, + responses( + (status = 200, description = "stored, and the hive has been told"), + (status = 400, description = "a name is not an identifier, the account name is not a single path segment, or the hive is not in this swarm (problem+json)", body = String), + (status = 503, description = "no swarm queue is wired up (problem+json)", body = String), + (status = 500, description = "the store write, the encode or the publish failed (problem+json)", body = String), + ), + tag = "agents" +)] +pub async fn put_matrix_account( + State(state): State, + axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>, + Json(req): Json, +) -> Result { + // The queue first, so a deployment that has none answers 503 whatever the + // caller spelled — and before the store is touched, so a request that + // could never be delivered does not leave a credential behind. + let Some(status) = state.status.as_ref() else { + return Err(error_problem( + StatusCode::SERVICE_UNAVAILABLE, + "this deployment wired up no swarm queue, so there is no hive to notify", + )); + }; + let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?; + let agent = hive_types::Ident::parse(&agent) + .map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))? + .into_string(); + // Built before the store is reached, so a malformed account name costs a + // parse and not a login. `account_path` is the validator: the store's + // charset is its rule to state, not this handler's to restate. + let secret_path = matrix::account_path(&agent, &account) + .map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?; + + let store = SecretStore::from_env(CERT_ROLE).await.map_err(|e| { + tracing::warn!(error = %e, "connecting to the swarm secret store failed"); + error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) + })?; + store + .write( + &secret_path, + &matrix::Credential { + value: req.token, + homeserver: req.homeserver, + }, + ) + .await + .map_err(|e| { + // The path names the agent and the account; the value is not in it. + tracing::warn!(path = %secret_path, error = %e, "writing the credential failed"); + error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) + })?; + + let notice = CredentialNotice { + agent: agent.clone(), + account: account.clone(), + }; + let payload = serde_json::to_vec(¬ice).map_err(|e| { + error_problem( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("encoding the credential notice failed: {e}"), + ) + })?; + let subject = credential_subject(&hive); + let client = status.queue_client(); + client + .publish(subject.clone(), payload.into()) + .await + .map_err(|e| { + error_problem( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("publishing to {subject} failed: {e}"), + ) + })?; + // Flushed for the reason `publish_deploy` flushes: `publish` hands the + // message to the connection's write buffer and returns, so without this + // the response can outrun the notice it reports as sent. + client.flush().await.map_err(|e| { + error_problem( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("flushing the credential notice to {subject} failed: {e}"), + ) + })?; + + tracing::info!(%subject, %hive, %agent, %account, "credential stored; hive notified"); + Ok(StatusCode::OK) +}