swarm-archive serves restic repositories with rclone, append-only, one login and one repository per agent. swarm-controller writes each placed agent's login and restic password to the store, replaces them on a move and removes them when the agent is destroyed. Every hive backs its agents up hourly under its own store identity. The archive host prunes by retention.
185 lines
6.9 KiB
Rust
185 lines
6.9 KiB
Rust
//! The swarm's secret-store client: where a credential lives, and how both ends
|
|
//! reach it.
|
|
//!
|
|
//! The HTTP is [`vaultrs`]'s job. What this crate owns is the *agreements* —
|
|
//! the rules every path obeys ([`path`]), the translation from this
|
|
//! deployment's environment into a logged-in client ([`client`]), and, per kind
|
|
//! of secret, the path it lives at together with the fields it holds
|
|
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`github`], [`grafana`],
|
|
//! [`acp`], [`attic`], [`backend_env`], [`ci`], [`review_classifier`], [`builder`], [`ci_metrics`],
|
|
//! [`claude_login`], [`proxy`]).
|
|
//! Each of those is a thing the controller and a hive must say identically, so
|
|
//! it is said once here.
|
|
//!
|
|
//! [`policy`] is the same kind of agreement seen from the other side: which of
|
|
//! those paths a given principal's own token may read — a hive's, and an
|
|
//! agent's, which are two documents because they are two shapes of grant rather
|
|
//! than one with a name in it. It belongs here rather than in the controller
|
|
//! because the grant and the path are one statement — spelled differently they
|
|
//! produce a 403 that names neither.
|
|
//!
|
|
//! [`client`] is deliberately ignorant of all of it: it moves whatever type a
|
|
//! caller names, so a second kind of secret is a new module beside [`matrix`]
|
|
//! and not another field on a struct shared with it.
|
|
//!
|
|
//! [`mtls`] is the one module about reaching the store rather than about a
|
|
//! value inside it, and its doc explains why that is not circular.
|
|
//!
|
|
//! [`install_crypto_provider`] is process setup, not an agreement. It lives
|
|
//! here because this crate depends on no other swarm crate, so a binary that
|
|
//! needs only the provider and the store links nothing else.
|
|
|
|
pub mod acp;
|
|
pub mod archive;
|
|
pub mod attic;
|
|
pub mod backend_env;
|
|
pub mod builder;
|
|
pub mod cert;
|
|
#[cfg(feature = "otel")]
|
|
pub mod cert_metrics;
|
|
pub mod ci;
|
|
pub mod ci_metrics;
|
|
pub mod claude_login;
|
|
pub mod client;
|
|
pub mod forge;
|
|
pub mod github;
|
|
pub mod gitlab;
|
|
pub mod grafana;
|
|
pub mod matrix;
|
|
#[cfg(feature = "otel")]
|
|
pub mod meter_provider;
|
|
pub mod mtls;
|
|
pub mod observability;
|
|
pub mod path;
|
|
pub mod policy;
|
|
pub mod proxy;
|
|
pub mod queue;
|
|
pub mod review_classifier;
|
|
pub mod startup_read;
|
|
pub mod stored_secret;
|
|
|
|
pub use client::SecretStore;
|
|
pub use startup_read::read_with_startup_retry;
|
|
|
|
/// What can go wrong between "we have a client certificate" and "we have the
|
|
/// credential".
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum Error {
|
|
/// A name that would have addressed something other than what the caller
|
|
/// meant. See [`path`].
|
|
#[error("{kind} name {value:?} is not a single path segment of [A-Za-z0-9_-]")]
|
|
PathSegment {
|
|
/// Which name was rejected. A principal's kind in the singular
|
|
/// (`agent`, `hive`, `service`, `controller`) when the name addresses
|
|
/// one, or what the name is to the secret otherwise — `account`, for
|
|
/// a matrix credential.
|
|
kind: &'static str,
|
|
/// The offending value, quoted in the message because the caller
|
|
/// usually got it from config and needs to see which one.
|
|
value: String,
|
|
},
|
|
|
|
/// A variable the store's address or identity comes from is unset or
|
|
/// empty. Named rather than defaulted: a wrong store address fails much
|
|
/// later and much less clearly than a missing one.
|
|
#[error("{0} is unset or empty")]
|
|
MissingEnv(&'static str),
|
|
|
|
/// A client-certificate file named by the environment could not be read.
|
|
#[error("reading {path} (from {var}): {source}")]
|
|
Identity {
|
|
/// The variable that named the file.
|
|
var: &'static str,
|
|
/// The path it named.
|
|
path: String,
|
|
/// The underlying IO failure.
|
|
source: std::io::Error,
|
|
},
|
|
|
|
/// The address would not parse into a URL the client can use.
|
|
#[error("the store's settings are unusable: {0}")]
|
|
Settings(String),
|
|
|
|
/// The store refused us, was unreachable, did not answer within the
|
|
/// client's request timeout, or answered something we could not parse.
|
|
#[error(transparent)]
|
|
Vault(#[from] Box<vaultrs::error::ClientError>),
|
|
|
|
/// The client certificate and key did not form a usable identity, or the
|
|
/// CA bundle did not parse.
|
|
#[error("building the TLS identity: {0}")]
|
|
Tls(#[source] reqwest::Error),
|
|
|
|
/// The store answered an issue request with a field empty that a usable
|
|
/// identity needs. Names the field, never its value.
|
|
#[error("the store issued a certificate whose {0} is empty")]
|
|
IncompleteIssue(&'static str),
|
|
|
|
/// The text was not one PEM-encoded X.509 certificate.
|
|
#[error("decoding the certificate: {0}")]
|
|
Certificate(String),
|
|
}
|
|
|
|
impl From<vaultrs::error::ClientError> for Error {
|
|
fn from(e: vaultrs::error::ClientError) -> Self {
|
|
// Boxed because `ClientError` is large enough that carrying it inline
|
|
// makes every `Result` in the crate pay for the rare arm.
|
|
Self::Vault(Box::new(e))
|
|
}
|
|
}
|
|
|
|
impl Error {
|
|
/// Whether the store answered 403: the principal's policy does not cover
|
|
/// the path. A policy the store's host has not written yet looks the same
|
|
/// as one that never will, so a caller that can do without the path
|
|
/// checks this before treating the failure as an outage.
|
|
#[must_use]
|
|
pub fn is_denied(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::Vault(e) if matches!(**e, vaultrs::error::ClientError::APIError { code: 403, .. })
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Install `aws-lc-rs` as the process-wide rustls crypto provider. Call it
|
|
/// first thing in `main` of every binary that dials the queue or reaches the
|
|
/// secret store over TLS.
|
|
///
|
|
/// rustls is built with both `ring` and `aws-lc-rs`; without an installed
|
|
/// default, the first TLS handshake panics. On the queue side async-nats
|
|
/// builds its config with `ClientConfig::builder()`, so the client never
|
|
/// connects. On the store side the client is reqwest, via vaultrs.
|
|
/// `aws-lc-rs` because it is what reqwest uses when nothing is installed.
|
|
pub fn install_crypto_provider() {
|
|
// `Err` only means a provider is already installed, which is all this needs.
|
|
let _already_installed = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn only_a_403_is_a_denial() {
|
|
let api = |code| {
|
|
Error::from(vaultrs::error::ClientError::APIError {
|
|
code,
|
|
errors: Vec::new(),
|
|
})
|
|
};
|
|
assert!(api(403).is_denied());
|
|
// The controls: a miss and an outage are not denials.
|
|
assert!(!api(404).is_denied());
|
|
assert!(!api(503).is_denied());
|
|
assert!(!Error::MissingEnv("BAO_ADDR").is_denied());
|
|
}
|
|
|
|
#[test]
|
|
fn a_tls_client_config_builds_once_the_provider_is_installed() {
|
|
install_crypto_provider();
|
|
let _config = rustls::ClientConfig::builder()
|
|
.with_root_certificates(rustls::RootCertStore::empty())
|
|
.with_no_client_auth();
|
|
}
|
|
}
|