From 7fc594f363d549121afcd17a34b65a0f2695c0f5 Mon Sep 17 00:00:00 2001 From: devhost agent Date: Tue, 3 Nov 2026 20:26:46 +0000 Subject: [PATCH 1/9] chore(openbao): start slice 1 of secrets-master-split (openbao-state-build) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DPsHU61VPD8TZwHanFM7zj Signed-off-by: devhost agent From d1cfee7239af229381167e978d54b36c13122902 Mon Sep 17 00:00:00 2001 From: devhost agent Date: Tue, 3 Nov 2026 20:45:23 +0000 Subject: [PATCH 2/9] feat(openbao): a state volume, dcf-secret and the mode option, 1.1.0 (openbao-state-build) Slice 1 of secrets-master-split. The Feature now owns its login state like gh and az: a named volume on /var/lib/devcontainer-features/openbao (OPENBAO_STATE_DIR) and a post-create hook, openbao-setup (task openbao:setup), that links ~/.vault-token into it, so a rebuild keeps the login. dcf-secret (get, put, status) is the one command callers use to ask for a secret: exit codes 10 to 14 with a one-line fix each, worked out from bao status, its JSON and bao token capabilities, never falling back to a key on disk. The mode option has only client, with OPENBAO_SETUP_MODE. NOTES carry NFR-9's paragraph (checked 2026-11-03). Docker-free tests for dcf-secret (stub bao) and openbao-setup run in task lint; container tests add no_taskfile, mode_client and dev_server (a real bao server -dev). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DPsHU61VPD8TZwHanFM7zj Signed-off-by: devhost agent --- README.md | 2 +- Taskfile.yml | 7 + src/openbao/CHANGELOG.md | 9 + src/openbao/NOTES.md | 186 ++++++++++++++------ src/openbao/README.md | 204 +++++++++++++++------- src/openbao/dcf-persist.sh | 59 +++++++ src/openbao/dcf-secret.sh | 194 +++++++++++++++++++++ src/openbao/devcontainer-feature.json | 23 ++- src/openbao/install.sh | 30 +++- src/openbao/openbao-setup.sh | 65 +++++++ src/openbao/taskfile.yml | 6 + test/_global/all_features.sh | 5 +- test/openbao/dev_server.sh | 76 ++++++++ test/openbao/mode_client.sh | 12 ++ test/openbao/no_taskfile.sh | 11 ++ test/openbao/pinned_version.sh | 6 + test/openbao/scenarios.json | 27 +++ test/openbao/test.sh | 18 ++ test/openbao/unit/dcf_secret_test.sh | 242 ++++++++++++++++++++++++++ test/openbao/unit/setup_test.sh | 126 ++++++++++++++ 20 files changed, 1185 insertions(+), 123 deletions(-) create mode 100644 src/openbao/dcf-persist.sh create mode 100644 src/openbao/dcf-secret.sh create mode 100644 src/openbao/openbao-setup.sh create mode 100644 test/openbao/dev_server.sh create mode 100644 test/openbao/mode_client.sh create mode 100644 test/openbao/no_taskfile.sh create mode 100644 test/openbao/unit/dcf_secret_test.sh create mode 100644 test/openbao/unit/setup_test.sh diff --git a/README.md b/README.md index d8585f7..39d9596 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. | +| [`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. | | [`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 4ea827a..a4e5236 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -112,6 +112,7 @@ tasks: - task: test:host-mounts - task: test:ssh-key-import - task: test:ssh-agent-unit + - task: test:openbao-unit - task: test:cloud-folder-unit - task: test:cloud-folder-login - task: test:agents-unit @@ -239,6 +240,12 @@ tasks: - bash test/ssh-agent/unit/setup_test.sh - 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, 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, ~/.vault-token linked into it (made, kept, a real file moved in, a link elsewhere left alone, 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. That the real bao gives the signals the stub plays is the container test test/openbao/dev_server.sh (CI). Part of lint. Needs nothing beyond bash and grep (script(1) for one check, skipped without it).' + cmds: + - bash test/openbao/unit/dcf_secret_test.sh + - bash test/openbao/unit/setup_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.' cmds: diff --git a/src/openbao/CHANGELOG.md b/src/openbao/CHANGELOG.md index 3edd92b..6e42288 100644 --- a/src/openbao/CHANGELOG.md +++ b/src/openbao/CHANGELOG.md @@ -8,6 +8,15 @@ 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.1.0] - 2026-11-03 + +### Added + +- **Your `bao login` now survives a rebuild.** The Feature owns a named volume (`openbao-config-`, at `/var/lib/devcontainer-features/openbao`, named by `OPENBAO_STATE_DIR`) and a post-create hook, `openbao-setup` (also `task openbao:setup`), that links `~/.vault-token` into it, the way `gh` and `az` keep their logins. **You must act once:** rebuild the container and run `bao login` again, because the volume starts empty. Nothing to configure; a repo that wants the state on the host disk bind-mounts the same target. A `BAO_TOKEN` in the environment still takes precedence over the saved login. +- **`dcf-secret`**, a command for scripts and other Features to read and write a secret without knowing where the server runs: `dcf-secret get `, `dcf-secret put --field ` (the value on stdin) and `dcf-secret status`. It fails with fixed exit codes (10 unreachable, 11 sealed, 12 not logged in, 13 denied, 14 no such secret), each with one line saying what to do, and never falls back to a key on disk. Secrets live in a KV v2 mount named `dcf` (`DCF_SECRET_MOUNT` changes it). Nothing to do unless you want it; `task openbao:kv:get` is unchanged. +- **The `mode` option**, with the one value `client` (the default, what the Feature always did: the CLI against a server outside the container). A later version adds `server`. `OPENBAO_SETUP_MODE` in `containerEnv` overrides it per container. Nothing to do. +- `task openbao:setup`: re-runs the post-create hook. + ## [1.0.4] - 2026-11-03 ### Added diff --git a/src/openbao/NOTES.md b/src/openbao/NOTES.md index fefe8bd..1438e6f 100644 --- a/src/openbao/NOTES.md +++ b/src/openbao/NOTES.md @@ -7,6 +7,12 @@ checks the tarball with `sha256sum -c`, and installs the binary only if the check passes. It runs `bao version` at the end, so the version is in the build log. It installs no OpenBao server and no `vault` command. +Besides the binary, `install.sh` puts three scripts on PATH (nothing is downloaded for them): +`dcf-secret` (the interface callers use to read and write secrets, see below), `openbao-setup` +(the post-create hook, see State) and `dcf-persist` (a verbatim copy of `src/common/dcf-persist.sh`, +which makes the state directory usable and says what backs it). It writes the option defaults the +hook reads to `/usr/local/share/openbao-feature/options.env`. + - `version` takes a number with or without a leading `v` (`2.7.1`, `v2.7.1`), or `latest` (the default). `latest` is resolved once, at image build time, through the `releases/latest` redirect (no API call, no @@ -22,61 +28,95 @@ build log. It installs no OpenBao server and no `vault` command. - `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`). - `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. -There is no run-time override of either; both are fixed at build. The -server address is a run-time setting: see State. +`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. ## State -The Feature has no state volume, no `containerEnv` and no post-create hook -of its own. Two things live outside the binary: - -- **The server address**: `BAO_ADDR`, which the Feature does not set. Put it - in your `devcontainer.json`. -- **The login token**: `bao login` writes it to `~/.vault-token` of the - remote user, which sits in the container's own filesystem. **A rebuild - deletes it and you are logged out.** The sibling CLIs (`gh`, `az`, - `argocd`, `coder-cli`) own their login state in a named volume under - `/var/lib/devcontainer-features/`; this one does not yet. - -One route keeps a login across rebuilds without a Feature change. - -**`BAO_TOKEN` from the environment.** The CLI uses `BAO_TOKEN` in place of -`~/.vault-token`, and the tasks pass the container's environment straight to -`bao`, so `task openbao:status` and the others pick it up. Set it in -`containerEnv`, reading from a variable on the host: - -```json -"containerEnv": { - "BAO_ADDR": "https://bao.example.com", - "BAO_TOKEN": "${localEnv:BAO_TOKEN}" -} -``` - -or from an env file the repo keeps out of git: - -```json -"runArgs": ["--env-file", "${localWorkspaceFolder}/.devcontainer/bao.env"] -``` - -with `.devcontainer/bao.env` holding plain `NAME=value` lines (no quotes, no -`export`): +Owned by the Feature (NFR-1), like `gh` and `az`: + +- `containerEnv` sets `OPENBAO_STATE_DIR=/var/lib/devcontainer-features/openbao`. +- `mounts` declares the named volume `openbao-config-${devcontainerId}` on that path. +- 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, then makes `~/.vault-token`, + where `bao login` keeps its token, a link to `$OPENBAO_STATE_DIR/token`. **A rebuild keeps the + login.** The hook never contacts the server and never fails the rest of post-create. + +`bao` has no variable for the token's folder (`gh` has `GH_CONFIG_DIR`), which is why the link +exists and `OPENBAO_STATE_DIR` is this Feature's own name. A repo that wants the state on the host +disk bind-mounts the same target and its mount wins (the README's "How the pieces fit"); a repo can +point `OPENBAO_STATE_DIR` elsewhere in its own `containerEnv`. + +- **The server address** is `BAO_ADDR`, which the Feature does not set. Put it in `containerEnv`. +- **`bao token revoke -self`** removes the link (the CLI deletes `~/.vault-token`, not what it + points at). The next `bao login` writes a real file; the next run of the hook moves it into the + volume and links it again, so the newer login wins over the revoked one left in the volume. +- **`BAO_TOKEN`** in the environment takes precedence over the saved login (the hook says so). It + is a way to give a container a token without a login, for a service that cannot run `bao login`: + set it in `containerEnv` from a host variable (`"BAO_TOKEN": "${localEnv:BAO_TOKEN}"`) or an env + file the repo keeps out of git. A stale value there makes `permission denied` appear even right + after a fresh `bao login`; unset it or update it. Never commit it. +- Not offered: a bind mount of the single file `~/.vault-token` from the host (a single-file mount + breaks when `bao login` replaces the file rather than writing into it). + +Upgrading from 1.0.x: the volume starts empty, so log in once after the rebuild; a login made +before it was in `~/.vault-token` of the old container is gone with that container. + +## `dcf-secret` + +The interface every Feature and script of this collection uses to read and write a secret, so +where the server runs does not matter to the caller. `openbao:kv:get` stays as the raw `bao kv +get` for people; `dcf-secret` is for callers. ```text -BAO_ADDR=https://bao.example.com -BAO_TOKEN= +dcf-secret get [--field ] the value on stdout, nothing else (field default: value) +dcf-secret put --field the value on stdin, never an argument +dcf-secret status [] one word on stdout, the fix on stderr ``` -The token then sits in the container's environment and the env file; tokens -expire, so renew the value there. Never commit it. - -A bind-mount of `~/.vault-token` from the host is not offered here: it is -untested, and a single-file mount breaks if `bao login` replaces the file -rather than writing into it. Until the Feature owns its state, use -`BAO_TOKEN`. +- **Names are logical** (`ssh/`, `register/`, `token/`; letters, digits, + `.`, `_`, `-`, with `/` between parts) and live at `/` in a KV v2 mount. The mount is + `dcf`; `DCF_SECRET_MOUNT` names another one (a client of a shared server uses its own). +- **Who asks**: the token `bao` uses (`BAO_TOKEN`, else the saved login). `DCF_SECRET_AS` (logging + in as an AppRole) is not in this version: setting it exits 2 rather than asking as someone else. +- **`put` replaces the whole secret** with the one field (it is `bao kv put`); it refuses a + terminal on stdin. **`get` writes the value only to stdout**: it is never put in a file or a + variable. +- **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 | Meaning | `status` prints | +| --- | --- | --- | +| 0 | done (`get`: the value is on stdout) | `ready` | +| 10 | the server is unreachable (`BAO_ADDR` wrong, server not running, no `bao`) | `unreachable` | +| 11 | the server is sealed, or not initialised | `sealed`, `not-initialised` | +| 12 | no token, or it expired | `logged-out` | +| 13 | the token may not use this name | `denied` (only with a name) | +| 14 | no such secret, or no such field | | +| 1 | `bao` failed and none of its signals say why (`put` only; its message follows) | | +| 2 | a wrong call (usage, a bad name, `DCF_SECRET_AS`) | | + +The code is worked out from `bao`'s own signals, never from the words of a message: after a failed +call it asks `bao status -format=json` (exit 2 is sealed, the JSON says `initialized`; anything +else is unreachable), then `bao token capabilities` on `/data/` (a refused call is no +valid token; no `read` or `root` for a get, no `create`, `update` or `root` for a put, is denied), +and a get that gets this far is a missing secret. A token therefore needs the default policy (every +token has it unless removed) besides `read` on `/data/` to get and `create` and +`update` to put. It speaks only `bao`, so it works the same against a Vault server. + +A caller without this Feature finds no `dcf-secret` and says so; the Features stay independent at +build time. ## Minimal `devcontainer.json` @@ -92,6 +132,7 @@ rather than writing into it. Until the Feature owns its state, use ``` Drop the `version` line to follow the newest release at each image build. +The login is kept in the Feature's volume: `bao login` once, and a rebuild keeps it. For a server with a private CA, trust the certificate in the image (`update-ca-certificates`) rather than setting `BAO_SKIP_VERIFY`. @@ -100,13 +141,17 @@ For a server with a private CA, trust the certificate in the image ```bash bao version # prints the installed version task openbao:version -bao status # needs BAO_ADDR; prints seal state and server version +openbao-setup # the post-create report, any time +dcf-secret status # needs BAO_ADDR; prints ready, or why not (exit codes above) ``` The build log carries the first line (`openbao: downloading OpenBao v (openbao__linux_.tar.gz)...`, then the `bao version` output). A failed checksum stops the build with `sha256sum`'s -`FAILED` line. There is no post-create line: nothing runs after the build. +`FAILED` line. The post-create log has the hook's lines: `openbao: /var/lib/devcontainer-features/openbao +(volume, writable)`, `openbao: mode client ...`, `openbao: login token linked into the state dir`, +the version, `BAO_ADDR`, and whether a login is saved. `volume` in the first line is what keeps the +login across rebuilds; `image` means the mount is missing. ## Composition @@ -114,8 +159,11 @@ version` output). A failed checksum stops the build with `sha256sum`'s the Taskfile registry exists and is refreshed) and the official `common-utils`. - It augments no official Feature and replaces none. +- `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). - Independent of `kube-state` and `git`: no kubeconfig is read and no git - config is written. + config is written. Its volume and hook are the shape of `gh` and `az`. ## Tasks @@ -132,6 +180,9 @@ registry, these appear under `openbao:`: flags go after `--`, e.g. `task openbao:kv:get KEY=secret/app -- -format=json`. +- `openbao:setup`: re-run the post-create hook (`openbao-setup`): make the state directory usable, + link `~/.vault-token` into it, report the address and the saved login. + 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` itself. @@ -146,16 +197,41 @@ itself. - A missing release asset or `checksums.txt` line fails the build (`curl -f`, or `sha256sum` finding no matching line). The binary is never installed unchecked. -- Logged out after every rebuild: expected, see State. Use `BAO_TOKEN`. +- Logged out after a rebuild: the volume is gone (a container-engine storage reset, or another + engine) or the hook's first line says `image` instead of `volume`. Log in again. +- Client mode only: there is no server, backup, onboarding or `DCF_SECRET_AS` in this version. +- `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 link relies on `bao login` writing through the link to the volume; the container test + `dev_server` checks it against a real `bao`. - `bao status` fails with a connection or certificate error: `BAO_ADDR` is unset or wrong, or the server's CA is not trusted in the image. -- A `BAO_TOKEN` in the environment takes precedence over `~/.vault-token`, - so a stale value there makes `permission denied` appear even right after - a fresh `bao login`; unset it or update it. +- A `BAO_TOKEN` in the environment takes precedence over the saved login (see State). ## Why this one exists -No published Dev Container Feature for the OpenBao CLI is known to this -repository, so there is no alternative to compare. The binary is checked -against the release's `checksums.txt`; OpenBao also signs releases with GPG -and Sigstore, which this Feature does not verify. +Checked 2026-11-03 (NFR-9): no published Dev Container Feature is a general OpenBao CLI Feature +with state, a setup hook and a tested interface, and none is in the containers.dev index (searched +by name and description for `openbao` and `vault`). Three repositories on GitHub hold a Feature +that installs `bao` for their own purpose: `openbao-agent` in `wyrd-company/devcontainers` (installs +OpenBao and runs OpenBao Agent as an s6-overlay service from the user's own config; the archive is +checked against `checksums.txt`), `openbao-secrets` in `dever-labs/devcontainers` (installs `bao` and +a helper that logs `gh` in with a token read from OpenBao through OIDC, a session volume at a fixed +home path; the archive is checked against `checksums.txt`) and `bao-env` in +`stevetfidalgo/mindmap-devcontainer-features` (a manifest tool for KV stores that installs `bao` 2.1.1 +and `yq`; no checksum check in its install script, a read of the script, not a test). They install +the binary as a means to something else, and none gives the CLI a login that survives a rebuild +(besides `openbao-secrets`' own session) or a command other Features can call. Here the binary is +checked against the release's `checksums.txt` (OpenBao also signs releases with GPG and Sigstore, +which this Feature does not verify), the login is in a volume the Feature owns, and `dcf-secret` +is the one interface the collection's Features use. The +official `devcontainers/features` collection has no OpenBao or Vault Feature; the Vault ones +elsewhere (`devcontainers-extra`, `postfinance`, `roul`) install HashiCorp's `vault`, not `bao`. + +## Version and upgrade + +`version` pins the OpenBao release at build (`2.7.1`, with or without `v`) or follows `latest`; pin +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`. diff --git a/src/openbao/README.md b/src/openbao/README.md index 970ecc0..8c3fb02 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. 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 (~/.vault-token is linked into it by the post-create hook openbao-setup) so `bao login` survives rebuilds, 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. ## Example Usage @@ -16,6 +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 | | 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 | ## What it installs @@ -27,6 +28,12 @@ checks the tarball with `sha256sum -c`, and installs the binary only if the check passes. It runs `bao version` at the end, so the version is in the build log. It installs no OpenBao server and no `vault` command. +Besides the binary, `install.sh` puts three scripts on PATH (nothing is downloaded for them): +`dcf-secret` (the interface callers use to read and write secrets, see below), `openbao-setup` +(the post-create hook, see State) and `dcf-persist` (a verbatim copy of `src/common/dcf-persist.sh`, +which makes the state directory usable and says what backs it). It writes the option defaults the +hook reads to `/usr/local/share/openbao-feature/options.env`. + - `version` takes a number with or without a leading `v` (`2.7.1`, `v2.7.1`), or `latest` (the default). `latest` is resolved once, at image build time, through the `releases/latest` redirect (no API call, no @@ -42,61 +49,95 @@ build log. It installs no OpenBao server and no `vault` command. - `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`). - `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. -There is no run-time override of either; both are fixed at build. The -server address is a run-time setting: see State. +`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. ## State -The Feature has no state volume, no `containerEnv` and no post-create hook -of its own. Two things live outside the binary: - -- **The server address**: `BAO_ADDR`, which the Feature does not set. Put it - in your `devcontainer.json`. -- **The login token**: `bao login` writes it to `~/.vault-token` of the - remote user, which sits in the container's own filesystem. **A rebuild - deletes it and you are logged out.** The sibling CLIs (`gh`, `az`, - `argocd`, `coder-cli`) own their login state in a named volume under - `/var/lib/devcontainer-features/`; this one does not yet. - -One route keeps a login across rebuilds without a Feature change. - -**`BAO_TOKEN` from the environment.** The CLI uses `BAO_TOKEN` in place of -`~/.vault-token`, and the tasks pass the container's environment straight to -`bao`, so `task openbao:status` and the others pick it up. Set it in -`containerEnv`, reading from a variable on the host: - -```json -"containerEnv": { - "BAO_ADDR": "https://bao.example.com", - "BAO_TOKEN": "${localEnv:BAO_TOKEN}" -} -``` - -or from an env file the repo keeps out of git: - -```json -"runArgs": ["--env-file", "${localWorkspaceFolder}/.devcontainer/bao.env"] -``` - -with `.devcontainer/bao.env` holding plain `NAME=value` lines (no quotes, no -`export`): +Owned by the Feature (NFR-1), like `gh` and `az`: + +- `containerEnv` sets `OPENBAO_STATE_DIR=/var/lib/devcontainer-features/openbao`. +- `mounts` declares the named volume `openbao-config-${devcontainerId}` on that path. +- 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, then makes `~/.vault-token`, + where `bao login` keeps its token, a link to `$OPENBAO_STATE_DIR/token`. **A rebuild keeps the + login.** The hook never contacts the server and never fails the rest of post-create. + +`bao` has no variable for the token's folder (`gh` has `GH_CONFIG_DIR`), which is why the link +exists and `OPENBAO_STATE_DIR` is this Feature's own name. A repo that wants the state on the host +disk bind-mounts the same target and its mount wins (the README's "How the pieces fit"); a repo can +point `OPENBAO_STATE_DIR` elsewhere in its own `containerEnv`. + +- **The server address** is `BAO_ADDR`, which the Feature does not set. Put it in `containerEnv`. +- **`bao token revoke -self`** removes the link (the CLI deletes `~/.vault-token`, not what it + points at). The next `bao login` writes a real file; the next run of the hook moves it into the + volume and links it again, so the newer login wins over the revoked one left in the volume. +- **`BAO_TOKEN`** in the environment takes precedence over the saved login (the hook says so). It + is a way to give a container a token without a login, for a service that cannot run `bao login`: + set it in `containerEnv` from a host variable (`"BAO_TOKEN": "${localEnv:BAO_TOKEN}"`) or an env + file the repo keeps out of git. A stale value there makes `permission denied` appear even right + after a fresh `bao login`; unset it or update it. Never commit it. +- Not offered: a bind mount of the single file `~/.vault-token` from the host (a single-file mount + breaks when `bao login` replaces the file rather than writing into it). + +Upgrading from 1.0.x: the volume starts empty, so log in once after the rebuild; a login made +before it was in `~/.vault-token` of the old container is gone with that container. + +## `dcf-secret` + +The interface every Feature and script of this collection uses to read and write a secret, so +where the server runs does not matter to the caller. `openbao:kv:get` stays as the raw `bao kv +get` for people; `dcf-secret` is for callers. ```text -BAO_ADDR=https://bao.example.com -BAO_TOKEN= +dcf-secret get [--field ] the value on stdout, nothing else (field default: value) +dcf-secret put --field the value on stdin, never an argument +dcf-secret status [] one word on stdout, the fix on stderr ``` -The token then sits in the container's environment and the env file; tokens -expire, so renew the value there. Never commit it. - -A bind-mount of `~/.vault-token` from the host is not offered here: it is -untested, and a single-file mount breaks if `bao login` replaces the file -rather than writing into it. Until the Feature owns its state, use -`BAO_TOKEN`. +- **Names are logical** (`ssh/`, `register/`, `token/`; letters, digits, + `.`, `_`, `-`, with `/` between parts) and live at `/` in a KV v2 mount. The mount is + `dcf`; `DCF_SECRET_MOUNT` names another one (a client of a shared server uses its own). +- **Who asks**: the token `bao` uses (`BAO_TOKEN`, else the saved login). `DCF_SECRET_AS` (logging + in as an AppRole) is not in this version: setting it exits 2 rather than asking as someone else. +- **`put` replaces the whole secret** with the one field (it is `bao kv put`); it refuses a + terminal on stdin. **`get` writes the value only to stdout**: it is never put in a file or a + variable. +- **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 | Meaning | `status` prints | +| --- | --- | --- | +| 0 | done (`get`: the value is on stdout) | `ready` | +| 10 | the server is unreachable (`BAO_ADDR` wrong, server not running, no `bao`) | `unreachable` | +| 11 | the server is sealed, or not initialised | `sealed`, `not-initialised` | +| 12 | no token, or it expired | `logged-out` | +| 13 | the token may not use this name | `denied` (only with a name) | +| 14 | no such secret, or no such field | | +| 1 | `bao` failed and none of its signals say why (`put` only; its message follows) | | +| 2 | a wrong call (usage, a bad name, `DCF_SECRET_AS`) | | + +The code is worked out from `bao`'s own signals, never from the words of a message: after a failed +call it asks `bao status -format=json` (exit 2 is sealed, the JSON says `initialized`; anything +else is unreachable), then `bao token capabilities` on `/data/` (a refused call is no +valid token; no `read` or `root` for a get, no `create`, `update` or `root` for a put, is denied), +and a get that gets this far is a missing secret. A token therefore needs the default policy (every +token has it unless removed) besides `read` on `/data/` to get and `create` and +`update` to put. It speaks only `bao`, so it works the same against a Vault server. + +A caller without this Feature finds no `dcf-secret` and says so; the Features stay independent at +build time. ## Minimal `devcontainer.json` @@ -112,6 +153,7 @@ rather than writing into it. Until the Feature owns its state, use ``` Drop the `version` line to follow the newest release at each image build. +The login is kept in the Feature's volume: `bao login` once, and a rebuild keeps it. For a server with a private CA, trust the certificate in the image (`update-ca-certificates`) rather than setting `BAO_SKIP_VERIFY`. @@ -120,13 +162,17 @@ For a server with a private CA, trust the certificate in the image ```bash bao version # prints the installed version task openbao:version -bao status # needs BAO_ADDR; prints seal state and server version +openbao-setup # the post-create report, any time +dcf-secret status # needs BAO_ADDR; prints ready, or why not (exit codes above) ``` The build log carries the first line (`openbao: downloading OpenBao v (openbao__linux_.tar.gz)...`, then the `bao version` output). A failed checksum stops the build with `sha256sum`'s -`FAILED` line. There is no post-create line: nothing runs after the build. +`FAILED` line. The post-create log has the hook's lines: `openbao: /var/lib/devcontainer-features/openbao +(volume, writable)`, `openbao: mode client ...`, `openbao: login token linked into the state dir`, +the version, `BAO_ADDR`, and whether a login is saved. `volume` in the first line is what keeps the +login across rebuilds; `image` means the mount is missing. ## Composition @@ -134,8 +180,11 @@ version` output). A failed checksum stops the build with `sha256sum`'s the Taskfile registry exists and is refreshed) and the official `common-utils`. - It augments no official Feature and replaces none. +- `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). - Independent of `kube-state` and `git`: no kubeconfig is read and no git - config is written. + config is written. Its volume and hook are the shape of `gh` and `az`. ## Tasks @@ -152,6 +201,9 @@ registry, these appear under `openbao:`: flags go after `--`, e.g. `task openbao:kv:get KEY=secret/app -- -format=json`. +- `openbao:setup`: re-run the post-create hook (`openbao-setup`): make the state directory usable, + link `~/.vault-token` into it, report the address and the saved login. + 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` itself. @@ -166,22 +218,56 @@ itself. - A missing release asset or `checksums.txt` line fails the build (`curl -f`, or `sha256sum` finding no matching line). The binary is never installed unchecked. -- Logged out after every rebuild: expected, see State. Use `BAO_TOKEN`. +- Logged out after a rebuild: the volume is gone (a container-engine storage reset, or another + engine) or the hook's first line says `image` instead of `volume`. Log in again. +- Client mode only: there is no server, backup, onboarding or `DCF_SECRET_AS` in this version. +- `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 link relies on `bao login` writing through the link to the volume; the container test + `dev_server` checks it against a real `bao`. - `bao status` fails with a connection or certificate error: `BAO_ADDR` is unset or wrong, or the server's CA is not trusted in the image. -- A `BAO_TOKEN` in the environment takes precedence over `~/.vault-token`, - so a stale value there makes `permission denied` appear even right after - a fresh `bao login`; unset it or update it. +- A `BAO_TOKEN` in the environment takes precedence over the saved login (see State). ## Why this one exists -No published Dev Container Feature for the OpenBao CLI is known to this -repository, so there is no alternative to compare. The binary is checked -against the release's `checksums.txt`; OpenBao also signs releases with GPG -and Sigstore, which this Feature does not verify. +Checked 2026-11-03 (NFR-9): no published Dev Container Feature is a general OpenBao CLI Feature +with state, a setup hook and a tested interface, and none is in the containers.dev index (searched +by name and description for `openbao` and `vault`). Three repositories on GitHub hold a Feature +that installs `bao` for their own purpose: `openbao-agent` in `wyrd-company/devcontainers` (installs +OpenBao and runs OpenBao Agent as an s6-overlay service from the user's own config; the archive is +checked against `checksums.txt`), `openbao-secrets` in `dever-labs/devcontainers` (installs `bao` and +a helper that logs `gh` in with a token read from OpenBao through OIDC, a session volume at a fixed +home path; the archive is checked against `checksums.txt`) and `bao-env` in +`stevetfidalgo/mindmap-devcontainer-features` (a manifest tool for KV stores that installs `bao` 2.1.1 +and `yq`; no checksum check in its install script, a read of the script, not a test). They install +the binary as a means to something else, and none gives the CLI a login that survives a rebuild +(besides `openbao-secrets`' own session) or a command other Features can call. Here the binary is +checked against the release's `checksums.txt` (OpenBao also signs releases with GPG and Sigstore, +which this Feature does not verify), the login is in a volume the Feature owns, and `dcf-secret` +is the one interface the collection's Features use. The +official `devcontainers/features` collection has no OpenBao or Vault Feature; the Vault ones +elsewhere (`devcontainers-extra`, `postfinance`, `roul`) install HashiCorp's `vault`, not `bao`. + +## Version and upgrade + +`version` pins the OpenBao release at build (`2.7.1`, with or without `v`) or follows `latest`; pin +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`. ## Changes +### [1.1.0] - 2026-11-03 + +#### Added + +- **Your `bao login` now survives a rebuild.** The Feature owns a named volume (`openbao-config-`, at `/var/lib/devcontainer-features/openbao`, named by `OPENBAO_STATE_DIR`) and a post-create hook, `openbao-setup` (also `task openbao:setup`), that links `~/.vault-token` into it, the way `gh` and `az` keep their logins. **You must act once:** rebuild the container and run `bao login` again, because the volume starts empty. Nothing to configure; a repo that wants the state on the host disk bind-mounts the same target. A `BAO_TOKEN` in the environment still takes precedence over the saved login. +- **`dcf-secret`**, a command for scripts and other Features to read and write a secret without knowing where the server runs: `dcf-secret get `, `dcf-secret put --field ` (the value on stdin) and `dcf-secret status`. It fails with fixed exit codes (10 unreachable, 11 sealed, 12 not logged in, 13 denied, 14 no such secret), each with one line saying what to do, and never falls back to a key on disk. Secrets live in a KV v2 mount named `dcf` (`DCF_SECRET_MOUNT` changes it). Nothing to do unless you want it; `task openbao:kv:get` is unchanged. +- **The `mode` option**, with the one value `client` (the default, what the Feature always did: the CLI against a server outside the container). A later version adds `server`. `OPENBAO_SETUP_MODE` in `containerEnv` overrides it per container. Nothing to do. +- `task openbao:setup`: re-runs the post-create hook. + ### [1.0.4] - 2026-11-03 #### Added @@ -194,12 +280,6 @@ and Sigstore, which this Feature does not verify. - Build-time downloads retry on transient network errors, so a flaky connection no longer fails the container build outright. Rebuild the container to pick it up. No change to options or tasks; nothing to do. ([#94](https://github.com/nabil-becker/devcontainer-features/pull/94)) -### [1.0.2] - 2026-10-21 - -#### Fixed - -- Az installs the Azure CLI; Feature tasks run in the caller's folder ([#48](https://github.com/nabil-becker/devcontainer-features/pull/48)) - 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-persist.sh b/src/openbao/dcf-persist.sh new file mode 100644 index 0000000..5dea79e --- /dev/null +++ b/src/openbao/dcf-persist.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# dcf-persist: make a tool's state directory usable by the current user and +# say where it lives. The one helper behind every -setup post-create hook +# of this collection (claude-code, git, gh, az), so they all behave the same: +# +# dcf-persist [--label ] ... +# +# For each : create it (with sudo when the parent is not writable), hand +# it to the current user when its top level is owned by someone else (named +# volumes and bind mounts arrive owned by root or by the host's uid), and +# print one line: label, path, what backs it, and whether it is writable. +# Backing is one of +# volume a named volume: survives rebuilds, deleted by a container-engine +# storage reset or a move to another engine; +# bind a folder from the host disk: survives all of that; +# image the container filesystem: gone at the next rebuild. +# Never fatal (exit 0 always): a problem is reported, and the rest of +# post-create goes on. Canonical copy: src/common/dcf-persist.sh; the other +# Features carry a verbatim copy, checked by `task lint`. +set -u + +label="" +if [ "${1:-}" = "--label" ]; then label="$2"; shift 2; fi +[ $# -gt 0 ] || { echo "usage: dcf-persist [--label ] ..." >&2; exit 0; } + +me="$(id -u):$(id -g)" +as_root() { # run a command as root: directly, or through passwordless sudo + if [ "$(id -u)" -eq 0 ]; then "$@"; elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then sudo -n "$@"; else return 1; fi +} + +backing_of() { # -> volume | bind | image + local src fstype + src="$(findmnt -no SOURCE --target "$1" 2>/dev/null || true)" + fstype="$(findmnt -no FSTYPE --target "$1" 2>/dev/null || true)" + # The container's own filesystem is the overlay root. A named volume shows + # the engine's volume store as its source (/var/lib/docker/volumes// + # _data, or the podman equivalent); any other mount came from outside. + case "$fstype" in overlay | "") echo image ;; *) case "$src" in *"/volumes/"*) echo volume ;; *) echo bind ;; esac ;; esac +} + +for dir in "$@"; do + name="${label:-$(basename "$dir")}" + if [ ! -d "$dir" ]; then + mkdir -p "$dir" 2>/dev/null || as_root mkdir -p "$dir" || { echo "$name: cannot create $dir" >&2; continue; } + fi + if [ "$(stat -c %u:%g "$dir" 2>/dev/null)" != "$me" ]; then + # Only the top level is checked, so a large folder is not walked on every + # start; -R because the contents came along with the same owner. + as_root chown -R "$me" "$dir" 2>/dev/null || true + fi + backing="$(backing_of "$dir")" + if [ -w "$dir" ]; then state=writable; else state="NOT writable by $(id -un)"; fi + echo "$name: $dir ($backing, $state)" + case "$backing" in + volume) echo "$name: a named volume is deleted by a container-engine storage reset; for the host disk, bind-mount the same target in devcontainer.json (see the Feature README)." ;; + image) echo "$name: inside the image, lost at the next rebuild; add the Feature's mount or set the directory variable to a mounted path." ;; + esac +done +exit 0 diff --git a/src/openbao/dcf-secret.sh b/src/openbao/dcf-secret.sh new file mode 100644 index 0000000..a06fd01 --- /dev/null +++ b/src/openbao/dcf-secret.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# dcf-secret: the one way a Feature or a script of this collection asks OpenBao for a secret. +# +# dcf-secret get [--field ] the value on stdout, nothing else +# dcf-secret put --field the value on stdin (never an argument) +# dcf-secret status [] one word: ready | sealed | unreachable | +# logged-out | denied | not-initialised +# +# A name is logical (ssh/, register/, token/) and lives at +# / (mount default: dcf, a KV v2 mount). A thin wrapper over the `bao` +# CLI (NFR-13): the exit code is worked out from bao's own signals (the exit code of `bao status`, +# the capabilities `bao token capabilities` reports), never from the words of an error message. +# Nothing is read from a file, a volume or the environment instead of the server: no fallback. +# +# Exit codes, the same for every caller, each with one plain line on stderr naming the fix: +# 0 done 10 the server is unreachable 11 the server is sealed (or not initialised) +# 12 no token, or it expired 13 the token may not use this name 14 no such secret +# 1 bao failed for a reason none of the signals explain (bao's own message follows) +# 2 a wrong call (usage) +set -u + +prog=dcf-secret +caps="" +mount="${DCF_SECRET_MOUNT:-dcf}" + +say() { echo "$prog: $*" >&2; } +die() { # + local code="$1" + shift + say "$*" + exit "$code" +} +usage() { + cat >&2 <<'EOF' +usage: dcf-secret get [--field ] print the value (field default: value) + dcf-secret put --field read the value from stdin + dcf-secret status [] print ready, sealed, unreachable, logged-out, + denied or not-initialised +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)"; } +fix_login() { say "no valid token (none, or it expired): log in with task openbao:login"; } +fix_denied() { say "the token may not $1 '$2' at $mount: its policy has to allow it (ask the server's owner)"; } + +command -v bao >/dev/null 2>&1 || die 10 "bao is not installed: add the openbao Feature to the devcontainer" + +[ -z "${DCF_SECRET_AS:-}" ] || die 2 "DCF_SECRET_AS (logging in as an AppRole) is not supported in this version of the openbao Feature" + +name_ok() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$ ]]; } +field_ok() { [[ "$1" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]]; } + +# Where in the server the name lives (KV v2 keeps the data under /data/). +api_path() { echo "$mount/data/$1"; } + +# `bao status` is the server's own answer: exit 0 unsealed, 2 sealed (an uninitialised server is +# sealed too, and its JSON says initialized false), anything else could not ask. Sets st_rc/st_json. +server_state() { + st_json="$(bao status -format=json 2>/dev/null)" + st_rc=$? +} +state_exit() { # exit with the line for a server that is not ready; no-op when it is + # $1 = word: also print the status word on stdout (dcf-secret status) + case "$st_rc" in + 0) return 0 ;; + 2) + if grep -Eq '"initialized"[[:space:]]*:[[:space:]]*false' <<<"$st_json"; then + [ "${1:-}" = word ] && echo not-initialised + fix_uninitialised + else + [ "${1:-}" = word ] && echo sealed + fix_sealed + fi + exit 11 + ;; + *) + [ "${1:-}" = word ] && echo unreachable + fix_unreachable + exit 10 + ;; + esac +} + +# Capabilities of the current token on a path (`bao token capabilities`): a failure means the call +# was refused, so there is no usable token (the default policy lets every valid token ask). +caps_of() { # -> sets caps, returns bao's exit code + caps="$(bao token capabilities "$1" 2>/dev/null)" +} +can() { # : does $caps allow it + if [ "$1" = read ]; then + grep -Eq '(^|[^a-z])(read|root)([^a-z]|$)' <<<"$caps" + else + grep -Eq '(^|[^a-z])(create|update|root)([^a-z]|$)' <<<"$caps" + fi +} + +# After a failed call: the server's state, then the token, then what the token may do. +# $1 read|write, $2 name, $3 bao's own stderr. Always exits. +diagnose() { + server_state + state_exit + caps_of "$(api_path "$2")" || { fix_login; exit 12; } + can "$1" || { fix_denied "$1" "$2"; exit 13; } + if [ "$1" = read ]; then + die 14 "no such secret (or field) '$2' at $mount: write it with dcf-secret put" + fi + say "bao could not write '$2' at $mount for a reason its signals do not explain:" + printf '%s\n' "$3" >&2 + exit 1 +} + +cmd_get() { + local name="" field=value + while [ $# -gt 0 ]; do + case "$1" in + --field) [ $# -ge 2 ] || usage; field="$2"; shift 2 ;; + --field=*) field="${1#--field=}"; shift ;; + -*) usage ;; + *) [ -z "$name" ] || usage; name="$1"; shift ;; + esac + done + [ -n "$name" ] || usage + name_ok "$name" || die 2 "'$name' is not a secret name (letters, digits, . _ - and / between parts)" + field_ok "$field" || die 2 "'$field' is not a field name" + + # The value goes straight to our stdout (fd 3 is the original one); only bao's stderr is kept. + local err rc + { err="$(bao kv get -mount="$mount" -field="$field" "$name" 2>&1 1>&3 3>&-)"; rc=$?; } 3>&1 + [ "$rc" -eq 0 ] && exit 0 + diagnose read "$name" "$err" +} + +cmd_put() { + local name="" field="" + while [ $# -gt 0 ]; do + case "$1" in + --field) [ $# -ge 2 ] || usage; field="$2"; shift 2 ;; + --field=*) field="${1#--field=}"; shift ;; + -*) usage ;; + *) [ -z "$name" ] || usage; name="$1"; shift ;; + esac + done + [ -n "$name" ] && [ -n "$field" ] || usage + name_ok "$name" || die 2 "'$name' is not a secret name (letters, digits, . _ - and / between parts)" + field_ok "$field" || die 2 "'$field' is not a field name" + [ ! -t 0 ] || die 2 "the value is read from stdin: pipe it in (never as an argument)" + + # `field=-` makes bao read the value from our stdin. It replaces the whole secret with this one + # field (kv put); bao's table of metadata on stdout is not ours to print. + local err rc + err="$(bao kv put -mount="$mount" "$name" "$field=-" 2>&1 >/dev/null)" + rc=$? + [ "$rc" -eq 0 ] && exit 0 + diagnose write "$name" "$err" +} + +cmd_status() { + local name="${1:-}" + [ $# -le 1 ] || usage + [ -z "$name" ] || name_ok "$name" || die 2 "'$name' is not a secret name" + server_state + state_exit word + if ! bao token lookup >/dev/null 2>&1; then + echo logged-out + fix_login + exit 12 + fi + if [ -n "$name" ]; then + caps_of "$(api_path "$name")" || true + if ! can read; then + echo denied + fix_denied read "$name" + exit 13 + fi + fi + echo ready + exit 0 +} + +[ $# -ge 1 ] || usage +cmd="$1" +shift +case "$cmd" in + get) cmd_get "$@" ;; + put) cmd_put "$@" ;; + status) cmd_status "$@" ;; + -h | --help | help) usage ;; + *) usage ;; +esac diff --git a/src/openbao/devcontainer-feature.json b/src/openbao/devcontainer-feature.json index aed7b48..36b2af9 100644 --- a/src/openbao/devcontainer-feature.json +++ b/src/openbao/devcontainer-feature.json @@ -1,8 +1,8 @@ { "id": "openbao", - "version": "1.0.4", + "version": "1.1.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. 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 (~/.vault-token is linked into it by the post-create hook openbao-setup) so `bao login` survives rebuilds, 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.", "documentationURL": "https://github.com/nabil-becker/devcontainer-features/tree/main/src/openbao", "licenseURL": "https://github.com/nabil-becker/devcontainer-features/blob/main/LICENSE", "options": { @@ -11,12 +11,31 @@ "default": true, "description": "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)." }, + "mode": { + "type": "string", + "enum": [ + "client" + ], + "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." + }, "version": { "type": "string", "default": "latest", "description": "OpenBao release to install (e.g. '2.7.1', with or without a leading 'v'), or 'latest'. Match your OpenBao server's version." } }, + "containerEnv": { + "OPENBAO_STATE_DIR": "/var/lib/devcontainer-features/openbao" + }, + "mounts": [ + { + "source": "openbao-config-${devcontainerId}", + "target": "/var/lib/devcontainer-features/openbao", + "type": "volume" + } + ], + "postCreateCommand": "openbao-setup", "installsAfter": [ "ghcr.io/nabil-becker/devcontainer-features/go-task", "ghcr.io/devcontainers/features/common-utils" diff --git a/src/openbao/install.sh b/src/openbao/install.sh index 86aff96..88e6ab6 100755 --- a/src/openbao/install.sh +++ b/src/openbao/install.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # Installs the OpenBao CLI (https://openbao.org) from GitHub releases, -# verified against the release's checksums.txt. Feature options arrive as -# upper-cased environment variables (see devcontainer-feature.json): VERSION. +# verified against the release's checksums.txt, plus the pieces that make it part of this +# collection's ecosystem: dcf-secret, dcf-persist, the post-create setup (openbao-setup) and the +# Feature Taskfile. Feature options arrive as upper-cased environment variables (see +# devcontainer-feature.json): VERSION, MODE. set -euo pipefail # Every download goes through dcf_curl (dcf-curl.sh, the collection's retry helper, vendored from @@ -10,6 +12,7 @@ set -euo pipefail . "$(cd "$(dirname "$0")" && pwd)/dcf-curl.sh" VERSION="${VERSION:-latest}" +MODE="${MODE:-client}" repo="openbao/openbao" if [ "$(id -u)" -ne 0 ]; then @@ -17,6 +20,11 @@ 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 + exit 1 +fi + missing=() command -v curl >/dev/null 2>&1 || missing+=(curl ca-certificates) command -v tar >/dev/null 2>&1 || missing+=(tar) @@ -67,6 +75,24 @@ install -m 0755 "$tmp/x/bao" /usr/local/bin/bao /usr/local/bin/bao version +# ------------------------------------------------------- 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 +# OPENBAO_STATE_DIR here: the volume is not mounted at image build. +feature_dir="$(cd "$(dirname "$0")" && pwd)" +share=/usr/local/share/openbao-feature +mkdir -p "$share" +{ + echo "# Written by the openbao Feature's install.sh from its options; sourced by openbao-setup." + printf 'MODE=%q\n' "$MODE" +} > "$share/options.env" +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/dcf-persist.sh" /usr/local/bin/dcf-persist + # ---------------------------------------------------- Feature Taskfile ---- # Drop this Feature's taskfile.yml where the go-task Feature's registry picks # it up (namespace = Feature id), and refresh the registry if go-task is diff --git a/src/openbao/openbao-setup.sh b/src/openbao/openbao-setup.sh new file mode 100644 index 0000000..8228c0e --- /dev/null +++ b/src/openbao/openbao-setup.sh @@ -0,0 +1,65 @@ +#!/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. +# +# 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. +# 2. ~/.vault-token, where `bao login` keeps its token, is made a link to /token, so +# the login survives a rebuild. A real file there (a login made after `bao token revoke -self` +# removed the link, which is newer than the volume's copy) is moved into the volume first. +# 3. Says which mode it runs in and what is known without asking the server: the address, whether +# a login is saved. It never contacts the server (an unreachable one must not hold post-create). +set -u + +# Defaults baked by install.sh from the Feature options. OPENBAO_SETUP_* in the environment +# (containerEnv) override them without a rebuild. +# 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 +mode="${OPENBAO_SETUP_MODE-${MODE:-client}}" +dir="${OPENBAO_STATE_DIR:-/var/lib/devcontainer-features/openbao}" +link="$HOME/.vault-token" +target="$dir/token" + +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 ;; +esac + +if [ ! -w "$dir" ]; then + echo "openbao: $dir is not writable, so the login is not linked into it and will not survive a rebuild" >&2 +elif [ -L "$link" ]; then + if [ "$(readlink "$link")" = "$target" ]; then + echo "openbao: login token linked into the state dir ($link, kept)" + else + echo "openbao: $link is a link to $(readlink "$link"), left alone; the login will not survive a rebuild" >&2 + fi +elif [ -e "$link" ]; then + if [ -f "$link" ] && mv -f "$link" "$target" 2>/dev/null && ln -s "$target" "$link" 2>/dev/null; then + chmod 0600 "$target" 2>/dev/null || true + echo "openbao: login token moved into the state dir and linked ($link)" + else + echo "openbao: could not move $link into $dir; the login will not survive a rebuild" >&2 + fi +elif ln -s "$target" "$link" 2>/dev/null; then + echo "openbao: login token linked into the state dir ($link)" +else + echo "openbao: could not link $link to $target (is \$HOME writable?)" >&2 +fi + +if ! command -v bao >/dev/null 2>&1; then + echo "openbao: bao not found on PATH; rebuild the container and check the build log for this Feature" >&2 + 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 "${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)" +else + echo "openbao: no login yet; run 'bao login' (or task openbao:login) once, it persists in $dir" +fi +exit 0 diff --git a/src/openbao/taskfile.yml b/src/openbao/taskfile.yml index 5777d5b..6e41f3f 100644 --- a/src/openbao/taskfile.yml +++ b/src/openbao/taskfile.yml @@ -29,3 +29,9 @@ tasks: vars: [KEY] cmds: - 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, link ~/.vault-token into it, report the address and whether a login is saved (idempotent; OPENBAO_SETUP_MODE overrides the Feature option).' + cmds: + - openbao-setup diff --git a/test/_global/all_features.sh b/test/_global/all_features.sh index 0f71a9a..85d7520 100755 --- a/test/_global/all_features.sh +++ b/test/_global/all_features.sh @@ -14,6 +14,8 @@ check "agent-adapter-claude" command -v agent-adapter-claude check "devcontainer" devcontainer --version check "coder" coder version check "bao" bao version +check "dcf-secret" command -v dcf-secret +check "openbao-setup" command -v openbao-setup check "velero" velero version --client-only check "kustomize" kustomize version check "kubeconform" kubeconform -v @@ -37,7 +39,7 @@ check "node not leaked onto PATH" bash -c "! command -v node" # Every state-owning Feature points its tool at its own mounted directory, # and the post-create hooks (run by `devcontainer up` before this script) # have made them usable by the remote user. -for v in CLAUDE_CONFIG_DIR GH_CONFIG_DIR AZURE_CONFIG_DIR KUBECONFIG GIT_CONFIG_GLOBAL CODER_CONFIG_DIR ARGOCD_CONFIG_DIR; do +for v in CLAUDE_CONFIG_DIR GH_CONFIG_DIR AZURE_CONFIG_DIR KUBECONFIG GIT_CONFIG_GLOBAL CODER_CONFIG_DIR ARGOCD_CONFIG_DIR OPENBAO_STATE_DIR; do check "$v points under /var/lib/devcontainer-features" bash -c "case \"\$$v\" in /var/lib/devcontainer-features/*) ;; *) exit 1 ;; esac" done check "claude-code state dir writable" test -w "$CLAUDE_CONFIG_DIR" @@ -47,6 +49,7 @@ check "kube state dir writable" test -w "$(dirname "$KUBECONFIG")" check "git global config dir writable" test -w "$(dirname "$GIT_CONFIG_GLOBAL")" check "coder state dir writable" test -w "$CODER_CONFIG_DIR" check "argocd state dir writable" test -w "$ARGOCD_CONFIG_DIR" +check "openbao state dir writable" test -w "$OPENBAO_STATE_DIR" check "ssh-agent socket folder writable" test -w /var/lib/devcontainer-features/ssh-agent check "ssh-agent key volume present" test -d /var/lib/devcontainer-features/ssh-agent-key diff --git a/test/openbao/dev_server.sh b/test/openbao/dev_server.sh new file mode 100644 index 0000000..05e5170 --- /dev/null +++ b/test/openbao/dev_server.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Scenario 'dev_server' (see scenarios.json): dcf-secret against a real `bao server -dev` (in +# memory, unsealed, KV v2 at `secret/`), remoteUser=vscode. This is what shows the real bao gives +# the signals the Docker-free tests (test/openbao/unit) only stub: `bao status` exit codes, the +# capabilities words, kv get and put through stdin, and a login written through the link into +# the volume. A dev server is not the server mode of a later version; it exists for this test. +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:18200 +root=dev-root-token +log="$(mktemp)" +bao server -dev -dev-root-token-id="$root" -dev-listen-address=127.0.0.1:18200 >"$log" 2>&1 & +server=$! +trap 'kill "$server" 2>/dev/null || true' EXIT +for _ in $(seq 1 60); do + bao status >/dev/null 2>&1 && break + sleep 0.5 +done + +# A home with no saved login, and the Feature's setup run into a scratch state folder, so the +# checks do not depend on the order of the volume's own state. +home="$(mktemp -d)" +state="$(mktemp -d)" +as_nobody() { env -u BAO_TOKEN HOME="$home" OPENBAO_STATE_DIR="$state" "$@"; } + +rc=0; as_nobody dcf-secret get ssh/x >/dev/null 2>&1 || rc=$? +check "no token is exit 12" [ "$rc" = 12 ] +rc=0; out="$(as_nobody dcf-secret status 2>/dev/null)" || rc=$? +check "status without a login is logged-out (exit 12)" [ "$rc:$out" = 12:logged-out ] + +BAO_TOKEN="$root" bao secrets enable -path=dcf -version=2 kv >/dev/null + +export BAO_TOKEN="$root" +rc=0; printf '%s' 'abc def' | dcf-secret put ssh/key --field private_key || rc=$? +check "put is exit 0" [ "$rc" = 0 ] +out="$(dcf-secret get ssh/key --field private_key)" +check "get returns what put stored, alone" [ "$out" = 'abc def' ] +rc=0; dcf-secret get ssh/missing >/dev/null 2>&1 || rc=$? +check "a missing secret is exit 14" [ "$rc" = 14 ] +rc=0; dcf-secret get ssh/key --field nofield >/dev/null 2>&1 || rc=$? +check "a missing field is exit 14" [ "$rc" = 14 ] +check "status is ready" [ "$(dcf-secret status)" = ready ] + +printf 'path "dcf/data/ok" { capabilities = ["read"] }\n' | bao policy write dcf-ok - >/dev/null +printf '%s' 'fine' | dcf-secret put ok --field value +limited="$(bao token create -policy=dcf-ok -field=token)" +rc=0; BAO_TOKEN="$limited" dcf-secret get ssh/key >/dev/null 2>&1 || rc=$? +check "a token without the policy is exit 13" [ "$rc" = 13 ] +check "the token may read its own name" [ "$(BAO_TOKEN="$limited" dcf-secret get ok)" = fine ] +rc=0; printf 'x' | BAO_TOKEN="$limited" dcf-secret put ok --field value >/dev/null 2>&1 || rc=$? +check "a read-only token may not put (exit 13)" [ "$rc" = 13 ] +check "status denied for a name it may not read" [ "$(BAO_TOKEN="$limited" dcf-secret status ssh/key 2>/dev/null || true)" = denied ] + +rc=0; BAO_TOKEN=not-a-token dcf-secret get ok >/dev/null 2>&1 || rc=$? +check "a bad token is exit 12" [ "$rc" = 12 ] + +# bao login writes through the link into the volume, and the saved login is then used +unset BAO_TOKEN +as_nobody openbao-setup >/dev/null +as_nobody bao login -no-print "$root" >/dev/null +check "the home token file is still the link" [ "$(readlink "$home/.vault-token")" = "$state/token" ] +check "the login landed in the state dir" [ -s "$state/token" ] +check "dcf-secret uses the saved login" [ "$(as_nobody dcf-secret get ok)" = fine ] + +rc=0; BAO_ADDR=http://127.0.0.1:1 dcf-secret get ok >/dev/null 2>&1 || rc=$? +check "a closed port is exit 10" [ "$rc" = 10 ] + +# Sealed is last: a dev server cannot be unsealed again without its key. +BAO_TOKEN="$root" bao operator seal >/dev/null +rc=0; out="$(as_nobody dcf-secret get ok 2>/dev/null)" || rc=$? +check "a sealed server is exit 11 and prints nothing" [ "$rc:$out" = 11: ] +check "status says sealed" [ "$(as_nobody dcf-secret status 2>/dev/null || true)" = sealed ] + +reportResults diff --git a/test/openbao/mode_client.sh b/test/openbao/mode_client.sh new file mode 100644 index 0000000..3f11eb2 --- /dev/null +++ b/test/openbao/mode_client.sh @@ -0,0 +1,12 @@ +#!/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. +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\"" + +reportResults diff --git a/test/openbao/no_taskfile.sh b/test/openbao/no_taskfile.sh new file mode 100644 index 0000000..ce7bf50 --- /dev/null +++ b/test/openbao/no_taskfile.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Scenario 'no_taskfile' (see scenarios.json): installTaskfile=false. +set -e +# shellcheck disable=SC1091 # provided by the devcontainer CLI at test time +source dev-container-features-test-lib + +check "bao is on PATH" command -v bao +check "dcf-secret is on PATH" command -v dcf-secret +check "no Taskfile registered" test ! -e /usr/local/share/go-task/includes.d/openbao.yml + +reportResults diff --git a/test/openbao/pinned_version.sh b/test/openbao/pinned_version.sh index d4ef209..84a17bc 100755 --- a/test/openbao/pinned_version.sh +++ b/test/openbao/pinned_version.sh @@ -8,4 +8,10 @@ check "bao 2.7.1 installed" bash -c "bao version | grep -F 'v2.7.1'" # shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash check "runs as non-root user" bash -c '[ "$(id -u)" -ne 0 ] && bao version' +check "dcf-secret and openbao-setup installed" bash -c "command -v dcf-secret && command -v openbao-setup" +# shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash +check "the volume's folder is writable by the remote user" bash -c '[ "$(id -u)" -ne 0 ] && openbao-setup | grep -E "writable\)$"' +# shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash +check "the login is linked into the state dir" bash -c '[ "$(readlink ~/.vault-token)" = "$OPENBAO_STATE_DIR/token" ]' + reportResults diff --git a/test/openbao/scenarios.json b/test/openbao/scenarios.json index 91cf898..e77c61e 100644 --- a/test/openbao/scenarios.json +++ b/test/openbao/scenarios.json @@ -7,5 +7,32 @@ } }, "remoteUser": "vscode" + }, + "no_taskfile": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "remoteUser": "vscode", + "features": { + "openbao": { + "installTaskfile": false + } + } + }, + "mode_client": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "remoteUser": "vscode", + "features": { + "openbao": { + "mode": "client" + } + } + }, + "dev_server": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "remoteUser": "vscode", + "features": { + "openbao": { + "version": "2.7.1" + } + } } } diff --git a/test/openbao/test.sh b/test/openbao/test.sh index d5fc70c..ce3adb9 100755 --- a/test/openbao/test.sh +++ b/test/openbao/test.sh @@ -13,4 +13,22 @@ check "binary is root-owned" bash -c '[ "$(stat -c %U "$(readlink -f "$(command check "taskfile registered" test -f /usr/local/share/go-task/includes.d/openbao.yml +# State (NFR-1): the variable, the pieces the hook needs, the options baked for it. +# shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash +check "OPENBAO_STATE_DIR set" bash -c '[ "$OPENBAO_STATE_DIR" = /var/lib/devcontainer-features/openbao ]' +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 "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\)$'" +check "openbao-setup links the login token into it" bash -c "openbao-setup >/dev/null; [ \"\$(readlink ~/.vault-token)\" = /var/lib/devcontainer-features/openbao/token ]" +check "second run keeps it" bash -c "openbao-setup | grep -F 'kept)'" +check "task openbao:setup is in the Taskfile" grep -q '^ setup:' /usr/local/share/go-task/includes.d/openbao.yml + +# dcf-secret with no server: unreachable, exit 10, one line (a closed port, no waiting on a host) +# shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash +check "dcf-secret status without a server is unreachable (exit 10)" bash -c 'out="$(BAO_ADDR=http://127.0.0.1:1 dcf-secret status 2>/dev/null)"; rc=$?; [ "$rc" = 10 ] && [ "$out" = unreachable ]' +# shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash +check "dcf-secret get without a server is exit 10 and prints nothing" bash -c 'out="$(BAO_ADDR=http://127.0.0.1:1 dcf-secret get ssh/x 2>/dev/null)"; rc=$?; [ "$rc" = 10 ] && [ -z "$out" ]' + reportResults diff --git a/test/openbao/unit/dcf_secret_test.sh b/test/openbao/unit/dcf_secret_test.sh new file mode 100644 index 0000000..4cc3dd9 --- /dev/null +++ b/test/openbao/unit/dcf_secret_test.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +# Docker-free tests of dcf-secret (src/openbao/dcf-secret.sh) against a stub `bao` that answers +# from environment variables and logs every call: each exit code and its one-line fix on stderr, +# the value alone on stdout, the value on stdin for put and never in an argument, the mount, name +# and field checks, status in every state, DCF_SECRET_AS refused, and no fallback to a file or the +# environment when the server says no. The stub plays only the signals the script is built on +# (the exit code of `bao status`, `initialized` in its JSON, `bao token capabilities`); that the +# real bao gives those is the container test's (test/openbao/dev_server.sh). 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). +set -u +exec "$T/bin/bao" <<'STUB' +#!/usr/bin/env bash +# Stub bao: STUB_* variables choose the answers; every call is logged (arguments only). +echo "$*" >> "$STUB_LOG" +case "$1 $2" in + "status -format=json") + printf '%s\n' "${STUB_STATUS_JSON:-{\"initialized\": true, \"sealed\": false\}}" + exit "${STUB_STATUS_RC:-0}" ;; + "kv get") + if [ "${STUB_KV_RC:-0}" -eq 0 ]; then printf '%s' "${STUB_VALUE-s3cret}"; exit 0; fi + echo "Error reading dcf/data/x: stub failure" >&2 + exit "$STUB_KV_RC" ;; + "kv put") + cat > "$STUB_DIR/put-stdin" + [ "${STUB_PUT_RC:-0}" -eq 0 ] && { echo "Key Value"; exit 0; } + echo "Error writing: stub failure" >&2 + exit "$STUB_PUT_RC" ;; + "token capabilities") + [ "${STUB_CAPS_RC:-0}" -eq 0 ] || { echo "permission denied" >&2; exit 2; } + echo "${STUB_CAPS-read}" + exit 0 ;; + "token lookup") + echo "token s.never-printed" + exit "${STUB_LOOKUP_RC:-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 +} + +# run [VAR=value ...] -- : stdout in $out, stderr in $err, exit status in $rc. Stdin is +# $INPUT (default empty). A clean environment with the stub first on PATH. +INPUT="" +run() { + local -a envs=() + while [ "$1" != -- ]; do envs+=("$1"); shift; done + shift + : > "$LOG" + rm -f "$T/put-stdin" + out="$(printf '%s' "$INPUT" | env -i PATH="$T/bin:/usr/bin:/bin" HOME="$T/home" STUB_LOG="$LOG" STUB_DIR="$T" \ + OPENBAO_STATE_DIR="$T/state" DCF_SECRET_X="from-the-environment" "${envs[@]}" bash "$script" "$@" 2>"$T/err")" + rc=$? + err="$(cat "$T/err")" + INPUT="" +} +lines() { printf '%s\n' "$1" | grep -c .; } + +# --- get: the value, alone +run -- get ssh/key +check "get: exit 0" 0 "$rc" +check "get: the value alone on stdout" "s3cret" "$out" +check "get: nothing on stderr" "" "$err" +has "get: asks the dcf mount, the value field, the name" "kv get -mount=dcf -field=value ssh/key" "$(cat "$LOG")" +check "get: one call to bao" 1 "$(lines "$(cat "$LOG")")" +run -- get register/scribe --field private_key +has "get --field" "-field=private_key register/scribe" "$(cat "$LOG")" +run -- get ssh/key --field=pub +has "get --field=x" "-field=pub ssh/key" "$(cat "$LOG")" +run DCF_SECRET_MOUNT=mine -- get token/gh +has "DCF_SECRET_MOUNT names the mount" "-mount=mine -field=value token/gh" "$(cat "$LOG")" +run 'STUB_VALUE=line one +line two' -- get ssh/key +check "get: a multi-line value is passed through untouched" "$(printf 'line one\nline two')" "$out" + +# --- get: each failure and its line +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" +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" +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" +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" +check "12: one line" 1 "$(lines "$err")" +run STUB_KV_RC=2 STUB_CAPS=deny -- get ssh/key +check "13: the token may not read" 13 "$rc" +has "13: the line names the name and the mount" "'ssh/key' at dcf" "$err" +check "13: one line" 1 "$(lines "$err")" +run STUB_KV_RC=2 STUB_CAPS=read -- get ssh/key +check "14: no such secret" 14 "$rc" +has "14: the fix is a put" "dcf-secret put" "$err" +check "14: one line" 1 "$(lines "$err")" +run STUB_KV_RC=2 STUB_CAPS="create, read, update" -- get ssh/key +check "14: capabilities listed with commas" 14 "$rc" +run STUB_KV_RC=2 STUB_CAPS=root -- get ssh/key +check "14: root may read" 14 "$rc" +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")" + +# --- 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")" +printf 'from-the-volume' > "$T/state/ssh/key" +run STUB_KV_RC=2 STUB_STATUS_RC=2 -- get ssh/key +check "no fallback: sealed with a key in the volume and in the environment, stdout empty" "" "$out" +check "no fallback: still 11" 11 "$rc" +run STUB_KV_RC=2 STUB_STATUS_RC=1 -- get ssh/key +check "no fallback: unreachable, stdout empty" "" "$out" +run STUB_KV_RC=2 STUB_CAPS=read -- get x +check "no fallback: a missing secret does not read DCF_SECRET_X" "" "$out" +check "no fallback: a missing secret is 14" 14 "$rc" +check "get never writes a file (the state folder holds only what the test put there)" "key" "$(ls "$T/state/ssh")" + +# --- get: bad calls +run -- get +check "get without a name is usage" 2 "$rc" +run -- get ../etc/passwd +check "a name with .. is refused" 2 "$rc" +run -- get /abs/path +check "a leading slash is refused" 2 "$rc" +run -- get 'a b' +check "a name with a space is refused" 2 "$rc" +run -- get 'a;rm' +check "a name with shell characters is refused" 2 "$rc" +run -- get ssh/key --field 'a=b' +check "a field with = is refused" 2 "$rc" +check "bad calls never reach bao" 0 "$(wc -l < "$LOG")" +run -- frobnicate +check "an unknown command is usage" 2 "$rc" +run -- get ssh/key --field +check "--field without a value is usage" 2 "$rc" +run -- +check "no command is usage" 2 "$rc" + +# --- put +INPUT="topsecret" run -- put ssh/key --field private_key +check "put: exit 0" 0 "$rc" +check "put: nothing on stdout (bao's table is not ours)" "" "$out" +check "put: the value reached bao on stdin" "topsecret" "$(cat "$T/put-stdin")" +has "put: field=- so bao reads stdin" "kv put -mount=dcf ssh/key private_key=-" "$(cat "$LOG")" +lacks "put: the value is in no argument" "topsecret" "$(cat "$LOG")" +INPUT="x" run -- put ssh/key +check "put without --field is usage" 2 "$rc" +INPUT="x" run STUB_PUT_RC=2 STUB_STATUS_RC=2 -- put ssh/key --field f +check "put: sealed is 11" 11 "$rc" +INPUT="x" run STUB_PUT_RC=2 STUB_STATUS_RC=1 -- put ssh/key --field f +check "put: unreachable is 10" 10 "$rc" +INPUT="x" run STUB_PUT_RC=2 STUB_CAPS_RC=2 -- put ssh/key --field f +check "put: no token is 12" 12 "$rc" +INPUT="x" run STUB_PUT_RC=2 STUB_CAPS=read -- put ssh/key --field f +check "put: read-only token is 13" 13 "$rc" +has "put: 13 names the write" "may not write" "$err" +INPUT="x" run STUB_PUT_RC=2 STUB_CAPS="create, update" -- put ssh/key --field f +check "put: allowed but failed is 1, never 14" 1 "$rc" +has "put: 1 carries bao's own message" "stub failure" "$err" +INPUT="x" run STUB_PUT_RC=2 STUB_CAPS=deny -- put ssh/key --field f +check "put: 14 does not apply to a put" 13 "$rc" +# a terminal on stdin is refused: run under a pseudo-terminal when script(1) is there +if command -v script >/dev/null 2>&1; then + tty_out="$(script -qec "env PATH=$T/bin:/usr/bin:/bin STUB_LOG=$LOG STUB_DIR=$T bash $script put ssh/key --field f" /dev/null 2>&1 &1)" +rc=$? +check "no bao on PATH is 10" 10 "$rc" +has "no bao: names the Feature" "openbao Feature" "$out" + +echo +echo "dcf_secret_test: $pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/test/openbao/unit/setup_test.sh b/test/openbao/unit/setup_test.sh new file mode 100644 index 0000000..48a5aaf --- /dev/null +++ b/test/openbao/unit/setup_test.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Docker-free tests of openbao-setup (src/openbao/openbao-setup.sh) over temp folders: the state +# directory made and reported (dcf-persist, the real copy), ~/.vault-token linked into it (made, +# kept on a second run, a real file moved in and linked, a link elsewhere left alone, a state +# folder that is not writable reported), the mode (client; an unknown one reported and run as +# client; OPENBAO_SETUP_MODE over the baked default), the report (bao missing, BAO_ADDR, BAO_TOKEN, +# a saved login), never fatal, and the token's text never printed. Part of `task lint` +# (`task test:openbao-unit`). SCRIPT points the test at another copy of the script. +set -u +exec /dev/null; rm -rf "$T"' EXIT +mkdir -p "$T/bin" +ln -s "$persist" "$T/bin/dcf-persist" +cat > "$T/bin/bao" <<'STUB' +#!/usr/bin/env bash +[ "$1" = version ] && echo "OpenBao v9.9.9 (stub)" +exit 0 +STUB +chmod +x "$T/bin/bao" "$persist" 2>/dev/null + +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 +} + +# fresh: a new home and state folder; run [VAR=value ...]: out = stdout+stderr, rc = exit status. +fresh() { rm -rf "${T:?}/home" "${T:?}/state"; mkdir -p "$T/home"; } +run() { + out="$(env -i PATH="$T/bin:/usr/bin:/bin" HOME="$T/home" OPENBAO_STATE_DIR="$T/state" "$@" bash "$script" 2>&1)" + rc=$? +} + +fresh +run +check "first run: exit 0" 0 "$rc" +has "reports the state dir with its backing" "openbao: $T/state (" "$out" +has "the state dir is writable" "writable)" "$out" +check "the state folder exists" yes "$([ -d "$T/state" ] && echo yes)" +check "the home token file is a link into the state dir" "$T/state/token" "$(readlink "$T/home/.vault-token")" +has "says it linked" "login token linked into the state dir" "$out" +has "mode client" "mode client" "$out" +has "reports the bao version" "OpenBao v9.9.9" "$out" +has "no login yet" "no login yet" "$out" +has "BAO_ADDR not set is said" "BAO_ADDR is not set" "$out" + +run +check "second run: exit 0" 0 "$rc" +has "second run keeps the link" ", kept)" "$out" +check "the link is unchanged" "$T/state/token" "$(readlink "$T/home/.vault-token")" + +# a login written through the link lands in the volume and is reported +printf 'hvs.PRIVATE-TOKEN-TEXT' > "$T/home/.vault-token" +check "writing through the link reaches the state dir" "hvs.PRIVATE-TOKEN-TEXT" "$(cat "$T/state/token")" +run +has "a saved login is reported" "a login is saved" "$out" +lacks "the token's text is never printed" "PRIVATE-TOKEN-TEXT" "$out" +run BAO_TOKEN=hvs.OTHER-TEXT +has "BAO_TOKEN is said to take precedence" "BAO_TOKEN is set" "$out" +lacks "BAO_TOKEN's text is never printed" "OTHER-TEXT" "$out" +run BAO_ADDR=https://bao.example.test +has "BAO_ADDR is reported" "BAO_ADDR https://bao.example.test" "$out" + +# a revoke removes the link; a later login is a real file, newer than the volume's copy +rm -f "$T/home/.vault-token" +printf 'hvs.NEWER' > "$T/home/.vault-token" +run +check "a real file is moved into the volume" "hvs.NEWER" "$(cat "$T/state/token")" +check "and linked" "$T/state/token" "$(readlink "$T/home/.vault-token")" +has "says it moved" "moved into the state dir" "$out" +check "the moved token is mode 600" 600 "$(stat -c %a "$T/state/token")" + +# a link somewhere else is the user's +fresh +mkdir -p "$T/elsewhere" +ln -s "$T/elsewhere/tok" "$T/home/.vault-token" +run +check "a link elsewhere: exit 0" 0 "$rc" +check "a link elsewhere is left alone" "$T/elsewhere/tok" "$(readlink "$T/home/.vault-token")" +has "and said" "left alone" "$out" + +# a state folder nobody can write: reported, never fatal, nothing linked (skipped as root) +if [ "$(id -u)" -ne 0 ]; then + fresh + mkdir -p "$T/state" + chmod 0555 "$T/state" + run + check "an unwritable state dir: exit 0" 0 "$rc" + has "an unwritable state dir is reported" "not writable" "$out" + check "nothing is linked into it" "" "$([ -e "$T/home/.vault-token" ] || [ -L "$T/home/.vault-token" ] && echo linked)" + chmod 0755 "$T/state" +fi + +# the mode +fresh +run OPENBAO_SETUP_MODE=server +check "an unknown mode: exit 0" 0 "$rc" +has "an unknown mode is reported" "mode 'server' is not available" "$out" + +# bao missing: the rest still ran and the exit is 0 +fresh +mkdir -p "$T/nobao" +ln -s "$persist" "$T/nobao/dcf-persist" +out="$(env -i PATH="$T/nobao:/usr/bin:/bin" HOME="$T/home" OPENBAO_STATE_DIR="$T/state" bash "$script" 2>&1)" +rc=$? +check "no bao: exit 0" 0 "$rc" +has "no bao: said, not fatal" "bao not found on PATH" "$out" +check "no bao: the link was still made" "$T/state/token" "$(readlink "$T/home/.vault-token")" + +echo +echo "setup_test: $pass passed, $fail failed" +[ "$fail" -eq 0 ] From afca4dbdfce58e1e7dc304e90296452d236d3f30 Mon Sep 17 00:00:00 2001 From: devhost agent Date: Tue, 3 Nov 2026 20:50:24 +0000 Subject: [PATCH 3/9] feat(openbao): dcf-secret put merges by default, --replace replaces (openbao-state-build) The Operator's ruling on #162: put writes only the given field and keeps the secret's others (bao kv patch); a secret that does not exist is created with kv put -cas=0, and if it appeared in between one more patch merges into it. --replace makes the secret exactly the one field. A token without the patch capability is refused with 13 and nothing is written. The volume name stays openbao-config-${devcontainerId}; NOTES say it supersedes the proposal's openbao-${devcontainerId}. Version stays 1.1.0 (unreleased). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DPsHU61VPD8TZwHanFM7zj Signed-off-by: devhost agent --- Taskfile.yml | 2 +- src/openbao/CHANGELOG.md | 2 +- src/openbao/NOTES.md | 29 +++++-- src/openbao/README.md | 31 +++++--- src/openbao/dcf-secret.sh | 75 ++++++++++++++---- test/openbao/dev_server.sh | 16 ++++ test/openbao/unit/dcf_secret_test.sh | 110 +++++++++++++++++++++------ 7 files changed, 208 insertions(+), 57 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index a4e5236..3284b15 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -241,7 +241,7 @@ 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, 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, ~/.vault-token linked into it (made, kept, a real file moved in, a link elsewhere left alone, 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. That the real bao gives the signals the stub plays is the container test test/openbao/dev_server.sh (CI). Part of lint. Needs nothing beyond bash and grep (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, ~/.vault-token linked into it (made, kept, a real file moved in, a link elsewhere left alone, 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. That the real bao gives the signals the stub plays is the container test test/openbao/dev_server.sh (CI). Part of lint. Needs nothing beyond bash and grep (script(1) for one check, skipped without it).' cmds: - bash test/openbao/unit/dcf_secret_test.sh - bash test/openbao/unit/setup_test.sh diff --git a/src/openbao/CHANGELOG.md b/src/openbao/CHANGELOG.md index 6e42288..d5d8886 100644 --- a/src/openbao/CHANGELOG.md +++ b/src/openbao/CHANGELOG.md @@ -13,7 +13,7 @@ Entries from 1.0.0 to 1.0.3 were generated from the commit history (`.github/scr ### Added - **Your `bao login` now survives a rebuild.** The Feature owns a named volume (`openbao-config-`, at `/var/lib/devcontainer-features/openbao`, named by `OPENBAO_STATE_DIR`) and a post-create hook, `openbao-setup` (also `task openbao:setup`), that links `~/.vault-token` into it, the way `gh` and `az` keep their logins. **You must act once:** rebuild the container and run `bao login` again, because the volume starts empty. Nothing to configure; a repo that wants the state on the host disk bind-mounts the same target. A `BAO_TOKEN` in the environment still takes precedence over the saved login. -- **`dcf-secret`**, a command for scripts and other Features to read and write a secret without knowing where the server runs: `dcf-secret get `, `dcf-secret put --field ` (the value on stdin) and `dcf-secret status`. It fails with fixed exit codes (10 unreachable, 11 sealed, 12 not logged in, 13 denied, 14 no such secret), each with one line saying what to do, and never falls back to a key on disk. Secrets live in a KV v2 mount named `dcf` (`DCF_SECRET_MOUNT` changes it). Nothing to do unless you want it; `task openbao:kv:get` is unchanged. +- **`dcf-secret`**, a command for scripts and other Features to read and write a secret without knowing where the server runs: `dcf-secret get `, `dcf-secret put --field ` (the value on stdin) and `dcf-secret status`. `put` writes only that field and keeps the secret's other fields (it creates the secret when it does not exist); `--replace` makes the secret exactly that one field. A token whose policy lacks the `patch` capability is refused with exit 13 rather than silently replacing the secret. It fails with fixed exit codes (10 unreachable, 11 sealed, 12 not logged in, 13 denied, 14 no such secret), each with one line saying what to do, and never falls back to a key on disk. Secrets live in a KV v2 mount named `dcf` (`DCF_SECRET_MOUNT` changes it). Nothing to do unless you want it; `task openbao:kv:get` is unchanged. - **The `mode` option**, with the one value `client` (the default, what the Feature always did: the CLI against a server outside the container). A later version adds `server`. `OPENBAO_SETUP_MODE` in `containerEnv` overrides it per container. Nothing to do. - `task openbao:setup`: re-runs the post-create hook. diff --git a/src/openbao/NOTES.md b/src/openbao/NOTES.md index 1438e6f..fc2ed55 100644 --- a/src/openbao/NOTES.md +++ b/src/openbao/NOTES.md @@ -45,7 +45,10 @@ hook reads to `/usr/local/share/openbao-feature/options.env`. Owned by the Feature (NFR-1), like `gh` and `az`: - `containerEnv` sets `OPENBAO_STATE_DIR=/var/lib/devcontainer-features/openbao`. -- `mounts` declares the named volume `openbao-config-${devcontainerId}` on that path. +- `mounts` declares the named volume `openbao-config-${devcontainerId}` on that path. The name + follows the repository's convention (`-config-${devcontainerId}`, as `gh-config-` and + `az-config-`) and supersedes `openbao-${devcontainerId}`, which the proposal + `docs/proposals/secrets-master-split.md` (section 3) wrote before it checked the convention. - 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, then makes `~/.vault-token`, where `bao login` keeps its token, a link to `$OPENBAO_STATE_DIR/token`. **A rebuild keeps the @@ -79,7 +82,8 @@ get` for people; `dcf-secret` is for callers. ```text dcf-secret get [--field ] the value on stdout, nothing else (field default: value) -dcf-secret put --field the value on stdin, never an argument +dcf-secret put --field [--replace] + the value on stdin, never an argument dcf-secret status [] one word on stdout, the fix on stderr ``` @@ -88,9 +92,14 @@ dcf-secret status [] one word on stdout, the fix on stderr `dcf`; `DCF_SECRET_MOUNT` names another one (a client of a shared server uses its own). - **Who asks**: the token `bao` uses (`BAO_TOKEN`, else the saved login). `DCF_SECRET_AS` (logging in as an AppRole) is not in this version: setting it exits 2 rather than asking as someone else. -- **`put` replaces the whole secret** with the one field (it is `bao kv put`); it refuses a - terminal on stdin. **`get` writes the value only to stdout**: it is never put in a file or a - variable. +- **`put` merges by default**: it writes only the given field and keeps the secret's other fields + (`bao kv patch`); when the secret does not exist yet it creates it (`bao kv put -cas=0`, which + fails if the secret appeared in the meantime, and then one more patch merges into it). + `--replace` makes the secret exactly this one field (`bao kv put`) and drops the others. A token + that may not patch is refused (exit 13, naming `--replace`), never silently turned into a + replace. `put` refuses a terminal on stdin and holds the value in memory only (never a file, + never an argument; a merge may offer it to `bao` twice). **`get` writes the value only to + stdout**: it is never put in a file or a variable. - **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. @@ -111,9 +120,11 @@ The code is worked out from `bao`'s own signals, never from the words of a messa call it asks `bao status -format=json` (exit 2 is sealed, the JSON says `initialized`; anything else is unreachable), then `bao token capabilities` on `/data/` (a refused call is no valid token; no `read` or `root` for a get, no `create`, `update` or `root` for a put, is denied), -and a get that gets this far is a missing secret. A token therefore needs the default policy (every -token has it unless removed) besides `read` on `/data/` to get and `create` and -`update` to put. It speaks only `bao`, so it works the same against a Vault server. +and a get that gets this far is a missing secret. A `put` that fails the same way checks for `patch` +(a merge needs it), then for `create` (a secret that does not exist yet needs it). A token +therefore needs the default policy (every token has it unless removed) besides `read` on +`/data/` to get, `patch` (and `create` for a new secret) to put, and `create` and +`update` for `put --replace`. It speaks only `bao`, so it works the same against a Vault server. A caller without this Feature finds no `dcf-secret` and says so; the Features stay independent at build time. @@ -199,6 +210,8 @@ itself. installed unchecked. - Logged out after a rebuild: the volume is gone (a container-engine storage reset, or another 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. - `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/`). diff --git a/src/openbao/README.md b/src/openbao/README.md index 8c3fb02..31d15fa 100644 --- a/src/openbao/README.md +++ b/src/openbao/README.md @@ -66,7 +66,10 @@ hook reads to `/usr/local/share/openbao-feature/options.env`. Owned by the Feature (NFR-1), like `gh` and `az`: - `containerEnv` sets `OPENBAO_STATE_DIR=/var/lib/devcontainer-features/openbao`. -- `mounts` declares the named volume `openbao-config-${devcontainerId}` on that path. +- `mounts` declares the named volume `openbao-config-${devcontainerId}` on that path. The name + follows the repository's convention (`-config-${devcontainerId}`, as `gh-config-` and + `az-config-`) and supersedes `openbao-${devcontainerId}`, which the proposal + `docs/proposals/secrets-master-split.md` (section 3) wrote before it checked the convention. - 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, then makes `~/.vault-token`, where `bao login` keeps its token, a link to `$OPENBAO_STATE_DIR/token`. **A rebuild keeps the @@ -100,7 +103,8 @@ get` for people; `dcf-secret` is for callers. ```text dcf-secret get [--field ] the value on stdout, nothing else (field default: value) -dcf-secret put --field the value on stdin, never an argument +dcf-secret put --field [--replace] + the value on stdin, never an argument dcf-secret status [] one word on stdout, the fix on stderr ``` @@ -109,9 +113,14 @@ dcf-secret status [] one word on stdout, the fix on stderr `dcf`; `DCF_SECRET_MOUNT` names another one (a client of a shared server uses its own). - **Who asks**: the token `bao` uses (`BAO_TOKEN`, else the saved login). `DCF_SECRET_AS` (logging in as an AppRole) is not in this version: setting it exits 2 rather than asking as someone else. -- **`put` replaces the whole secret** with the one field (it is `bao kv put`); it refuses a - terminal on stdin. **`get` writes the value only to stdout**: it is never put in a file or a - variable. +- **`put` merges by default**: it writes only the given field and keeps the secret's other fields + (`bao kv patch`); when the secret does not exist yet it creates it (`bao kv put -cas=0`, which + fails if the secret appeared in the meantime, and then one more patch merges into it). + `--replace` makes the secret exactly this one field (`bao kv put`) and drops the others. A token + that may not patch is refused (exit 13, naming `--replace`), never silently turned into a + replace. `put` refuses a terminal on stdin and holds the value in memory only (never a file, + never an argument; a merge may offer it to `bao` twice). **`get` writes the value only to + stdout**: it is never put in a file or a variable. - **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. @@ -132,9 +141,11 @@ The code is worked out from `bao`'s own signals, never from the words of a messa call it asks `bao status -format=json` (exit 2 is sealed, the JSON says `initialized`; anything else is unreachable), then `bao token capabilities` on `/data/` (a refused call is no valid token; no `read` or `root` for a get, no `create`, `update` or `root` for a put, is denied), -and a get that gets this far is a missing secret. A token therefore needs the default policy (every -token has it unless removed) besides `read` on `/data/` to get and `create` and -`update` to put. It speaks only `bao`, so it works the same against a Vault server. +and a get that gets this far is a missing secret. A `put` that fails the same way checks for `patch` +(a merge needs it), then for `create` (a secret that does not exist yet needs it). A token +therefore needs the default policy (every token has it unless removed) besides `read` on +`/data/` to get, `patch` (and `create` for a new secret) to put, and `create` and +`update` for `put --replace`. It speaks only `bao`, so it works the same against a Vault server. A caller without this Feature finds no `dcf-secret` and says so; the Features stay independent at build time. @@ -220,6 +231,8 @@ itself. installed unchecked. - Logged out after a rebuild: the volume is gone (a container-engine storage reset, or another 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. - `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/`). @@ -264,7 +277,7 @@ Nothing is removed; `mode` stays `client`. #### Added - **Your `bao login` now survives a rebuild.** The Feature owns a named volume (`openbao-config-`, at `/var/lib/devcontainer-features/openbao`, named by `OPENBAO_STATE_DIR`) and a post-create hook, `openbao-setup` (also `task openbao:setup`), that links `~/.vault-token` into it, the way `gh` and `az` keep their logins. **You must act once:** rebuild the container and run `bao login` again, because the volume starts empty. Nothing to configure; a repo that wants the state on the host disk bind-mounts the same target. A `BAO_TOKEN` in the environment still takes precedence over the saved login. -- **`dcf-secret`**, a command for scripts and other Features to read and write a secret without knowing where the server runs: `dcf-secret get `, `dcf-secret put --field ` (the value on stdin) and `dcf-secret status`. It fails with fixed exit codes (10 unreachable, 11 sealed, 12 not logged in, 13 denied, 14 no such secret), each with one line saying what to do, and never falls back to a key on disk. Secrets live in a KV v2 mount named `dcf` (`DCF_SECRET_MOUNT` changes it). Nothing to do unless you want it; `task openbao:kv:get` is unchanged. +- **`dcf-secret`**, a command for scripts and other Features to read and write a secret without knowing where the server runs: `dcf-secret get `, `dcf-secret put --field ` (the value on stdin) and `dcf-secret status`. `put` writes only that field and keeps the secret's other fields (it creates the secret when it does not exist); `--replace` makes the secret exactly that one field. A token whose policy lacks the `patch` capability is refused with exit 13 rather than silently replacing the secret. It fails with fixed exit codes (10 unreachable, 11 sealed, 12 not logged in, 13 denied, 14 no such secret), each with one line saying what to do, and never falls back to a key on disk. Secrets live in a KV v2 mount named `dcf` (`DCF_SECRET_MOUNT` changes it). Nothing to do unless you want it; `task openbao:kv:get` is unchanged. - **The `mode` option**, with the one value `client` (the default, what the Feature always did: the CLI against a server outside the container). A later version adds `server`. `OPENBAO_SETUP_MODE` in `containerEnv` overrides it per container. Nothing to do. - `task openbao:setup`: re-runs the post-create hook. diff --git a/src/openbao/dcf-secret.sh b/src/openbao/dcf-secret.sh index a06fd01..ea3ead3 100644 --- a/src/openbao/dcf-secret.sh +++ b/src/openbao/dcf-secret.sh @@ -2,7 +2,9 @@ # dcf-secret: the one way a Feature or a script of this collection asks OpenBao for a secret. # # dcf-secret get [--field ] the value on stdout, nothing else -# dcf-secret put --field the value on stdin (never an argument) +# dcf-secret put --field [--replace] +# the value on stdin (never an argument); keeps the +# secret's other fields, --replace drops them # dcf-secret status [] one word: ready | sealed | unreachable | # logged-out | denied | not-initialised # @@ -15,7 +17,7 @@ # Exit codes, the same for every caller, each with one plain line on stderr naming the fix: # 0 done 10 the server is unreachable 11 the server is sealed (or not initialised) # 12 no token, or it expired 13 the token may not use this name 14 no such secret -# 1 bao failed for a reason none of the signals explain (bao's own message follows) +# 1 bao failed for a reason none of the signals explain (a put; bao's own message follows) # 2 a wrong call (usage) set -u @@ -33,7 +35,9 @@ die() { # usage() { cat >&2 <<'EOF' usage: dcf-secret get [--field ] print the value (field default: value) - dcf-secret put --field read the value from stdin + dcf-secret put --field [--replace] + read the value from stdin; the secret's other + fields are kept unless --replace dcf-secret status [] print ready, sealed, unreachable, logged-out, denied or not-initialised EOF @@ -91,12 +95,15 @@ state_exit() { # exit with the line for a server that is not ready; no-op when i caps_of() { # -> sets caps, returns bao's exit code caps="$(bao token capabilities "$1" 2>/dev/null)" } -can() { # : does $caps allow it - if [ "$1" = read ]; then - grep -Eq '(^|[^a-z])(read|root)([^a-z]|$)' <<<"$caps" - else - grep -Eq '(^|[^a-z])(create|update|root)([^a-z]|$)' <<<"$caps" - fi +can() { # : does $caps allow it (write: create or update) + local words + case "$1" in + read) words='read' ;; + patch) words='patch' ;; + create) words='create' ;; + *) words='create|update' ;; + esac + grep -Eq "(^|[^a-z])($words|root)([^a-z]|\$)" <<<"$caps" } # After a failed call: the server's state, then the token, then what the token may do. @@ -136,11 +143,12 @@ cmd_get() { } cmd_put() { - local name="" field="" + local name="" field="" replace=0 while [ $# -gt 0 ]; do case "$1" in --field) [ $# -ge 2 ] || usage; field="$2"; shift 2 ;; --field=*) field="${1#--field=}"; shift ;; + --replace) replace=1; shift ;; -*) usage ;; *) [ -z "$name" ] || usage; name="$1"; shift ;; esac @@ -150,13 +158,50 @@ cmd_put() { field_ok "$field" || die 2 "'$field' is not a field name" [ ! -t 0 ] || die 2 "the value is read from stdin: pipe it in (never as an argument)" - # `field=-` makes bao read the value from our stdin. It replaces the whole secret with this one - # field (kv put); bao's table of metadata on stdout is not ours to print. - local err rc - err="$(bao kv put -mount="$mount" "$name" "$field=-" 2>&1 >/dev/null)" + # The value is held in memory only (never a file, never an argument) because a merge may need + # to offer it to bao twice. `x` keeps trailing newlines through the command substitution. + local value err rc + value="$(cat; printf x)" + value="${value%x}" + # shellcheck disable=SC2069 # stderr is wanted, bao's stdout (a table of metadata) is not + send() { printf '%s' "$value" | bao kv "$@" 2>&1 >/dev/null; } + + if [ "$replace" -eq 1 ]; then + # --replace: the secret becomes this one field (bao kv put). + err="$(send put -mount="$mount" "$name" "$field=-")" + rc=$? + [ "$rc" -eq 0 ] && exit 0 + diagnose write "$name" "$err" + fi + + # The default merges: patch writes this field and keeps the others. A patch fails on a secret + # that does not exist yet, and on a token without the patch capability: the capabilities say + # which, never the words of the error. + err="$(send patch -mount="$mount" "$name" "$field=-")" rc=$? [ "$rc" -eq 0 ] && exit 0 - diagnose write "$name" "$err" + server_state + state_exit + caps_of "$(api_path "$name")" || { fix_login; exit 12; } + if ! can patch; then + say "the token may not patch '$name' at $mount, so a field cannot be merged: its policy needs the patch capability, or write the whole secret with --replace" + exit 13 + fi + if ! can create; then + say "the token may not create '$name' at $mount (it does not exist yet, so there is nothing to patch): its policy has to allow it (ask the server's owner)" + exit 13 + fi + # Create it, only if it is still absent (-cas=0): a secret that appeared since the patch makes + # this fail, and then one more patch merges into it. + err="$(send put -cas=0 -mount="$mount" "$name" "$field=-")" + rc=$? + [ "$rc" -eq 0 ] && exit 0 + err="$(send patch -mount="$mount" "$name" "$field=-")" + rc=$? + [ "$rc" -eq 0 ] && exit 0 + say "bao could not write '$name' at $mount for a reason its signals do not explain:" + printf '%s\n' "$err" >&2 + exit 1 } cmd_status() { diff --git a/test/openbao/dev_server.sh b/test/openbao/dev_server.sh index 05e5170..2cbd4f5 100644 --- a/test/openbao/dev_server.sh +++ b/test/openbao/dev_server.sh @@ -37,6 +37,16 @@ rc=0; printf '%s' 'abc def' | dcf-secret put ssh/key --field private_key || rc=$ check "put is exit 0" [ "$rc" = 0 ] out="$(dcf-secret get ssh/key --field private_key)" check "get returns what put stored, alone" [ "$out" = 'abc def' ] +# Two fields under one name survive two puts (merge is the default); --replace drops the others. +printf '%s' 'the-public-part' | dcf-secret put ssh/key --field public_key +check "a second put keeps the first field" [ "$(dcf-secret get ssh/key --field private_key)" = 'abc def' ] +check "and stores the second" [ "$(dcf-secret get ssh/key --field public_key)" = the-public-part ] +printf '%s' 'abc def2' | dcf-secret put ssh/key --field private_key +check "a put of an existing field replaces only that field" [ "$(dcf-secret get ssh/key --field private_key):$(dcf-secret get ssh/key --field public_key)" = 'abc def2:the-public-part' ] +printf '%s' 'only' | dcf-secret put ssh/key --field private_key --replace +check "--replace keeps the one field" [ "$(dcf-secret get ssh/key --field private_key)" = only ] +rc=0; dcf-secret get ssh/key --field public_key >/dev/null 2>&1 || rc=$? +check "--replace dropped the other field (exit 14)" [ "$rc" = 14 ] rc=0; dcf-secret get ssh/missing >/dev/null 2>&1 || rc=$? check "a missing secret is exit 14" [ "$rc" = 14 ] rc=0; dcf-secret get ssh/key --field nofield >/dev/null 2>&1 || rc=$? @@ -51,6 +61,12 @@ check "a token without the policy is exit 13" [ "$rc" = 13 ] check "the token may read its own name" [ "$(BAO_TOKEN="$limited" dcf-secret get ok)" = fine ] rc=0; printf 'x' | BAO_TOKEN="$limited" dcf-secret put ok --field value >/dev/null 2>&1 || rc=$? check "a read-only token may not put (exit 13)" [ "$rc" = 13 ] +printf 'path "dcf/data/cu" { capabilities = ["create", "update"] }\n' | bao policy write dcf-cu - >/dev/null +cu="$(bao token create -policy=dcf-cu -field=token)" +rc=0; printf 'x' | BAO_TOKEN="$cu" dcf-secret put cu --field value >/dev/null 2>&1 || rc=$? +check "a token without the patch capability may not merge (exit 13, nothing written)" [ "$rc" = 13 ] +rc=0; printf 'x' | BAO_TOKEN="$cu" dcf-secret put cu --field value --replace >/dev/null 2>&1 || rc=$? +check "the same token may --replace" [ "$rc" = 0 ] check "status denied for a name it may not read" [ "$(BAO_TOKEN="$limited" dcf-secret status ssh/key 2>/dev/null || true)" = denied ] rc=0; BAO_TOKEN=not-a-token dcf-secret get ok >/dev/null 2>&1 || rc=$? diff --git a/test/openbao/unit/dcf_secret_test.sh b/test/openbao/unit/dcf_secret_test.sh index 4cc3dd9..0e5bd6b 100644 --- a/test/openbao/unit/dcf_secret_test.sh +++ b/test/openbao/unit/dcf_secret_test.sh @@ -31,11 +31,21 @@ case "$1 $2" in if [ "${STUB_KV_RC:-0}" -eq 0 ]; then printf '%s' "${STUB_VALUE-s3cret}"; exit 0; fi echo "Error reading dcf/data/x: stub failure" >&2 exit "$STUB_KV_RC" ;; - "kv put") - cat > "$STUB_DIR/put-stdin" - [ "${STUB_PUT_RC:-0}" -eq 0 ] && { echo "Key Value"; exit 0; } + "kv put" | "kv patch") + # The value each call received on stdin is kept per call (stdin., n counting the kv put and kv patch calls); STUB_PATCH_RCS is a + # list of exit codes, one per patch call (the last one repeats), for the race. + n="$(grep -cE '^kv (put|patch)' "$STUB_LOG")" + cat > "$STUB_DIR/stdin.$n" + if [ "$2" = patch ]; then + k="$(grep -c '^kv patch' "$STUB_LOG")" + rcs=(${STUB_PATCH_RCS:-0}) + rc="${rcs[$((k - 1))]:-${rcs[${#rcs[@]} - 1]}}" + else + rc="${STUB_PUT_RC:-0}" + fi + [ "$rc" -eq 0 ] && { echo "Key Value"; exit 0; } echo "Error writing: stub failure" >&2 - exit "$STUB_PUT_RC" ;; + exit "$rc" ;; "token capabilities") [ "${STUB_CAPS_RC:-0}" -eq 0 ] || { echo "permission denied" >&2; exit 2; } echo "${STUB_CAPS-read}" @@ -71,7 +81,7 @@ run() { while [ "$1" != -- ]; do envs+=("$1"); shift; done shift : > "$LOG" - rm -f "$T/put-stdin" + rm -f "$T"/stdin.* out="$(printf '%s' "$INPUT" | env -i PATH="$T/bin:/usr/bin:/bin" HOME="$T/home" STUB_LOG="$LOG" STUB_DIR="$T" \ OPENBAO_STATE_DIR="$T/state" DCF_SECRET_X="from-the-environment" "${envs[@]}" bash "$script" "$@" 2>"$T/err")" rc=$? @@ -166,29 +176,83 @@ check "--field without a value is usage" 2 "$rc" run -- check "no command is usage" 2 "$rc" -# --- put +# --- put: merge by default (kv patch), create when absent (kv put -cas=0), --replace +calls() { cat "$LOG"; } +kvcalls() { grep -E '^kv (put|patch)' "$LOG"; } +stdin_of() { cat "$T/stdin.$1"; } # the value the th kv put or kv patch call received INPUT="topsecret" run -- put ssh/key --field private_key -check "put: exit 0" 0 "$rc" +check "put merges: exit 0" 0 "$rc" check "put: nothing on stdout (bao's table is not ours)" "" "$out" -check "put: the value reached bao on stdin" "topsecret" "$(cat "$T/put-stdin")" -has "put: field=- so bao reads stdin" "kv put -mount=dcf ssh/key private_key=-" "$(cat "$LOG")" -lacks "put: the value is in no argument" "topsecret" "$(cat "$LOG")" +has "put: kv patch with field=- so bao reads stdin" "kv patch -mount=dcf ssh/key private_key=-" "$(calls)" +lacks "put merging never calls kv put" "kv put" "$(calls)" +check "put: the value reached bao on stdin" "topsecret" "$(stdin_of 1)" +lacks "put: the value is in no argument" "topsecret" "$(calls)" +check "put merging: one call to bao" 1 "$(lines "$(calls)")" INPUT="x" run -- put ssh/key check "put without --field is usage" 2 "$rc" -INPUT="x" run STUB_PUT_RC=2 STUB_STATUS_RC=2 -- put ssh/key --field f -check "put: sealed is 11" 11 "$rc" -INPUT="x" run STUB_PUT_RC=2 STUB_STATUS_RC=1 -- put ssh/key --field f -check "put: unreachable is 10" 10 "$rc" -INPUT="x" run STUB_PUT_RC=2 STUB_CAPS_RC=2 -- put ssh/key --field f -check "put: no token is 12" 12 "$rc" -INPUT="x" run STUB_PUT_RC=2 STUB_CAPS=read -- put ssh/key --field f -check "put: read-only token is 13" 13 "$rc" -has "put: 13 names the write" "may not write" "$err" -INPUT="x" run STUB_PUT_RC=2 STUB_CAPS="create, update" -- put ssh/key --field f -check "put: allowed but failed is 1, never 14" 1 "$rc" +INPUT="x" run -- put ssh/key --field f --bogus +check "put with an unknown flag is usage" 2 "$rc" + +# the secret does not exist yet: patch fails, the token may create, so it is created (-cas=0) +INPUT="topsecret" run STUB_PATCH_RCS=2 STUB_CAPS="create, patch, read, update" -- put ssh/key --field private_key +check "put creates an absent secret: exit 0" 0 "$rc" +has "put: creates with -cas=0, only if still absent" "kv put -cas=0 -mount=dcf ssh/key private_key=-" "$(calls)" +check "put: the same value reached the create (kept in memory for the second offer)" "topsecret" "$(stdin_of 2)" +lacks "put create: the value is in no argument" "topsecret" "$(calls)" +printf -v nl 'line one +line two + +' +INPUT="$nl" run STUB_PATCH_RCS=2 STUB_CAPS="create, patch" -- put ssh/key --field f +check "put: a multi-line value with trailing newlines is kept byte for byte" "yes" "$(printf '%s' "$nl" | cmp -s - "$T/stdin.2" && echo yes)" + +# the secret appeared between the patch and the create: the create fails, one more patch merges +INPUT="v" run STUB_PATCH_RCS="2 0" STUB_PUT_RC=2 STUB_CAPS="create, patch" -- put ssh/key --field f +check "put race: exit 0 after retrying as a patch" 0 "$rc" +check "put race: patch, create, patch" "kv patch|kv put|kv patch" "$(kvcalls | awk '{printf "%s%s %s", (NR>1?"|":""), $1, $2}')" +check "put race: the retry carried the value" "v" "$(stdin_of 3)" +INPUT="x" run STUB_PATCH_RCS=2 STUB_PUT_RC=2 STUB_CAPS="create, patch" -- put ssh/key --field f +check "put: patch, create and the retry all failing is 1, never 14" 1 "$rc" has "put: 1 carries bao's own message" "stub failure" "$err" -INPUT="x" run STUB_PUT_RC=2 STUB_CAPS=deny -- put ssh/key --field f -check "put: 14 does not apply to a put" 13 "$rc" +check "put: the retry is only one" 3 "$(lines "$(kvcalls)")" + +# --replace: one field, kv put, no patch +INPUT="topsecret" run -- put ssh/key --field private_key --replace +check "put --replace: exit 0" 0 "$rc" +has "put --replace: kv put of the one field" "kv put -mount=dcf ssh/key private_key=-" "$(calls)" +lacks "put --replace: no -cas" "-cas" "$(calls)" +lacks "put --replace: no patch" "kv patch" "$(calls)" +check "put --replace: the value reached bao" "topsecret" "$(stdin_of 1)" + +# patch denied while put is allowed: never a silent replace +INPUT="x" run STUB_PATCH_RCS=2 STUB_CAPS="create, update" -- put ssh/key --field f +check "put: no patch capability is 13" 13 "$rc" +has "put: 13 points at --replace" "--replace" "$err" +has "put: 13 names the patch capability" "patch" "$err" +lacks "put: no kv put when only the patch was denied (nothing replaced)" "kv put" "$(calls)" +INPUT="x" run STUB_PATCH_RCS=2 STUB_CAPS="create, update" -- put ssh/key --field f --replace +check "the same token with --replace works" 0 "$rc" +INPUT="x" run STUB_PATCH_RCS=2 STUB_CAPS="patch, read" -- put ssh/key --field f +check "put: absent secret and no create capability is 13" 13 "$rc" +has "put: 13 names the creation" "may not create" "$err" +lacks "put: nothing written" "kv put" "$(calls)" + +# the server and the token, for a merge and for --replace +for flag in "" "--replace"; do + INPUT="x" run STUB_PATCH_RCS=2 STUB_PUT_RC=2 STUB_STATUS_RC=2 -- put ssh/key --field f $flag + check "put $flag: sealed is 11" 11 "$rc" + INPUT="x" run STUB_PATCH_RCS=2 STUB_PUT_RC=2 STUB_STATUS_RC=1 -- put ssh/key --field f $flag + check "put $flag: unreachable is 10" 10 "$rc" + INPUT="x" run STUB_PATCH_RCS=2 STUB_PUT_RC=2 STUB_CAPS_RC=2 -- put ssh/key --field f $flag + check "put $flag: no token is 12" 12 "$rc" + INPUT="x" run STUB_PATCH_RCS=2 STUB_PUT_RC=2 STUB_CAPS=read -- put ssh/key --field f $flag + check "put $flag: a read-only token is 13" 13 "$rc" + INPUT="x" run STUB_PATCH_RCS=2 STUB_PUT_RC=2 STUB_CAPS=deny -- put ssh/key --field f $flag + check "put $flag: 14 does not apply to a put (13)" 13 "$rc" +done +INPUT="x" run STUB_PUT_RC=2 STUB_CAPS="create, update" -- put ssh/key --field f --replace +check "put --replace: allowed but failed is 1" 1 "$rc" +has "put --replace: 1 carries bao's own message" "stub failure" "$err" # a terminal on stdin is refused: run under a pseudo-terminal when script(1) is there if command -v script >/dev/null 2>&1; then tty_out="$(script -qec "env PATH=$T/bin:/usr/bin:/bin STUB_LOG=$LOG STUB_DIR=$T bash $script put ssh/key --field f" /dev/null 2>&1 Date: Tue, 3 Nov 2026 21:01:50 +0000 Subject: [PATCH 4/9] fix(openbao): keep the login through bao's token helper, not a link (openbao-state-build) CI on #162 showed a real bao login replaces ~/.vault-token (the internal helper writes a temp file and renames it), so a link into the volume does not hold. The Feature now uses the CLI's own mechanism: BAO_CONFIG_PATH in containerEnv names a config with token_helper = /usr/local/bin/openbao-token-helper, which keeps the token at $OPENBAO_STATE_DIR/token (mode 600, written atomically; erase on revoke). openbao-setup drops the link and moves a token left in ~/.vault-token into the state dir once. Unit tests for the helper and the hook, and the dev_server scenario (login, use, bao token revoke -self), are updated. Version stays 1.1.0. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DPsHU61VPD8TZwHanFM7zj Signed-off-by: devhost agent --- Taskfile.yml | 3 +- src/openbao/CHANGELOG.md | 2 +- src/openbao/NOTES.md | 52 +++++++++------ src/openbao/README.md | 56 ++++++++++------- src/openbao/devcontainer-feature.json | 5 +- src/openbao/install.sh | 7 +++ src/openbao/openbao-setup.sh | 45 +++++++------ src/openbao/openbao-token-helper.sh | 33 ++++++++++ src/openbao/taskfile.yml | 2 +- test/openbao/dev_server.sh | 22 +++++-- test/openbao/pinned_version.sh | 2 +- test/openbao/test.sh | 8 ++- test/openbao/unit/setup_test.sh | 82 +++++++++++++----------- test/openbao/unit/token_helper_test.sh | 87 ++++++++++++++++++++++++++ 14 files changed, 296 insertions(+), 110 deletions(-) create mode 100644 src/openbao/openbao-token-helper.sh create mode 100644 test/openbao/unit/token_helper_test.sh diff --git a/Taskfile.yml b/Taskfile.yml index 3284b15..e4ad31c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -241,10 +241,11 @@ 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, ~/.vault-token linked into it (made, kept, a real file moved in, a link elsewhere left alone, 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. That the real bao gives the signals the stub plays is the container test test/openbao/dev_server.sh (CI). Part of lint. Needs nothing beyond bash and grep (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, 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. That the real bao gives the signals the stub plays is the container test test/openbao/dev_server.sh (CI). Part of lint. Needs nothing beyond bash and grep (script(1) for one check, 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 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 d5d8886..cf5fe9c 100644 --- a/src/openbao/CHANGELOG.md +++ b/src/openbao/CHANGELOG.md @@ -12,7 +12,7 @@ Entries from 1.0.0 to 1.0.3 were generated from the commit history (`.github/scr ### Added -- **Your `bao login` now survives a rebuild.** The Feature owns a named volume (`openbao-config-`, at `/var/lib/devcontainer-features/openbao`, named by `OPENBAO_STATE_DIR`) and a post-create hook, `openbao-setup` (also `task openbao:setup`), that links `~/.vault-token` into it, the way `gh` and `az` keep their logins. **You must act once:** rebuild the container and run `bao login` again, because the volume starts empty. Nothing to configure; a repo that wants the state on the host disk bind-mounts the same target. A `BAO_TOKEN` in the environment still takes precedence over the saved login. +- **Your `bao login` now survives a rebuild.** The Feature owns a named volume (`openbao-config-`, at `/var/lib/devcontainer-features/openbao`, named by `OPENBAO_STATE_DIR`) and points `bao` at a token helper (`BAO_CONFIG_PATH`, set in `containerEnv`, so it holds for `devcontainer exec` too) that keeps the token there, as `gh` and `az` keep their logins. The post-create hook, `openbao-setup` (also `task openbao:setup`), makes the folder usable and moves a token left in `~/.vault-token` into it once. **You must act once:** rebuild the container and run `bao login` again, because the volume starts empty. A repo that sets its own `BAO_CONFIG_PATH` replaces the Feature's config (the hook says so); add `token_helper = "/usr/local/bin/openbao-token-helper"` to it. A repo that wants the state on the host disk bind-mounts the same target. A `BAO_TOKEN` in the environment still takes precedence over the saved login. - **`dcf-secret`**, a command for scripts and other Features to read and write a secret without knowing where the server runs: `dcf-secret get `, `dcf-secret put --field ` (the value on stdin) and `dcf-secret status`. `put` writes only that field and keeps the secret's other fields (it creates the secret when it does not exist); `--replace` makes the secret exactly that one field. A token whose policy lacks the `patch` capability is refused with exit 13 rather than silently replacing the secret. It fails with fixed exit codes (10 unreachable, 11 sealed, 12 not logged in, 13 denied, 14 no such secret), each with one line saying what to do, and never falls back to a key on disk. Secrets live in a KV v2 mount named `dcf` (`DCF_SECRET_MOUNT` changes it). Nothing to do unless you want it; `task openbao:kv:get` is unchanged. - **The `mode` option**, with the one value `client` (the default, what the Feature always did: the CLI against a server outside the container). A later version adds `server`. `OPENBAO_SETUP_MODE` in `containerEnv` overrides it per container. Nothing to do. - `task openbao:setup`: re-runs the post-create hook. diff --git a/src/openbao/NOTES.md b/src/openbao/NOTES.md index fc2ed55..52208e9 100644 --- a/src/openbao/NOTES.md +++ b/src/openbao/NOTES.md @@ -7,7 +7,7 @@ checks the tarball with `sha256sum -c`, and installs the binary only if the check passes. It runs `bao version` at the end, so the version is in the build log. It installs no OpenBao server and no `vault` command. -Besides the binary, `install.sh` puts three scripts on PATH (nothing is downloaded for them): +Besides the binary, `install.sh` puts four scripts on PATH (nothing is downloaded for them): `dcf-secret` (the interface callers use to read and write secrets, see below), `openbao-setup` (the post-create hook, see State) and `dcf-persist` (a verbatim copy of `src/common/dcf-persist.sh`, which makes the state directory usable and says what backs it). It writes the option defaults the @@ -49,30 +49,44 @@ Owned by the Feature (NFR-1), like `gh` and `az`: follows the repository's convention (`-config-${devcontainerId}`, as `gh-config-` and `az-config-`) and supersedes `openbao-${devcontainerId}`, which the proposal `docs/proposals/secrets-master-split.md` (section 3) wrote before it checked the convention. +- `containerEnv` also sets `BAO_CONFIG_PATH=/usr/local/share/openbao-feature/bao-config.hcl`, a + config whose one line, `token_helper = "/usr/local/bin/openbao-token-helper"`, makes `bao` keep + its login token in the volume, at `$OPENBAO_STATE_DIR/token` (mode 600). Because it is + `containerEnv`, it holds for every process, `devcontainer exec` and agents' shells included. + **A rebuild keeps the login.** - 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, then makes `~/.vault-token`, - where `bao login` keeps its token, a link to `$OPENBAO_STATE_DIR/token`. **A rebuild keeps the - login.** The hook never contacts the server and never fails the rest of post-create. - -`bao` has no variable for the token's folder (`gh` has `GH_CONFIG_DIR`), which is why the link -exists and `OPENBAO_STATE_DIR` is this Feature's own name. A repo that wants the state on the host -disk bind-mounts the same target and its mount wins (the README's "How the pieces fit"); a repo can -point `OPENBAO_STATE_DIR` elsewhere in its own `containerEnv`. + 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 + never fails the rest of post-create. + +How `bao` finds the helper, confirmed in OpenBao's source (`internal/command/config` and +`internal/command/token`, v2.1.1 and v2.7.1): the CLI reads an HCL config at `~/.bao`, or at the +path in `BAO_CONFIG_PATH`, and a `token_helper` in it names an absolute path to an executable +that `bao` runs as ` get`, `store` (token on stdin) or `erase`. Without one, `bao` uses +its internal helper, which writes `~/.vault-token` through a temporary file and a rename: a link +to the volume at that path is replaced by the first `bao login`, which is why the Feature does +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`. -- **`bao token revoke -self`** removes the link (the CLI deletes `~/.vault-token`, not what it - points at). The next `bao login` writes a real file; the next run of the hook moves it into the - volume and links it again, so the newer login wins over the revoked one left in the volume. +- **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 + that wants the state on the host disk bind-mounts the volume's target (the README's "How the + pieces fit"), or points `OPENBAO_STATE_DIR` elsewhere in its own `containerEnv`. +- **`bao token revoke -self`** and `bao logout` run the helper's `erase`: the token file is + removed and the next `dcf-secret` call says to log in. - **`BAO_TOKEN`** in the environment takes precedence over the saved login (the hook says so). It is a way to give a container a token without a login, for a service that cannot run `bao login`: set it in `containerEnv` from a host variable (`"BAO_TOKEN": "${localEnv:BAO_TOKEN}"`) or an env file the repo keeps out of git. A stale value there makes `permission denied` appear even right after a fresh `bao login`; unset it or update it. Never commit it. -- Not offered: a bind mount of the single file `~/.vault-token` from the host (a single-file mount - breaks when `bao login` replaces the file rather than writing into it). +- OpenBao 2.6.0 and later also read `BAO_TOKEN_PATH` (the internal helper's file, still written by + a rename); the Feature uses the token helper because it works for every version `version` can pin. Upgrading from 1.0.x: the volume starts empty, so log in once after the rebuild; a login made -before it was in `~/.vault-token` of the old container is gone with that container. +before it, in `~/.vault-token` of the old container, is gone with that container. ## `dcf-secret` @@ -160,7 +174,7 @@ The build log carries the first line (`openbao: downloading OpenBao v (openbao__linux_.tar.gz)...`, then the `bao version` output). A failed checksum stops the build with `sha256sum`'s `FAILED` line. The post-create log has the hook's lines: `openbao: /var/lib/devcontainer-features/openbao -(volume, writable)`, `openbao: mode client ...`, `openbao: login token linked into the state dir`, +(volume, writable)`, `openbao: mode client ...`, `openbao: token helper in effect`, the version, `BAO_ADDR`, and whether a login is saved. `volume` in the first line is what keeps the login across rebuilds; `image` means the mount is missing. @@ -192,7 +206,7 @@ registry, these appear under `openbao:`: `task openbao:kv:get KEY=secret/app -- -format=json`. - `openbao:setup`: re-run the post-create hook (`openbao-setup`): make the state directory usable, - link `~/.vault-token` into it, report 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. 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` @@ -215,8 +229,8 @@ itself. - Client mode only: there is no server, backup, onboarding or `DCF_SECRET_AS` in this version. - `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 link relies on `bao login` writing through the link to the volume; the container test - `dev_server` checks it against a real `bao`. +- The login is kept by a token helper `bao` runs as a shell command on every call that needs the + token; the container test `dev_server` checks login, use and revoke against a real `bao`. - `bao status` fails with a connection or certificate error: `BAO_ADDR` is unset or wrong, or the server's CA is not trusted in the image. - A `BAO_TOKEN` in the environment takes precedence over the saved login (see State). diff --git a/src/openbao/README.md b/src/openbao/README.md index 31d15fa..c0f963f 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 (~/.vault-token is linked into it by the post-create hook openbao-setup) so `bao login` survives rebuilds, 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). Set BAO_ADDR in containerEnv to point it at your server. ## Example Usage @@ -28,7 +28,7 @@ checks the tarball with `sha256sum -c`, and installs the binary only if the check passes. It runs `bao version` at the end, so the version is in the build log. It installs no OpenBao server and no `vault` command. -Besides the binary, `install.sh` puts three scripts on PATH (nothing is downloaded for them): +Besides the binary, `install.sh` puts four scripts on PATH (nothing is downloaded for them): `dcf-secret` (the interface callers use to read and write secrets, see below), `openbao-setup` (the post-create hook, see State) and `dcf-persist` (a verbatim copy of `src/common/dcf-persist.sh`, which makes the state directory usable and says what backs it). It writes the option defaults the @@ -70,30 +70,44 @@ Owned by the Feature (NFR-1), like `gh` and `az`: follows the repository's convention (`-config-${devcontainerId}`, as `gh-config-` and `az-config-`) and supersedes `openbao-${devcontainerId}`, which the proposal `docs/proposals/secrets-master-split.md` (section 3) wrote before it checked the convention. +- `containerEnv` also sets `BAO_CONFIG_PATH=/usr/local/share/openbao-feature/bao-config.hcl`, a + config whose one line, `token_helper = "/usr/local/bin/openbao-token-helper"`, makes `bao` keep + its login token in the volume, at `$OPENBAO_STATE_DIR/token` (mode 600). Because it is + `containerEnv`, it holds for every process, `devcontainer exec` and agents' shells included. + **A rebuild keeps the login.** - 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, then makes `~/.vault-token`, - where `bao login` keeps its token, a link to `$OPENBAO_STATE_DIR/token`. **A rebuild keeps the - login.** The hook never contacts the server and never fails the rest of post-create. - -`bao` has no variable for the token's folder (`gh` has `GH_CONFIG_DIR`), which is why the link -exists and `OPENBAO_STATE_DIR` is this Feature's own name. A repo that wants the state on the host -disk bind-mounts the same target and its mount wins (the README's "How the pieces fit"); a repo can -point `OPENBAO_STATE_DIR` elsewhere in its own `containerEnv`. + 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 + never fails the rest of post-create. + +How `bao` finds the helper, confirmed in OpenBao's source (`internal/command/config` and +`internal/command/token`, v2.1.1 and v2.7.1): the CLI reads an HCL config at `~/.bao`, or at the +path in `BAO_CONFIG_PATH`, and a `token_helper` in it names an absolute path to an executable +that `bao` runs as ` get`, `store` (token on stdin) or `erase`. Without one, `bao` uses +its internal helper, which writes `~/.vault-token` through a temporary file and a rename: a link +to the volume at that path is replaced by the first `bao login`, which is why the Feature does +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`. -- **`bao token revoke -self`** removes the link (the CLI deletes `~/.vault-token`, not what it - points at). The next `bao login` writes a real file; the next run of the hook moves it into the - volume and links it again, so the newer login wins over the revoked one left in the volume. +- **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 + that wants the state on the host disk bind-mounts the volume's target (the README's "How the + pieces fit"), or points `OPENBAO_STATE_DIR` elsewhere in its own `containerEnv`. +- **`bao token revoke -self`** and `bao logout` run the helper's `erase`: the token file is + removed and the next `dcf-secret` call says to log in. - **`BAO_TOKEN`** in the environment takes precedence over the saved login (the hook says so). It is a way to give a container a token without a login, for a service that cannot run `bao login`: set it in `containerEnv` from a host variable (`"BAO_TOKEN": "${localEnv:BAO_TOKEN}"`) or an env file the repo keeps out of git. A stale value there makes `permission denied` appear even right after a fresh `bao login`; unset it or update it. Never commit it. -- Not offered: a bind mount of the single file `~/.vault-token` from the host (a single-file mount - breaks when `bao login` replaces the file rather than writing into it). +- OpenBao 2.6.0 and later also read `BAO_TOKEN_PATH` (the internal helper's file, still written by + a rename); the Feature uses the token helper because it works for every version `version` can pin. Upgrading from 1.0.x: the volume starts empty, so log in once after the rebuild; a login made -before it was in `~/.vault-token` of the old container is gone with that container. +before it, in `~/.vault-token` of the old container, is gone with that container. ## `dcf-secret` @@ -181,7 +195,7 @@ The build log carries the first line (`openbao: downloading OpenBao v (openbao__linux_.tar.gz)...`, then the `bao version` output). A failed checksum stops the build with `sha256sum`'s `FAILED` line. The post-create log has the hook's lines: `openbao: /var/lib/devcontainer-features/openbao -(volume, writable)`, `openbao: mode client ...`, `openbao: login token linked into the state dir`, +(volume, writable)`, `openbao: mode client ...`, `openbao: token helper in effect`, the version, `BAO_ADDR`, and whether a login is saved. `volume` in the first line is what keeps the login across rebuilds; `image` means the mount is missing. @@ -213,7 +227,7 @@ registry, these appear under `openbao:`: `task openbao:kv:get KEY=secret/app -- -format=json`. - `openbao:setup`: re-run the post-create hook (`openbao-setup`): make the state directory usable, - link `~/.vault-token` into it, report 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. 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` @@ -236,8 +250,8 @@ itself. - Client mode only: there is no server, backup, onboarding or `DCF_SECRET_AS` in this version. - `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 link relies on `bao login` writing through the link to the volume; the container test - `dev_server` checks it against a real `bao`. +- The login is kept by a token helper `bao` runs as a shell command on every call that needs the + token; the container test `dev_server` checks login, use and revoke against a real `bao`. - `bao status` fails with a connection or certificate error: `BAO_ADDR` is unset or wrong, or the server's CA is not trusted in the image. - A `BAO_TOKEN` in the environment takes precedence over the saved login (see State). @@ -276,7 +290,7 @@ Nothing is removed; `mode` stays `client`. #### Added -- **Your `bao login` now survives a rebuild.** The Feature owns a named volume (`openbao-config-`, at `/var/lib/devcontainer-features/openbao`, named by `OPENBAO_STATE_DIR`) and a post-create hook, `openbao-setup` (also `task openbao:setup`), that links `~/.vault-token` into it, the way `gh` and `az` keep their logins. **You must act once:** rebuild the container and run `bao login` again, because the volume starts empty. Nothing to configure; a repo that wants the state on the host disk bind-mounts the same target. A `BAO_TOKEN` in the environment still takes precedence over the saved login. +- **Your `bao login` now survives a rebuild.** The Feature owns a named volume (`openbao-config-`, at `/var/lib/devcontainer-features/openbao`, named by `OPENBAO_STATE_DIR`) and points `bao` at a token helper (`BAO_CONFIG_PATH`, set in `containerEnv`, so it holds for `devcontainer exec` too) that keeps the token there, as `gh` and `az` keep their logins. The post-create hook, `openbao-setup` (also `task openbao:setup`), makes the folder usable and moves a token left in `~/.vault-token` into it once. **You must act once:** rebuild the container and run `bao login` again, because the volume starts empty. A repo that sets its own `BAO_CONFIG_PATH` replaces the Feature's config (the hook says so); add `token_helper = "/usr/local/bin/openbao-token-helper"` to it. A repo that wants the state on the host disk bind-mounts the same target. A `BAO_TOKEN` in the environment still takes precedence over the saved login. - **`dcf-secret`**, a command for scripts and other Features to read and write a secret without knowing where the server runs: `dcf-secret get `, `dcf-secret put --field ` (the value on stdin) and `dcf-secret status`. `put` writes only that field and keeps the secret's other fields (it creates the secret when it does not exist); `--replace` makes the secret exactly that one field. A token whose policy lacks the `patch` capability is refused with exit 13 rather than silently replacing the secret. It fails with fixed exit codes (10 unreachable, 11 sealed, 12 not logged in, 13 denied, 14 no such secret), each with one line saying what to do, and never falls back to a key on disk. Secrets live in a KV v2 mount named `dcf` (`DCF_SECRET_MOUNT` changes it). Nothing to do unless you want it; `task openbao:kv:get` is unchanged. - **The `mode` option**, with the one value `client` (the default, what the Feature always did: the CLI against a server outside the container). A later version adds `server`. `OPENBAO_SETUP_MODE` in `containerEnv` overrides it per container. Nothing to do. - `task openbao:setup`: re-runs the post-create hook. diff --git a/src/openbao/devcontainer-feature.json b/src/openbao/devcontainer-feature.json index 36b2af9..5fbe928 100644 --- a/src/openbao/devcontainer-feature.json +++ b/src/openbao/devcontainer-feature.json @@ -2,7 +2,7 @@ "id": "openbao", "version": "1.1.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 (~/.vault-token is linked into it by the post-create hook openbao-setup) so `bao login` survives rebuilds, 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). Set BAO_ADDR in containerEnv to point it at your server.", "documentationURL": "https://github.com/nabil-becker/devcontainer-features/tree/main/src/openbao", "licenseURL": "https://github.com/nabil-becker/devcontainer-features/blob/main/LICENSE", "options": { @@ -26,7 +26,8 @@ } }, "containerEnv": { - "OPENBAO_STATE_DIR": "/var/lib/devcontainer-features/openbao" + "OPENBAO_STATE_DIR": "/var/lib/devcontainer-features/openbao", + "BAO_CONFIG_PATH": "/usr/local/share/openbao-feature/bao-config.hcl" }, "mounts": [ { diff --git a/src/openbao/install.sh b/src/openbao/install.sh index 88e6ab6..cf9627d 100755 --- a/src/openbao/install.sh +++ b/src/openbao/install.sh @@ -92,6 +92,13 @@ 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/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, +# `devcontainer exec` included); the helper keeps the token in OPENBAO_STATE_DIR. +install -m 0755 "$feature_dir/openbao-token-helper.sh" /usr/local/bin/openbao-token-helper +printf '%s\n' '# Written by the openbao Feature: bao keeps its login token in the Feature'"'"'s volume.' \ + 'token_helper = "/usr/local/bin/openbao-token-helper"' > "$share/bao-config.hcl" +chmod 0644 "$share/bao-config.hcl" # ---------------------------------------------------- Feature Taskfile ---- # Drop this Feature's taskfile.yml where the go-task Feature's registry picks diff --git a/src/openbao/openbao-setup.sh b/src/openbao/openbao-setup.sh index 8228c0e..c2cd1a4 100644 --- a/src/openbao/openbao-setup.sh +++ b/src/openbao/openbao-setup.sh @@ -5,11 +5,13 @@ # # 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. -# 2. ~/.vault-token, where `bao login` keeps its token, is made a link to /token, so -# the login survives a rebuild. A real file there (a login made after `bao token revoke -self` -# removed the link, which is newer than the volume's copy) is moved into the volume first. +# 2. bao keeps its token there through the token helper the Feature configures (BAO_CONFIG_PATH in +# containerEnv, set for every process): the login survives a rebuild. A token already in +# ~/.vault-token (a login from before this Feature, or from a process without the variable) is +# moved into the state dir once, when the state dir has none and the helper is in effect. # 3. Says which mode it runs in and what is known without asking the server: the address, whether -# a login is saved. It never contacts the server (an unreachable one must not hold post-create). +# the token helper is in effect, whether a login is saved. It never contacts the server (an +# unreachable one must not hold post-create). set -u # Defaults baked by install.sh from the Feature options. OPENBAO_SETUP_* in the environment @@ -18,8 +20,9 @@ set -u [ -f /usr/local/share/openbao-feature/options.env ] && source /usr/local/share/openbao-feature/options.env mode="${OPENBAO_SETUP_MODE-${MODE:-client}}" dir="${OPENBAO_STATE_DIR:-/var/lib/devcontainer-features/openbao}" -link="$HOME/.vault-token" +old="$HOME/.vault-token" target="$dir/token" +feature_config=/usr/local/share/openbao-feature/bao-config.hcl dcf-persist --label openbao "$dir" @@ -28,25 +31,29 @@ case "$mode" in *) echo "openbao: mode '$mode' is not available in this version (client only); running as client" >&2 ;; esac +# Only with the token helper in effect is a token moved: otherwise bao still reads ~/.vault-token. +helper=0 +[ "${BAO_CONFIG_PATH:-}" = "$feature_config" ] && helper=1 + if [ ! -w "$dir" ]; then - echo "openbao: $dir is not writable, so the login is not linked into it and will not survive a rebuild" >&2 -elif [ -L "$link" ]; then - if [ "$(readlink "$link")" = "$target" ]; then - echo "openbao: login token linked into the state dir ($link, kept)" - else - echo "openbao: $link is a link to $(readlink "$link"), left alone; the login will not survive a rebuild" >&2 - fi -elif [ -e "$link" ]; then - if [ -f "$link" ] && mv -f "$link" "$target" 2>/dev/null && ln -s "$target" "$link" 2>/dev/null; then + echo "openbao: $dir is not writable, so the login cannot be kept in it and will not survive a rebuild" >&2 +elif [ "$helper" -eq 1 ] && [ -f "$old" ] && [ ! -L "$old" ]; then + if [ -e "$target" ]; then + echo "openbao: $old is left where it is: the state dir already has a login, and bao uses that one" + elif (umask 077; mv "$old" "$target") 2>/dev/null; then chmod 0600 "$target" 2>/dev/null || true - echo "openbao: login token moved into the state dir and linked ($link)" + echo "openbao: login token moved from $old into the state dir (once)" else - echo "openbao: could not move $link into $dir; the login will not survive a rebuild" >&2 + echo "openbao: could not move $old into $dir; that login will not survive a rebuild" >&2 fi -elif ln -s "$target" "$link" 2>/dev/null; then - echo "openbao: login token linked into the state dir ($link)" +fi + +if [ "$helper" -eq 1 ]; then + echo "openbao: token helper in effect (BAO_CONFIG_PATH), the login is kept in $dir" +elif [ -z "${BAO_CONFIG_PATH:-}" ]; then + echo "openbao: BAO_CONFIG_PATH is not set, so bao keeps its token in ~/.vault-token and a rebuild logs you out; this Feature sets it in containerEnv (a repo's own containerEnv may have removed it)" >&2 else - echo "openbao: could not link $link to $target (is \$HOME writable?)" >&2 + echo "openbao: BAO_CONFIG_PATH is $BAO_CONFIG_PATH, not this Feature's $feature_config; put token_helper = \"/usr/local/bin/openbao-token-helper\" in that file to keep the login in $dir" >&2 fi if ! command -v bao >/dev/null 2>&1; then diff --git a/src/openbao/openbao-token-helper.sh b/src/openbao/openbao-token-helper.sh new file mode 100644 index 0000000..b4964cd --- /dev/null +++ b/src/openbao/openbao-token-helper.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# openbao-token-helper: the token helper the openbao Feature gives `bao` (config `token_helper`, +# found through BAO_CONFIG_PATH, both set by the Feature). bao calls it with one argument: +# get print the stored token (nothing, exit 0, when there is none) +# store keep the token read from stdin +# erase forget the token (bao token revoke -self, bao logout) +# The token lives at $OPENBAO_STATE_DIR/token, mode 0600, in the Feature's volume, so a rebuild +# keeps the login. The internal helper bao uses otherwise writes ~/.vault-token by a rename, which +# no link can follow; this is the CLI's own way to keep the token elsewhere. It prints no token +# except on `get`, and writes the file atomically (a temp file in the same folder, then a rename). +dir="${OPENBAO_STATE_DIR:-/var/lib/devcontainer-features/openbao}" +file="$dir/token" + +case "${1:-}" in + get) + [ -f "$file" ] || exit 0 + cat "$file" + ;; + store) + umask 077 + mkdir -p "$dir" || exit 1 + tmp="$file.tmp.$$" + cat > "$tmp" || { rm -f "$tmp"; exit 1; } + mv -f "$tmp" "$file" || { rm -f "$tmp"; exit 1; } + ;; + erase) + rm -f "$file" || exit 1 + ;; + *) + echo "usage: openbao-token-helper get|store|erase (it is called by bao, see token_helper)" >&2 + exit 2 + ;; +esac diff --git a/src/openbao/taskfile.yml b/src/openbao/taskfile.yml index 6e41f3f..cd6ec85 100644 --- a/src/openbao/taskfile.yml +++ b/src/openbao/taskfile.yml @@ -32,6 +32,6 @@ tasks: setup: dir: '{{.USER_WORKING_DIR}}' - desc: 'Re-run the post-create setup: make OPENBAO_STATE_DIR usable by the current user, link ~/.vault-token into it, report 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 (idempotent; OPENBAO_SETUP_MODE overrides the Feature option).' cmds: - openbao-setup diff --git a/test/openbao/dev_server.sh b/test/openbao/dev_server.sh index 2cbd4f5..5979163 100644 --- a/test/openbao/dev_server.sh +++ b/test/openbao/dev_server.sh @@ -2,8 +2,8 @@ # Scenario 'dev_server' (see scenarios.json): dcf-secret against a real `bao server -dev` (in # memory, unsealed, KV v2 at `secret/`), remoteUser=vscode. This is what shows the real bao gives # the signals the Docker-free tests (test/openbao/unit) only stub: `bao status` exit codes, the -# capabilities words, kv get and put through stdin, and a login written through the link into -# the volume. A dev server is not the server mode of a later version; it exists for this test. +# capabilities words, kv get and put through stdin, and a login kept by the token helper in the +# volume. A dev server is not the server mode of a later version; it exists for this test. set -e # shellcheck disable=SC1091 # provided by the devcontainer CLI at test time source dev-container-features-test-lib @@ -23,7 +23,7 @@ done # checks do not depend on the order of the volume's own state. home="$(mktemp -d)" state="$(mktemp -d)" -as_nobody() { env -u BAO_TOKEN HOME="$home" OPENBAO_STATE_DIR="$state" "$@"; } +as_nobody() { env -u BAO_TOKEN HOME="$home" OPENBAO_STATE_DIR="$state" BAO_CONFIG_PATH=/usr/local/share/openbao-feature/bao-config.hcl "$@"; } rc=0; as_nobody dcf-secret get ssh/x >/dev/null 2>&1 || rc=$? check "no token is exit 12" [ "$rc" = 12 ] @@ -72,13 +72,23 @@ check "status denied for a name it may not read" [ "$(BAO_TOKEN="$limited" dcf-s rc=0; BAO_TOKEN=not-a-token dcf-secret get ok >/dev/null 2>&1 || rc=$? check "a bad token is exit 12" [ "$rc" = 12 ] -# bao login writes through the link into the volume, and the saved login is then used +# bao login goes through the token helper into the state dir (the helper is the CLI's own way to +# keep the token elsewhere: its internal one writes ~/.vault-token by a rename), and the saved +# login is then used; revoking it erases it. unset BAO_TOKEN +check "BAO_CONFIG_PATH is the Feature's, from containerEnv" [ "$BAO_CONFIG_PATH" = /usr/local/share/openbao-feature/bao-config.hcl ] as_nobody openbao-setup >/dev/null -as_nobody bao login -no-print "$root" >/dev/null -check "the home token file is still the link" [ "$(readlink "$home/.vault-token")" = "$state/token" ] +as_nobody bao login -no-print "$limited" >/dev/null check "the login landed in the state dir" [ -s "$state/token" ] +check "the token file is mode 600" [ "$(stat -c %a "$state/token")" = 600 ] +check "nothing was written to the home folder" [ -z "$(ls -A "$home")" ] +check "the state dir holds the token bao was given" [ "$(cat "$state/token")" = "$limited" ] check "dcf-secret uses the saved login" [ "$(as_nobody dcf-secret get ok)" = fine ] +check "status is ready with the saved login" [ "$(as_nobody dcf-secret status ok)" = ready ] +as_nobody bao token revoke -self >/dev/null +check "bao token revoke -self erases the token through the helper" [ ! -e "$state/token" ] +rc=0; as_nobody dcf-secret get ok >/dev/null 2>&1 || rc=$? +check "after the revoke there is no login (exit 12)" [ "$rc" = 12 ] rc=0; BAO_ADDR=http://127.0.0.1:1 dcf-secret get ok >/dev/null 2>&1 || rc=$? check "a closed port is exit 10" [ "$rc" = 10 ] diff --git a/test/openbao/pinned_version.sh b/test/openbao/pinned_version.sh index 84a17bc..082e31d 100755 --- a/test/openbao/pinned_version.sh +++ b/test/openbao/pinned_version.sh @@ -12,6 +12,6 @@ check "dcf-secret and openbao-setup installed" bash -c "command -v dcf-secret && # shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash check "the volume's folder is writable by the remote user" bash -c '[ "$(id -u)" -ne 0 ] && openbao-setup | grep -E "writable\)$"' # shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash -check "the login is linked into the state dir" bash -c '[ "$(readlink ~/.vault-token)" = "$OPENBAO_STATE_DIR/token" ]' +check "bao is pointed at the token helper's config" bash -c '[ "$BAO_CONFIG_PATH" = /usr/local/share/openbao-feature/bao-config.hcl ]' reportResults diff --git a/test/openbao/test.sh b/test/openbao/test.sh index ce3adb9..03752cb 100755 --- a/test/openbao/test.sh +++ b/test/openbao/test.sh @@ -21,8 +21,12 @@ check "openbao-setup on PATH" command -v openbao-setup check "dcf-secret on PATH" command -v dcf-secret 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\)$'" -check "openbao-setup links the login token into it" bash -c "openbao-setup >/dev/null; [ \"\$(readlink ~/.vault-token)\" = /var/lib/devcontainer-features/openbao/token ]" -check "second run keeps it" bash -c "openbao-setup | grep -F 'kept)'" +# shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash +check "BAO_CONFIG_PATH names the Feature's token-helper config" bash -c '[ "$BAO_CONFIG_PATH" = /usr/local/share/openbao-feature/bao-config.hcl ]' +check "the config names the token helper" grep -qx 'token_helper = "/usr/local/bin/openbao-token-helper"' /usr/local/share/openbao-feature/bao-config.hcl +check "the token helper is on PATH and executable" test -x /usr/local/bin/openbao-token-helper +check "openbao-setup says the token helper is in effect" bash -c "openbao-setup | grep -F 'token helper in effect'" +check "the helper round-trips a token in the state dir (mode 600)" bash -c "d=\$(mktemp -d) && printf tok | OPENBAO_STATE_DIR=\$d openbao-token-helper store && [ \"\$(OPENBAO_STATE_DIR=\$d openbao-token-helper get)\" = tok ] && [ \"\$(stat -c %a \$d/token)\" = 600 ] && OPENBAO_STATE_DIR=\$d openbao-token-helper erase && [ ! -e \$d/token ]" check "task openbao:setup is in the Taskfile" grep -q '^ setup:' /usr/local/share/go-task/includes.d/openbao.yml # dcf-secret with no server: unreachable, exit 10, one line (a closed port, no waiting on a host) diff --git a/test/openbao/unit/setup_test.sh b/test/openbao/unit/setup_test.sh index 48a5aaf..ea5b2a4 100644 --- a/test/openbao/unit/setup_test.sh +++ b/test/openbao/unit/setup_test.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # Docker-free tests of openbao-setup (src/openbao/openbao-setup.sh) over temp folders: the state -# directory made and reported (dcf-persist, the real copy), ~/.vault-token linked into it (made, -# kept on a second run, a real file moved in and linked, a link elsewhere left alone, a state -# folder that is not writable reported), the mode (client; an unknown one reported and run as -# client; OPENBAO_SETUP_MODE over the baked default), the report (bao missing, BAO_ADDR, BAO_TOKEN, -# a saved login), never fatal, and the token's text never printed. Part of `task lint` -# (`task test:openbao-unit`). SCRIPT points the test at another copy of the script. +# 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 +# `task lint` (`task test:openbao-unit`). SCRIPT points the test at another copy of the script. set -u exec "$T/home/.vault-token" -check "writing through the link reaches the state dir" "hvs.PRIVATE-TOKEN-TEXT" "$(cat "$T/state/token")" -run +# a login saved by the helper is reported, and its text never printed +printf 'hvs.PRIVATE-TOKEN-TEXT' > "$T/state/token" +run BAO_CONFIG_PATH="$CFG" has "a saved login is reported" "a login is saved" "$out" lacks "the token's text is never printed" "PRIVATE-TOKEN-TEXT" "$out" -run BAO_TOKEN=hvs.OTHER-TEXT +run BAO_CONFIG_PATH="$CFG" BAO_TOKEN=hvs.OTHER-TEXT has "BAO_TOKEN is said to take precedence" "BAO_TOKEN is set" "$out" lacks "BAO_TOKEN's text is never printed" "OTHER-TEXT" "$out" -run BAO_ADDR=https://bao.example.test +run BAO_CONFIG_PATH="$CFG" BAO_ADDR=https://bao.example.test has "BAO_ADDR is reported" "BAO_ADDR https://bao.example.test" "$out" -# a revoke removes the link; a later login is a real file, newer than the volume's copy -rm -f "$T/home/.vault-token" -printf 'hvs.NEWER' > "$T/home/.vault-token" -run -check "a real file is moved into the volume" "hvs.NEWER" "$(cat "$T/state/token")" -check "and linked" "$T/state/token" "$(readlink "$T/home/.vault-token")" -has "says it moved" "moved into the state dir" "$out" +# a token left in ~/.vault-token is moved into the state dir once +fresh +printf 'hvs.OLD-LOGIN' > "$T/home/.vault-token" +chmod 0644 "$T/home/.vault-token" +run BAO_CONFIG_PATH="$CFG" +check "an old token: exit 0" 0 "$rc" +check "an old token is moved into the state dir" "hvs.OLD-LOGIN" "$(cat "$T/state/token")" +check "and is gone from the home folder" "" "$(ls -A "$T/home")" check "the moved token is mode 600" 600 "$(stat -c %a "$T/state/token")" - -# a link somewhere else is the user's +has "says it moved it" "moved from" "$out" +lacks "never prints the old token" "OLD-LOGIN" "$out" +printf 'hvs.AGAIN' > "$T/home/.vault-token" +run BAO_CONFIG_PATH="$CFG" +check "a second old token does not replace the state dir's" "hvs.OLD-LOGIN" "$(cat "$T/state/token")" +check "and is left where it is" "hvs.AGAIN" "$(cat "$T/home/.vault-token")" +has "says it left it" "left where it is" "$out" +# without the helper in effect bao still reads ~/.vault-token: it is never moved fresh -mkdir -p "$T/elsewhere" -ln -s "$T/elsewhere/tok" "$T/home/.vault-token" +printf 'hvs.KEEP' > "$T/home/.vault-token" run -check "a link elsewhere: exit 0" 0 "$rc" -check "a link elsewhere is left alone" "$T/elsewhere/tok" "$(readlink "$T/home/.vault-token")" -has "and said" "left alone" "$out" +check "no helper: the token stays in the home folder" "hvs.KEEP" "$(cat "$T/home/.vault-token")" +check "no helper: nothing in the state dir" "" "$(ls -A "$T/state")" +has "no helper: BAO_CONFIG_PATH unset is said" "BAO_CONFIG_PATH is not set" "$out" +check "no helper: still exit 0" 0 "$rc" +run BAO_CONFIG_PATH=/elsewhere/config.hcl +has "another BAO_CONFIG_PATH is said" "not this Feature's" "$out" +check "another BAO_CONFIG_PATH: the token stays" "hvs.KEEP" "$(cat "$T/home/.vault-token")" -# a state folder nobody can write: reported, never fatal, nothing linked (skipped as root) +# a state folder nobody can write: reported, never fatal, nothing moved (skipped as root) if [ "$(id -u)" -ne 0 ]; then fresh + printf 'hvs.STAY' > "$T/home/.vault-token" mkdir -p "$T/state" chmod 0555 "$T/state" - run + run BAO_CONFIG_PATH="$CFG" check "an unwritable state dir: exit 0" 0 "$rc" has "an unwritable state dir is reported" "not writable" "$out" - check "nothing is linked into it" "" "$([ -e "$T/home/.vault-token" ] || [ -L "$T/home/.vault-token" ] && echo linked)" + check "the old token stays" "hvs.STAY" "$(cat "$T/home/.vault-token")" chmod 0755 "$T/state" fi @@ -119,7 +127,7 @@ out="$(env -i PATH="$T/nobao:/usr/bin:/bin" HOME="$T/home" OPENBAO_STATE_DIR="$T rc=$? check "no bao: exit 0" 0 "$rc" has "no bao: said, not fatal" "bao not found on PATH" "$out" -check "no bao: the link was still made" "$T/state/token" "$(readlink "$T/home/.vault-token")" +check "no bao: the report before it still ran" "yes" "$(grep -q "mode client" <<<"$out" && echo yes)" echo echo "setup_test: $pass passed, $fail failed" diff --git a/test/openbao/unit/token_helper_test.sh b/test/openbao/unit/token_helper_test.sh new file mode 100644 index 0000000..8cafc14 --- /dev/null +++ b/test/openbao/unit/token_helper_test.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Docker-free tests of openbao-token-helper (src/openbao/openbao-token-helper.sh), the token helper +# bao calls with get, store or erase: the token kept at $OPENBAO_STATE_DIR/token with mode 600, +# written atomically (no temp file left), a missing token read as nothing with exit 0, erase +# forgetting it (and a missing one being no error), the folder made when absent, a failing store +# reported with a non-zero exit, no output besides `get`, and the way bao calls it (through +# `sh -c " "` with the environment inherited). Part of `task lint` +# (`task test:openbao-unit`). SCRIPT points the test at another copy of the script. +set -u +exec /dev/null; rm -rf "$T"' EXIT +cp "$script" "$T/helper" +chmod +x "$T/helper" + +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 +} + +# run [stdin]: out = stdout, err = stderr, rc = exit status; called the way bao does, through +# a shell with the environment inherited. +run() { + out="$(printf '%s' "${2-}" | env -i PATH="/usr/bin:/bin" OPENBAO_STATE_DIR="$T/state" sh -c "$T/helper $1" 2>"$T/err")" + rc=$? + err="$(cat "$T/err")" +} + +run get +check "get with no token: exit 0" 0 "$rc" +check "get with no token: prints nothing" "" "$out" + +run store 'hvs.TOKEN-ONE' +check "store: exit 0" 0 "$rc" +check "store: prints nothing" "" "$out$err" +check "the folder is made when absent" yes "$([ -d "$T/state" ] && echo yes)" +check "the token is at OPENBAO_STATE_DIR/token" "hvs.TOKEN-ONE" "$(cat "$T/state/token")" +check "the token file is mode 600" 600 "$(stat -c %a "$T/state/token")" +run get +check "get returns exactly what was stored" "hvs.TOKEN-ONE" "$out" +check "get: exit 0" 0 "$rc" + +run store 'hvs.TOKEN-TWO' +run get +check "store again replaces it" "hvs.TOKEN-TWO" "$out" +check "no temp file is left behind" "token" "$(ls -A "$T/state")" +check "still mode 600 after a replace" 600 "$(stat -c %a "$T/state/token")" + +printf -v nl 'tok\n' +run store "$nl" +check "a trailing newline is kept as given (the helper adds and strips nothing)" yes "$(printf '%s' "$nl" | cmp -s - "$T/state/token" && echo yes)" + +run erase +check "erase: exit 0" 0 "$rc" +check "erase removes the token" "" "$(ls -A "$T/state")" +run get +check "get after erase prints nothing" "" "$out" +run erase +check "erase of a missing token is no error" 0 "$rc" + +run bogus +check "an unknown operation is exit 2" 2 "$rc" +run "" +check "no operation is exit 2" 2 "$rc" + +# a failing store is non-zero and prints a reason only on stderr (skipped as root: it can write anywhere) +if [ "$(id -u)" -ne 0 ]; then + chmod 0555 "$T/state" + run store 'hvs.NOPE' + check "store into an unwritable folder fails" 1 "$([ "$rc" -ne 0 ] && echo 1 || echo 0)" + check "and the token is not printed" "" "$(grep -F NOPE <<<"$out$err")" + chmod 0755 "$T/state" +fi + +# a path with a space in the state dir: bao runs the helper through a shell, the variable is read +# inside, so the folder is free to have one +out="$(printf 'x' | env -i PATH="/usr/bin:/bin" OPENBAO_STATE_DIR="$T/a state" sh -c "$T/helper store"; cat "$T/a state/token")" +check "a state dir with a space" "x" "$out" + +echo +echo "token_helper_test: $pass passed, $fail failed" +[ "$fail" -eq 0 ] From 6b1a5bff55971542c8d5d27c6e7878ab2d2f553a Mon Sep 17 00:00:00 2001 From: devhost agent Date: Tue, 3 Nov 2026 21:10:36 +0000 Subject: [PATCH 5/9] feat(openbao): task openbao:logout; a revoked token is a dead login, not an erased one (openbao-state-build) CI on #162 showed bao token revoke -self does not call the token helper's erase (OpenBao's token_revoke command only calls the API). The test now expects the dead token to stay stored and dcf-secret to answer 12, and the new task openbao:logout revokes the token (ignored if already dead) and erases the saved one. dcf-secret's exit-12 line names login and logout. Unit tests for the task, the dev_server scenario (with go-task) and NOTES, CHANGELOG and README follow. Version stays 1.1.0. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DPsHU61VPD8TZwHanFM7zj Signed-off-by: devhost agent --- Taskfile.yml | 3 +- src/openbao/CHANGELOG.md | 1 + src/openbao/NOTES.md | 10 ++++-- src/openbao/README.md | 11 ++++-- src/openbao/dcf-secret.sh | 2 +- src/openbao/openbao-token-helper.sh | 3 +- src/openbao/taskfile.yml | 7 ++++ test/openbao/dev_server.sh | 22 ++++++++++-- test/openbao/scenarios.json | 1 + test/openbao/unit/dcf_secret_test.sh | 1 + test/openbao/unit/logout_test.sh | 53 ++++++++++++++++++++++++++++ 11 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 test/openbao/unit/logout_test.sh diff --git a/Taskfile.yml b/Taskfile.yml index e4ad31c..f168d29 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -241,11 +241,12 @@ 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. That the real bao gives the signals the stub plays is the container test test/openbao/dev_server.sh (CI). Part of lint. Needs nothing beyond bash and grep (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, 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).' 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 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 cf5fe9c..8f2dfd9 100644 --- a/src/openbao/CHANGELOG.md +++ b/src/openbao/CHANGELOG.md @@ -15,6 +15,7 @@ Entries from 1.0.0 to 1.0.3 were generated from the commit history (`.github/scr - **Your `bao login` now survives a rebuild.** The Feature owns a named volume (`openbao-config-`, at `/var/lib/devcontainer-features/openbao`, named by `OPENBAO_STATE_DIR`) and points `bao` at a token helper (`BAO_CONFIG_PATH`, set in `containerEnv`, so it holds for `devcontainer exec` too) that keeps the token there, as `gh` and `az` keep their logins. The post-create hook, `openbao-setup` (also `task openbao:setup`), makes the folder usable and moves a token left in `~/.vault-token` into it once. **You must act once:** rebuild the container and run `bao login` again, because the volume starts empty. A repo that sets its own `BAO_CONFIG_PATH` replaces the Feature's config (the hook says so); add `token_helper = "/usr/local/bin/openbao-token-helper"` to it. A repo that wants the state on the host disk bind-mounts the same target. A `BAO_TOKEN` in the environment still takes precedence over the saved login. - **`dcf-secret`**, a command for scripts and other Features to read and write a secret without knowing where the server runs: `dcf-secret get `, `dcf-secret put --field ` (the value on stdin) and `dcf-secret status`. `put` writes only that field and keeps the secret's other fields (it creates the secret when it does not exist); `--replace` makes the secret exactly that one field. A token whose policy lacks the `patch` capability is refused with exit 13 rather than silently replacing the secret. It fails with fixed exit codes (10 unreachable, 11 sealed, 12 not logged in, 13 denied, 14 no such secret), each with one line saying what to do, and never falls back to a key on disk. Secrets live in a KV v2 mount named `dcf` (`DCF_SECRET_MOUNT` changes it). Nothing to do unless you want it; `task openbao:kv:get` is unchanged. - **The `mode` option**, with the one value `client` (the default, what the Feature always did: the CLI against a server outside the container). A later version adds `server`. `OPENBAO_SETUP_MODE` in `containerEnv` overrides it per container. Nothing to do. +- `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 diff --git a/src/openbao/NOTES.md b/src/openbao/NOTES.md index 52208e9..adcfeba 100644 --- a/src/openbao/NOTES.md +++ b/src/openbao/NOTES.md @@ -75,8 +75,11 @@ atomically. "/usr/local/bin/openbao-token-helper"` to that config to keep the login in the volume. A repo that wants the state on the host disk bind-mounts the volume's target (the README's "How the pieces fit"), or points `OPENBAO_STATE_DIR` elsewhere in its own `containerEnv`. -- **`bao token revoke -self`** and `bao logout` run the helper's `erase`: the token file is - removed and the next `dcf-secret` call says to log in. +- **Log out with `task openbao:logout`**: it runs `bao token revoke -self` (ignored when the token + is already dead) and then the helper's `erase`, so no token is left in the volume. `bao token + revoke -self` alone only revokes the token on the server (OpenBao's `token_revoke` command calls + the API and never the helper; there is no `bao logout`), so the dead token stays stored and + `dcf-secret` answers exit 12 until you `task openbao:login` again. - **`BAO_TOKEN`** in the environment takes precedence over the saved login (the hook says so). It is a way to give a container a token without a login, for a service that cannot run `bao login`: set it in `containerEnv` from a host variable (`"BAO_TOKEN": "${localEnv:BAO_TOKEN}"`) or an env @@ -198,6 +201,7 @@ registry, these appear under `openbao:`: - `openbao:version`: show the installed version. - `openbao:status`: run `bao status` against `BAO_ADDR` (seal state, HA, version); extra flags go after `--`. +- `openbao:logout`: revoke the current token on the server and erase the saved one. - `openbao:login`: run `bao login` interactively; auth method and flags go after `--`, e.g. `task openbao:login -- -method=oidc`. - `openbao:kv:get`: read the secret `KEY=` from a KV engine @@ -230,7 +234,7 @@ itself. - `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 - token; the container test `dev_server` checks login, use and revoke against a real `bao`. + token; the container test `dev_server` checks login, use, revoke and `openbao:logout` against a real `bao`. - `bao status` fails with a connection or certificate error: `BAO_ADDR` is unset or wrong, or the server's CA is not trusted in the image. - A `BAO_TOKEN` in the environment takes precedence over the saved login (see State). diff --git a/src/openbao/README.md b/src/openbao/README.md index c0f963f..e56a538 100644 --- a/src/openbao/README.md +++ b/src/openbao/README.md @@ -96,8 +96,11 @@ atomically. "/usr/local/bin/openbao-token-helper"` to that config to keep the login in the volume. A repo that wants the state on the host disk bind-mounts the volume's target (the README's "How the pieces fit"), or points `OPENBAO_STATE_DIR` elsewhere in its own `containerEnv`. -- **`bao token revoke -self`** and `bao logout` run the helper's `erase`: the token file is - removed and the next `dcf-secret` call says to log in. +- **Log out with `task openbao:logout`**: it runs `bao token revoke -self` (ignored when the token + is already dead) and then the helper's `erase`, so no token is left in the volume. `bao token + revoke -self` alone only revokes the token on the server (OpenBao's `token_revoke` command calls + the API and never the helper; there is no `bao logout`), so the dead token stays stored and + `dcf-secret` answers exit 12 until you `task openbao:login` again. - **`BAO_TOKEN`** in the environment takes precedence over the saved login (the hook says so). It is a way to give a container a token without a login, for a service that cannot run `bao login`: set it in `containerEnv` from a host variable (`"BAO_TOKEN": "${localEnv:BAO_TOKEN}"`) or an env @@ -219,6 +222,7 @@ registry, these appear under `openbao:`: - `openbao:version`: show the installed version. - `openbao:status`: run `bao status` against `BAO_ADDR` (seal state, HA, version); extra flags go after `--`. +- `openbao:logout`: revoke the current token on the server and erase the saved one. - `openbao:login`: run `bao login` interactively; auth method and flags go after `--`, e.g. `task openbao:login -- -method=oidc`. - `openbao:kv:get`: read the secret `KEY=` from a KV engine @@ -251,7 +255,7 @@ itself. - `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 - token; the container test `dev_server` checks login, use and revoke against a real `bao`. + token; the container test `dev_server` checks login, use, revoke and `openbao:logout` against a real `bao`. - `bao status` fails with a connection or certificate error: `BAO_ADDR` is unset or wrong, or the server's CA is not trusted in the image. - A `BAO_TOKEN` in the environment takes precedence over the saved login (see State). @@ -293,6 +297,7 @@ Nothing is removed; `mode` stays `client`. - **Your `bao login` now survives a rebuild.** The Feature owns a named volume (`openbao-config-`, at `/var/lib/devcontainer-features/openbao`, named by `OPENBAO_STATE_DIR`) and points `bao` at a token helper (`BAO_CONFIG_PATH`, set in `containerEnv`, so it holds for `devcontainer exec` too) that keeps the token there, as `gh` and `az` keep their logins. The post-create hook, `openbao-setup` (also `task openbao:setup`), makes the folder usable and moves a token left in `~/.vault-token` into it once. **You must act once:** rebuild the container and run `bao login` again, because the volume starts empty. A repo that sets its own `BAO_CONFIG_PATH` replaces the Feature's config (the hook says so); add `token_helper = "/usr/local/bin/openbao-token-helper"` to it. A repo that wants the state on the host disk bind-mounts the same target. A `BAO_TOKEN` in the environment still takes precedence over the saved login. - **`dcf-secret`**, a command for scripts and other Features to read and write a secret without knowing where the server runs: `dcf-secret get `, `dcf-secret put --field ` (the value on stdin) and `dcf-secret status`. `put` writes only that field and keeps the secret's other fields (it creates the secret when it does not exist); `--replace` makes the secret exactly that one field. A token whose policy lacks the `patch` capability is refused with exit 13 rather than silently replacing the secret. It fails with fixed exit codes (10 unreachable, 11 sealed, 12 not logged in, 13 denied, 14 no such secret), each with one line saying what to do, and never falls back to a key on disk. Secrets live in a KV v2 mount named `dcf` (`DCF_SECRET_MOUNT` changes it). Nothing to do unless you want it; `task openbao:kv:get` is unchanged. - **The `mode` option**, with the one value `client` (the default, what the Feature always did: the CLI against a server outside the container). A later version adds `server`. `OPENBAO_SETUP_MODE` in `containerEnv` overrides it per container. Nothing to do. +- `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 diff --git a/src/openbao/dcf-secret.sh b/src/openbao/dcf-secret.sh index ea3ead3..aecbd56 100644 --- a/src/openbao/dcf-secret.sh +++ b/src/openbao/dcf-secret.sh @@ -49,7 +49,7 @@ EOF 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)"; } -fix_login() { say "no valid token (none, or it expired): log in with task openbao:login"; } +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)"; } command -v bao >/dev/null 2>&1 || die 10 "bao is not installed: add the openbao Feature to the devcontainer" diff --git a/src/openbao/openbao-token-helper.sh b/src/openbao/openbao-token-helper.sh index b4964cd..3b82675 100644 --- a/src/openbao/openbao-token-helper.sh +++ b/src/openbao/openbao-token-helper.sh @@ -3,7 +3,8 @@ # found through BAO_CONFIG_PATH, both set by the Feature). bao calls it with one argument: # get print the stored token (nothing, exit 0, when there is none) # store keep the token read from stdin -# erase forget the token (bao token revoke -self, bao logout) +# erase forget the token (bao does not call it on `bao token revoke -self`, which only revokes +# the token on the server; task openbao:logout calls it after the revoke) # The token lives at $OPENBAO_STATE_DIR/token, mode 0600, in the Feature's volume, so a rebuild # keeps the login. The internal helper bao uses otherwise writes ~/.vault-token by a rename, which # no link can follow; this is the CLI's own way to keep the token elsewhere. It prints no token diff --git a/src/openbao/taskfile.yml b/src/openbao/taskfile.yml index cd6ec85..2a98cba 100644 --- a/src/openbao/taskfile.yml +++ b/src/openbao/taskfile.yml @@ -22,6 +22,13 @@ tasks: cmds: - 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-token-helper erase + kv:get: dir: '{{.USER_WORKING_DIR}}' desc: 'Read secret KEY= from a KV engine (KEY, not PATH: Task would read the shell PATH).' diff --git a/test/openbao/dev_server.sh b/test/openbao/dev_server.sh index 5979163..965afed 100644 --- a/test/openbao/dev_server.sh +++ b/test/openbao/dev_server.sh @@ -85,10 +85,28 @@ check "nothing was written to the home folder" [ -z "$(ls -A "$home")" ] check "the state dir holds the token bao was given" [ "$(cat "$state/token")" = "$limited" ] check "dcf-secret uses the saved login" [ "$(as_nobody dcf-secret get ok)" = fine ] check "status is ready with the saved login" [ "$(as_nobody dcf-secret status ok)" = ready ] +# bao token revoke -self revokes on the server and never calls the helper (OpenBao's token_revoke +# command only calls the API): the dead token stays stored, and dcf-secret says to log in. as_nobody bao token revoke -self >/dev/null -check "bao token revoke -self erases the token through the helper" [ ! -e "$state/token" ] +check "revoke -self leaves the (now dead) token stored" [ -s "$state/token" ] +rc=0; err="$(as_nobody dcf-secret get ok 2>&1 >/dev/null)" || rc=$? +check "after the revoke dcf-secret is exit 12" [ "$rc" = 12 ] +check "with the no-login line, naming login and logout" [ "$(grep -c 'task openbao:login.*task openbao:logout' <<<"$err")" = 1 ] + +# task openbao:logout revokes and erases, leaving nothing, and is no error on a dead token +again="$(bao token create -policy=dcf-ok -field=token)" +as_nobody bao login -no-print "$again" >/dev/null +check "a new login is stored" [ "$(cat "$state/token")" = "$again" ] +logout() { as_nobody task -t /usr/local/share/go-task/includes.d/openbao.yml logout; } +rc=0; logout >/dev/null 2>&1 || rc=$? +check "task openbao:logout is exit 0" [ "$rc" = 0 ] +check "logout erased the saved token" [ ! -e "$state/token" ] +rc=0; BAO_TOKEN="$again" bao token lookup >/dev/null 2>&1 || rc=$? +check "logout revoked the token on the server" [ "$rc" != 0 ] +rc=0; logout >/dev/null 2>&1 || rc=$? +check "logout again, with nothing stored, is still exit 0" [ "$rc" = 0 ] rc=0; as_nobody dcf-secret get ok >/dev/null 2>&1 || rc=$? -check "after the revoke there is no login (exit 12)" [ "$rc" = 12 ] +check "after logout there is no login (exit 12)" [ "$rc" = 12 ] rc=0; BAO_ADDR=http://127.0.0.1:1 dcf-secret get ok >/dev/null 2>&1 || rc=$? check "a closed port is exit 10" [ "$rc" = 10 ] diff --git a/test/openbao/scenarios.json b/test/openbao/scenarios.json index e77c61e..24e8900 100644 --- a/test/openbao/scenarios.json +++ b/test/openbao/scenarios.json @@ -30,6 +30,7 @@ "image": "mcr.microsoft.com/devcontainers/base:debian", "remoteUser": "vscode", "features": { + "go-task": {}, "openbao": { "version": "2.7.1" } diff --git a/test/openbao/unit/dcf_secret_test.sh b/test/openbao/unit/dcf_secret_test.sh index 0e5bd6b..9dd6f35 100644 --- a/test/openbao/unit/dcf_secret_test.sh +++ b/test/openbao/unit/dcf_secret_test.sh @@ -124,6 +124,7 @@ has "11: not initialised says so" "not initialised" "$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" +has "12: and the logout that clears a dead token" "task openbao:logout" "$err" check "12: one line" 1 "$(lines "$err")" run STUB_KV_RC=2 STUB_CAPS=deny -- get ssh/key check "13: the token may not read" 13 "$rc" diff --git a/test/openbao/unit/logout_test.sh b/test/openbao/unit/logout_test.sh new file mode 100644 index 0000000..0badc27 --- /dev/null +++ b/test/openbao/unit/logout_test.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# 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. +set -u +exec /dev/null 2>&1 || { echo "logout_test: task is required"; exit 1; } + +T="$(mktemp -d /tmp/oblogout.XXXXXX)" +trap 'rm -rf "$T"' EXIT +mkdir -p "$T/bin" "$T/state" +install -m 0755 "$root/src/openbao/openbao-token-helper.sh" "$T/bin/openbao-token-helper" +cat > "$T/bin/bao" <<'STUB' +#!/usr/bin/env bash +echo "$*" >> "$STUB_LOG" +exit "${STUB_REVOKE_RC:-0}" +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 +} + +logout() { # [VAR=value ...]: rc = exit status of the task + : > "$T/calls.log" + (cd "$T" && env PATH="$T/bin:$PATH" STUB_LOG="$T/calls.log" OPENBAO_STATE_DIR="$T/state" "$@" \ + task -t "$root/src/openbao/taskfile.yml" logout >"$T/out" 2>&1) + rc=$? +} + +printf 'hvs.TOKEN' > "$T/state/token" +logout +check "logout: exit 0" 0 "$rc" +check "logout revokes the token on the server" "token revoke -self" "$(cat "$T/calls.log")" +check "logout erases the saved token" "" "$(ls -A "$T/state")" + +printf 'hvs.DEAD' > "$T/state/token" +logout STUB_REVOKE_RC=2 +check "a revoke that fails (a dead token): the task is still exit 0" 0 "$rc" +check "and the saved token is erased anyway" "" "$(ls -A "$T/state")" + +logout +check "nothing stored: exit 0" 0 "$rc" + +echo +echo "logout_test: $pass passed, $fail failed" +[ "$fail" -eq 0 ] From c93fb9680d9ffe6252cf26fbd16489e857622bec Mon Sep 17 00:00:00 2001 From: devhost agent Date: Tue, 3 Nov 2026 21:17:22 +0000 Subject: [PATCH 6/9] chore(openbao): start slice 2a, server mode (openbao-local-storage) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DPsHU61VPD8TZwHanFM7zj Signed-off-by: devhost agent From 9466c92c8851762cbb2538904f403555fcba7624 Mon Sep 17 00:00:00 2001 From: devhost agent Date: Tue, 3 Nov 2026 21:51:50 +0000 Subject: [PATCH 7/9] feat(openbao): server mode, a local server with raft storage, init, unseal, userpass and AppRole (openbao-local-storage) openbao 1.2.0, slice 2a of the secrets-master-split proposal. The mode option gains `server`: the post-start hook (openbao-setup start) writes a config into the volume (raft storage, one loopback listener without TLS, the file audit device declared in the config) and starts bao server, sealed until the Operator unseals it. Tasks: openbao:init (asks for a backup destination or an explicit "no backup" and records it, the Operator's password, 3 shares with threshold 2 printed to the terminal only, a proof unseal, configuration, root token revoked), :unseal, :seal, :server:status, :approle:issue (ROLE/POLICY_PATH, ALL=1) and :approle:revoke; userpass login of 4 hours, at most 8. init, unseal and seal refuse inside an agent's job (DEVHOST_JOB_CHILD, CLAUDECODE, OPENBAO_AGENT_JOB_VARS), init and unseal also without a terminal. AppRole roles are bound to loopback with one valid secret id each, written 0600. dcf-secret's fix lines name task openbao:unseal, :init and :server:status. In server mode BAO_ADDR defaults to the local server through env.sh and openbao-run. disable_mlock is not written: OpenBao has no mlock since 2.0.0. Server mode needs OpenBao 2.4.0 or newer (the declarative audit stanza). Tests: server_test.sh (a stub bao; a refusal test per task, init under a pseudo-terminal, status, AppRole), start hook tests in setup_test.sh, new dcf-secret cases, and the container scenario server_mode (real bao; also run by hand against bao 2.7.2). NOTES, CHANGELOG and READMEs updated. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DPsHU61VPD8TZwHanFM7zj Signed-off-by: devhost agent --- README.md | 2 +- Taskfile.yml | 3 +- src/openbao/CHANGELOG.md | 17 + src/openbao/NOTES.md | 190 +++++++++- src/openbao/README.md | 217 ++++++++++-- src/openbao/dcf-secret.sh | 14 +- src/openbao/devcontainer-feature.json | 10 +- src/openbao/env.sh | 27 ++ src/openbao/install.sh | 21 +- src/openbao/openbao-run.sh | 9 + src/openbao/openbao-server.sh | 455 ++++++++++++++++++++++++ src/openbao/openbao-setup.sh | 137 +++++++- src/openbao/taskfile.yml | 66 +++- test/openbao/mode_client.sh | 9 +- test/openbao/scenarios.json | 11 + test/openbao/server_mode.sh | 194 +++++++++++ test/openbao/test.sh | 5 + test/openbao/unit/dcf_secret_test.sh | 30 +- test/openbao/unit/logout_test.sh | 4 +- test/openbao/unit/server_test.sh | 483 ++++++++++++++++++++++++++ test/openbao/unit/setup_test.sh | 120 ++++++- 21 files changed, 1953 insertions(+), 71 deletions(-) create mode 100644 src/openbao/env.sh create mode 100644 src/openbao/openbao-run.sh create mode 100644 src/openbao/openbao-server.sh create mode 100644 test/openbao/server_mode.sh create mode 100644 test/openbao/unit/server_test.sh diff --git a/README.md b/README.md index 39d9596..27d2232 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 f168d29..8a4d358 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -241,12 +241,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 8f2dfd9..7d10f2a 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. +- 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` and `seal` 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` and `unseal` 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.0] - 2026-11-03 ### Added diff --git a/src/openbao/NOTES.md b/src/openbao/NOTES.md index adcfeba..8f1a4f5 100644 --- a/src/openbao/NOTES.md +++ b/src/openbao/NOTES.md @@ -28,17 +28,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 @@ -57,7 +61,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 @@ -69,7 +73,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 @@ -91,6 +99,131 @@ 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`, AppRole roles and secret ids (`auth/approle/role/*`), and policies named `approle-*` + (what `openbao:approle:issue` writes). **Not** `snapshot-force`, audit devices, or any other policy + or `sys/` path. Whoever can write a role and its policy can act as that role, so the Operator's + token is the strongest thing short of the shares; it lasts 4 hours. + +**Agents.** `init`, `unseal` and `seal` **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` and `unseal` 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=` +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). 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), its policy and 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 @@ -120,7 +253,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 | | --- | --- | --- | @@ -171,6 +307,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 @@ -187,6 +324,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). @@ -209,8 +348,21 @@ 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; `ROLE=` or `ALL=1` reissues; one valid secret id each. +- `openbao:approle:revoke`: server mode: `ROLE=` deletes the role, its secret ids, its policy + and its files. - `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` @@ -230,7 +382,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 @@ -265,4 +426,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 e56a538..c71c1e3 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 | ## What it installs @@ -49,17 +49,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 @@ -78,7 +82,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 @@ -90,7 +94,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 @@ -112,6 +120,131 @@ 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`, AppRole roles and secret ids (`auth/approle/role/*`), and policies named `approle-*` + (what `openbao:approle:issue` writes). **Not** `snapshot-force`, audit devices, or any other policy + or `sys/` path. Whoever can write a role and its policy can act as that role, so the Operator's + token is the strongest thing short of the shares; it lasts 4 hours. + +**Agents.** `init`, `unseal` and `seal` **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` and `unseal` 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=` +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). 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), its policy and 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 @@ -141,7 +274,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 | | --- | --- | --- | @@ -192,6 +328,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 @@ -208,6 +345,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). @@ -230,8 +369,21 @@ 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; `ROLE=` or `ALL=1` reissues; one valid secret id each. +- `openbao:approle:revoke`: server mode: `ROLE=` deletes the role, its secret ids, its policy + and its files. - `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` @@ -251,7 +403,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 @@ -286,10 +447,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. +- 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` and `seal` 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` and `unseal` 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.0] - 2026-11-03 #### Added @@ -306,12 +489,6 @@ Nothing is removed; `mode` stays `client`. - 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. -### [1.0.3] - 2026-10-28 - -#### Fixed - -- Build-time downloads retry on transient network errors, so a flaky connection no longer fails the container build outright. Rebuild the container to pick it up. No change to options or tasks; nothing to do. ([#94](https://github.com/nabil-becker/devcontainer-features/pull/94)) - 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 5fbe928..528c548 100644 --- a/src/openbao/devcontainer-feature.json +++ b/src/openbao/devcontainer-feature.json @@ -1,8 +1,8 @@ { "id": "openbao", - "version": "1.1.0", + "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..8e6748d --- /dev/null +++ b/src/openbao/openbao-server.sh @@ -0,0 +1,455 @@ +#!/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) refuses inside an agent's job +# and, for init and unseal, 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 ---- +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" >/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 + 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)" + # The role may read this one secret and nothing else (not even a listing). + printf 'path "%s/data/%s" {\n capabilities = ["read"]\n}\n' "${ppath%%/*}" "${ppath#*/}" | bao policy write "$policy" - >/dev/null || die 1 "could not write the policy $policy" + elif ! role_exists "$role"; then + die 2 "role '$role' does not exist: give POLICY_PATH= (the one secret it may read) the first time" + fi + if [ -n "$ppath" ]; then + # 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/$role" token_policies="$policy" 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 || die 1 "could not write the role $role" + 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" + preflight + role_exists "$role" || die 14 "no such role '$role'" + # Deleting a role destroys the secret ids issued under it (OpenBao's role deletion flushes them). + bao delete "auth/approle/role/$role" >/dev/null || die 1 "could not delete the role $role" + bao policy delete "approle-$role" >/dev/null 2>&1 || true + 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..8f04801 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, 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. 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. 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..dea19dd --- /dev/null +++ b/test/openbao/server_mode.sh @@ -0,0 +1,194 @@ +#!/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: loopback binding honoured (secret id and token), one valid secret id after +# a reissue, revoke; +# - 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" +} + +# --- 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 --------------------------------------------------------------------------------------- +t approle:issue ROLE=scribe POLICY_PATH=dcf/register/scribe > /dev/null 2>&1 +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" +role="$(bao read -format=json auth/approle/role/scribe)" +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 +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 write auth/approle/role/scribe secret_id_bound_cidrs=127.0.0.1/32 token_bound_cidrs=10.0.0.0/8 > /dev/null +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 ] +t approle:revoke ROLE=scribe > /dev/null 2>&1 +check "approle:revoke removed the files" test ! -e "$state/approle/scribe" +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..c89824c --- /dev/null +++ b/test/openbao/unit/server_test.sh @@ -0,0 +1,483 @@ +#!/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). +# 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)" +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 ;; + "policy write") cat > "$STUB_DIR/policy.$3"; exit 0 ;; + "policy delete") 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/"*) 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" && 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/"*) 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}" +} +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 ---- +reset unsealed +run OPENBAO_TASK_ROLE=scribe OPENBAO_TASK_POLICY_PATH=dcf/register/scribe -- approle-issue +check "approle:issue a new role: exit 0" 0 "$rc" +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")" +lacks "the secret id is never printed" "SECRETID" "$out" +lacks "nor the role id" "ROLEID" "$out" +lacks "nor put in an argument" "SECRETID" "$(calls)" +# reissue: exactly one valid secret id afterwards +: > "$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")" +# ALL=1 +run OPENBAO_TASK_ROLE=librarian OPENBAO_TASK_POLICY_PATH=dcf/register/librarian -- approle-issue +: > "$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")" +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" +# refusals and preflight +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" +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" +reset sealed +run OPENBAO_TASK_ROLE=x OPENBAO_TASK_POLICY_PATH=dcf/x -- approle-issue +check "approle:issue on a sealed server: exit 11" 11 "$rc" +reset unsealed +run OPENBAO_TASK_ROLE=x OPENBAO_TASK_POLICY_PATH=dcf/x STUB_LOOKUP_RC=2 -- 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 +run OPENBAO_TASK_ROLE=x OPENBAO_TASK_POLICY_PATH=dcf/x -- approle-issue +check "approle:issue with the server unreachable: exit 10" 10 "$rc" +# revoke +reset unsealed +run OPENBAO_TASK_ROLE=scribe OPENBAO_TASK_POLICY_PATH=dcf/register/scribe -- approle-issue +run 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 removes the files" "no" "$([ -e "$T/state/approle/scribe" ] && echo yes || echo no)" +run OPENBAO_TASK_ROLE=scribe -- approle-revoke +check "approle:revoke of an unknown role: exit 14" 14 "$rc" +run -- approle-revoke +check "approle:revoke without a role: exit 2" 2 "$rc" + +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 From 1fad13575492e73209c2d96da4764c67062de202 Mon Sep 17 00:00:00 2001 From: devhost agent Date: Wed, 4 Nov 2026 11:40:27 +0000 Subject: [PATCH 8/9] fix(openbao): the Operator's policy only reissues secret ids; making and deleting a role take a root token from the shares (openbao-local-storage) The Operator's token could write any approle-* policy and any role, so it could write one with snapshot-force and log in through a role holding it, sidestepping ruling 3. Its policy now covers only the reissue of secret ids for roles that exist (list roles, read a role-id, list and destroy secret-id accessors, make a secret id) and starting or cancelling a root-token generation. approle:issue with POLICY_PATH and approle:revoke make a root token with bao operator generate-root (OTP, two shares at a hidden prompt), use it for that step only and revoke it after, also on failure; both refuse inside an agent's job and without a terminal. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DPsHU61VPD8TZwHanFM7zj Signed-off-by: devhost agent --- src/openbao/CHANGELOG.md | 4 +- src/openbao/NOTES.md | 37 +++-- src/openbao/README.md | 41 ++++-- src/openbao/openbao-server.sh | 137 ++++++++++++++--- src/openbao/taskfile.yml | 4 +- test/openbao/server_mode.sh | 61 +++++++- test/openbao/unit/server_test.sh | 245 +++++++++++++++++++++++-------- 7 files changed, 405 insertions(+), 124 deletions(-) diff --git a/src/openbao/CHANGELOG.md b/src/openbao/CHANGELOG.md index 7d10f2a..7c1ebf8 100644 --- a/src/openbao/CHANGELOG.md +++ b/src/openbao/CHANGELOG.md @@ -15,13 +15,13 @@ Entries from 1.0.0 to 1.0.3 were generated from the commit history (`.github/scr - **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. +- `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` and `seal` 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` and `unseal` refuse without a terminal: the shares and the token must never land in a transcript. Run them yourself in a plain terminal. +- **`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. diff --git a/src/openbao/NOTES.md b/src/openbao/NOTES.md index 8f1a4f5..2c932fa 100644 --- a/src/openbao/NOTES.md +++ b/src/openbao/NOTES.md @@ -164,32 +164,44 @@ of every request, with secrets hashed, names each identity that read a key. 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`, AppRole roles and secret ids (`auth/approle/role/*`), and policies named `approle-*` - (what `openbao:approle:issue` writes). **Not** `snapshot-force`, audit devices, or any other policy - or `sys/` path. Whoever can write a role and its policy can act as that role, so the Operator's - token is the strongest thing short of the shares; it lasts 4 hours. - -**Agents.** `init`, `unseal` and `seal` **refuse inside an agent's job**: when `DEVHOST_JOB_CHILD` + `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` and `unseal` also refuse without a terminal on stdin and stdout (they ask +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=` -makes the role (lower case name) with a policy `approle-` that lets it read that one secret +**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). A reissue leaves **exactly one +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), its policy and its files; a token it +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. @@ -357,9 +369,10 @@ registry, these appear under `openbao:`: 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; `ROLE=` or `ALL=1` reissues; one valid secret id each. + 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. + 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; in server mode also the start hook. diff --git a/src/openbao/README.md b/src/openbao/README.md index c71c1e3..9d4706e 100644 --- a/src/openbao/README.md +++ b/src/openbao/README.md @@ -185,32 +185,44 @@ of every request, with secrets hashed, names each identity that read a key. 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`, AppRole roles and secret ids (`auth/approle/role/*`), and policies named `approle-*` - (what `openbao:approle:issue` writes). **Not** `snapshot-force`, audit devices, or any other policy - or `sys/` path. Whoever can write a role and its policy can act as that role, so the Operator's - token is the strongest thing short of the shares; it lasts 4 hours. - -**Agents.** `init`, `unseal` and `seal` **refuse inside an agent's job**: when `DEVHOST_JOB_CHILD` + `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` and `unseal` also refuse without a terminal on stdin and stdout (they ask +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=` -makes the role (lower case name) with a policy `approle-` that lets it read that one secret +**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). A reissue leaves **exactly one +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), its policy and its files; a token it +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. @@ -378,9 +390,10 @@ registry, these appear under `openbao:`: 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; `ROLE=` or `ALL=1` reissues; one valid secret id each. + 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. + 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; in server mode also the start hook. @@ -463,13 +476,13 @@ through `openbao-run` (the same `bao` call; in client mode nothing is added) and - **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. +- `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` and `seal` 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` and `unseal` refuse without a terminal: the shares and the token must never land in a transcript. Run them yourself in a plain terminal. +- **`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. diff --git a/src/openbao/openbao-server.sh b/src/openbao/openbao-server.sh index 8e6748d..57cac04 100644 --- a/src/openbao/openbao-server.sh +++ b/src/openbao/openbao-server.sh @@ -16,8 +16,9 @@ # 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) refuses inside an agent's job -# and, for init and unseal, without a terminal: the shares and the token must never reach a +# 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. @@ -123,9 +124,12 @@ unseal_loop() { # ---------------------------------------------------------------------------------- init ---- operator_policy() { cat <