mara, reviewing the previous commit: "the field is specific to matrix, why add it to the general struct". She is right, and the answer is that there was no general struct — `Credential` had one consumer, the crate's only path builder was `matrix_account`, and `value` is pinned by `glue-matrix-bao-token.nix`, a matrix unit. It was matrix's throughout, wearing a general name; adding `homeserver` is what made that visible. `client` now moves whatever type a caller names and decodes nothing itself. That is forwarding rather than machinery: `vaultrs::kv2::read`/`set` are already generic over the payload. The matrix agreement moves to its own module holding both halves — where a credential lives (`account_path`, was `path::matrix_account`) and what the object at that path holds. `path` keeps only what every path obeys, so a second kind of swarm secret becomes a module beside `matrix` rather than another optional field on a struct it shares. argus raised the same collision from the other direction on #4092: two mutually-exclusive `Option`s modelling one concept is the failure mode this forecloses. `checked_segment` stays public in `path`: hive-priv builds an on-disk path from the same names and must accept the same charset. Behaviour is unchanged. The compatibility properties move with the struct — `Option` is what lets a pre-`homeserver` stored object decode, and `skip_serializing_if` is what keeps a token-only object free of `"homeserver":null` for that nix reader. Refs #3726
88 lines
4.4 KiB
Markdown
88 lines
4.4 KiB
Markdown
# swarm-secret-client
|
|
|
|
Reading and writing a swarm credential in the secret store, over `vaultrs`.
|
|
The HTTP is that crate's job. What this one owns is the **agreements** both
|
|
ends of the store have to state identically: where a credential lives, which
|
|
field its bytes are in, and how this deployment's environment becomes a
|
|
logged-in client.
|
|
|
|
The surrounding picture — which secret is minted where, and why delivery is a
|
|
copy rather than a bind mount — is [`docs/swarm/secrets.md`](../docs/swarm/secrets.md).
|
|
|
|
## Why a crate and not a module per binary
|
|
|
|
The store has two Rust ends and they are peers: the swarm controller writes a
|
|
credential, a hive reads it. Neither is senior to the other, so a path
|
|
formatted at each call site is an agreement with no owner — it holds right up
|
|
until one side is edited alone, and then it fails as a missing key rather than
|
|
as a mismatch.
|
|
|
|
The third end is what settles it. `nix/host-modules/glue-matrix-bao-token.nix`
|
|
reads the store with `bao kv get -field=value`. That reader is a shell line in
|
|
a nix module: it cannot be renamed by the same refactor as a Rust struct, and
|
|
no Rust test reaches it. So the field name is pinned by a test against the
|
|
serialised literal rather than left to the struct definition.
|
|
|
|
## The identity is a certificate, and the role is the hive's name
|
|
|
|
Authentication is the store's `cert` auth method.
|
|
`nix/host-modules/glue-bao-tls.nix` mints the client certificate with its CN
|
|
set to the hive's name, because a cert-auth role matches on the CN. So
|
|
`cert_role` is not a free choice for the caller: a hive passes its own name,
|
|
and the policy attached to that role is what scopes what it may read.
|
|
|
|
The listener's `tls_require_and_verify_client_cert` is a different thing and
|
|
not a substitute. It decides who may open a connection; it says nothing about
|
|
who the connection belongs to, and a store with the option set and no `cert`
|
|
mount configured refuses every login made here. That refusal is what
|
|
`Error::Vault` out of `connect` means.
|
|
|
|
## Configuration
|
|
|
|
`Settings::from_env` reads `BAO_ADDR`, `BAO_CLIENT_CERT`, `BAO_CLIENT_KEY`,
|
|
and the optional `BAO_CACERT`.
|
|
|
|
**The `BAO_` spellings are read explicitly rather than left to `vaultrs`.** Its
|
|
own defaults look for `VAULT_ADDR` / `VAULT_CLIENT_CERT` / `VAULT_CLIENT_KEY`,
|
|
which no unit in this tree sets. Falling through to them builds a client with
|
|
**no identity at all**, and that surfaces as a TLS handshake failure — a place
|
|
that names neither the variable nor the reason.
|
|
|
|
**Empty is as absent as unset.** systemd renders an unset nix option as
|
|
`Environment=BAO_CACERT=`, so empty is the shape a missing value arrives in.
|
|
|
|
**The certificate and key are paths, not values**, and are read at connect
|
|
time — same rule as every other credential in this tree, for the same reason:
|
|
a value in a nix expression is rendered into the world-readable store.
|
|
|
|
Reading the environment is separate from connecting (`Settings::from_lookup`)
|
|
because every one of those failures is a misconfiguration an operator has to
|
|
read an error about, and none of them needs a reachable store to happen.
|
|
|
|
## Names that arrive from elsewhere
|
|
|
|
`matrix::account_path` is fallible, which for a string formatter needs saying:
|
|
its segments are an agent name from the topology and an account name from that
|
|
agent's own config. A `/` turns one agent's segment into another agent's
|
|
directory and `..` walks out of the prefix entirely, so the charset it accepts
|
|
is deliberately narrower than what the store would.
|
|
|
|
## One module per kind of secret
|
|
|
|
`client` moves whatever type a caller names; it decodes nothing itself. What a
|
|
stored object _holds_ is stated in the module that also builds its path —
|
|
`matrix` today, and a second kind of swarm secret gets a module beside it.
|
|
|
|
The split is deliberate. A single shared struct that grows one field per
|
|
consumer ends up carrying, on every path, a field only one path's reader has
|
|
ever heard of; and the two things a kind of secret must pin — where it lives
|
|
and what is in it — are one agreement that reads worse split across modules.
|
|
|
|
## What this crate does not do
|
|
|
|
It has no opinion on **what** a caller may read. That is the policy attached to
|
|
the cert role, and it lives in the store.
|
|
|
|
It holds the token minted at login and renews nothing. A handle is built per
|
|
credential, so the login is the cheap part of a rare operation — a caller that
|
|
wanted to keep one alive across a token's lifetime would need more than this.
|