feat: OpenBao-backed secrets for docker-local and vps - #176
Conversation
…cal, resolved via OpenBao
🤖 Augment PR SummarySummary: This PR moves docker-local and VPS deployment secret resolution to OpenBao. Changes:
Technical Notes: The external OpenBao volume is intended to survive fresh deployment bundle directories; VPS configuration expects existing production secrets to be seeded before its first run. 🤖 Was this summary useful? React with 👍 or 👎 |
| } | ||
|
|
||
| listener "tcp" { | ||
| address = "0.0.0.0:8200" |
There was a problem hiding this comment.
This listener binds OpenBao’s unauthenticated/TLS-disabled API to every interface; with the vps fragment’s host network, port 8200 is consequently reachable on the VPS public interfaces, contrary to the deployment contract that nothing binds to 0.0.0.0. The docker-local fragment also publishes it on all host interfaces, so AppRole credentials and secret API traffic can be exposed or observed outside the intended local host.
Severity: high
Other Locations
docker/versola-tools/compose.fragment.yml.template:37
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
…st/VPS interfaces)
|
augment review |
| platform: linux/amd64 | ||
| container_name: versola-central | ||
| restart: unless-stopped | ||
| network_mode: host |
There was a problem hiding this comment.
At docker/versola-tools/compose.fragment.vps.yml.template:46, host networking makes central use the host namespace, while VersolaApp only calls Server.Config.default.port, whose default bind address is 0.0.0.0; ports 8090/8091 can therefore be reached directly on any network path to the VPS, bypassing nginx and its TLS/access controls. The same exposure applies to auth and edge.
Severity: high
Other Locations
docker/versola-tools/compose.fragment.vps.yml.template:70docker/versola-tools/compose.fragment.vps.yml.template:87
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
…s public interface, bypassing nginx)
|
augment review |
| "SESSION_COOKIE_SECRET" -> sessionCookieSecret, | ||
| "USER_AGENT_COOKIE_SECRET" -> userAgentCookieSecret, | ||
| "PAR_REQUESTS_SECRET" -> parRequestsSecret, | ||
| "JWT_PRIVATE_KEY" -> jwtKey.privateB64, |
There was a problem hiding this comment.
When OpenBao is initialized for the already-running VPS, this stores a newly generated private key while the existing jwks table retains its prior key. Bootstrap adds the new JWK, but auth signs with JWT.PublicKeys.active (the first, unordered persisted record), so it can put the old kid on a token signed by this new private key and validators will reject it.
Severity: high
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
| val centralExtras = if isVps then Seq("POSTGRES_PASSWORD" -> centralPgPass) else Seq.empty | ||
| writeGeneratedSecrets(dir, "central.generated-secrets.env", Seq( | ||
| "CENTRAL_SECRET_KEY" -> centralSecretKey, | ||
| "CLIENT_SECRETS_SECRET" -> clientSecretsSecret, |
There was a problem hiding this comment.
A first VPS setup against an empty OpenBao generates a new CLIENT_SECRETS_SECRET even though the existing central resource secret remains encrypted under the prior key. Bootstrap skips that existing resource, so central cannot recover its credential to sync to edge and the edge admin proxy becomes unavailable after this rollout.
Severity: high
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
|
|
||
| val edgeExtras = if isVps then Seq("POSTGRES_PASSWORD" -> edgePgPass) else Seq.empty | ||
| writeGeneratedSecrets(dir, "edge.generated-secrets.env", Seq( | ||
| "EDGE_PRIVATE_KEY" -> edgeKey.privateB64, |
There was a problem hiding this comment.
EDGE_PRIVATE_KEY is persisted without its paired key identity. On the initial migration central retains the existing edge-default public JWK, and on a later run the literal key-id is regenerated from the date while this private key is reused; either case makes the sync token's KID/key differ from central's persisted public key, causing edge-to-central syncs to return 401.
Severity: high
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
…ocument seeding real crypto material for vps migration
|
augment review |
| // Unlike docker-local's throwaway Postgres container, the VPS's Postgres | ||
| // role already exists outside this script's control -- see the comment | ||
| // on pgPassDefault below. | ||
| val isVps = env == "vps" |
There was a problem hiding this comment.
vps is emitted verbatim as env, but VersolaApp.envName recognizes only "prod" as EnvName.Prod, so this real VPS runs as Test("vps"). That makes OTPs deterministic and not delivered, and enables test-only service endpoints, so the deployment cannot safely serve production traffic.
Severity: high
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
…ocument seeding real crypto material for vps migration
|
augment review |
| # stop them. A stable name fixes both: Compose looks containers up by | ||
| # project+service, not by which file path happened to describe them this | ||
| # time, so any bundle directory's compose file manages the same stack. | ||
| name: versola-local |
There was a problem hiding this comment.
Existing docker-local stacks were created under bundle-derived project names, while their containers already use these fixed names; changing the project to versola-local cannot adopt them. The first post-upgrade configure/up will therefore fail with container-name conflicts before this stable name can help. Other locations where this applies: docker/versola-tools/compose.fragment.vps.yml.template:32 (the documented existing VPS stack is an implicit versola project).
Severity: high
Other Locations
docker/versola-tools/compose.fragment.vps.yml.template:32
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
… a pre-existing deployment
|
augment review |
Generated configs no longer contain a secret's real value. Every secret
field (JWT signing key, session/cookie secrets, Postgres password, admin
bootstrap password) is written as a
${?VAR}HOCON placeholder instead(see gen-env.scala's
secretField/secretKeyField) — versola-cli resolveseach one against an OpenBao server before starting anything: an existing
value wins over regenerating one, so secrets survive being reconfigured
rather than rotating on every deploy. Companion PR in versola-cli:
versolauth/versola-cli#7.
What's in this PR:
openbaoservice added to the local docker-compose stack (KV v2 +AppRole), with a persistent
externalvolume so its data survives afresh bundle directory each
configurerun.instead of writing it literally. This includes the JWT/edge RSA key
pairs' public halves (JWKS, edge's public-key-jwk), not just the
private ones — both halves have to resolve from the same source or a
second
configurereuses the old private key from OpenBao while writinga freshly generated public one, a mismatch that silently broke edge's
sync calls to central (401s) before this was fixed.
vpsbranch in gen-env.scala: same placeholder mechanism, butnon-interactive host-network defaults (
network_mode: host,127.0.0.1addressing, the realid.versola.kzdomain) for the onereal VPS this deploys to, plus a vps-only compose template (no
postgres/nginx — both native on the VPS) and a
TARGET-awareentrypoint.sh so one versola-tools image serves both targets.
develop.md: the one-time OpenBao setup procedure (init/unseal/enableKV+AppRole/policy/role) for both targets, and the vps-specific note on
seeding the real Postgres password before the first deploy (it isn't
something this script can generate — the Postgres role already exists).
Verified locally end-to-end, including a real browser login against the
docker-local stack after a fresh
configure.