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) +}