119 lines
6 KiB
HCL
119 lines
6 KiB
HCL
|
|
// OpenBao server config for the Thermograph estate. Lives at /etc/openbao/config.hcl
|
|||
|
|
// on vps2. Verified against OpenBao v2.6.1 (2026-07-22).
|
|||
|
|
//
|
|||
|
|
// ============================================================================
|
|||
|
|
// WHY THIS RUNS AS A NATIVE SYSTEMD SERVICE AND NOT A CONTAINER
|
|||
|
|
// ============================================================================
|
|||
|
|
// The estate is Docker-native and this is the one thing that must not be. The rule
|
|||
|
|
// is that OpenBao must not depend on anything it secures.
|
|||
|
|
//
|
|||
|
|
// * A Swarm service is fatal: deploy-stack.sh renders secrets in order to deploy
|
|||
|
|
// the stack, so the stack cannot contain the thing that holds them.
|
|||
|
|
// * A standalone `docker run --restart=always` is merely bad: it makes the vault's
|
|||
|
|
// availability a function of the same Docker daemon that deploys restart, and
|
|||
|
|
// leaves it one careless `docker system prune -a` from gone. The root CLAUDE.md
|
|||
|
|
// already lists `prune -a` as a thing that eats the rollback image; it would
|
|||
|
|
// eat this too.
|
|||
|
|
//
|
|||
|
|
// A native binary with Restart=always has exactly one dependency: the local disk.
|
|||
|
|
// See infra/openbao/openbao.service.
|
|||
|
|
//
|
|||
|
|
// Corollary that is easy to get wrong: the listener cert must NOT come from the
|
|||
|
|
// Caddy/ACME path. That would put DNS and the public internet in the boot chain of
|
|||
|
|
// the estate's secret store. It is self-signed, long-lived, and on disk.
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
ui = false
|
|||
|
|
cluster_name = "thermograph"
|
|||
|
|
disable_mlock = true
|
|||
|
|
log_level = "info"
|
|||
|
|
|
|||
|
|
// STORAGE: raft, and this is now forced rather than chosen. OpenBao 2.6.0
|
|||
|
|
// DEPRECATED the `file` backend for removal in v2.7.0, so picking `file` today buys
|
|||
|
|
// a migration within a release. Raft also has the only first-class backup primitive
|
|||
|
|
// (`bao operator raft snapshot save`), which is the disaster-recovery story.
|
|||
|
|
//
|
|||
|
|
// A single-node raft is the honest shape for a one-operator estate. Note that a
|
|||
|
|
// TWO-node raft would be strictly worse than one: quorum of two means losing either
|
|||
|
|
// node loses write availability. Three voters would be needed, and the desktop
|
|||
|
|
// (10.10.0.3) is intermittent, so it cannot be a reliable third.
|
|||
|
|
storage "raft" {
|
|||
|
|
path = "/var/lib/openbao"
|
|||
|
|
node_id = "vps2"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Mesh-only. 10.10.0.1 is vps2's WireGuard address. There is deliberately no
|
|||
|
|
// 0.0.0.0 listener and no public DNS record — vps2 is a public VPS, and reaching
|
|||
|
|
// OpenBao should require being on the mesh, exactly like the Swarm control ports
|
|||
|
|
// and the Loki endpoint.
|
|||
|
|
listener "tcp" {
|
|||
|
|
address = "10.10.0.1:8200"
|
|||
|
|
tls_cert_file = "/etc/openbao/tls/bao.crt"
|
|||
|
|
tls_key_file = "/etc/openbao/tls/bao.key"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Loopback, so a by-hand `bao` on the box works without the mesh address.
|
|||
|
|
listener "tcp" {
|
|||
|
|
address = "127.0.0.1:8200"
|
|||
|
|
tls_cert_file = "/etc/openbao/tls/bao.crt"
|
|||
|
|
tls_key_file = "/etc/openbao/tls/bao.key"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
api_addr = "https://10.10.0.1:8200"
|
|||
|
|
cluster_addr = "https://10.10.0.1:8201"
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// SEAL: static auto-unseal. This is the crux of the whole design.
|
|||
|
|
// ============================================================================
|
|||
|
|
// The `static` seal (OpenBao v2.4.0+) auto-unseals from a local key with no cloud
|
|||
|
|
// KMS and no HSM. OpenBao's own docs hedge it — "carefully evaluate use of Static
|
|||
|
|
// Key Auto Unseal" — but that caveat is written for multi-tenant enterprises.
|
|||
|
|
//
|
|||
|
|
// The decisive argument here is that static seal is NOT A DOWNGRADE FROM THE STATUS
|
|||
|
|
// QUO. Today /etc/thermograph/age.key is one file, 0400, root-owned, on each
|
|||
|
|
// rendering host, which decrypts every secret in the estate forever with no audit
|
|||
|
|
// trail. A static seal key at 0400 on vps2 is the IDENTICAL trust model: one file on
|
|||
|
|
// one box whose compromise is total compromise. What changes is everything layered
|
|||
|
|
// above it — per-consumer ACLs, an audit log, five-minute tokens, versioned rollback.
|
|||
|
|
//
|
|||
|
|
// WHY NOT SHAMIR: `bao operator init -key-shares=5 -key-threshold=3` means the vault
|
|||
|
|
// is sealed after every process restart — reboot, package upgrade, OOM kill — and a
|
|||
|
|
// human must type three shares. render-secrets.sh is on the deploy path for all three
|
|||
|
|
// environments, so a sealed vault blocks every deploy and every hotfix. With one
|
|||
|
|
// operator the N-of-M threshold is theatre: one person holds all the shares, so it
|
|||
|
|
// buys nothing against the real threat while costing an availability property the
|
|||
|
|
// estate currently has for free. Today a Contabo reboot needs no human at all.
|
|||
|
|
// Introducing a human-in-the-loop requirement that does not exist today is a
|
|||
|
|
// regression this estate cannot absorb.
|
|||
|
|
//
|
|||
|
|
// THE ONE NEW SINGLE POINT OF FAILURE this migration introduces: lose this key and
|
|||
|
|
// the raft snapshots become unrestorable. It MUST exist in at least two places, one
|
|||
|
|
// of them not on vps2. That is a hard operator obligation, not a suggestion.
|
|||
|
|
//
|
|||
|
|
// Rotation is supported n-1 via previous_key / previous_key_id.
|
|||
|
|
seal "static" {
|
|||
|
|
current_key_id = "20260801-1"
|
|||
|
|
current_key = "file:///etc/openbao/unseal.key"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// AUDIT: two devices, deliberately.
|
|||
|
|
// ============================================================================
|
|||
|
|
// ⚠️ OpenBao STOPS ANSWERING REQUESTS ENTIRELY when no enabled audit device can
|
|||
|
|
// record them. That is correct behaviour for an audit log and a catastrophic way to
|
|||
|
|
// discover a full disk: a full /var on vps2 would block every deploy on the estate.
|
|||
|
|
// Two independent devices mean one wedging is survivable.
|
|||
|
|
//
|
|||
|
|
// Values are HMAC-SHA256'd in the log, so it is safe to ship to Loki through the
|
|||
|
|
// existing Alloy pipeline — which finally makes "who read which secret, when" a
|
|||
|
|
// queryable question. Today reading age.key leaves no trace whatsoever.
|
|||
|
|
//
|
|||
|
|
// logrotate on this path MUST signal with SIGHUP, or bao keeps writing to an
|
|||
|
|
// unlinked inode and the log silently stops growing.
|
|||
|
|
audit "file" {
|
|||
|
|
file_path = "/var/log/openbao/audit.log"
|
|||
|
|
mode = "0600"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
audit "syslog" {}
|