Replaces the inline-Python rotator approach before it grows a tenth copy. No CronJob uses it yet — see the open question at the end.
Why replace inline Python
The fleet currently carries ~130 lines of Python embedded in YAML strings across 9 manifests:
Duplicated
In
auth/kubernetes/login + SA-token read
9 of 9 manifests
def call() helper, X-Vault-Token, merge-patch
4
lint / type-check / tests in CI
none
Nine copies of the same OpenBao login isn't a pattern, it's copy-paste. The trailing-newline bug that crash-looped external-dns is exactly what one shared, tested write-helper prevents once instead of nine times.
Home Assistant would have been the worst case: a hand-rolled WebSocket handshake with frame masking, inside a YAML string, unlintable and untestable. HA mints long-lived access tokens only over its WebSocket API — verified against 2026.7.2, both auth/long_lived_access_token and auth/delete_refresh_token exist and the REST API has no equivalent.
FROM scratch
image: 4.85 MB (3.2 MB binary + metadata)
binary: static-pie linked, stripped
/bin/sh: absent — exec into it fails at runc
One file. No libc, no shell, no package manager — no base-image CVE stream to patch, and nothing for a compromised rotator to exec. rustls + webpki-roots compiles the trust store into the binary, so TLS needs no CA bundle on disk. In-cluster traffic is plain HTTP, but that keeps the door open for this binary to take over the Cloudflare and Proxmox rotators too.
Ordering is load-bearing
ha-token is make-before-break, and the sequence must not be reshuffled:
list existing token ids for the client_name — before minting, so the new token can never land in the revocation set
mint the replacement
write it to OpenBao
only then revoke the old ones
If step 3 fails, the old token is still valid and still stored, so the run is a no-op rather than an outage. Revoking before writing would strand the consumer with a dead credential.
A failed revoke is deliberately non-fatal but logged loudly — the new token is already live, and a token meant to be retired that is still valid is a real finding, not noise.
Verified
cargo test passes (path-splitting unit tests)
static-pie linked, stripped; image contains only the binary
exits non-zero on usage error
no secret value is ever logged
pipeline path trigger now includes secret-rotator/** — without it, source changes would silently not rebuild the image
Open question — network, not tooling
No CronJob ships here because the Home Assistant rotator can't reach HA from the openbao namespace:
home.renner.uno:443 BLOCKED (ingress-LB hairpin, as with pve.renner.uno)
home-assistant.home-assistant.svc:80 BLOCKED (ciliumnetworkpolicy/default-deny-ingress)
From inside its own namespace HA answers 200 OK. So this needs either a targeted CiliumNetworkPolicy (openbao → HA:8124), which puts a hole in the zero-netpol property the openbao-ns pattern was chosen for, or the job moved into home-assistant (which likely just relocates the policy to the OpenBao side).
Replaces the inline-Python rotator approach before it grows a tenth copy. No CronJob uses it yet — see the open question at the end.
## Why replace inline Python
The fleet currently carries **~130 lines of Python embedded in YAML strings** across 9 manifests:
| Duplicated | In |
|---|---|
| `auth/kubernetes/login` + SA-token read | **9 of 9** manifests |
| `def call()` helper, `X-Vault-Token`, merge-patch | 4 |
| lint / type-check / tests in CI | **none** |
Nine copies of the same OpenBao login isn't a pattern, it's copy-paste. The trailing-newline bug that crash-looped external-dns is exactly what one shared, tested write-helper prevents once instead of nine times.
Home Assistant would have been the worst case: a hand-rolled WebSocket handshake with frame masking, inside a YAML string, unlintable and untestable. HA mints long-lived access tokens **only** over its WebSocket API — verified against 2026.7.2, both `auth/long_lived_access_token` and `auth/delete_refresh_token` exist and the REST API has no equivalent.
## FROM scratch
```
image: 4.85 MB (3.2 MB binary + metadata)
binary: static-pie linked, stripped
/bin/sh: absent — exec into it fails at runc
```
One file. No libc, no shell, no package manager — no base-image CVE stream to patch, and nothing for a compromised rotator to exec. `rustls` + `webpki-roots` compiles the trust store **into** the binary, so TLS needs no CA bundle on disk. In-cluster traffic is plain HTTP, but that keeps the door open for this binary to take over the Cloudflare and Proxmox rotators too.
## Ordering is load-bearing
`ha-token` is make-before-break, and the sequence must not be reshuffled:
1. list existing token ids for the `client_name` — **before** minting, so the new token can never land in the revocation set
2. mint the replacement
3. write it to OpenBao
4. only then revoke the old ones
If step 3 fails, the old token is still valid and still stored, so the run is a **no-op rather than an outage**. Revoking before writing would strand the consumer with a dead credential.
A failed revoke is deliberately non-fatal but logged loudly — the new token is already live, and a token meant to be retired that is still valid is a real finding, not noise.
## Verified
- `cargo test` passes (path-splitting unit tests)
- static-pie linked, stripped; image contains only the binary
- exits **non-zero** on usage error
- no secret value is ever logged
- pipeline path trigger now includes `secret-rotator/**` — **without it, source changes would silently not rebuild the image**
## Open question — network, not tooling
No CronJob ships here because the Home Assistant rotator can't reach HA from the `openbao` namespace:
```
home.renner.uno:443 BLOCKED (ingress-LB hairpin, as with pve.renner.uno)
home-assistant.home-assistant.svc:80 BLOCKED (ciliumnetworkpolicy/default-deny-ingress)
```
From inside its own namespace HA answers `200 OK`. So this needs either a targeted CiliumNetworkPolicy (`openbao` → HA:8124), which puts a hole in the zero-netpol property the openbao-ns pattern was chosen for, or the job moved into `home-assistant` (which likely just relocates the policy to the OpenBao side).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Replaces the inline-Python approach before it grows a tenth copy. Today the
fleet carries ~130 lines of Python embedded in YAML strings across 9 manifests,
with auth/kubernetes/login duplicated in all 9 and the call()/merge-patch
helpers in 4 — none of it lintable, testable, or reviewable as code.
The Home Assistant rotator would have been the worst of them: a hand-rolled
WebSocket handshake with frame masking inside a YAML string. HA mints
long-lived access tokens only over its WebSocket API (verified against
2026.7.2 — auth/long_lived_access_token and auth/delete_refresh_token both
exist; the REST API has no equivalent).
Ships as a static musl binary in FROM scratch: 4.85 MB, one file, no libc, no
shell, no package manager. Nothing to patch and nothing for a compromised
rotator to exec. rustls with webpki-roots compiles the trust store into the
binary, so TLS works without a CA bundle on disk — in-cluster traffic is plain
HTTP, but that keeps the door open to take over the Cloudflare and Proxmox
rotators.
ha-token mode is make-before-break, and the ordering is load-bearing: list
existing token ids for the client_name BEFORE minting (so the new token can
never land in the revocation set), mint, write to OpenBao, and only then
revoke. If the OpenBao write fails, the old token is still valid and still
stored, so the run is a no-op rather than an outage.
A failed revoke is deliberately non-fatal but logged loudly: the new token is
already live, and a token meant to be retired that is still valid is a real
finding, not noise.
Verified: static-pie linked and stripped; image contains only the binary
(3.2 MB binary, 4.85 MB image); /bin/sh absent; exits non-zero on usage error;
cargo test passes. The pipeline path trigger now includes secret-rotator/**,
without which source changes would silently not rebuild the image.
No CronJob uses it yet — the Home Assistant rotator needs a network decision
first: the home-assistant namespace is default-deny-ingress, so openbao cannot
reach it, and the public name hairpins off the ingress LB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revoking by client_name would have destroyed live credentials. Checked the
three stored HA tokens against auth/refresh_tokens by decoding each one's JWT
iss claim, and the names are not what they look like:
apps/home-assistant/prometheus-token -> "ServiceMonitor"
apps/home-assistant/config-sync-token -> "laptop local"
apps/home-assistant/shelly-manager-llat-> "Shelly Manager"
Separate, unrelated "Prometheus" and "Config Reconciler" tokens also exist. A
name-matching rotator pointed at prometheus-token would have revoked
"Prometheus" — a live credential belonging to something else — and left the
actual stored token valid. Same for config-sync.
An HA long-lived access token is a JWT whose iss claim IS its own
refresh-token id, so the outgoing token identifies itself exactly. The
rotator now reads that, confirms with HA that the id really is a long-lived
access token, and refuses to proceed if it is not. No name matching anywhere.
The JWT signature is deliberately not verified: this is not authentication,
only reading which id the token claims, and HA remains the authority via the
auth/refresh_tokens confirmation before any revoke.
Adds a small base64url decoder (payloads are unpadded) with unit tests
covering iss extraction, unpadded decoding, non-JWT input, and a JWT with no
iss. 6 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
paperless, authentik, woodpecker, crowdsec-bouncer-key and shelly-manager all
ran the same shell one-liner:
head -c <N> /dev/urandom | base64 | tr -d '\n'
so the port has to be byte-compatible, not merely similar: N raw bytes, the
STANDARD base64 alphabet (+ and /, not URL-safe), WITH = padding, no newline.
Consumers store these verbatim and none filter the character set, so quietly
switching to base64url or dropping padding would change the credential shape
for no reason. The encoder is verified against the RFC 4648 vectors, which are
what coreutils base64 produces, plus a case asserting + and / rather than - and _.
BAO_FIELDS is comma-separated and each field gets its own independent value,
written in ONE merge-patch. That preserves shelly-manager's behaviour, where
admin-password and api-key must land together rather than leaving a window
with one rotated and one stale.
Adds assert_fields_exist as a pre-flight guard. Merge-patch CREATES a field
that isn't present, so a typo in BAO_FIELDS would add `secret-ky`, report
success, and leave the real credential un-rotated — a silent no-op that would
only surface during an incident. Rotation only ever replaces existing fields,
so a missing field is now a hard error that names both what is missing and
what is actually there.
Byte lengths preserved from the originals: 50 for the Django-style keys
(paperless, authentik), 32 for the rest.
Module is `secretgen`, not `gen` — the latter is a reserved keyword in the
2024 edition. 11 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Systematic review (posted as COMMENT since claude-bot authored this PR; conclusion: agree with the existing REQUEST_CHANGES).
Reviewer's requested changes — all verified as correct
base64 crate (secretgen.rs, ha.rs): confirmed already in Cargo.lock as a transitive dependency. The PR currently hand-rolls two base64 implementations (standard-alphabet encoder in secretgen.rs, base64url decoder in ha.rs) while compiling the crate that does both. Promoting it to a direct dep deletes ~50 lines at zero added compile cost.
getrandom (secretgen.rs): also already in the tree (via ring/rand). Replacing the /dev/urandom file read with the getrandom syscall is one line and removes the binary's only filesystem dependency — a nice property in a FROM-scratch image.
"laptop local" config-sync token: the stored credential's provenance is wrong and the code comment codifies it as permanent. Remediation is self-serving: one run of this rotator against that path with a proper HA_CLIENT_NAME mints a correctly-named token, stores it, and revokes the "laptop local" one. Then delete the apologetic comments.
Additional pre-merge item
No timeouts anywhere (medium). The inline Python this replaces passes timeout=30 on every call; the binary configures no ureq agent timeout and no read timeout on the blocking tungstenite socket, so a hung endpoint stalls until the Job's activeDeadlineSeconds kills it with no useful error. The cluster demonstrably has hanging paths (the hairpin blocks documented in this very PR).
Can ride along or follow up
RANDOM_BYTES=0 writes an empty secret and reports success — a < 16 → error floor fits the otherwise-defensive style (assert_fields_exist exists for exactly this class of silent misconfig).
If mint succeeds but the OpenBao patch fails, the fresh HA token stays valid for 365 d, referenced nowhere, never GC'd (revocation only targets the stored token's id). Log the orphan id in that error path.
BaoError/HaError + err() are identical boilerplate; a pve/netbird module would be copies 3 and 4 — consolidate before the next mode lands.
Verified non-issues
The Content-Type: application/merge-patch+json header in patch_fields is not clobbered by send_json — verified in ureq 3.3.0 source (src/request.rs: content type only appended if !request.headers().has_content_type()). The KV-v2 merge-patch semantics the fleet depends on (co-located fields survive) are correct as written.
No secret value appears in any log or error path.
The base64 encoder matches RFC 4648 / coreutils byte-for-byte (test vectors included) — consumers store values verbatim, so shape parity matters.
Make-before-break ordering for ha-token is correct, and actually safer than described: revocation targets only the single id from the stored token's JWT iss, so the fresh token can never enter the revocation set.
Fit for the planned PVE / NetBird ports
NetBird: near-trivial with existing primitives (read bootstrap PAT, one JSON POST, one merge-patch); netbird.renner.uno has a publicly-valid cert, so webpki-roots suffices.
PVE (owner decision, recorded): connect via the stable Service pve.apps.svc.cluster.local:8006 — never per-node (node identity is not stable). All PVE nodes serve valid Let's Encrypt certs, but no node cert can match the Service hostname, so scope verification-off to the PVE calls only: ureq 3.3 supports TlsConfigBuilder::disable_verification() per-Agent — a dedicated non-verifying Agent for PVE while OpenBao/HA/NetBird stay on the default verified agent, the exact analogue of the Python rotator's novrfy ssl context. Note the PVE flow is inherently break-before-make (Proxmox has no in-place token roll) — don't copy the HA ordering doctrine. Also needs form-encoded POSTs (send_form) and a two-field bootstrap read. The "Proxmox uses a self-signed cert" comment in the existing pve-exporter.yaml is outdated and shouldn't be carried over.
Repo standards check: complies with the CI-only workflow (image built by Woodpecker, no local escape hatch; the added secret-rotator/** path trigger closes a real gap); no stale version pins; the HA WebSocket API use is a justified, documented deviation from the follow-official-docs rule (no REST equivalent exists, verified against 2026.7.2).
Systematic review (posted as COMMENT since claude-bot authored this PR; conclusion: **agree with the existing REQUEST_CHANGES**).
## Reviewer's requested changes — all verified as correct
1. **`base64` crate** (secretgen.rs, ha.rs): confirmed already in Cargo.lock as a transitive dependency. The PR currently hand-rolls *two* base64 implementations (standard-alphabet encoder in secretgen.rs, base64url decoder in ha.rs) while compiling the crate that does both. Promoting it to a direct dep deletes ~50 lines at zero added compile cost.
2. **`getrandom`** (secretgen.rs): also already in the tree (via ring/rand). Replacing the `/dev/urandom` file read with the `getrandom` syscall is one line and removes the binary's only filesystem dependency — a nice property in a FROM-scratch image.
3. **"laptop local" config-sync token**: the stored credential's provenance is wrong and the code comment codifies it as permanent. Remediation is self-serving: one run of this rotator against that path with a proper `HA_CLIENT_NAME` mints a correctly-named token, stores it, and revokes the "laptop local" one. Then delete the apologetic comments.
## Additional pre-merge item
**No timeouts anywhere (medium).** The inline Python this replaces passes `timeout=30` on every call; the binary configures no ureq agent timeout and no read timeout on the blocking tungstenite socket, so a hung endpoint stalls until the Job's `activeDeadlineSeconds` kills it with no useful error. The cluster demonstrably has hanging paths (the hairpin blocks documented in this very PR).
## Can ride along or follow up
- `RANDOM_BYTES=0` writes an empty secret and reports success — a `< 16 → error` floor fits the otherwise-defensive style (`assert_fields_exist` exists for exactly this class of silent misconfig).
- If `mint` succeeds but the OpenBao patch fails, the fresh HA token stays valid for 365 d, referenced nowhere, never GC'd (revocation only targets the stored token's id). Log the orphan id in that error path.
- `BaoError`/`HaError` + `err()` are identical boilerplate; a pve/netbird module would be copies 3 and 4 — consolidate before the next mode lands.
## Verified non-issues
- The `Content-Type: application/merge-patch+json` header in `patch_fields` is **not** clobbered by `send_json` — verified in ureq 3.3.0 source (src/request.rs: content type only appended `if !request.headers().has_content_type()`). The KV-v2 merge-patch semantics the fleet depends on (co-located fields survive) are correct as written.
- No secret value appears in any log or error path.
- The base64 encoder matches RFC 4648 / coreutils byte-for-byte (test vectors included) — consumers store values verbatim, so shape parity matters.
- Make-before-break ordering for ha-token is correct, and actually safer than described: revocation targets only the single id from the stored token's JWT `iss`, so the fresh token can never enter the revocation set.
## Fit for the planned PVE / NetBird ports
- **NetBird**: near-trivial with existing primitives (read bootstrap PAT, one JSON POST, one merge-patch); `netbird.renner.uno` has a publicly-valid cert, so webpki-roots suffices.
- **PVE** (owner decision, recorded): connect via the stable Service `pve.apps.svc.cluster.local:8006` — **never per-node** (node identity is not stable). All PVE nodes serve valid Let's Encrypt certs, but no node cert can match the Service hostname, so scope verification-off to the PVE calls only: ureq 3.3 supports `TlsConfigBuilder::disable_verification()` per-Agent — a dedicated non-verifying Agent for PVE while OpenBao/HA/NetBird stay on the default verified agent, the exact analogue of the Python rotator's `novrfy` ssl context. Note the PVE flow is inherently break-before-make (Proxmox has no in-place token roll) — don't copy the HA ordering doctrine. Also needs form-encoded POSTs (`send_form`) and a two-field bootstrap read. The "Proxmox uses a self-signed cert" comment in the existing pve-exporter.yaml is outdated and shouldn't be carried over.
- Repo standards check: complies with the CI-only workflow (image built by Woodpecker, no local escape hatch; the added `secret-rotator/**` path trigger closes a real gap); no stale version pins; the HA WebSocket API use is a justified, documented deviation from the follow-official-docs rule (no REST equivalent exists, verified against 2026.7.2).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
No timeouts on any HTTP call (ureq 3's default agent has none), and the tungstenite socket in ha.rs has no read timeout either — the Python being replaced used timeout=30 everywhere. A hung endpoint stalls the Job until activeDeadlineSeconds with no useful error; the cluster demonstrably has hanging paths (the hairpin blocks in the PR description). Build a shared ureq::Agent with a global timeout and set a read timeout on the WS stream.
No timeouts on any HTTP call (ureq 3's default agent has none), and the tungstenite socket in ha.rs has no read timeout either — the Python being replaced used `timeout=30` everywhere. A hung endpoint stalls the Job until `activeDeadlineSeconds` with no useful error; the cluster demonstrably has hanging paths (the hairpin blocks in the PR description). Build a shared `ureq::Agent` with a global timeout and set a read timeout on the WS stream.
RANDOM_BYTES=0 (or any tiny value) generates an empty/weak secret, patches it into OpenBao, and reports success. Add a floor, e.g. if bytes < 16 { return Err(...) } — same silent-misconfig class that assert_fields_exist already guards against.
`RANDOM_BYTES=0` (or any tiny value) generates an empty/weak secret, patches it into OpenBao, and reports success. Add a floor, e.g. `if bytes < 16 { return Err(...) }` — same silent-misconfig class that `assert_fields_exist` already guards against.
If mint succeeds and this patch fails, the freshly minted token remains valid for the full lifespan, is referenced nowhere, and is never revoked (revocation only ever targets the stored token's id). Worth logging the orphan's refresh-token id in this error path so it can be revoked manually.
If `mint` succeeds and this patch fails, the freshly minted token remains valid for the full lifespan, is referenced nowhere, and is never revoked (revocation only ever targets the stored token's id). Worth logging the orphan's refresh-token id in this error path so it can be revoked manually.
Addresses three review findings, all real bugs rather than style:
1. No timeouts anywhere. The Python this replaces passed timeout=30 on every
call; ureq 3's default agent has none and tungstenite has no read timeout,
so a hung endpoint would stall the Job silently until activeDeadlineSeconds
with no useful error — and the cluster demonstrably has hanging paths (the
ingress-LB hairpin). Now: a shared ureq::Agent with timeout_global(30s)
threaded through every OpenBao call, and a 30s read timeout set on the
TcpStream under tungstenite's socket (handling both plain and rustls
streams).
2. RANDOM_BYTES=0 would generate a near-empty secret, store it, and report
success — the same silent-misconfig class assert_fields_exist guards. Now
floored at 16 bytes (128 bits); every real rotator uses 32 or 50.
3. If mint succeeds but the OpenBao store fails, the freshly minted token is
valid for its full lifespan, referenced nowhere, and never auto-revoked
(revocation only targets the OLD stored id). The store error path now logs
the orphan's refresh-token id — recovered from its own JWT — so it can be
revoked by hand, and makes clear the consumer is unaffected because the old
token is still stored and valid.
clippy -D warnings clean; 10 tests pass; scratch image builds (4.92 MB) and
all error paths exit non-zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All three automated-review findings were real bugs; fixed and verified.
2261 — no timeouts (bao.rs:38, ha.rs socket). Correct, and I dropped this from the Python, which used timeout=30 everywhere. Added a shared ureq::Agent with timeout_global(30s) threaded through every OpenBao call, and a 30s read timeout on the TcpStream under tungstenite's socket (handles both plain ws:// and rustlswss://). Matters here specifically because the cluster has proven hanging paths — the ingress-LB hairpin in the PR description.
2259 — RANDOM_BYTES floor (main.rs:64). Correct, same silent-misconfig class as assert_fields_exist. Now refuses below 16 bytes (128 bits); every real rotator uses 32 or 50. Verified: RANDOM_BYTES=0 exits 1 with a clear message.
2260 — orphaned token on mint-then-patch-fail (main.rs:142). Correct. The store-error path now logs the orphan's refresh-token id — recovered from the new token's own JWT iss — for manual revocation, and states plainly that the consumer is unaffected because the old token is still stored and valid.
(Also from the first pass, for completeness: 2254/2255/2257 switched to the already-present getrandom and base64 crates, deleting ~45 lines of hand-rolled encode/decode; 2256 — config-sync's laptop local token — fixed out-of-band, a dedicated Config Sync token is minted and stored, laptop local left untouched, pending only the HA pod restart to pick it up.)
## Second review pass addressed — commit 808cb539
All three automated-review findings were real bugs; fixed and verified.
**2261 — no timeouts (bao.rs:38, ha.rs socket).** Correct, and I dropped this from the Python, which used `timeout=30` everywhere. Added a shared `ureq::Agent` with `timeout_global(30s)` threaded through every OpenBao call, and a 30s read timeout on the `TcpStream` under tungstenite's socket (handles both plain `ws://` and `rustls` `wss://`). Matters here specifically because the cluster has proven hanging paths — the ingress-LB hairpin in the PR description.
**2259 — `RANDOM_BYTES` floor (main.rs:64).** Correct, same silent-misconfig class as `assert_fields_exist`. Now refuses below 16 bytes (128 bits); every real rotator uses 32 or 50. Verified: `RANDOM_BYTES=0` exits 1 with a clear message.
**2260 — orphaned token on mint-then-patch-fail (main.rs:142).** Correct. The store-error path now logs the orphan's refresh-token id — recovered from the new token's own JWT `iss` — for manual revocation, and states plainly that the consumer is unaffected because the old token is still stored and valid.
Verification: `clippy -D warnings` clean, 10 tests pass, scratch image builds (4.92 MB), all error paths exit non-zero.
(Also from the first pass, for completeness: 2254/2255/2257 switched to the already-present `getrandom` and `base64` crates, deleting ~45 lines of hand-rolled encode/decode; 2256 — config-sync's `laptop local` token — fixed out-of-band, a dedicated `Config Sync` token is minted and stored, `laptop local` left untouched, pending only the HA pod restart to pick it up.)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Replaces the inline-Python rotator approach before it grows a tenth copy. No CronJob uses it yet — see the open question at the end.
Why replace inline Python
The fleet currently carries ~130 lines of Python embedded in YAML strings across 9 manifests:
auth/kubernetes/login+ SA-token readdef call()helper,X-Vault-Token, merge-patchNine copies of the same OpenBao login isn't a pattern, it's copy-paste. The trailing-newline bug that crash-looped external-dns is exactly what one shared, tested write-helper prevents once instead of nine times.
Home Assistant would have been the worst case: a hand-rolled WebSocket handshake with frame masking, inside a YAML string, unlintable and untestable. HA mints long-lived access tokens only over its WebSocket API — verified against 2026.7.2, both
auth/long_lived_access_tokenandauth/delete_refresh_tokenexist and the REST API has no equivalent.FROM scratch
One file. No libc, no shell, no package manager — no base-image CVE stream to patch, and nothing for a compromised rotator to exec.
rustls+webpki-rootscompiles the trust store into the binary, so TLS needs no CA bundle on disk. In-cluster traffic is plain HTTP, but that keeps the door open for this binary to take over the Cloudflare and Proxmox rotators too.Ordering is load-bearing
ha-tokenis make-before-break, and the sequence must not be reshuffled:client_name— before minting, so the new token can never land in the revocation setIf step 3 fails, the old token is still valid and still stored, so the run is a no-op rather than an outage. Revoking before writing would strand the consumer with a dead credential.
A failed revoke is deliberately non-fatal but logged loudly — the new token is already live, and a token meant to be retired that is still valid is a real finding, not noise.
Verified
cargo testpasses (path-splitting unit tests)secret-rotator/**— without it, source changes would silently not rebuild the imageOpen question — network, not tooling
No CronJob ships here because the Home Assistant rotator can't reach HA from the
openbaonamespace:From inside its own namespace HA answers
200 OK. So this needs either a targeted CiliumNetworkPolicy (openbao→ HA:8124), which puts a hole in the zero-netpol property the openbao-ns pattern was chosen for, or the job moved intohome-assistant(which likely just relocates the policy to the OpenBao side).🤖 Generated with Claude Code
paperless, authentik, woodpecker, crowdsec-bouncer-key and shelly-manager all ran the same shell one-liner: head -c <N> /dev/urandom | base64 | tr -d '\n' so the port has to be byte-compatible, not merely similar: N raw bytes, the STANDARD base64 alphabet (+ and /, not URL-safe), WITH = padding, no newline. Consumers store these verbatim and none filter the character set, so quietly switching to base64url or dropping padding would change the credential shape for no reason. The encoder is verified against the RFC 4648 vectors, which are what coreutils base64 produces, plus a case asserting + and / rather than - and _. BAO_FIELDS is comma-separated and each field gets its own independent value, written in ONE merge-patch. That preserves shelly-manager's behaviour, where admin-password and api-key must land together rather than leaving a window with one rotated and one stale. Adds assert_fields_exist as a pre-flight guard. Merge-patch CREATES a field that isn't present, so a typo in BAO_FIELDS would add `secret-ky`, report success, and leave the real credential un-rotated — a silent no-op that would only surface during an incident. Rotation only ever replaces existing fields, so a missing field is now a hard error that names both what is missing and what is actually there. Byte lengths preserved from the originals: 50 for the Django-style keys (paperless, authentik), 32 for the rest. Module is `secretgen`, not `gen` — the latter is a reserved keyword in the 2024 edition. 11 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>@@ -0,0 +59,4 @@}fn b64url_decode(s: &str) -> Result<Vec<u8>, Box<dyn Error>> {const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";Crate: base64
@@ -0,0 +119,4 @@// Identify the outgoing token from its OWN jwt, not by client_name.// Name matching would be wrong here: prometheus-token is named// "ServiceMonitor" (an unrelated "Prometheus" token also exists) and// config-sync-token is named "laptop local" (an unrelatedif config sync is using my laptop local this is you mistake and needs to be fixed
@@ -0,0 +17,4 @@const STD: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";/// N cryptographically-random bytes, standard-base64 encoded with padding.pub fn random_secret(bytes: usize) -> Result<String, Box<dyn Error>> {you already have getrandom as a dependency
@@ -0,0 +24,4 @@Ok(b64_encode(&buf))}fn b64_encode(input: &[u8]) -> String {use the 'base64' crate
Systematic review (posted as COMMENT since claude-bot authored this PR; conclusion: agree with the existing REQUEST_CHANGES).
Reviewer's requested changes — all verified as correct
base64crate (secretgen.rs, ha.rs): confirmed already in Cargo.lock as a transitive dependency. The PR currently hand-rolls two base64 implementations (standard-alphabet encoder in secretgen.rs, base64url decoder in ha.rs) while compiling the crate that does both. Promoting it to a direct dep deletes ~50 lines at zero added compile cost.getrandom(secretgen.rs): also already in the tree (via ring/rand). Replacing the/dev/urandomfile read with thegetrandomsyscall is one line and removes the binary's only filesystem dependency — a nice property in a FROM-scratch image.HA_CLIENT_NAMEmints a correctly-named token, stores it, and revokes the "laptop local" one. Then delete the apologetic comments.Additional pre-merge item
No timeouts anywhere (medium). The inline Python this replaces passes
timeout=30on every call; the binary configures no ureq agent timeout and no read timeout on the blocking tungstenite socket, so a hung endpoint stalls until the Job'sactiveDeadlineSecondskills it with no useful error. The cluster demonstrably has hanging paths (the hairpin blocks documented in this very PR).Can ride along or follow up
RANDOM_BYTES=0writes an empty secret and reports success — a< 16 → errorfloor fits the otherwise-defensive style (assert_fields_existexists for exactly this class of silent misconfig).mintsucceeds but the OpenBao patch fails, the fresh HA token stays valid for 365 d, referenced nowhere, never GC'd (revocation only targets the stored token's id). Log the orphan id in that error path.BaoError/HaError+err()are identical boilerplate; a pve/netbird module would be copies 3 and 4 — consolidate before the next mode lands.Verified non-issues
Content-Type: application/merge-patch+jsonheader inpatch_fieldsis not clobbered bysend_json— verified in ureq 3.3.0 source (src/request.rs: content type only appendedif !request.headers().has_content_type()). The KV-v2 merge-patch semantics the fleet depends on (co-located fields survive) are correct as written.iss, so the fresh token can never enter the revocation set.Fit for the planned PVE / NetBird ports
netbird.renner.unohas a publicly-valid cert, so webpki-roots suffices.pve.apps.svc.cluster.local:8006— never per-node (node identity is not stable). All PVE nodes serve valid Let's Encrypt certs, but no node cert can match the Service hostname, so scope verification-off to the PVE calls only: ureq 3.3 supportsTlsConfigBuilder::disable_verification()per-Agent — a dedicated non-verifying Agent for PVE while OpenBao/HA/NetBird stay on the default verified agent, the exact analogue of the Python rotator'snovrfyssl context. Note the PVE flow is inherently break-before-make (Proxmox has no in-place token roll) — don't copy the HA ordering doctrine. Also needs form-encoded POSTs (send_form) and a two-field bootstrap read. The "Proxmox uses a self-signed cert" comment in the existing pve-exporter.yaml is outdated and shouldn't be carried over.secret-rotator/**path trigger closes a real gap); no stale version pins; the HA WebSocket API use is a justified, documented deviation from the follow-official-docs rule (no REST equivalent exists, verified against 2026.7.2).🤖 Generated with Claude Code
@@ -0,0 +35,4 @@let jwt = std::fs::read_to_string(SA_TOKEN).map_err(|e| BaoError(format!("reading {SA_TOKEN}: {e}")))?;let body = serde_json::json!({ "role": role, "jwt": jwt.trim() });let mut resp = ureq::post(format!("{addr}/v1/auth/kubernetes/login"))No timeouts on any HTTP call (ureq 3's default agent has none), and the tungstenite socket in ha.rs has no read timeout either — the Python being replaced used
timeout=30everywhere. A hung endpoint stalls the Job untilactiveDeadlineSecondswith no useful error; the cluster demonstrably has hanging paths (the hairpin blocks in the PR description). Build a sharedureq::Agentwith a global timeout and set a read timeout on the WS stream.@@ -0,0 +61,4 @@let bao_addr = env_or("BAO_ADDR", "http://openbao.openbao.svc.cluster.local:8200");let bao_role = env("BAO_ROLE")?;let bao_path = env("BAO_PATH")?;let bytes: usize = env_or("RANDOM_BYTES", "32").parse()?;RANDOM_BYTES=0(or any tiny value) generates an empty/weak secret, patches it into OpenBao, and reports success. Add a floor, e.g.if bytes < 16 { return Err(...) }— same silent-misconfig class thatassert_fields_existalready guards against.@@ -0,0 +139,4 @@};let fresh = ha.mint(&client_name, lifespan)?;bao.patch_field(&bao_path, &field, &fresh)?;If
mintsucceeds and this patch fails, the freshly minted token remains valid for the full lifespan, is referenced nowhere, and is never revoked (revocation only ever targets the stored token's id). Worth logging the orphan's refresh-token id in this error path so it can be revoked manually.Second review pass addressed — commit
808cb539All three automated-review findings were real bugs; fixed and verified.
2261 — no timeouts (bao.rs:38, ha.rs socket). Correct, and I dropped this from the Python, which used
timeout=30everywhere. Added a sharedureq::Agentwithtimeout_global(30s)threaded through every OpenBao call, and a 30s read timeout on theTcpStreamunder tungstenite's socket (handles both plainws://andrustlswss://). Matters here specifically because the cluster has proven hanging paths — the ingress-LB hairpin in the PR description.2259 —
RANDOM_BYTESfloor (main.rs:64). Correct, same silent-misconfig class asassert_fields_exist. Now refuses below 16 bytes (128 bits); every real rotator uses 32 or 50. Verified:RANDOM_BYTES=0exits 1 with a clear message.2260 — orphaned token on mint-then-patch-fail (main.rs:142). Correct. The store-error path now logs the orphan's refresh-token id — recovered from the new token's own JWT
iss— for manual revocation, and states plainly that the consumer is unaffected because the old token is still stored and valid.Verification:
clippy -D warningsclean, 10 tests pass, scratch image builds (4.92 MB), all error paths exit non-zero.(Also from the first pass, for completeness: 2254/2255/2257 switched to the already-present
getrandomandbase64crates, deleting ~45 lines of hand-rolled encode/decode; 2256 — config-sync'slaptop localtoken — fixed out-of-band, a dedicatedConfig Synctoken is minted and stored,laptop localleft untouched, pending only the HA pod restart to pick it up.)