20 Commits

Author SHA1 Message Date
benvin 7a6a3f559a Merge pull request 'vctx: colourise json/yaml output' (#6) from benvin/vctx-colour into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #6
2026-10-24 16:25:00 +11:00
unkin-agent 7540cbc9a4 vctx: always exec write and patch (MFA prompt on stdout)
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-10-24 16:16:20 +11:00
unkin-agent cb1e048e0f Merge main
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-10-23 23:20:42 +11:00
benvin c89657b347 Merge pull request 'Encrypt cached tokens at rest' (#7) from benvin/encrypt-cache into main
Reviewed-on: #7
2026-10-23 23:19:45 +11:00
unkin-agent 1cedcbde19 vctx: exec auth/ writes uncoloured; stop forwarding SIGINT to vault
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-10-23 23:17:42 +11:00
unkin-agent 92223ce33d vctx: exec login and operator commands uncoloured; test signal forwarding
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-10-23 23:12:33 +11:00
unkin-agent 42048ae6ee Encrypt cached tokens with a kernel keyring key
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-10-23 23:07:35 +11:00
unkin-agent f151902e43 vctx: colourise json/yaml output with --color
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-10-23 23:06:41 +11:00
benvin ede28266b3 Merge pull request 'Build release binaries for linux only' (#5) from benvin/linux-only-release into main
Reviewed-on: #5
2026-10-23 22:43:43 +11:00
benvin ac032216c8 Merge pull request 'vctx: set VAULT_FORMAT from the vault command' (#3) from benvin/vctx-format into main
Reviewed-on: #3
2026-10-23 22:43:17 +11:00
benvin c895aec43c Merge pull request 'Add OIDC login with role selection to vcm' (#4) from benvin/vcm-oidc into main
Reviewed-on: #4
2026-10-23 22:43:09 +11:00
unkin-agent d0210bf760 Build release binaries for linux only
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-10-23 22:34:17 +11:00
benvin 1dcb23606f Merge pull request 'Rename vctl to vcm' (#2) from benvin/vcm-rename into main
Reviewed-on: #2
2026-10-23 22:32:49 +11:00
unkin-agent 0d9dd267fc Drop ambient copies of vctx-managed env vars before exec
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-10-23 22:03:45 +11:00
unkin-agent ddc25139f7 Support OIDC form_post, require loopback listener, redact request URLs in errors
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-10-23 22:01:42 +11:00
unkin-agent 718a3a9063 Stop vctx format scan at --; limit raw to vault read
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-10-23 22:00:31 +11:00
unkin-agent 588aea7858 Add OIDC login with per-context role to vcm
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-10-23 21:58:12 +11:00
unkin-agent 5a64b10b25 Set VAULT_FORMAT in vctx from the vault command
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
2026-10-23 21:57:28 +11:00
unkin-agent b59a62f038 Rename vctl to vcm and move config/cache to vcm dirs
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-10-23 21:55:13 +11:00
benvin 46560219ed Merge pull request 'Add vctl and vctx Vault token CLIs' (#1) from benvin/vault-tools-initial into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #1
2026-07-27 00:46:55 +10:00
27 changed files with 1446 additions and 325 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
# cross-compiled release artifacts at repo root (e.g. vctl-linux-amd64). # cross-compiled release artifacts at repo root (e.g. vcm-linux-amd64).
# NB: do NOT ignore /vctl or /vctx bare — those are the tool source directories. # NB: do NOT ignore /vcm or /vctx bare — those are the tool source directories.
/vctl-* /vcm-*
/vctx-* /vctx-*
sha256sums.txt sha256sums.txt
# build output: binaries, completions, RPM # build output: binaries, completions, RPM
+9 -10
View File
@@ -18,8 +18,8 @@ steps:
cpu: 2 cpu: 2
# Build both binaries into dist/ (consumed by the RPM step) plus the # Build both binaries into dist/ (consumed by the RPM step) plus the
# cross-platform binaries attached to the Gitea release. Each tool is a # linux amd64/arm64 binaries attached to the Gitea release. Each tool is a
# separate main package, so they are built individually per os/arch. # separate main package, so they are built individually per arch.
- name: build - name: build
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands: commands:
@@ -28,13 +28,12 @@ steps:
# for the shell instead of substituting them (as pipeline vars) at parse # for the shell instead of substituting them (as pipeline vars) at parse
# time. ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$. # time. ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$.
- | - |
for entry in "vctl:./vctl" "vctx:./vctx"; do for entry in "vcm:./vcm" "vctx:./vctx"; do
name="$${entry%%:*}"; pkg="$${entry##*:}" name="$${entry%%:*}"; pkg="$${entry##*:}"
for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do for arch in amd64 arm64; do
os="$${osarch%/*}"; arch="$${osarch#*/}" GOOS=linux GOARCH="$$arch" \
GOOS="$$os" GOARCH="$$arch" \
go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" \ go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" \
-o "$${name}-$${os}-$${arch}" "$$pkg" -o "$${name}-linux-$${arch}" "$$pkg"
done done
done done
depends_on: [test] depends_on: [test]
@@ -101,7 +100,7 @@ steps:
memory: 512Mi memory: 512Mi
cpu: 500m cpu: 500m
# Cut a Gitea release with the cross-platform binaries attached. # Cut a Gitea release with the linux amd64/arm64 binaries attached.
- name: release - name: release
image: git.unkin.net/unkin/almalinux9-base:20260606 image: git.unkin.net/unkin/almalinux9-base:20260606
environment: environment:
@@ -131,11 +130,11 @@ steps:
NOTES=$$(git log --pretty=format:"- %s") NOTES=$$(git log --pretty=format:"- %s")
fi fi
tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "$${NOTES}" --login gitea --repo "${CI_REPO}" tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "$${NOTES}" --login gitea --repo "${CI_REPO}"
# The build step writes the cross-compiled binaries into the workspace # The build step writes the linux binaries into the workspace
# root; the package step writes the RPM to dist/. Generate a checksums # root; the package step writes the RPM to dist/. Generate a checksums
# manifest over everything we attach so downloads can be verified. # manifest over everything we attach so downloads can be verified.
RPM=$$(ls dist/*.rpm 2>/dev/null | head -1) RPM=$$(ls dist/*.rpm 2>/dev/null | head -1)
ASSETS="vctl-linux-amd64 vctl-linux-arm64 vctl-darwin-amd64 vctl-darwin-arm64 vctx-linux-amd64 vctx-linux-arm64 vctx-darwin-amd64 vctx-darwin-arm64" ASSETS="vcm-linux-amd64 vcm-linux-arm64 vctx-linux-amd64 vctx-linux-arm64"
[ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM" [ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM"
sha256sum $$ASSETS > sha256sums.txt sha256sum $$ASSETS > sha256sums.txt
tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \ tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \
+8 -8
View File
@@ -5,24 +5,24 @@
`vault-tools` is a Go monorepo of small CLIs for managing Vault tokens across `vault-tools` is a Go monorepo of small CLIs for managing Vault tokens across
multiple vault instances ("contexts"): multiple vault instances ("contexts"):
- **`vctl`** — logs in to / renews Vault tokens per context (`login`, `renew`, - **`vcm`** — logs in to / renews Vault tokens per context (`login`, `renew`,
`list`), with `--all`, `--method` and `--user` flags. Caches each token under `list`), with `--all`, `--method` and `--user` flags. Caches each token under
`~/.cache/vault/<context>` as JSON (token + accessor + policies + TTL/expiry). `~/.cache/vcm/<context>` as JSON (token + accessor + policies + TTL/expiry).
- **`vctx`** — thin wrapper around the real `vault` CLI: - **`vctx`** — thin wrapper around the real `vault` CLI:
`vctx --context <ctx> <vault args...>` resolves the context, sets `vctx --context <ctx> <vault args...>` resolves the context, sets
`VAULT_ADDR`/`VAULT_TOKEN`/`VAULT_NAMESPACE`, and execs `vault`. `VAULT_ADDR`/`VAULT_TOKEN`/`VAULT_NAMESPACE`, and execs `vault`.
Both tools share the `shared/` package (config parsing, token cache, Vault HTTP Both tools share the `shared/` package (config parsing, token cache, Vault HTTP
client) and read the SAME config file (`~/.config/vault/vctl.yaml` or client) and read the SAME config file (`~/.config/vcm/config.yaml`)
`config.yaml`) and token cache so one `~/.config/vault/` configures everything. and token cache so one `~/.config/vcm/` configures everything.
## Structure ## Structure
``` ```
shared/ # config.go, cache.go, vault.go (+ *_test.go) shared/ # config.go, cache.go, vault.go (+ *_test.go)
vctl/main.go # vctl CLI (module main package) vcm/main.go # vcm CLI (module main package)
vctx/main.go # vctx CLI (module main package) vctx/main.go # vctx CLI (module main package)
docs/vctl.md # per-command docs docs/vcm.md # per-command docs
docs/vctx.md docs/vctx.md
go.mod # module git.unkin.net/unkin/vault-tools go.mod # module git.unkin.net/unkin/vault-tools
Makefile # build / test / lint / completions / rpm / version-bump Makefile # build / test / lint / completions / rpm / version-bump
@@ -39,9 +39,9 @@ mains to one file).
## Build / test ## Build / test
```bash ```bash
make build # -> dist/vctl, dist/vctx make build # -> dist/vcm, dist/vctx
make test # go test -race ./... make test # go test -race ./...
make completions # -> dist/completions/{vctl,vctx}.{bash,fish}, _{vctl,vctx} make completions # -> dist/completions/{vcm,vctx}.{bash,fish}, _{vcm,vctx}
make rpm # build + nfpm RPM (bundles completions) make rpm # build + nfpm RPM (bundles completions)
go build ./... && go test ./... go build ./... && go test ./...
``` ```
+2 -2
View File
@@ -1,6 +1,6 @@
# vault-tools: a Go monorepo of Vault token CLIs (vctl + vctx) sharing shared/. # vault-tools: a Go monorepo of Vault token CLIs (vcm + vctx) sharing shared/.
# Each tool is a separate main package under its own subfolder. # Each tool is a separate main package under its own subfolder.
BINARIES := vctl vctx BINARIES := vcm vctx
DIST := dist DIST := dist
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)" GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
+20 -19
View File
@@ -1,13 +1,13 @@
# vault-tools # vault-tools
A Go monorepo of small CLI tools for working with multiple Vault instances A Go monorepo of small CLI tools for working with multiple Vault instances
("contexts"). Both tools share a single config file (`~/.config/vault/`) and ("contexts"). Both tools share a single config file (`~/.config/vcm/`) and
token cache (`~/.cache/vault/`), so once a context is configured every tool token cache (`~/.cache/vcm/`), so once a context is configured every tool
knows about it. knows about it.
| Tool | Purpose | | Tool | Purpose |
| -------------------- | ----------------------------------------------------------------------- | | -------------------- | ----------------------------------------------------------------------- |
| [`vctl`](docs/vctl.md) | Log in to / renew Vault tokens per context and cache them on disk. | | [`vcm`](docs/vcm.md) | Log in to / renew Vault tokens per context and cache them on disk. |
| [`vctx`](docs/vctx.md) | Run the real `vault` CLI against a named context (sets `VAULT_ADDR`/`VAULT_TOKEN`/`VAULT_NAMESPACE`, then execs `vault`). | | [`vctx`](docs/vctx.md) | Run the real `vault` CLI against a named context (sets `VAULT_ADDR`/`VAULT_TOKEN`/`VAULT_NAMESPACE`, then execs `vault`). |
See the per-command docs in [`docs/`](docs/) for full details. See the per-command docs in [`docs/`](docs/) for full details.
@@ -16,8 +16,8 @@ See the per-command docs in [`docs/`](docs/) for full details.
```bash ```bash
# 1. Configure your vaults # 1. Configure your vaults
mkdir -p ~/.config/vault mkdir -p ~/.config/vcm
cat > ~/.config/vault/vctl.yaml <<'YAML' cat > ~/.config/vcm/config.yaml <<'YAML'
defaults: defaults:
method: ldap method: ldap
user: ben user: ben
@@ -29,40 +29,41 @@ contexts:
namespace: staging namespace: staging
YAML YAML
# 2. Log in (prompts for password), caches a token under ~/.cache/vault/ # 2. Log in (prompts for password), caches a token under ~/.cache/vcm/
vctl login sydney vcm login sydney
vctl login --all # or log in to every context at once vcm login --all # or log in to every context at once
# 3. Use the vault CLI against a context # 3. Use the vault CLI against a context
vctx --context sydney kv put kv/foo/bar secret=baz vctx --context sydney kv put kv/foo/bar secret=baz
vctx --context staging/sydney kv list kv/ vctx --context staging/sydney kv list kv/
# 4. Keep tokens fresh # 4. Keep tokens fresh
vctl renew --all vcm renew --all
# See what's configured and which tokens are still valid # See what's configured and which tokens are still valid
vctl list vcm list
``` ```
## Configuration ## Configuration
Both tools read the first of `~/.config/vault/vctl.yaml` or Both tools read `~/.config/vcm/config.yaml` (`$XDG_CONFIG_HOME` honoured). The
`~/.config/vault/config.yaml` that exists (`$XDG_CONFIG_HOME` honoured). The
file maps context names to a vault address plus optional per-context overrides file maps context names to a vault address plus optional per-context overrides
(`method`, `user`, `namespace`, `path`) with file-level `defaults`. Context (`method`, `user`, `namespace`, `path`) with file-level `defaults`. Context
names may contain slashes, which nest on disk in the token cache. See names may contain slashes, which nest on disk in the token cache. See
[docs/vctl.md](docs/vctl.md#configuration) for the full schema and resolution [docs/vcm.md](docs/vcm.md#configuration) for the full schema and resolution
rules. rules.
Tokens are cached under `~/.cache/vault/<context>` (`$XDG_CACHE_HOME` honoured) Tokens are cached under `~/.cache/vcm/<context>` (`$XDG_CACHE_HOME` honoured)
as JSON with restrictive permissions (files `0600`, dirs `0700`), storing the with restrictive permissions (files `0600`, dirs `0700`), storing the token
token plus its accessor, policies, TTL/expiry and renewable flag. plus its accessor, policies, TTL/expiry and renewable flag. Files are
encrypted with a per-boot key in the kernel user keyring; after a reboot the
tokens show as locked, so run `vcm login` again.
## Layout ## Layout
``` ```
shared/ # config parsing, token cache, Vault HTTP client (shared lib + tests) shared/ # config parsing, token cache, Vault HTTP client (shared lib + tests)
vctl/ # vctl CLI (main package) vcm/ # vcm CLI (main package)
vctx/ # vctx CLI (main package) vctx/ # vctx CLI (main package)
docs/ # per-command documentation docs/ # per-command documentation
packaging/nfpm.yaml# nfpm spec (envsubst-templated) for the RPM (both binaries + completions) packaging/nfpm.yaml# nfpm spec (envsubst-templated) for the RPM (both binaries + completions)
@@ -74,7 +75,7 @@ Makefile # build / test / completions / rpm / version-bump targets
## Building ## Building
```bash ```bash
make build # build vctl + vctx into dist/ make build # build vcm + vctx into dist/
make test # go test -race ./... make test # go test -race ./...
make completions # generate bash/zsh/fish completions into dist/completions/ make completions # generate bash/zsh/fish completions into dist/completions/
make rpm # build + package an RPM (needs nfpm) make rpm # build + package an RPM (needs nfpm)
@@ -89,7 +90,7 @@ make patch # or: make minor / make major
``` ```
which creates the next semver tag and pushes it. The release pipeline then which creates the next semver tag and pushes it. The release pipeline then
tests, cross-compiles both binaries (linux/darwin × amd64/arm64), builds an RPM tests, builds both binaries for linux amd64/arm64, builds an RPM
(bundling shell completions), PUTs the RPM to the artifactapi local `rpm-internal` (bundling shell completions), PUTs the RPM to the artifactapi local `rpm-internal`
yum repo, and cuts a Gitea release with the binaries + checksums attached. yum repo, and cuts a Gitea release with the binaries + checksums attached.
+1 -1
View File
@@ -1,5 +1,5 @@
// Package vaulttools is the module root for the vault-tools monorepo. The // Package vaulttools is the module root for the vault-tools monorepo. The
// shipped commands live in their own subfolders (vctl/, vctx/) and share the // shipped commands live in their own subfolders (vcm/, vctx/) and share the
// shared/ library; this file exists so the module root is itself a buildable Go // shared/ library; this file exists so the module root is itself a buildable Go
// package (some tooling, e.g. the pre-commit go-vet hook, runs `go list` here). // package (some tooling, e.g. the pre-commit go-vet hook, runs `go list` here).
package vaulttools package vaulttools
+57 -31
View File
@@ -1,19 +1,19 @@
# vctl # vcm
`vctl` manages Vault tokens for multiple vault instances ("contexts"). It logs `vcm` manages Vault tokens for multiple vault instances ("contexts"). It logs
in to (or renews tokens for) one or all configured contexts and caches each in to (or renews tokens for) one or all configured contexts and caches each
resulting token under `~/.cache/vault/<context>` for use by resulting token under `~/.cache/vcm/<context>` for use by
[`vctx`](vctx.md) and other tooling. [`vctx`](vctx.md) and other tooling.
## Synopsis ## Synopsis
``` ```
vctl login <context> # log in to one context vcm login <context> # log in to one context
vctl login --all # log in to every configured context vcm login --all # log in to every configured context
vctl renew <context> # renew one context's cached token vcm renew <context> # renew one context's cached token
vctl renew --all # renew every context that has a cached token vcm renew --all # renew every context that has a cached token
vctl list # show contexts + cached-token status vcm list # show contexts + cached-token status
vctl version vcm version
``` ```
## Flags ## Flags
@@ -23,22 +23,25 @@ Both `login` and `renew` accept:
| Flag | Default | Description | | Flag | Default | Description |
| -------------- | -------------------------------- | -------------------------------------------------- | | -------------- | -------------------------------- | -------------------------------------------------- |
| `--all` | `false` | Operate on every configured context. | | `--all` | `false` | Operate on every configured context. |
| `--method <m>` | context/config, else `ldap` | Override the auth method (`ldap`, `userpass`, `okta`, `radius`, `token`). | | `--method <m>` | context/config, else `ldap` | Override the auth method (`ldap`, `userpass`, `okta`, `radius`, `token`, `oidc`). |
| `--user <u>` | context/config, else `$USER` | Override the login username. | | `--user <u>` | context/config, else `$USER` | Override the login username. |
`login` also accepts:
| Flag | Default | Description |
| -------------- | -------------------------------- | -------------------------------------------------- |
| `--role <r>` | context `role`, else mount default | OIDC role to log in with. |
| `--listen <a>` | context `oidc_listen`, else `localhost:8250` | OIDC callback listen address. |
A context name or `--all` is required (but not both). A context name or `--all` is required (but not both).
## Configuration ## Configuration
`vctl` reads the first of these files that exists: `vcm` reads `~/.config/vcm/config.yaml` (`$XDG_CONFIG_HOME` is honoured in
place of `~/.config`).
1. `~/.config/vault/vctl.yaml`
2. `~/.config/vault/config.yaml`
(`$XDG_CONFIG_HOME` is honoured in place of `~/.config`.)
```yaml ```yaml
# ~/.config/vault/vctl.yaml # ~/.config/vcm/config.yaml
defaults: defaults:
method: ldap # applied to any context that doesn't set its own method: ldap # applied to any context that doesn't set its own
user: ben user: ben
@@ -57,6 +60,11 @@ contexts:
address: https://vault-legacy.example.net address: https://vault-legacy.example.net
method: userpass method: userpass
path: userpass2 # override the auth mount path (default = method) path: userpass2 # override the auth mount path (default = method)
sydney/admin: # OIDC: one context per role
address: https://vault.syd1.au.unkin.net
method: oidc
role: admin
``` ```
### Field resolution ### Field resolution
@@ -68,6 +76,9 @@ For each context, every field is resolved with the chain
- `user` — built-in default `$USER`. - `user` — built-in default `$USER`.
- `namespace` — no built-in default (unset means the root namespace). - `namespace` — no built-in default (unset means the root namespace).
- `path` — the auth mount path; defaults to the resolved `method`. - `path` — the auth mount path; defaults to the resolved `method`.
- `role` — OIDC role; unset uses the mount's `default_role`.
- `oidc_listen` — OIDC callback listener on a loopback host (`localhost`,
`127.0.0.1` or `[::1]`); defaults to `localhost:8250`.
`--method` / `--user` on the command line override the resolved values. When `--method` / `--user` on the command line override the resolved values. When
`--method` changes the method and the context did not pin an explicit `path`, `--method` changes the method and the context did not pin an explicit `path`,
@@ -75,19 +86,31 @@ the auth path follows the new method.
## Auth methods ## Auth methods
- **Password methods** (`ldap`, `userpass`, `okta`, `radius`): `vctl` prompts - **Password methods** (`ldap`, `userpass`, `okta`, `radius`): `vcm` prompts
for a password (no echo) and POSTs to `auth/<path>/login/<user>`. for a password (no echo) and POSTs to `auth/<path>/login/<user>`.
- **`token`**: `vctl` prompts for a raw Vault token (no echo), verifies it with - **`token`**: `vcm` prompts for a raw Vault token (no echo), verifies it with
`auth/token/lookup-self`, and caches its details. `auth/token/lookup-self`, and caches its details.
- **`oidc`**: the same flow as `vault login -method=oidc`. `vcm` listens on
`oidc_listen`, requests `auth/<path>/oidc/auth_url` with
`redirect_uri=http://<oidc_listen>/oidc/callback`, opens the URL with
`xdg-open` (printing it too), and exchanges the returned code via
`auth/<path>/oidc/callback`. It gives up after 2 minutes. The redirect URI
must be in the role's `allowed_redirect_uris`. `--all` runs each OIDC
context's browser login in turn.
## Token cache ## Token cache
Tokens are written to `~/.cache/vault/<context>` (honouring `$XDG_CACHE_HOME`) Tokens are written to `~/.cache/vcm/<context>` (honouring `$XDG_CACHE_HOME`)
as JSON. Slash contexts nest: `staging/sydney` → encrypted (AES-256-GCM) with a random key held in the kernel user keyring
`~/.cache/vault/staging/sydney`. Parent directories are created `0700` and token (description `vcm`, shared with `vctx`). The key lives only until reboot, so
after a reboot cached tokens can no longer be decrypted: `vcm list` shows
them as `locked — run 'vcm login <context>'` and `vctx` errors with the same
hint. Files written by older, plaintext versions show as locked too; logging
in again replaces them. Slash contexts nest: `staging/sydney` →
`~/.cache/vcm/staging/sydney`. Parent directories are created `0700` and token
files are written `0600`. files are written `0600`.
Each file stores enough to inspect and later revoke the token: The decrypted payload stores enough to inspect and later revoke the token:
```json ```json
{ {
@@ -111,29 +134,32 @@ The `accessor` lets you revoke the token later without exposing the secret
```bash ```bash
# Log in to one context (prompts for password) # Log in to one context (prompts for password)
vctl login sydney vcm login sydney
# Log in everywhere at once # Log in everywhere at once
vctl login --all vcm login --all
# Log in to a context overriding the method + user # Log in to a context overriding the method + user
vctl login sydney --method okta --user someone.else vcm login sydney --method okta --user someone.else
# OIDC login with a different role than the context's
vcm login sydney/admin --role readonly
# Renew a slash-named context # Renew a slash-named context
vctl renew staging/sydney vcm renew staging/sydney
# Renew everything that currently has a cached token # Renew everything that currently has a cached token
vctl renew --all vcm renew --all
# See what is configured and which tokens are still valid # See what is configured and which tokens are still valid
vctl list vcm list
``` ```
## Shell completion ## Shell completion
```bash ```bash
vctl completion bash > /etc/bash_completion.d/vctl vcm completion bash > /etc/bash_completion.d/vcm
vctl completion zsh > ~/.zsh/completions/_vctl vcm completion zsh > ~/.zsh/completions/_vcm
``` ```
Context names complete dynamically from the config file (the RPM installs these Context names complete dynamically from the config file (the RPM installs these
+54 -12
View File
@@ -1,14 +1,14 @@
# vctx # vctx
`vctx` is a thin, context-aware wrapper around the real `vault` CLI. It resolves `vctx` is a thin, context-aware wrapper around the real `vault` CLI. It resolves
a context (using the same config file and token cache as [`vctl`](vctl.md)), a context (using the same config file and token cache as [`vcm`](vcm.md)),
sets `VAULT_ADDR`, `VAULT_TOKEN` and `VAULT_NAMESPACE` for that single sets `VAULT_ADDR`, `VAULT_TOKEN` and `VAULT_NAMESPACE` for that single
invocation only, and execs `vault` with the remaining arguments. invocation only, and execs `vault` with the remaining arguments.
## Synopsis ## Synopsis
``` ```
vctx --context <context> <any vault args...> vctx --context <context> [--format <fmt>] [--color auto|always|never] <any vault args...>
vctx version vctx version
``` ```
@@ -18,26 +18,68 @@ vctx version
vctx --context sydney kv put kv/foo/bar secret=baz vctx --context sydney kv put kv/foo/bar secret=baz
``` ```
1. Resolves the `sydney` context from `~/.config/vault/vctl.yaml` (or 1. Resolves the `sydney` context from `~/.config/vcm/config.yaml` — the exact file `vcm` uses.
`config.yaml`) — the exact file `vctl` uses. 2. Loads the cached token from `~/.cache/vcm/sydney` (errors with a hint to
2. Loads the cached token from `~/.cache/vault/sydney` (errors with a hint to run `vcm login sydney` if none exists or it is locked after a reboot).
run `vctl login sydney` if none exists).
3. Sets, for this process only: 3. Sets, for this process only:
- `VAULT_ADDR` = the context's address - `VAULT_ADDR` = the context's address
- `VAULT_TOKEN` = the cached token - `VAULT_TOKEN` = the cached token
- `VAULT_NAMESPACE` = the context's namespace (only if non-empty; falls back - `VAULT_NAMESPACE` = the context's namespace (only if non-empty; falls back
to the namespace recorded in the cached token) to the namespace recorded in the cached token)
- `VAULT_FORMAT` = `--format` if given, else picked from the command (see
[Output format](#output-format))
4. `exec`s the `vault` binary (found on `PATH`) with everything after the 4. `exec`s the `vault` binary (found on `PATH`) with everything after the
context. Because it replaces the process, `vault`'s exit status, signals and context. Because it replaces the process, `vault`'s exit status, signals and
TTY behaviour pass straight through. TTY behaviour pass straight through (json/yaml output on a terminal is
instead run as a child and colourised; see [Colour](#colour)).
The ambient `VAULT_ADDR` / `VAULT_TOKEN` in your shell are ignored for the call The ambient `VAULT_ADDR` / `VAULT_TOKEN` / `VAULT_NAMESPACE` in your shell are ignored for the call
— `vctx` always targets the chosen context. — `vctx` always targets the chosen context.
## The `--context` flag ## Output format
`--context` is the only flag `vctx` consumes; it must come **before** the vault Without `--format`, `vctx` sets `VAULT_FORMAT` from the vault command:
command. Everything from the first non-flag argument onward is handed to `vault`
| Command | `VAULT_FORMAT` |
|--------------------------------------------|----------------|
| `kv list ...` | `table` |
| `kv get ...` | `yaml` |
| any path containing `/creds/` (read/write) | `yaml` |
| anything else | unset (vault default) |
It is left alone when `VAULT_FORMAT` is already set in the environment or the
vault args include `-format` or `-field`. `--format` (`table`, `json`, `yaml`,
`pretty`; `raw` for `vault read` only) always sets it, overriding the environment.
## Colour
When the effective format is `json` or `yaml` (from the table above,
`--format`, a `-format` vault arg, or `VAULT_FORMAT`), `vctx` colourises vault's
output like `jq`/`yq`. To do so it runs `vault` as a child instead of exec'ing
it: stdin and stderr pass straight through, stdout is buffered and re-emitted
coloured, SIGTERM/SIGHUP are forwarded (SIGINT already reaches vault from the
terminal), and vault's exit code is returned
(`128+n` if killed by signal `n`).
`--color` controls this:
| Value | Behaviour |
|----------|-----------|
| `auto` | colour only when stdout is a terminal, `NO_COLOR` is unset/empty and `TERM` is not `dumb` (default) |
| `always` | always colour |
| `never` | never colour |
Without colour (piped output, `NO_COLOR`, `table`/`raw`/`pretty` formats)
`vctx` execs `vault` exactly as before, so scripts see unchanged output.
Streaming or interactive commands (`monitor`, `debug`, `events`, `ssh`,
`agent`, `proxy`, `server`, `login`, `operator`, and `write`/`patch`, which
may prompt for MFA) are always exec'd and never coloured. `JQ_COLORS`
overrides the JSON palette as in `jq`.
## Flags
`--context`, `--format` and `--color` are the only flags `vctx` consumes; they must come
**before** the vault command. Everything from the first non-flag argument onward is handed to `vault`
untouched, so vault's own flags work normally: untouched, so vault's own flags work normally:
```bash ```bash
@@ -68,7 +110,7 @@ vctx --context sydney token lookup
## Requirements ## Requirements
- The `vault` CLI must be installed and on `PATH`. - The `vault` CLI must be installed and on `PATH`.
- A token must already be cached for the context (`vctl login <context>`). - A token must already be cached for the context (`vcm login <context>`).
## Shell completion ## Shell completion
+37
View File
@@ -0,0 +1,37 @@
# Example vault-tools config. Copy to ~/.config/vcm/config.yaml
# and edit. Read by both vcm and vctx.
# File-level defaults applied to any context that doesn't set its own value.
defaults:
method: ldap # ldap | userpass | okta | radius | token | oidc
user: ben # login user (defaults to $USER if omitted)
# namespace: "" # optional default Vault namespace
contexts:
# Simplest form: just an address; inherits method/user from defaults.
sydney:
address: https://vault.syd1.au.unkin.net
# Slash-named context nests on disk as ~/.cache/vcm/staging/sydney.
staging/sydney:
address: https://vault-staging.syd1.au.unkin.net
namespace: staging
user: svc-ben # per-context override
# Custom auth method + mount path.
legacy:
address: https://vault-legacy.example.net
method: userpass
path: userpass2 # auth mount path (defaults to the method name)
# OIDC: one context per role, all on the same Vault.
sydney/admin:
address: https://vault.syd1.au.unkin.net
method: oidc
role: admin # OIDC role (default: the mount's default_role)
sydney/readonly:
address: https://vault.syd1.au.unkin.net
method: oidc
path: oidc # auth mount path (defaults to the method name)
role: readonly
# oidc_listen: localhost:8250 # local callback listener (default)
-25
View File
@@ -1,25 +0,0 @@
# Example vault-tools config. Copy to ~/.config/vault/vctl.yaml (or config.yaml)
# and edit. Read by both vctl and vctx.
# File-level defaults applied to any context that doesn't set its own value.
defaults:
method: ldap # ldap | userpass | okta | radius | token
user: ben # login user (defaults to $USER if omitted)
# namespace: "" # optional default Vault namespace
contexts:
# Simplest form: just an address; inherits method/user from defaults.
sydney:
address: https://vault.syd1.au.unkin.net
# Slash-named context nests on disk as ~/.cache/vault/staging/sydney.
staging/sydney:
address: https://vault-staging.syd1.au.unkin.net
namespace: staging
user: svc-ben # per-context override
# Custom auth method + mount path.
legacy:
address: https://vault-legacy.example.net
method: userpass
path: userpass2 # auth mount path (defaults to the method name)
+4 -3
View File
@@ -1,15 +1,16 @@
module git.unkin.net/unkin/vault-tools module git.unkin.net/unkin/vault-tools
go 1.25 go 1.25.0
require ( require (
git.unkin.net/unkin/golib v0.2.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
golang.org/x/term v0.30.0 golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.31.0 // indirect
) )
+6 -4
View File
@@ -1,3 +1,5 @@
git.unkin.net/unkin/golib v0.2.0 h1:PrgjEJNhavODOKXdf2WCF2z0eVT+NsB4EoVlX7TOvwE=
git.unkin.net/unkin/golib v0.2.0/go.mod h1:3lVQcPSwJm8ExD64U5edmVmdo9gPICkKJ4pbrT4bulo=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
@@ -7,10 +9,10 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+9 -9
View File
@@ -1,5 +1,5 @@
--- ---
# nfpm config for building the vault-tools RPM (vctl + vctx). # nfpm config for building the vault-tools RPM (vcm + vctx).
# Rendered through envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`. # Rendered through envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`.
name: ${PACKAGE_NAME} name: ${PACKAGE_NAME}
@@ -24,8 +24,8 @@ provides:
contents: contents:
# The CLI binaries. # The CLI binaries.
- src: dist/vctl - src: dist/vcm
dst: /usr/bin/vctl dst: /usr/bin/vcm
file_info: file_info:
mode: 0755 mode: 0755
owner: root owner: root
@@ -38,16 +38,16 @@ contents:
group: root group: root
# Shell completions (generated by scripts/build-rpm.sh before packaging). # Shell completions (generated by scripts/build-rpm.sh before packaging).
- src: dist/completions/vctl.bash - src: dist/completions/vcm.bash
dst: /usr/share/bash-completion/completions/vctl dst: /usr/share/bash-completion/completions/vcm
file_info: file_info:
mode: 0644 mode: 0644
- src: dist/completions/_vctl - src: dist/completions/_vcm
dst: /usr/share/zsh/site-functions/_vctl dst: /usr/share/zsh/site-functions/_vcm
file_info: file_info:
mode: 0644 mode: 0644
- src: dist/completions/vctl.fish - src: dist/completions/vcm.fish
dst: /usr/share/fish/vendor_completions.d/vctl.fish dst: /usr/share/fish/vendor_completions.d/vcm.fish
file_info: file_info:
mode: 0644 mode: 0644
- src: dist/completions/vctx.bash - src: dist/completions/vctx.bash
+3 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# Package the (already built) vctl and vctx binaries into an RPM with nfpm, # Package the (already built) vcm and vctx binaries into an RPM with nfpm,
# bundling generated bash/zsh/fish shell completions. # bundling generated bash/zsh/fish shell completions.
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG) # Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
# #
@@ -12,7 +12,7 @@ cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}" VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}" # strip a leading v VERSION="${VERSION#v}" # strip a leading v
PACKAGE="vault-tools" PACKAGE="vault-tools"
BINARIES=(vctl vctx) BINARIES=(vcm vctx)
DIST="dist" DIST="dist"
for b in "${BINARIES[@]}"; do for b in "${BINARIES[@]}"; do
@@ -37,7 +37,7 @@ export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1" export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64" export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux" export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="CLI tools for managing Vault tokens across multiple vault instances: vctl (per-context login/renew) and vctx (context-aware vault CLI wrapper)" export PACKAGE_DESCRIPTION="CLI tools for managing Vault tokens across multiple vault instances: vcm (per-context login/renew) and vctx (context-aware vault CLI wrapper)"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>" export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-tools" export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-tools"
export PACKAGE_LICENSE="MIT" export PACKAGE_LICENSE="MIT"
+59 -15
View File
@@ -2,13 +2,24 @@ package shared
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"git.unkin.net/unkin/golib/keyring"
) )
// KeyringDesc names the kernel keyring key that seals cached tokens; vcm and
// vctx share it. Tests override it.
var KeyringDesc = "vcm"
// ErrLocked means a cached token exists but cannot be decrypted: the per-boot
// keyring key is gone (reboot) or the file predates encryption.
var ErrLocked = errors.New("token locked")
// Token is the cached result of a successful login. It stores enough detail to // Token is the cached result of a successful login. It stores enough detail to
// later inspect or revoke the token (accessor, policies) and to reason about // later inspect or revoke the token (accessor, policies) and to reason about
// its lifetime (issued/expiry, renewable) — not just the secret itself. // its lifetime (issued/expiry, renewable) — not just the secret itself.
@@ -39,7 +50,7 @@ type Token struct {
ExpiresAt time.Time `json:"expires_at,omitempty"` ExpiresAt time.Time `json:"expires_at,omitempty"`
} }
// CacheDir returns the XDG_CACHE_HOME/vault directory that holds token files. // CacheDir returns the XDG_CACHE_HOME/vcm directory that holds token files.
func CacheDir() string { func CacheDir() string {
base := os.Getenv("XDG_CACHE_HOME") base := os.Getenv("XDG_CACHE_HOME")
if base == "" { if base == "" {
@@ -82,8 +93,9 @@ func validateContextName(context string) error {
return nil return nil
} }
// SaveToken writes a token to its cache path as JSON, creating parent // SaveToken writes a token to its cache path as keyring-sealed JSON, creating
// directories (0700) as needed and writing the file with 0600 permissions. // parent directories (0700) as needed and writing the file with 0600
// permissions.
func SaveToken(t *Token) error { func SaveToken(t *Token) error {
path, err := TokenPath(t.Context) path, err := TokenPath(t.Context)
if err != nil { if err != nil {
@@ -96,24 +108,38 @@ func SaveToken(t *Token) error {
if err != nil { if err != nil {
return fmt.Errorf("encoding token: %w", err) return fmt.Errorf("encoding token: %w", err)
} }
data = append(data, '\n') k, err := keyring.Open(KeyringDesc)
if err != nil {
return err
}
sealed, err := k.Seal(data)
if err != nil {
return fmt.Errorf("encrypting token: %w", err)
}
// Write via a temp file + rename so a token file is never left partially // Write via a unique temp file + rename so a token file is never left
// written, and create it 0600 from the start (never briefly world-readable). // partially written and concurrent writers never share a temp file.
tmp := path + ".tmp" f, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
if err := os.WriteFile(tmp, data, 0o600); err != nil { if err != nil {
return fmt.Errorf("writing token: %w", err) return fmt.Errorf("writing token: %w", err)
} }
if err := os.Rename(tmp, path); err != nil { defer func() { _ = os.Remove(f.Name()) }()
_ = os.Remove(tmp) _, err = f.Write(sealed)
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
return fmt.Errorf("writing token: %w", err)
}
if err := os.Rename(f.Name(), path); err != nil {
return fmt.Errorf("saving token: %w", err) return fmt.Errorf("saving token: %w", err)
} }
return nil return nil
} }
// LoadToken reads and decodes a cached token for a context. A missing token // LoadToken reads and decrypts a cached token for a context. A missing token
// returns an error wrapping os.ErrNotExist so callers can detect "not logged // wraps os.ErrNotExist; one that cannot be decrypted (key lost on reboot, or a
// in" with errors.Is. // legacy plaintext file) wraps ErrLocked. Both mean "run vcm login".
func LoadToken(context string) (*Token, error) { func LoadToken(context string) (*Token, error) {
path, err := TokenPath(context) path, err := TokenPath(context)
if err != nil { if err != nil {
@@ -122,17 +148,35 @@ func LoadToken(context string) (*Token, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil, fmt.Errorf("no cached token for context %q (run 'vctl login %s'): %w", context, context, os.ErrNotExist) return nil, fmt.Errorf("no cached token for context %q (run 'vcm login %s'): %w", context, context, os.ErrNotExist)
} }
return nil, fmt.Errorf("reading token %s: %w", path, err) return nil, fmt.Errorf("reading token %s: %w", path, err)
} }
if json.Valid(data) {
return nil, lockedErr(context)
}
k, err := keyring.Open(KeyringDesc)
if err != nil {
return nil, err
}
plain, err := k.Unseal(data)
if errors.Is(err, keyring.ErrKeyUnavailable) {
return nil, lockedErr(context)
}
if err != nil {
return nil, fmt.Errorf("decrypting token %s: %w", path, err)
}
var t Token var t Token
if err := json.Unmarshal(data, &t); err != nil { if err := json.Unmarshal(plain, &t); err != nil {
return nil, fmt.Errorf("parsing token %s: %w", path, err) return nil, fmt.Errorf("parsing token %s: %w", path, err)
} }
return &t, nil return &t, nil
} }
func lockedErr(context string) error {
return fmt.Errorf("cached token for context %q is locked (run 'vcm login %s'): %w", context, context, ErrLocked)
}
// DeleteToken removes a context's cached token file, if present. // DeleteToken removes a context's cached token file, if present.
func DeleteToken(context string) error { func DeleteToken(context string) error {
path, err := TokenPath(context) path, err := TokenPath(context)
+63 -63
View File
@@ -1,7 +1,7 @@
// Package shared holds the plumbing common to the vault-tools CLIs (vctl and // Package shared holds the plumbing common to the vault-tools CLIs (vcm and
// vctx): config-file parsing, the on-disk token cache, and a small Vault HTTP // vctx): config-file parsing, the on-disk token cache, and a small Vault HTTP
// API client. Both tools read the SAME config file and token cache so a single // API client. Both tools read the SAME config file and token cache so a single
// ~/.config/vault/ configures every tool in the family. // ~/.config/vcm/ configures every tool in the family.
package shared package shared
import ( import (
@@ -19,12 +19,10 @@ const (
DefaultMethod = "ldap" DefaultMethod = "ldap"
// appDir is the per-user config/cache subdirectory both tools live under. // appDir is the per-user config/cache subdirectory both tools live under.
appDir = "vault" appDir = "vcm"
)
// configFileNames are the accepted config file basenames, tried in order. The configFileName = "config.yaml"
// first one that exists wins. )
var configFileNames = []string{"vctl.yaml", "config.yaml"}
// Context is a single vault instance the tools can target. Every field except // Context is a single vault instance the tools can target. Every field except
// Address is optional and, when empty, falls back to the file-level Defaults // Address is optional and, when empty, falls back to the file-level Defaults
@@ -32,7 +30,7 @@ var configFileNames = []string{"vctl.yaml", "config.yaml"}
type Context struct { type Context struct {
// Address is the Vault API base URL, e.g. https://vault.syd1.au.unkin.net. // Address is the Vault API base URL, e.g. https://vault.syd1.au.unkin.net.
Address string `yaml:"address"` Address string `yaml:"address"`
// Method is the auth method (ldap, userpass, okta, radius, token, ...). // Method is the auth method (ldap, userpass, okta, radius, token, oidc).
Method string `yaml:"method,omitempty"` Method string `yaml:"method,omitempty"`
// User is the login username (LDAP/userpass/...); ignored for token auth. // User is the login username (LDAP/userpass/...); ignored for token auth.
User string `yaml:"user,omitempty"` User string `yaml:"user,omitempty"`
@@ -40,6 +38,10 @@ type Context struct {
Namespace string `yaml:"namespace,omitempty"` Namespace string `yaml:"namespace,omitempty"`
// Path overrides the auth mount path (defaults to Method), e.g. "ldap2". // Path overrides the auth mount path (defaults to Method), e.g. "ldap2".
Path string `yaml:"path,omitempty"` Path string `yaml:"path,omitempty"`
// Role is the OIDC role to log in with (empty = the mount's default_role).
Role string `yaml:"role,omitempty"`
// OIDCListen is the local OIDC callback listener (default localhost:8250).
OIDCListen string `yaml:"oidc_listen,omitempty"`
} }
// Defaults holds file-level fallbacks applied to every context that does not // Defaults holds file-level fallbacks applied to every context that does not
@@ -64,15 +66,26 @@ type Config struct {
// ResolvedContext is a Context with all defaults applied, ready to use. Method // ResolvedContext is a Context with all defaults applied, ready to use. Method
// and User are always populated. // and User are always populated.
type ResolvedContext struct { type ResolvedContext struct {
Name string Name string
Address string Address string
Method string Method string
User string User string
Namespace string Namespace string
Path string Path string
Role string
OIDCListen string
} }
// ConfigDir returns the XDG_CONFIG_HOME/vault directory. // Overrides are CLI-flag values that replace resolved context fields; empty
// fields leave the resolved value untouched.
type Overrides struct {
Method string
User string
Role string
Listen string
}
// ConfigDir returns the XDG_CONFIG_HOME/vcm directory.
func ConfigDir() string { func ConfigDir() string {
base := os.Getenv("XDG_CONFIG_HOME") base := os.Getenv("XDG_CONFIG_HOME")
if base == "" { if base == "" {
@@ -82,45 +95,32 @@ func ConfigDir() string {
return filepath.Join(base, appDir) return filepath.Join(base, appDir)
} }
// ConfigPath returns the path of the first existing config file, or the path // ConfigPath returns the path of the config file, whether or not it exists.
// the file would take (the first candidate) when none exists yet.
func ConfigPath() string { func ConfigPath() string {
dir := ConfigDir() return filepath.Join(ConfigDir(), configFileName)
for _, name := range configFileNames {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err == nil {
return p
}
}
return filepath.Join(dir, configFileNames[0])
} }
// Load reads and parses the first config file found in ConfigDir. A missing // Load reads and parses the config file. A missing config file is not an
// config file is not an error: an empty Config is returned so callers can give // error: an empty Config is returned so callers can give a helpful "no
// a helpful "no contexts configured" message. // contexts configured" message.
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := &Config{Contexts: map[string]Context{}} cfg := &Config{Contexts: map[string]Context{}}
dir := ConfigDir() p := ConfigPath()
for _, name := range configFileNames { data, err := os.ReadFile(p)
p := filepath.Join(dir, name) if err != nil {
data, err := os.ReadFile(p) if os.IsNotExist(err) {
if err != nil { return cfg, nil
if os.IsNotExist(err) {
continue
}
return cfg, fmt.Errorf("reading config %s: %w", p, err)
} }
if err := yaml.Unmarshal(data, cfg); err != nil { return cfg, fmt.Errorf("reading config %s: %w", p, err)
return cfg, fmt.Errorf("parsing config %s: %w", p, err)
}
if cfg.Contexts == nil {
cfg.Contexts = map[string]Context{}
}
cfg.path = p
return cfg, nil
} }
if err := yaml.Unmarshal(data, cfg); err != nil {
return cfg, fmt.Errorf("parsing config %s: %w", p, err)
}
if cfg.Contexts == nil {
cfg.Contexts = map[string]Context{}
}
cfg.path = p
return cfg, nil return cfg, nil
} }
@@ -156,34 +156,34 @@ func (c *Config) Resolve(name string) (ResolvedContext, error) {
path := firstNonEmpty(ctx.Path, method) path := firstNonEmpty(ctx.Path, method)
return ResolvedContext{ return ResolvedContext{
Name: name, Name: name,
Address: ctx.Address, Address: ctx.Address,
Method: method, Method: method,
User: user, User: user,
Namespace: namespace, Namespace: namespace,
Path: path, Path: path,
Role: ctx.Role,
OIDCListen: firstNonEmpty(ctx.OIDCListen, DefaultOIDCListen),
}, nil }, nil
} }
// ResolveWithOverrides is Resolve plus explicit CLI-flag overrides for method // ResolveWithOverrides is Resolve plus explicit CLI-flag overrides. When the
// and user; an empty override leaves the resolved value untouched. When the
// method is overridden and the context did not pin an explicit auth path, the // method is overridden and the context did not pin an explicit auth path, the
// auth path follows the new method. // auth path follows the new method.
func (c *Config) ResolveWithOverrides(name, method, user string) (ResolvedContext, error) { func (c *Config) ResolveWithOverrides(name string, o Overrides) (ResolvedContext, error) {
rc, err := c.Resolve(name) rc, err := c.Resolve(name)
if err != nil { if err != nil {
return rc, err return rc, err
} }
if method != "" { if o.Method != "" {
pinnedPath := c.Contexts[name].Path != "" rc.Method = o.Method
rc.Method = method if c.Contexts[name].Path == "" {
if !pinnedPath { rc.Path = o.Method
rc.Path = method
} }
} }
if user != "" { rc.User = firstNonEmpty(o.User, rc.User)
rc.User = user rc.Role = firstNonEmpty(o.Role, rc.Role)
} rc.OIDCListen = firstNonEmpty(o.Listen, rc.OIDCListen)
return rc, nil return rc, nil
} }
+47 -21
View File
@@ -7,7 +7,7 @@ import (
) )
// withConfigDir points XDG_CONFIG_HOME at a temp dir and writes the given // withConfigDir points XDG_CONFIG_HOME at a temp dir and writes the given
// config file into <tmp>/vault/<name>, returning the temp base. // config file into <tmp>/vcm/<name>, returning the temp base.
func withConfigDir(t *testing.T, name, content string) string { func withConfigDir(t *testing.T, name, content string) string {
t.Helper() t.Helper()
base := t.TempDir() base := t.TempDir()
@@ -42,7 +42,7 @@ contexts:
` `
func TestLoadAndContextNames(t *testing.T) { func TestLoadAndContextNames(t *testing.T) {
withConfigDir(t, "vctl.yaml", sampleConfig) withConfigDir(t, "config.yaml", sampleConfig)
cfg, err := Load() cfg, err := Load()
if err != nil { if err != nil {
@@ -63,18 +63,7 @@ func TestLoadAndContextNames(t *testing.T) {
} }
} }
func TestConfigPathPrefersVctlYaml(t *testing.T) { func TestConfigPath(t *testing.T) {
base := withConfigDir(t, "vctl.yaml", sampleConfig)
// also write a config.yaml; vctl.yaml should win
if err := os.WriteFile(filepath.Join(base, appDir, "config.yaml"), []byte(sampleConfig), 0o600); err != nil {
t.Fatal(err)
}
if got, want := ConfigPath(), filepath.Join(base, appDir, "vctl.yaml"); got != want {
t.Errorf("ConfigPath = %q, want %q", got, want)
}
}
func TestConfigPathFallsBackToConfigYaml(t *testing.T) {
base := withConfigDir(t, "config.yaml", sampleConfig) base := withConfigDir(t, "config.yaml", sampleConfig)
if got, want := ConfigPath(), filepath.Join(base, appDir, "config.yaml"); got != want { if got, want := ConfigPath(), filepath.Join(base, appDir, "config.yaml"); got != want {
t.Errorf("ConfigPath = %q, want %q", got, want) t.Errorf("ConfigPath = %q, want %q", got, want)
@@ -93,7 +82,7 @@ func TestLoadMissingConfigIsNotError(t *testing.T) {
} }
func TestResolveAppliesDefaults(t *testing.T) { func TestResolveAppliesDefaults(t *testing.T) {
withConfigDir(t, "vctl.yaml", sampleConfig) withConfigDir(t, "config.yaml", sampleConfig)
cfg, err := Load() cfg, err := Load()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -128,7 +117,7 @@ func TestResolveAppliesDefaults(t *testing.T) {
} }
func TestResolveUnknownContext(t *testing.T) { func TestResolveUnknownContext(t *testing.T) {
withConfigDir(t, "vctl.yaml", sampleConfig) withConfigDir(t, "config.yaml", sampleConfig)
cfg, _ := Load() cfg, _ := Load()
if _, err := cfg.Resolve("nope"); err == nil { if _, err := cfg.Resolve("nope"); err == nil {
t.Error("expected error for unknown context") t.Error("expected error for unknown context")
@@ -136,7 +125,7 @@ func TestResolveUnknownContext(t *testing.T) {
} }
func TestResolveMethodDefaultWhenUnset(t *testing.T) { func TestResolveMethodDefaultWhenUnset(t *testing.T) {
withConfigDir(t, "vctl.yaml", ` withConfigDir(t, "config.yaml", `
contexts: contexts:
bare: bare:
address: https://vault.example.net address: https://vault.example.net
@@ -156,11 +145,11 @@ contexts:
} }
func TestResolveWithOverrides(t *testing.T) { func TestResolveWithOverrides(t *testing.T) {
withConfigDir(t, "vctl.yaml", sampleConfig) withConfigDir(t, "config.yaml", sampleConfig)
cfg, _ := Load() cfg, _ := Load()
// Overriding the method also moves the auth path (context did not pin one). // Overriding the method also moves the auth path (context did not pin one).
rc, err := cfg.ResolveWithOverrides("sydney", "okta", "otheruser") rc, err := cfg.ResolveWithOverrides("sydney", Overrides{Method: "okta", User: "otheruser"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -169,7 +158,7 @@ func TestResolveWithOverrides(t *testing.T) {
} }
// legacy pins path=userpass2, so a method override must NOT change the path. // legacy pins path=userpass2, so a method override must NOT change the path.
rc, err = cfg.ResolveWithOverrides("legacy", "okta", "") rc, err = cfg.ResolveWithOverrides("legacy", Overrides{Method: "okta"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -178,7 +167,7 @@ func TestResolveWithOverrides(t *testing.T) {
} }
// Empty overrides leave resolved values untouched. // Empty overrides leave resolved values untouched.
rc, err = cfg.ResolveWithOverrides("sydney", "", "") rc, err = cfg.ResolveWithOverrides("sydney", Overrides{})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -186,3 +175,40 @@ func TestResolveWithOverrides(t *testing.T) {
t.Errorf("no-op override resolved = %+v", rc) t.Errorf("no-op override resolved = %+v", rc)
} }
} }
func TestResolveOIDC(t *testing.T) {
withConfigDir(t, "config.yaml", `
contexts:
admin:
address: https://vault.example
method: oidc
role: admins
dev:
address: https://vault.example
method: oidc
path: oidc-dev
oidc_listen: localhost:8251
`)
cfg, _ := Load()
rc, err := cfg.Resolve("admin")
if err != nil {
t.Fatal(err)
}
if rc.Path != "oidc" || rc.Role != "admins" || rc.OIDCListen != DefaultOIDCListen {
t.Errorf("admin resolved = %+v", rc)
}
rc, err = cfg.ResolveWithOverrides("dev", Overrides{Role: "readers"})
if err != nil {
t.Fatal(err)
}
if rc.Path != "oidc-dev" || rc.Role != "readers" || rc.OIDCListen != "localhost:8251" {
t.Errorf("dev resolved = %+v", rc)
}
rc, _ = cfg.ResolveWithOverrides("admin", Overrides{Listen: "127.0.0.1:9999"})
if rc.OIDCListen != "127.0.0.1:9999" || rc.Role != "admins" {
t.Errorf("listen override resolved = %+v", rc)
}
}
+132
View File
@@ -0,0 +1,132 @@
package shared
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"git.unkin.net/unkin/golib/keyring"
"golang.org/x/sys/unix"
)
// TestMain seals tokens with a run-unique key, skipping outside CI where
// keyctl is unimplemented.
func TestMain(m *testing.M) {
KeyringDesc = fmt.Sprintf("vault-tools-test:shared:%d", os.Getpid())
k, err := keyring.Open(KeyringDesc)
if errors.Is(err, unix.ENOSYS) && os.Getenv("CI") == "" {
fmt.Println("skipping: kernel keyring unavailable:", err)
os.Exit(0)
}
if err != nil {
fmt.Println(err)
os.Exit(1)
}
code := m.Run()
_ = k.Forget()
os.Exit(code)
}
func writeRaw(t *testing.T, context string, data []byte) string {
t.Helper()
path, err := TokenPath(context)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestSaveTokenSealsAtRest(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
if err := SaveToken(&Token{Context: "sydney", Token: "hvs.secret"}); err != nil {
t.Fatal(err)
}
path, _ := TokenPath("sydney")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(data, []byte("hvs.secret")) {
t.Fatal("token stored in plaintext")
}
entries, _ := os.ReadDir(filepath.Dir(path))
if len(entries) != 1 {
t.Errorf("cache dir has %d entries, want only the token file", len(entries))
}
}
func TestLoadTokenLockedAfterKeyLoss(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
if err := SaveToken(&Token{Context: "sydney", Token: "s.x"}); err != nil {
t.Fatal(err)
}
k, err := keyring.Open(KeyringDesc)
if err != nil {
t.Fatal(err)
}
if err := k.Forget(); err != nil {
t.Fatal(err)
}
_, err = LoadToken("sydney")
if !errors.Is(err, ErrLocked) || !strings.Contains(err.Error(), "vcm login sydney") {
t.Fatalf("LoadToken = %v, want ErrLocked with login hint", err)
}
if path, _ := TokenPath("sydney"); !fileExists(path) {
t.Error("locked token file was deleted")
}
}
func TestLoadTokenLegacyPlaintextIsLocked(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
if err := os.MkdirAll(CacheDir(), 0o700); err != nil {
t.Fatal(err)
}
path := writeRaw(t, "sydney", []byte(`{"context":"sydney","token":"s.x"}`+"\n"))
if _, err := LoadToken("sydney"); !errors.Is(err, ErrLocked) {
t.Fatalf("LoadToken = %v, want ErrLocked", err)
}
if !fileExists(path) {
t.Error("legacy token file was deleted")
}
}
func TestLoadTokenCorruptNamesFile(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
if err := SaveToken(&Token{Context: "sydney", Token: "s.x"}); err != nil {
t.Fatal(err)
}
path, _ := TokenPath("sydney")
data, _ := os.ReadFile(path)
data[len(data)-1] ^= 0xff
writeRaw(t, "sydney", data)
_, err := LoadToken("sydney")
if !errors.Is(err, keyring.ErrCorrupt) || !strings.Contains(err.Error(), path) {
t.Fatalf("LoadToken = %v, want ErrCorrupt naming %s", err, path)
}
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func TestSaveTokenRenameFailureLeavesNoTemp(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
path, _ := TokenPath("sydney")
if err := os.MkdirAll(filepath.Join(path, "blocker"), 0o700); err != nil {
t.Fatal(err)
}
if err := SaveToken(&Token{Context: "sydney", Token: "s.x"}); err == nil {
t.Fatal("expected rename onto a non-empty directory to fail")
}
if entries, _ := os.ReadDir(CacheDir()); len(entries) != 1 {
t.Errorf("cache dir has %d entries, want temp file cleaned up", len(entries))
}
}
+130
View File
@@ -0,0 +1,130 @@
package shared
import (
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"time"
)
// DefaultOIDCListen is the callback listener, matching the vault CLI default.
const DefaultOIDCListen = "localhost:8250"
// OIDCTimeout bounds how long LoginOIDC waits for the browser callback.
var OIDCTimeout = 2 * time.Minute
// OpenBrowser opens url in the user's browser; a package variable so tests can
// drive the callback instead.
var OpenBrowser = func(u string) error { return exec.Command("xdg-open", u).Start() }
type oidcResult struct {
tok *Token
err error
}
// LoginOIDC runs the vault CLI's OIDC flow (vault-plugin-auth-jwt cli.go):
// fetch auth_url, open the browser, receive code+state on a local callback,
// and exchange them via auth/<path>/oidc/callback.
func LoginOIDC(rc ResolvedContext) (*Token, error) {
host, _, err := net.SplitHostPort(rc.OIDCListen)
if err != nil {
return nil, fmt.Errorf("oidc listen address %q: %w", rc.OIDCListen, err)
}
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
return nil, fmt.Errorf("oidc listen address %q must be on localhost, 127.0.0.1 or [::1]", rc.OIDCListen)
}
ln, err := net.Listen("tcp", rc.OIDCListen)
if err != nil {
return nil, fmt.Errorf("oidc callback listener: %w", err)
}
redirectURI := "http://" + rc.OIDCListen + "/oidc/callback"
nonce := rand.Text()
data, err := doRequest(http.MethodPost, rc.Address, "auth/"+rc.Path+"/oidc/auth_url", rc.Namespace, "",
map[string]string{"role": rc.Role, "redirect_uri": redirectURI, "client_nonce": nonce})
if err != nil {
_ = ln.Close()
return nil, err
}
var ar struct {
Data struct {
AuthURL string `json:"auth_url"`
} `json:"data"`
}
if err := json.Unmarshal(data, &ar); err != nil {
_ = ln.Close()
return nil, fmt.Errorf("decoding auth_url response: %w", err)
}
if ar.Data.AuthURL == "" {
_ = ln.Close()
return nil, fmt.Errorf("vault returned no auth_url for role %q with redirect_uri %q (check the role's allowed_redirect_uris)", rc.Role, redirectURI)
}
done := make(chan oidcResult, 1)
mux := http.NewServeMux()
mux.HandleFunc("/oidc/callback", func(w http.ResponseWriter, r *http.Request) {
res := oidcCallback(rc, r, nonce)
if res.err != nil {
http.Error(w, "Vault login failed: "+res.err.Error(), http.StatusBadRequest)
} else {
_, _ = fmt.Fprintln(w, "Vault login successful. You can close this window.")
}
select {
case done <- res:
default:
}
})
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
go func() { _ = srv.Serve(ln) }()
defer func() { _ = srv.Close() }()
fmt.Fprintf(os.Stderr, "Complete the login via your OIDC provider:\n\n %s\n\n", ar.Data.AuthURL)
if err := OpenBrowser(ar.Data.AuthURL); err != nil {
fmt.Fprintf(os.Stderr, "Could not open a browser (%v); visit the URL above manually.\n", err)
}
select {
case res := <-done:
return res.tok, res.err
case <-time.After(OIDCTimeout):
return nil, fmt.Errorf("timed out after %s waiting for the OIDC callback on %s", OIDCTimeout, redirectURI)
}
}
// oidcCallback exchanges the provider's state+code for a Vault token. A POST
// is the form_post response mode: forward the form to Vault, then GET as usual.
func oidcCallback(rc ResolvedContext, r *http.Request, nonce string) oidcResult {
if e := r.FormValue("error"); e != "" {
return oidcResult{err: fmt.Errorf("provider returned %s: %s", e, r.FormValue("error_description"))}
}
state, code := r.FormValue("state"), r.FormValue("code")
if state == "" || (code == "" && r.Method != http.MethodPost) {
return oidcResult{err: errors.New("callback missing code or state")}
}
cb := "auth/" + rc.Path + "/oidc/callback"
if r.Method == http.MethodPost {
if _, err := doRequest(http.MethodPost, rc.Address, cb, rc.Namespace, "",
map[string]string{"state": state, "code": code, "id_token": r.FormValue("id_token"), "client_nonce": nonce}); err != nil {
return oidcResult{err: err}
}
}
v := url.Values{"state": {state}, "code": {code}, "client_nonce": {nonce}}
data, err := doRequest(http.MethodGet, rc.Address, cb+"?"+v.Encode(), rc.Namespace, "", nil)
if err != nil {
return oidcResult{err: err}
}
var ar authResponse
if err := json.Unmarshal(data, &ar); err != nil {
return oidcResult{err: fmt.Errorf("decoding callback response: %w", err)}
}
if ar.Auth.ClientToken == "" {
return oidcResult{err: fmt.Errorf("oidc login for context %q returned no token", rc.Name)}
}
return oidcResult{tok: tokenFromAuth(rc, ar)}
}
+178
View File
@@ -0,0 +1,178 @@
package shared
import (
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func freeListen(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
_ = ln.Close()
return addr
}
// fakeOIDCVault serves auth_url and callback on mount "sso", checking the
// nonce/redirect round-trip, and records the redirect_uri it was given.
func fakeOIDCVault(t *testing.T, wantRole string) (*httptest.Server, *string) {
t.Helper()
var redirect, nonce, idToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Vault-Namespace") != "team-a" {
t.Errorf("namespace header = %q", r.Header.Get("X-Vault-Namespace"))
}
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/sso/oidc/auth_url":
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["role"] != wantRole || body["client_nonce"] == "" {
t.Errorf("auth_url body = %v", body)
}
redirect, nonce = body["redirect_uri"], body["client_nonce"]
_, _ = io.WriteString(w, `{"data":{"auth_url":"https://idp.example/authorize?state=st1"}}`)
case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/sso/oidc/callback":
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["state"] != "st1" || body["client_nonce"] != nonce {
t.Errorf("form_post body = %v", body)
}
idToken = body["id_token"]
case r.Method == http.MethodGet && r.URL.Path == "/v1/auth/sso/oidc/callback":
q := r.URL.Query()
if q.Get("state") != "st1" || (q.Get("code") != "c0de" && idToken != "idt") || q.Get("client_nonce") != nonce {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"errors":["bad callback"]}`)
return
}
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.oidc","accessor":"acc","policies":["default"],"lease_duration":600,"renewable":true}}`)
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv, &redirect
}
// fakeBrowser stands in for the IdP: it hits the local callback with query.
func fakeBrowser(t *testing.T, redirect *string, query string) {
t.Helper()
fakeBrowserDo(t, func() (*http.Response, error) { return http.Get(*redirect + "?" + query) })
}
func fakeBrowserDo(t *testing.T, do func() (*http.Response, error)) {
t.Helper()
prev := OpenBrowser
OpenBrowser = func(u string) error {
if !strings.HasPrefix(u, "https://idp.example/") {
t.Errorf("browser url = %q", u)
}
go func() {
resp, err := do()
if err == nil {
_ = resp.Body.Close()
}
}()
return nil
}
t.Cleanup(func() { OpenBrowser = prev })
}
func TestLoginOIDC(t *testing.T) {
srv, redirect := fakeOIDCVault(t, "admins")
fakeBrowser(t, redirect, "state=st1&code=c0de")
listen := freeListen(t)
rc := ResolvedContext{Name: "sso", Address: srv.URL, Method: "oidc", Path: "sso", Role: "admins", Namespace: "team-a", OIDCListen: listen}
tok, err := Login(rc, "")
if err != nil {
t.Fatalf("Login: %v", err)
}
if *redirect != "http://"+listen+"/oidc/callback" {
t.Errorf("redirect_uri = %q", *redirect)
}
if tok.Token != "s.oidc" || tok.Accessor != "acc" || tok.LeaseDurationSeconds != 600 || tok.Namespace != "team-a" {
t.Errorf("token = %+v", tok)
}
}
func TestLoginOIDCCallbackRejected(t *testing.T) {
srv, redirect := fakeOIDCVault(t, "")
fakeBrowser(t, redirect, "state=st1&code=wrong")
rc := ResolvedContext{Name: "sso", Address: srv.URL, Method: "oidc", Path: "sso", Namespace: "team-a", OIDCListen: freeListen(t)}
if _, err := LoginOIDC(rc); err == nil || !strings.Contains(err.Error(), "bad callback") {
t.Errorf("want vault callback error, got %v", err)
}
}
func TestLoginOIDCProviderError(t *testing.T) {
srv, redirect := fakeOIDCVault(t, "")
fakeBrowser(t, redirect, url.Values{"error": {"access_denied"}, "error_description": {"user said no"}}.Encode())
rc := ResolvedContext{Name: "sso", Address: srv.URL, Method: "oidc", Path: "sso", Namespace: "team-a", OIDCListen: freeListen(t)}
if _, err := LoginOIDC(rc); err == nil || !strings.Contains(err.Error(), "user said no") {
t.Errorf("want provider error, got %v", err)
}
}
func TestLoginOIDCTimeout(t *testing.T) {
srv, _ := fakeOIDCVault(t, "")
prevB, prevT := OpenBrowser, OIDCTimeout
OpenBrowser = func(string) error { return nil }
OIDCTimeout = 50 * time.Millisecond
t.Cleanup(func() { OpenBrowser, OIDCTimeout = prevB, prevT })
rc := ResolvedContext{Name: "sso", Address: srv.URL, Method: "oidc", Path: "sso", Namespace: "team-a", OIDCListen: freeListen(t)}
if _, err := LoginOIDC(rc); err == nil || !strings.Contains(err.Error(), "timed out") {
t.Errorf("want timeout, got %v", err)
}
}
func TestLoginOIDCNoAuthURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"data":{"auth_url":""}}`)
}))
defer srv.Close()
rc := ResolvedContext{Name: "sso", Address: srv.URL, Method: "oidc", Path: "oidc", Role: "nope", OIDCListen: freeListen(t)}
if _, err := LoginOIDC(rc); err == nil || !strings.Contains(err.Error(), "allowed_redirect_uris") {
t.Errorf("want no auth_url error, got %v", err)
}
}
func TestLoginOIDCFormPost(t *testing.T) {
srv, redirect := fakeOIDCVault(t, "")
fakeBrowserDo(t, func() (*http.Response, error) {
return http.PostForm(*redirect, url.Values{"state": {"st1"}, "id_token": {"idt"}})
})
rc := ResolvedContext{Name: "sso", Address: srv.URL, Method: "oidc", Path: "sso", Namespace: "team-a", OIDCListen: freeListen(t)}
tok, err := LoginOIDC(rc)
if err != nil || tok.Token != "s.oidc" {
t.Fatalf("form_post login: tok=%+v err=%v", tok, err)
}
}
func TestLoginOIDCRejectsNonLoopbackListen(t *testing.T) {
for _, l := range []string{":8250", "0.0.0.0:8250", "192.0.2.1:8250", "localhost"} {
rc := ResolvedContext{Name: "sso", Address: "http://unused", Method: "oidc", Path: "sso", OIDCListen: l}
if _, err := LoginOIDC(rc); err == nil || !strings.Contains(err.Error(), "oidc listen address") {
t.Errorf("%q: want listen error, got %v", l, err)
}
}
}
func TestDoRequestErrorOmitsQuery(t *testing.T) {
_, err := doRequest(http.MethodGet, "http://127.0.0.1:1", "auth/sso/oidc/callback?code=secretcode&state=st", "", "", nil)
if err == nil || strings.Contains(err.Error(), "secretcode") || !strings.Contains(err.Error(), "/v1/auth/sso/oidc/callback") {
t.Errorf("error = %v", err)
}
}
+15 -4
View File
@@ -3,9 +3,11 @@ package shared
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
) )
@@ -79,8 +81,8 @@ func doRequest(method, address, path, namespace, token string, payload any) ([]b
body = bytes.NewReader(b) body = bytes.NewReader(b)
} }
url := strings.TrimRight(address, "/") + "/v1/" + strings.TrimLeft(path, "/") reqURL := strings.TrimRight(address, "/") + "/v1/" + strings.TrimLeft(path, "/")
req, err := http.NewRequest(method, url, body) req, err := http.NewRequest(method, reqURL, body)
if err != nil { if err != nil {
return nil, fmt.Errorf("building request: %w", err) return nil, fmt.Errorf("building request: %w", err)
} }
@@ -96,7 +98,13 @@ func doRequest(method, address, path, namespace, token string, payload any) ([]b
resp, err := httpClient.Do(req) resp, err := httpClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("request to %s failed: %w", url, err) // Query strings can carry OIDC code/state/nonce; keep them out of errors.
bare, _, _ := strings.Cut(reqURL, "?")
var ue *url.Error
if errors.As(err, &ue) {
ue.URL = bare
}
return nil, fmt.Errorf("request to %s failed: %w", bare, err)
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
@@ -133,11 +141,14 @@ func tokenFromAuth(rc ResolvedContext, ar authResponse) *Token {
// Login authenticates against a context and returns a Token ready to cache. // Login authenticates against a context and returns a Token ready to cache.
// For password methods, secret is the password; for the token method, secret is // For password methods, secret is the password; for the token method, secret is
// the raw client token to adopt (verified via lookup-self). // the raw client token to adopt (verified via lookup-self); oidc ignores it.
func Login(rc ResolvedContext, secret string) (*Token, error) { func Login(rc ResolvedContext, secret string) (*Token, error) {
if IsTokenMethod(rc.Method) { if IsTokenMethod(rc.Method) {
return loginWithToken(rc, secret) return loginWithToken(rc, secret)
} }
if rc.Method == "oidc" {
return LoginOIDC(rc)
}
if !NeedsPassword(rc.Method) { if !NeedsPassword(rc.Method) {
return nil, fmt.Errorf("unsupported auth method %q", rc.Method) return nil, fmt.Errorf("unsupported auth method %q", rc.Method)
} }
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"git.unkin.net/unkin/golib/keyring"
"git.unkin.net/unkin/vault-tools/shared"
"golang.org/x/sys/unix"
)
// TestMain seals tokens with a run-unique key, skipping outside CI where
// keyctl is unimplemented.
func TestMain(m *testing.M) {
shared.KeyringDesc = fmt.Sprintf("vault-tools-test:vcm:%d", os.Getpid())
k, err := keyring.Open(shared.KeyringDesc)
if errors.Is(err, unix.ENOSYS) && os.Getenv("CI") == "" {
fmt.Println("skipping: kernel keyring unavailable:", err)
os.Exit(0)
}
if err != nil {
fmt.Println(err)
os.Exit(1)
}
code := m.Run()
_ = k.Forget()
os.Exit(code)
}
func TestRunListLockedAndUnreadable(t *testing.T) {
setupVcm(t, vcmConfig)
dir := filepath.Join(shared.CacheDir(), "staging")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(shared.CacheDir(), "sydney"), []byte(`{"token":"s.x"}`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "sydney"), []byte("\x01garbage"), 0o600); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
if err := runList(&buf); err != nil {
t.Fatal(err)
}
out := buf.String()
if !strings.Contains(out, "locked — run 'vcm login sydney'") {
t.Errorf("expected locked status for sydney:\n%s", out)
}
if !strings.Contains(out, "sealed data corrupt") {
t.Errorf("expected corrupt status for staging/sydney:\n%s", out)
}
}
+30 -21
View File
@@ -1,16 +1,17 @@
// Command vctl manages Vault tokens for multiple vault instances ("contexts"). // Command vcm manages Vault tokens for multiple vault instances ("contexts").
// //
// It logs in to (or renews tokens for) one or all configured contexts and // It logs in to (or renews tokens for) one or all configured contexts and
// caches the resulting tokens under ~/.cache/vault/<context> for use by vctx // caches the resulting tokens under ~/.cache/vcm/<context> for use by vctx
// and other tooling. // and other tooling.
// //
// vctl login sydney // vcm login sydney
// vctl login --all // vcm login --all
// vctl renew staging/sydney // vcm renew staging/sydney
// vctl renew --all // vcm renew --all
package main package main
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -33,17 +34,16 @@ func main() {
func newRootCmd() *cobra.Command { func newRootCmd() *cobra.Command {
var ( var (
method string ov shared.Overrides
user string all bool
all bool
) )
root := &cobra.Command{ root := &cobra.Command{
Use: "vctl", Use: "vcm",
Short: "Manage Vault tokens for multiple vault instances (contexts).", Short: "Manage Vault tokens for multiple vault instances (contexts).",
Long: "vctl logs in to and renews Vault tokens for the contexts defined in\n" + Long: "vcm logs in to and renews Vault tokens for the contexts defined in\n" +
"~/.config/vault/vctl.yaml (or config.yaml), caching each token under\n" + "~/.config/vcm/config.yaml, caching each token under\n" +
"~/.cache/vault/<context> for use by vctx and other tooling.", "~/.cache/vcm/<context> for use by vctx and other tooling.",
SilenceUsage: true, SilenceUsage: true,
} }
@@ -66,10 +66,12 @@ func newRootCmd() *cobra.Command {
ValidArgsFunction: contextCompletion, ValidArgsFunction: contextCompletion,
SilenceUsage: true, SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runOverContexts(args, all, method, user, "login", doLogin) return runOverContexts(args, all, ov, "login", doLogin)
}, },
} }
loginCmd.Flags().BoolVar(&all, "all", false, "Log in to every configured context") loginCmd.Flags().BoolVar(&all, "all", false, "Log in to every configured context")
loginCmd.Flags().StringVar(&ov.Role, "role", "", "OIDC role override (default: context role or the mount's default_role)")
loginCmd.Flags().StringVar(&ov.Listen, "listen", "", "OIDC callback listen address (default: context oidc_listen or "+shared.DefaultOIDCListen+")")
renewCmd := &cobra.Command{ renewCmd := &cobra.Command{
Use: "renew [context]", Use: "renew [context]",
@@ -78,17 +80,17 @@ func newRootCmd() *cobra.Command {
ValidArgsFunction: contextCompletion, ValidArgsFunction: contextCompletion,
SilenceUsage: true, SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runOverContexts(args, all, method, user, "renew", doRenew) return runOverContexts(args, all, ov, "renew", doRenew)
}, },
} }
renewCmd.Flags().BoolVar(&all, "all", false, "Renew every configured context that has a cached token") renewCmd.Flags().BoolVar(&all, "all", false, "Renew every configured context that has a cached token")
// --method / --user apply to both login and renew. // --method / --user apply to both login and renew.
for _, c := range []*cobra.Command{loginCmd, renewCmd} { for _, c := range []*cobra.Command{loginCmd, renewCmd} {
c.Flags().StringVar(&method, "method", "", "Auth method override (default: context/config or "+shared.DefaultMethod+")") c.Flags().StringVar(&ov.Method, "method", "", "Auth method override (default: context/config or "+shared.DefaultMethod+")")
c.Flags().StringVar(&user, "user", "", "Login user override (default: context/config or $USER)") c.Flags().StringVar(&ov.User, "user", "", "Login user override (default: context/config or $USER)")
_ = c.RegisterFlagCompletionFunc("method", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { _ = c.RegisterFlagCompletionFunc("method", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"ldap", "userpass", "okta", "radius", "token"}, cobra.ShellCompDirectiveNoFileComp return []string{"ldap", "userpass", "okta", "radius", "token", "oidc"}, cobra.ShellCompDirectiveNoFileComp
}) })
} }
@@ -120,7 +122,7 @@ type contextAction func(rc shared.ResolvedContext) (*shared.Token, error)
// runOverContexts resolves the target context(s) and applies fn to each, // runOverContexts resolves the target context(s) and applies fn to each,
// reporting per-context success/failure and returning an error only when at // reporting per-context success/failure and returning an error only when at
// least one context failed. // least one context failed.
func runOverContexts(args []string, all bool, method, user, verb string, fn contextAction) error { func runOverContexts(args []string, all bool, ov shared.Overrides, verb string, fn contextAction) error {
cfg, err := shared.Load() cfg, err := shared.Load()
if err != nil { if err != nil {
return err return err
@@ -143,7 +145,7 @@ func runOverContexts(args []string, all bool, method, user, verb string, fn cont
var failed int var failed int
for _, name := range names { for _, name := range names {
rc, err := cfg.ResolveWithOverrides(name, method, user) rc, err := cfg.ResolveWithOverrides(name, ov)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "%s %s: %v\n", verb, name, err) fmt.Fprintf(os.Stderr, "%s %s: %v\n", verb, name, err)
failed++ failed++
@@ -251,7 +253,14 @@ func runList(w io.Writer) error {
continue continue
} }
status := "no token" status := "no token"
if tok, err := shared.LoadToken(name); err == nil { tok, err := shared.LoadToken(name)
switch {
case errors.Is(err, os.ErrNotExist):
case errors.Is(err, shared.ErrLocked):
status = fmt.Sprintf("locked — run 'vcm login %s'", name)
case err != nil:
status = err.Error()
default:
if !tok.ExpiresAt.IsZero() { if !tok.ExpiresAt.IsZero() {
remaining := time.Until(tok.ExpiresAt) remaining := time.Until(tok.ExpiresAt)
if remaining > 0 { if remaining > 0 {
+25 -25
View File
@@ -16,23 +16,23 @@ import (
"git.unkin.net/unkin/vault-tools/shared" "git.unkin.net/unkin/vault-tools/shared"
) )
// setupVctl points XDG dirs at temp locations and writes a config file. It // setupVcm points XDG dirs at temp locations and writes a config file. It
// returns the config dir base so tests can inspect cache writes. // returns the config dir base so tests can inspect cache writes.
func setupVctl(t *testing.T, cfg string) { func setupVcm(t *testing.T, cfg string) {
t.Helper() t.Helper()
cfgHome := t.TempDir() cfgHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", cfgHome) t.Setenv("XDG_CONFIG_HOME", cfgHome)
t.Setenv("XDG_CACHE_HOME", t.TempDir()) t.Setenv("XDG_CACHE_HOME", t.TempDir())
cfgDir := filepath.Join(cfgHome, "vault") cfgDir := filepath.Join(cfgHome, "vcm")
if err := os.MkdirAll(cfgDir, 0o700); err != nil { if err := os.MkdirAll(cfgDir, 0o700); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(cfgDir, "vctl.yaml"), []byte(cfg), 0o600); err != nil { if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte(cfg), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
const vctlConfig = ` const vcmConfig = `
defaults: defaults:
method: ldap method: ldap
user: ben user: ben
@@ -47,13 +47,13 @@ contexts:
// --- runOverContexts: selection + iteration + aggregation ------------------ // --- runOverContexts: selection + iteration + aggregation ------------------
func TestRunOverContextsSingle(t *testing.T) { func TestRunOverContextsSingle(t *testing.T) {
setupVctl(t, vctlConfig) setupVcm(t, vcmConfig)
var seen []string var seen []string
fn := func(rc shared.ResolvedContext) (*shared.Token, error) { fn := func(rc shared.ResolvedContext) (*shared.Token, error) {
seen = append(seen, rc.Name) seen = append(seen, rc.Name)
return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil
} }
if err := runOverContexts([]string{"sydney"}, false, "", "", "login", fn); err != nil { if err := runOverContexts([]string{"sydney"}, false, shared.Overrides{}, "login", fn); err != nil {
t.Fatalf("runOverContexts: %v", err) t.Fatalf("runOverContexts: %v", err)
} }
if len(seen) != 1 || seen[0] != "sydney" { if len(seen) != 1 || seen[0] != "sydney" {
@@ -66,13 +66,13 @@ func TestRunOverContextsSingle(t *testing.T) {
} }
func TestRunOverContextsAllIteratesEveryContext(t *testing.T) { func TestRunOverContextsAllIteratesEveryContext(t *testing.T) {
setupVctl(t, vctlConfig) setupVcm(t, vcmConfig)
var seen []string var seen []string
fn := func(rc shared.ResolvedContext) (*shared.Token, error) { fn := func(rc shared.ResolvedContext) (*shared.Token, error) {
seen = append(seen, rc.Name) seen = append(seen, rc.Name)
return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil
} }
if err := runOverContexts(nil, true, "", "", "login", fn); err != nil { if err := runOverContexts(nil, true, shared.Overrides{}, "login", fn); err != nil {
t.Fatalf("runOverContexts --all: %v", err) t.Fatalf("runOverContexts --all: %v", err)
} }
sort.Strings(seen) sort.Strings(seen)
@@ -88,40 +88,40 @@ func TestRunOverContextsAllIteratesEveryContext(t *testing.T) {
} }
func TestRunOverContextsContextAndAllConflict(t *testing.T) { func TestRunOverContextsContextAndAllConflict(t *testing.T) {
setupVctl(t, vctlConfig) setupVcm(t, vcmConfig)
fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil } fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil }
err := runOverContexts([]string{"sydney"}, true, "", "", "login", fn) err := runOverContexts([]string{"sydney"}, true, shared.Overrides{}, "login", fn)
if err == nil || !strings.Contains(err.Error(), "not both") { if err == nil || !strings.Contains(err.Error(), "not both") {
t.Errorf("expected conflict error, got %v", err) t.Errorf("expected conflict error, got %v", err)
} }
} }
func TestRunOverContextsNoTarget(t *testing.T) { func TestRunOverContextsNoTarget(t *testing.T) {
setupVctl(t, vctlConfig) setupVcm(t, vcmConfig)
fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil } fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil }
if err := runOverContexts(nil, false, "", "", "login", fn); err == nil { if err := runOverContexts(nil, false, shared.Overrides{}, "login", fn); err == nil {
t.Error("expected error when neither context nor --all given") t.Error("expected error when neither context nor --all given")
} }
} }
func TestRunOverContextsAllEmptyConfig(t *testing.T) { func TestRunOverContextsAllEmptyConfig(t *testing.T) {
setupVctl(t, "contexts: {}\n") setupVcm(t, "contexts: {}\n")
fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil } fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil }
err := runOverContexts(nil, true, "", "", "login", fn) err := runOverContexts(nil, true, shared.Overrides{}, "login", fn)
if err == nil || !strings.Contains(err.Error(), "no contexts configured") { if err == nil || !strings.Contains(err.Error(), "no contexts configured") {
t.Errorf("expected no-contexts error, got %v", err) t.Errorf("expected no-contexts error, got %v", err)
} }
} }
func TestRunOverContextsAggregatesFailures(t *testing.T) { func TestRunOverContextsAggregatesFailures(t *testing.T) {
setupVctl(t, vctlConfig) setupVcm(t, vcmConfig)
fn := func(rc shared.ResolvedContext) (*shared.Token, error) { fn := func(rc shared.ResolvedContext) (*shared.Token, error) {
if rc.Name == "sydney" { if rc.Name == "sydney" {
return nil, io.ErrUnexpectedEOF // simulate a login failure return nil, io.ErrUnexpectedEOF // simulate a login failure
} }
return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil
} }
err := runOverContexts(nil, true, "", "", "login", fn) err := runOverContexts(nil, true, shared.Overrides{}, "login", fn)
if err == nil || !strings.Contains(err.Error(), "1 of 2") { if err == nil || !strings.Contains(err.Error(), "1 of 2") {
t.Errorf("expected '1 of 2' aggregate error, got %v", err) t.Errorf("expected '1 of 2' aggregate error, got %v", err)
} }
@@ -137,7 +137,7 @@ func TestRunOverContextsAggregatesFailures(t *testing.T) {
func TestRunOverContextsResolveErrorCounts(t *testing.T) { func TestRunOverContextsResolveErrorCounts(t *testing.T) {
// A context missing an address fails resolution and is counted as a failure. // A context missing an address fails resolution and is counted as a failure.
setupVctl(t, ` setupVcm(t, `
contexts: contexts:
good: good:
address: https://vault.example.net address: https://vault.example.net
@@ -146,20 +146,20 @@ contexts:
fn := func(rc shared.ResolvedContext) (*shared.Token, error) { fn := func(rc shared.ResolvedContext) (*shared.Token, error) {
return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil
} }
err := runOverContexts(nil, true, "", "", "login", fn) err := runOverContexts(nil, true, shared.Overrides{}, "login", fn)
if err == nil || !strings.Contains(err.Error(), "1 of 2") { if err == nil || !strings.Contains(err.Error(), "1 of 2") {
t.Errorf("expected resolve failure counted, got %v", err) t.Errorf("expected resolve failure counted, got %v", err)
} }
} }
func TestRunOverContextsAppliesOverrides(t *testing.T) { func TestRunOverContextsAppliesOverrides(t *testing.T) {
setupVctl(t, vctlConfig) setupVcm(t, vcmConfig)
var got shared.ResolvedContext var got shared.ResolvedContext
fn := func(rc shared.ResolvedContext) (*shared.Token, error) { fn := func(rc shared.ResolvedContext) (*shared.Token, error) {
got = rc got = rc
return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil
} }
if err := runOverContexts([]string{"sydney"}, false, "okta", "someone", "login", fn); err != nil { if err := runOverContexts([]string{"sydney"}, false, shared.Overrides{Method: "okta", User: "someone"}, "login", fn); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got.Method != "okta" || got.User != "someone" || got.Path != "okta" { if got.Method != "okta" || got.User != "someone" || got.Path != "okta" {
@@ -237,7 +237,7 @@ func TestDoLoginTokenMethod(t *testing.T) {
// --- doRenew --------------------------------------------------------------- // --- doRenew ---------------------------------------------------------------
func TestDoRenewNoCachedToken(t *testing.T) { func TestDoRenewNoCachedToken(t *testing.T) {
setupVctl(t, vctlConfig) setupVcm(t, vcmConfig)
rc := shared.ResolvedContext{Name: "sydney", Address: "https://vault.example.net"} rc := shared.ResolvedContext{Name: "sydney", Address: "https://vault.example.net"}
if _, err := doRenew(rc); err == nil { if _, err := doRenew(rc); err == nil {
t.Error("expected error renewing a context with no cached token") t.Error("expected error renewing a context with no cached token")
@@ -245,7 +245,7 @@ func TestDoRenewNoCachedToken(t *testing.T) {
} }
func TestDoRenewUsesCachedToken(t *testing.T) { func TestDoRenewUsesCachedToken(t *testing.T) {
setupVctl(t, vctlConfig) setupVcm(t, vcmConfig)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/auth/token/renew-self" { if r.URL.Path != "/v1/auth/token/renew-self" {
t.Errorf("path = %q", r.URL.Path) t.Errorf("path = %q", r.URL.Path)
@@ -276,7 +276,7 @@ func TestDoRenewUsesCachedToken(t *testing.T) {
// --- runList --------------------------------------------------------------- // --- runList ---------------------------------------------------------------
func TestRunListShowsTokenStatus(t *testing.T) { func TestRunListShowsTokenStatus(t *testing.T) {
setupVctl(t, vctlConfig) setupVcm(t, vcmConfig)
// sydney has a valid token; staging/sydney has none. // sydney has a valid token; staging/sydney has none.
if err := shared.SaveToken(&shared.Token{ if err := shared.SaveToken(&shared.Token{
Context: "sydney", Context: "sydney",
@@ -300,7 +300,7 @@ func TestRunListShowsTokenStatus(t *testing.T) {
} }
func TestRunListEmptyConfig(t *testing.T) { func TestRunListEmptyConfig(t *testing.T) {
setupVctl(t, "contexts: {}\n") setupVcm(t, "contexts: {}\n")
var buf bytes.Buffer var buf bytes.Buffer
if err := runList(&buf); err != nil { if err := runList(&buf); err != nil {
t.Fatal(err) t.Fatal(err)
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
"testing"
"git.unkin.net/unkin/golib/keyring"
"git.unkin.net/unkin/vault-tools/shared"
"golang.org/x/sys/unix"
)
// TestMain seals tokens with a run-unique key, skipping outside CI where
// keyctl is unimplemented.
func TestMain(m *testing.M) {
shared.KeyringDesc = fmt.Sprintf("vault-tools-test:vctx:%d", os.Getpid())
k, err := keyring.Open(shared.KeyringDesc)
if errors.Is(err, unix.ENOSYS) && os.Getenv("CI") == "" {
fmt.Println("skipping: kernel keyring unavailable:", err)
os.Exit(0)
}
if err != nil {
fmt.Println(err)
os.Exit(1)
}
code := m.Run()
_ = k.Forget()
os.Exit(code)
}
func TestBuildInvocationLockedToken(t *testing.T) {
setupVctx(t, vctxConfig, map[string]shared.Token{"sydney": {Address: "https://v", Token: "s.X"}})
k, err := keyring.Open(shared.KeyringDesc)
if err != nil {
t.Fatal(err)
}
if err := k.Forget(); err != nil {
t.Fatal(err)
}
_, _, _, err = buildInvocation("sydney", "", []string{"status"}, nil)
if !errors.Is(err, shared.ErrLocked) || !strings.Contains(err.Error(), "vcm login sydney") {
t.Fatalf("buildInvocation = %v, want ErrLocked with login hint", err)
}
}
+200 -21
View File
@@ -1,19 +1,26 @@
// Command vctx is a thin, context-aware wrapper around the real `vault` CLI. // Command vctx is a thin, context-aware wrapper around the real `vault` CLI.
// //
// It resolves a context (via the same config + token cache as vctl), sets // It resolves a context (via the same config + token cache as vcm), sets
// VAULT_ADDR / VAULT_TOKEN / VAULT_NAMESPACE for that single invocation, and // VAULT_ADDR / VAULT_TOKEN / VAULT_NAMESPACE for that single invocation, and
// execs `vault` with the remaining arguments: // execs `vault` with the remaining arguments (or runs it as a child to
// colourise json/yaml output on a terminal):
// //
// vctx --context sydney kv put kv/foo/bar secret=baz // vctx --context sydney kv put kv/foo/bar secret=baz
// vctx --context staging/sydney token lookup // vctx --context staging/sydney token lookup
package main package main
import ( import (
"bytes"
"errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"os/signal"
"strings"
"syscall" "syscall"
"git.unkin.net/unkin/golib/colour"
"git.unkin.net/unkin/vault-tools/shared" "git.unkin.net/unkin/vault-tools/shared"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -25,6 +32,14 @@ var version = "dev"
// variable so tests can substitute a fake in place of syscall.Exec. // variable so tests can substitute a fake in place of syscall.Exec.
var execVault = syscall.Exec var execVault = syscall.Exec
// osExit is swapped in tests to observe the child's exit code.
var osExit = os.Exit
// uncaptured commands are always exec'd: buffering their stdout would hide
// streamed output or prompts (unseal keys, MFA passcodes; write and patch can
// prompt for MFA) until exit, or break the TTY, for ssh.
var uncaptured = map[string]bool{"monitor": true, "debug": true, "events": true, "ssh": true, "agent": true, "proxy": true, "server": true, "login": true, "operator": true, "write": true, "patch": true}
func main() { func main() {
if err := newRootCmd().Execute(); err != nil { if err := newRootCmd().Execute(); err != nil {
os.Exit(1) os.Exit(1)
@@ -32,15 +47,16 @@ func main() {
} }
func newRootCmd() *cobra.Command { func newRootCmd() *cobra.Command {
var context string var context, format, color string
root := &cobra.Command{ root := &cobra.Command{
Use: "vctx --context <context> <vault args...>", Use: "vctx --context <context> <vault args...>",
Short: "Run the vault CLI against a named context.", Short: "Run the vault CLI against a named context.",
Long: "vctx resolves a context from ~/.config/vault (shared with vctl), loads its\n" + Long: "vctx resolves a context from ~/.config/vcm (shared with vcm), loads its\n" +
"cached token from ~/.cache/vault/<context>, sets VAULT_ADDR, VAULT_TOKEN and\n" + "cached token from ~/.cache/vcm/<context>, sets VAULT_ADDR, VAULT_TOKEN and\n" +
"VAULT_NAMESPACE for this invocation only, and execs `vault` with the\n" + "VAULT_NAMESPACE (and VAULT_FORMAT: yaml for kv get and */creds/*, table\n" +
"remaining arguments.\n\n" + "for kv list, unless already chosen) for this invocation only, and execs\n" +
"`vault` with the remaining arguments.\n\n" +
"Example: vctx --context sydney kv put kv/foo/bar secret=baz", "Example: vctx --context sydney kv put kv/foo/bar secret=baz",
// Everything from the first non-flag argument on is the vault command // Everything from the first non-flag argument on is the vault command
// line; SetInterspersed(false) below stops flag parsing there so flags // line; SetInterspersed(false) below stops flag parsing there so flags
@@ -48,7 +64,7 @@ func newRootCmd() *cobra.Command {
Args: cobra.ArbitraryArgs, Args: cobra.ArbitraryArgs,
SilenceUsage: true, SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runVctx(context, args) return runVctx(context, format, color, args)
}, },
} }
@@ -64,6 +80,12 @@ func newRootCmd() *cobra.Command {
} }
return cfg.ContextNames(), cobra.ShellCompDirectiveNoFileComp return cfg.ContextNames(), cobra.ShellCompDirectiveNoFileComp
}) })
root.Flags().StringVar(&format, "format", "", "VAULT_FORMAT for this call (default: picked from the vault command)")
_ = root.RegisterFlagCompletionFunc("format", cobra.FixedCompletions(
[]string{"table", "json", "yaml", "pretty"}, cobra.ShellCompDirectiveNoFileComp))
root.Flags().StringVar(&color, "color", "auto", "Colourise json/yaml output: auto, always or never")
_ = root.RegisterFlagCompletionFunc("color", cobra.FixedCompletions(
[]string{"auto", "always", "never"}, cobra.ShellCompDirectiveNoFileComp))
root.AddCommand(&cobra.Command{ root.AddCommand(&cobra.Command{
Use: "version", Use: "version",
@@ -77,19 +99,32 @@ func newRootCmd() *cobra.Command {
} }
// runVctx validates inputs, builds the vault invocation for the context, and // runVctx validates inputs, builds the vault invocation for the context, and
// execs it (replacing this process). // execs it (replacing this process), or runs it as a child and colourises its
func runVctx(context string, args []string) error { // stdout when colour is enabled and the output format is json or yaml.
func runVctx(context, format, color string, args []string) error {
if context == "" { if context == "" {
return fmt.Errorf("--context is required") return fmt.Errorf("--context is required")
} }
mode, ok := map[string]colour.Mode{"auto": colour.Auto, "always": colour.Always, "never": colour.Never}[color]
if !ok {
return fmt.Errorf("--color must be auto, always or never, got %q", color)
}
if len(args) == 0 { if len(args) == 0 {
return fmt.Errorf("no vault arguments given (e.g. vctx --context %s kv list kv/)", context) return fmt.Errorf("no vault arguments given (e.g. vctx --context %s kv list kv/)", context)
} }
bin, argv, env, err := buildInvocation(context, args, os.Environ()) bin, argv, env, err := buildInvocation(context, format, args, os.Environ())
if err != nil { if err != nil {
return err return err
} }
if f := colourFormat(args, env); f != "" && colour.Enabled(mode, os.Stdout) {
code, err := runChild(bin, argv, env, f, os.Stdout)
if err != nil {
return err
}
osExit(code)
return nil
}
// Replace this process with vault so its exit status, signals and TTY // Replace this process with vault so its exit status, signals and TTY
// behaviour pass straight through. // behaviour pass straight through.
if err := execVault(bin, argv, env); err != nil { if err := execVault(bin, argv, env); err != nil {
@@ -101,9 +136,11 @@ func runVctx(context string, args []string) error {
// buildInvocation resolves the context and its cached token, locates the vault // buildInvocation resolves the context and its cached token, locates the vault
// binary, and returns the binary path, argv (vault + args) and the environment // binary, and returns the binary path, argv (vault + args) and the environment
// to exec with. baseEnv is the starting environment (normally os.Environ()); // to exec with. baseEnv is the starting environment (normally os.Environ());
// the Vault settings are appended so they override any ambient values (exec // ambient copies of the keys vctx sets are dropped first, since Go programs
// semantics: the last assignment of a variable wins). // (vault included) honour the first occurrence of a duplicated variable.
func buildInvocation(context string, args, baseEnv []string) (bin string, argv []string, env []string, err error) { // VAULT_NAMESPACE is always dropped so a context without one is not redirected.
// format, when non-empty, is set as VAULT_FORMAT; otherwise autoFormat picks one.
func buildInvocation(context, format string, args, baseEnv []string) (bin string, argv []string, env []string, err error) {
cfg, err := shared.Load() cfg, err := shared.Load()
if err != nil { if err != nil {
return "", nil, nil, err return "", nil, nil, err
@@ -122,12 +159,10 @@ func buildInvocation(context string, args, baseEnv []string) (bin string, argv [
return "", nil, nil, fmt.Errorf("vault CLI not found in PATH: %w", err) return "", nil, nil, fmt.Errorf("vault CLI not found in PATH: %w", err)
} }
env = make([]string, len(baseEnv), len(baseEnv)+3) set := map[string]string{
copy(env, baseEnv) "VAULT_ADDR": rc.Address,
env = append(env, "VAULT_TOKEN": tok.Token,
"VAULT_ADDR="+rc.Address, }
"VAULT_TOKEN="+tok.Token,
)
// Namespace comes from the context, falling back to what the token was // Namespace comes from the context, falling back to what the token was
// issued under; only set when non-empty. // issued under; only set when non-empty.
ns := rc.Namespace ns := rc.Namespace
@@ -135,9 +170,153 @@ func buildInvocation(context string, args, baseEnv []string) (bin string, argv [
ns = tok.Namespace ns = tok.Namespace
} }
if ns != "" { if ns != "" {
env = append(env, "VAULT_NAMESPACE="+ns) set["VAULT_NAMESPACE"] = ns
}
if format == "" {
format = autoFormat(args, baseEnv)
}
if format != "" {
set["VAULT_FORMAT"] = format
}
for _, e := range baseEnv {
k, _, _ := strings.Cut(e, "=")
if _, ok := set[k]; ok || k == "VAULT_NAMESPACE" {
continue
}
env = append(env, e)
}
for _, k := range []string{"VAULT_ADDR", "VAULT_TOKEN", "VAULT_NAMESPACE", "VAULT_FORMAT"} {
if v, ok := set[k]; ok {
env = append(env, k+"="+v)
}
} }
argv = append([]string{vaultBin}, args...) argv = append([]string{vaultBin}, args...)
return vaultBin, argv, env, nil return vaultBin, argv, env, nil
} }
// autoFormat picks VAULT_FORMAT from the vault command line: yaml for
// `kv get` and dynamic creds (`*/creds/*`), table for `kv list`, else "".
// It returns "" when the caller already chose (ambient VAULT_FORMAT, or a
// -format/-field flag, since vault formats -field values in non-table modes).
func autoFormat(args, baseEnv []string) string {
for _, e := range baseEnv {
if strings.HasPrefix(e, "VAULT_FORMAT=") && e != "VAULT_FORMAT=" {
return ""
}
}
var pos []string
for i, a := range args {
if a == "--" {
pos = append(pos, args[i+1:]...)
break
}
name, _, _ := strings.Cut(strings.TrimLeft(a, "-"), "=")
switch {
case !strings.HasPrefix(a, "-"):
pos = append(pos, a)
case name == "format" || name == "field":
return ""
}
}
isKV := func(sub string) bool { return len(pos) >= 2 && pos[0] == "kv" && pos[1] == sub }
switch {
case isKV("list"):
return "table"
case isKV("get"):
return "yaml"
}
for _, p := range pos {
if strings.Contains(p, "/creds/") {
return "yaml"
}
}
return ""
}
// colourFormat returns the effective vault output format ("json" or "yaml")
// when it is one we colourise, else "". A -format arg beats VAULT_FORMAT, as in
// vault itself.
func colourFormat(args, env []string) string {
f := ""
var pos []string
for _, e := range env {
if v, ok := strings.CutPrefix(e, "VAULT_FORMAT="); ok {
f = v
}
}
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--" {
pos = append(pos, args[i+1:]...)
break
}
if !strings.HasPrefix(a, "-") {
pos = append(pos, a)
continue
}
name, val, hasVal := strings.Cut(strings.TrimLeft(a, "-"), "=")
if name != "format" {
continue
}
if !hasVal && i+1 < len(args) {
i++
val = args[i]
}
f = val
}
f = strings.ToLower(f)
if len(pos) == 0 || uncaptured[pos[0]] || (f != "json" && f != "yaml") {
return ""
}
return f
}
// runChild runs vault with stdin/stderr passed through, forwards SIGTERM and
// SIGHUP, then writes its captured stdout colourised to w. It returns vault's
// exit code, or 128+n if it died from signal n.
func runChild(bin string, argv, env []string, format string, w io.Writer) (int, error) {
var out bytes.Buffer
cmd := exec.Command(bin, argv[1:]...)
cmd.Env, cmd.Stdin, cmd.Stdout, cmd.Stderr = env, os.Stdin, &out, os.Stderr
sigs := make(chan os.Signal, 1)
// SIGINT is caught but not forwarded: vault is in our process group and already gets ^C.
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer signal.Stop(sigs)
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("run vault: %w", err)
}
done := make(chan struct{})
defer close(done)
go func() {
for {
select {
case s := <-sigs:
if s != syscall.SIGINT {
_ = cmd.Process.Signal(s)
}
case <-done:
return
}
}
}()
err := cmd.Wait()
var ee *exec.ExitError
if err != nil && !errors.As(err, &ee) {
return 0, fmt.Errorf("run vault: %w", err)
}
paint := colour.JSON
if format == "yaml" {
paint = colour.YAML
}
if err := paint(w, out.Bytes()); err != nil {
return 0, err
}
if ws, ok := cmd.ProcessState.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
return 128 + int(ws.Signal()), nil
}
return cmd.ProcessState.ExitCode(), nil
}
+250 -25
View File
@@ -1,12 +1,15 @@
package main package main
import ( import (
"bytes"
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strings" "strings"
"syscall"
"testing" "testing"
"time"
"git.unkin.net/unkin/vault-tools/shared" "git.unkin.net/unkin/vault-tools/shared"
) )
@@ -20,11 +23,11 @@ func setupVctx(t *testing.T, cfg string, tokens map[string]shared.Token) string
t.Setenv("XDG_CONFIG_HOME", cfgHome) t.Setenv("XDG_CONFIG_HOME", cfgHome)
t.Setenv("XDG_CACHE_HOME", t.TempDir()) t.Setenv("XDG_CACHE_HOME", t.TempDir())
cfgDir := filepath.Join(cfgHome, "vault") cfgDir := filepath.Join(cfgHome, "vcm")
if err := os.MkdirAll(cfgDir, 0o700); err != nil { if err := os.MkdirAll(cfgDir, 0o700); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(cfgDir, "vctl.yaml"), []byte(cfg), 0o600); err != nil { if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte(cfg), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for name, tok := range tokens { for name, tok := range tokens {
@@ -53,16 +56,20 @@ contexts:
namespace: staging namespace: staging
` `
// lastVal returns the value of the last occurrence of key in an env slice, // envVal returns key's value from an env slice, failing the test if the key
// mirroring exec's "last assignment wins" semantics. // appears more than once (Go's os/exec children honour the first occurrence).
func lastVal(env []string, key string) (string, bool) { func envVal(t *testing.T, env []string, key string) (string, bool) {
val, ok := "", false t.Helper()
val, n := "", 0
for _, e := range env { for _, e := range env {
if strings.HasPrefix(e, key+"=") { if strings.HasPrefix(e, key+"=") {
val, ok = e[len(key)+1:], true val, n = e[len(key)+1:], n+1
} }
} }
return val, ok if n > 1 {
t.Errorf("%s appears %d times in env %v", key, n, env)
}
return val, n > 0
} }
func TestBuildInvocationArgsAndEnvOverride(t *testing.T) { func TestBuildInvocationArgsAndEnvOverride(t *testing.T) {
@@ -71,10 +78,10 @@ func TestBuildInvocationArgsAndEnvOverride(t *testing.T) {
}) })
// Ambient VAULT_ADDR/VAULT_TOKEN must be overridden by the context's values. // Ambient VAULT_ADDR/VAULT_TOKEN must be overridden by the context's values.
baseEnv := []string{"HOME=/home/x", "VAULT_ADDR=ambient", "VAULT_TOKEN=ambient"} baseEnv := []string{"HOME=/home/x", "VAULT_ADDR=ambient", "VAULT_TOKEN=ambient", "VAULT_NAMESPACE=ambient"}
args := []string{"kv", "get", "-field=foo", "secret/x"} args := []string{"kv", "get", "-field=foo", "secret/x"}
bin, argv, env, err := buildInvocation("sydney", args, baseEnv) bin, argv, env, err := buildInvocation("sydney", "", args, baseEnv)
if err != nil { if err != nil {
t.Fatalf("buildInvocation: %v", err) t.Fatalf("buildInvocation: %v", err)
} }
@@ -86,17 +93,17 @@ func TestBuildInvocationArgsAndEnvOverride(t *testing.T) {
if !reflect.DeepEqual(argv, wantArgv) { if !reflect.DeepEqual(argv, wantArgv) {
t.Errorf("argv = %v, want %v", argv, wantArgv) t.Errorf("argv = %v, want %v", argv, wantArgv)
} }
if v, _ := lastVal(env, "VAULT_ADDR"); v != "https://vault.syd1.au.unkin.net" { if v, _ := envVal(t, env, "VAULT_ADDR"); v != "https://vault.syd1.au.unkin.net" {
t.Errorf("VAULT_ADDR = %q, want context override to win", v) t.Errorf("VAULT_ADDR = %q, want context override to win", v)
} }
if v, _ := lastVal(env, "VAULT_TOKEN"); v != "s.SYDTOK" { if v, _ := envVal(t, env, "VAULT_TOKEN"); v != "s.SYDTOK" {
t.Errorf("VAULT_TOKEN = %q, want s.SYDTOK", v) t.Errorf("VAULT_TOKEN = %q, want s.SYDTOK", v)
} }
if _, ok := lastVal(env, "VAULT_NAMESPACE"); ok { if _, ok := envVal(t, env, "VAULT_NAMESPACE"); ok {
t.Error("VAULT_NAMESPACE should be unset for a context with no namespace") t.Error("VAULT_NAMESPACE should be unset for a context with no namespace")
} }
// The ambient HOME must be preserved. // The ambient HOME must be preserved.
if v, _ := lastVal(env, "HOME"); v != "/home/x" { if v, _ := envVal(t, env, "HOME"); v != "/home/x" {
t.Errorf("HOME = %q, base env not preserved", v) t.Errorf("HOME = %q, base env not preserved", v)
} }
} }
@@ -105,11 +112,11 @@ func TestBuildInvocationNamespaceFromContext(t *testing.T) {
setupVctx(t, vctxConfig, map[string]shared.Token{ setupVctx(t, vctxConfig, map[string]shared.Token{
"staging/sydney": {Address: "https://vault-staging.syd1.au.unkin.net", Namespace: "staging", Token: "s.X"}, "staging/sydney": {Address: "https://vault-staging.syd1.au.unkin.net", Namespace: "staging", Token: "s.X"},
}) })
_, _, env, err := buildInvocation("staging/sydney", []string{"token", "lookup"}, nil) _, _, env, err := buildInvocation("staging/sydney", "", []string{"token", "lookup"}, nil)
if err != nil { if err != nil {
t.Fatalf("buildInvocation: %v", err) t.Fatalf("buildInvocation: %v", err)
} }
if v, _ := lastVal(env, "VAULT_NAMESPACE"); v != "staging" { if v, _ := envVal(t, env, "VAULT_NAMESPACE"); v != "staging" {
t.Errorf("VAULT_NAMESPACE = %q, want staging", v) t.Errorf("VAULT_NAMESPACE = %q, want staging", v)
} }
} }
@@ -120,18 +127,18 @@ func TestBuildInvocationNamespaceFallsBackToToken(t *testing.T) {
setupVctx(t, vctxConfig, map[string]shared.Token{ setupVctx(t, vctxConfig, map[string]shared.Token{
"sydney": {Address: "https://vault.syd1.au.unkin.net", Namespace: "from-token", Token: "s.X"}, "sydney": {Address: "https://vault.syd1.au.unkin.net", Namespace: "from-token", Token: "s.X"},
}) })
_, _, env, err := buildInvocation("sydney", []string{"status"}, nil) _, _, env, err := buildInvocation("sydney", "", []string{"status"}, nil)
if err != nil { if err != nil {
t.Fatalf("buildInvocation: %v", err) t.Fatalf("buildInvocation: %v", err)
} }
if v, _ := lastVal(env, "VAULT_NAMESPACE"); v != "from-token" { if v, _ := envVal(t, env, "VAULT_NAMESPACE"); v != "from-token" {
t.Errorf("VAULT_NAMESPACE = %q, want from-token fallback", v) t.Errorf("VAULT_NAMESPACE = %q, want from-token fallback", v)
} }
} }
func TestBuildInvocationMissingToken(t *testing.T) { func TestBuildInvocationMissingToken(t *testing.T) {
setupVctx(t, vctxConfig, nil) // no cached tokens setupVctx(t, vctxConfig, nil) // no cached tokens
_, _, _, err := buildInvocation("sydney", []string{"status"}, nil) _, _, _, err := buildInvocation("sydney", "", []string{"status"}, nil)
if err == nil { if err == nil {
t.Fatal("expected error for missing cached token") t.Fatal("expected error for missing cached token")
} }
@@ -142,16 +149,16 @@ func TestBuildInvocationMissingToken(t *testing.T) {
func TestBuildInvocationUnknownContext(t *testing.T) { func TestBuildInvocationUnknownContext(t *testing.T) {
setupVctx(t, vctxConfig, nil) setupVctx(t, vctxConfig, nil)
if _, _, _, err := buildInvocation("nope", []string{"status"}, nil); err == nil { if _, _, _, err := buildInvocation("nope", "", []string{"status"}, nil); err == nil {
t.Error("expected error for unknown context") t.Error("expected error for unknown context")
} }
} }
func TestRunVctxValidation(t *testing.T) { func TestRunVctxValidation(t *testing.T) {
if err := runVctx("", []string{"status"}); err == nil || !strings.Contains(err.Error(), "--context is required") { if err := runVctx("", "", "auto", []string{"status"}); err == nil || !strings.Contains(err.Error(), "--context is required") {
t.Errorf("empty context: got %v", err) t.Errorf("empty context: got %v", err)
} }
if err := runVctx("sydney", nil); err == nil || !strings.Contains(err.Error(), "no vault arguments") { if err := runVctx("sydney", "", "auto", nil); err == nil || !strings.Contains(err.Error(), "no vault arguments") {
t.Errorf("no args: got %v", err) t.Errorf("no args: got %v", err)
} }
} }
@@ -170,7 +177,7 @@ func TestRunVctxExecsWithBuiltInvocation(t *testing.T) {
} }
t.Cleanup(func() { execVault = orig }) t.Cleanup(func() { execVault = orig })
if err := runVctx("sydney", []string{"kv", "list", "kv/"}); err != nil { if err := runVctx("sydney", "", "auto", []string{"kv", "list", "kv/"}); err != nil {
t.Fatalf("runVctx: %v", err) t.Fatalf("runVctx: %v", err)
} }
if gotBin != vault { if gotBin != vault {
@@ -179,7 +186,7 @@ func TestRunVctxExecsWithBuiltInvocation(t *testing.T) {
if !reflect.DeepEqual(gotArgv, []string{vault, "kv", "list", "kv/"}) { if !reflect.DeepEqual(gotArgv, []string{vault, "kv", "list", "kv/"}) {
t.Errorf("exec argv = %v", gotArgv) t.Errorf("exec argv = %v", gotArgv)
} }
if v, _ := lastVal(gotEnv, "VAULT_TOKEN"); v != "s.SYDTOK" { if v, _ := envVal(t, gotEnv, "VAULT_TOKEN"); v != "s.SYDTOK" {
t.Errorf("exec env VAULT_TOKEN = %q", v) t.Errorf("exec env VAULT_TOKEN = %q", v)
} }
} }
@@ -192,8 +199,226 @@ func TestRunVctxSurfacesExecError(t *testing.T) {
execVault = func(bin string, argv, env []string) error { return errors.New("boom") } execVault = func(bin string, argv, env []string) error { return errors.New("boom") }
t.Cleanup(func() { execVault = orig }) t.Cleanup(func() { execVault = orig })
err := runVctx("sydney", []string{"status"}) err := runVctx("sydney", "", "auto", []string{"status"})
if err == nil || !strings.Contains(err.Error(), "exec vault") { if err == nil || !strings.Contains(err.Error(), "exec vault") {
t.Errorf("expected wrapped exec error, got %v", err) t.Errorf("expected wrapped exec error, got %v", err)
} }
} }
func TestAutoFormat(t *testing.T) {
cases := []struct {
args []string
env []string
want string
}{
{[]string{"kv", "get", "-mount=secret", "foo"}, nil, "yaml"},
{[]string{"read", "aws/creds/role"}, nil, "yaml"},
{[]string{"write", "db/creds/x", "ttl=1h"}, nil, "yaml"},
{[]string{"kv", "list"}, nil, "table"},
{[]string{"kv", "list", "aws/creds/"}, nil, "table"},
{[]string{"status"}, nil, ""},
{[]string{"kv", "put", "kv/foo", "a=b"}, nil, ""},
{[]string{"kv", "get", "-format=json", "kv/foo"}, nil, ""},
{[]string{"read", "--format", "json", "aws/creds/role"}, nil, ""},
{[]string{"kv", "get", "-field=password", "kv/foo"}, nil, ""},
{[]string{"kv", "get", "--", "-format=json"}, nil, "yaml"},
{[]string{"kv", "get", "kv/foo"}, []string{"VAULT_FORMAT=json"}, ""},
{[]string{"kv", "get", "kv/foo"}, []string{"VAULT_FORMAT="}, "yaml"},
}
for _, c := range cases {
if got := autoFormat(c.args, c.env); got != c.want {
t.Errorf("autoFormat(%v, %v) = %q, want %q", c.args, c.env, got, c.want)
}
}
}
func TestBuildInvocationFormat(t *testing.T) {
setupVctx(t, vctxConfig, map[string]shared.Token{
"sydney": {Address: "https://vault.syd1.au.unkin.net", Token: "s.X"},
})
ambient := []string{"VAULT_FORMAT=table"}
cases := []struct {
flag string
args []string
base []string
want string
set bool
}{
{"", []string{"kv", "get", "kv/foo"}, nil, "yaml", true},
{"", []string{"status"}, nil, "", false},
{"json", []string{"kv", "get", "kv/foo"}, nil, "json", true},
{"json", []string{"status"}, nil, "json", true},
{"", []string{"kv", "get", "kv/foo"}, ambient, "table", true},
{"json", []string{"kv", "get", "kv/foo"}, ambient, "json", true},
}
for _, c := range cases {
_, _, env, err := buildInvocation("sydney", c.flag, c.args, c.base)
if err != nil {
t.Fatalf("buildInvocation: %v", err)
}
if v, ok := envVal(t, env, "VAULT_FORMAT"); v != c.want || ok != c.set {
t.Errorf("flag %q args %v base %v: VAULT_FORMAT = %q (set %v), want %q (set %v)", c.flag, c.args, c.base, v, ok, c.want, c.set)
}
}
}
func TestColourFormat(t *testing.T) {
cases := []struct {
args []string
env []string
want string
}{
{[]string{"kv", "get", "kv/foo"}, []string{"VAULT_FORMAT=yaml"}, "yaml"},
{[]string{"read", "x"}, []string{"VAULT_FORMAT=JSON"}, "json"},
{[]string{"kv", "list", "kv/"}, []string{"VAULT_FORMAT=table"}, ""},
{[]string{"read", "x"}, nil, ""},
{[]string{"read", "x"}, []string{"VAULT_FORMAT=pretty"}, ""},
{[]string{"read", "-format=raw", "x"}, []string{"VAULT_FORMAT=json"}, ""},
{[]string{"read", "-format=json", "x"}, nil, "json"},
{[]string{"read", "--format", "yaml", "x"}, []string{"VAULT_FORMAT=table"}, "yaml"},
{[]string{"read", "--", "-format=json"}, nil, ""},
{[]string{"events", "subscribe", "kv*"}, []string{"VAULT_FORMAT=json"}, ""},
{[]string{"monitor"}, []string{"VAULT_FORMAT=json"}, ""},
{[]string{"ssh", "-format=json", "host"}, nil, ""},
{[]string{"login", "-method=userpass"}, []string{"VAULT_FORMAT=json"}, ""},
{[]string{"operator", "unseal"}, []string{"VAULT_FORMAT=json"}, ""},
{[]string{"-format=yaml", "operator", "generate-root"}, nil, ""},
{[]string{"operator", "rekey", "-format=json"}, nil, ""},
{[]string{"write", "auth/userpass/login/bob"}, []string{"VAULT_FORMAT=json"}, ""},
{[]string{"write", "-format=json", "ns1/auth/x/login/y"}, nil, ""},
{[]string{"write", "-format=json", "secret/foo", "a=b"}, nil, ""},
{[]string{"patch", "auth/x/config"}, []string{"VAULT_FORMAT=json"}, ""},
{[]string{"patch", "-format=yaml", "secret/foo", "a=b"}, nil, ""},
}
for _, c := range cases {
if got := colourFormat(c.args, c.env); got != c.want {
t.Errorf("colourFormat(%v, %v) = %q, want %q", c.args, c.env, got, c.want)
}
}
}
// fakeVault writes a shell script standing in for vault and returns its path.
func fakeVault(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "vault")
if err := os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatal(err)
}
return p
}
func TestRunChild(t *testing.T) {
cases := []struct {
name, body, format string
code int
want string
}{
{"json exit 0", `echo '{"a":1}'`, "json", 0, "\x1b[1;34m\"a\""},
{"yaml exit 3", "echo 'a: b'; exit 3", "yaml", 3, "\x1b[36ma"},
{"invalid passes through", "echo 'not json {'; exit 2", "json", 2, "not json {\n"},
{"signal", "kill -TERM $$", "json", 128 + 15, ""},
}
for _, c := range cases {
bin := fakeVault(t, c.body)
var out bytes.Buffer
code, err := runChild(bin, []string{bin}, nil, c.format, &out)
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
if code != c.code {
t.Errorf("%s: code = %d, want %d", c.name, code, c.code)
}
if !strings.Contains(out.String(), c.want) {
t.Errorf("%s: output %q missing %q", c.name, out.String(), c.want)
}
}
}
func TestRunChildForwardsSignal(t *testing.T) {
dir := t.TempDir()
bin := fakeVault(t, `trap 'echo INT >> "$D/got"' INT
trap 'echo TERM >> "$D/got"; trap - TERM; kill -TERM $$' TERM
touch "$D/ready"
i=0; while [ $i -lt 100 ]; do sleep 0.05; i=$((i+1)); done`)
type res struct {
code int
err error
}
ch := make(chan res, 1)
go func() {
code, err := runChild(bin, []string{bin}, append(os.Environ(), "D="+dir), "json", &bytes.Buffer{})
ch <- res{code, err}
}()
for i := 0; i < 200; i++ {
if _, err := os.Stat(filepath.Join(dir, "ready")); err == nil {
break
}
time.Sleep(10 * time.Millisecond)
}
if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil {
t.Fatal(err)
}
time.Sleep(200 * time.Millisecond)
if err := syscall.Kill(os.Getpid(), syscall.SIGTERM); err != nil {
t.Fatal(err)
}
r := <-ch
if r.err != nil {
t.Fatal(r.err)
}
if got, _ := os.ReadFile(filepath.Join(dir, "got")); string(got) != "TERM\n" {
t.Errorf("child signals = %q, want only SIGTERM", got)
}
if r.code != 128+15 {
t.Errorf("code = %d, want %d", r.code, 128+15)
}
}
func TestRunChildPassesArgsAndEnv(t *testing.T) {
bin := fakeVault(t, `echo "{\"args\":\"$*\",\"tok\":\"$VAULT_TOKEN\"}"`)
var out bytes.Buffer
if _, err := runChild(bin, []string{bin, "kv", "get", "x"}, []string{"VAULT_TOKEN=s.T"}, "json", &out); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "kv get x") || !strings.Contains(out.String(), "s.T") {
t.Errorf("output %q", out.String())
}
}
func TestRunVctxColourDecision(t *testing.T) {
setupVctx(t, vctxConfig, map[string]shared.Token{
"sydney": {Address: "https://vault.syd1.au.unkin.net", Token: "s.X"},
})
t.Setenv("VAULT_FORMAT", "")
origExec, origExit := execVault, osExit
t.Cleanup(func() { execVault, osExit = origExec, origExit })
cases := []struct {
color string
args []string
exec bool
}{
{"never", []string{"kv", "get", "kv/foo"}, true},
{"auto", []string{"kv", "get", "kv/foo"}, true}, // stdout is not a TTY under go test
{"always", []string{"kv", "list", "kv/"}, true},
{"always", []string{"status"}, true},
{"always", []string{"kv", "get", "kv/foo"}, false},
{"always", []string{"read", "-format=json", "x"}, false},
{"always", []string{"operator", "unseal", "-format=json"}, true},
{"always", []string{"login", "-format=json"}, true},
}
for _, c := range cases {
execed, exited := false, -1
execVault = func(string, []string, []string) error { execed = true; return nil }
osExit = func(code int) { exited = code }
if err := runVctx("sydney", "", c.color, c.args); err != nil {
t.Fatalf("%s %v: %v", c.color, c.args, err)
}
if execed != c.exec || (exited == 0) == c.exec {
t.Errorf("%s %v: exec=%v exit=%d, want exec=%v", c.color, c.args, execed, exited, c.exec)
}
}
if err := runVctx("sydney", "", "rainbow", []string{"status"}); err == nil {
t.Error("expected error for invalid --color")
}
}