diff --git a/README.md b/README.md index 5c00938..3ff6027 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ installing Node.js, and a one-line bootstrap that wires both into any repo. | [`phi-check`](src/phi-check) | A linter-style command that refuses files holding PHI (US SSNs, Medicare Beneficiary Identifiers, X12, HL7 v2, FHIR Patient data, and a record id or date of birth next to a diagnosis or a name label): `phi-check ...`, never printing the matched text, failing closed on binary and missing files, knowing nothing of git (the repository wires it into a hook or CI); the patterns are one importable module for other guards. Python 3 standard library only, nothing downloaded; Python comes through `dependsOn`. | | [`common`](src/common) | The shared scripts: `dcf-persist`, behind every Feature's setup script (make a state directory usable and say what backs it), for a repo's own scripts too; and `dcf-curl`, which every installer's downloads go through so a TLS reset or a cut connection is retried. | | [`coder-cli`](src/coder-cli) | [Coder](https://coder.com) CLI from GitHub releases, verified against the release checksums. | -| [`openbao`](src/openbao) | [OpenBao](https://openbao.org) CLI (`bao`), verified against the release checksums, its login (`~/.vault-token`) kept in a volume it owns (`OPENBAO_STATE_DIR`), and `dcf-secret` (get, put, status; fixed exit codes), the one command the collection's Features use to ask for a secret. | +| [`openbao`](src/openbao) | [OpenBao](https://openbao.org) CLI (`bao`), verified against the release checksums, its login (`~/.vault-token`) kept in a volume it owns (`OPENBAO_STATE_DIR`), and `dcf-secret` (get, put, status; fixed exit codes), the one command the collection's Features use to ask for a secret. `mode: server` also runs a local server (raft on the volume, sealed until the Operator unseals it; `openbao:init`, `:unseal`, `:seal`, userpass login, loopback-bound AppRoles). | | [`velero`](src/velero) | [Velero](https://velero.io) CLI, verified against the release `CHECKSUM`. | | [`kustomize`](src/kustomize) | Standalone [kustomize](https://kustomize.io), verified against the release checksums. | | [`kubeconform`](src/kubeconform) | [kubeconform](https://github.com/yannh/kubeconform) manifest validator, verified against the release `CHECKSUMS`. | diff --git a/Taskfile.yml b/Taskfile.yml index fd6dbc9..a511587 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -259,12 +259,13 @@ tasks: - bash test/ssh-agent/unit/passphrase_test.sh test:openbao-unit: - desc: 'Docker-free tests of the openbao Feature''s scripts. dcf-secret against a stub bao that answers from environment variables and logs every call (dcf_secret_test.sh): get (the value alone on stdout, the mount, the field and the name checks, a multi-line value untouched), each exit code with its one-line fix (10 unreachable, 11 sealed or not initialised, 12 no token, 13 denied, 14 no such secret, and 1 and 2 for a put bao cannot explain and a wrong call) worked out from bao status, the JSON field initialized and the capabilities bao token capabilities reports, put (the value on stdin and in no argument, kept byte for byte; a merge by default with kv patch, a create with -cas=0 when the secret is absent, one retry as a patch when it appeared in between, --replace with kv put, a token without the patch capability refused with 13 and nothing written; nothing on stdout, a terminal refused), status in every state, DCF_SECRET_AS refused, no bao on PATH, and no fallback to a file in the state folder or a variable when the server is sealed, unreachable or the secret is missing. openbao-setup over temp folders (setup_test.sh) with the real dcf-persist: the state folder made and reported, a token left in ~/.vault-token moved into it once (only with the token helper in effect, never over one already there, mode 600), the token helper reported in effect, unset or another file, an unwritable folder reported, the mode (client, an unknown one reported, run as client), BAO_ADDR, BAO_TOKEN and a saved login reported without their text, bao missing, never fatal. The token helper bao calls (token_helper_test.sh): get, store and erase through `sh -c`, the token at OPENBAO_STATE_DIR/token with mode 600, written atomically, a missing token read as nothing, erase of a missing one no error, a failing store non-zero. `task openbao:logout` (logout_test.sh): revoke then erase, the erase when the revoke fails, nothing stored. That the real bao gives the signals the stub plays is the container test test/openbao/dev_server.sh (CI). Part of lint. Needs bash, grep and task (script(1) for one check, skipped without it).' + desc: 'Docker-free tests of the openbao Feature''s scripts. dcf-secret against a stub bao that answers from environment variables and logs every call (dcf_secret_test.sh): get (the value alone on stdout, the mount, the field and the name checks, a multi-line value untouched), each exit code with its one-line fix (10 unreachable, 11 sealed or not initialised, 12 no token, 13 denied, 14 no such secret, and 1 and 2 for a put bao cannot explain and a wrong call) worked out from bao status, the JSON field initialized and the capabilities bao token capabilities reports, put (the value on stdin and in no argument, kept byte for byte; a merge by default with kv patch, a create with -cas=0 when the secret is absent, one retry as a patch when it appeared in between, --replace with kv put, a token without the patch capability refused with 13 and nothing written; nothing on stdout, a terminal refused), status in every state, DCF_SECRET_AS refused, no bao on PATH, and no fallback to a file in the state folder or a variable when the server is sealed, unreachable or the secret is missing. openbao-setup over temp folders (setup_test.sh) with the real dcf-persist: the state folder made and reported, a token left in ~/.vault-token moved into it once (only with the token helper in effect, never over one already there, mode 600), the token helper reported in effect, unset or another file, an unwritable folder reported, the mode (client, server, an unknown one reported, run as client), BAO_ADDR and server mode''s default for it, BAO_TOKEN and a saved login reported without their text, bao missing, never fatal; and the server-mode start hook (`openbao-setup start`) against a stub bao that plays `bao server` and `bao status`: the config written (raft on the state folder, one loopback listener without TLS, the file audit device, no disable_mlock), the server started once and reused, a dead pid not reused, not initialised, sealed and unsealed said, a server that does not answer reported with its log, a config without the marker line left alone, client mode and a missing bao doing nothing, never fatal. The token helper bao calls (token_helper_test.sh): get, store and erase through `sh -c`, the token at OPENBAO_STATE_DIR/token with mode 600, written atomically, a missing token read as nothing, erase of a missing one no error, a failing store non-zero. `task openbao:logout` (logout_test.sh): revoke then erase, the erase when the revoke fails, nothing stored. Server mode''s tasks (server_test.sh, openbao-server against a stub bao with a small state): the refusal inside an agent''s job for every task that handles a share or a token (init, unseal, seal), by DEVHOST_JOB_CHILD, CLAUDECODE and a variable named in OPENBAO_AGENT_JOB_VARS, with and without a terminal and through the Taskfile, never a call to bao, never a prompt; the refusal without a terminal and in client mode; init under a pseudo-terminal (the backup choice asked for, recorded and asked again when empty or invalid, the Operator''s password asked twice and kept off every argument, 3 shares and threshold 2 printed to the terminal only and written nowhere, the unseal, the configuration in order with the login lifetime of 4 hours and at most 8, the root token used only for it and revoked, also after a failed step, only -pgp-keys accepted, never twice); unseal, seal and status in every state with the standing no-backup warning; the AppRole tasks (the role''s one-secret policy, the loopback binding, 5 minute tokens, one valid secret id written mode 600 and never printed, ALL=1, revoke) and their exit codes. That the real bao gives the signals the stubs play is the container tests test/openbao/dev_server.sh and test/openbao/server_mode.sh (CI). Part of lint. Needs bash, grep and task (script(1) for the terminal checks, skipped without it).' cmds: - bash test/openbao/unit/dcf_secret_test.sh - bash test/openbao/unit/setup_test.sh - bash test/openbao/unit/token_helper_test.sh - bash test/openbao/unit/logout_test.sh + - bash test/openbao/unit/server_test.sh test:ssh-key-import: desc: 'Docker-free bash tests of the host task devcontainer:ssh-key-import in the bash twin (src/devcontainer-cli/devcontainer-cli.sh): the key file reaches `devcontainer exec` on stdin and never in an argument, the .pub follows, the key load runs last, the refusals (no file, no running container, key mode off in the container), and the host-side warnings (default name, a public key also in authorized_keys or another ~/.ssh/*.pub, never printing the key); stub devcontainer and docker record what they get; part of lint. The PowerShell twin is in test:bootstrap.' diff --git a/src/openbao/CHANGELOG.md b/src/openbao/CHANGELOG.md index 20979b4..a3d4bed 100644 --- a/src/openbao/CHANGELOG.md +++ b/src/openbao/CHANGELOG.md @@ -8,6 +8,23 @@ here; `task lint` fails when the entry is missing. Entries from 1.0.0 to 1.0.3 were generated from the commit history (`.github/scripts/changelog.py backfill`) and the "Changes in x.y.z" sections that NOTES.md used to hold; most list commit subjects as written then; the newest of them is in wording for users. +## [1.2.0] - 2026-11-03 + +### Added + +- **Server mode: a local OpenBao server in the container.** Set the `mode` option to `server` (or `OPENBAO_SETUP_MODE=server` in `containerEnv`, no rebuild; needs OpenBao 2.4.0 or newer, and an older pinned `version` fails the build with that said). The post-start hook starts one server with raft storage on the Feature's volume and a loopback-only listener (`127.0.0.1:8200`, no TLS), and it comes up **sealed**: nothing can read a secret until you unseal it. The hook never waits and never prompts; it prints whether the server is not initialised, sealed or unsealed. `mode: client` stays the default and behaves as before. **You must act once, in a plain terminal in the container:** `task openbao:init`, then after every container restart `task openbao:unseal`. +- `task openbao:init`: asks for a **backup destination or the words `no backup`** (never defaulted, and the answer is recorded in the volume), asks for the Operator's password, makes **3 unseal shares with a threshold of 2** and prints them once, only to the terminal (keep one in a password manager, one on paper offline, one with a second person or on a hardware key; losing two loses every secret), has you enter two of them to prove you stored them, sets up the `dcf` KV mount, `userpass` and `approle`, and revokes the root token. `-pgp-keys=...` after `--` encrypts the shares. +- `task openbao:unseal` and `task openbao:seal`. `task openbao:server:status` shows the state, the storage, the audit log, whether your login is valid and the backup choice, with a standing warning while there is none. Your login is `task openbao:login -- -method=userpass username=operator`: it lasts **4 hours, at most 8**, and a renewal past that is refused. +- `task openbao:approle:issue ROLE= POLICY_PATH=` makes a role for a machine identity (for the devhost's backs, later) that may read that one secret, only from inside the container (loopback-bound secret ids and tokens), with 5 minute tokens; `ROLE=` alone or `ALL=1` reissues, leaving exactly one valid secret id per role, written to the volume and never printed. `task openbao:approle:revoke ROLE=` removes a role. **Making a role and deleting one ask for two unseal shares** (typed at a hidden prompt, they make a root token, `bao operator generate-root`, used for that step only and revoked straight after, also on failure); reissuing (`ROLE=` alone, `ALL=1`) uses your login and asks for none. Your login's policy cannot write a policy or make, change or delete a role, so a token of yours can never give a role `snapshot-force`. +- A file audit device in the volume (`audit.log`), declared in the server's config. +- **Not included yet:** `openbao:backup` and restore. A backup destination is recorded but not used, so until a later version a lost machine loses what is in the server's volume (this is why `init` asks and `server:status` warns); take a snapshot by hand with `bao operator raft snapshot save`. + +### Changed + +- **`init`, `unseal`, `seal` and the two steps that ask for shares (making and deleting a role) refuse inside an agent's job** (when `DEVHOST_JOB_CHILD` or `CLAUDECODE` is set, or a variable you name in `OPENBAO_AGENT_JOB_VARS`), and `init`, `unseal` and those two steps refuse without a terminal: the shares and the token must never land in a transcript. Run them yourself in a plain terminal. +- `dcf-secret`'s failure lines now name the tasks: sealed points to `task openbao:unseal`, not initialised to `task openbao:init`, unreachable to `task openbao:server:status`, each adding what to do against a server elsewhere. The exit codes are unchanged. +- In server mode `BAO_ADDR` defaults to the local server for `dcf-secret` and every task when you have not set it (a bare `bao` still needs `BAO_ADDR`). The tasks `status`, `login`, `logout`, `kv:get` and `setup` run the same `bao` call through `openbao-run`, which adds only that default; client mode is unchanged. `task openbao:setup` now also runs the start hook. + ## [1.1.1] - 2026-11-03 ### Changed diff --git a/src/openbao/NOTES.md b/src/openbao/NOTES.md index 125fba2..1d7a4c0 100644 --- a/src/openbao/NOTES.md +++ b/src/openbao/NOTES.md @@ -5,11 +5,18 @@ bao CLI --BAO_ADDR (set in containerEnv)--> OpenBao server dcf-secret --> bao | `--> token helper --> volume openbao-config-/token (or BAO_TOKEN from the environment) + + mode server: post-start --> bao server (127.0.0.1:8200, raft) --> volume openbao-config-/ + sealed until task openbao:unseal raft/, audit.log, approle/ ``` | Data | Where it lives | Storage tier | | --- | --- | --- | | Login token | `token` in the Feature's volume `openbao-config-${devcontainerId}`, kept by a token helper (`BAO_CONFIG_PATH`), so a rebuild keeps the login; or `BAO_TOKEN` in the environment | 4 working set | +| Server storage (mode server) | `raft/` in the same volume: every secret, policy and role of the local server | 4 working set, with ADR 0048's bounded-loss exception: not rebuildable, so a lost machine loses what came after the last backup (backups arrive in 1.3.0; until then a snapshot by hand) | +| Audit log (mode server) | `audit.log` in the same volume | 4 working set | +| AppRole secret ids (mode server) | `approle//` in the same volume, mode 600 | 4 working set (reissued with `task openbao:approle:issue ALL=1`) | +| Unseal shares | never in the container: printed once by `task openbao:init` | custody, outside the tiers (ADR 0049) | Tiers are the four of ADR 0045: 1 git, 2 shared folder, 3 personal folder, 4 working set (rebuildable only). @@ -45,17 +52,21 @@ hook reads to `/usr/local/share/openbao-feature/options.env`. - `version` (default `latest`): set it to the version of the OpenBao server you talk to, so the CLI and the server agree. A value that does not start with an `x.y.z` number fails the build. -- `mode` (default `client`, the only value in this version): the CLI and `dcf-secret` against a - server outside the container, whose address is `BAO_ADDR`. Any other value fails the build. A - later version adds `server`; a consumer that changes nothing keeps `client`. Override per - container with `OPENBAO_SETUP_MODE` in `containerEnv` (the hook reads it; an unknown value is - reported and the hook runs as `client`). +- `mode` (`client`, the default, or `server`): `client` is the CLI and `dcf-secret` against a + server outside the container, whose address is `BAO_ADDR`; a consumer that changes nothing keeps + it. `server` also runs a server in the container (see "Server mode"); it needs OpenBao 2.4.0 or + newer, and a build with an older pinned `version` fails with that said. Any other value fails the + build. Override per container with `OPENBAO_SETUP_MODE` in `containerEnv` (the hooks read it, no + rebuild; an unknown value is reported and the hooks run as `client`). A container built as `client` + and switched to `server` this way works, because the binary is the same; only the build-time + version check is skipped. - `installTaskfile` (default `true`): set it to `false` when you do not use the go-task registry and do not want `/usr/local/share/go-task/includes.d/openbao.yml` in the image. `version` and `installTaskfile` are fixed at build. `mode` has the run-time twin -`OPENBAO_SETUP_MODE`. The server address is a run-time setting: see State. +`OPENBAO_SETUP_MODE`. The server address is a run-time setting: see State. In server mode it +defaults to the local server. ## State @@ -74,7 +85,7 @@ Owned by the Feature (NFR-1), like `gh` and `az`: - The post-create hook `openbao-setup` (also `task openbao:setup`) runs `dcf-persist`, so the folder belongs to the remote user and the log says what backs it, and says whether the token helper is in effect. A token already in `~/.vault-token` (a login from before this Feature) is - moved into the volume once, when the volume has none. The hook never contacts the server and + moved into the volume once, when the volume has none. The post-create hook never contacts the server and never fails the rest of post-create. How `bao` finds the helper, confirmed in OpenBao's source (`internal/command/config` and @@ -86,7 +97,11 @@ to the volume at that path is replaced by the first `bao login`, which is why th not use one. The helper is a few lines of `sh` (`openbao-token-helper`) and the token is written atomically. -- **The server address** is `BAO_ADDR`, which the Feature does not set. Put it in `containerEnv`. +- **The server address** is `BAO_ADDR`, which the Feature does not set in `containerEnv`: its + metadata is static, so an option cannot change it, and client mode keeps the CLI's own default. + Put it in `containerEnv`. In server mode, when it is unset, `dcf-secret`, `openbao-setup` and + every task of this Feature use `http://127.0.0.1:8200` (the local server); a bare `bao` does not, + so give it `BAO_ADDR` (export it, or put it in `containerEnv`) or go through the tasks. - **A repo that sets its own `BAO_CONFIG_PATH`** (or whose `containerEnv` leaves this one out) replaces the Feature's config; the hook says so. Add `token_helper = "/usr/local/bin/openbao-token-helper"` to that config to keep the login in the volume. A repo @@ -108,6 +123,143 @@ atomically. Upgrading from 1.0.x: the volume starts empty, so log in once after the rebuild; a login made before it, in `~/.vault-token` of the old container, is gone with that container. +## Server mode + +`mode: server` runs an OpenBao server inside the container: one node, **raft** storage on the +Feature's volume, one listener on `127.0.0.1:8200` with no TLS (it never leaves the container's +loopback; another container cannot reach it), started at post-start and **sealed until the Operator +unseals it**. Client mode stays the default and is unchanged. Why a local server and what a machine +loss costs: ADR 0048 and the "Backup" paragraph below. + +```json +"features": { + "ghcr.io/nabil-becker/devcontainer-features/openbao:1": { "mode": "server", "version": "2.7.1" }, + "ghcr.io/nabil-becker/devcontainer-features/go-task:1": {} +} +``` + +**The start hook** (`postStartCommand`, `openbao-setup start`, also run by `task openbao:setup`) +writes the server's config into the volume, starts `bao server` in the background (its pid in +`server.pid`, its log in `server.log`; a live server is reused), waits only for the listener to +answer (at most 20 seconds), and prints one line: `not initialised; the Operator runs task +openbao:init`, `sealed; the Operator runs task openbao:unseal`, or `running and unsealed`. It never +waits for an unseal and never prompts (a start hook that waits holds every Feature's start), and it +never fails the start. The config is `server.hcl` in the volume; while its first line is the +Feature's marker it is rewritten on every start, so an upgrade reaches it; delete the marker line to +keep your own edits. It holds: `storage "raft"` at `raft/` with `node_id = "devhost"`, one `tcp` +listener on `127.0.0.1:8200` with `tls_disable`, `api_addr` and `cluster_addr` on loopback (raft +requires `cluster_addr`), no UI, and the file **audit device** `audit.log`, declared in the config +(OpenBao 2.4.0 and later: a server refuses an audit device made through the API unless +`unsafe_allow_api_audit_creation` is set, and the declarative stanza is the supported way). The log +of every request, with secrets hashed, names each identity that read a key. + +**The gate.** Two steps, both the Operator's: + +```text + container start task openbao:unseal task openbao:login -- -method=userpass username=operator + | (2 of the 3 shares) (per sitting) + v | | + [ sealed ] ----------------> [ unsealed ] ------------> [ the Operator's token: 4 hours, at most 8 ] + dcf-secret: exit 11 AppRoles can log in dcf-secret reads and writes (exit 12 when it ends) +``` + +- **`task openbao:init`** (once). Interactive, in a plain terminal only. It asks for a **backup + destination** (an absolute folder path that exists and is writable, or the words `no backup`; an + empty answer is asked again: the choice is never defaulted) and for the **Operator's password** + (twice, 12 characters at least); initialises with **3 unseal shares and a threshold of 2**; prints + the shares to the terminal, once, and says to keep one in a password manager, one on paper + offline and one with a second trusted person or on a hardware key, never in git, the container or + where backups go; waits for Enter and then asks for two of them at `bao`'s own hidden prompt (so + the unseal proves they were stored correctly); enables the `dcf` KV v2 mount, `userpass` and + `approle`, writes the Operator's policy and the user `operator`; records the backup choice; and + **revokes the root token**. The root token and the shares live in the task's memory only, never + in a file or an argument. `-pgp-keys=...` after `--` encrypts the shares to your keys (you decrypt + them yourself before the unseal step; not covered by a test). A failed configuration step revokes + the root token as well and says how to start over: the store holds nothing yet, so stop the + server, delete `raft/` in the volume and run the start hook and `init` again (new shares). Run + twice it refuses: the server is initialised. +- **`task openbao:unseal`**: asks for shares at `bao`'s hidden prompt until the server is unsealed + (needed after every server start, so after a container restart). `task openbao:seal` seals it + with the saved login (`bao operator seal`; the Operator's policy has `update` and `sudo` on + `sys/seal`). +- **The login**: `userpass`, user `operator`, token `ttl` **4 hours**, `max_ttl` **8 hours**; a + renewal past 8 hours is capped (`bao token renew` says so). `task openbao:logout` ends it early. A + night shift that must sign overnight starts with a login made for it. These lifetimes are set on + the server this Feature runs; against another server they are its owner's. +- **The Operator's policy**: read and write everything under the `dcf` mount, the snapshot read + (`sys/storage/raft/snapshot`, for the backup of a later version), `update` and `sudo` on + `sys/seal`, and the **reissue of secret ids for roles that already exist** (list the roles, read a + role's role id, list a role's secret-id accessors, destroy a secret id by accessor, make a new one). + It may also start and cancel a root-token generation (`sys/generate-root-token/*`), which only the + shares can complete. **Not** any policy (`sys/policies/acl/*`), not making, changing or deleting a + role, not `snapshot-force`, audit devices, or any other `sys/` path: a token that could write a + policy could write one with `snapshot-force` and log in through a role holding it, which would + sidestep the rule that restore needs the shares. Making and deleting a role therefore take a root + token made from the shares, for that step only (below). + +**Agents.** `init`, `unseal`, `seal`, and the two steps that ask for the shares (making a role, +deleting a role) **refuse inside an agent's job**: when `DEVHOST_JOB_CHILD` +(the devhost's backs and its test run) or `CLAUDECODE` (set by Claude Code on every process it +starts) is set, or a variable named in `OPENBAO_AGENT_JOB_VARS` (space separated, for another agent +CLI), each saying why and exiting 3: the shares and the token must never land in a transcript, which +the scribe reads. `init`, `unseal` and those two steps also refuse without a terminal on stdin and stdout (they ask +for secrets and print them only to a terminal), and all server-mode tasks refuse in client mode. +Run them yourself in a plain terminal in the devcontainer (a terminal opened from inside a Claude +Code session inherits `CLAUDECODE` and is refused too: open a fresh one). A refusal is no call to `bao`. + +**AppRole** (`task openbao:approle:issue`, `:revoke`; server mode). One role per machine identity, +generic: the Feature never calls a task of another Feature. `ROLE= POLICY_PATH=` +**asks for the unseal shares** (it writes a policy): it makes a root token with `bao operator +generate-root` (a one-time password held in a variable, two shares typed at a hidden prompt, the +token decoded in memory), runs that step with it, and revokes it straight after, also when the step +fails or is interrupted (an abandoned generation is cancelled). No share, password or token is +printed, logged, put in a file or passed as an argument but the one-time password, which is useless +without the encoded token (that goes over stdin). It makes the role (lower case name) with a policy `approle-` that lets it read that one secret +and nothing else, **bound to loopback**: `secret_id_bound_cidrs` and `token_bound_cidrs` of +`127.0.0.1/32`, so a stolen secret id or token works only from inside this container, and only while +the server is unsealed; tokens last 5 minutes, at most 15; secret ids do not expire (an expiring one +would stop the backs on a date nobody is watching). `ROLE=` alone reissues an existing role, +and `ALL=1` every role in the store (after a restore or on suspicion); both use the Operator's login +only and ask for no share. A reissue leaves **exactly one +valid secret id**: the others of the role are destroyed by their accessors first (issuing a new one +does not end the old). The secret id and the role id are written to `approle//secret_id` and +`role_id` in the volume (mode 600, folder 700), never printed. `approle:revoke ROLE=` deletes +the role (OpenBao's role deletion destroys its secret ids) and its policy with a root token from the +shares, as above, and then its files; a token it +already made lives at most 15 minutes. There is no `DCF_SECRET_AS` yet: nothing logs in as a role in +this version except by hand, the backs are wired in a later slice. + +**`task openbao:server:status`** (both modes; exit 0 unsealed, 10 unreachable, 11 sealed or not +initialised): the mode, the address, the server's state, and in server mode the process, the +storage, the audit log, the backup choice and whether the login is valid. + +**Backup, and ADR 0045.** The live store stays in the volume (tier 4): raft's database is written in +place and under a lock, which no `cloud-folder` mode promises a database. So a lost machine loses +what was written since the last backup, a **bounded-loss exception to ADR 0045 for server mode** +(ADR 0048); it is a choice, never a silent default: `init` asks, records the answer in the volume +(`backup-choice`), and `server:status` shows the destination, or a standing `WARNING no backup`. +**This version has no `openbao:backup` yet** (the next minor adds it, with restore): until then a +destination is only recorded, and a backup is `bao operator raft snapshot save` by hand. The unseal +shares, the other thing a machine loss must not take, are kept off the machine by `init`'s +instructions. A snapshot is ciphertext, useless without the shares and everything with them. + +**What was confirmed in OpenBao's own documentation and source** (github.com/openbao/openbao, +checked on the tree of 2026-11-03): OpenBao has used **no `mlock` since 2.0.0** (the RFC +"mlock removal"; the migration guide says to remove `disable_mlock`, and it is an unknown field in +the config), so a container without `IPC_LOCK` needs nothing and the Feature writes no +`disable_mlock`; raft needs `cluster_addr` (and `api_addr` is set so the server does not guess); the +declarative `audit` stanza exists from 2.4.0, hence the version floor; self-initialisation +(`initialize` stanza) needs an auto-unseal seal and returns no shares, so it does not fit the Shamir +gate and `init` is the CLI's own `bao operator init`; AppRole's `secret_id_ttl` defaults to 0 (no +expiry), `secret_id_bound_cidrs` is checked at login and `token_bound_cidrs` on every use of the +token, deleting a role destroys its secret ids, and a secret id can be destroyed by its accessor; +`bao operator init` prints `Unseal Key N:` and `Initial Root Token:`, and `bao operator unseal` +reads a share at a hidden prompt that needs a terminal. The container test `server_mode` runs all +of it against a real server. + +Files in the volume for server mode: `server.hcl`, `server.pid`, `server.log`, `raft/`, +`audit.log`, `backup-choice`, `approle//{secret_id,role_id}`, and the login `token`. + ## `dcf-secret` The interface every Feature and script of this collection uses to read and write a secret, so @@ -137,7 +289,10 @@ dcf-secret status [] one word on stdout, the fix on stderr - **No fallback, ever.** On a non-zero exit nothing is read from a file, a volume or the environment instead. A second source would be a second master. -Exit codes, the same for every caller, each with one plain line on stderr naming the fix: +Exit codes, the same for every caller, each with one plain line on stderr naming the fix (the +sealed line names `task openbao:unseal`, not initialised names `task openbao:init`, unreachable +names `task openbao:server:status`, each adding what to do against a server that is not this +container's): | Exit | Meaning | `status` prints | | --- | --- | --- | @@ -188,6 +343,7 @@ bao version # prints the installed version task openbao:version openbao-setup # the post-create report, any time dcf-secret status # needs BAO_ADDR; prints ready, or why not (exit codes above) +task openbao:server:status # server mode: the state, the storage, the backup choice ``` The build log carries the first line (`openbao: downloading OpenBao @@ -204,6 +360,8 @@ login across rebuilds; `image` means the mount is missing. the Taskfile registry exists and is refreshed) and the official `common-utils`. - It augments no official Feature and replaces none. +- Server mode is what the later slices of `docs/proposals/secrets-master-split.md` build on: backup + and restore, onboarding, the `openbao:ssh:*` records and `ssh-agent`'s `key:load` from the store. - `dcf-secret` is what later Features (`ssh-agent`, `gh`, `az`, `cloud-folder`) are to call for their secrets; none does yet, and none names this Feature in `installsAfter` (the Features stay independent at build time). @@ -226,8 +384,22 @@ registry, these appear under `openbao:`: flags go after `--`, e.g. `task openbao:kv:get KEY=secret/app -- -format=json`. +- `openbao:server:status`: mode, address, the server's state, and in server mode the process, the + storage, the audit log, the backup choice (a standing warning when there is none) and the login. +- `openbao:init`: server mode, once, in a plain terminal: the backup choice, the Operator's + password, the unseal shares (printed once to the terminal), the first unseal and the setup; + revokes the root token. Refuses inside an agent's job. +- `openbao:unseal`: server mode: shares at the hidden prompt until unsealed. Refuses inside an + agent's job. +- `openbao:seal`: server mode: seal the server with the saved login. Refuses inside an agent's job. +- `openbao:approle:issue`: server mode: `ROLE= POLICY_PATH=` makes a loopback-bound + role that reads one secret (asks for two unseal shares; refuses inside an agent's job); + `ROLE=` or `ALL=1` reissues with your login, no shares; one valid secret id each. +- `openbao:approle:revoke`: server mode: `ROLE=` deletes the role, its secret ids, its policy + and its files (asks for two unseal shares; refuses inside an agent's job). - `openbao:setup`: re-run the post-create hook (`openbao-setup`): make the state directory usable, - move a token left in `~/.vault-token` into it, report the token helper, the address and the saved login. + move a token left in `~/.vault-token` into it, report the token helper, the address and the saved login; in + server mode also the start hook. No task reads a variable besides `KEY`; the address and token come from `BAO_ADDR` and `BAO_TOKEN` in the container's environment, as for `bao` @@ -247,7 +419,16 @@ itself. engine) or the hook's first line says `image` instead of `volume`. Log in again. - `put` without `--replace` needs the `patch` capability on the secret's path; a policy that has only `create` and `update` must use `--replace`, which drops the secret's other fields. -- Client mode only: there is no server, backup, onboarding or `DCF_SECRET_AS` in this version. +- No `openbao:backup`, restore, onboarding or `DCF_SECRET_AS` yet (later versions); in server mode a + destination is recorded, not used. No task changes the Operator's password or a policy: both need a + root token made from the shares (`bao operator generate-root`, used once and revoked); then `bao + write auth/userpass/users/operator/password password=-` changes the password. +- Server mode: the shares are the keys to everything; losing two of three loses every secret. The + loopback listener has no TLS: any process of the container can reach it and read the Operator's + saved token (mode 600, the remote user's), as with any login kept in a volume; the token's 4 hour + lifetime is the bound. `server.log` and `audit.log` grow without rotation. The server stops with + the container and comes back sealed (the start hook says so); a server that does not answer is + reported with the end of its log. Pinning `version` below 2.4.0 fails the build in server mode. - `dcf-secret` asks `bao` up to three questions after a failure, so a failure costs a few round trips; a success costs one. It assumes a KV v2 mount (the data lives under `/data/`). - The login is kept by a token helper `bao` runs as a shell command on every call that needs the @@ -282,4 +463,9 @@ elsewhere (`devcontainers-extra`, `postfinance`, `roul`) install HashiCorp's `va it to your server's version. The Feature's own version is the tag you reference (`openbao:1` follows 1.x; see Changes). **1.1.0** adds the volume, `openbao-setup`, `dcf-secret` and `mode`, all additive: rebuild to get them, and log in once, because the volume starts empty. -Nothing is removed; `mode` stays `client`. +Nothing is removed; `mode` stays `client`. **1.2.0** adds `mode: server` (the `server` value, the +start hook, `openbao:init`, `:unseal`, `:seal`, `:server:status`, `:approle:issue`, `:approle:revoke`, +`openbao-server`, `openbao-run`), all additive: rebuild to get them, and nothing changes for a +container that stays `client`, except that the tasks `status`, `login`, `logout` and `kv:get` now go +through `openbao-run` (the same `bao` call; in client mode nothing is added) and the +`dcf-secret` failure lines name the new tasks. diff --git a/src/openbao/README.md b/src/openbao/README.md index ce77b5d..57463e6 100644 --- a/src/openbao/README.md +++ b/src/openbao/README.md @@ -1,7 +1,7 @@ # OpenBao CLI (openbao) -Installs the OpenBao CLI (https://openbao.org), the open-source Vault fork, as a release binary at /usr/local/bin/bao, verified against the release's checksums.txt. Owns the login state as a named volume at OPENBAO_STATE_DIR=/var/lib/devcontainer-features/openbao (bao's token helper, set through BAO_CONFIG_PATH, keeps the token in it) so `bao login` survives rebuilds, with the post-create hook openbao-setup, and installs dcf-secret, the one command the collection's Features use to read and write secrets (get, put, status, with fixed exit codes). Set BAO_ADDR in containerEnv to point it at your server. +Installs the OpenBao CLI (https://openbao.org), the open-source Vault fork, as a release binary at /usr/local/bin/bao, verified against the release's checksums.txt. Owns the login state as a named volume at OPENBAO_STATE_DIR=/var/lib/devcontainer-features/openbao (bao's token helper, set through BAO_CONFIG_PATH, keeps the token in it) so `bao login` survives rebuilds, with the post-create hook openbao-setup, and installs dcf-secret, the one command the collection's Features use to read and write secrets (get, put, status, with fixed exit codes). Mode client (the default) is the CLI against a server of yours (set BAO_ADDR in containerEnv). Mode server also runs a server inside the container, started at post-start with raft storage on the same volume and sealed until the Operator unseals it: tasks openbao:init (unseal shares printed only to a terminal, a backup choice asked for), openbao:unseal, openbao:seal, userpass login for the Operator, loopback-bound AppRole roles (openbao:approle:issue) and openbao:server:status. ## Example Usage @@ -16,7 +16,7 @@ Installs the OpenBao CLI (https://openbao.org), the open-source Vault fork, as a | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| | installTaskfile | Also install this Feature's Taskfile into /usr/local/share/go-task/includes.d so its tasks appear under the : namespace of a root Taskfile that includes /usr/local/share/go-task/features.yml (see the go-task Feature). | boolean | true | -| mode | How the Feature is used. Only 'client' exists in this version: the bao CLI and dcf-secret against a server outside the container (BAO_ADDR). Override per container with OPENBAO_SETUP_MODE in containerEnv. | string | client | +| mode | How the Feature is used. 'client' (default): the bao CLI and dcf-secret against a server outside the container (BAO_ADDR). 'server': also run a server in the container (raft storage on the Feature's volume, loopback only, started at post-start, sealed until `task openbao:unseal`; needs OpenBao 2.4.0 or newer). Override per container with OPENBAO_SETUP_MODE in containerEnv. | string | client | | version | OpenBao release to install (e.g. '2.7.1', with or without a leading 'v'), or 'latest'. Match your OpenBao server's version. | string | latest | ## How it fits @@ -26,11 +26,18 @@ Installs the OpenBao CLI (https://openbao.org), the open-source Vault fork, as a bao CLI --BAO_ADDR (set in containerEnv)--> OpenBao server dcf-secret --> bao | `--> token helper --> volume openbao-config-/token (or BAO_TOKEN from the environment) + + mode server: post-start --> bao server (127.0.0.1:8200, raft) --> volume openbao-config-/ + sealed until task openbao:unseal raft/, audit.log, approle/ ``` | Data | Where it lives | Storage tier | | --- | --- | --- | | Login token | `token` in the Feature's volume `openbao-config-${devcontainerId}`, kept by a token helper (`BAO_CONFIG_PATH`), so a rebuild keeps the login; or `BAO_TOKEN` in the environment | 4 working set | +| Server storage (mode server) | `raft/` in the same volume: every secret, policy and role of the local server | 4 working set, with ADR 0048's bounded-loss exception: not rebuildable, so a lost machine loses what came after the last backup (backups arrive in 1.3.0; until then a snapshot by hand) | +| Audit log (mode server) | `audit.log` in the same volume | 4 working set | +| AppRole secret ids (mode server) | `approle//` in the same volume, mode 600 | 4 working set (reissued with `task openbao:approle:issue ALL=1`) | +| Unseal shares | never in the container: printed once by `task openbao:init` | custody, outside the tiers (ADR 0049) | Tiers are the four of ADR 0045: 1 git, 2 shared folder, 3 personal folder, 4 working set (rebuildable only). @@ -66,17 +73,21 @@ hook reads to `/usr/local/share/openbao-feature/options.env`. - `version` (default `latest`): set it to the version of the OpenBao server you talk to, so the CLI and the server agree. A value that does not start with an `x.y.z` number fails the build. -- `mode` (default `client`, the only value in this version): the CLI and `dcf-secret` against a - server outside the container, whose address is `BAO_ADDR`. Any other value fails the build. A - later version adds `server`; a consumer that changes nothing keeps `client`. Override per - container with `OPENBAO_SETUP_MODE` in `containerEnv` (the hook reads it; an unknown value is - reported and the hook runs as `client`). +- `mode` (`client`, the default, or `server`): `client` is the CLI and `dcf-secret` against a + server outside the container, whose address is `BAO_ADDR`; a consumer that changes nothing keeps + it. `server` also runs a server in the container (see "Server mode"); it needs OpenBao 2.4.0 or + newer, and a build with an older pinned `version` fails with that said. Any other value fails the + build. Override per container with `OPENBAO_SETUP_MODE` in `containerEnv` (the hooks read it, no + rebuild; an unknown value is reported and the hooks run as `client`). A container built as `client` + and switched to `server` this way works, because the binary is the same; only the build-time + version check is skipped. - `installTaskfile` (default `true`): set it to `false` when you do not use the go-task registry and do not want `/usr/local/share/go-task/includes.d/openbao.yml` in the image. `version` and `installTaskfile` are fixed at build. `mode` has the run-time twin -`OPENBAO_SETUP_MODE`. The server address is a run-time setting: see State. +`OPENBAO_SETUP_MODE`. The server address is a run-time setting: see State. In server mode it +defaults to the local server. ## State @@ -95,7 +106,7 @@ Owned by the Feature (NFR-1), like `gh` and `az`: - The post-create hook `openbao-setup` (also `task openbao:setup`) runs `dcf-persist`, so the folder belongs to the remote user and the log says what backs it, and says whether the token helper is in effect. A token already in `~/.vault-token` (a login from before this Feature) is - moved into the volume once, when the volume has none. The hook never contacts the server and + moved into the volume once, when the volume has none. The post-create hook never contacts the server and never fails the rest of post-create. How `bao` finds the helper, confirmed in OpenBao's source (`internal/command/config` and @@ -107,7 +118,11 @@ to the volume at that path is replaced by the first `bao login`, which is why th not use one. The helper is a few lines of `sh` (`openbao-token-helper`) and the token is written atomically. -- **The server address** is `BAO_ADDR`, which the Feature does not set. Put it in `containerEnv`. +- **The server address** is `BAO_ADDR`, which the Feature does not set in `containerEnv`: its + metadata is static, so an option cannot change it, and client mode keeps the CLI's own default. + Put it in `containerEnv`. In server mode, when it is unset, `dcf-secret`, `openbao-setup` and + every task of this Feature use `http://127.0.0.1:8200` (the local server); a bare `bao` does not, + so give it `BAO_ADDR` (export it, or put it in `containerEnv`) or go through the tasks. - **A repo that sets its own `BAO_CONFIG_PATH`** (or whose `containerEnv` leaves this one out) replaces the Feature's config; the hook says so. Add `token_helper = "/usr/local/bin/openbao-token-helper"` to that config to keep the login in the volume. A repo @@ -129,6 +144,143 @@ atomically. Upgrading from 1.0.x: the volume starts empty, so log in once after the rebuild; a login made before it, in `~/.vault-token` of the old container, is gone with that container. +## Server mode + +`mode: server` runs an OpenBao server inside the container: one node, **raft** storage on the +Feature's volume, one listener on `127.0.0.1:8200` with no TLS (it never leaves the container's +loopback; another container cannot reach it), started at post-start and **sealed until the Operator +unseals it**. Client mode stays the default and is unchanged. Why a local server and what a machine +loss costs: ADR 0048 and the "Backup" paragraph below. + +```json +"features": { + "ghcr.io/nabil-becker/devcontainer-features/openbao:1": { "mode": "server", "version": "2.7.1" }, + "ghcr.io/nabil-becker/devcontainer-features/go-task:1": {} +} +``` + +**The start hook** (`postStartCommand`, `openbao-setup start`, also run by `task openbao:setup`) +writes the server's config into the volume, starts `bao server` in the background (its pid in +`server.pid`, its log in `server.log`; a live server is reused), waits only for the listener to +answer (at most 20 seconds), and prints one line: `not initialised; the Operator runs task +openbao:init`, `sealed; the Operator runs task openbao:unseal`, or `running and unsealed`. It never +waits for an unseal and never prompts (a start hook that waits holds every Feature's start), and it +never fails the start. The config is `server.hcl` in the volume; while its first line is the +Feature's marker it is rewritten on every start, so an upgrade reaches it; delete the marker line to +keep your own edits. It holds: `storage "raft"` at `raft/` with `node_id = "devhost"`, one `tcp` +listener on `127.0.0.1:8200` with `tls_disable`, `api_addr` and `cluster_addr` on loopback (raft +requires `cluster_addr`), no UI, and the file **audit device** `audit.log`, declared in the config +(OpenBao 2.4.0 and later: a server refuses an audit device made through the API unless +`unsafe_allow_api_audit_creation` is set, and the declarative stanza is the supported way). The log +of every request, with secrets hashed, names each identity that read a key. + +**The gate.** Two steps, both the Operator's: + +```text + container start task openbao:unseal task openbao:login -- -method=userpass username=operator + | (2 of the 3 shares) (per sitting) + v | | + [ sealed ] ----------------> [ unsealed ] ------------> [ the Operator's token: 4 hours, at most 8 ] + dcf-secret: exit 11 AppRoles can log in dcf-secret reads and writes (exit 12 when it ends) +``` + +- **`task openbao:init`** (once). Interactive, in a plain terminal only. It asks for a **backup + destination** (an absolute folder path that exists and is writable, or the words `no backup`; an + empty answer is asked again: the choice is never defaulted) and for the **Operator's password** + (twice, 12 characters at least); initialises with **3 unseal shares and a threshold of 2**; prints + the shares to the terminal, once, and says to keep one in a password manager, one on paper + offline and one with a second trusted person or on a hardware key, never in git, the container or + where backups go; waits for Enter and then asks for two of them at `bao`'s own hidden prompt (so + the unseal proves they were stored correctly); enables the `dcf` KV v2 mount, `userpass` and + `approle`, writes the Operator's policy and the user `operator`; records the backup choice; and + **revokes the root token**. The root token and the shares live in the task's memory only, never + in a file or an argument. `-pgp-keys=...` after `--` encrypts the shares to your keys (you decrypt + them yourself before the unseal step; not covered by a test). A failed configuration step revokes + the root token as well and says how to start over: the store holds nothing yet, so stop the + server, delete `raft/` in the volume and run the start hook and `init` again (new shares). Run + twice it refuses: the server is initialised. +- **`task openbao:unseal`**: asks for shares at `bao`'s hidden prompt until the server is unsealed + (needed after every server start, so after a container restart). `task openbao:seal` seals it + with the saved login (`bao operator seal`; the Operator's policy has `update` and `sudo` on + `sys/seal`). +- **The login**: `userpass`, user `operator`, token `ttl` **4 hours**, `max_ttl` **8 hours**; a + renewal past 8 hours is capped (`bao token renew` says so). `task openbao:logout` ends it early. A + night shift that must sign overnight starts with a login made for it. These lifetimes are set on + the server this Feature runs; against another server they are its owner's. +- **The Operator's policy**: read and write everything under the `dcf` mount, the snapshot read + (`sys/storage/raft/snapshot`, for the backup of a later version), `update` and `sudo` on + `sys/seal`, and the **reissue of secret ids for roles that already exist** (list the roles, read a + role's role id, list a role's secret-id accessors, destroy a secret id by accessor, make a new one). + It may also start and cancel a root-token generation (`sys/generate-root-token/*`), which only the + shares can complete. **Not** any policy (`sys/policies/acl/*`), not making, changing or deleting a + role, not `snapshot-force`, audit devices, or any other `sys/` path: a token that could write a + policy could write one with `snapshot-force` and log in through a role holding it, which would + sidestep the rule that restore needs the shares. Making and deleting a role therefore take a root + token made from the shares, for that step only (below). + +**Agents.** `init`, `unseal`, `seal`, and the two steps that ask for the shares (making a role, +deleting a role) **refuse inside an agent's job**: when `DEVHOST_JOB_CHILD` +(the devhost's backs and its test run) or `CLAUDECODE` (set by Claude Code on every process it +starts) is set, or a variable named in `OPENBAO_AGENT_JOB_VARS` (space separated, for another agent +CLI), each saying why and exiting 3: the shares and the token must never land in a transcript, which +the scribe reads. `init`, `unseal` and those two steps also refuse without a terminal on stdin and stdout (they ask +for secrets and print them only to a terminal), and all server-mode tasks refuse in client mode. +Run them yourself in a plain terminal in the devcontainer (a terminal opened from inside a Claude +Code session inherits `CLAUDECODE` and is refused too: open a fresh one). A refusal is no call to `bao`. + +**AppRole** (`task openbao:approle:issue`, `:revoke`; server mode). One role per machine identity, +generic: the Feature never calls a task of another Feature. `ROLE= POLICY_PATH=` +**asks for the unseal shares** (it writes a policy): it makes a root token with `bao operator +generate-root` (a one-time password held in a variable, two shares typed at a hidden prompt, the +token decoded in memory), runs that step with it, and revokes it straight after, also when the step +fails or is interrupted (an abandoned generation is cancelled). No share, password or token is +printed, logged, put in a file or passed as an argument but the one-time password, which is useless +without the encoded token (that goes over stdin). It makes the role (lower case name) with a policy `approle-` that lets it read that one secret +and nothing else, **bound to loopback**: `secret_id_bound_cidrs` and `token_bound_cidrs` of +`127.0.0.1/32`, so a stolen secret id or token works only from inside this container, and only while +the server is unsealed; tokens last 5 minutes, at most 15; secret ids do not expire (an expiring one +would stop the backs on a date nobody is watching). `ROLE=` alone reissues an existing role, +and `ALL=1` every role in the store (after a restore or on suspicion); both use the Operator's login +only and ask for no share. A reissue leaves **exactly one +valid secret id**: the others of the role are destroyed by their accessors first (issuing a new one +does not end the old). The secret id and the role id are written to `approle//secret_id` and +`role_id` in the volume (mode 600, folder 700), never printed. `approle:revoke ROLE=` deletes +the role (OpenBao's role deletion destroys its secret ids) and its policy with a root token from the +shares, as above, and then its files; a token it +already made lives at most 15 minutes. There is no `DCF_SECRET_AS` yet: nothing logs in as a role in +this version except by hand, the backs are wired in a later slice. + +**`task openbao:server:status`** (both modes; exit 0 unsealed, 10 unreachable, 11 sealed or not +initialised): the mode, the address, the server's state, and in server mode the process, the +storage, the audit log, the backup choice and whether the login is valid. + +**Backup, and ADR 0045.** The live store stays in the volume (tier 4): raft's database is written in +place and under a lock, which no `cloud-folder` mode promises a database. So a lost machine loses +what was written since the last backup, a **bounded-loss exception to ADR 0045 for server mode** +(ADR 0048); it is a choice, never a silent default: `init` asks, records the answer in the volume +(`backup-choice`), and `server:status` shows the destination, or a standing `WARNING no backup`. +**This version has no `openbao:backup` yet** (the next minor adds it, with restore): until then a +destination is only recorded, and a backup is `bao operator raft snapshot save` by hand. The unseal +shares, the other thing a machine loss must not take, are kept off the machine by `init`'s +instructions. A snapshot is ciphertext, useless without the shares and everything with them. + +**What was confirmed in OpenBao's own documentation and source** (github.com/openbao/openbao, +checked on the tree of 2026-11-03): OpenBao has used **no `mlock` since 2.0.0** (the RFC +"mlock removal"; the migration guide says to remove `disable_mlock`, and it is an unknown field in +the config), so a container without `IPC_LOCK` needs nothing and the Feature writes no +`disable_mlock`; raft needs `cluster_addr` (and `api_addr` is set so the server does not guess); the +declarative `audit` stanza exists from 2.4.0, hence the version floor; self-initialisation +(`initialize` stanza) needs an auto-unseal seal and returns no shares, so it does not fit the Shamir +gate and `init` is the CLI's own `bao operator init`; AppRole's `secret_id_ttl` defaults to 0 (no +expiry), `secret_id_bound_cidrs` is checked at login and `token_bound_cidrs` on every use of the +token, deleting a role destroys its secret ids, and a secret id can be destroyed by its accessor; +`bao operator init` prints `Unseal Key N:` and `Initial Root Token:`, and `bao operator unseal` +reads a share at a hidden prompt that needs a terminal. The container test `server_mode` runs all +of it against a real server. + +Files in the volume for server mode: `server.hcl`, `server.pid`, `server.log`, `raft/`, +`audit.log`, `backup-choice`, `approle//{secret_id,role_id}`, and the login `token`. + ## `dcf-secret` The interface every Feature and script of this collection uses to read and write a secret, so @@ -158,7 +310,10 @@ dcf-secret status [] one word on stdout, the fix on stderr - **No fallback, ever.** On a non-zero exit nothing is read from a file, a volume or the environment instead. A second source would be a second master. -Exit codes, the same for every caller, each with one plain line on stderr naming the fix: +Exit codes, the same for every caller, each with one plain line on stderr naming the fix (the +sealed line names `task openbao:unseal`, not initialised names `task openbao:init`, unreachable +names `task openbao:server:status`, each adding what to do against a server that is not this +container's): | Exit | Meaning | `status` prints | | --- | --- | --- | @@ -209,6 +364,7 @@ bao version # prints the installed version task openbao:version openbao-setup # the post-create report, any time dcf-secret status # needs BAO_ADDR; prints ready, or why not (exit codes above) +task openbao:server:status # server mode: the state, the storage, the backup choice ``` The build log carries the first line (`openbao: downloading OpenBao @@ -225,6 +381,8 @@ login across rebuilds; `image` means the mount is missing. the Taskfile registry exists and is refreshed) and the official `common-utils`. - It augments no official Feature and replaces none. +- Server mode is what the later slices of `docs/proposals/secrets-master-split.md` build on: backup + and restore, onboarding, the `openbao:ssh:*` records and `ssh-agent`'s `key:load` from the store. - `dcf-secret` is what later Features (`ssh-agent`, `gh`, `az`, `cloud-folder`) are to call for their secrets; none does yet, and none names this Feature in `installsAfter` (the Features stay independent at build time). @@ -247,8 +405,22 @@ registry, these appear under `openbao:`: flags go after `--`, e.g. `task openbao:kv:get KEY=secret/app -- -format=json`. +- `openbao:server:status`: mode, address, the server's state, and in server mode the process, the + storage, the audit log, the backup choice (a standing warning when there is none) and the login. +- `openbao:init`: server mode, once, in a plain terminal: the backup choice, the Operator's + password, the unseal shares (printed once to the terminal), the first unseal and the setup; + revokes the root token. Refuses inside an agent's job. +- `openbao:unseal`: server mode: shares at the hidden prompt until unsealed. Refuses inside an + agent's job. +- `openbao:seal`: server mode: seal the server with the saved login. Refuses inside an agent's job. +- `openbao:approle:issue`: server mode: `ROLE= POLICY_PATH=` makes a loopback-bound + role that reads one secret (asks for two unseal shares; refuses inside an agent's job); + `ROLE=` or `ALL=1` reissues with your login, no shares; one valid secret id each. +- `openbao:approle:revoke`: server mode: `ROLE=` deletes the role, its secret ids, its policy + and its files (asks for two unseal shares; refuses inside an agent's job). - `openbao:setup`: re-run the post-create hook (`openbao-setup`): make the state directory usable, - move a token left in `~/.vault-token` into it, report the token helper, the address and the saved login. + move a token left in `~/.vault-token` into it, report the token helper, the address and the saved login; in + server mode also the start hook. No task reads a variable besides `KEY`; the address and token come from `BAO_ADDR` and `BAO_TOKEN` in the container's environment, as for `bao` @@ -268,7 +440,16 @@ itself. engine) or the hook's first line says `image` instead of `volume`. Log in again. - `put` without `--replace` needs the `patch` capability on the secret's path; a policy that has only `create` and `update` must use `--replace`, which drops the secret's other fields. -- Client mode only: there is no server, backup, onboarding or `DCF_SECRET_AS` in this version. +- No `openbao:backup`, restore, onboarding or `DCF_SECRET_AS` yet (later versions); in server mode a + destination is recorded, not used. No task changes the Operator's password or a policy: both need a + root token made from the shares (`bao operator generate-root`, used once and revoked); then `bao + write auth/userpass/users/operator/password password=-` changes the password. +- Server mode: the shares are the keys to everything; losing two of three loses every secret. The + loopback listener has no TLS: any process of the container can reach it and read the Operator's + saved token (mode 600, the remote user's), as with any login kept in a volume; the token's 4 hour + lifetime is the bound. `server.log` and `audit.log` grow without rotation. The server stops with + the container and comes back sealed (the start hook says so); a server that does not answer is + reported with the end of its log. Pinning `version` below 2.4.0 fails the build in server mode. - `dcf-secret` asks `bao` up to three questions after a failure, so a failure costs a few round trips; a success costs one. It assumes a KV v2 mount (the data lives under `/data/`). - The login is kept by a token helper `bao` runs as a shell command on every call that needs the @@ -303,10 +484,32 @@ elsewhere (`devcontainers-extra`, `postfinance`, `roul`) install HashiCorp's `va it to your server's version. The Feature's own version is the tag you reference (`openbao:1` follows 1.x; see Changes). **1.1.0** adds the volume, `openbao-setup`, `dcf-secret` and `mode`, all additive: rebuild to get them, and log in once, because the volume starts empty. -Nothing is removed; `mode` stays `client`. +Nothing is removed; `mode` stays `client`. **1.2.0** adds `mode: server` (the `server` value, the +start hook, `openbao:init`, `:unseal`, `:seal`, `:server:status`, `:approle:issue`, `:approle:revoke`, +`openbao-server`, `openbao-run`), all additive: rebuild to get them, and nothing changes for a +container that stays `client`, except that the tasks `status`, `login`, `logout` and `kv:get` now go +through `openbao-run` (the same `bao` call; in client mode nothing is added) and the +`dcf-secret` failure lines name the new tasks. ## Changes +### [1.2.0] - 2026-11-03 + +#### Added + +- **Server mode: a local OpenBao server in the container.** Set the `mode` option to `server` (or `OPENBAO_SETUP_MODE=server` in `containerEnv`, no rebuild; needs OpenBao 2.4.0 or newer, and an older pinned `version` fails the build with that said). The post-start hook starts one server with raft storage on the Feature's volume and a loopback-only listener (`127.0.0.1:8200`, no TLS), and it comes up **sealed**: nothing can read a secret until you unseal it. The hook never waits and never prompts; it prints whether the server is not initialised, sealed or unsealed. `mode: client` stays the default and behaves as before. **You must act once, in a plain terminal in the container:** `task openbao:init`, then after every container restart `task openbao:unseal`. +- `task openbao:init`: asks for a **backup destination or the words `no backup`** (never defaulted, and the answer is recorded in the volume), asks for the Operator's password, makes **3 unseal shares with a threshold of 2** and prints them once, only to the terminal (keep one in a password manager, one on paper offline, one with a second person or on a hardware key; losing two loses every secret), has you enter two of them to prove you stored them, sets up the `dcf` KV mount, `userpass` and `approle`, and revokes the root token. `-pgp-keys=...` after `--` encrypts the shares. +- `task openbao:unseal` and `task openbao:seal`. `task openbao:server:status` shows the state, the storage, the audit log, whether your login is valid and the backup choice, with a standing warning while there is none. Your login is `task openbao:login -- -method=userpass username=operator`: it lasts **4 hours, at most 8**, and a renewal past that is refused. +- `task openbao:approle:issue ROLE= POLICY_PATH=` makes a role for a machine identity (for the devhost's backs, later) that may read that one secret, only from inside the container (loopback-bound secret ids and tokens), with 5 minute tokens; `ROLE=` alone or `ALL=1` reissues, leaving exactly one valid secret id per role, written to the volume and never printed. `task openbao:approle:revoke ROLE=` removes a role. **Making a role and deleting one ask for two unseal shares** (typed at a hidden prompt, they make a root token, `bao operator generate-root`, used for that step only and revoked straight after, also on failure); reissuing (`ROLE=` alone, `ALL=1`) uses your login and asks for none. Your login's policy cannot write a policy or make, change or delete a role, so a token of yours can never give a role `snapshot-force`. +- A file audit device in the volume (`audit.log`), declared in the server's config. +- **Not included yet:** `openbao:backup` and restore. A backup destination is recorded but not used, so until a later version a lost machine loses what is in the server's volume (this is why `init` asks and `server:status` warns); take a snapshot by hand with `bao operator raft snapshot save`. + +#### Changed + +- **`init`, `unseal`, `seal` and the two steps that ask for shares (making and deleting a role) refuse inside an agent's job** (when `DEVHOST_JOB_CHILD` or `CLAUDECODE` is set, or a variable you name in `OPENBAO_AGENT_JOB_VARS`), and `init`, `unseal` and those two steps refuse without a terminal: the shares and the token must never land in a transcript. Run them yourself in a plain terminal. +- `dcf-secret`'s failure lines now name the tasks: sealed points to `task openbao:unseal`, not initialised to `task openbao:init`, unreachable to `task openbao:server:status`, each adding what to do against a server elsewhere. The exit codes are unchanged. +- In server mode `BAO_ADDR` defaults to the local server for `dcf-secret` and every task when you have not set it (a bare `bao` still needs `BAO_ADDR`). The tasks `status`, `login`, `logout`, `kv:get` and `setup` run the same `bao` call through `openbao-run`, which adds only that default; client mode is unchanged. `task openbao:setup` now also runs the start hook. + ### [1.1.1] - 2026-11-03 #### Changed @@ -323,12 +526,6 @@ Nothing is removed; `mode` stays `client`. - `task openbao:logout`: revokes your token on the server and erases the saved one, so no login is left behind (`bao token revoke -self` alone leaves the dead token stored, and `dcf-secret` then says to log in). Nothing to do unless you log out. - `task openbao:setup`: re-runs the post-create hook. -### [1.0.4] - 2026-11-03 - -#### Added - -- This changelog: every release of this Feature now has an entry here, written for its users, and the Feature's README shows the latest three under "Changes". Nothing to do. - The full history is in [CHANGELOG.md](https://github.com/nabil-becker/devcontainer-features/blob/main/src/openbao/CHANGELOG.md). --- diff --git a/src/openbao/dcf-secret.sh b/src/openbao/dcf-secret.sh index aecbd56..786d3b1 100644 --- a/src/openbao/dcf-secret.sh +++ b/src/openbao/dcf-secret.sh @@ -44,11 +44,15 @@ EOF exit 2 } -# What to do about each failure, one line each. The Operator-side tasks that do not exist in every -# version of the Feature are not named: the line must be true for the version that prints it. -fix_unreachable() { say "the server at ${BAO_ADDR:-the default address (https://127.0.0.1:8200)} is unreachable: check BAO_ADDR, and that the server is running (bao status shows why)"; } -fix_sealed() { say "the server is sealed: the Operator unseals it (bao operator unseal)"; } -fix_uninitialised() { say "the server is not initialised: the Operator initialises it (bao operator init)"; } +# What to do about each failure, one line each. They name this Feature's tasks, which work in both +# modes: in server mode the Operator runs them; in client mode the server is somebody else's and +# the same tasks say so. env.sh (shared with the tasks) gives the BAO_ADDR default of server mode. +ob_dir="${OPENBAO_FEATURE_DIR:-/usr/local/share/openbao-feature}" +# shellcheck disable=SC1091 # installed by install.sh +[ -f "$ob_dir/env.sh" ] && . "$ob_dir/env.sh" +fix_unreachable() { say "the server at ${BAO_ADDR:-the default address (https://127.0.0.1:8200)} is unreachable: task openbao:server:status says whether it runs and how to start it (against a server elsewhere, check BAO_ADDR)"; } +fix_sealed() { say "the server is sealed: the Operator unseals it with task openbao:unseal (against a server elsewhere, its owner does, with bao operator unseal)"; } +fix_uninitialised() { say "the server is not initialised: the Operator initialises it with task openbao:init (against a server elsewhere, its owner does, with bao operator init)"; } fix_login() { say "no valid token (none, expired or revoked): log in with task openbao:login (task openbao:logout first clears a dead one)"; } fix_denied() { say "the token may not $1 '$2' at $mount: its policy has to allow it (ask the server's owner)"; } diff --git a/src/openbao/devcontainer-feature.json b/src/openbao/devcontainer-feature.json index 3f2cf81..528c548 100644 --- a/src/openbao/devcontainer-feature.json +++ b/src/openbao/devcontainer-feature.json @@ -1,8 +1,8 @@ { "id": "openbao", - "version": "1.1.1", + "version": "1.2.0", "name": "OpenBao CLI", - "description": "Installs the OpenBao CLI (https://openbao.org), the open-source Vault fork, as a release binary at /usr/local/bin/bao, verified against the release's checksums.txt. Owns the login state as a named volume at OPENBAO_STATE_DIR=/var/lib/devcontainer-features/openbao (bao's token helper, set through BAO_CONFIG_PATH, keeps the token in it) so `bao login` survives rebuilds, with the post-create hook openbao-setup, and installs dcf-secret, the one command the collection's Features use to read and write secrets (get, put, status, with fixed exit codes). Set BAO_ADDR in containerEnv to point it at your server.", + "description": "Installs the OpenBao CLI (https://openbao.org), the open-source Vault fork, as a release binary at /usr/local/bin/bao, verified against the release's checksums.txt. Owns the login state as a named volume at OPENBAO_STATE_DIR=/var/lib/devcontainer-features/openbao (bao's token helper, set through BAO_CONFIG_PATH, keeps the token in it) so `bao login` survives rebuilds, with the post-create hook openbao-setup, and installs dcf-secret, the one command the collection's Features use to read and write secrets (get, put, status, with fixed exit codes). Mode client (the default) is the CLI against a server of yours (set BAO_ADDR in containerEnv). Mode server also runs a server inside the container, started at post-start with raft storage on the same volume and sealed until the Operator unseals it: tasks openbao:init (unseal shares printed only to a terminal, a backup choice asked for), openbao:unseal, openbao:seal, userpass login for the Operator, loopback-bound AppRole roles (openbao:approle:issue) and openbao:server:status.", "documentationURL": "https://github.com/nabil-becker/devcontainer-features/tree/main/src/openbao", "licenseURL": "https://github.com/nabil-becker/devcontainer-features/blob/main/LICENSE", "options": { @@ -14,10 +14,11 @@ "mode": { "type": "string", "enum": [ - "client" + "client", + "server" ], "default": "client", - "description": "How the Feature is used. Only 'client' exists in this version: the bao CLI and dcf-secret against a server outside the container (BAO_ADDR). Override per container with OPENBAO_SETUP_MODE in containerEnv." + "description": "How the Feature is used. 'client' (default): the bao CLI and dcf-secret against a server outside the container (BAO_ADDR). 'server': also run a server in the container (raft storage on the Feature's volume, loopback only, started at post-start, sealed until `task openbao:unseal`; needs OpenBao 2.4.0 or newer). Override per container with OPENBAO_SETUP_MODE in containerEnv." }, "version": { "type": "string", @@ -37,6 +38,7 @@ } ], "postCreateCommand": "openbao-setup", + "postStartCommand": "openbao-setup start", "installsAfter": [ "ghcr.io/nabil-becker/devcontainer-features/go-task", "ghcr.io/devcontainers/features/common-utils" diff --git a/src/openbao/env.sh b/src/openbao/env.sh new file mode 100644 index 0000000..a050507 --- /dev/null +++ b/src/openbao/env.sh @@ -0,0 +1,27 @@ +# shellcheck shell=sh +# openbao-feature/env.sh: sourced (never run) by dcf-secret, openbao-setup, openbao-server and +# openbao-run, so they agree on two things: which mode this container is in, and the server +# address. POSIX sh only, because the Taskfile's shell and dash source it too. +# +# The mode is the Feature option, baked into options.env by install.sh, unless OPENBAO_SETUP_MODE +# in the environment (containerEnv) overrides it. In server mode BAO_ADDR defaults to the local +# listener when nothing sets it; a Feature cannot set BAO_ADDR in containerEnv by option (its +# metadata is static, and client mode must keep the CLI's own default), so every command of this +# Feature applies the default itself. A bare `bao` does not: set BAO_ADDR for it. +_ob_dir="${OPENBAO_FEATURE_DIR:-/usr/local/share/openbao-feature}" + +openbao_mode() { + ( + MODE=client + # shellcheck disable=SC1091 # written by install.sh at image build + [ -f "$_ob_dir/options.env" ] && . "$_ob_dir/options.env" + printf '%s' "${OPENBAO_SETUP_MODE-${MODE:-client}}" + ) +} + +if [ -z "${BAO_ADDR:-}" ] && [ "$(openbao_mode)" = server ]; then + BAO_ADDR=http://127.0.0.1:8200 + export BAO_ADDR + OPENBAO_ADDR_DEFAULTED=1 + export OPENBAO_ADDR_DEFAULTED +fi diff --git a/src/openbao/install.sh b/src/openbao/install.sh index cf9627d..0e041dc 100755 --- a/src/openbao/install.sh +++ b/src/openbao/install.sh @@ -20,8 +20,8 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi -if [ "$MODE" != "client" ]; then - echo "openbao: mode '$MODE' is not available in this version (only 'client')." >&2 +if [ "$MODE" != "client" ] && [ "$MODE" != "server" ]; then + echo "openbao: mode '$MODE' is not known (client or server)." >&2 exit 1 fi @@ -59,6 +59,13 @@ if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then exit 1 fi +# Server mode declares its audit device in the server config (the audit stanza, OpenBao 2.4.0 and +# later); an older server would refuse the config, so the build says so instead. +if [ "$MODE" = "server" ] && [ "$(printf '%s\n%s\n' 2.4.0 "${VERSION%%[-+]*}" | sort -V | head -n 1)" != "2.4.0" ]; then + echo "openbao: mode server needs OpenBao 2.4.0 or newer (its config declares the audit device); got ${VERSION}. Pin version 2.4.0 or later." >&2 + exit 1 +fi + base_url="https://github.com/$repo/releases/download/v${VERSION}" asset="openbao_${VERSION}_linux_${arch}.tar.gz" tmp="$(mktemp -d)" @@ -78,8 +85,11 @@ install -m 0755 "$tmp/x/bao" /usr/local/bin/bao # ------------------------------------------------------- ecosystem pieces ---- # dcf-secret is the interface every caller uses to ask for a secret; dcf-persist (verbatim copy of # src/common/dcf-persist.sh) makes the state directory usable; openbao-setup is this Feature's -# postCreateCommand (see devcontainer-feature.json) and reads its option defaults from -# options.env, so the hook and `task openbao:setup` behave the same. Nothing is written into +# postCreateCommand and, as `openbao-setup start`, its postStartCommand (see +# devcontainer-feature.json) and reads its option defaults from options.env, so the hooks and +# `task openbao:setup` behave the same; openbao-server is what the server-mode tasks run, +# openbao-run what the others run so that server mode's BAO_ADDR default applies, and env.sh is the +# snippet they all source (the mode, that default). Nothing is written into # OPENBAO_STATE_DIR here: the volume is not mounted at image build. feature_dir="$(cd "$(dirname "$0")" && pwd)" share=/usr/local/share/openbao-feature @@ -91,6 +101,9 @@ mkdir -p "$share" chmod 0644 "$share/options.env" install -m 0755 "$feature_dir/dcf-secret.sh" /usr/local/bin/dcf-secret install -m 0755 "$feature_dir/openbao-setup.sh" /usr/local/bin/openbao-setup +install -m 0755 "$feature_dir/openbao-server.sh" /usr/local/bin/openbao-server +install -m 0755 "$feature_dir/openbao-run.sh" /usr/local/bin/openbao-run +install -m 0644 "$feature_dir/env.sh" "$share/env.sh" install -m 0755 "$feature_dir/dcf-persist.sh" /usr/local/bin/dcf-persist # bao's token helper: the CLI's own way to keep its token elsewhere than ~/.vault-token. bao finds # the config through BAO_CONFIG_PATH (containerEnv of this Feature, so it holds for every process, diff --git a/src/openbao/openbao-run.sh b/src/openbao/openbao-run.sh new file mode 100644 index 0000000..49da0e9 --- /dev/null +++ b/src/openbao/openbao-run.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# openbao-run [args...]: run a command (a bao call of the Feature's Taskfile) with this +# Feature's environment defaults applied: in server mode, BAO_ADDR is the local listener when +# nothing sets it (env.sh). Nothing else changes; in client mode it is exec "$@". +dir="${OPENBAO_FEATURE_DIR:-/usr/local/share/openbao-feature}" +# shellcheck disable=SC1091 # installed by install.sh +[ -f "$dir/env.sh" ] && . "$dir/env.sh" +[ $# -ge 1 ] || { echo "usage: openbao-run [args...]" >&2; exit 2; } +exec "$@" diff --git a/src/openbao/openbao-server.sh b/src/openbao/openbao-server.sh new file mode 100644 index 0000000..57cac04 --- /dev/null +++ b/src/openbao/openbao-server.sh @@ -0,0 +1,546 @@ +#!/usr/bin/env bash +# openbao-server: what the openbao Feature's server-mode tasks run (task openbao:init, :unseal, +# :seal, :server:status, :approle:issue and :approle:revoke). It is a thin layer over the `bao` +# CLI (NFR-13): the state of the server comes from `bao status`'s exit code and JSON, never from +# parsed English; the only text read back is the init output's "Unseal Key N:" and "Initial Root +# Token:" lines, which the CLI documents. +# +# openbao-server init [-pgp-keys=...] once: ask for the backup choice and the Operator's +# password, initialise (3 shares, threshold 2), print the +# shares to the terminal, unseal, configure, revoke root +# openbao-server unseal ask for shares at bao's own prompt until it is unsealed +# openbao-server seal seal the server (the Operator's token) +# openbao-server status mode, state, storage, backup choice, login (both modes) +# openbao-server approle-issue ROLE= [POLICY_PATH=] or ALL=1 (the +# OPENBAO_TASK_ROLE, OPENBAO_TASK_POLICY_PATH and +# OPENBAO_TASK_ALL variables the Taskfile sets) +# openbao-server approle-revoke ROLE= (OPENBAO_TASK_ROLE) +# +# Every command that handles a share or a token (init, unseal, seal, and approle-issue with +# POLICY_PATH and approle-revoke, which make a root token from the shares for their one step and +# revoke it after) refuses inside an agent's job and, for all but seal, without a terminal: the shares and the token must never reach a +# transcript, which the scribe reads. Exit codes: dcf-secret's numbers where they mean the same +# (10 unreachable, 11 sealed or not initialised, 12 no valid token, 14 no such role), 3 refused +# (an agent's job, no terminal, the wrong mode), 2 a wrong call, 1 a step failed. +set -u + +prog=openbao-server +ob_dir="${OPENBAO_FEATURE_DIR:-/usr/local/share/openbao-feature}" +# shellcheck disable=SC1091 # installed by install.sh +[ -f "$ob_dir/env.sh" ] && . "$ob_dir/env.sh" +dir="${OPENBAO_STATE_DIR:-/var/lib/devcontainer-features/openbao}" +mount="${DCF_SECRET_MOUNT:-dcf}" +st_rc=0 +st_json="" + +say() { echo "$prog: $*" >&2; } +die() { # + local code="$1" + shift + say "$*" + exit "$code" +} +usage() { + say "usage: openbao-server init|unseal|seal|status|approle-issue|approle-revoke (the tasks of the openbao Feature run it)" + exit 2 +} + +command -v bao >/dev/null 2>&1 || die 10 "bao is not installed: add the openbao Feature to the devcontainer" + +mode_now="$(openbao_mode 2>/dev/null || echo client)" + +# ---------------------------------------------------------------------------- refusals ---- +# An agent's job: the devhost's backs run their CLI with DEVHOST_JOB_CHILD, and Claude Code sets +# CLAUDECODE on every process it starts (its tool calls, subagents and headless runs). +# OPENBAO_AGENT_JOB_VARS names more variables (space separated) for another agent CLI. +guard() { # + local v + # shellcheck disable=SC2086 # a list of names + for v in DEVHOST_JOB_CHILD CLAUDECODE ${OPENBAO_AGENT_JOB_VARS:-}; do + if [ -n "${!v:-}" ]; then + die 3 "task openbao:$1 refuses to run inside an agent's job ($v is set): $2. Run it yourself in a plain terminal in the devcontainer" + fi + done +} +need_tty() { # + { [ -t 0 ] && [ -t 1 ]; } || die 3 "task openbao:$1 needs a terminal on stdin and stdout (it asks for secrets and prints them only to a terminal); run it in a plain terminal, not through a pipe or a script" +} +need_server_mode() { # + [ "$mode_now" = server ] || die 3 "task openbao:$1 is for server mode: this container runs no server (mode $mode_now); the server's owner does this, or set the Feature option mode to server" +} + +# ----------------------------------------------------------------------- the server ---- +# `bao status`: exit 0 unsealed, 2 sealed (an uninitialised server is sealed too, and its JSON says +# initialized false), anything else could not ask. +server_state() { + st_json="$(bao status -format=json 2>/dev/null)" + st_rc=$? +} +is_initialised() { ! grep -Eq '"initialized"[[:space:]]*:[[:space:]]*false' <<<"$st_json"; } +state_exit() { # exit with the line for a server that is not ready; no-op when it is + case "$st_rc" in + 0) return 0 ;; + 2) + if is_initialised; then + die 11 "the server is sealed: the Operator unseals it with task openbao:unseal" + fi + die 11 "the server is not initialised: the Operator initialises it with task openbao:init" + ;; + *) die 10 "the server at ${BAO_ADDR:-the default address (https://127.0.0.1:8200)} is unreachable: task openbao:server:status says whether it runs; the start hook is openbao-setup start" ;; + esac +} +need_token() { + bao token lookup >/dev/null 2>&1 || die 12 "no valid token (none, expired or revoked): log in with task openbao:login -- -method=userpass username=operator (task openbao:logout first clears a dead one)" +} +preflight() { + server_state + state_exit + need_token +} +wait_active() { # after an unseal, a single raft node needs a moment to become the active node + local _ + for _ in $(seq 1 40); do + bao read -format=json sys/leader 2>/dev/null | grep -Eq '"is_self"[[:space:]]*:[[:space:]]*true' && return 0 + sleep 0.5 + done + return 1 +} + +# Ask for shares at bao's own prompt (input hidden, a terminal required) until the server is +# unsealed; `bao operator unseal` prints the progress each time. +unseal_loop() { + local n=0 + while :; do + server_state + [ "$st_rc" -eq 0 ] && break + [ "$st_rc" -eq 2 ] || state_exit + n=$((n + 1)) + [ "$n" -le 6 ] || die 1 "still sealed after $((n - 1)) shares: check the shares and start again (bao operator unseal -reset clears the progress)" + bao operator unseal || die 1 "bao could not take that share (a wrong share? bao operator unseal -reset clears the progress, then run this again)" + done + wait_active || say "the server is unsealed but has not become the active node yet; give it a few seconds" +} + +# ---------------------------------------------------------------------------------- init ---- +operator_policy() { + cat </dev/null 2>&1 || bao secrets enable -path="$mount" -version=2 kv >/dev/null || return 1 + bao read sys/auth/userpass >/dev/null 2>&1 || bao auth enable userpass >/dev/null || return 1 + bao read sys/auth/approle >/dev/null 2>&1 || bao auth enable approle >/dev/null || return 1 + operator_policy | bao policy write operator - >/dev/null || return 1 + # The login lifetime (ruling 6): 4 hours, at most 8 with no renewal past that. + printf '%s' "$op_pw" | bao write auth/userpass/users/operator password=- token_policies=operator token_ttl=4h token_max_ttl=8h >/dev/null || return 1 +} + +ask() { # -> $REPLY, echoed input + printf '%s ' "$1" >&2 + IFS= read -r REPLY || die 3 "no answer (the input ended)" +} +ask_secret() { # -> $REPLY, input hidden + printf '%s ' "$1" >&2 + IFS= read -rs REPLY || die 3 "no answer (the input ended)" + printf '\n' >&2 +} + +cmd_init() { + guard init "it prints the unseal shares and the root token once, and they must never land in a transcript" + need_server_mode init + need_tty init + local a + for a in "$@"; do + case "$a" in + -pgp-keys=* | -pgp-keys | -root-token-pgp-key=* | -root-token-pgp-key) ;; + *) die 2 "openbao:init takes only -pgp-keys (encrypts the shares to your keys; you decrypt them yourself before the unseal step); the share count and threshold are fixed at 3 and 2" ;; + esac + done + server_state + case "$st_rc" in + 0) die 3 "the server is already initialised and unsealed: nothing to initialise" ;; + 2) is_initialised && die 3 "the server is already initialised: unseal it with task openbao:unseal (a second init would need an empty store)" ;; + *) state_exit ;; + esac + + echo "openbao: initialising the server at ${BAO_ADDR:-the default address}. Three unseal shares are made (any two open the server)." >&2 + echo "openbao: a machine loss loses what is in this server's volume (a bounded-loss exception to ADR 0045): a backup destination limits that." >&2 + + # The backup choice is asked for and never defaulted. + local choice_kind="" choice_dest="" tries=0 + while [ -z "$choice_kind" ]; do + tries=$((tries + 1)) + [ "$tries" -le 5 ] || die 3 "no backup choice made: nothing was changed" + ask "Backup destination: a folder path (the cloud folder, a USB disk), or type: no backup" + case "$REPLY" in + "") echo "openbao: the choice is explicit: a folder path, or the words: no backup" >&2 ;; + [Nn]o\ [Bb]ackup) choice_kind=none ;; + /*) + if [ -d "$REPLY" ] && [ -w "$REPLY" ]; then + choice_kind=path + choice_dest="$REPLY" + else + echo "openbao: '$REPLY' is not a writable folder; pick another, or type: no backup" >&2 + fi + ;; + *) echo "openbao: '$REPLY' is not an absolute folder path; pick one, or type: no backup" >&2 ;; + esac + done + + tries=0 + local pw1="" pw2="" + op_pw="" + while [ -z "$op_pw" ]; do + tries=$((tries + 1)) + [ "$tries" -le 5 ] || die 3 "no password chosen: nothing was changed" + ask_secret "Choose the Operator's password (at least 12 characters; you log in with it each sitting):" + pw1="$REPLY" + ask_secret "Again:" + pw2="$REPLY" + if [ "${#pw1}" -lt 12 ]; then + echo "openbao: too short (12 characters at least)" >&2 + elif [ "$pw1" != "$pw2" ]; then + echo "openbao: the two did not match" >&2 + else + op_pw="$pw1" + fi + done + + local out root shares + out="$(bao operator init -key-shares=3 -key-threshold=2 "$@" 2>&1)" || { + printf '%s\n' "$out" | sed 's/^/openbao-server: /' >&2 + die 1 "bao operator init failed" + } + root="$(sed -n 's/^Initial Root Token: //p' <<<"$out")" + shares="$(grep -E '^Unseal Key [0-9]+: ' <<<"$out")" + [ -n "$root" ] && [ -n "$shares" ] || die 1 "bao operator init answered, but its output had no shares or root token; the server may be initialised without them: stop it, delete $dir/raft and start again" + + cat </dev/null 2>&1 + root="" + op_pw="" + die 1 "a configuration step failed (bao's message is above) and the root token is revoked. The store holds nothing yet, so to start over: stop the server, delete $dir/raft, run openbao-setup start and task openbao:init again (new shares)" + fi + + umask 077 + { + echo "choice=$choice_kind" + [ "$choice_kind" = path ] && echo "dest=$choice_dest" + echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > "$dir/backup-choice" + BAO_TOKEN="$root" bao token revoke -self >/dev/null 2>&1 || say "the root token could not be revoked: revoke it by hand (bao token revoke -self)" + root="" + op_pw="" + + echo "openbao: initialised, unsealed and configured: the dcf KV mount, userpass (user operator, login 4 hours, at most 8), approle, the Operator's policy, the audit log." >&2 + if [ "$choice_kind" = none ]; then + echo "openbao: backup choice recorded: NO BACKUP. A lost machine loses everything in this server; task openbao:server:status keeps saying so." >&2 + else + echo "openbao: backup destination recorded: $choice_dest" >&2 + fi + echo "openbao: the root token is revoked. Log in: task openbao:login -- -method=userpass username=operator" >&2 +} + +# -------------------------------------------------------------------------------- unseal ---- +cmd_unseal() { + guard unseal "it asks for the unseal shares, which must never land in a transcript" + need_server_mode unseal + need_tty unseal + server_state + case "$st_rc" in + 0) + echo "openbao: the server is already unsealed" >&2 + exit 0 + ;; + 2) is_initialised || die 11 "the server is not initialised: the Operator initialises it with task openbao:init" ;; + *) state_exit ;; + esac + unseal_loop + echo "openbao: unsealed. Log in (task openbao:login -- -method=userpass username=operator) if you have no valid login." >&2 +} + +# ---------------------------------------------------------------------------------- seal ---- +cmd_seal() { + guard seal "it uses the Operator's token and stops every Feature that reads a secret" + need_server_mode seal + server_state + if [ "$st_rc" -eq 2 ]; then + echo "openbao: the server is already sealed" >&2 + exit 0 + fi + state_exit + need_token + bao operator seal || die 1 "bao could not seal the server (the token's policy needs update and sudo on sys/seal, which the Operator's policy has)" + echo "openbao: sealed. The Operator unseals it with task openbao:unseal." >&2 +} + +# --------------------------------------------------------------------------------- status ---- +cmd_status() { + local state + echo "mode: $mode_now" + echo "address: ${BAO_ADDR:-the default (https://127.0.0.1:8200)}" + server_state + case "$st_rc" in + 0) state=unsealed ;; + 2) if is_initialised; then state=sealed; else state="not initialised"; fi ;; + *) state=unreachable ;; + esac + echo "server: $state" + if [ "$mode_now" = server ]; then + local pid + pid="$(cat "$dir/server.pid" 2>/dev/null || true)" + if [ -n "$pid" ] && [ "$(cat "/proc/$pid/comm" 2>/dev/null)" = bao ]; then + echo "process: bao server, pid $pid, log $dir/server.log" + else + echo "process: not running: openbao-setup start (task openbao:setup) starts it" + fi + echo "storage: raft, $dir/raft (tier 4: the volume)" + echo "audit: $dir/audit.log" + local choice="" dest="" chosen="" + if [ -f "$dir/backup-choice" ]; then + choice="$(sed -n 's/^choice=//p' "$dir/backup-choice")" + dest="$(sed -n 's/^dest=//p' "$dir/backup-choice")" + chosen="$(sed -n 's/^date=//p' "$dir/backup-choice")" + fi + case "$choice" in + path) echo "backup: destination $dest (chosen $chosen); there is no backup task in this version yet: copy a raft snapshot (bao operator raft snapshot save) there yourself" ;; + none) echo "backup: WARNING no backup (chosen $chosen): a lost machine loses everything in this server" ;; + *) echo "backup: WARNING none chosen yet: task openbao:init asks (a lost machine loses everything in this server)" ;; + esac + fi + if [ "$st_rc" -eq 0 ]; then + if bao token lookup >/dev/null 2>&1; then + echo "login: valid" + else + echo "login: none or expired: task openbao:login -- -method=userpass username=operator" + fi + fi + case "$state" in + unsealed) exit 0 ;; + unreachable) exit 10 ;; + *) exit 11 ;; + esac +} + +# --------------------------------------------------------------------------------- approle ---- +# A root token from the unseal shares, for one step. `bao operator generate-root` (OTP flow): the +# attempt is started with a one-time password kept in a variable, the Operator types the shares at +# bao's own hidden prompt (as in unseal), the encoded token is decoded with the OTP, the step runs +# with the token (BAO_TOKEN in a subshell, nowhere else), and the token is revoked straight after, +# also on failure or an interrupt (the attempt is cancelled when it was abandoned). No share, OTP +# or token is printed, logged or put in a file; the OTP is the one argument (useless without the +# encoded token, which goes over stdin). +root_otp="" root_tok="" root_started=0 root_done=0 +root_cleanup() { + if [ -n "$root_tok" ]; then + BAO_TOKEN="$root_tok" bao token revoke -self >/dev/null 2>&1 || echo "$prog: THE ROOT TOKEN COULD NOT BE REVOKED: revoke it now (bao token revoke -self with it), or seal the server" >&2 + root_tok="" + fi + if [ "$root_started" -eq 1 ] && [ "$root_done" -eq 0 ]; then + bao operator generate-root -cancel >/dev/null 2>&1 || true + fi + root_started=0 + root_otp="" +} +as_root() { # [args]: the command runs with a root token + local task="$1" what="$2" out req n=0 enc rc=0 + shift 2 + guard "$task" "it needs a root token made from the unseal shares, which must never land in a transcript" + need_tty "$task" + trap 'root_cleanup' EXIT + trap 'root_cleanup; exit 130' INT TERM HUP + root_otp="$(bao operator generate-root -generate-otp)" && [ -n "$root_otp" ] || die 1 "bao could not make a one-time password for the root token (does the Operator's login still hold? task openbao:login)" + out="$(bao operator generate-root -init -otp="$root_otp" -format=json 2>&1)" || { + echo "$prog: bao could not start a root token generation. One may already be in progress (bao operator generate-root -status; -cancel ends it)." >&2 + die 3 "nothing was changed" + } + root_started=1 + req="$(sed -n 's/.*"required"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' <<<"$out" | head -n 1)" + [ -n "$req" ] || req=2 + echo "openbao: $what needs a root token, made from $req unseal shares typed at the prompt (hidden); it is used for this step only and revoked straight after." >&2 + while :; do + n=$((n + 1)) + [ "$n" -le $((req + 2)) ] || die 1 "the root token generation did not complete after $((n - 1)) shares: check the shares and run the task again (the attempt is cancelled)" + # bao's own prompt goes to its stdout, which is captured here (the JSON is read back): so say it + printf 'Unseal share %s of %s (hidden): ' "$n" "$req" >&2 + out="$(bao operator generate-root -otp="$root_otp" -format=json)" || { printf '\n' >&2; die 1 "bao could not take that share; the attempt is cancelled: run the task again"; } + printf '\n' >&2 + grep -Eq '"complete"[[:space:]]*:[[:space:]]*true' <<<"$out" && break + done + root_done=1 + enc="$(sed -n 's/.*"encoded_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' <<<"$out" | head -n 1)" + out="" + root_tok="$(printf '%s' "$enc" | bao operator generate-root -decode=- -otp="$root_otp")" && [ -n "$root_tok" ] || die 1 "the root token could not be decoded: the shares were probably wrong; run the task again" + enc="" + root_otp="" + ( + # shellcheck disable=SC2031 # the token is meant to live in this subshell only + export BAO_TOKEN="$root_tok" + "$@" + ) || rc=$? + root_cleanup + trap - EXIT INT TERM HUP + return "$rc" +} + +# What the root token is used for. +create_role() { # + # The role may read this one secret and nothing else (not even a listing). + printf 'path "%s/data/%s" {\n capabilities = ["read"]\n}\n' "${3%%/*}" "${3#*/}" | bao policy write "$2" - >/dev/null || { say "could not write the policy $2"; return 1; } + # Loopback only: a stolen secret id works from inside this container, and the token it gives + # too. Short tokens (5 minutes, at most 15); secret ids that do not expire, so the backs do + # not stop on a date nobody watches (they are reissued instead). + bao write "auth/approle/role/$1" token_policies="$2" token_ttl=5m token_max_ttl=15m \ + secret_id_ttl=0 secret_id_num_uses=0 secret_id_bound_cidrs=127.0.0.1/32 token_bound_cidrs=127.0.0.1/32 >/dev/null || { say "could not write the role $1"; return 1; } +} +delete_role() { # + # Deleting a role destroys the secret ids issued under it (OpenBao's role deletion flushes them). + bao delete "auth/approle/role/$1" >/dev/null || { say "could not delete the role $1"; return 1; } + bao policy delete "approle-$1" >/dev/null 2>&1 || true +} + +role_ok() { [[ "$1" =~ ^[a-z][a-z0-9_-]{0,63}$ ]]; } +ppath_ok() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)+$ ]]; } +list_keys() { bao list "$1" 2>/dev/null | sed '1,2d'; } +role_exists() { bao read "auth/approle/role/$1/role-id" >/dev/null 2>&1; } + +# One valid secret id per role: the old ones are destroyed (issuing a new one does not end the +# others of the role, so their accessors are destroyed first), then one is made and written, with +# the role id (not secret), under the state dir. Nothing is printed of either. +reissue_role() { # + local role="$1" acc sid rid + while IFS= read -r acc; do + [ -n "$acc" ] || continue + bao write "auth/approle/role/$role/secret-id-accessor/destroy" secret_id_accessor="$acc" >/dev/null || { say "could not destroy a secret id of role $role"; return 1; } + done < <(list_keys "auth/approle/role/$role/secret-id") + sid="$(bao write -f -field=secret_id "auth/approle/role/$role/secret-id")" && [ -n "$sid" ] || { say "could not make a secret id for role $role"; return 1; } + rid="$(bao read -field=role_id "auth/approle/role/$role/role-id")" && [ -n "$rid" ] || { say "could not read the role id of role $role"; return 1; } + ( + umask 077 + mkdir -p "$dir/approle/$role" || exit 1 + printf '%s' "$sid" > "$dir/approle/$role/secret_id.tmp.$$" && mv -f "$dir/approle/$role/secret_id.tmp.$$" "$dir/approle/$role/secret_id" || exit 1 + printf '%s' "$rid" > "$dir/approle/$role/role_id.tmp.$$" && mv -f "$dir/approle/$role/role_id.tmp.$$" "$dir/approle/$role/role_id" || exit 1 + ) || { say "could not write the secret id of role $role under $dir/approle"; return 1; } + echo "openbao: role $role: a new secret id (and the role id) written under $dir/approle/$role, mode 600; every earlier secret id of the role is destroyed" >&2 +} + +cmd_approle_issue() { + need_server_mode approle:issue + local role="${OPENBAO_TASK_ROLE:-}" ppath="${OPENBAO_TASK_POLICY_PATH:-}" all="${OPENBAO_TASK_ALL:-}" + if [ -n "$all" ]; then + [ -z "$role" ] && [ -z "$ppath" ] || die 2 "ALL=1 reissues every role in the store: do not combine it with ROLE or POLICY_PATH" + else + [ -n "$role" ] || die 2 "name a role (ROLE=, lower case, with POLICY_PATH= the first time) or ALL=1" + fi + [ -z "$ppath" ] || { + ppath_ok "$ppath" || die 2 "'$ppath' is not / (letters, digits, . _ - and / between parts)" + role_ok "$role" || die 2 "'$role' is not a role name (lower case letters, digits, - and _, starting with a letter)" + guard approle:issue "making a role needs a root token made from the unseal shares, which must never land in a transcript" + need_tty approle:issue + } + preflight + if [ -n "$all" ]; then + local r n=0 failed=0 + while IFS= read -r r; do + [ -n "$r" ] || continue + n=$((n + 1)) + reissue_role "$r" || failed=1 + done < <(list_keys auth/approle/role) + [ "$n" -gt 0 ] || echo "openbao: no AppRole roles in the store, nothing to reissue" >&2 + exit "$failed" + fi + role_ok "$role" || die 2 "'$role' is not a role name (lower case letters, digits, - and _, starting with a letter)" + local policy="approle-$role" + if [ -n "$ppath" ]; then + ppath_ok "$ppath" || die 2 "'$ppath' is not / (letters, digits, . _ - and / between parts)" + # Making a role writes a policy: that is the shares' step (a token that could write any policy + # could give a role snapshot-force). Refused inside an agent's job and without a terminal. + as_root approle:issue "making the role $role (it writes a policy)" create_role "$role" "$policy" "$ppath" || die 1 "the role $role was not made (the messages above say why); the root token is revoked" + elif ! role_exists "$role"; then + die 2 "role '$role' does not exist: give POLICY_PATH= (the one secret it may read) the first time; making a role asks for the unseal shares" + fi + reissue_role "$role" || exit 1 +} + +cmd_approle_revoke() { + need_server_mode approle:revoke + local role="${OPENBAO_TASK_ROLE:-}" + [ -n "$role" ] || die 2 "name a role: ROLE=" + role_ok "$role" || die 2 "'$role' is not a role name" + guard approle:revoke "deleting a role needs a root token made from the unseal shares, which must never land in a transcript" + need_tty approle:revoke + preflight + role_exists "$role" || die 14 "no such role '$role'" + # Deleting a role, and its policy, is the shares' step (see approle:issue). + as_root approle:revoke "deleting the role $role" delete_role "$role" || die 1 "the role $role was not deleted (the messages above say why); the root token is revoked" + rm -rf "${dir:?}/approle/$role" + echo "openbao: role $role deleted with its secret ids and its policy, and its files under $dir/approle removed; a token it already made lives at most 15 minutes (token_max_ttl)" >&2 +} + +[ $# -ge 1 ] || usage +cmd="$1" +shift +case "$cmd" in + init) cmd_init "$@" ;; + unseal) cmd_unseal ;; + seal) cmd_seal ;; + status) cmd_status ;; + approle-issue) cmd_approle_issue ;; + approle-revoke) cmd_approle_revoke ;; + *) usage ;; +esac diff --git a/src/openbao/openbao-setup.sh b/src/openbao/openbao-setup.sh index c2cd1a4..f854d0b 100644 --- a/src/openbao/openbao-setup.sh +++ b/src/openbao/openbao-setup.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash -# openbao-setup: the openbao Feature's postCreateCommand, run as the remote user from the -# workspace folder after every create/rebuild; also behind `task openbao:setup`. Idempotent, never -# fatal: a problem is reported, not allowed to abort the rest of post-create. +# openbao-setup: the openbao Feature's postCreateCommand (no argument) and postStartCommand +# (`openbao-setup start`), run as the remote user from the workspace folder; also behind +# `task openbao:setup`. Idempotent, never fatal: a problem is reported, not allowed to abort the +# rest of post-create or post-start. # # 1. dcf-persist makes OPENBAO_STATE_DIR (the Feature's named volume, or a bind mount a repo put on # the same target) exist and belong to the remote user. @@ -12,23 +13,141 @@ # 3. Says which mode it runs in and what is known without asking the server: the address, whether # the token helper is in effect, whether a login is saved. It never contacts the server (an # unreachable one must not hold post-create). +# 4. `start`, server mode only: write the server's config into the state dir (raft storage on the +# Feature's volume, one loopback listener, the file audit device), start `bao server` in the +# background and say whether it is not initialised, sealed or unsealed. It never waits for an +# unseal and never prompts: a start hook that waits holds every Feature's start. The server +# stays sealed until the Operator runs task openbao:unseal. set -u # Defaults baked by install.sh from the Feature options. OPENBAO_SETUP_* in the environment # (containerEnv) override them without a rebuild. +feature_dir="${OPENBAO_FEATURE_DIR:-/usr/local/share/openbao-feature}" # shellcheck disable=SC1091 # written by install.sh at image build -[ -f /usr/local/share/openbao-feature/options.env ] && source /usr/local/share/openbao-feature/options.env +[ -f "$feature_dir/options.env" ] && source "$feature_dir/options.env" mode="${OPENBAO_SETUP_MODE-${MODE:-client}}" +# env.sh gives server mode its BAO_ADDR default (the local listener), as it does for dcf-secret +# shellcheck disable=SC1091 # installed by install.sh +[ -f "$feature_dir/env.sh" ] && source "$feature_dir/env.sh" dir="${OPENBAO_STATE_DIR:-/var/lib/devcontainer-features/openbao}" old="$HOME/.vault-token" target="$dir/token" feature_config=/usr/local/share/openbao-feature/bao-config.hcl +# ----------------------------------------------------------------- server mode: the start ---- +conf="$dir/server.hcl" +marker='# Written by the openbao Feature (openbao-setup start); remove this line to keep your own edits.' +listen=127.0.0.1:8200 + +write_config() { + # Rewritten on every start while it still carries the marker line (so a Feature upgrade reaches + # it); a config whose marker was removed is the repo's own and is left alone. + if [ -f "$conf" ] && [ "$(head -n 1 "$conf")" != "$marker" ]; then return 0; fi + ( + umask 077 + { + echo "$marker" + cat < "$conf.tmp.$$" && mv -f "$conf.tmp.$$" "$conf" + ) +} + +server_alive() { + local pid + pid="$(cat "$dir/server.pid" 2>/dev/null)" || return 1 + [ -n "$pid" ] && [ "$(cat "/proc/$pid/comm" 2>/dev/null)" = bao ] +} + +start_server() { + [ "$mode" = server ] || return 0 + if ! command -v bao >/dev/null 2>&1; then + echo "openbao: bao not found on PATH, the server is not started; rebuild the container" >&2 + return 0 + fi + [ -d "$dir" ] || mkdir -p "$dir" 2>/dev/null + if [ ! -w "$dir" ]; then + echo "openbao: $dir is not writable, the server is not started (its storage lives there)" >&2 + return 0 + fi + if ! write_config; then + echo "openbao: could not write $conf, the server is not started" >&2 + return 0 + fi + (umask 077; mkdir -p "$dir/raft") + if server_alive; then + echo "openbao: server already running (pid $(cat "$dir/server.pid"))" + else + if command -v setsid >/dev/null 2>&1; then + (umask 077; setsid bao server -config="$conf" >"$dir/server.log" 2>&1 & echo "$!" > "$dir/server.pid") + else + (umask 077; nohup bao server -config="$conf" >"$dir/server.log" 2>&1 & echo "$!" > "$dir/server.pid") + fi + fi + # Wait only for the listener to answer (any answer: sealed counts), never for an unseal. + local st_rc st_json + for _ in $(seq 1 "${OPENBAO_START_TRIES:-40}"); do + st_json="$(BAO_ADDR="http://$listen" bao status -format=json 2>/dev/null)" + st_rc=$? + [ "$st_rc" -ne 1 ] && break + sleep 0.5 + done + case "$st_rc" in + 0) echo "openbao: server running and unsealed at http://$listen" ;; + 2) + if grep -Eq '"initialized"[[:space:]]*:[[:space:]]*false' <<<"$st_json"; then + echo "openbao: server started at http://$listen, not initialised; the Operator runs task openbao:init" + else + echo "openbao: server started at http://$listen, sealed; the Operator runs task openbao:unseal" + fi + ;; + *) + echo "openbao: the server was started but does not answer at http://$listen; the end of $dir/server.log:" >&2 + tail -n 8 "$dir/server.log" 2>/dev/null | sed 's/^/openbao: /' >&2 + ;; + esac + return 0 +} + +if [ "${1:-}" = start ]; then + start_server + exit 0 +fi + dcf-persist --label openbao "$dir" case "$mode" in client) echo "openbao: mode client (the bao CLI and dcf-secret; BAO_ADDR names the server)" ;; - *) echo "openbao: mode '$mode' is not available in this version (client only); running as client" >&2 ;; + server) echo "openbao: mode server (a server with raft storage in $dir/raft, started by the post-start hook; sealed until the Operator runs task openbao:unseal)" ;; + *) + echo "openbao: mode '$mode' is not known (client or server); running as client" >&2 + mode=client + ;; esac # Only with the token helper in effect is a token moved: otherwise bao still reads ~/.vault-token. @@ -61,11 +180,17 @@ if ! command -v bao >/dev/null 2>&1; then exit 0 fi echo "openbao: $(bao version 2>/dev/null | head -n 1) at $(command -v bao)" -echo "openbao: BAO_ADDR ${BAO_ADDR:-is not set (bao falls back to https://127.0.0.1:8200); put it in containerEnv}" +if [ -n "${OPENBAO_ADDR_DEFAULTED:-}" ]; then + echo "openbao: BAO_ADDR is not set; server mode uses its default $BAO_ADDR for dcf-secret and the tasks (a bare bao needs BAO_ADDR set)" +else + echo "openbao: BAO_ADDR ${BAO_ADDR:-is not set (bao falls back to https://127.0.0.1:8200); put it in containerEnv}" +fi if [ -n "${BAO_TOKEN:-}" ]; then echo "openbao: BAO_TOKEN is set in the environment and takes precedence over the saved login" elif [ -s "$target" ]; then echo "openbao: a login is saved in $dir (it may have expired; dcf-secret status checks it)" +elif [ "$mode" = server ]; then + echo "openbao: no login yet; once the server is unsealed, run task openbao:login -- -method=userpass username=operator (it persists in $dir)" else echo "openbao: no login yet; run 'bao login' (or task openbao:login) once, it persists in $dir" fi diff --git a/src/openbao/taskfile.yml b/src/openbao/taskfile.yml index 2a98cba..e3ffdec 100644 --- a/src/openbao/taskfile.yml +++ b/src/openbao/taskfile.yml @@ -1,5 +1,6 @@ -# Tasks contributed by the openbao Feature (namespace: openbao). BAO_ADDR -# must point at your server (set it in containerEnv). +# Tasks contributed by the openbao Feature (namespace: openbao). BAO_ADDR must point at your +# server (set it in containerEnv); in server mode it defaults to the local server (openbao-run). +# The tasks that talk to bao go through openbao-run, which applies that default and nothing else. version: '3' tasks: @@ -11,22 +12,68 @@ tasks: status: dir: '{{.USER_WORKING_DIR}}' - desc: 'Show the server status (seal state, HA, version) at BAO_ADDR.' + desc: 'Show the server status (seal state, HA, version) at BAO_ADDR (bao status).' cmds: - - bao status {{.CLI_ARGS}} + - openbao-run bao status {{.CLI_ARGS}} + + server:status: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Show the mode, the address, the server''s state (unsealed, sealed, not initialised, unreachable), and in server mode the process, the storage, the audit log, the backup choice (a standing warning when there is none) and whether the login is valid. Exit 0 unsealed, 10 unreachable, 11 sealed or not initialised.' + cmds: + - openbao-server status + + init: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Server mode, once, the Operator only, in a plain terminal (refuses inside an agent''s job and without a terminal): ask for a backup destination or an explicit "no backup" and for the Operator''s password, initialise the server (3 unseal shares, threshold 2, printed once to the terminal), unseal it with two of them, enable the dcf KV mount, userpass (login 4 hours, at most 8) and approle, write the Operator''s policy, record the backup choice, revoke the root token. -pgp-keys=... after -- encrypts the shares.' + interactive: true + cmds: + - openbao-server init {{.CLI_ARGS}} + + unseal: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Server mode, the Operator only, in a plain terminal (refuses inside an agent''s job and without a terminal): enter unseal shares at bao''s own hidden prompt until the server is unsealed. Needed after every server start.' + interactive: true + cmds: + - openbao-server unseal + + seal: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Server mode: seal the server with the saved login (refuses inside an agent''s job). Everything that reads a secret then fails with exit 11 until task openbao:unseal.' + cmds: + - openbao-server seal + + approle:issue: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Server mode: ROLE= POLICY_PATH= makes (or updates) an AppRole role that may read that one secret (this step asks for two unseal shares at a hidden prompt, to make a root token used for it alone and revoked after; the Operator only, in a plain terminal, refused inside an agent''s job), bound to the container''s loopback, with 5 minute tokens (at most 15) and secret ids that do not expire; ROLE= alone reissues an existing role; ALL=1 reissues every role in the store (both with your login, no shares). Each role ends with exactly one valid secret id, written with its role id under the state dir (mode 600, never printed).' + env: + OPENBAO_TASK_ROLE: '{{.ROLE | default ""}}' + OPENBAO_TASK_POLICY_PATH: '{{.POLICY_PATH | default ""}}' + OPENBAO_TASK_ALL: '{{.ALL | default ""}}' + cmds: + - openbao-server approle-issue + + approle:revoke: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Server mode: ROLE= deletes the role, which destroys its secret ids, with its policy and its files under the state dir. Asks for two unseal shares at a hidden prompt (a root token for this step alone, revoked after): the Operator only, in a plain terminal, refused inside an agent''s job. A token it already made lives at most 15 minutes.' + requires: + vars: [ROLE] + env: + OPENBAO_TASK_ROLE: '{{.ROLE}}' + cmds: + - openbao-server approle-revoke login: dir: '{{.USER_WORKING_DIR}}' - desc: 'Log in to BAO_ADDR. Auth method and flags after --, e.g. task openbao:login -- -method=oidc' + desc: 'Log in to BAO_ADDR. Auth method and flags after --, e.g. task openbao:login -- -method=oidc; against the server this Feature runs: task openbao:login -- -method=userpass username=operator' interactive: true cmds: - - bao login {{.CLI_ARGS}} + - openbao-run bao login {{.CLI_ARGS}} logout: dir: '{{.USER_WORKING_DIR}}' desc: 'Log out: revoke the current token on the server (bao token revoke -self; ignored if it is already dead) and erase the saved token in the state dir, so nothing is left behind. bao token revoke alone leaves the dead token stored.' cmds: - - bao token revoke -self || true + - openbao-run bao token revoke -self || true - openbao-token-helper erase kv:get: @@ -35,10 +82,11 @@ tasks: requires: vars: [KEY] cmds: - - bao kv get {{.CLI_ARGS}} '{{.KEY}}' + - openbao-run bao kv get {{.CLI_ARGS}} '{{.KEY}}' setup: dir: '{{.USER_WORKING_DIR}}' - desc: 'Re-run the post-create setup: make OPENBAO_STATE_DIR usable by the current user, move a token left in ~/.vault-token into it, report the token helper, the address and whether a login is saved (idempotent; OPENBAO_SETUP_MODE overrides the Feature option).' + desc: 'Re-run the post-create setup: make OPENBAO_STATE_DIR usable by the current user, move a token left in ~/.vault-token into it, report the token helper, the address and whether a login is saved; in server mode also run the start hook (write the server config, start the server if it is not running, say whether it is sealed). Idempotent; OPENBAO_SETUP_MODE overrides the Feature option.' cmds: - openbao-setup + - openbao-setup start diff --git a/test/openbao/mode_client.sh b/test/openbao/mode_client.sh index 3f11eb2..a515e65 100644 --- a/test/openbao/mode_client.sh +++ b/test/openbao/mode_client.sh @@ -1,12 +1,15 @@ #!/bin/bash -# Scenario 'mode_client' (see scenarios.json): mode=client given explicitly, and the run-time twin -# OPENBAO_SETUP_MODE for a mode this version does not have. +# Scenario 'mode_client' (see scenarios.json): mode=client given explicitly, the run-time twin +# OPENBAO_SETUP_MODE for a mode the Feature does not have, and that client mode runs no server. set -e # shellcheck disable=SC1091 # provided by the devcontainer CLI at test time source dev-container-features-test-lib check "mode baked as client" grep -q '^MODE=client$' /usr/local/share/openbao-feature/options.env check "openbao-setup says client" bash -c "openbao-setup | grep -F 'mode client'" -check "OPENBAO_SETUP_MODE=server is reported and runs as client" bash -c "OPENBAO_SETUP_MODE=server openbao-setup 2>&1 | grep -F \"mode 'server' is not available\"" +check "an unknown OPENBAO_SETUP_MODE is reported and runs as client" bash -c "OPENBAO_SETUP_MODE=sideways openbao-setup 2>&1 | grep -F \"mode 'sideways' is not known\"" +check "the start hook does nothing in client mode (no config, no server)" bash -c "openbao-setup start && [ ! -e \"\$OPENBAO_STATE_DIR/server.hcl\" ] && ! pgrep -x bao" +check "the server-mode tasks say they are for server mode " bash -c "openbao-server unseal &1 | grep -F 'this container runs no server'" +check "dcf-secret keeps the client-mode address default (BAO_ADDR stays unset)" bash -c "env -u BAO_ADDR bash -c '. /usr/local/share/openbao-feature/env.sh; [ -z \"\${BAO_ADDR:-}\" ]'" reportResults diff --git a/test/openbao/scenarios.json b/test/openbao/scenarios.json index 24e8900..722477d 100644 --- a/test/openbao/scenarios.json +++ b/test/openbao/scenarios.json @@ -35,5 +35,16 @@ "version": "2.7.1" } } + }, + "server_mode": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "remoteUser": "vscode", + "features": { + "go-task": {}, + "openbao": { + "version": "2.7.1", + "mode": "server" + } + } } } diff --git a/test/openbao/server_mode.sh b/test/openbao/server_mode.sh new file mode 100644 index 0000000..2a52217 --- /dev/null +++ b/test/openbao/server_mode.sh @@ -0,0 +1,243 @@ +#!/bin/bash +# Scenario 'server_mode' (see scenarios.json): the server this Feature runs (mode=server, OpenBao +# 2.7.1), remoteUser=vscode, with the real bao: what the Docker-free tests (test/openbao/unit) can +# only stub. A terminal is played with script(1), whose output file is the Operator's screen. +# - the start hook: raft storage on the volume, sealed and not initialised, the process the +# remote user's, started without any mlock capability or disable_mlock; +# - task openbao:init: the shares printed only to the terminal and in no file; the backup choice +# recorded; the login lifetime of 4 hours and at most 8 (a renewal is capped); the root token gone; +# - a restart comes back sealed and keeps what was written; task openbao:unseal and :seal; +# - dcf-secret's lines for each state; the audit device; +# - the AppRole tasks: the Operator's token cannot write a policy or make or delete a role; +# making and deleting a role go through a root token made from two shares (typed at a +# terminal, never echoed, revoked after); loopback binding honoured (secret id and token), one +# valid secret id after a reissue (the Operator's token only); +# - the refusals inside an agent's job and without a terminal, for init, unseal and seal. +set -e +# shellcheck disable=SC1091 # provided by the devcontainer CLI at test time +source dev-container-features-test-lib + +export BAO_ADDR=http://127.0.0.1:8200 +unset BAO_TOKEN +state="$OPENBAO_STATE_DIR" +tf="${OPENBAO_TEST_TASKFILE:-/usr/local/share/go-task/includes.d/openbao.yml}" +t() { task -t "$tf" "$@"; } +work="$(mktemp -d)" +pw='correct horse battery staple' + +# term : run the command under a pseudo-terminal, typing the lines of the +# answers (the word ENTER is an empty line) one by one; the screen is $work/screen, the exit code +# $work/rc. The shares are read off the screen when it says to press Enter, and typed back. +term() { # ... + local cmd="$1" a s1 s2 + shift + rm -f "$work/fifo" "$work/screen" "$work/rc" + mkfifo "$work/fifo" + script -qefc "$cmd; echo \$? > $work/rc" "$work/screen" < "$work/fifo" > /dev/null 2>&1 & + local spid=$! + exec 3> "$work/fifo" + sleep 1 + for a in "$@"; do + case "$a" in + SHARES) + for _ in $(seq 1 60); do grep -aq 'Press Enter' "$work/screen" && break; sleep 0.5; done + s1="$(grep -a -m1 '^Unseal Key 1: ' "$work/screen" | sed 's/^Unseal Key 1: //' | tr -d '\r')" + s2="$(grep -a -m1 '^Unseal Key 2: ' "$work/screen" | sed 's/^Unseal Key 2: //' | tr -d '\r')" + printf '%s\n' "$s1" "$s2" > "$work/shares" + printf '\n' >&3 + sleep 1 + printf '%s\n' "$s1" >&3 + sleep 1 + printf '%s\n' "$s2" >&3 + ;; + ENTER) printf '\n' >&3 ;; + *) printf '%s\n' "$a" >&3 ;; + esac + sleep 0.7 + done + sleep 1 + exec 3>&- + wait "$spid" || true + tr -d '\r' < "$work/screen" > "$work/screen.txt" || true + term_rc="$(cat "$work/rc" 2>/dev/null || echo '?')" +} +not() { ! "$@"; } +contains() { grep -qF -- "$2" <<<"$1"; } # +excludes() { ! grep -qF -- "$2" <<<"$1"; } +in_files() { grep -rlaF -- "$1" "$2" > /dev/null 2>&1; } # : found in any file +unsealed() { [ "$(openbao-run bao status -format=json | grep -c '"sealed": false')" = 1 ]; } +ttl() { bao token lookup -format=json | sed -n 's/.*"ttl": *\([0-9]*\).*/\1/p' | head -n 1; } +ttl_between() { local t; t="$(ttl)"; [ "$t" -le "$1" ] && [ "$t" -gt "$2" ]; } # +login_scribe() { bao write -field=token auth/approle/login role_id="$(cat "$state/approle/scribe/role_id")" secret_id="$(cat "$state/approle/scribe/secret_id")"; } +unseal_again() { # type the two saved shares at task openbao:unseal's prompts + local s1 s2 + s1="$(sed -n 1p "$work/shares")" + s2="$(sed -n 2p "$work/shares")" + term "task -t $tf unseal" "$s1" "$s2" +} + +# A root token made from the saved shares by hand (the test's own use, to inspect and tamper with +# a role, which the Operator's token may not): $rootok, to be dropped with drop_root. +make_root() { + local otp out enc nonce + otp="$(bao operator generate-root -generate-otp)" + # A share read from stdin (-) needs the attempt's nonce; only bao's own prompt looks it up. + out="$(bao operator generate-root -init -otp="$otp" -format=json)" || return 1 + nonce="$(sed -n 's/.*"nonce": *"\([^"]*\)".*/\1/p' <<<"$out" | head -n 1)" + out="$(sed -n 1p "$work/shares" | bao operator generate-root -nonce="$nonce" -format=json -)" || return 1 + out="$(sed -n 2p "$work/shares" | bao operator generate-root -nonce="$nonce" -format=json -)" || return 1 + enc="$(sed -n 's/.*"encoded_token": *"\([^"]*\)".*/\1/p' <<<"$out" | head -n 1)" + rootok="$(printf '%s' "$enc" | bao operator generate-root -decode=- -otp="$otp")" && [ -n "$rootok" ] +} +drop_root() { BAO_TOKEN="$rootok" bao token revoke -self > /dev/null 2>&1; rootok=""; } + +# --- the start hook ------------------------------------------------------------------------ +# post-create (hands the volume to the remote user) and post-start, as the lifecycle runs them +openbao-setup > /dev/null +openbao-setup start +rc=0; out="$(dcf-secret status 2>/dev/null)" || rc=$? +check "after the start hook the server is not initialised (exit 11)" [ "$rc:$out" = 11:not-initialised ] +check "the config is in the volume, with raft storage there" grep -q "path = \"$state/raft\"" "$state/server.hcl" +check "the config sets no disable_mlock (OpenBao uses no mlock since 2.0.0)" not grep -q "^disable_mlock" "$state/server.hcl" +check "the server runs as the remote user, not root" [ "$(ps -o user= -p "$(cat "$state/server.pid")" | tr -d ' ')" = "$(id -un)" ] +check "the server started without a lock on memory and says no error about it" not grep -qi 'mlock' "$state/server.log" +check "starting again reuses the live server" contains "$(openbao-setup start)" 'already running' +rc=0; err="$(dcf-secret get ssh/x 2>&1 > /dev/null)" || rc=$? +check "dcf-secret says to initialise, exit 11" [ "$rc" = 11 ] +check "and names the init task" contains "$err" 'task openbao:init' + +# --- the refusals before init ------------------------------------------------------------------- +refused() { [ "$1" != 0 ] && contains "$2" "refuses to run inside an agent's job"; } # +for tk in init unseal seal; do + rc=0; out="$(DEVHOST_JOB_CHILD=1 t "$tk" 2>&1 < /dev/null)" || rc=$? + check "task openbao:$tk refuses inside the devhost's job" refused "$rc" "$out" + rc=0; out="$(CLAUDECODE=1 t "$tk" 2>&1 < /dev/null)" || rc=$? + check "task openbao:$tk refuses inside a Claude Code run" refused "$rc" "$out" +done +rc=0; out="$(t init 2>&1 < /dev/null)" || rc=$? +check "task openbao:init without a terminal is refused" [ "$rc" != 0 ] +check "and says it needs a terminal" contains "$out" 'needs a terminal' +check "none of the refusals touched the server (still not initialised)" [ "$(dcf-secret status 2>/dev/null)" = not-initialised ] + +# --- init --------------------------------------------------------------------------------------- +term "task -t $tf init" "no backup" "$pw" "$pw" SHARES +check "task openbao:init exits 0" [ "$term_rc" = 0 ] +check "the shares were shown on the terminal" [ "$(grep -c '^Unseal Key [0-9]: ' "$work/screen.txt")" = 3 ] +check "the backup choice is recorded as no backup" grep -qx 'choice=none' "$state/backup-choice" +check "the screen says there is no backup" grep -q 'NO BACKUP' "$work/screen.txt" +check "no share is in any file of the volume" not in_files "$(sed -n 1p "$work/shares")" "$state" +check "the init output (the root token line) is nowhere in the volume" not in_files 'Initial Root Token' "$state" +check "the server is unsealed" unsealed +rc=0; dcf-secret get ssh/x > /dev/null 2>&1 || rc=$? +check "no login yet: dcf-secret is exit 12" [ "$rc" = 12 ] + +# --- the Operator's login -------------------------------------------------------------------------- +term "task -t $tf login -- -method=userpass username=operator" "$pw" +check "the userpass login works" [ "$(dcf-secret status)" = ready ] +check "the login token is in the volume (mode 600)" [ "$(stat -c %a "$state/token")" = 600 ] +check "the login lasts 4 hours" ttl_between 14400 14000 +bao token renew -increment=24h > /dev/null 2>&1 || true +check "a renewal past the maximum is capped at 8 hours" ttl_between 28800 14400 +check "the root token was revoked at the end of init (the Operator's policy cannot list auth mounts)" not bao auth list + +# --- the store, and what the Operator may do -------------------------------------------------------- +check "dcf-secret put works with the login" bash -c "printf 'abc def' | dcf-secret put ssh/key --field private_key" +check "and get returns it" [ "$(dcf-secret get ssh/key --field private_key)" = 'abc def' ] +check "the Operator may not use snapshot-force" not bao write -f sys/storage/raft/snapshot-force +check "the audit log exists, is in the volume and is not empty" test -s "$state/audit.log" +check "the audit log holds no secret in clear" not grep -qF 'abc def' "$state/audit.log" +out="$(t server:status)" || true +check "server:status shows unsealed" contains "$out" 'server: unsealed' +check "and the standing no-backup warning" contains "$out" 'WARNING no backup' + +# --- AppRole --------------------------------------------------------------------------------------- +# the Operator's token cannot write a policy, make or change a role, or delete one +rc=0; echo 'path "sys/storage/raft/snapshot-force" { capabilities = ["update"] }' | bao policy write approle-evil - > /dev/null 2>&1 || rc=$? +check "the Operator's token cannot write a policy (permission denied)" [ "$rc" != 0 ] +rc=0; out="$(echo 'path "x" { capabilities = ["read"] }' | bao policy write approle-evil - 2>&1)" || rc=$? +check "and the refusal is the policy's" contains "$out" 'permission denied' +rc=0; out="$(bao write auth/approle/role/evil token_policies=operator 2>&1)" || rc=$? +check "it cannot create a role" [ "$rc" != 0 ] +check "the refusal is the policy's" contains "$out" 'permission denied' +rc=0; bao read sys/policies/acl/operator > /dev/null 2>&1 || rc=$? +check "it cannot read policies either" [ "$rc" != 0 ] +# making a role asks for the shares (no terminal: refused, nothing made) +rc=0; out="$(t approle:issue ROLE=scribe POLICY_PATH=dcf/register/scribe 2>&1 < /dev/null)" || rc=$? +check "making a role without a terminal is refused" [ "$rc" != 0 ] +check "and the refusal says it needs a terminal" contains "$out" 'needs a terminal' +# making a role: the shares make a root token for that step only +term "task -t $tf approle:issue ROLE=scribe POLICY_PATH=dcf/register/scribe" "$(sed -n 1p "$work/shares")" "$(sed -n 2p "$work/shares")" +check "approle:issue of a new role through the shares exits 0" [ "$term_rc" = 0 ] +check "it said it asks for the shares" contains "$(cat "$work/screen.txt")" 'unseal shares typed at the prompt' +check "no share was echoed on the screen" excludes "$(cat "$work/screen.txt")" "$(sed -n 1p "$work/shares")" +check "the role exists" bao read auth/approle/role/scribe/role-id +check "and the root token generation is not left open" contains "$(bao operator generate-root -status 2>&1)" 'Started false' +check "approle:issue wrote the secret id (mode 600)" [ "$(stat -c %a "$state/approle/scribe/secret_id")" = 600 ] +check "and the role id" test -s "$state/approle/scribe/role_id" +make_root +role="$(BAO_TOKEN="$rootok" bao read -format=json auth/approle/role/scribe)" +drop_root +check "the role's secret ids are bound to loopback" contains "$role" '"secret_id_bound_cidrs": [' +check "and its tokens" contains "$role" '"token_bound_cidrs": [' +check "the binding is 127.0.0.1 (twice)" [ "$(grep -c '"127.0.0.1' <<<"$role")" -ge 2 ] +check "secret ids do not expire (secret_id_ttl 0)" contains "$role" '"secret_id_ttl": 0' +printf 'only this' | dcf-secret put register/scribe --field private_key +tok="$(login_scribe)" +check "the back logs in from loopback with the files and reads its one secret" [ "$(BAO_TOKEN="$tok" dcf-secret get register/scribe --field private_key)" = 'only this' ] +rc=0; BAO_TOKEN="$tok" dcf-secret get ssh/key --field private_key > /dev/null 2>&1 || rc=$? +check "and nothing else (exit 13)" [ "$rc" = 13 ] +old_sid="$(cat "$state/approle/scribe/secret_id")" +t approle:issue ALL=1 > /dev/null 2>&1 +check "ALL=1 wrote a new secret id" [ "$(cat "$state/approle/scribe/secret_id")" != "$old_sid" ] +rc=0; bao write auth/approle/login role_id="$(cat "$state/approle/scribe/role_id")" secret_id="$old_sid" > /dev/null 2>&1 || rc=$? +check "the earlier secret id no longer logs in" [ "$rc" != 0 ] +check "the new one does" login_scribe +# the CIDR bindings are enforced: the container's loopback is in them, so show the converse +make_root +BAO_TOKEN="$rootok" bao write auth/approle/role/scribe secret_id_bound_cidrs=10.0.0.0/8 > /dev/null +rc=0; out="$(login_scribe 2>&1)" || rc=$? +check "a secret id bound to another network cannot log in from loopback" [ "$rc" != 0 ] +check "and the refusal is the CIDR's" contains "$out" 'CIDR' +BAO_TOKEN="$rootok" bao write auth/approle/role/scribe secret_id_bound_cidrs=127.0.0.1/32 token_bound_cidrs=10.0.0.0/8 > /dev/null +drop_root +tok="$(login_scribe)" +rc=0; BAO_TOKEN="$tok" bao token lookup > /dev/null 2>&1 || rc=$? +check "a token bound to another network is refused from loopback" [ "$rc" != 0 ] +rc=0; bao delete auth/approle/role/scribe > /dev/null 2>&1 || rc=$? +check "the Operator's token cannot delete a role" [ "$rc" != 0 ] +term "task -t $tf approle:revoke ROLE=scribe" "$(sed -n 1p "$work/shares")" "$(sed -n 2p "$work/shares")" +check "approle:revoke through the shares exits 0" [ "$term_rc" = 0 ] +check "approle:revoke removed the files" test ! -e "$state/approle/scribe" +make_root +check "approle:revoke removed the role's policy" not env BAO_TOKEN="$rootok" bao read sys/policies/acl/approle-scribe +drop_root +check "the Operator's own login is still the Operator's (no root policy on it)" not contains "$(bao token lookup 2>&1)" 'root' +check "no share was echoed on the screen by revoke" excludes "$(cat "$work/screen.txt")" "$(sed -n 1p "$work/shares")" +rc=0; bao write auth/approle/login role_id=x secret_id=y > /dev/null 2>&1 || rc=$? +check "and the role no longer logs in" [ "$rc" != 0 ] + +# --- seal, restart, unseal ---------------------------------------------------------------------------- +t seal > /dev/null 2>&1 +rc=0; out="$(dcf-secret status 2>/dev/null)" || rc=$? +check "after task openbao:seal dcf-secret is sealed (exit 11)" [ "$rc:$out" = 11:sealed ] +rc=0; err="$(dcf-secret get ssh/key 2>&1 > /dev/null)" || rc=$? +check "its line names task openbao:unseal" contains "$err" 'task openbao:unseal' +# a restart of the server: the process dies, the start hook brings it back, sealed +kill "$(cat "$state/server.pid")" +sleep 2 +rc=0; out="$(dcf-secret status 2>/dev/null)" || rc=$? +check "with the server down dcf-secret is unreachable (exit 10)" [ "$rc:$out" = 10:unreachable ] +rc=0; err="$(dcf-secret get ssh/key 2>&1 > /dev/null)" || rc=$? +check "its line names task openbao:server:status" contains "$err" 'task openbao:server:status' +out="$(openbao-setup start)" +check "the start hook brings the server back sealed" contains "$out" 'sealed; the Operator runs task openbao:unseal' +rc=0; out="$(dcf-secret status 2>/dev/null)" || rc=$? +check "a restarted server is sealed (exit 11)" [ "$rc:$out" = 11:sealed ] +unseal_again +check "task openbao:unseal exits 0" [ "$term_rc" = 0 ] +check "the server is unsealed again" unsealed +check "what was written before the restart is still there (the login too)" [ "$(dcf-secret get ssh/key --field private_key)" = 'abc def' ] +check "the unseal printed no share" excludes "$(cat "$work/screen.txt")" "$(sed -n 1p "$work/shares")" + +rm -rf "$work" +reportResults diff --git a/test/openbao/test.sh b/test/openbao/test.sh index 03752cb..ec8fdc9 100755 --- a/test/openbao/test.sh +++ b/test/openbao/test.sh @@ -19,6 +19,11 @@ check "OPENBAO_STATE_DIR set" bash -c '[ "$OPENBAO_STATE_DIR" = /var/lib/devcont check "dcf-persist on PATH" command -v dcf-persist check "openbao-setup on PATH" command -v openbao-setup check "dcf-secret on PATH" command -v dcf-secret +check "openbao-server on PATH" command -v openbao-server +check "openbao-run on PATH" command -v openbao-run +check "env.sh is installed for them" test -f /usr/local/share/openbao-feature/env.sh +check "the server-mode tasks are in the Taskfile" bash -c "grep -q '^ init:' /usr/local/share/go-task/includes.d/openbao.yml && grep -q '^ unseal:' /usr/local/share/go-task/includes.d/openbao.yml && grep -q '^ seal:' /usr/local/share/go-task/includes.d/openbao.yml && grep -q '^ server:status:' /usr/local/share/go-task/includes.d/openbao.yml && grep -q '^ approle:issue:' /usr/local/share/go-task/includes.d/openbao.yml && grep -q '^ approle:revoke:' /usr/local/share/go-task/includes.d/openbao.yml" +check "client mode (the default) runs no server" bash -c "openbao-setup start; ! pgrep -x bao" check "mode baked for openbao-setup" grep -q '^MODE=client$' /usr/local/share/openbao-feature/options.env check "openbao-setup reports the state dir" bash -c "openbao-setup | grep -E '^openbao: /var/lib/devcontainer-features/openbao \((volume|bind|image), writable\)$'" # shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash diff --git a/test/openbao/unit/dcf_secret_test.sh b/test/openbao/unit/dcf_secret_test.sh index 9dd6f35..0238789 100644 --- a/test/openbao/unit/dcf_secret_test.sh +++ b/test/openbao/unit/dcf_secret_test.sh @@ -23,6 +23,7 @@ cat > "$T/bin/bao" <<'STUB' #!/usr/bin/env bash # Stub bao: STUB_* variables choose the answers; every call is logged (arguments only). echo "$*" >> "$STUB_LOG" +echo "${BAO_ADDR-unset}" >> "$STUB_DIR/addr.log" case "$1 $2" in "status -format=json") printf '%s\n' "${STUB_STATUS_JSON:-{\"initialized\": true, \"sealed\": false\}}" @@ -112,15 +113,17 @@ run STUB_KV_RC=2 STUB_STATUS_RC=1 STUB_STATUS_JSON= -- get ssh/key check "10: unreachable" 10 "$rc" check "10: stdout empty" "" "$out" has "10: the fix names BAO_ADDR" "BAO_ADDR" "$err" +has "10: and the task that says whether the server runs" "task openbao:server:status" "$err" check "10: one line" 1 "$(lines "$err")" run STUB_KV_RC=2 STUB_STATUS_RC=2 STUB_STATUS_JSON='{"initialized": true, "sealed": true}' -- get ssh/key check "11: sealed" 11 "$rc" check "11: stdout empty" "" "$out" -has "11: the fix says who unseals" "unseal" "$err" +has "11: the fix says who unseals, and the task" "task openbao:unseal" "$err" check "11: one line" 1 "$(lines "$err")" run STUB_KV_RC=2 STUB_STATUS_RC=2 STUB_STATUS_JSON='{"initialized": false, "sealed": true}' -- get ssh/key check "11: not initialised is 11 too" 11 "$rc" has "11: not initialised says so" "not initialised" "$err" +has "11: not initialised names the init task" "task openbao:init" "$err" run STUB_KV_RC=2 STUB_CAPS_RC=2 -- get ssh/key check "12: no valid token" 12 "$rc" has "12: the fix is the login" "task openbao:login" "$err" @@ -142,6 +145,31 @@ run STUB_KV_RC=2 STUB_CAPS="create, update" -- get ssh/key check "13: create and update alone do not read" 13 "$rc" has "13: the capabilities asked on the KV v2 data path" "token capabilities dcf/data/ssh/key" "$(cat "$LOG")" +# --- server mode: the same lines, and BAO_ADDR defaults to the local server (env.sh, options.env) +mkdir -p "$T/feat-server" "$T/feat-client" +cp "$here/../../../src/openbao/env.sh" "$T/feat-server/env.sh" +cp "$here/../../../src/openbao/env.sh" "$T/feat-client/env.sh" +printf 'MODE=server\n' > "$T/feat-server/options.env" +printf 'MODE=client\n' > "$T/feat-client/options.env" +: > "$T/addr.log" +run OPENBAO_FEATURE_DIR="$T/feat-server" -- get ssh/key +check "server mode: get works" 0 "$rc" +check "server mode: BAO_ADDR defaults to the local listener" "http://127.0.0.1:8200" "$(sort -u "$T/addr.log")" +: > "$T/addr.log" +run OPENBAO_FEATURE_DIR="$T/feat-server" BAO_ADDR=https://bao.example.test -- get ssh/key +check "server mode: a BAO_ADDR that is set is kept" "https://bao.example.test" "$(sort -u "$T/addr.log")" +: > "$T/addr.log" +run OPENBAO_FEATURE_DIR="$T/feat-client" -- get ssh/key +check "client mode: BAO_ADDR is left unset (bao's own default)" "unset" "$(sort -u "$T/addr.log")" +: > "$T/addr.log" +run OPENBAO_FEATURE_DIR="$T/feat-client" OPENBAO_SETUP_MODE=server -- get ssh/key +check "OPENBAO_SETUP_MODE=server overrides the baked mode" "http://127.0.0.1:8200" "$(sort -u "$T/addr.log")" +run OPENBAO_FEATURE_DIR="$T/feat-server" STUB_KV_RC=2 STUB_STATUS_RC=1 STUB_STATUS_JSON= -- get ssh/key +check "server mode: unreachable is still 10" 10 "$rc" +has "server mode: unreachable names the default address" "http://127.0.0.1:8200" "$err" +run OPENBAO_FEATURE_DIR="$T/feat-server" STUB_KV_RC=2 STUB_STATUS_RC=2 STUB_STATUS_JSON='{"initialized": true}' -- get ssh/key +has "server mode: sealed names the unseal task" "task openbao:unseal" "$err" + # --- the order of the questions, and that nothing is read in place of the server run STUB_KV_RC=2 STUB_STATUS_RC=2 -- get ssh/key check "sealed: no capabilities question once the server is known sealed" 0 "$(grep -c 'token capabilities' "$LOG")" diff --git a/test/openbao/unit/logout_test.sh b/test/openbao/unit/logout_test.sh index 0badc27..8235522 100644 --- a/test/openbao/unit/logout_test.sh +++ b/test/openbao/unit/logout_test.sh @@ -2,7 +2,8 @@ # Docker-free tests of `task openbao:logout` (src/openbao/taskfile.yml) with a stub bao and the real # token helper: the token revoked on the server (bao token revoke -self) and then erased from the # state dir, the erase done when the revoke fails (a token that is already dead), and no error -# when nothing is stored. Part of `task lint` (`task test:openbao-unit`). Needs task. +# when nothing is stored. The task goes through openbao-run (the real one: it applies server mode's +# BAO_ADDR default and changes nothing else). Part of `task lint` (`task test:openbao-unit`). Needs task. set -u exec "$T/bin/bao" <<'STUB' #!/usr/bin/env bash echo "$*" >> "$STUB_LOG" diff --git a/test/openbao/unit/server_test.sh b/test/openbao/unit/server_test.sh new file mode 100644 index 0000000..a787786 --- /dev/null +++ b/test/openbao/unit/server_test.sh @@ -0,0 +1,600 @@ +#!/usr/bin/env bash +# Docker-free tests of openbao-server (src/openbao/openbao-server.sh), what the server-mode tasks +# run, against a stub `bao` that keeps a small state (not initialised, sealed, unsealed, down) and +# logs every call with the token it was given. That the real bao behaves as the stub plays is the +# container test test/openbao/server_mode.sh (CI), and was run against a real bao 2.7.2 server while +# this was written. Covered: +# - the refusal inside an agent's job for EVERY task that handles a share or a token (init, +# unseal, seal): each of DEVHOST_JOB_CHILD, CLAUDECODE and a variable named in +# OPENBAO_AGENT_JOB_VARS, with and without a terminal, directly and through the Taskfile, and +# never a call to bao, never a prompt; and the refusal without a terminal and in client mode; +# - init under a pseudo-terminal (script(1)): the backup choice asked for and recorded (a path, or +# the explicit words; an empty or invalid answer asked again), the Operator's password (twice, +# 12 characters at least, on bao's stdin and never in an argument), 3 shares threshold 2, the +# shares printed to the terminal only (in no file, no log, no argument), the unseal, the +# configuration in order, the root token used only for it and then revoked, and a failed step +# revoking it too; only -pgp-keys accepted; +# - unseal and seal; status in every state with the backup warning; the AppRole tasks (a role's +# policy, loopback binding, one valid secret id written 0600 and never printed, ALL=1, revoke): the +# Operator's token (the stub refuses it a policy write and a role write or delete) only reissues; +# making and deleting a role go through the root token the shares make (generate-root, two shares +# typed under a pseudo-terminal, the token used for that step only and revoked after, also after a +# failing step, a wrong share cancelling the attempt), refused inside an agent's job and without a +# terminal. +# Part of `task lint` (`task test:openbao-unit`). SCRIPT points the test at another copy of the +# script (used to show a test fails when the code it covers is broken). Needs task, and script(1) +# for the terminal tests (skipped without it). +set -u +exec "$T/feat-server/options.env" +printf 'MODE=client\n' > "$T/feat-client/options.env" +install -m 0755 "$script" "$T/bin/openbao-server" +install -m 0755 "$root/src/openbao/openbao-run.sh" "$T/bin/openbao-run" +LOG="$T/calls.log" + +cat > "$T/bin/bao" <<'STUB' +#!/bin/bash +# Stub bao. $STUB_DIR/stubstate is uninit, sealed, unsealed or down; $STUB_DIR/have lists what exists. +echo "$*" >> "$STUB_LOG" +echo "$1 $2 token=${BAO_TOKEN-unset}" >> "$STUB_DIR/tokens.log" +st="$(cat "$STUB_DIR/stubstate" 2>/dev/null || echo unsealed)" +deny_operator() { [ "${BAO_TOKEN:-}" != s.OPERATOR ] || { echo "Error: permission denied" >&2; exit 2; }; } +have() { grep -qxF "$1" "$STUB_DIR/have" 2>/dev/null; } +add() { echo "$1" >> "$STUB_DIR/have"; } +drop() { grep -vxF "$1" "$STUB_DIR/have" > "$STUB_DIR/have.new" 2>/dev/null; mv "$STUB_DIR/have.new" "$STUB_DIR/have"; } +case "$1 $2" in + "status -format=json") + case "$st" in + uninit) echo '{"initialized": false, "sealed": true}'; exit 2 ;; + sealed) echo '{"initialized": true, "sealed": true}'; exit 2 ;; + down) exit 1 ;; + *) echo '{"initialized": true, "sealed": false}'; exit 0 ;; + esac ;; + "operator init") + [ "$st" = uninit ] || exit 2 + [ -z "${STUB_INIT_FAIL:-}" ] || { echo "Error initializing: stub" >&2; exit 2; } + echo sealed > "$STUB_DIR/stubstate" + printf 'Unseal Key 1: SHARE-ONE-aaaa\nUnseal Key 2: SHARE-TWO-bbbb\nUnseal Key 3: SHARE-THREE-cccc\n\nInitial Root Token: s.ROOT-TOKEN\n\nVault initialized.\n' + exit 0 ;; + "operator unseal") + [ -z "${STUB_UNSEAL_FAIL:-}" ] || { echo "Error unsealing: stub" >&2; exit 2; } + n=$(( $(cat "$STUB_DIR/unseal.n" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$STUB_DIR/unseal.n" + if [ "$n" -ge 2 ]; then echo unsealed > "$STUB_DIR/stubstate"; echo "Sealed false"; else echo "Unseal Progress 1/2"; fi + exit 0 ;; + "operator seal") [ "${STUB_SEAL_RC:-0}" -eq 0 ] && echo sealed > "$STUB_DIR/stubstate"; exit "${STUB_SEAL_RC:-0}" ;; + "token lookup") exit "${STUB_LOOKUP_RC:-0}" ;; + "token revoke") exit 0 ;; + "read -format=json") echo '{"data": {"is_self": true}}' | sed 's/"is_self": true/"is_self": true/'; exit 0 ;; + "secrets enable") [ -z "${STUB_STEP_FAIL:-}" ] || exit 2; add "sys/mounts/dcf"; exit 0 ;; + "auth enable") add "sys/auth/$3"; exit 0 ;; + "operator generate-root") + case "$*" in + *-generate-otp*) echo "OTP-STUB-1234"; exit 0 ;; + *-init*) + [ ! -e "$STUB_DIR/gr.attempt" ] || { echo "Error: a root generation attempt is already in progress" >&2; exit 2; } + echo 0 > "$STUB_DIR/gr.attempt" + echo '{"nonce": "n1", "started": true, "progress": 0, "required": 2, "complete": false, "encoded_token": ""}'; exit 0 ;; + *-cancel*) rm -f "$STUB_DIR/gr.attempt"; echo cancelled >> "$STUB_DIR/gr.cancelled"; exit 0 ;; + *-decode=-*) cat > /dev/null; echo "s.GENROOT"; exit 0 ;; + *) + [ -e "$STUB_DIR/gr.attempt" ] || exit 2 + IFS= read -rs share + [ "$share" != BADSHARE ] || { echo "Error posting unseal key: stub" >&2; exit 2; } + n=$(( $(cat "$STUB_DIR/gr.attempt") + 1 )); echo "$n" > "$STUB_DIR/gr.attempt" + if [ "$n" -ge 2 ]; then + rm -f "$STUB_DIR/gr.attempt" + echo '{"nonce": "n1", "started": true, "progress": 2, "required": 2, "complete": true, "encoded_token": "ENCODED-STUB"}' + else + echo '{"nonce": "n1", "started": true, "progress": 1, "required": 2, "complete": false, "encoded_token": ""}' + fi + exit 0 ;; + esac ;; + "policy write") deny_operator; cat > "$STUB_DIR/policy.$3"; exit 0 ;; + "policy delete") deny_operator; rm -f "$STUB_DIR/policy.$3"; exit 0 ;; + "list auth/approle/role") + roles="$(sed -n 's#^auth/approle/role/##p' "$STUB_DIR/have" 2>/dev/null)" + [ -n "$roles" ] || { echo "No value found" >&2; exit 2; } + printf 'Keys\n----\n%s\n' "$roles"; exit 0 ;; + "delete auth/approle/role/"*) deny_operator; drop "$2"; rm -f "$STUB_DIR/acc.${2##*/}"; exit 0 ;; +esac +case "$1" in + read) + case "$2" in + -field=role_id) echo "ROLEID-${3#auth/approle/role/}" | sed 's#/role-id##'; exit 0 ;; + *) have "${2%/role-id}" && exit 0; exit 2 ;; + esac ;; + list) + r="${2#auth/approle/role/}"; r="${r%/secret-id}" + [ -s "$STUB_DIR/acc.$r" ] || { echo "No value found" >&2; exit 2; } + printf 'Keys\n----\n'; cat "$STUB_DIR/acc.$r"; exit 0 ;; + write) + case "${*:2}" in + *"secret-id-accessor/destroy"*) + r="$2"; r="${r#auth/approle/role/}"; r="${r%%/*}" + a="${!#}"; a="${a#secret_id_accessor=}" + grep -vxF "$a" "$STUB_DIR/acc.$r" > "$STUB_DIR/acc.new" 2>/dev/null; mv "$STUB_DIR/acc.new" "$STUB_DIR/acc.$r"; exit 0 ;; + "-f -field=secret_id auth/approle/role/"*) + r="${4#auth/approle/role/}"; r="${r%/secret-id}" + n=$(( $(cat "$STUB_DIR/sid.n" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$STUB_DIR/sid.n" + echo "ACC-$n" >> "$STUB_DIR/acc.$r" + printf 'SECRETID-%s' "$n"; exit 0 ;; + "auth/userpass/users/operator "*) cat > "$STUB_DIR/userpass.stdin"; exit 0 ;; + "auth/approle/role/"*) deny_operator; [ -z "${STUB_ROLE_FAIL:-}" ] || { echo "Error writing role: stub" >&2; exit 2; }; add "$2"; exit 0 ;; + esac ;; + delete) exit 0 ;; +esac +echo "stub bao: unexpected call: $*" >&2 +exit 99 +STUB +chmod +x "$T/bin/bao" + +pass=0 fail=0 +check() { # + if [ "$2" = "$3" ]; then pass=$((pass + 1)); echo "ok - $1" + else fail=$((fail + 1)); echo "FAIL - $1 (expected '$2', got '$3')"; fi +} +has() { # + if grep -qF -- "$2" <<<"$3"; then pass=$((pass + 1)); echo "ok - $1" + else fail=$((fail + 1)); echo "FAIL - $1 (no '$2' in: $3)"; fi +} +lacks() { # + if grep -qF -- "$2" <<<"$3"; then fail=$((fail + 1)); echo "FAIL - $1 ('$2' found in: $3)" + else pass=$((pass + 1)); echo "ok - $1"; fi +} + +reset() { # : a fresh stub state and state dir + rm -rf "${T:?}/state" "${T:?}"/acc.* "${T:?}"/sid.n "${T:?}"/unseal.n "${T:?}"/policy.* "${T:?}"/userpass.stdin "${T:?}"/have "${T:?}"/tokens.log + mkdir -p "$T/state" "$T/backups" + echo "${1:-unsealed}" > "$T/stubstate" + : > "$LOG" +} +envs() { # the environment every run gets, the mode first + echo "PATH=$T/bin:/usr/local/bin:/usr/bin:/bin" "HOME=$T/home" "STUB_LOG=$LOG" "STUB_DIR=$T" "OPENBAO_STATE_DIR=$T/state" "OPENBAO_FEATURE_DIR=$T/feat-${MODE_FEAT:-server}" ${OPTOK:+"BAO_TOKEN=$OPTOK"} +} +MODE_FEAT=server +# run [VAR=value ...] -- : stdout+stderr in $out, exit status in $rc, no terminal +run() { + local -a e=() + while [ "$1" != -- ]; do e+=("$1"); shift; done + shift + # shellcheck disable=SC2046 # envs prints words + out="$(env -i $(envs) "${e[@]}" bash "$T/bin/openbao-server" "$@" 2>&1)" + rc=$? +} +have_script=0 +command -v script >/dev/null 2>&1 && have_script=1 +# script -e returns the child's exit status, but through a pipeline only the last command's status +# is seen: keep it in a file +term() { # [VAR=value ...] -- + local input="$1" + shift + local -a e=() + while [ "$1" != -- ]; do e+=("$1"); shift; done + shift + # shellcheck disable=SC2046 # envs prints words + local cmd + cmd="env -i $(envs | tr '\n' ' ') ${e[*]:-} bash $T/bin/openbao-server $*" + rm -f "$T/term.rc" + out="$({ + sleep 0.7 + while IFS= read -r -d ';' part; do printf '%s\n' "$part"; sleep 0.4; done <<<"$input;" + sleep 1.5 + } | { script -qefc "$cmd; echo \$? > $T/term.rc" /dev/null 2>&1 || true; } | tr -d '\r')" + rc="$(cat "$T/term.rc" 2>/dev/null || echo "?")" +} +calls() { cat "$LOG"; } + +# ------------------------------------------------------------------------------ refusals ---- +# Inside an agent's job: every task that handles a share or a token. No call to bao, whatever else. +for t in init unseal seal; do + for v in DEVHOST_JOB_CHILD CLAUDECODE; do + reset sealed + run "$v=1" -- "$t" + check "$t refuses inside an agent's job ($v): exit 3" 3 "$rc" + has "$t ($v): the line names the variable and the reason" "refuses to run inside an agent's job ($v is set)" "$out" + check "$t ($v): bao is never called" "" "$(calls)" + done + reset sealed + run OPENBAO_AGENT_JOB_VARS="OTHERCLI MYCLI" MYCLI=1 -- "$t" + check "$t refuses for a variable named in OPENBAO_AGENT_JOB_VARS: exit 3" 3 "$rc" + has "$t (OPENBAO_AGENT_JOB_VARS): names MYCLI" "(MYCLI is set)" "$out" + reset sealed + run DEVHOST_JOB_CHILD= -- "$t" /dev/null 2>&1; then + for t in init unseal seal; do + reset sealed + # shellcheck disable=SC2046 # envs prints words + out="$(cd "$T" && env -i $(envs) DEVHOST_JOB_CHILD=1 task -t "$root/src/openbao/taskfile.yml" "$t" 2>&1)" + rc=$? + check "task openbao:$t refuses inside an agent's job: not exit 0" 1 "$([ "$rc" -ne 0 ] && echo 1 || echo 0)" + has "task openbao:$t: the refusal is the one shown" "refuses to run inside an agent's job (DEVHOST_JOB_CHILD is set)" "$out" + check "task openbao:$t: bao is never called" "" "$(calls)" + done +else + echo "skip - the Taskfile refusals (no task on PATH)" +fi +# with a terminal the refusal still comes first: no prompt, no call +if [ "$have_script" -eq 1 ]; then + for t in init unseal seal; do + reset sealed + term "no backup" DEVHOST_JOB_CHILD=1 -- "$t" + has "$t under a terminal in an agent's job is still refused" "refuses to run inside an agent's job" "$out" + lacks "$t under a terminal in an agent's job asks nothing" "Backup destination" "$out" + check "$t under a terminal in an agent's job: bao is never called" "" "$(calls)" + done +fi +# no terminal +for t in init unseal; do + reset sealed + run -- "$t" + check "$t without a terminal: exit 3" 3 "$rc" + has "$t without a terminal: says so" "needs a terminal" "$out" + check "$t without a terminal: bao is never called" "" "$(calls)" +done +# client mode: this container runs no server +MODE_FEAT=client +for t in init unseal seal approle-issue approle-revoke; do + reset sealed + run OPENBAO_TASK_ROLE=x -- "$t" + check "$t in client mode: exit 3" 3 "$rc" + has "$t in client mode: says why" "this container runs no server (mode client)" "$out" + check "$t in client mode: bao is never called" "" "$(calls)" +done +MODE_FEAT=server + +# ------------------------------------------------------------------------------------ init ---- +if [ "$have_script" -eq 1 ]; then + reset uninit + term "no backup;correct horse battery;correct horse battery;;" -- init + check "init: exit 0" 0 "$rc" + has "init: the shares are printed to the terminal" "Unseal Key 1: SHARE-ONE-aaaa" "$out" + has "init: all three" "Unseal Key 3: SHARE-THREE-cccc" "$out" + has "init: says to store them" "Keep one in your password manager" "$out" + check "init: the backup choice file says none, with a date" "choice=none" "$(sed -n 1p "$T/state/backup-choice")" + check "init: the choice has a date" 1 "$(grep -c '^date=20' "$T/state/backup-choice")" + check "init: the choice file is mode 600" 600 "$(stat -c %a "$T/state/backup-choice")" + check "init: 3 shares and threshold 2" "operator init -key-shares=3 -key-threshold=2" "$(grep '^operator init' "$LOG")" + check "init: unsealed with two shares (two unseal calls, no share as an argument)" "operator unseal|operator unseal" "$(grep '^operator unseal' "$LOG" | paste -sd'|')" + order="$(grep -nE '^(operator init|operator unseal|secrets enable|auth enable|policy write|write auth/userpass|token revoke)' "$LOG" | sed 's/^[0-9]*://' | awk '{print $1" "$2}' | paste -sd'|')" + check "init: the steps in order, root revoked last" "operator init|operator unseal|operator unseal|secrets enable|auth enable|auth enable|policy write|write auth/userpass/users/operator|token revoke" "$order" + has "init: the dcf KV v2 mount" "secrets enable -path=dcf -version=2 kv" "$(calls)" + has "init: userpass and approle enabled" "auth enable userpass" "$(calls)" + has "init: approle" "auth enable approle" "$(calls)" + has "init: the login lifetime of ruling 6 (4 hours, at most 8)" "token_ttl=4h token_max_ttl=8h" "$(calls)" + has "init: the Operator's policy" "token_policies=operator" "$(calls)" + check "init: the password reached bao on stdin" "correct horse battery" "$(cat "$T/userpass.stdin")" + lacks "init: the password is in no argument" "correct horse" "$(calls)" + lacks "init: no share is in any argument" "SHARE-" "$(calls)" + check "init: the root token was used for the configuration only" "yes" "$(grep -q 'secrets enable token=s.ROOT-TOKEN' "$T/tokens.log" && ! grep -q 'status -format=json token=s.ROOT' "$T/tokens.log" && echo yes)" + check "init: and revoked with itself" "yes" "$(grep -q 'token revoke token=s.ROOT-TOKEN' "$T/tokens.log" && echo yes)" + check "init: no share or token is written anywhere in the state dir" "" "$(grep -rl 'SHARE-\|ROOT-TOKEN' "$T/state" 2>/dev/null)" + has "init: the Operator's policy allows sealing and the snapshot read, not snapshot-force" "sys/seal" "$(cat "$T/policy.operator")" + has "init: the policy has the snapshot read" "sys/storage/raft/snapshot" "$(cat "$T/policy.operator")" + lacks "init: the policy has no path for snapshot-force" 'snapshot-force"' "$(cat "$T/policy.operator")" + has "init: the policy has the dcf secrets" 'path "dcf/*"' "$(cat "$T/policy.operator")" + has "init: says the backup is none and will keep saying it" "NO BACKUP" "$out" + + # a backup destination: an empty answer, a relative one and a missing folder are asked again + reset uninit + term ";backups;/nonexistent/dir;$T/backups;a password that is long;a password that is long;;" -- init + check "a path choice: exit 0" 0 "$rc" + check "the choice is the path" "choice=path" "$(sed -n 1p "$T/state/backup-choice")" + check "with the destination" "dest=$T/backups" "$(sed -n 2p "$T/state/backup-choice")" + has "an empty answer is asked again" "the choice is explicit" "$out" + has "a relative path is refused" "'backups' is not an absolute folder path" "$out" + has "a missing folder is refused" "'/nonexistent/dir' is not a writable folder" "$out" + has "the destination is said" "backup destination recorded: $T/backups" "$out" + + # the password: too short, then mismatched, then accepted + reset uninit + term "no backup;short;short;twelve chars ok;different one here;correct horse battery;correct horse battery;;" -- init + check "the password is asked again until it is good: exit 0" 0 "$rc" + has "a short password is refused" "too short" "$out" + has "a mismatch is refused" "did not match" "$out" + check "the accepted password is the last pair" "correct horse battery" "$(cat "$T/userpass.stdin")" + + # only -pgp-keys is accepted + reset uninit + term "no backup;correct horse battery;correct horse battery;;" -- init -pgp-keys=a.asc,b.asc,c.asc + check "init -pgp-keys is passed to bao" "operator init -key-shares=3 -key-threshold=2 -pgp-keys=a.asc,b.asc,c.asc" "$(grep '^operator init' "$LOG")" + reset uninit + term "no backup" -- init -key-shares=1 + check "init refuses any other flag (the share count is fixed): exit 2" 2 "$rc" + check "init with another flag: bao is never called" "" "$(calls)" + + # not twice + reset sealed + term "no backup" -- init + check "init on an initialised, sealed server is refused" 3 "$rc" + has "init: says to unseal" "task openbao:unseal" "$out" + lacks "init: asks nothing then" "Backup destination" "$out" + reset unsealed + term "no backup" -- init + check "init on an unsealed server is refused" 3 "$rc" + reset down + term "no backup" -- init + check "init with the server unreachable: exit 10" 10 "$rc" + + # a failing step revokes the root token and says how to start over; a failing init prints nothing + reset uninit + term "no backup;correct horse battery;correct horse battery;;" STUB_STEP_FAIL=1 -- init + check "a configuration step that fails: exit 1" 1 "$rc" + check "the root token is revoked after a failed step" "yes" "$(grep -q 'token revoke token=s.ROOT-TOKEN' "$T/tokens.log" && echo yes)" + has "and the way to start over is named" "delete $T/state/raft" "$out" + check "no backup choice is recorded for a failed init" "no" "$([ -e "$T/state/backup-choice" ] && echo yes || echo no)" + reset uninit + term "no backup;correct horse battery;correct horse battery;;" STUB_INIT_FAIL=1 -- init + check "bao operator init failing: exit 1" 1 "$rc" + lacks "and no share is shown" "Unseal Key" "$out" + + # unseal + reset sealed + term "" -- unseal + check "unseal: exit 0" 0 "$rc" + check "unseal: two prompts' worth of bao operator unseal" 2 "$(grep -c '^operator unseal' "$LOG")" + check "unseal: no share is an argument" "operator unseal|operator unseal" "$(grep '^operator unseal' "$LOG" | paste -sd'|')" + reset unsealed + term "" -- unseal + check "unseal on an unsealed server: exit 0, nothing asked" "0 0" "$rc $(grep -c '^operator unseal' "$LOG")" + reset uninit + term "" -- unseal + check "unseal on a server that is not initialised: exit 11" 11 "$rc" + has "unseal: names the init task" "task openbao:init" "$out" + reset down + term "" -- unseal + check "unseal on an unreachable server: exit 10" 10 "$rc" + reset sealed + term "" STUB_UNSEAL_FAIL=1 -- unseal + check "unseal with a share bao refuses: exit 1" 1 "$rc" + has "unseal: says how to reset" "-reset" "$out" +else + echo "skip - init and unseal under a terminal (no script(1))" +fi + +# ------------------------------------------------------------------------------------- seal ---- +reset unsealed +run -- seal +check "seal: exit 0" 0 "$rc" +check "seal calls bao operator seal" 1 "$(grep -c '^operator seal' "$LOG")" +has "seal: names the unseal task" "task openbao:unseal" "$out" +reset sealed +run -- seal +check "seal on a sealed server: exit 0" 0 "$rc" +check "seal on a sealed server: no second seal" 0 "$(grep -c '^operator seal' "$LOG")" +reset unsealed +run STUB_LOOKUP_RC=2 -- seal +check "seal without a valid login: exit 12" 12 "$rc" +check "seal without a login: nothing sealed" 0 "$(grep -c '^operator seal' "$LOG")" +reset down +run -- seal +check "seal with the server unreachable: exit 10" 10 "$rc" +reset unsealed +run STUB_SEAL_RC=2 -- seal +check "seal refused by the policy: exit 1" 1 "$rc" + +# ---------------------------------------------------------------------------------- status ---- +reset unsealed +run -- status +check "status unsealed: exit 0" 0 "$rc" +has "status: the mode" "mode: server" "$out" +has "status: the address defaults to the local server" "address: http://127.0.0.1:8200" "$out" +has "status: unsealed" "server: unsealed" "$out" +has "status: the storage" "storage: raft, $T/state/raft" "$out" +has "status: the audit log" "audit: $T/state/audit.log" "$out" +has "status: no backup chosen yet is a warning" "WARNING none chosen yet" "$out" +has "status: a valid login" "login: valid" "$out" +printf 'choice=none\ndate=2026-11-03T10:00:00Z\n' > "$T/state/backup-choice" +run -- status +has "status: a recorded no backup is a standing warning with its date" "WARNING no backup (chosen 2026-11-03T10:00:00Z)" "$out" +printf 'choice=path\ndest=/mnt/somewhere\ndate=2026-11-03T10:00:00Z\n' > "$T/state/backup-choice" +run -- status +has "status: a destination is shown" "destination /mnt/somewhere" "$out" +lacks "status: a destination is no warning" "WARNING" "$out" +run STUB_LOOKUP_RC=2 -- status +has "status: no login says how" "-method=userpass username=operator" "$out" +check "status: still exit 0" 0 "$rc" +reset sealed +run -- status +check "status sealed: exit 11" 11 "$rc" +has "status sealed" "server: sealed" "$out" +lacks "status sealed asks no login" "login:" "$out" +reset uninit +run -- status +check "status not initialised: exit 11" 11 "$rc" +has "status not initialised" "server: not initialised" "$out" +reset down +run -- status +check "status unreachable: exit 10" 10 "$rc" +has "status unreachable" "server: unreachable" "$out" +has "status: a server that is not running says how to start it" "openbao-setup start" "$out" +MODE_FEAT=client +reset unsealed +run BAO_ADDR=https://bao.example.test -- status +check "status in client mode: exit 0" 0 "$rc" +has "status in client mode: the mode" "mode: client" "$out" +has "status in client mode: the address as set" "address: https://bao.example.test" "$out" +lacks "status in client mode: no process or storage lines" "storage:" "$out" +lacks "status in client mode: no backup warning" "backup:" "$out" +MODE_FEAT=server + +# ---------------------------------------------------------------------------------- approle ---- +# The Operator's token (OPTOK) may reissue secret ids of roles that exist; the stub answers +# "permission denied" to it for a policy write or delete and for writing or deleting a role. Making +# and deleting a role therefore only works through the root token the shares make (as_root). +OPTOK=s.OPERATOR +SH='SHARE-ONE;SHARE-TWO' +mkrole() { # : through a terminal, typing two shares + term "$SH" OPENBAO_TASK_ROLE="$1" OPENBAO_TASK_POLICY_PATH="$2" -- approle-issue +} +# refusals that come before any share or call, and need no terminal +reset unsealed +run OPENBAO_TASK_ROLE=newrole -- approle-issue +check "a new role without POLICY_PATH: exit 2" 2 "$rc" +has "says to give POLICY_PATH" "POLICY_PATH" "$out" +has "and that making a role asks for the shares" "unseal shares" "$out" +run OPENBAO_TASK_ROLE='Bad Role' OPENBAO_TASK_POLICY_PATH=dcf/x -- approle-issue +check "a bad role name: exit 2" 2 "$rc" +run OPENBAO_TASK_ROLE=ok OPENBAO_TASK_POLICY_PATH='dcf' -- approle-issue +check "a policy path with no secret in it: exit 2" 2 "$rc" +run OPENBAO_TASK_ROLE=ok OPENBAO_TASK_POLICY_PATH='dcf/a"b' -- approle-issue +check "a policy path with a quote: exit 2" 2 "$rc" +run OPENBAO_TASK_ALL=1 OPENBAO_TASK_ROLE=x -- approle-issue +check "ALL=1 with a role: exit 2" 2 "$rc" +run -- approle-issue +check "no role and no ALL: exit 2" 2 "$rc" +run -- approle-revoke +check "approle:revoke without a role: exit 2" 2 "$rc" +# making and deleting a role refuse inside an agent's job and without a terminal, before any call +for v in DEVHOST_JOB_CHILD CLAUDECODE; do + reset unsealed + run "$v=1" OPENBAO_TASK_ROLE=scribe OPENBAO_TASK_POLICY_PATH=dcf/register/scribe -- approle-issue + check "approle:issue with POLICY_PATH refuses inside an agent's job ($v): exit 3" 3 "$rc" + has "approle:issue ($v): the line names the variable" "refuses to run inside an agent's job ($v is set)" "$out" + check "approle:issue ($v): bao is never called" "" "$(calls)" + run "$v=1" OPENBAO_TASK_ROLE=scribe -- approle-revoke + check "approle:revoke refuses inside an agent's job ($v): exit 3" 3 "$rc" + check "approle:revoke ($v): bao is never called" "" "$(calls)" +done +reset unsealed +run OPENBAO_TASK_ROLE=scribe OPENBAO_TASK_POLICY_PATH=dcf/register/scribe -- approle-issue +check "approle:issue with POLICY_PATH without a terminal: exit 3" 3 "$rc" +has "approle:issue without a terminal says so" "needs a terminal" "$out" +check "approle:issue without a terminal: bao is never called" "" "$(calls)" +run OPENBAO_TASK_ROLE=scribe -- approle-revoke +check "approle:revoke without a terminal: exit 3" 3 "$rc" +check "approle:revoke without a terminal: bao is never called" "" "$(calls)" +# a plain reissue of a role that exists needs no terminal and no agent-job refusal +reset unsealed +run CLAUDECODE=1 OPENBAO_TASK_ROLE=ghost -- approle-issue +check "a reissue is not refused inside an agent's job (the role is just missing: exit 2)" 2 "$rc" + +if [ "$have_script" -eq 1 ]; then + # making a role: the shares make a root token, used for the policy and the role only + reset unsealed + mkrole scribe dcf/register/scribe + check "approle:issue a new role: exit 0" 0 "$rc" + has "it says it asks for the shares" "unseal shares typed at the prompt" "$out" + check "the policy is named after the role and reads that one secret" 'path "dcf/data/register/scribe" {' "$(sed -n 1p "$T/policy.approle-scribe")" + check "and only reads" ' capabilities = ["read"]' "$(sed -n 2p "$T/policy.approle-scribe")" + rolecall="$(grep '^write auth/approle/role/scribe token_policies' "$LOG")" + has "the role is bound to loopback for the secret id" "secret_id_bound_cidrs=127.0.0.1/32" "$rolecall" + has "and for the token" "token_bound_cidrs=127.0.0.1/32" "$rolecall" + has "the token lives 5 minutes, at most 15" "token_ttl=5m token_max_ttl=15m" "$rolecall" + has "secret ids do not expire" "secret_id_ttl=0 secret_id_num_uses=0" "$rolecall" + has "the role carries its policy" "token_policies=approle-scribe" "$rolecall" + check "the secret id is written" "SECRETID-1" "$(cat "$T/state/approle/scribe/secret_id")" + check "with the role id" "ROLEID-scribe" "$(cat "$T/state/approle/scribe/role_id")" + check "the secret id file is mode 600" 600 "$(stat -c %a "$T/state/approle/scribe/secret_id")" + check "the folder is private" 700 "$(stat -c %a "$T/state/approle/scribe")" + check "the policy and the role were written with the generated root token" 2 "$(grep -cE '^(policy write|write auth/approle/role/scribe) token=s.GENROOT' "$T/tokens.log")" + check "the secret id was made with the Operator's token, not the root one" "yes" "$(grep -q '^write -f token=s.OPERATOR' "$T/tokens.log" && ! grep -q '^write -f token=s.GENROOT' "$T/tokens.log" && echo yes)" + check "the root token is revoked with itself, after the step" "yes" "$(grep -q '^token revoke token=s.GENROOT' "$T/tokens.log" && echo yes)" + order="$(grep -nE '^(operator generate-root|policy write|write auth/approle/role/scribe token_policies|token revoke)' "$LOG" | sed 's/^[0-9]*://' | awk '{print $1" "$2}' | paste -sd'|')" + check "the order: one-time password, init, two shares, decode, the step, the revoke" "operator generate-root|operator generate-root|operator generate-root|operator generate-root|operator generate-root|policy write|write auth/approle/role/scribe|token revoke" "$order" + lacks "no share is in the log of calls" "SHARE-" "$(calls)" + lacks "no share is echoed to the screen" "SHARE-" "$out" + lacks "the root token is not on the screen" "GENROOT" "$out" + lacks "no encoded token is on the screen" "ENCODED-STUB" "$out" + check "no share or token is written anywhere in the state dir" "" "$(grep -rl 'SHARE-\|GENROOT\|ENCODED' "$T/state" 2>/dev/null)" + check "the attempt is not left open" "no" "$([ -e "$T/gr.attempt" ] && echo yes || echo no)" + # the Operator's token alone cannot do what the shares do + rc=0; out="$(BAO_TOKEN=s.OPERATOR bash -c "PATH=$T/bin:\$PATH STUB_LOG=$LOG STUB_DIR=$T bao policy write approle-evil -" 2>&1 "$LOG"; : > "$T/tokens.log" + run OPENBAO_TASK_ROLE=scribe -- approle-issue + check "reissuing an existing role: exit 0" 0 "$rc" + check "the old secret id's accessor is destroyed" 1 "$(grep -c 'secret-id-accessor/destroy secret_id_accessor=ACC-1' "$LOG")" + check "a new secret id replaces it" "SECRETID-2" "$(cat "$T/state/approle/scribe/secret_id")" + check "one valid secret id is left" "ACC-2" "$(cat "$T/acc.scribe")" + check "an existing role's settings are not rewritten without POLICY_PATH" 0 "$(grep -c '^write auth/approle/role/scribe token_policies' "$LOG")" + check "a reissue never asks for the shares (no generate-root call)" 0 "$(grep -c 'generate-root' "$LOG")" + check "a reissue uses only the Operator's token" 0 "$(grep -vc 'token=s.OPERATOR' "$T/tokens.log")" + # ALL=1 + mkrole librarian dcf/register/librarian + : > "$LOG"; : > "$T/tokens.log" + run OPENBAO_TASK_ALL=1 -- approle-issue + check "ALL=1: exit 0" 0 "$rc" + check "ALL=1 reissues every role (in the order the server lists them)" "SECRETID-5 SECRETID-4" "$(printf '%s %s' "$(cat "$T/state/approle/librarian/secret_id")" "$(cat "$T/state/approle/scribe/secret_id")")" + check "ALL=1: the old ids are destroyed" 2 "$(grep -c 'secret-id-accessor/destroy' "$LOG")" + check "ALL=1 rewrites no role" 0 "$(grep -c '^write auth/approle/role/[a-z]* token_policies' "$LOG")" + check "ALL=1 uses only the Operator's token, no shares" 0 "$(grep -c 'generate-root' "$LOG")" + reset unsealed + run OPENBAO_TASK_ALL=1 -- approle-issue + check "ALL=1 with no roles: exit 0" 0 "$rc" + has "ALL=1 with no roles says so" "no AppRole roles" "$out" + # a wrong share: the attempt is cancelled, the role is not made, no root token exists to revoke + reset unsealed + rm -f "$T/gr.cancelled" + term "BADSHARE;SHARE-TWO" OPENBAO_TASK_ROLE=bad OPENBAO_TASK_POLICY_PATH=dcf/x -- approle-issue + check "a refused share: exit 1" 1 "$rc" + check "the attempt is cancelled" "cancelled" "$(cat "$T/gr.cancelled" 2>/dev/null | head -n 1)" + check "no role was made" 0 "$(grep -c '^write auth/approle/role/bad' "$LOG")" + check "no policy was written" 0 "$(grep -c '^policy write' "$LOG")" + # an attempt already running (someone else's) is not taken over or cancelled + reset unsealed + echo 1 > "$T/gr.attempt" + rm -f "$T/gr.cancelled" + mkrole bad dcf/x + check "an attempt already in progress: exit 3" 3 "$rc" + check "and it is left alone (not cancelled)" "no" "$([ -e "$T/gr.cancelled" ] && echo yes || echo no)" + rm -f "$T/gr.attempt" + # a failing step still revokes the root token + reset unsealed + : > "$T/tokens.log" + term "$SH" STUB_ROLE_FAIL=1 OPENBAO_TASK_ROLE=bad OPENBAO_TASK_POLICY_PATH=dcf/x -- approle-issue + check "a failing step: exit 1" 1 "$rc" + check "a failing step still revokes the root token" "yes" "$(grep -q '^token revoke token=s.GENROOT' "$T/tokens.log" && echo yes)" + check "a failing step leaves no attempt open" "no" "$([ -e "$T/gr.attempt" ] && echo yes || echo no)" + : > "$LOG" + # preflight: sealed, no login, unreachable + reset sealed + mkrole x dcf/x + check "approle:issue on a sealed server: exit 11" 11 "$rc" + check "and asks for no share" 0 "$(grep -c 'generate-root' "$LOG")" + reset unsealed + term "$SH" STUB_LOOKUP_RC=2 OPENBAO_TASK_ROLE=x OPENBAO_TASK_POLICY_PATH=dcf/x -- approle-issue + check "approle:issue without a login: exit 12" 12 "$rc" + check "approle:issue without a login writes nothing" 0 "$(grep -c '^write\|^policy' "$LOG")" + reset down + mkrole x dcf/x + check "approle:issue with the server unreachable: exit 10" 10 "$rc" + # revoke + reset unsealed + mkrole scribe dcf/register/scribe + : > "$LOG"; : > "$T/tokens.log" + term "$SH" OPENBAO_TASK_ROLE=scribe -- approle-revoke + check "approle:revoke: exit 0" 0 "$rc" + check "approle:revoke deletes the role (OpenBao ends its secret ids with it)" 1 "$(grep -c '^delete auth/approle/role/scribe' "$LOG")" + check "approle:revoke deletes the role's policy" 1 "$(grep -c '^policy delete approle-scribe' "$LOG")" + check "approle:revoke used the generated root token for the role" 1 "$(grep -c '^delete auth/approle/role/scribe token=s.GENROOT' "$T/tokens.log")" + check "and for the policy" 1 "$(grep -c '^policy delete token=s.GENROOT' "$T/tokens.log")" + check "approle:revoke revoked the root token after" "yes" "$(grep -q '^token revoke token=s.GENROOT' "$T/tokens.log" && echo yes)" + check "approle:revoke removes the files" "no" "$([ -e "$T/state/approle/scribe" ] && echo yes || echo no)" + lacks "approle:revoke: no share on the screen" "SHARE-" "$out" + : > "$LOG" + term "$SH" OPENBAO_TASK_ROLE=scribe -- approle-revoke + check "approle:revoke of an unknown role: exit 14" 14 "$rc" + check "and asks for no share" 0 "$(grep -c 'generate-root' "$LOG")" +else + echo "skip - the AppRole role tasks (no script(1) for a terminal)" +fi +OPTOK= + +echo +echo "server_test: $pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/test/openbao/unit/setup_test.sh b/test/openbao/unit/setup_test.sh index ea5b2a4..675c4f3 100644 --- a/test/openbao/unit/setup_test.sh +++ b/test/openbao/unit/setup_test.sh @@ -3,8 +3,14 @@ # directory made and reported (dcf-persist, the real copy), a token left in ~/.vault-token moved # into it once (only with the token helper in effect, never over a token the state dir already # has, mode 600), the token helper reported (in effect, BAO_CONFIG_PATH unset or another file), -# the mode (client; an unknown one reported and run as client), the report (bao missing, -# BAO_ADDR, BAO_TOKEN, a saved login), never fatal, and the token's text never printed. Part of +# the mode (client; an unknown one reported and run as client; server), the report (bao missing, +# BAO_ADDR and its server-mode default, BAO_TOKEN, a saved login), never fatal, and the token's text +# never printed. Server mode's start hook (`openbao-setup start`) against a stub bao that plays +# `bao server` and `bao status`: the config written (raft on the state dir, one loopback listener +# without TLS, the file audit device, no disable_mlock), the server started once and reused, its +# pid and log in the state dir, the line for not initialised, sealed and unsealed, a server that +# does not answer reported with its log and still exit 0, a config without the marker left alone, a +# config with it rewritten, client mode and a missing bao doing nothing, never fatal. Part of # `task lint` (`task test:openbao-unit`). SCRIPT points the test at another copy of the script. set -u exec "$T/feat/options.env" +ln -s "$persist" "$T/srvbin/dcf-persist" +# (#!/bin/bash, not env: the hook recognises the server by its process name, bao) +cat > "$T/srvbin/bao" <<'STUB' +#!/bin/bash +case "$1" in + version) echo "OpenBao v9.9.9 (stub)"; exit 0 ;; + server) + echo "server $*" >> "$STUB_LOG" + echo "stub server log line" >&2 + sleep 300 & + wait ;; + status) + echo "status ${BAO_ADDR-unset}" >> "$STUB_LOG" + printf '%s\n' "${STUB_STATUS_JSON:-{\"initialized\": true\}}" + exit "${STUB_STATUS_RC:-0}" ;; +esac +exit 0 +STUB +chmod +x "$T/srvbin/bao" +stop_server() { # the stub server leaves a sleep behind: stop both, whatever the test did + local pid + pid="$(cat "$T/state/server.pid" 2>/dev/null)" && [ -n "$pid" ] && pkill -P "$pid" 2>/dev/null + [ -n "$pid" ] && kill "$pid" 2>/dev/null + return 0 +} +trap 'stop_server; chmod -R u+w "$T" 2>/dev/null; rm -rf "$T"' EXIT +srv() { # [VAR=value ...] -- : the Feature in server mode with the stub bao first on PATH + local -a envs=() + while [ "$1" != -- ]; do envs+=("$1"); shift; done + shift + out="$(env -i PATH="$T/srvbin:/usr/bin:/bin" HOME="$T/home" OPENBAO_STATE_DIR="$T/state" OPENBAO_FEATURE_DIR="$T/feat" \ + STUB_LOG="$T/calls.log" OPENBAO_START_TRIES=4 "${envs[@]}" bash "$script" "$@" 2>&1)" + rc=$? +} +conf="$T/state/server.hcl" +fresh; : > "$T/calls.log" +srv -- +check "server mode post-create: exit 0" 0 "$rc" +has "post-create says server mode" "mode server" "$out" +has "post-create names the default address" "BAO_ADDR is not set; server mode uses its default http://127.0.0.1:8200" "$out" +has "post-create says how to log in" "-method=userpass username=operator" "$out" +check "post-create does not start the server" 0 "$(grep -c '^server' "$T/calls.log")" +srv BAO_ADDR=https://elsewhere.test -- +has "a BAO_ADDR that is set is reported as it is" "BAO_ADDR https://elsewhere.test" "$out" + +fresh; : > "$T/calls.log" +srv STUB_STATUS_RC=2 'STUB_STATUS_JSON={"initialized": false}' -- start +check "start: exit 0" 0 "$rc" +has "start: not initialised, names the init task" "not initialised; the Operator runs task openbao:init" "$out" +check "start: the server was started once, with the config in the state dir" "server server -config=$conf" "$(grep '^server' "$T/calls.log")" +check "start: the pid is recorded" yes "$([ -s "$T/state/server.pid" ] && echo yes)" +check "start: the server's own log is in the state dir" yes "$([ -f "$T/state/server.log" ] && echo yes)" +check "start: the config is mode 600" 600 "$(stat -c %a "$conf")" +check "start: raft storage on the state dir" 1 "$(grep -c "path = \"$T/state/raft\"" "$conf")" +check "start: a fixed node id" 1 "$(grep -c 'node_id = "devhost"' "$conf")" +check "start: one loopback listener without TLS" "1 1" "$(grep -c 'address = "127.0.0.1:8200"' "$conf") $(grep -c 'tls_disable = true' "$conf")" +check "start: the audit device is declared in the config, in the state dir" 1 "$(grep -c "file_path = \"$T/state/audit.log\"" "$conf")" +check "start: no disable_mlock (OpenBao has none since 2.0.0)" 0 "$(grep -c '^disable_mlock' "$conf")" +check "start: the raft folder exists, private" 700 "$(stat -c %a "$T/state/raft")" +check "start: the status was asked at the local listener, whatever BAO_ADDR says" yes "$(grep -q '^status http://127.0.0.1:8200$' "$T/calls.log" && echo yes)" +srv STUB_STATUS_RC=2 -- start +has "start again: the live server is reused" "already running" "$out" +has "start again: sealed, names the unseal task" "sealed; the Operator runs task openbao:unseal" "$out" +check "start again: still one server" 1 "$(grep -c '^server' "$T/calls.log")" +srv -- start +has "start: unsealed is said" "running and unsealed" "$out" +stop_server +rm -f "$T/state/server.pid" +srv -- start +check "a dead pid is not reused: a second server start" 2 "$(grep -c '^server' "$T/calls.log")" +# the config: rewritten while it carries the marker, left alone without it +sed -i '1s/^/# my own edits\n/' "$conf" +srv -- start +check "a config without the marker line is left alone" "# my own edits" "$(head -n 1 "$conf")" +check "and was not rewritten" 0 "$(grep -c 'Written by the openbao Feature' "$conf" | sed 's/^1$/0/')" +stop_server + +fresh; : > "$T/calls.log" +srv STUB_STATUS_RC=1 'STUB_STATUS_JSON=' -- start +check "a server that does not answer: still exit 0" 0 "$rc" +has "a server that does not answer is reported" "does not answer" "$out" +has "with the end of its log" "stub server log line" "$out" +stop_server + +fresh; : > "$T/calls.log" +srv OPENBAO_SETUP_MODE=client -- start +check "client mode: start does nothing" "" "$out" +check "client mode: no server, no config" "0 no" "$(grep -c '^server' "$T/calls.log") $([ -e "$T/state/server.hcl" ] && echo yes || echo no)" +out="$(env -i PATH="/usr/bin:/bin" HOME="$T/home" OPENBAO_STATE_DIR="$T/state" OPENBAO_FEATURE_DIR="$T/feat" bash "$script" start 2>&1)" +rc=$? +check "start without bao: exit 0" 0 "$rc" +has "start without bao: said" "bao not found on PATH" "$out" +if [ "$(id -u)" -ne 0 ]; then + fresh; mkdir -p "$T/state"; chmod 0555 "$T/state" + srv -- start + check "start with an unwritable state dir: exit 0" 0 "$rc" + has "start with an unwritable state dir: said" "not writable" "$out" + chmod 0755 "$T/state" +fi +fresh # bao missing: the rest still ran and the exit is 0 fresh