Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f53e9be6c | ||
|
|
1f4736e79b | ||
|
|
e7dbb94f28 |
32 changed files with 1050 additions and 531 deletions
|
|
@ -193,6 +193,15 @@ hand-maintained per-file tree drifts out of sync with the code.
|
|||
`claude --print` per classification (no tools, no session) and replies
|
||||
with the rows and the overall verdict. Holds no forge credential. Design:
|
||||
[`docs/swarm/review-classifier.md`](docs/swarm/review-classifier.md).
|
||||
- **`swarm-agent-verify/`** — the check that a presented
|
||||
`swarm-agent.<agent>.<secret>` token holds the secret stored at
|
||||
`swarm/agents/<agent>/queue`, read under the verifier's own certificate.
|
||||
Shared by `swarm-nats-auth` (the queue's auth callout) and
|
||||
`swarm-logs-auth`.
|
||||
- **`swarm-logs-auth/`** — the log store gateway's `auth_request` responder
|
||||
for agents: nginx sends it `/select/logsql/` requests carrying an agent
|
||||
token, it answers 200 or 401 via `swarm-agent-verify`. Runs on the log
|
||||
store's host (`swarm-victorialogs.nix`).
|
||||
- **`swarmctl/`** — swarm-level operator CLI, installed by the
|
||||
swarm-controller module on the host that runs the daemon. Runs as
|
||||
**root and acts directly** — it serves no socket, publishes no HTTP
|
||||
|
|
|
|||
31
Cargo.lock
generated
31
Cargo.lock
generated
|
|
@ -4789,6 +4789,18 @@ version = "2.6.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swarm-agent-verify"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"subtle",
|
||||
"swarm-queue-client",
|
||||
"swarm-secret-client",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swarm-authelia-bridge"
|
||||
version = "0.1.0"
|
||||
|
|
@ -4870,6 +4882,23 @@ dependencies = [
|
|||
"reqwest",
|
||||
"serde_json",
|
||||
"swarm-queue-client",
|
||||
"swarm-secret-client",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swarm-logs-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"swarm-agent-verify",
|
||||
"swarm-queue-client",
|
||||
"swarm-secret-client",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4910,7 +4939,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"subtle",
|
||||
"swarm-agent-verify",
|
||||
"swarm-queue-client",
|
||||
"swarm-secret-client",
|
||||
"tokio",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ members = [
|
|||
"hive-subagent-mcp",
|
||||
"hive-types",
|
||||
"hivectl",
|
||||
"swarm-agent-verify",
|
||||
"swarm-authelia-bridge",
|
||||
"swarm-authelia-bridge-sock",
|
||||
"swarm-controller",
|
||||
|
|
@ -34,6 +35,7 @@ members = [
|
|||
"swarm-queue-client",
|
||||
"swarm-review-classifier",
|
||||
"swarm-logs",
|
||||
"swarm-logs-auth",
|
||||
"swarm-secret-client",
|
||||
"swarmctl",
|
||||
]
|
||||
|
|
@ -104,6 +106,7 @@ hive-priv-sock = { path = "hive-priv-sock" }
|
|||
hive-runtime = { path = "hive-runtime" }
|
||||
hive-sock-client = { path = "hive-sock-client" }
|
||||
hive-types = { path = "hive-types" }
|
||||
swarm-agent-verify = { path = "swarm-agent-verify" }
|
||||
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
|
||||
swarm-matrix-client = { path = "swarm-matrix-client" }
|
||||
swarm-queue-client = { path = "swarm-queue-client" }
|
||||
|
|
@ -234,7 +237,7 @@ futures-util = "0.3"
|
|||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
# Constant-time comparison of a presented secret against the stored one
|
||||
# (`swarm-nats-auth::agent_token`). Already in the tree through the TLS stack.
|
||||
# (`swarm-agent-verify`). Already in the tree through the TLS stack.
|
||||
subtle = "2.6"
|
||||
# The NATS protocol client, for the swarm queue's auth-callout responder.
|
||||
# `default-features = false` because the default set is broad - jetstream, kv,
|
||||
|
|
|
|||
|
|
@ -182,8 +182,9 @@ the credential from the store again for each attempt.
|
|||
|
||||
The hive's shared queue client is still delivered into the harness unit's
|
||||
credentials directory, and `swarm.statusPublish.tokenEndpoint` still reaches
|
||||
the container, but only for `swarm-logs`: the harness never connects with
|
||||
them.
|
||||
the container, but only for a `swarm-logs` built before it read the agent's
|
||||
own queue secret: the harness never connects with them, and a current
|
||||
`swarm-logs` presents that secret to the log store instead.
|
||||
|
||||
<details><summary>What an agent publishes over the queue</summary>
|
||||
|
||||
|
|
|
|||
|
|
@ -55,27 +55,27 @@ of the cell says how.
|
|||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
| store path | minter | reader — pulls at runtime, holds in memory | automatic re-mint | automatic re-pull |
|
||||
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | ✅ the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ `hive-matrix-daemon` exits when the homeserver rejects its token, and a five-minute timer restarts it, which reads the store again |
|
||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | must be stated |
|
||||
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass |
|
||||
| `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, once at start | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ❌ read once at start; the controller holds the old value until it restarts |
|
||||
| `swarm/controller/swarm-controller/forge/token` | the forge, inside the `hive-forge` container, once; `swarm-bao-forge-publisher` on the forge's host copies it in under its own certificate, with a write-only grant | `swarm-controller`, under its own certificate, at start | ❌ the forge mints it once and keeps its copy in the container; the publisher writes that copy each time the forge container starts and each time the mint writes the file | ❌ the controller reads it at start, and in the background with backoff until it first finds it; a replaced token reaches it with the next restart |
|
||||
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config; then `hive-agent-bao-identity` in the agent container, under the leaf it holds | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ✅ `hive-agent-bao-identity` re-reads it every 10 minutes and swaps a new leaf into `/run/hive-agent-bao-identity/`, which every store login in the container presents. It reads under the leaf it holds, so it renews only while that leaf is valid |
|
||||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff |
|
||||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
|
||||
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when an operator links an external forge account in the swarm UI | `hive-agent-forge-accounts` in the agent container, under the agent's own certificate, into `<state>/forge-<label>-token` and `forge-<label>.json` | ❌ an operator's token; replaced only by deleting it and linking the label again | ✅ the agent re-fetches on a 2-minute timer |
|
||||
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when an operator links a GitHub account in the swarm UI | `hive-agent-github-token` in the agent container, under the agent's own certificate, into `<state>/github-token` | ❌ an operator's token; replaced only by deleting it and linking it again | ✅ the agent re-fetches on a 2-minute timer, and `hive-github-notify` re-reads the file on every poll |
|
||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
|
||||
| `swarm/hives/<hive>/forge/token` | `swarm-controller`, with the forge's admin API, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `forge::reader_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ hive-c0re reads the store on every forge read |
|
||||
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |
|
||||
| `swarm/hives/<hive>/oidc/client` | authelia, at its first boot; `swarm-secret-publish` copies it in | hive-c0re, under the hive's own certificate, when it first connects to the queue | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ❌ read until the first connect succeeds, retried with a backoff of up to five minutes while it's missing; then held until hive-c0re restarts |
|
||||
| `swarm/services/<clientId>/oidc/client` | authelia | that service's own reader, under its own certificate — the queue responder's under `swarm-nats-auth`'s, the homeserver's under `swarm-matrix-ctl`'s, the forge's under `swarm-forge-publisher`'s, the review classifier's under `swarm-review-classifier`'s; the bao UI's by the store's granter | must be stated | must be stated |
|
||||
| `swarm/services/ci-runner/registration-token` | `swarm-controller`, from the forge's admin API, at start and in a five-minute pass; its grant on the path is write-only | `swarm-bao-ci-runner` on the runner's host, under the runner's own certificate, into a file only `swarm-ci-runner-register` in the runner's container loads; no other principal's policy reads it | ❌ the forge hands out one token until an admin resets it; the controller writes a reset token within five minutes | ✅ `swarm-bao-ci-runner` reads it at boot, on deploy and every ten minutes, and restarts the runner when the value changed |
|
||||
| `swarm/services/review-classifier/anthropic-api-key` | the operator, by hand in the store's UI, field `api_key` | `swarm-review-classifier` in its container, under its own certificate, at the start of every run; held for that run and handed to `claude`'s environment only; no other principal's policy reads it | ❌ an operator's key; replaced by writing a new one | ✅ read on every run, so a written or removed key takes effect on the next run |
|
||||
| `swarm/services/review-classifier/forge/token` | `swarm-controller`, with the forge's admin API, for its `review-classifier` account, at start and in a five-minute pass; the secret publisher's policy denies it | `swarm-controller` under its own certificate, before it decides whether to mint, and on every review it posts as the account; the classifier service's policy doesn't read it | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the controller reads the store on every review it posts |
|
||||
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated |
|
||||
| store path | minter | reader — pulls at runtime, holds in memory | automatic re-mint | automatic re-pull |
|
||||
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | ✅ the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ `hive-matrix-daemon` exits when the homeserver rejects its token, and a five-minute timer restarts it, which reads the store again |
|
||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | must be stated |
|
||||
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass |
|
||||
| `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, once at start | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ❌ read once at start; the controller holds the old value until it restarts |
|
||||
| `swarm/controller/swarm-controller/forge/token` | the forge, inside the `hive-forge` container, once; `swarm-bao-forge-publisher` on the forge's host copies it in under its own certificate, with a write-only grant | `swarm-controller`, under its own certificate, at start | ❌ the forge mints it once and keeps its copy in the container; the publisher writes that copy each time the forge container starts and each time the mint writes the file | ❌ the controller reads it at start, and in the background with backoff until it first finds it; a replaced token reaches it with the next restart |
|
||||
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config; then `hive-agent-bao-identity` in the agent container, under the leaf it holds | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ✅ `hive-agent-bao-identity` re-reads it every 10 minutes and swaps a new leaf into `/run/hive-agent-bao-identity/`, which every store login in the container presents. It reads under the leaf it holds, so it renews only while that leaf is valid |
|
||||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive; `swarm-logs` in the agent container, under the same certificate, on every query, as the bearer it presents at the log store; `swarm-nats-auth` and `swarm-logs-auth`, each under its own certificate, to check a presented secret | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff. `swarm-logs` reads it on every query and once more after a 401 |
|
||||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
|
||||
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when an operator links an external forge account in the swarm UI | `hive-agent-forge-accounts` in the agent container, under the agent's own certificate, into `<state>/forge-<label>-token` and `forge-<label>.json` | ❌ an operator's token; replaced only by deleting it and linking the label again | ✅ the agent re-fetches on a 2-minute timer |
|
||||
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when an operator links a GitHub account in the swarm UI | `hive-agent-github-token` in the agent container, under the agent's own certificate, into `<state>/github-token` | ❌ an operator's token; replaced only by deleting it and linking it again | ✅ the agent re-fetches on a 2-minute timer, and `hive-github-notify` re-reads the file on every poll |
|
||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
|
||||
| `swarm/hives/<hive>/forge/token` | `swarm-controller`, with the forge's admin API, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `forge::reader_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ hive-c0re reads the store on every forge read |
|
||||
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |
|
||||
| `swarm/hives/<hive>/oidc/client` | authelia, at its first boot; `swarm-secret-publish` copies it in | hive-c0re, under the hive's own certificate, when it first connects to the queue | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ❌ read until the first connect succeeds, retried with a backoff of up to five minutes while it's missing; then held until hive-c0re restarts |
|
||||
| `swarm/services/<clientId>/oidc/client` | authelia | that service's own reader, under its own certificate — the queue responder's under `swarm-nats-auth`'s, the homeserver's under `swarm-matrix-ctl`'s, the forge's under `swarm-forge-publisher`'s, the review classifier's under `swarm-review-classifier`'s; the bao UI's by the store's granter | must be stated | must be stated |
|
||||
| `swarm/services/ci-runner/registration-token` | `swarm-controller`, from the forge's admin API, at start and in a five-minute pass; its grant on the path is write-only | `swarm-bao-ci-runner` on the runner's host, under the runner's own certificate, into a file only `swarm-ci-runner-register` in the runner's container loads; no other principal's policy reads it | ❌ the forge hands out one token until an admin resets it; the controller writes a reset token within five minutes | ✅ `swarm-bao-ci-runner` reads it at boot, on deploy and every ten minutes, and restarts the runner when the value changed |
|
||||
| `swarm/services/review-classifier/anthropic-api-key` | the operator, by hand in the store's UI, field `api_key` | `swarm-review-classifier` in its container, under its own certificate, at the start of every run; held for that run and handed to `claude`'s environment only; no other principal's policy reads it | ❌ an operator's key; replaced by writing a new one | ✅ read on every run, so a written or removed key takes effect on the next run |
|
||||
| `swarm/services/review-classifier/forge/token` | `swarm-controller`, with the forge's admin API, for its `review-classifier` account, at start and in a five-minute pass; the secret publisher's policy denies it | `swarm-controller` under its own certificate, before it decides whether to mint, and on every review it posts as the account; the classifier service's policy doesn't read it | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the controller reads the store on every review it posts |
|
||||
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated |
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
|
|
|
|||
|
|
@ -259,8 +259,9 @@ The store's **first reader** is the agent containers' queue credential:
|
|||
`glue-queue-agent-credential.nix` lands it as two files, the client secret and
|
||||
the client id it authenticates, because that's how a queue client reads them —
|
||||
the secret by path, the id by value. No local fallback exists, and none is
|
||||
possible, so absent files mean this hive's agents have no `swarm-logs`, which
|
||||
is what a swarm looks like before the publisher on the authelia host has run.
|
||||
possible, so absent files mean an agent built before `swarm-logs` read its own
|
||||
queue secret has no working `swarm-logs`, which is what a swarm looks like
|
||||
before the publisher on the authelia host has run.
|
||||
The reader runs before `hive-c0re.service`, which wants it (doesn't require
|
||||
it), so an agent container never renders ahead of the credential; an
|
||||
unreachable store delays the daemon's start rather than failing it.
|
||||
|
|
@ -274,8 +275,8 @@ it needs no read access to either — and hands them to
|
|||
`systemd-nspawn --load-credential` through `hive-priv`, which runs as root.
|
||||
Inside, `hive-agent.service` names the same two ids in `LoadCredential=`, which
|
||||
lands them in its own `$CREDENTIALS_DIRECTORY`, owned by the agent user and by
|
||||
nobody else, where the agent's `swarm-logs` reads them. The harness itself
|
||||
doesn't: it connects to the queue with the agent's own credential. `hive-c0re`
|
||||
nobody else, where a `swarm-logs` built before it read the agent's own queue
|
||||
secret reads them. The harness itself doesn't: it connects to the queue with the agent's own credential. `hive-c0re`
|
||||
never reads the bytes at any point: it runs as `hive-core`, which is the reason
|
||||
a copy wasn't an option either. When the files aren't there the daemon forwards
|
||||
nothing and says so in its journal, and the container starts without them.
|
||||
|
|
|
|||
|
|
@ -82,6 +82,13 @@ A hive reads no **swarm service's** OIDC client secret. Every reader of
|
|||
collector and the store forwarder each fetch theirs with a leaf of their own,
|
||||
and the store's granter fetches the bao UI's.
|
||||
|
||||
Two services read **every agent's queue secret**
|
||||
(`swarm/agents/<agent>/queue`), each under a leaf of its own whose policy
|
||||
reaches that one leaf per agent and nothing else: the queue's auth callout
|
||||
(`swarm-nats-auth`) and the log store's agent check (`swarm-logs-auth`, on the
|
||||
log store's host). Either one compromised can present any agent's queue
|
||||
secret, at the queue and at the log store's query route alike.
|
||||
|
||||
### Per-hive credentials live under the hive kind
|
||||
|
||||
`swarm_secret_client::policy` scopes the **hive** stanza narrowly (unlike the
|
||||
|
|
|
|||
|
|
@ -154,6 +154,9 @@
|
|||
services.hyperhive.deploy.swarm-ui.package =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-ui;
|
||||
services.hyperhive.deploy.victorialogs.authPackage =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-logs-auth;
|
||||
services.hyperhive.deploy.nats.authPackage =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth;
|
||||
|
|
|
|||
|
|
@ -7,13 +7,11 @@
|
|||
# and a default inside the binary would be an address we hope points at
|
||||
# something.
|
||||
#
|
||||
# ⚠️ Gated on the queue being configured as well as on the query URL, because
|
||||
# the identity this CLI presents IS the queue's: one per-hive machine client,
|
||||
# one credential pair, delivered once. A swarm with a log store and no agent
|
||||
# credential has nothing for this to authenticate as, and installing a binary
|
||||
# that can only fail would be worse than leaving it off PATH — an agent would
|
||||
# read the 401 as "no logs", which is the exact confusion the store's machine
|
||||
# route was added to end.
|
||||
# ⚠️ Gated on the secret store's address as well as on the query URL, because
|
||||
# the identity this CLI presents is the agent's own queue secret, read from the
|
||||
# store with the certificate ./bao.nix keeps. Without a store there is nothing
|
||||
# to authenticate as, and a binary that can only fail would read its 401 as
|
||||
# "no logs".
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
|
|
@ -22,22 +20,22 @@
|
|||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.agent.logs;
|
||||
queueCfg = config.services.hyperhive.agent.queue;
|
||||
configured = cfg.queryUrl != null && queueCfg.tokenEndpoint != null;
|
||||
baoCfg = config.services.hyperhive.agent.bao;
|
||||
configured = cfg.queryUrl != null && baoCfg.addr != null;
|
||||
|
||||
# The four coordinates `swarm-logs` reads, all-or-none on its side. Bound
|
||||
# here as one attrset so the wrapper below cannot set three of them: a
|
||||
# half-set environment is the failure the binary reports as a deployment
|
||||
# bug, and it should not be reachable from the module that sets it.
|
||||
# The same store coordinates ./queue-identity.nix gives the harness, except
|
||||
# the CA: `%d` expands only inside a unit, so it is spelled as the harness
|
||||
# unit's credentials directory, which the agent user's shells can read.
|
||||
#
|
||||
# 🩸 Two of these are credential **paths**. Neither the id nor the secret
|
||||
# is read here — a value in `--set` lands in the wrapper script, which is
|
||||
# in the world-readable nix store.
|
||||
# 🩸 Paths and a name only. A value in `--set` lands in the wrapper script,
|
||||
# which is in the world-readable nix store.
|
||||
wrapperEnv = {
|
||||
HIVE_AGENT_LOGS_QUERY_URL = cfg.queryUrl;
|
||||
HIVE_AGENT_OIDC_TOKEN_ENDPOINT = queueCfg.tokenEndpoint;
|
||||
HIVE_AGENT_OIDC_CLIENT_ID_FILE = queueCfg.clientIdFile;
|
||||
HIVE_AGENT_OIDC_CLIENT_SECRET_FILE = queueCfg.clientSecretFile;
|
||||
HIVE_AGENT_NAME = config.services.hyperhive.agent.user.name;
|
||||
BAO_ADDR = baoCfg.addr;
|
||||
BAO_CLIENT_CERT = baoCfg.clientCertFile;
|
||||
BAO_CLIENT_KEY = baoCfg.clientKeyFile;
|
||||
BAO_CACERT = "/run/credentials/hive-agent.service/hive-agent-bao-server-ca";
|
||||
};
|
||||
|
||||
# Same shape `swarm-controller.nix` wraps `swarmctl` in — `symlinkJoin` +
|
||||
|
|
@ -71,12 +69,6 @@ in
|
|||
Set by the generated meta flake from the host's own
|
||||
{option}`services.hyperhive.swarm.victorialogs.domain`.
|
||||
|
||||
⚠️ The **whole URL**, not a host to build one from, because this same
|
||||
string is also the audience `swarm-logs` mints its token for. Two
|
||||
spellings of one address present as a valid token refused at the
|
||||
store — the rule `swarm-otel.nix` already states over its own push
|
||||
targets.
|
||||
|
||||
`null` means this hive has not been given the log store's address for
|
||||
its agents, and `swarm-logs` is then not installed at all. Deliberately
|
||||
not defaulted: a guessed address resolves cleanly to the wrong place.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
./glue-forge-oidc-client.nix
|
||||
./glue-grafana-oidc-client.nix
|
||||
./glue-hive-tls-bao-identity.nix
|
||||
./glue-logs-auth-bao-identity.nix
|
||||
./glue-matrix-ctl-bao-identity.nix
|
||||
./glue-matrix-oidc-client.nix
|
||||
./glue-nats-auth-bao-identity.nix
|
||||
|
|
|
|||
|
|
@ -270,6 +270,13 @@ in
|
|||
[ -s ${pkiDir}/nats-auth.pem ] || ${signLeaf} ${pkiDir} nats-auth \
|
||||
${lib.escapeShellArg deployCfg.bao.natsAuthCommonName} "" clientAuth
|
||||
|
||||
# The log store's agent auth responder, which reads agent queue
|
||||
# credentials. Minted here for the matrix-ctl leaf's reason: the log
|
||||
# store is a swarm singleton, so elsewhere this is the file an operator
|
||||
# copies.
|
||||
[ -s ${pkiDir}/logs-auth.pem ] || ${signLeaf} ${pkiDir} logs-auth \
|
||||
${lib.escapeShellArg deployCfg.bao.logsAuthCommonName} "" clientAuth
|
||||
|
||||
# The CI runner's token fetch. Minted here for the matrix-ctl leaf's
|
||||
# reason: the runner is a swarm singleton, so on a runner host without
|
||||
# the store this is the file an operator copies.
|
||||
|
|
|
|||
31
nix/host-modules/glue-logs-auth-bao-identity.nix
Normal file
31
nix/host-modules/glue-logs-auth-bao-identity.nix
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Glue: point the log store's agent auth responder at the bao leaf minted for it.
|
||||
#
|
||||
# ONE PAIRING PER FILE — the swarm-logs-auth principal ← bao, and nothing else.
|
||||
# Deleting this leaves a log store with no responder identity unless the
|
||||
# operator names one, which ./swarm-victorialogs.nix refuses at eval.
|
||||
#
|
||||
# ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the
|
||||
# leaf. What belongs here is the pairing: which paths the responder presents.
|
||||
#
|
||||
# ⚠️ Gated on the store running on this host, the only place the leaf is
|
||||
# minted. A log-store host without the store gets no default here; the
|
||||
# operator places the responder's leaf and names it.
|
||||
#
|
||||
# Everything is `mkDefault`. An operator naming their own paths wins.
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
deployCfg = config.services.hyperhive.deploy;
|
||||
pkiDir = import ./lib/bao-pki-dir.nix;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (deployCfg.victorialogs.enable && deployCfg.bao.enable) {
|
||||
services.hyperhive.deploy.victorialogs = {
|
||||
authBaoClientCertFile = lib.mkDefault "${pkiDir}/logs-auth.pem";
|
||||
authBaoClientKeyFile = lib.mkDefault "${pkiDir}/logs-auth-key.pem";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -271,29 +271,19 @@ in
|
|||
HIVE_AGENT_OIDC_TOKEN_ENDPOINT = config.services.hyperhive.swarm.statusPublish.tokenEndpoint;
|
||||
}
|
||||
//
|
||||
# Where an agent reads the swarm's logs, forwarded the same way and gated on
|
||||
# the same credential: `swarm-logs` authenticates as the queue's own per-hive
|
||||
# machine client, so an address with no credential behind it would put a
|
||||
# binary on PATH that can only ever answer 401 — which an agent reads as "no
|
||||
# logs", the exact confusion the store's machine route was added to end.
|
||||
# Where an agent reads the swarm's logs. Gated on the store identity below,
|
||||
# because `swarm-logs` authenticates with the agent's own queue secret, read
|
||||
# from the store: an address with nothing to authenticate as would put a
|
||||
# binary on PATH that can only ever answer 401, which an agent reads as "no
|
||||
# logs".
|
||||
#
|
||||
# The **machine** route (`^~ /select/logsql/`), not the browser one at `/`:
|
||||
# that one ends in `error_page 401 =302` and hands a machine caller
|
||||
# authelia's login page as a 200 with an HTML body. See
|
||||
# `swarm-victorialogs.nix`'s location for the whole reasoning.
|
||||
#
|
||||
# ⚠️ The full URL rather than the domain, because the same string is also
|
||||
# the audience the token is minted for — `swarm-otel.nix` states that rule
|
||||
# over its own push targets, and two spellings present as a valid token
|
||||
# refused at the store.
|
||||
lib.optionalAttrs
|
||||
(
|
||||
config.services.hyperhive.deploy.hive-controller.queue.agentNatsUrl != null
|
||||
&& config.services.hyperhive.swarm.statusPublish.tokenEndpoint != null
|
||||
)
|
||||
{
|
||||
HIVE_AGENT_LOGS_QUERY_URL = "https://${config.services.hyperhive.swarm.victorialogs.domain}/select/logsql/query";
|
||||
}
|
||||
lib.optionalAttrs haveBaoClientIdentity {
|
||||
HIVE_AGENT_LOGS_QUERY_URL = "https://${config.services.hyperhive.swarm.victorialogs.domain}/select/logsql/query";
|
||||
}
|
||||
//
|
||||
# Where the swarm's secret store is, and the identity this hive presents to
|
||||
# it (hive-c0re::lifecycle::agent_identity). `swarm_secret_client` reads these
|
||||
|
|
|
|||
|
|
@ -964,6 +964,17 @@ let
|
|||
}
|
||||
'';
|
||||
|
||||
# The log store's agent auth responder, which checks the secret an agent
|
||||
# presents against the one stored for it: the same `+` leaf as the queue
|
||||
# responder's, and nothing else.
|
||||
logsAuthReaders = [
|
||||
{
|
||||
name = "swarm-logs-auth";
|
||||
cn = baoDeploy.logsAuthCommonName;
|
||||
policyText = readStanza "${credentialMountPath}/data/swarm/agents/+/queue";
|
||||
}
|
||||
];
|
||||
|
||||
# The CI runner's token fetch. The one reader of the path, which the
|
||||
# controller writes and cannot read back.
|
||||
ciRunnerReaders = [
|
||||
|
|
@ -1797,6 +1808,19 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
logsAuthCommonName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "swarm-logs-auth";
|
||||
description = ''
|
||||
Subject the store's `swarm-logs-auth` cert-auth role accepts: the
|
||||
identity the log store's agent auth responder presents to read agent
|
||||
queue credentials, `swarm/agents/<agent>/queue` and nothing else under
|
||||
an agent.
|
||||
|
||||
⚠️ Reserved as a hive name by ./swarm.nix, like its siblings.
|
||||
'';
|
||||
};
|
||||
|
||||
ciRunnerCommonName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "swarm-ci-runner";
|
||||
|
|
@ -3171,6 +3195,8 @@ in
|
|||
|
||||
systemd.services.swarm-bao-nats-auth-policy = readerPolicyUnit "write the queue responder's agent-credential-reader bao policy and cert-auth role" natsAuthReaders;
|
||||
|
||||
systemd.services.swarm-bao-logs-auth-policy = readerPolicyUnit "write the log store responder's agent-credential-reader bao policy and cert-auth role" logsAuthReaders;
|
||||
|
||||
systemd.services.swarm-bao-ci-runner-policy = readerPolicyUnit "write the CI runner's registration-token-reader bao policy and cert-auth role" ciRunnerReaders;
|
||||
|
||||
systemd.services.swarm-bao-review-classifier-policy = readerPolicyUnit "write the review classifier's API-key-reader bao policy and cert-auth role" reviewClassifierReaders;
|
||||
|
|
@ -3302,6 +3328,7 @@ in
|
|||
"swarm-bao-nats-tls-policy.service"
|
||||
"swarm-bao-hive-leaf-policy.service"
|
||||
"swarm-bao-ci-runner-policy.service"
|
||||
"swarm-bao-logs-auth-policy.service"
|
||||
"swarm-bao-forge-publisher-policy.service"
|
||||
]
|
||||
++ granterAfter;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,22 @@ let
|
|||
error_page 401 =302 https://${hyperhiveCfg.swarm.authelia.domain}/?rd=$target_url;
|
||||
'';
|
||||
|
||||
# The agents' `auth_request` responder (`swarm-logs-auth`). Its socket's
|
||||
# directory holds that socket alone and is traverse-only for others, the
|
||||
# shape `swarm-controller.nix` gives its own.
|
||||
logsAuthUnit = "swarm-logs-auth";
|
||||
logsAuthSocket = "/run/${logsAuthUnit}/auth.sock";
|
||||
logsAuthCredential = id: "/run/credentials/${logsAuthUnit}.service/${id}";
|
||||
# Credential id → host path of the responder's store identity.
|
||||
logsAuthStoreFiles = {
|
||||
"bao-client.pem" = deployCfg.victorialogs.authBaoClientCertFile;
|
||||
"bao-client-key.pem" = deployCfg.victorialogs.authBaoClientKeyFile;
|
||||
}
|
||||
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {
|
||||
"bao-ca.pem" = deployCfg.bao.serverCaFile;
|
||||
};
|
||||
baoCfg = hyperhiveCfg.swarm.bao;
|
||||
|
||||
# Shared host netns, like every sibling swarm container: the collector
|
||||
# and Grafana reach this at 127.0.0.1:<port>.
|
||||
privateNetwork = false;
|
||||
|
|
@ -88,7 +104,74 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
options.services.hyperhive.deploy.victorialogs.authPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-logs-auth";
|
||||
description = ''
|
||||
The `swarm-logs-auth` package: the gateway's `auth_request` responder
|
||||
that checks an agent's `swarm-agent.<agent>.<secret>` bearer on
|
||||
`/select/logsql/` against the secret store.
|
||||
'';
|
||||
};
|
||||
|
||||
options.services.hyperhive.deploy.victorialogs.authBaoClientCertFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/var/lib/swarm-bao-pki/logs-auth.pem";
|
||||
description = ''
|
||||
Client certificate `swarm-logs-auth` presents to the swarm's secret store
|
||||
to read agents' queue secrets. Its subject must be
|
||||
{option}`services.hyperhive.deploy.bao.logsAuthCommonName`.
|
||||
|
||||
Required when `deploy.victorialogs.enable` is set; unset fails at eval.
|
||||
./glue-logs-auth-bao-identity.nix points it at the leaf
|
||||
./glue-bao-tls.nix mints on a host that runs the store; on any other
|
||||
log-store host it is a file the operator places.
|
||||
|
||||
A path, never a value.
|
||||
'';
|
||||
};
|
||||
|
||||
options.services.hyperhive.deploy.victorialogs.authBaoClientKeyFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/var/lib/swarm-bao-pki/logs-auth-key.pem";
|
||||
description = ''
|
||||
Private key for {option}`services.hyperhive.deploy.victorialogs.authBaoClientCertFile`.
|
||||
A path, never a value.
|
||||
'';
|
||||
};
|
||||
|
||||
config = lib.mkIf deployCfg.victorialogs.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion =
|
||||
deployCfg.victorialogs.authBaoClientCertFile != null
|
||||
&& deployCfg.victorialogs.authBaoClientKeyFile != null;
|
||||
message = ''
|
||||
services.hyperhive.deploy.victorialogs.authBaoClientCertFile and
|
||||
authBaoClientKeyFile must both be set.
|
||||
|
||||
swarm-logs-auth reads agents' queue secrets from the swarm's secret
|
||||
store with this certificate. They default to the leaf the store
|
||||
mints where services.hyperhive.deploy.bao.enable is set on this
|
||||
host; elsewhere, issue a leaf whose CN is
|
||||
services.hyperhive.deploy.bao.logsAuthCommonName and name it in
|
||||
both options.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# Which check a `/select/logsql/` request gets. `auth_request` takes a
|
||||
# fixed URI, so it names `/__swarm_logs_auth`, which rewrites to the
|
||||
# target this map picks from the caller's `Authorization`.
|
||||
services.nginx.appendHttpConfig = ''
|
||||
map $http_authorization $swarm_logs_auth_target {
|
||||
"~^Bearer swarm-agent\." /__swarm_logs_agent_auth;
|
||||
default /__hive_authelia;
|
||||
}
|
||||
'';
|
||||
|
||||
# This store publishes its own health as prometheus metrics on the same
|
||||
# listener it serves queries on, so the swarm's collector scrapes it with
|
||||
# no exporter and no extra port — same arrangement as the metrics store.
|
||||
|
|
@ -174,12 +257,35 @@ in
|
|||
# logs. That is the rule in force, not an omission: read
|
||||
# permissions are a later thing, and this location is where one
|
||||
# attaches when it exists.
|
||||
#
|
||||
# Two checks: an agent's own `swarm-agent.` bearer goes to
|
||||
# `swarm-logs-auth`, anything else to authelia (`appendHttpConfig`
|
||||
# above picks).
|
||||
"^~ /select/logsql/" = {
|
||||
# No URI part, so the request path and its query string reach
|
||||
# the store as the caller sent them.
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}";
|
||||
extraConfig = ''
|
||||
auth_request /__hive_authelia;
|
||||
auth_request /__swarm_logs_auth;
|
||||
'';
|
||||
};
|
||||
|
||||
# `rewrite … last` re-matches locations, so the subrequest lands in
|
||||
# one of the two internal locations named by the map.
|
||||
"= /__swarm_logs_auth" = {
|
||||
extraConfig = ''
|
||||
internal;
|
||||
rewrite ^ $swarm_logs_auth_target last;
|
||||
'';
|
||||
};
|
||||
|
||||
# The `Authorization` header rides the subrequest unchanged.
|
||||
"= /__swarm_logs_agent_auth" = {
|
||||
proxyPass = "http://unix:${logsAuthSocket}:/";
|
||||
extraConfig = ''
|
||||
internal;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -207,6 +313,53 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
# The agents' check on `/select/logsql/`. On the host rather than in the
|
||||
# store's container: it needs nothing of the store, only nginx's reach to
|
||||
# its socket and its own store identity.
|
||||
systemd.services.${logsAuthUnit} = {
|
||||
description = "swarm log store auth_request responder for agent credentials";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
# Where the store's PKI is minted on this host, the leaf is one of its
|
||||
# files. Ordering only: elsewhere the unit does not exist.
|
||||
after = [
|
||||
"network.target"
|
||||
"swarm-bao-pki.service"
|
||||
];
|
||||
serviceConfig = {
|
||||
ExecStart = lib.concatStringsSep " " [
|
||||
"${deployCfg.victorialogs.authPackage}/bin/swarm-logs-auth"
|
||||
"--socket ${logsAuthSocket}"
|
||||
# The role ./swarm-bao.nix writes on the store's `cert` mount.
|
||||
"--store-cert-role swarm-logs-auth"
|
||||
];
|
||||
# Paths only: `argv` is world-readable via /proc/<pid>/cmdline.
|
||||
LoadCredential = lib.mapAttrsToList (id: path: "${id}:${path}") logsAuthStoreFiles;
|
||||
DynamicUser = true;
|
||||
RuntimeDirectory = logsAuthUnit;
|
||||
# Traverse-only for others, so nginx reaches the socket path.
|
||||
RuntimeDirectoryMode = "0751";
|
||||
# The path never vanishes from under a running nginx; the responder
|
||||
# unlinks a stale socket as it starts.
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
SyslogIdentifier = logsAuthUnit;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
PrivateDevices = true;
|
||||
NoNewPrivileges = true;
|
||||
};
|
||||
environment = {
|
||||
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
||||
BAO_CLIENT_CERT = logsAuthCredential "bao-client.pem";
|
||||
BAO_CLIENT_KEY = logsAuthCredential "bao-client-key.pem";
|
||||
}
|
||||
// lib.optionalAttrs (logsAuthStoreFiles ? "bao-ca.pem") {
|
||||
BAO_CACERT = logsAuthCredential "bao-ca.pem";
|
||||
};
|
||||
};
|
||||
|
||||
containers.${cfg.machine} = {
|
||||
autoStart = true;
|
||||
ephemeral = false;
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ let
|
|||
deployCfg.bao.granterCommonName
|
||||
deployCfg.bao.natsAuthCommonName
|
||||
deployCfg.bao.ciRunnerCommonName
|
||||
deployCfg.bao.logsAuthCommonName
|
||||
deployCfg.bao.reviewClassifierCommonName
|
||||
deployCfg.bao.forgePublisherCommonName
|
||||
]
|
||||
|
|
|
|||
|
|
@ -40,8 +40,45 @@ let
|
|||
|
||||
agentNoBao = shared.agentBare;
|
||||
|
||||
# `swarm-logs` authenticates with the agent's queue secret from the store, so
|
||||
# the store address gates it beside the query URL.
|
||||
logsUrl = "https://logs.t.local/select/logsql/query";
|
||||
agentLogs = agentWith {
|
||||
services.hyperhive.agent.bao.addr = "https://bao.t.local:8200";
|
||||
services.hyperhive.agent.logs.queryUrl = logsUrl;
|
||||
};
|
||||
agentLogsNoBao = agentWith { services.hyperhive.agent.logs.queryUrl = logsUrl; };
|
||||
swarmLogsIn =
|
||||
machine:
|
||||
lib.findFirst (
|
||||
p: (p.name or "") == "swarm-logs-configured"
|
||||
) null machine.environment.systemPackages;
|
||||
|
||||
agentBaoIdentity = machine: machine.systemd.services.hive-agent-bao-identity;
|
||||
cases = [
|
||||
{
|
||||
# The wrapper names the store identity ./queue-identity.nix gives the
|
||||
# harness, by path, and nothing of the hive's shared OIDC client.
|
||||
name = "swarm-logs is wrapped with the agent's own store identity";
|
||||
ok =
|
||||
let
|
||||
w = (swarmLogsIn agentLogs).buildCommand;
|
||||
a = agentLogs.services.hyperhive.agent;
|
||||
set = k: v: lib.hasInfix "--set ${k} ${lib.escapeShellArg v}" w;
|
||||
in
|
||||
set "HIVE_AGENT_LOGS_QUERY_URL" logsUrl
|
||||
&& set "HIVE_AGENT_NAME" a.user.name
|
||||
&& set "BAO_ADDR" "https://bao.t.local:8200"
|
||||
&& set "BAO_CLIENT_CERT" a.bao.clientCertFile
|
||||
&& set "BAO_CLIENT_KEY" a.bao.clientKeyFile
|
||||
&& set "BAO_CACERT" "/run/credentials/hive-agent.service/hive-agent-bao-server-ca"
|
||||
&& !(lib.hasInfix "OIDC" w);
|
||||
}
|
||||
{
|
||||
# A query URL with nothing to authenticate as installs nothing.
|
||||
name = "an agent with a log store but no secret store gets no swarm-logs";
|
||||
ok = swarmLogsIn agentLogsNoBao == null && swarmLogsIn agentLogs != null;
|
||||
}
|
||||
{
|
||||
# Both ids or neither: the secret authenticates nobody without the id it
|
||||
# belongs to, and `swarm-logs` reads both.
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ let
|
|||
_: u: (u.environment.BAO_CLIENT_CERT or null) == granterCertFile
|
||||
) baoGrantWithConsumers.systemd.services;
|
||||
|
||||
# The sixteen units that write a `swarm-*` grant, by name, for the discovery
|
||||
# The seventeen units that write a `swarm-*` grant, by name, for the discovery
|
||||
# control below.
|
||||
grantingUnitNames = [
|
||||
"swarm-bao-controller-policy"
|
||||
|
|
@ -186,6 +186,7 @@ let
|
|||
"swarm-bao-operator-viewer-policy"
|
||||
"swarm-bao-hive-leaf-policy"
|
||||
"swarm-bao-ci-runner-policy"
|
||||
"swarm-bao-logs-auth-policy"
|
||||
"swarm-bao-forge-publisher-policy"
|
||||
"swarm-bao-review-classifier-policy"
|
||||
];
|
||||
|
|
@ -704,6 +705,31 @@ let
|
|||
in
|
||||
lib.hasInfix "/nats-auth.pem ]" s && lib.hasInfix "swarm-nats-auth \"\" clientAuth" s;
|
||||
}
|
||||
{
|
||||
# The same `+` leaf as the queue responder, read-only, and nothing else:
|
||||
# this principal checks a presented secret and does nothing with it.
|
||||
name = "the log store responder's grant is every agent's queue secret, read-only, and nothing else";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-logs-auth-policy.script;
|
||||
in
|
||||
lib.hasInfix "path \"secret/data/swarm/agents/+/queue\" {\n capabilities = [\"read\"]\n}" s
|
||||
&& lib.length (lib.filter lib.isList (builtins.split "path \"" s)) == 1
|
||||
&& !(lib.hasInfix "secret/data/swarm/agents/*" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/hives" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/services" s)
|
||||
&& lib.hasInfix "auth/cert/certs/swarm-logs-auth" s
|
||||
&& lib.hasInfix "allowed_common_names=swarm-logs-auth" s
|
||||
&& lib.hasInfix "token_policies=swarm-logs-auth" s;
|
||||
}
|
||||
{
|
||||
name = "the PKI unit signs the log store responder's leaf under its own subject";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-pki.script;
|
||||
in
|
||||
lib.hasInfix "/logs-auth.pem ]" s && lib.hasInfix "swarm-logs-auth \"\" clientAuth" s;
|
||||
}
|
||||
{
|
||||
# 🩸 The half that makes the policies above bind: a policy grants only
|
||||
# through a token that carries it, and a token is minted by a cert-auth
|
||||
|
|
@ -915,24 +941,24 @@ let
|
|||
}
|
||||
{
|
||||
# A store host without the granter's pair writes its grants some other
|
||||
# way, so none of the sixteen units may exist. Without this arm
|
||||
# way, so none of the seventeen units may exist. Without this arm
|
||||
# `lib.mkIf haveGranter` could be dropped from any of them and every other
|
||||
# case here would still pass.
|
||||
name = "without the granter's pair none of the sixteen granting units render";
|
||||
name = "without the granter's pair none of the seventeen granting units render";
|
||||
ok =
|
||||
let
|
||||
s = baoGranterOptOut.systemd.services;
|
||||
in
|
||||
lib.all (unit: !(s ? ${unit})) (grantingUnitNames ++ [ "swarm-bao-granter-role" ])
|
||||
# The control: the same store with the pair renders all sixteen.
|
||||
# The control: the same store with the pair renders all seventeen.
|
||||
&& lib.all (unit: baoGrantHere.systemd.services ? ${unit}) grantingUnitNames;
|
||||
}
|
||||
{
|
||||
# 🩸 What replaced the silent skip. With no bootstrap token the sixteen still
|
||||
# 🩸 What replaced the silent skip. With no bootstrap token the seventeen still
|
||||
# render, and a refused granter fails them with the step that fixes it.
|
||||
# A store host that never named a token is told to name one, since the
|
||||
# unit that sets the granter up renders only where it has.
|
||||
name = "a store host without a bootstrap token renders the sixteen, each failing loudly with the one-time step";
|
||||
name = "a store host without a bootstrap token renders the seventeen, each failing loudly with the one-time step";
|
||||
ok =
|
||||
let
|
||||
s = baoGranterNoToken.systemd.services;
|
||||
|
|
@ -1695,8 +1721,8 @@ let
|
|||
}
|
||||
{
|
||||
# What makes the case above mean something: discovery by the granter's
|
||||
# certificate reaches all sixteen units, and each yields calls.
|
||||
name = "the granter-policy check sees all sixteen granting units, and parses calls from each";
|
||||
# certificate reaches all seventeen units, and each yields calls.
|
||||
name = "the granter-policy check sees all seventeen granting units, and parses calls from each";
|
||||
ok =
|
||||
lib.sort lib.lessThan (lib.attrNames granterUnits) == lib.sort lib.lessThan grantingUnitNames
|
||||
&& lib.all (u: baoCalls u.script != [ ]) (lib.attrValues granterUnits)
|
||||
|
|
|
|||
|
|
@ -504,6 +504,44 @@ let
|
|||
&& !(lib.hasInfix "$args" q.extraConfig)
|
||||
&& !(lib.hasInfix "?" q.proxyPass);
|
||||
}
|
||||
{
|
||||
# `auth_request` takes a fixed URI, so the split between the agents'
|
||||
# responder and authelia is a map plus a rewrite in an internal
|
||||
# location. Every link of that chain is pinned: the map keys on the
|
||||
# agent-token prefix and falls back to authelia, the query location
|
||||
# names the switch, the switch rewrites to the map's pick, and the
|
||||
# agent target reaches the responder's socket, which the unit binds.
|
||||
name = "the machine query location sends agent bearers to swarm-logs-auth and the rest to authelia";
|
||||
ok =
|
||||
let
|
||||
l = allLocal.services.nginx.virtualHosts."logs.t.local".locations;
|
||||
http = allLocal.services.nginx.appendHttpConfig;
|
||||
unit = allLocal.systemd.services.swarm-logs-auth;
|
||||
in
|
||||
lib.hasInfix "map \$http_authorization \$swarm_logs_auth_target" http
|
||||
&& lib.hasInfix "\"~^Bearer swarm-agent\\.\" /__swarm_logs_agent_auth;" http
|
||||
&& lib.hasInfix "default /__hive_authelia;" http
|
||||
&& lib.hasInfix "auth_request /__swarm_logs_auth;" l."^~ /select/logsql/".extraConfig
|
||||
&& lib.hasInfix "rewrite ^ \$swarm_logs_auth_target last;" l."= /__swarm_logs_auth".extraConfig
|
||||
&& lib.hasInfix "internal;" l."= /__swarm_logs_agent_auth".extraConfig
|
||||
&& l."= /__swarm_logs_agent_auth".proxyPass == "http://unix:/run/swarm-logs-auth/auth.sock:/"
|
||||
&& lib.hasInfix "--socket /run/swarm-logs-auth/auth.sock" unit.serviceConfig.ExecStart
|
||||
&& l ? "= /__hive_authelia";
|
||||
}
|
||||
{
|
||||
# The responder reads the store as itself, with the leaf the store's
|
||||
# host mints for it.
|
||||
name = "swarm-logs-auth presents its own store leaf";
|
||||
ok =
|
||||
let
|
||||
v = allLocal.services.hyperhive.deploy.victorialogs;
|
||||
lc = allLocal.systemd.services.swarm-logs-auth.serviceConfig.LoadCredential;
|
||||
in
|
||||
lib.hasSuffix "/logs-auth.pem" v.authBaoClientCertFile
|
||||
&& lib.hasSuffix "/logs-auth-key.pem" v.authBaoClientKeyFile
|
||||
&& builtins.elem "bao-client.pem:${v.authBaoClientCertFile}" lc
|
||||
&& builtins.elem "bao-client-key.pem:${v.authBaoClientKeyFile}" lc;
|
||||
}
|
||||
{
|
||||
# The absence arm for the case above, and the option's own rule — a
|
||||
# service declares its entry under its own `enable` — made checkable.
|
||||
|
|
|
|||
|
|
@ -163,6 +163,10 @@ in
|
|||
# rather than every hive's.
|
||||
swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder";
|
||||
|
||||
# The log store gateway's `auth_request` responder for agents. Out of
|
||||
# `daemonBins` like the responder above: it runs on the log store's host only.
|
||||
swarm-logs-auth = mkBinPackage "swarm-logs-auth" "hyperhive swarm log store agent auth responder";
|
||||
|
||||
# The matrix container's control binary; `mint` publishes the sender token. Out of `daemonBins` for
|
||||
# "runs *inside* a container, not on the host" reason as the responder
|
||||
# above, and with a second one: putting it in the core bundle would place
|
||||
|
|
|
|||
18
swarm-agent-verify/Cargo.toml
Normal file
18
swarm-agent-verify/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "swarm-agent-verify"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
# Comparing an agent's presented secret with the stored one.
|
||||
subtle.workspace = true
|
||||
# The agent-token spelling the agent also uses.
|
||||
swarm-queue-client.workspace = true
|
||||
# The read of the stored credential the token is checked against.
|
||||
swarm-secret-client.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
217
swarm-agent-verify/src/lib.rs
Normal file
217
swarm-agent-verify/src/lib.rs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
//! Checking an agent's own credential against the swarm secret store.
|
||||
//!
|
||||
//! The token is `swarm_queue_client::agent_token`'s spelling: a prefix, the
|
||||
//! agent's name, and its secret. The name is only a claim. It becomes an
|
||||
//! identity when the secret equals the one stored at
|
||||
//! `swarm/agents/<agent>/queue`, and [`verify`] grants nothing on the name
|
||||
//! alone: every path that does not reach that comparison, and every lookup
|
||||
//! that fails, is a denial.
|
||||
//!
|
||||
//! Two verifiers use this: the queue's auth callout (`swarm-nats-auth`) and
|
||||
//! the log store's `auth_request` responder (`swarm-logs-auth`). Each reads
|
||||
//! the store under its own certificate.
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use subtle::ConstantTimeEq;
|
||||
use swarm_queue_client::agent_token::AgentToken;
|
||||
use swarm_secret_client::client::{DEFAULT_CERT_MOUNT, Settings};
|
||||
use swarm_secret_client::queue::{self, AgentCredential};
|
||||
|
||||
/// Where the stored credential for an agent comes from.
|
||||
pub trait CredentialSource {
|
||||
/// The object at `agent`'s queue path, `None` when nothing is stored
|
||||
/// there, or an error when the store could not answer.
|
||||
fn lookup(
|
||||
&self,
|
||||
agent: &str,
|
||||
) -> impl Future<Output = anyhow::Result<Option<AgentCredential>>> + Send;
|
||||
}
|
||||
|
||||
/// The swarm secret store, reached with the verifier's own certificate.
|
||||
pub struct Store {
|
||||
settings: Settings,
|
||||
cert_role: String,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// The store at `settings`, logged in to as `cert_role`.
|
||||
#[must_use]
|
||||
pub fn new(settings: Settings, cert_role: String) -> Self {
|
||||
Self {
|
||||
settings,
|
||||
cert_role,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CredentialSource for Store {
|
||||
/// Logs in per lookup, so no token is left to expire inside a long-lived
|
||||
/// process.
|
||||
async fn lookup(&self, agent: &str) -> anyhow::Result<Option<AgentCredential>> {
|
||||
let path = queue::agent_queue_path(agent)?;
|
||||
let store = swarm_secret_client::SecretStore::connect(
|
||||
&self.settings,
|
||||
&self.cert_role,
|
||||
DEFAULT_CERT_MOUNT,
|
||||
)
|
||||
.await
|
||||
.context("logging in to the secret store")?;
|
||||
store
|
||||
.read_optional(&path)
|
||||
.await
|
||||
.with_context(|| format!("reading {path}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `token`'s secret is the one stored for the agent it names.
|
||||
///
|
||||
/// The lookup is bounded by `timeout`, after which this denies.
|
||||
pub async fn verify(
|
||||
source: &impl CredentialSource,
|
||||
token: &AgentToken<'_>,
|
||||
timeout: Duration,
|
||||
) -> bool {
|
||||
let agent = token.agent;
|
||||
let stored = match tokio::time::timeout(timeout, source.lookup(agent)).await {
|
||||
Ok(Ok(Some(stored))) => stored,
|
||||
Ok(Ok(None)) => {
|
||||
tracing::warn!(
|
||||
agent,
|
||||
"no queue credential is stored for this agent; denying"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
agent,
|
||||
error = format!("{e:#}"),
|
||||
"credential lookup failed; denying"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(agent, "credential lookup timed out; denying");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if stored.agent != agent {
|
||||
tracing::warn!(
|
||||
agent,
|
||||
stored_agent = %stored.agent,
|
||||
"the stored credential names a different agent than its path; denying"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Constant time, so the time to refuse says nothing about how much of the
|
||||
// secret was right. A length mismatch returns early; the length is not
|
||||
// secret, every minted secret has the same one.
|
||||
stored
|
||||
.value
|
||||
.as_bytes()
|
||||
.ct_eq(token.secret.as_bytes())
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use swarm_queue_client::agent_token::parse_agent_token;
|
||||
|
||||
/// A store holding at most one credential, or failing outright.
|
||||
enum Fake {
|
||||
/// `credential` is stored at `path_agent`'s queue path.
|
||||
Holds {
|
||||
path_agent: &'static str,
|
||||
credential: AgentCredential,
|
||||
},
|
||||
Fails,
|
||||
/// A store that accepts the request and never answers.
|
||||
Hangs,
|
||||
}
|
||||
|
||||
impl CredentialSource for Fake {
|
||||
async fn lookup(&self, agent: &str) -> anyhow::Result<Option<AgentCredential>> {
|
||||
match self {
|
||||
Self::Holds {
|
||||
path_agent,
|
||||
credential,
|
||||
} => Ok((agent == *path_agent).then(|| credential.clone())),
|
||||
Self::Fails => anyhow::bail!("store unreachable"),
|
||||
Self::Hangs => std::future::pending().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stored(path_agent: &'static str, named: &str) -> Fake {
|
||||
Fake::Holds {
|
||||
path_agent,
|
||||
credential: AgentCredential {
|
||||
value: "Ab9_-zSECRET".to_owned(),
|
||||
agent: named.to_owned(),
|
||||
minted_at: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn atlas() -> Fake {
|
||||
stored("atlas", "atlas")
|
||||
}
|
||||
|
||||
const BOUND: Duration = Duration::from_millis(200);
|
||||
|
||||
async fn verified(source: &Fake, token: &str) -> bool {
|
||||
let token = parse_agent_token(token)
|
||||
.expect("an agent token")
|
||||
.expect("well-formed");
|
||||
verify(source, &token, BOUND).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_stored_secret_is_accepted() {
|
||||
assert!(verified(&atlas(), "swarm-agent.atlas.Ab9_-zSECRET").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wrong_secret_is_denied() {
|
||||
assert!(!verified(&atlas(), "swarm-agent.atlas.Ab9_-zSECREX").await);
|
||||
assert!(!verified(&atlas(), "swarm-agent.atlas.Ab9_-zSECRE").await);
|
||||
}
|
||||
|
||||
/// The secret check is what stops one agent claiming another's name: the
|
||||
/// right secret under someone else's name finds that agent's credential,
|
||||
/// or none.
|
||||
#[tokio::test]
|
||||
async fn another_agents_name_with_this_agents_secret_is_denied() {
|
||||
assert!(!verified(&atlas(), "swarm-agent.argus.Ab9_-zSECRET").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_agent_with_nothing_stored_is_denied() {
|
||||
assert!(!verified(&stored("argus", "argus"), "swarm-agent.atlas.Ab9_-zSECRET").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_lookup_is_denied() {
|
||||
assert!(!verified(&Fake::Fails, "swarm-agent.atlas.Ab9_-zSECRET").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_store_that_never_answers_is_denied_within_the_bound() {
|
||||
let started = std::time::Instant::now();
|
||||
assert!(!verified(&Fake::Hangs, "swarm-agent.atlas.Ab9_-zSECRET").await);
|
||||
let took = started.elapsed();
|
||||
assert!(took >= BOUND, "denied before the bound: {took:?}");
|
||||
assert!(
|
||||
took < BOUND + Duration::from_millis(250),
|
||||
"denied well after the bound: {took:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stored_object_naming_another_agent_is_denied() {
|
||||
assert!(!verified(&stored("argus", "atlas"), "swarm-agent.argus.Ab9_-zSECRET").await);
|
||||
}
|
||||
}
|
||||
23
swarm-logs-auth/Cargo.toml
Normal file
23
swarm-logs-auth/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "swarm-logs-auth"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "swarm-logs-auth"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
swarm-agent-verify.workspace = true
|
||||
# The agent-token spelling, and the rustls provider the store client needs.
|
||||
swarm-queue-client.workspace = true
|
||||
swarm-secret-client.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
160
swarm-logs-auth/src/main.rs
Normal file
160
swarm-logs-auth/src/main.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
//! The log store gateway's `auth_request` responder for agents.
|
||||
//!
|
||||
//! nginx sends it every `/select/logsql/` request whose `Authorization` is
|
||||
//! `Bearer swarm-agent.<agent>.<secret>` (`swarm-victorialogs.nix`). It answers
|
||||
//! 200 when the secret is the one stored at `swarm/agents/<agent>/queue`, and
|
||||
//! 401 otherwise, store failures included: `auth_request` turns any other
|
||||
//! status into a 500 for the caller.
|
||||
//!
|
||||
//! It reads the store under its own certificate, whose policy reaches every
|
||||
//! agent's `queue` secret and nothing else. It serves a unix socket whose
|
||||
//! directory only the gateway's nginx may enter.
|
||||
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode, header};
|
||||
use clap::Parser;
|
||||
use swarm_agent_verify::CredentialSource;
|
||||
use swarm_queue_client::agent_token::parse_agent_token;
|
||||
|
||||
/// How long one store lookup may take before the request is denied. Well
|
||||
/// under nginx's 60s `proxy_read_timeout`, so a slow store reads as a 401
|
||||
/// rather than a gateway timeout.
|
||||
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(about = "auth_request responder: checks an agent's queue secret against the store")]
|
||||
struct Args {
|
||||
/// Unix socket to serve on.
|
||||
#[arg(long)]
|
||||
socket: PathBuf,
|
||||
/// Cert-auth role this responder logs in to the store as.
|
||||
#[arg(long, default_value = "swarm-logs-auth")]
|
||||
store_cert_role: String,
|
||||
}
|
||||
|
||||
/// The answer for one subrequest's `Authorization` header.
|
||||
async fn decide(source: &impl CredentialSource, authorization: Option<&str>) -> StatusCode {
|
||||
let Some(token) = authorization.and_then(|v| v.strip_prefix("Bearer ")) else {
|
||||
tracing::warn!("no bearer token; denying");
|
||||
return StatusCode::UNAUTHORIZED;
|
||||
};
|
||||
let token = match parse_agent_token(token) {
|
||||
Some(Ok(token)) => token,
|
||||
Some(Err(e)) => {
|
||||
tracing::warn!(error = %e, "malformed agent token; denying");
|
||||
return StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("not an agent token; denying");
|
||||
return StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
};
|
||||
if swarm_agent_verify::verify(source, &token, LOOKUP_TIMEOUT).await {
|
||||
tracing::info!(agent = token.agent, "verified");
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::UNAUTHORIZED
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
State(store): State<Arc<swarm_agent_verify::Store>>,
|
||||
headers: HeaderMap,
|
||||
) -> StatusCode {
|
||||
let authorization = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok());
|
||||
decide(store.as_ref(), authorization).await
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
swarm_queue_client::install_crypto_provider();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
// stdout is journald, which keeps ANSI escapes as raw bytes.
|
||||
.with_ansi(false)
|
||||
.init();
|
||||
let args = Args::parse();
|
||||
|
||||
let settings = swarm_secret_client::client::Settings::from_env()
|
||||
.context("reading the secret store's address and this responder's identity")?;
|
||||
let store = Arc::new(swarm_agent_verify::Store::new(
|
||||
settings,
|
||||
args.store_cert_role,
|
||||
));
|
||||
|
||||
// `RuntimeDirectoryPreserve=yes` can leave the previous run's socket; the
|
||||
// directory is this unit's alone, so unlinking it is safe.
|
||||
if let Err(e) = std::fs::remove_file(&args.socket)
|
||||
&& e.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
return Err(e)
|
||||
.with_context(|| format!("clearing stale socket at {}", args.socket.display()));
|
||||
}
|
||||
let listener = tokio::net::UnixListener::bind(&args.socket)
|
||||
.with_context(|| format!("binding {}", args.socket.display()))?;
|
||||
// nginx is another user and `connect(2)` needs write. The directory is
|
||||
// the access control, not this mode.
|
||||
std::fs::set_permissions(&args.socket, std::fs::Permissions::from_mode(0o666))
|
||||
.with_context(|| format!("chmod {}", args.socket.display()))?;
|
||||
tracing::info!(socket = %args.socket.display(), "swarm-logs-auth listening");
|
||||
|
||||
let app = axum::Router::new().fallback(handle).with_state(store);
|
||||
axum::serve(listener, app).await.context("serving")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use swarm_secret_client::queue::AgentCredential;
|
||||
|
||||
/// A store holding atlas's credential.
|
||||
struct Atlas;
|
||||
|
||||
impl CredentialSource for Atlas {
|
||||
async fn lookup(&self, agent: &str) -> anyhow::Result<Option<AgentCredential>> {
|
||||
Ok((agent == "atlas").then(|| AgentCredential {
|
||||
value: "Ab9_-zSECRET".to_owned(),
|
||||
agent: "atlas".to_owned(),
|
||||
minted_at: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_stored_secret_is_200() {
|
||||
assert_eq!(
|
||||
decide(&Atlas, Some("Bearer swarm-agent.atlas.Ab9_-zSECRET")).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn everything_else_is_401() {
|
||||
for authorization in [
|
||||
None,
|
||||
Some("swarm-agent.atlas.Ab9_-zSECRET"),
|
||||
Some("Basic swarm-agent.atlas.Ab9_-zSECRET"),
|
||||
Some("Bearer swarm-agent.atlas.Ab9_-zSECREX"),
|
||||
Some("Bearer swarm-agent.argus.Ab9_-zSECRET"),
|
||||
Some("Bearer swarm-agent.atlas"),
|
||||
Some("Bearer authelia_at_abc.def"),
|
||||
] {
|
||||
assert_eq!(
|
||||
decide(&Atlas, authorization).await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"{authorization:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,13 +12,15 @@ path = "src/main.rs"
|
|||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
clap-markdown = "0.1"
|
||||
# The whole point of the crate's dependency list: minting the access token
|
||||
# is `swarm_queue_client::mint_token_for_blocking`, not a second copy of
|
||||
# the `client_credentials` request. See `src/auth.rs`.
|
||||
# The agent-token spelling the gateway's responder parses, and the rustls
|
||||
# provider the store client needs.
|
||||
swarm-queue-client.workspace = true
|
||||
# Blocking, not async: this binary makes exactly two requests and exits, so
|
||||
# a reactor would exist only to be torn down. It also matches the token
|
||||
# minter reached for above, which is `swarm-queue-client`'s blocking one.
|
||||
# The read of this agent's own queue secret.
|
||||
swarm-secret-client.workspace = true
|
||||
# Only to drive that one async read; the query itself is blocking.
|
||||
tokio.workspace = true
|
||||
# Blocking, not async: this binary makes one request (two on a 401) and
|
||||
# exits, so a reactor would exist only to be torn down.
|
||||
reqwest = { workspace = true, features = ["blocking"] }
|
||||
serde_json.workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -20,40 +20,19 @@ attach at the gateway location, not in this binary.
|
|||
|
||||
## How it authenticates
|
||||
|
||||
The agent container already holds one identity, the per-hive machine client
|
||||
`hive-<name>-agent`, delivered as a systemd credential pair. This binary
|
||||
presents a `client_credentials` access token minted from it as
|
||||
`Authorization: Bearer`.
|
||||
As the agent itself. On each query it reads the agent's own queue secret,
|
||||
`swarm/agents/<agent>/queue`, from the swarm secret store with the agent's
|
||||
store certificate (`nix/agent-modules/bao.nix`), the same read `hive-agent`
|
||||
makes for its queue connection. It sends the secret as
|
||||
`Authorization: Bearer swarm-agent.<agent>.<secret>`, spelled by
|
||||
`swarm_queue_client::agent_token`.
|
||||
|
||||
Minting is **not** implemented here — `swarm_queue_client::mint_token_for_blocking`
|
||||
already owns the request, its error type and its CA handling, and a
|
||||
token-endpoint fix has to be findable in one place. What this crate adds is
|
||||
the agent-shaped half: the client id arrives as a _file_ beside the secret, so
|
||||
nothing outside `nix/agent-modules/queue.nix` spells `hive-<name>-agent`
|
||||
twice. That is the same problem `hive-agent/src/swarm_queue.rs` solves, and
|
||||
`src/auth.rs` is its `decide` restated over this binary's inputs.
|
||||
At the gateway, nginx hands a bearer of that shape to `swarm-logs-auth`, which
|
||||
compares it with the stored secret (`swarm-agent-verify`, the check the queue's
|
||||
auth callout also uses). A 401 makes `swarm-logs` read the secret again and
|
||||
retry once, which covers a secret re-minted between the read and the check.
|
||||
|
||||
🩸 Every credential here is a **path**. The secret's contents are read at the
|
||||
moment of the request, inside `swarm-queue-client`, and are never bound to a
|
||||
name in this crate, logged, or rendered into an error.
|
||||
|
||||
### The audience is the URL
|
||||
|
||||
`swarm-logs` mints its token with the query URL as the requested audience,
|
||||
because that is what authelia checks at the authz endpoint the gateway's
|
||||
`auth_request` calls. The same rule `swarm-otel.nix` states over its own push
|
||||
targets: one binding for the address and the audience, since two spellings of
|
||||
one address present as a valid token refused at the store.
|
||||
|
||||
⚠️ That requires the agent client to be _registered_ for that audience and to
|
||||
hold `authelia.bearer.authz`. Both are set in `swarm-authelia.nix`'s
|
||||
`agentClients`; without them authelia answers `invalid_target` at the token
|
||||
endpoint, or the gateway answers 401 with no explanation.
|
||||
|
||||
⚠️ Registration is not issuance, so the token request _asks_ for both: the
|
||||
audience and the scope are named in the `client_credentials` form, because a
|
||||
client that is registered for a scope it does not request is handed a token
|
||||
carrying none, and the gateway refuses that with the same bare 401.
|
||||
🩸 The secret is never logged or rendered into an error.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
@ -62,19 +41,17 @@ shape `swarmctl` is configured in, and for the same reason: every value is
|
|||
derived from an option that module owns, so a default here would be an address
|
||||
we _hope_ points at something.
|
||||
|
||||
| variable | what |
|
||||
| ------------------------------------ | ---------------------------------------------------- |
|
||||
| `HIVE_AGENT_LOGS_QUERY_URL` | the LogsQL query endpoint; also the token's audience |
|
||||
| `HIVE_AGENT_OIDC_TOKEN_ENDPOINT` | authelia's token endpoint |
|
||||
| `HIVE_AGENT_OIDC_CLIENT_ID_FILE` | path of the delivered client-id credential |
|
||||
| `HIVE_AGENT_OIDC_CLIENT_SECRET_FILE` | path of the delivered secret credential |
|
||||
| `HIVE_AGENT_OIDC_CA_FILE` | optional extra trust anchor; unset in this tree |
|
||||
| variable | what |
|
||||
| --------------------------- | ------------------------------------------------ |
|
||||
| `HIVE_AGENT_LOGS_QUERY_URL` | the LogsQL query endpoint |
|
||||
| `HIVE_AGENT_NAME` | this agent's name; the store path and role |
|
||||
| `BAO_ADDR` | the secret store's address |
|
||||
| `BAO_CLIENT_CERT` | path of the agent's live store certificate |
|
||||
| `BAO_CLIENT_KEY` | path of its key |
|
||||
| `BAO_CACERT` | path of the store's CA; missing or empty is fine |
|
||||
|
||||
`HIVE_AGENT`, the same prefix the harness reads, because it is the same
|
||||
identity — an agent authenticates as its hive's agent client whether the
|
||||
caller is the harness or a CLI the agent typed. The first four are all-or-none:
|
||||
a half-set environment is a deployment bug, and this binary says so by name
|
||||
rather than behaving like an unconfigured one.
|
||||
With no `HIVE_AGENT_LOGS_QUERY_URL` the binary says there is no log store;
|
||||
with the URL and no store address it reports a deployment bug by name.
|
||||
|
||||
## Output
|
||||
|
||||
|
|
|
|||
|
|
@ -1,187 +1,149 @@
|
|||
//! The access token this CLI presents to the log store's gateway route.
|
||||
//! The credential this CLI presents to the log store's gateway route: the
|
||||
//! agent's own queue secret, read from the swarm secret store with the agent's
|
||||
//! store certificate.
|
||||
//!
|
||||
//! Minting it is **not** implemented here. `swarm-queue-client` already owns
|
||||
//! the `client_credentials` request against authelia — the basic-auth form,
|
||||
//! the per-call secret read, the CA-trust builder and the error type that
|
||||
//! surfaces authelia's own `error_description` — and this crate calls
|
||||
//! [`swarm_queue_client::mint_token_for_blocking`] rather than writing a
|
||||
//! second copy of it. A token-endpoint fix has to be findable in one place.
|
||||
//! The read is `hive-agent`'s `swarm_queue` one: the same variables, the same
|
||||
//! path (`swarm/agents/<agent>/queue`) and the same cert-auth role. The secret
|
||||
//! goes out as `Bearer swarm-agent.<agent>.<secret>`, spelled by
|
||||
//! `swarm_queue_client::agent_token`; the gateway's `swarm-logs-auth`
|
||||
//! responder checks it against the store.
|
||||
//!
|
||||
//! What *is* here is the shape of an agent's identity, which differs from the
|
||||
//! daemons that crate was written for in exactly one way: **the client id
|
||||
//! arrives as a file, not as a value.** It rides in the same systemd
|
||||
//! credential pair as the secret so that nothing outside `queue.nix` ever
|
||||
//! spells `hive-<name>-agent` again. That is the same problem
|
||||
//! `hive-agent/src/swarm_queue.rs` solves, and [`Config::from_env`] below is
|
||||
//! that module's `decide` restated over this binary's inputs — including its
|
||||
//! rule that a *half*-set environment is a deployment bug rather than an
|
||||
//! absent integration.
|
||||
//!
|
||||
//! 🩸 Every credential in here is a **path**. The secret's contents are read
|
||||
//! by `swarm-queue-client` at the moment of the request and are never bound to
|
||||
//! a name in this crate, never logged and never rendered into an error.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
//! 🩸 The secret is never logged and never rendered into an error.
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use swarm_queue_client::{ClientSecret, QueueConfig};
|
||||
use swarm_secret_client::client::{DEFAULT_CERT_MOUNT, Settings};
|
||||
use swarm_secret_client::queue::{self, AgentCredential};
|
||||
use swarm_secret_client::{SecretStore, policy};
|
||||
|
||||
/// Variable prefix for this binary's coordinates.
|
||||
///
|
||||
/// `HIVE_AGENT`, deliberately the **same** prefix the harness reads, because
|
||||
/// it is the same identity: an agent authenticates as its hive's agent client
|
||||
/// whether the caller is the harness or a CLI the agent typed. A prefix of its
|
||||
/// own would be a second set of variables carrying one fact, which is the
|
||||
/// disagreement `queue.nix`'s credential pair exists to prevent.
|
||||
const ENV_PREFIX: &str = "HIVE_AGENT";
|
||||
/// The full URL of the LogsQL query endpoint, gateway side.
|
||||
const ENV_QUERY_URL: &str = "HIVE_AGENT_LOGS_QUERY_URL";
|
||||
/// This agent's name, which the store path and the role are built from.
|
||||
const ENV_AGENT_NAME: &str = "HIVE_AGENT_NAME";
|
||||
/// The store's address; the rest of its coordinates are `Settings`'s.
|
||||
const ENV_ADDR: &str = "BAO_ADDR";
|
||||
/// The store's CA bundle, which may name an undelivered credential.
|
||||
const ENV_CACERT: &str = "BAO_CACERT";
|
||||
|
||||
/// Where this CLI queries, and who it queries as.
|
||||
pub struct Config {
|
||||
/// Full URL of the LogsQL query endpoint, gateway side.
|
||||
///
|
||||
/// The whole URL rather than a host to build one from, because this same
|
||||
/// string is **also the audience** the token is minted for — the rule
|
||||
/// `swarm-otel.nix` states over its own push targets ("one binding
|
||||
/// because the same string is both the address requested and the audience
|
||||
/// the token is minted for — two spellings present as a valid token
|
||||
/// refused at the store"). Splitting it would reintroduce exactly that.
|
||||
pub query_url: String,
|
||||
/// Everything needed to mint a token, in the form the minter wants it.
|
||||
pub queue: QueueConfig,
|
||||
agent: String,
|
||||
settings: Settings,
|
||||
role: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Read the configuration out of the environment the wrapper sets.
|
||||
///
|
||||
/// All four coordinates or none, for the reason `swarm-queue-client`'s
|
||||
/// README gives and `hive-agent`'s `swarm_queue` repeats: a half-set
|
||||
/// environment produces a process that looks configured and never
|
||||
/// authenticates. Unlike the harness, this binary *errors* on the absent
|
||||
/// case rather than degrading — a CLI whose only
|
||||
/// job is the query has nothing left to do without it, and an operator
|
||||
/// running it wants to be told why rather than handed an empty result
|
||||
/// they will read as "no logs matched".
|
||||
/// Errors rather than degrading: a CLI whose only job is the query has
|
||||
/// nothing to do without it, and an empty result would read as "no logs
|
||||
/// matched".
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let var = |suffix: &str| std::env::var(format!("{ENV_PREFIX}_{suffix}")).ok();
|
||||
Self::from_lookup(|k| std::env::var(k).ok())
|
||||
}
|
||||
|
||||
let query_url = var("LOGS_QUERY_URL");
|
||||
let token_endpoint = var("OIDC_TOKEN_ENDPOINT");
|
||||
let client_id_file = var("OIDC_CLIENT_ID_FILE");
|
||||
let client_secret_file = var("OIDC_CLIENT_SECRET_FILE");
|
||||
|
||||
// Outside the all-or-none group on purpose: a swarm behind a
|
||||
// publicly-trusted certificate needs no extra anchor, so a CA path
|
||||
// with no endpoint is meaningless rather than half-configured. Nothing in this tree sets
|
||||
// it for an agent — a container already trusts the store's root, which
|
||||
// `hive_c0re::meta` embeds at build time.
|
||||
let ca_file = var("OIDC_CA_FILE").map(PathBuf::from);
|
||||
|
||||
let (query_url, token_endpoint, client_id_file, client_secret_file) = match (
|
||||
query_url,
|
||||
token_endpoint,
|
||||
client_id_file,
|
||||
client_secret_file,
|
||||
) {
|
||||
(Some(u), Some(t), Some(i), Some(s)) => (u, t, i, s),
|
||||
(None, None, None, None) => bail!(
|
||||
"this agent has no swarm log store configured: \
|
||||
{ENV_PREFIX}_LOGS_QUERY_URL, {ENV_PREFIX}_OIDC_TOKEN_ENDPOINT, \
|
||||
{ENV_PREFIX}_OIDC_CLIENT_ID_FILE and \
|
||||
{ENV_PREFIX}_OIDC_CLIENT_SECRET_FILE are all unset"
|
||||
),
|
||||
_ => bail!(
|
||||
"swarm log store half-configured: {ENV_PREFIX}_LOGS_QUERY_URL, \
|
||||
{ENV_PREFIX}_OIDC_TOKEN_ENDPOINT, {ENV_PREFIX}_OIDC_CLIENT_ID_FILE \
|
||||
and {ENV_PREFIX}_OIDC_CLIENT_SECRET_FILE are set together or not \
|
||||
at all — this is a deployment bug, not a missing feature"
|
||||
),
|
||||
fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
|
||||
let set = |k: &str| get(k).filter(|v| !v.is_empty());
|
||||
let Some(query_url) = set(ENV_QUERY_URL) else {
|
||||
bail!("this agent has no swarm log store configured: {ENV_QUERY_URL} is unset");
|
||||
};
|
||||
|
||||
let client_id = read_client_id(Path::new(&client_id_file))?;
|
||||
|
||||
if set(ENV_ADDR).is_none() {
|
||||
bail!(
|
||||
"{ENV_QUERY_URL} is set but {ENV_ADDR} is not, so there is no secret store \
|
||||
to read this agent's credential from — this is a deployment bug"
|
||||
);
|
||||
}
|
||||
let agent =
|
||||
set(ENV_AGENT_NAME).with_context(|| format!("{ENV_AGENT_NAME} is unset or empty"))?;
|
||||
let settings =
|
||||
Settings::from_lookup(|k| set(k).filter(|v| k != ENV_CACERT || ca_is_usable(v)))
|
||||
.context("reading the swarm secret store's coordinates from the environment")?;
|
||||
Ok(Self {
|
||||
query_url,
|
||||
queue: QueueConfig {
|
||||
// 🩸 Unused by the token mint and deliberately not read from
|
||||
// the environment. `QueueConfig` is the queue's connect
|
||||
// config as well as its token config, and this binary needs
|
||||
// only the second half — taking `HIVE_AGENT_NATS_URL` for it
|
||||
// would make a broker address a prerequisite for reading
|
||||
// logs, which is a coupling with no cause. Named so a reader
|
||||
// of a stack trace is not left wondering.
|
||||
url: String::from("unused: swarm-logs mints a token and never connects"),
|
||||
token_endpoint,
|
||||
client_id,
|
||||
client_secret: ClientSecret::File(PathBuf::from(client_secret_file)),
|
||||
ca_file,
|
||||
},
|
||||
role: policy::agent_object_name(&agent)?,
|
||||
path: queue::agent_queue_path(&agent)?,
|
||||
agent,
|
||||
settings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read this agent's secret from the store and spell it as the bearer.
|
||||
///
|
||||
/// Read on every call, so a caller that was refused can call again and
|
||||
/// present a secret re-minted since.
|
||||
pub fn bearer(&self) -> Result<String> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("starting the runtime for the secret store read")?;
|
||||
let stored: Option<AgentCredential> = runtime.block_on(async {
|
||||
SecretStore::connect(&self.settings, &self.role, DEFAULT_CERT_MOUNT)
|
||||
.await
|
||||
.context("logging in to the swarm secret store with this agent's certificate")?
|
||||
.read_optional(&self.path)
|
||||
.await
|
||||
.with_context(|| format!("reading {}", self.path))
|
||||
})?;
|
||||
let stored = stored.with_context(|| format!("nothing is stored at {}", self.path))?;
|
||||
swarm_queue_client::agent_token::format_agent_token(&self.agent, &stored.value)
|
||||
.with_context(|| format!("spelling the credential stored at {}", self.path))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the OIDC client id out of the file the systemd credential landed at.
|
||||
///
|
||||
/// Trailing newline stripped and an empty file refused, both copied from
|
||||
/// `hive-agent`'s `read_client_id`: the writing unit ends the file with a
|
||||
/// newline, and an id carrying one authenticates as nobody. Where the harness
|
||||
/// treats absence as the ordinary state of a hive whose credential has not
|
||||
/// been published yet and carries on, this errors — see [`Config::from_env`]
|
||||
/// for why a CLI has nothing to carry on with.
|
||||
fn read_client_id(path: &Path) -> Result<String> {
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("reading the OIDC client id from {}", path.display()))?;
|
||||
let id = raw.trim();
|
||||
if id.is_empty() {
|
||||
bail!(
|
||||
"the OIDC client id at {} is empty — the queue credential has not \
|
||||
been published to this hive yet",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Ok(id.to_owned())
|
||||
/// Whether the CA bundle at `path` is a file with bytes in it. `BAO_CACERT`
|
||||
/// names a systemd credential that may not have been delivered; absent means
|
||||
/// the container's own trust store.
|
||||
fn ca_is_usable(path: &str) -> bool {
|
||||
std::fs::metadata(path).is_ok_and(|m| m.len() > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn client_id_loses_its_trailing_newline() {
|
||||
let dir = std::env::temp_dir().join(format!("swarm-logs-id-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let path = dir.join("client-id");
|
||||
std::fs::write(&path, "hive-alpha-agent\n").expect("write");
|
||||
assert_eq!(read_client_id(&path).expect("read"), "hive-alpha-agent");
|
||||
std::fs::remove_dir_all(&dir).expect("cleanup");
|
||||
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||
let pairs: Vec<(String, String)> = pairs
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
|
||||
.collect();
|
||||
move |k| pairs.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
/// An empty credential file is the shape a hive has before its secret
|
||||
/// store has published anything, and it must not become a token request
|
||||
/// that authenticates as the empty string.
|
||||
const FULL: &[(&str, &str)] = &[
|
||||
(ENV_QUERY_URL, "https://logs.example/select/logsql/query"),
|
||||
(ENV_AGENT_NAME, "atlas"),
|
||||
(ENV_ADDR, "https://bao.example:8200"),
|
||||
("BAO_CLIENT_CERT", "/nonexistent/cert"),
|
||||
("BAO_CLIENT_KEY", "/nonexistent/key"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn an_empty_client_id_is_refused_by_name() {
|
||||
let dir = std::env::temp_dir().join(format!("swarm-logs-empty-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let path = dir.join("client-id");
|
||||
std::fs::write(&path, "\n").expect("write");
|
||||
let err = read_client_id(&path).expect_err("empty id must not be accepted");
|
||||
assert!(
|
||||
err.to_string().contains("is empty"),
|
||||
"error should say the id is empty, got: {err}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).expect("cleanup");
|
||||
fn no_query_url_says_there_is_no_log_store() {
|
||||
let err = Config::from_lookup(env(&[])).err().expect("refused");
|
||||
assert!(err.to_string().contains("no swarm log store"), "{err}");
|
||||
}
|
||||
|
||||
/// A missing file names the path, because the operator's next move is to
|
||||
/// look at whether the credential was delivered at all.
|
||||
#[test]
|
||||
fn a_missing_client_id_file_names_its_path() {
|
||||
let path = Path::new("/nonexistent/swarm-logs/client-id");
|
||||
let err = read_client_id(path).expect_err("missing file must not be accepted");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("/nonexistent/swarm-logs/client-id"),
|
||||
"error should name the path, got: {err}"
|
||||
);
|
||||
fn a_query_url_without_a_store_is_a_deployment_bug() {
|
||||
let err = Config::from_lookup(env(&FULL[..2])).err().expect("refused");
|
||||
assert!(err.to_string().contains("deployment bug"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_agent_name_is_named() {
|
||||
let mut pairs = FULL.to_vec();
|
||||
pairs.retain(|(k, _)| *k != ENV_AGENT_NAME);
|
||||
let err = Config::from_lookup(env(&pairs)).err().expect("refused");
|
||||
assert!(err.to_string().contains(ENV_AGENT_NAME), "{err}");
|
||||
}
|
||||
|
||||
/// The path and role are the agent's own, the ones its policy grants.
|
||||
#[test]
|
||||
fn the_read_is_the_agents_own_queue_secret() {
|
||||
let cfg = Config::from_lookup(env(FULL)).expect("configured");
|
||||
assert_eq!(cfg.path, "swarm/agents/atlas/queue");
|
||||
assert_eq!(cfg.role, policy::agent_object_name("atlas").expect("valid"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ fn main() -> Result<()> {
|
|||
// keep working on a machine that has no log store wired up at
|
||||
// all — `markdown-docs` in particular runs in a nix check, where
|
||||
// no credential exists and none should.
|
||||
swarm_queue_client::install_crypto_provider();
|
||||
let cfg = auth::Config::from_env()?;
|
||||
let stdout = std::io::stdout();
|
||||
query::run(&cfg, &logsql, limit, format, &mut stdout.lock())
|
||||
|
|
|
|||
|
|
@ -46,41 +46,29 @@ pub fn run(
|
|||
format: Format,
|
||||
out: &mut impl Write,
|
||||
) -> Result<()> {
|
||||
// Minted per invocation, with the query URL as the audience and the
|
||||
// `authelia.bearer.authz` scope. All three are load-bearing: authelia
|
||||
// refuses a token carrying no audience at the authz endpoint the
|
||||
// gateway's `auth_request` calls, and the audience it checks is the URL
|
||||
// being requested — so the string sent here and the string requested
|
||||
// below must be one binding, which is why `Config` holds exactly one.
|
||||
//
|
||||
// The scope has to be ASKED for, not merely registered: the agent client
|
||||
// is granted `authelia.bearer.authz` by the `agentClients` entry in
|
||||
// `swarm-authelia.nix`, but an OAuth2 server issues no scope the client
|
||||
// never requested, and a scopeless token is refused at the authz endpoint
|
||||
// exactly as an unauthenticated one is — a bare nginx 401 with nothing in
|
||||
// it that names the scope. `swarm-otel.nix` records the same failure
|
||||
// against the collector's client, on the same string.
|
||||
let token = swarm_queue_client::mint_token_for_blocking(
|
||||
&cfg.queue,
|
||||
Some(&cfg.query_url),
|
||||
Some("authelia.bearer.authz"),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("{}", swarm_queue_client::chain(&e)))
|
||||
.context("minting an access token for the swarm log store")?;
|
||||
|
||||
let http = build_http_client(cfg)?;
|
||||
let http = build_http_client()?;
|
||||
|
||||
let mut form = vec![("query", logsql.to_owned())];
|
||||
if let Some(limit) = limit {
|
||||
form.push(("limit", limit.to_string()));
|
||||
}
|
||||
let send = || -> Result<reqwest::blocking::Response> {
|
||||
let bearer = cfg
|
||||
.bearer()
|
||||
.context("reading this agent's credential for the swarm log store")?;
|
||||
http.post(&cfg.query_url)
|
||||
.bearer_auth(bearer)
|
||||
.form(&form)
|
||||
.send()
|
||||
.with_context(|| format!("querying the swarm log store at {}", cfg.query_url))
|
||||
};
|
||||
|
||||
let response = http
|
||||
.post(&cfg.query_url)
|
||||
.bearer_auth(&token)
|
||||
.form(&form)
|
||||
.send()
|
||||
.with_context(|| format!("querying the swarm log store at {}", cfg.query_url))?;
|
||||
let mut response = send()?;
|
||||
// A 401 can be a secret re-minted between the store read and the check,
|
||||
// so it is read again and the query sent once more.
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
response = send()?;
|
||||
}
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
|
|
@ -140,37 +128,13 @@ fn render(line: &str, format: Format) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// The HTTP client for the query itself.
|
||||
/// The HTTP client for the query itself, trusting the container's own roots.
|
||||
///
|
||||
/// Separate from the one `swarm-queue-client` builds for the token endpoint
|
||||
/// because they are two hosts — `auth.<swarm>` and `logs.<swarm>` — and the
|
||||
/// CA-trust knob is declared per endpoint. Same optional anchor, same reason:
|
||||
/// without it a swarm using its own CA fails at TLS with
|
||||
/// `invalid peer certificate: UnknownIssuer`, having never looked at the
|
||||
/// anchor sitting on the host.
|
||||
///
|
||||
/// No timeout, deliberately, where the token client has one: a query over a
|
||||
/// long retention window legitimately takes longer than any number that would
|
||||
/// be right for a token request, and a client-side cut would truncate a
|
||||
/// correct answer into a shorter one with no marker saying so.
|
||||
fn build_http_client(cfg: &Config) -> Result<reqwest::blocking::Client> {
|
||||
let mut builder = reqwest::blocking::Client::builder();
|
||||
if let Some(path) = &cfg.queue.ca_file {
|
||||
let pem = std::fs::read(path).with_context(|| {
|
||||
format!(
|
||||
"reading the log-store CA certificate from {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let cert = reqwest::Certificate::from_pem(&pem).with_context(|| {
|
||||
format!(
|
||||
"parsing the log-store CA certificate from {} as PEM",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
builder = builder.add_root_certificate(cert);
|
||||
}
|
||||
builder
|
||||
/// No timeout, deliberately: a query over a long retention window legitimately
|
||||
/// takes long, and a client-side cut would truncate a correct answer into a
|
||||
/// shorter one with no marker saying so.
|
||||
fn build_http_client() -> Result<reqwest::blocking::Client> {
|
||||
reqwest::blocking::Client::builder()
|
||||
.build()
|
||||
.context("building the log-store HTTP client")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,17 +23,16 @@ serde.workspace = true
|
|||
serde_json.workspace = true
|
||||
# The jti digest: base32hex(sha256(claims)) over every JWT this crate signs.
|
||||
sha2.workspace = true
|
||||
# Comparing an agent's presented secret with the stored one.
|
||||
subtle.workspace = true
|
||||
# For `status::BUCKET` and `notices::STREAM` - the subjects a hive may
|
||||
# publish to are derived from these names, and every end that touches them
|
||||
# must agree on the same one. Deliberately WITHOUT the `kv` feature: this
|
||||
# crate derives subject strings, it never opens the bucket. `notices`
|
||||
# is name-only too (no `jetstream`/`kv` surface), same reason.
|
||||
swarm-queue-client = { workspace = true, features = ["notices"] }
|
||||
# The agent-token spelling the agent also uses, and the read of the stored
|
||||
# credential it is checked against.
|
||||
# The store reads: the callout keys, its own OIDC secret, and the agent
|
||||
# credentials `swarm-agent-verify` checks a presented token against.
|
||||
swarm-secret-client.workspace = true
|
||||
swarm-agent-verify.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,22 +1,15 @@
|
|||
//! Verifying an agent's own credential, presented as `auth_token`.
|
||||
//! Classifying a presented `auth_token`, and the grant for an agent's own.
|
||||
//!
|
||||
//! The spelling is `swarm_queue_client::agent_token`'s, shared with the agent
|
||||
//! that presents it: a prefix, the agent's name, and its secret. The name is only a
|
||||
//! claim. It becomes an identity when the secret equals the one stored at
|
||||
//! `swarm/agents/<agent>/queue`, and nothing in this module grants on the name
|
||||
//! alone: every path that does not reach that comparison, and every lookup
|
||||
//! that fails, is a denial.
|
||||
//! that presents it. Whether its secret is the stored one is
|
||||
//! [`swarm_agent_verify::verify`]'s answer; nothing here grants on the name
|
||||
//! alone.
|
||||
//!
|
||||
//! A token without the prefix is not this module's: it goes to introspection
|
||||
//! unchanged.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use anyhow::Context;
|
||||
use subtle::ConstantTimeEq;
|
||||
use swarm_agent_verify::CredentialSource;
|
||||
use swarm_queue_client::agent_token::{AgentToken, Malformed, parse_agent_token};
|
||||
use swarm_secret_client::client::{DEFAULT_CERT_MOUNT, Settings};
|
||||
use swarm_secret_client::queue::{self, AgentCredential};
|
||||
|
||||
use crate::policy::{Permissions, Policy};
|
||||
|
||||
|
|
@ -41,112 +34,18 @@ pub fn classify(token: &str) -> Presented<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Where the stored credential for an agent comes from.
|
||||
pub trait CredentialSource {
|
||||
/// The object at `agent`'s queue path, `None` when nothing is stored
|
||||
/// there, or an error when the store could not answer.
|
||||
fn lookup(
|
||||
&self,
|
||||
agent: &str,
|
||||
) -> impl Future<Output = anyhow::Result<Option<AgentCredential>>> + Send;
|
||||
}
|
||||
|
||||
/// The swarm secret store, reached with this responder's own certificate.
|
||||
pub struct Store {
|
||||
settings: Settings,
|
||||
cert_role: String,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// The store at `settings`, logged in to as `cert_role`.
|
||||
pub fn new(settings: Settings, cert_role: String) -> Self {
|
||||
Self {
|
||||
settings,
|
||||
cert_role,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CredentialSource for Store {
|
||||
/// Logs in per lookup. An agent connects once per boot and per reconnect,
|
||||
/// so a login each time costs little, and it leaves no token to expire
|
||||
/// inside a long-lived process.
|
||||
async fn lookup(&self, agent: &str) -> anyhow::Result<Option<AgentCredential>> {
|
||||
let path = queue::agent_queue_path(agent)?;
|
||||
let store = swarm_secret_client::SecretStore::connect(
|
||||
&self.settings,
|
||||
&self.cert_role,
|
||||
DEFAULT_CERT_MOUNT,
|
||||
)
|
||||
.await
|
||||
.context("logging in to the secret store")?;
|
||||
store
|
||||
.read_optional(&path)
|
||||
.await
|
||||
.with_context(|| format!("reading {path}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `token`'s secret is the one stored for the agent it names.
|
||||
/// The grant for an agent token, or `None` for a denial.
|
||||
///
|
||||
/// The lookup is bounded by introspection's budget, under the server's
|
||||
/// The store lookup is bounded by introspection's budget, under the server's
|
||||
/// `authorization.timeout`. The callout loop answers one request at a time, so
|
||||
/// a store that does not answer holds every request behind it, OIDC ones
|
||||
/// included, for at most that long, and this then denies.
|
||||
async fn verify(source: &impl CredentialSource, token: &AgentToken<'_>) -> bool {
|
||||
let agent = token.agent;
|
||||
let stored = match tokio::time::timeout(
|
||||
crate::introspect::INTROSPECTION_TIMEOUT,
|
||||
source.lookup(agent),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(stored))) => stored,
|
||||
Ok(Ok(None)) => {
|
||||
tracing::warn!(
|
||||
agent,
|
||||
"no queue credential is stored for this agent; denying"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
agent,
|
||||
error = format!("{e:#}"),
|
||||
"credential lookup failed; denying"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(agent, "credential lookup timed out; denying");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if stored.agent != agent {
|
||||
tracing::warn!(
|
||||
agent,
|
||||
stored_agent = %stored.agent,
|
||||
"the stored credential names a different agent than its path; denying"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Constant time, so the time to refuse says nothing about how much of the
|
||||
// secret was right. A length mismatch returns early; the length is not
|
||||
// secret, every minted secret has the same one.
|
||||
stored
|
||||
.value
|
||||
.as_bytes()
|
||||
.ct_eq(token.secret.as_bytes())
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The grant for an agent token, or `None` for a denial.
|
||||
pub async fn authorize(
|
||||
policy: &Policy,
|
||||
source: &impl CredentialSource,
|
||||
token: &AgentToken<'_>,
|
||||
) -> Option<Permissions> {
|
||||
if !verify(source, token).await {
|
||||
if !swarm_agent_verify::verify(source, token, crate::introspect::INTROSPECTION_TIMEOUT).await {
|
||||
return None;
|
||||
}
|
||||
let permissions = policy.agent_token_permissions(token.agent);
|
||||
|
|
@ -162,47 +61,28 @@ pub async fn authorize(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use swarm_secret_client::queue::AgentCredential;
|
||||
|
||||
/// A store holding at most one credential, or failing outright.
|
||||
/// A store holding atlas's credential, or one that never answers.
|
||||
/// The store's own denials are `swarm_agent_verify`'s tests.
|
||||
enum Fake {
|
||||
/// `credential` is stored at `path_agent`'s queue path.
|
||||
Holds {
|
||||
path_agent: &'static str,
|
||||
credential: AgentCredential,
|
||||
},
|
||||
Fails,
|
||||
/// A store that accepts the request and never answers.
|
||||
Atlas,
|
||||
Hangs,
|
||||
}
|
||||
|
||||
impl CredentialSource for Fake {
|
||||
async fn lookup(&self, agent: &str) -> anyhow::Result<Option<AgentCredential>> {
|
||||
match self {
|
||||
Self::Holds {
|
||||
path_agent,
|
||||
credential,
|
||||
} => Ok((agent == *path_agent).then(|| credential.clone())),
|
||||
Self::Fails => anyhow::bail!("store unreachable"),
|
||||
Self::Atlas => Ok((agent == "atlas").then(|| AgentCredential {
|
||||
value: "Ab9_-zSECRET".to_owned(),
|
||||
agent: "atlas".to_owned(),
|
||||
minted_at: None,
|
||||
})),
|
||||
Self::Hangs => std::future::pending().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stored(path_agent: &'static str, named: &str) -> Fake {
|
||||
Fake::Holds {
|
||||
path_agent,
|
||||
credential: AgentCredential {
|
||||
value: "Ab9_-zSECRET".to_owned(),
|
||||
agent: named.to_owned(),
|
||||
minted_at: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn atlas() -> Fake {
|
||||
stored("atlas", "atlas")
|
||||
}
|
||||
|
||||
fn policy() -> Policy {
|
||||
Policy::new(
|
||||
"hive-".to_owned(),
|
||||
|
|
@ -229,7 +109,7 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn a_valid_agent_token_is_granted_exactly_its_own_subjects() {
|
||||
let g = grant(&atlas(), "swarm-agent.atlas.Ab9_-zSECRET")
|
||||
let g = grant(&Fake::Atlas, "swarm-agent.atlas.Ab9_-zSECRET")
|
||||
.await
|
||||
.expect("granted");
|
||||
assert_eq!(
|
||||
|
|
@ -245,42 +125,7 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn a_wrong_secret_is_denied() {
|
||||
assert!(
|
||||
grant(&atlas(), "swarm-agent.atlas.Ab9_-zSECREX")
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
grant(&atlas(), "swarm-agent.atlas.Ab9_-zSECRE")
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// The secret check is what stops one agent claiming another's name: the
|
||||
/// right secret under someone else's name finds that agent's credential,
|
||||
/// or none.
|
||||
#[tokio::test]
|
||||
async fn another_agents_name_with_this_agents_secret_is_denied() {
|
||||
assert!(
|
||||
grant(&atlas(), "swarm-agent.argus.Ab9_-zSECRET")
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_agent_with_nothing_stored_is_denied() {
|
||||
assert!(
|
||||
grant(&stored("argus", "argus"), "swarm-agent.atlas.Ab9_-zSECRET")
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_lookup_is_denied() {
|
||||
assert!(
|
||||
grant(&Fake::Fails, "swarm-agent.atlas.Ab9_-zSECRET")
|
||||
grant(&Fake::Atlas, "swarm-agent.atlas.Ab9_-zSECREX")
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
|
|
@ -305,15 +150,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stored_object_naming_another_agent_is_denied() {
|
||||
assert!(
|
||||
grant(&stored("argus", "atlas"), "swarm-agent.argus.Ab9_-zSECRET")
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_verified_agent_with_no_subjects_configured_is_denied() {
|
||||
let bare = Policy::new(
|
||||
|
|
@ -327,7 +163,7 @@ mod tests {
|
|||
let Presented::Agent(token) = classify("swarm-agent.atlas.Ab9_-zSECRET") else {
|
||||
panic!("an agent token");
|
||||
};
|
||||
assert!(authorize(&bare, &atlas(), &token).await.is_none());
|
||||
assert!(authorize(&bare, &Fake::Atlas, &token).await.is_none());
|
||||
}
|
||||
|
||||
/// Everything without the prefix reaches introspection as it arrived.
|
||||
|
|
|
|||
|
|
@ -242,7 +242,7 @@ async fn serve(args: &Args) -> anyhow::Result<()> {
|
|||
.context("logging in to the secret store for the callout keys and the OIDC client secret")?;
|
||||
let (callout_xkey, issuer, user_seed) = load_callout_keys(&keys).await?;
|
||||
let client_secret = load_client_secret(&keys, args).await?;
|
||||
let store = agent_token::Store::new(settings, args.store_cert_role.clone());
|
||||
let store = swarm_agent_verify::Store::new(settings, args.store_cert_role.clone());
|
||||
let client = async_nats::ConnectOptions::with_nkey(user_seed)
|
||||
.name("swarm-nats-auth")
|
||||
.connect(&args.nats_url)
|
||||
|
|
|
|||
Loading…
Reference in a new issue