swarm-secret-client: the agreements both ends of the store must share
mara ruled (a) on #3726: a thin workspace crate over `vaultrs` rather than keeping bao access in nix and having each end trigger units. The HTTP is the SDK's job; what this crate owns is the things the controller and a hive must say *identically*, and which have no other home because neither end is senior to the other. Three such agreements: `path::matrix_account` builds where a credential lives. It is fallible rather than a `format!`, because both names reach it from elsewhere -- the agent name from the topology, the account name from an agent's own config -- and a `/` or `..` in either does not produce a malformed path, it produces a valid path to a *different agent's* secret. The charset mirrors the KV bucket-name rule. `Credential`'s `value` field is not a free choice: glue-matrix-bao-token.nix reads the store with `bao kv get -field=value`, so the name is load-bearing for a consumer no Rust test can reach. A test pins the serialised shape. `client::Settings` reads BAO_ADDR / BAO_CLIENT_CERT / BAO_CLIENT_KEY / BAO_CACERT explicitly instead of letting vaultrs fall through to its own defaults, which look for VAULT_ADDR / VAULT_CLIENT_CERT / VAULT_CLIENT_KEY. Every unit in this tree sets the BAO_ spellings, so the defaults would yield a client with no identity at all -- surfacing as a TLS handshake failure, which names neither the missing variable nor the reason. The env read is split from the connect so every misconfiguration arm is testable without a reachable store and without touching process-global env. Dependency impact, measured against the lock at forge/main rather than assumed: native-tls 0 -> 0, openssl-sys 0 -> 0, one reqwest (0.13.4) which vaultrs shares, and 10 new crates that are all derive/proc-macro helpers. Refs #3726
This commit is contained in:
parent
9c601c4166
commit
4384a1fffa
6 changed files with 584 additions and 6 deletions
154
Cargo.lock
generated
154
Cargo.lock
generated
|
|
@ -894,14 +894,38 @@ dependencies = [
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling"
|
||||||
|
version = "0.20.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core 0.20.11",
|
||||||
|
"darling_macro 0.20.11",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.23.0"
|
version = "0.23.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core 0.23.0",
|
||||||
"darling_macro",
|
"darling_macro 0.23.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_core"
|
||||||
|
version = "0.20.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||||
|
dependencies = [
|
||||||
|
"fnv",
|
||||||
|
"ident_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"strsim",
|
||||||
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -917,13 +941,24 @@ dependencies = [
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_macro"
|
||||||
|
version = "0.20.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core 0.20.11",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling_macro"
|
name = "darling_macro"
|
||||||
version = "0.23.0"
|
version = "0.23.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core 0.23.0",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
@ -1020,6 +1055,37 @@ dependencies = [
|
||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
||||||
|
dependencies = [
|
||||||
|
"derive_builder_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder_core"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||||
|
dependencies = [
|
||||||
|
"darling 0.20.11",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder_macro"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||||
|
dependencies = [
|
||||||
|
"derive_builder_core",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
@ -3823,7 +3889,7 @@ version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3"
|
checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling 0.23.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -4012,6 +4078,40 @@ dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustify"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4800ce4c1cc2fec12c559dae2ddbf0e17fcee7569b796e6d75898efef443368b"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"reqwest",
|
||||||
|
"rustify_derive",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustify_derive"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78ea7fda74240f7410d0198b603a8a2f662acc7d76b6667a49f9b162cd8d9b4f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"regex",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"syn 1.0.109",
|
||||||
|
"synstructure 0.12.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
|
|
@ -4642,6 +4742,17 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "swarm-secret-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"vaultrs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swarmctl"
|
name = "swarmctl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -4696,6 +4807,18 @@ dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "synstructure"
|
||||||
|
version = "0.12.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 1.0.109",
|
||||||
|
"unicode-xid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
|
|
@ -5337,6 +5460,25 @@ version = "0.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vaultrs"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "30ffcc0e81025065dda612ec1e26a3d81bb16ef3062354873d17a35965d68522"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"derive_builder",
|
||||||
|
"http",
|
||||||
|
"reqwest",
|
||||||
|
"rustify",
|
||||||
|
"rustify_derive",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
|
|
@ -5873,7 +6015,7 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
"synstructure",
|
"synstructure 0.13.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -5914,7 +6056,7 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
"synstructure",
|
"synstructure 0.13.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ members = [
|
||||||
"swarm-controller",
|
"swarm-controller",
|
||||||
"swarm-nats-auth",
|
"swarm-nats-auth",
|
||||||
"swarm-queue-client",
|
"swarm-queue-client",
|
||||||
|
"swarm-secret-client",
|
||||||
"swarmctl",
|
"swarmctl",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -90,7 +91,9 @@ hive-sock-client = { path = "hive-sock-client" }
|
||||||
hive-types = { path = "hive-types" }
|
hive-types = { path = "hive-types" }
|
||||||
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
|
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
|
||||||
swarm-queue-client = { path = "swarm-queue-client" }
|
swarm-queue-client = { path = "swarm-queue-client" }
|
||||||
|
swarm-secret-client = { path = "swarm-secret-client" }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
vaultrs = "0.8"
|
||||||
tower-http = { version = "0.7", features = ["fs"] }
|
tower-http = { version = "0.7", features = ["fs"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
rmcp = { version = "2", default-features = false, features = [
|
rmcp = { version = "2", default-features = false, features = [
|
||||||
|
|
|
||||||
19
swarm-secret-client/Cargo.toml
Normal file
19
swarm-secret-client/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[package]
|
||||||
|
name = "swarm-secret-client"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# `Identity` is re-exported by vaultrs, but the identity is built here (from
|
||||||
|
# the `BAO_*` files) rather than by its env defaults, so the dependency is
|
||||||
|
# direct rather than incidental.
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
vaultrs.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
248
swarm-secret-client/src/client.rs
Normal file
248
swarm-secret-client/src/client.rs
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
//! A logged-in handle on the store, built from this deployment's environment.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder};
|
||||||
|
|
||||||
|
use crate::{Error, path::MOUNT};
|
||||||
|
|
||||||
|
/// The store's address.
|
||||||
|
pub const ENV_ADDR: &str = "BAO_ADDR";
|
||||||
|
/// PEM client certificate presented to the store's listener.
|
||||||
|
pub const ENV_CLIENT_CERT: &str = "BAO_CLIENT_CERT";
|
||||||
|
/// PEM private key for [`ENV_CLIENT_CERT`].
|
||||||
|
pub const ENV_CLIENT_KEY: &str = "BAO_CLIENT_KEY";
|
||||||
|
/// CA bundle the store's own certificate is verified against. Optional:
|
||||||
|
/// absent means the system trust store, which is what a deployment with a
|
||||||
|
/// real CA wants and what a self-signed one must not be left with.
|
||||||
|
pub const ENV_CACERT: &str = "BAO_CACERT";
|
||||||
|
|
||||||
|
/// The auth mount a cert login goes through, unless a caller says otherwise.
|
||||||
|
pub const DEFAULT_CERT_MOUNT: &str = "cert";
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Credential {
|
||||||
|
/// `glue-matrix-bao-token.nix` reads the store with
|
||||||
|
/// `bao kv get -field=value`, so this name is load-bearing for a reader
|
||||||
|
/// this crate does not control. Renaming it silently breaks that unit.
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the store is and which identity we present to it.
|
||||||
|
///
|
||||||
|
/// Separate from the connect so the environment can be checked without one:
|
||||||
|
/// every arm below is a misconfiguration an operator has to read an error
|
||||||
|
/// about, and none of them needs a reachable store to happen.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct Settings {
|
||||||
|
address: String,
|
||||||
|
cert_path: String,
|
||||||
|
key_path: String,
|
||||||
|
ca_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
/// Read the `BAO_*` variables from the process environment.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`Error::MissingEnv`] naming the first variable that is unset or empty.
|
||||||
|
pub fn from_env() -> Result<Self, Error> {
|
||||||
|
Self::from_lookup(|k| std::env::var(k).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Settings::from_env`] against an arbitrary lookup.
|
||||||
|
///
|
||||||
|
/// [`vaultrs`]'s own defaults are deliberately not used: it looks for
|
||||||
|
/// `VAULT_ADDR` / `VAULT_CLIENT_CERT` / `VAULT_CLIENT_KEY`, and every unit
|
||||||
|
/// in this tree sets the `BAO_` spellings. Falling through to those
|
||||||
|
/// defaults yields a client with **no identity**, which fails at the TLS
|
||||||
|
/// handshake rather than anywhere that names the cause.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`Error::MissingEnv`] naming the first variable that is unset or empty.
|
||||||
|
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self, Error> {
|
||||||
|
let required = |var: &'static str| -> Result<String, Error> {
|
||||||
|
get(var)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.ok_or(Error::MissingEnv(var))
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
address: required(ENV_ADDR)?,
|
||||||
|
cert_path: required(ENV_CLIENT_CERT)?,
|
||||||
|
key_path: required(ENV_CLIENT_KEY)?,
|
||||||
|
ca_path: get(ENV_CACERT).filter(|v| !v.is_empty()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client that has already exchanged its certificate for a token.
|
||||||
|
pub struct SecretStore {
|
||||||
|
inner: VaultClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_file(var: &'static str, path: &str) -> Result<Vec<u8>, Error> {
|
||||||
|
std::fs::read(path).map_err(|source| Error::Identity {
|
||||||
|
var,
|
||||||
|
path: path.to_owned(),
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretStore {
|
||||||
|
/// Connect using the `BAO_*` environment and log in with the certificate
|
||||||
|
/// auth method, returning a handle that already holds a token.
|
||||||
|
///
|
||||||
|
/// `cert_role` is the role configured on the store's `cert` mount; it
|
||||||
|
/// selects which policy the returned token carries.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// As [`SecretStore::connect`], plus [`Error::MissingEnv`].
|
||||||
|
pub async fn from_env(cert_role: &str) -> Result<Self, Error> {
|
||||||
|
Self::connect(&Settings::from_env()?, cert_role, DEFAULT_CERT_MOUNT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Present `settings`' identity to the store and log in at `cert_mount`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`Error::Identity`] when a named file cannot be read, [`Error::Tls`]
|
||||||
|
/// when the cert/key pair does not form a usable identity,
|
||||||
|
/// [`Error::Settings`] when the address will not parse, and
|
||||||
|
/// [`Error::Vault`] when the store refuses the login — which is what an
|
||||||
|
/// unconfigured `cert` mount looks like from here.
|
||||||
|
pub async fn connect(
|
||||||
|
settings: &Settings,
|
||||||
|
cert_role: &str,
|
||||||
|
cert_mount: &str,
|
||||||
|
) -> Result<Self, Error> {
|
||||||
|
// One PEM blob holding both, which is the shape `Identity::from_pem`
|
||||||
|
// wants; the two stay separate on disk because the key is the half
|
||||||
|
// that gets `0600`.
|
||||||
|
let mut pem = read_file(ENV_CLIENT_CERT, &settings.cert_path)?;
|
||||||
|
pem.push(b'\n');
|
||||||
|
pem.extend_from_slice(&read_file(ENV_CLIENT_KEY, &settings.key_path)?);
|
||||||
|
let identity = reqwest::Identity::from_pem(&pem).map_err(Error::Tls)?;
|
||||||
|
|
||||||
|
let mut builder = VaultClientSettingsBuilder::default();
|
||||||
|
builder
|
||||||
|
.address(settings.address.clone())
|
||||||
|
.identity(Some(identity));
|
||||||
|
if let Some(ca) = &settings.ca_path {
|
||||||
|
builder.ca_certs(vec![ca.clone()]);
|
||||||
|
}
|
||||||
|
let built = builder
|
||||||
|
.build()
|
||||||
|
.map_err(|e| Error::Settings(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut inner = VaultClient::new(built)?;
|
||||||
|
let auth = vaultrs::auth::cert::login(&inner, cert_mount, cert_role).await?;
|
||||||
|
inner.set_token(&auth.client_token);
|
||||||
|
Ok(Self { inner })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the credential stored at `path`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`Error::Vault`] when the path does not exist, the token's policy does
|
||||||
|
/// not cover it, or the stored object has no `value` field.
|
||||||
|
pub async fn read(&self, path: &str) -> Result<String, Error> {
|
||||||
|
let c: Credential = vaultrs::kv2::read(&self.inner, MOUNT, path).await?;
|
||||||
|
Ok(c.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `value` as the credential at `path`, creating a new version.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`Error::Vault`] when the token's policy does not cover the path.
|
||||||
|
pub async fn write(&self, path: &str, value: &str) -> Result<(), Error> {
|
||||||
|
let body = Credential {
|
||||||
|
value: value.to_owned(),
|
||||||
|
};
|
||||||
|
vaultrs::kv2::set(&self.inner, MOUNT, path, &body).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A lookup standing in for a fully-configured unit's environment.
|
||||||
|
fn full(k: &str) -> Option<String> {
|
||||||
|
match k {
|
||||||
|
ENV_ADDR => Some("https://store.invalid:8200".to_owned()),
|
||||||
|
ENV_CLIENT_CERT => Some("/run/c.pem".to_owned()),
|
||||||
|
ENV_CLIENT_KEY => Some("/run/k.pem".to_owned()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_complete_environment_is_accepted() {
|
||||||
|
// The control: without this, every assertion below could be passing
|
||||||
|
// because `from_lookup` rejects everything.
|
||||||
|
let s = Settings::from_lookup(full).expect("every required variable is set");
|
||||||
|
assert_eq!(s.address, "https://store.invalid:8200");
|
||||||
|
assert_eq!(s.ca_path, None, "an absent CA is the system trust store");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_required_variable_is_named_when_it_is_the_missing_one() {
|
||||||
|
for var in [ENV_ADDR, ENV_CLIENT_CERT, ENV_CLIENT_KEY] {
|
||||||
|
let e = Settings::from_lookup(|k| if k == var { None } else { full(k) })
|
||||||
|
.expect_err("one required variable is absent");
|
||||||
|
assert!(
|
||||||
|
matches!(e, Error::MissingEnv(v) if v == var),
|
||||||
|
"dropping {var} should name {var}, got {e:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_variable_is_as_absent_as_an_unset_one() {
|
||||||
|
// systemd writes `Environment=BAO_CACERT=` for an unset nix option, so
|
||||||
|
// empty is the shape this actually arrives in.
|
||||||
|
let e = Settings::from_lookup(|k| {
|
||||||
|
if k == ENV_ADDR {
|
||||||
|
Some(String::new())
|
||||||
|
} else {
|
||||||
|
full(k)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect_err("an empty address is not an address");
|
||||||
|
assert!(matches!(e, Error::MissingEnv(ENV_ADDR)), "got {e:?}");
|
||||||
|
|
||||||
|
let s = Settings::from_lookup(|k| {
|
||||||
|
if k == ENV_CACERT {
|
||||||
|
Some(String::new())
|
||||||
|
} else {
|
||||||
|
full(k)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("an empty CA is optional, not fatal");
|
||||||
|
assert_eq!(s.ca_path, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_cert_names_the_file_and_the_variable() {
|
||||||
|
let e = read_file(ENV_CLIENT_CERT, "/nonexistent/cert.pem")
|
||||||
|
.expect_err("the file does not exist");
|
||||||
|
match e {
|
||||||
|
Error::Identity { var, ref path, .. } => {
|
||||||
|
assert_eq!(var, ENV_CLIENT_CERT);
|
||||||
|
assert_eq!(path, "/nonexistent/cert.pem");
|
||||||
|
}
|
||||||
|
other => panic!("wanted an Identity error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_value_field_matches_what_the_nix_reader_asks_for() {
|
||||||
|
// The literal is the point: `bao kv get -field=value` is the other end
|
||||||
|
// of this agreement and lives in a file no Rust test can reach, so the
|
||||||
|
// name is pinned here rather than derived from the struct.
|
||||||
|
let json = serde_json::to_string(&Credential {
|
||||||
|
value: "t".to_owned(),
|
||||||
|
})
|
||||||
|
.expect("a struct of one String serialises");
|
||||||
|
assert_eq!(json, r#"{"value":"t"}"#);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
swarm-secret-client/src/lib.rs
Normal file
68
swarm-secret-client/src/lib.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
//! 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 path a credential is written to and read from ([`path`]), the field its
|
||||||
|
//! bytes live in, and the translation from this deployment's environment into
|
||||||
|
//! a logged-in client ([`client`]). Each of those is a thing the controller and
|
||||||
|
//! a hive must say identically, so it is said once here.
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
|
pub mod path;
|
||||||
|
|
||||||
|
pub use client::SecretStore;
|
||||||
|
|
||||||
|
/// 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 — `agent` or `account`.
|
||||||
|
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, 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),
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
98
swarm-secret-client/src/path.rs
Normal file
98
swarm-secret-client/src/path.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
//! Where a credential lives, for both ends of the store.
|
||||||
|
//!
|
||||||
|
//! The controller writes and a hive reads, and neither is senior to the other,
|
||||||
|
//! so the path they must agree on is built here rather than formatted at each
|
||||||
|
//! call site.
|
||||||
|
|
||||||
|
use crate::Error;
|
||||||
|
|
||||||
|
/// The KV v2 mount every swarm secret lives under.
|
||||||
|
///
|
||||||
|
/// A literal because the store's existing reader already hardcodes the same
|
||||||
|
/// one (`secret/swarm/matrix/registration-token`); an option nobody sets would
|
||||||
|
/// be two ways to say one thing.
|
||||||
|
pub const MOUNT: &str = "secret";
|
||||||
|
|
||||||
|
/// The prefix under [`MOUNT`] owned by per-agent credentials.
|
||||||
|
pub const AGENT_PREFIX: &str = "swarm/agents";
|
||||||
|
|
||||||
|
/// A path segment that cannot change the path's shape.
|
||||||
|
///
|
||||||
|
/// The charset is deliberately narrower than what the store accepts: a `/`
|
||||||
|
/// turns one agent's segment into another agent's directory, and `..` walks
|
||||||
|
/// out of the prefix entirely. Both are names this crate receives from
|
||||||
|
/// elsewhere — an agent name from the topology, an account name from an
|
||||||
|
/// agent's own config — so neither is trusted to be well-formed here.
|
||||||
|
fn checked_segment(kind: &'static str, value: &str) -> Result<(), Error> {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(Error::PathSegment {
|
||||||
|
kind,
|
||||||
|
value: value.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !value
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||||
|
{
|
||||||
|
return Err(Error::PathSegment {
|
||||||
|
kind,
|
||||||
|
value: value.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path holding `agent`'s token for the external matrix account `account`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`Error::PathSegment`] when either name contains anything but
|
||||||
|
/// `[A-Za-z0-9_-]`, which is what keeps one agent's name from addressing
|
||||||
|
/// another agent's secret.
|
||||||
|
pub fn matrix_account(agent: &str, account: &str) -> Result<String, Error> {
|
||||||
|
checked_segment("agent", agent)?;
|
||||||
|
checked_segment("account", account)?;
|
||||||
|
Ok(format!("{AGENT_PREFIX}/{agent}/matrix/{account}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_well_formed_pair_lands_under_the_agent_prefix() {
|
||||||
|
let p = matrix_account("atlas", "ops-relay").expect("both segments are legal");
|
||||||
|
assert_eq!(p, "swarm/agents/atlas/matrix/ops-relay");
|
||||||
|
assert!(p.starts_with(AGENT_PREFIX));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_segment_cannot_escape_its_own_directory() {
|
||||||
|
// Each of these is a *different* way to address another agent's tree,
|
||||||
|
// and the last two are the ones a charset check catches but a
|
||||||
|
// `contains("..")` check does not.
|
||||||
|
for bad in [
|
||||||
|
"../argus",
|
||||||
|
"atlas/../argus",
|
||||||
|
"atlas/matrix",
|
||||||
|
"a b",
|
||||||
|
"a.b",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
matrix_account(bad, "ops-relay").is_err(),
|
||||||
|
"agent segment {bad:?} must be refused"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matrix_account("atlas", bad).is_err(),
|
||||||
|
"account segment {bad:?} must be refused"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_legal_charset_is_actually_reachable() {
|
||||||
|
// The control for the test above: if `checked_segment` rejected
|
||||||
|
// everything, the escape cases would pass for the wrong reason.
|
||||||
|
assert!(matrix_account("a-b_C9", "d-e_F0").is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue