Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a6a3f559a | |||
| 7540cbc9a4 | |||
| cb1e048e0f | |||
| c89657b347 | |||
| 1cedcbde19 | |||
| 92223ce33d | |||
| 42048ae6ee | |||
| f151902e43 | |||
| ede28266b3 | |||
| ac032216c8 | |||
| c895aec43c | |||
| d0210bf760 | |||
| 1dcb23606f | |||
| 0d9dd267fc | |||
| ddc25139f7 | |||
| 718a3a9063 | |||
| 588aea7858 | |||
| 5a64b10b25 | |||
| b59a62f038 |
+3
-3
@@ -1,6 +1,6 @@
|
||||
# cross-compiled release artifacts at repo root (e.g. vctl-linux-amd64).
|
||||
# NB: do NOT ignore /vctl or /vctx bare — those are the tool source directories.
|
||||
/vctl-*
|
||||
# cross-compiled release artifacts at repo root (e.g. vcm-linux-amd64).
|
||||
# NB: do NOT ignore /vcm or /vctx bare — those are the tool source directories.
|
||||
/vcm-*
|
||||
/vctx-*
|
||||
sha256sums.txt
|
||||
# build output: binaries, completions, RPM
|
||||
|
||||
@@ -18,8 +18,8 @@ steps:
|
||||
cpu: 2
|
||||
|
||||
# Build both binaries into dist/ (consumed by the RPM step) plus the
|
||||
# cross-platform binaries attached to the Gitea release. Each tool is a
|
||||
# separate main package, so they are built individually per os/arch.
|
||||
# linux amd64/arm64 binaries attached to the Gitea release. Each tool is a
|
||||
# separate main package, so they are built individually per arch.
|
||||
- name: build
|
||||
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
|
||||
commands:
|
||||
@@ -28,13 +28,12 @@ steps:
|
||||
# for the shell instead of substituting them (as pipeline vars) at parse
|
||||
# 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##*:}"
|
||||
for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
|
||||
os="$${osarch%/*}"; arch="$${osarch#*/}"
|
||||
GOOS="$$os" GOARCH="$$arch" \
|
||||
for arch in amd64 arm64; do
|
||||
GOOS=linux GOARCH="$$arch" \
|
||||
go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" \
|
||||
-o "$${name}-$${os}-$${arch}" "$$pkg"
|
||||
-o "$${name}-linux-$${arch}" "$$pkg"
|
||||
done
|
||||
done
|
||||
depends_on: [test]
|
||||
@@ -101,7 +100,7 @@ steps:
|
||||
memory: 512Mi
|
||||
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
|
||||
image: git.unkin.net/unkin/almalinux9-base:20260606
|
||||
environment:
|
||||
@@ -131,11 +130,11 @@ steps:
|
||||
NOTES=$$(git log --pretty=format:"- %s")
|
||||
fi
|
||||
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
|
||||
# manifest over everything we attach so downloads can be verified.
|
||||
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"
|
||||
sha256sum $$ASSETS > sha256sums.txt
|
||||
tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \
|
||||
|
||||
@@ -5,24 +5,24 @@
|
||||
`vault-tools` is a Go monorepo of small CLIs for managing Vault tokens across
|
||||
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
|
||||
`~/.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 --context <ctx> <vault args...>` resolves the context, sets
|
||||
`VAULT_ADDR`/`VAULT_TOKEN`/`VAULT_NAMESPACE`, and execs `vault`.
|
||||
|
||||
Both tools share the `shared/` package (config parsing, token cache, Vault HTTP
|
||||
client) and read the SAME config file (`~/.config/vault/vctl.yaml` or
|
||||
`config.yaml`) and token cache so one `~/.config/vault/` configures everything.
|
||||
client) and read the SAME config file (`~/.config/vcm/config.yaml`)
|
||||
and token cache so one `~/.config/vcm/` configures everything.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
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)
|
||||
docs/vctl.md # per-command docs
|
||||
docs/vcm.md # per-command docs
|
||||
docs/vctx.md
|
||||
go.mod # module git.unkin.net/unkin/vault-tools
|
||||
Makefile # build / test / lint / completions / rpm / version-bump
|
||||
@@ -39,9 +39,9 @@ mains to one file).
|
||||
## Build / test
|
||||
|
||||
```bash
|
||||
make build # -> dist/vctl, dist/vctx
|
||||
make build # -> dist/vcm, dist/vctx
|
||||
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)
|
||||
go build ./... && go test ./...
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
BINARIES := vctl vctx
|
||||
BINARIES := vcm vctx
|
||||
DIST := dist
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# vault-tools
|
||||
|
||||
A Go monorepo of small CLI tools for working with multiple Vault instances
|
||||
("contexts"). Both tools share a single config file (`~/.config/vault/`) and
|
||||
token cache (`~/.cache/vault/`), so once a context is configured every tool
|
||||
("contexts"). Both tools share a single config file (`~/.config/vcm/`) and
|
||||
token cache (`~/.cache/vcm/`), so once a context is configured every tool
|
||||
knows about it.
|
||||
|
||||
| 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`). |
|
||||
|
||||
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
|
||||
# 1. Configure your vaults
|
||||
mkdir -p ~/.config/vault
|
||||
cat > ~/.config/vault/vctl.yaml <<'YAML'
|
||||
mkdir -p ~/.config/vcm
|
||||
cat > ~/.config/vcm/config.yaml <<'YAML'
|
||||
defaults:
|
||||
method: ldap
|
||||
user: ben
|
||||
@@ -29,40 +29,41 @@ contexts:
|
||||
namespace: staging
|
||||
YAML
|
||||
|
||||
# 2. Log in (prompts for password), caches a token under ~/.cache/vault/
|
||||
vctl login sydney
|
||||
vctl login --all # or log in to every context at once
|
||||
# 2. Log in (prompts for password), caches a token under ~/.cache/vcm/
|
||||
vcm login sydney
|
||||
vcm login --all # or log in to every context at once
|
||||
|
||||
# 3. Use the vault CLI against a context
|
||||
vctx --context sydney kv put kv/foo/bar secret=baz
|
||||
vctx --context staging/sydney kv list kv/
|
||||
|
||||
# 4. Keep tokens fresh
|
||||
vctl renew --all
|
||||
vcm renew --all
|
||||
|
||||
# See what's configured and which tokens are still valid
|
||||
vctl list
|
||||
vcm list
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Both tools read the first of `~/.config/vault/vctl.yaml` or
|
||||
`~/.config/vault/config.yaml` that exists (`$XDG_CONFIG_HOME` honoured). The
|
||||
Both tools read `~/.config/vcm/config.yaml` (`$XDG_CONFIG_HOME` honoured). The
|
||||
file maps context names to a vault address plus optional per-context overrides
|
||||
(`method`, `user`, `namespace`, `path`) with file-level `defaults`. Context
|
||||
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.
|
||||
|
||||
Tokens are cached under `~/.cache/vault/<context>` (`$XDG_CACHE_HOME` honoured)
|
||||
as JSON with restrictive permissions (files `0600`, dirs `0700`), storing the
|
||||
token plus its accessor, policies, TTL/expiry and renewable flag.
|
||||
Tokens are cached under `~/.cache/vcm/<context>` (`$XDG_CACHE_HOME` honoured)
|
||||
with restrictive permissions (files `0600`, dirs `0700`), storing the token
|
||||
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
|
||||
|
||||
```
|
||||
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)
|
||||
docs/ # per-command documentation
|
||||
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
|
||||
|
||||
```bash
|
||||
make build # build vctl + vctx into dist/
|
||||
make build # build vcm + vctx into dist/
|
||||
make test # go test -race ./...
|
||||
make completions # generate bash/zsh/fish completions into dist/completions/
|
||||
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
|
||||
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`
|
||||
yum repo, and cuts a Gitea release with the binaries + checksums attached.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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
|
||||
// package (some tooling, e.g. the pre-commit go-vet hook, runs `go list` here).
|
||||
package vaulttools
|
||||
|
||||
+57
-31
@@ -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
|
||||
resulting token under `~/.cache/vault/<context>` for use by
|
||||
resulting token under `~/.cache/vcm/<context>` for use by
|
||||
[`vctx`](vctx.md) and other tooling.
|
||||
|
||||
## Synopsis
|
||||
|
||||
```
|
||||
vctl login <context> # log in to one context
|
||||
vctl login --all # log in to every configured context
|
||||
vctl renew <context> # renew one context's cached token
|
||||
vctl renew --all # renew every context that has a cached token
|
||||
vctl list # show contexts + cached-token status
|
||||
vctl version
|
||||
vcm login <context> # log in to one context
|
||||
vcm login --all # log in to every configured context
|
||||
vcm renew <context> # renew one context's cached token
|
||||
vcm renew --all # renew every context that has a cached token
|
||||
vcm list # show contexts + cached-token status
|
||||
vcm version
|
||||
```
|
||||
|
||||
## Flags
|
||||
@@ -23,22 +23,25 @@ Both `login` and `renew` accept:
|
||||
| Flag | Default | Description |
|
||||
| -------------- | -------------------------------- | -------------------------------------------------- |
|
||||
| `--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. |
|
||||
|
||||
`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).
|
||||
|
||||
## Configuration
|
||||
|
||||
`vctl` reads the first of these files that exists:
|
||||
|
||||
1. `~/.config/vault/vctl.yaml`
|
||||
2. `~/.config/vault/config.yaml`
|
||||
|
||||
(`$XDG_CONFIG_HOME` is honoured in place of `~/.config`.)
|
||||
`vcm` reads `~/.config/vcm/config.yaml` (`$XDG_CONFIG_HOME` is honoured in
|
||||
place of `~/.config`).
|
||||
|
||||
```yaml
|
||||
# ~/.config/vault/vctl.yaml
|
||||
# ~/.config/vcm/config.yaml
|
||||
defaults:
|
||||
method: ldap # applied to any context that doesn't set its own
|
||||
user: ben
|
||||
@@ -57,6 +60,11 @@ contexts:
|
||||
address: https://vault-legacy.example.net
|
||||
method: userpass
|
||||
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
|
||||
@@ -68,6 +76,9 @@ For each context, every field is resolved with the chain
|
||||
- `user` — built-in default `$USER`.
|
||||
- `namespace` — no built-in default (unset means the root namespace).
|
||||
- `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` 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
|
||||
|
||||
- **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>`.
|
||||
- **`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.
|
||||
- **`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
|
||||
|
||||
Tokens are written to `~/.cache/vault/<context>` (honouring `$XDG_CACHE_HOME`)
|
||||
as JSON. Slash contexts nest: `staging/sydney` →
|
||||
`~/.cache/vault/staging/sydney`. Parent directories are created `0700` and token
|
||||
Tokens are written to `~/.cache/vcm/<context>` (honouring `$XDG_CACHE_HOME`)
|
||||
encrypted (AES-256-GCM) with a random key held in the kernel user keyring
|
||||
(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`.
|
||||
|
||||
Each file stores enough to inspect and later revoke the token:
|
||||
The decrypted payload stores enough to inspect and later revoke the token:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -111,29 +134,32 @@ The `accessor` lets you revoke the token later without exposing the secret
|
||||
|
||||
```bash
|
||||
# Log in to one context (prompts for password)
|
||||
vctl login sydney
|
||||
vcm login sydney
|
||||
|
||||
# Log in everywhere at once
|
||||
vctl login --all
|
||||
vcm login --all
|
||||
|
||||
# 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
|
||||
vctl renew staging/sydney
|
||||
vcm renew staging/sydney
|
||||
|
||||
# Renew everything that currently has a cached token
|
||||
vctl renew --all
|
||||
vcm renew --all
|
||||
|
||||
# See what is configured and which tokens are still valid
|
||||
vctl list
|
||||
vcm list
|
||||
```
|
||||
|
||||
## Shell completion
|
||||
|
||||
```bash
|
||||
vctl completion bash > /etc/bash_completion.d/vctl
|
||||
vctl completion zsh > ~/.zsh/completions/_vctl
|
||||
vcm completion bash > /etc/bash_completion.d/vcm
|
||||
vcm completion zsh > ~/.zsh/completions/_vcm
|
||||
```
|
||||
|
||||
Context names complete dynamically from the config file (the RPM installs these
|
||||
+54
-12
@@ -1,14 +1,14 @@
|
||||
# vctx
|
||||
|
||||
`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
|
||||
invocation only, and execs `vault` with the remaining arguments.
|
||||
|
||||
## Synopsis
|
||||
|
||||
```
|
||||
vctx --context <context> <any vault args...>
|
||||
vctx --context <context> [--format <fmt>] [--color auto|always|never] <any vault args...>
|
||||
vctx version
|
||||
```
|
||||
|
||||
@@ -18,26 +18,68 @@ vctx version
|
||||
vctx --context sydney kv put kv/foo/bar secret=baz
|
||||
```
|
||||
|
||||
1. Resolves the `sydney` context from `~/.config/vault/vctl.yaml` (or
|
||||
`config.yaml`) — the exact file `vctl` uses.
|
||||
2. Loads the cached token from `~/.cache/vault/sydney` (errors with a hint to
|
||||
run `vctl login sydney` if none exists).
|
||||
1. Resolves the `sydney` context from `~/.config/vcm/config.yaml` — the exact file `vcm` uses.
|
||||
2. Loads the cached token from `~/.cache/vcm/sydney` (errors with a hint to
|
||||
run `vcm login sydney` if none exists or it is locked after a reboot).
|
||||
3. Sets, for this process only:
|
||||
- `VAULT_ADDR` = the context's address
|
||||
- `VAULT_TOKEN` = the cached token
|
||||
- `VAULT_NAMESPACE` = the context's namespace (only if non-empty; falls back
|
||||
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
|
||||
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.
|
||||
|
||||
## The `--context` flag
|
||||
## Output format
|
||||
|
||||
`--context` is the only flag `vctx` consumes; it must come **before** the vault
|
||||
command. Everything from the first non-flag argument onward is handed to `vault`
|
||||
Without `--format`, `vctx` sets `VAULT_FORMAT` from the vault command:
|
||||
|
||||
| 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:
|
||||
|
||||
```bash
|
||||
@@ -68,7 +110,7 @@ vctx --context sydney token lookup
|
||||
## Requirements
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -1,15 +1,16 @@
|
||||
module git.unkin.net/unkin/vault-tools
|
||||
|
||||
go 1.25
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.unkin.net/unkin/golib v0.2.0
|
||||
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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
)
|
||||
|
||||
@@ -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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
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/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
+9
-9
@@ -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`.
|
||||
|
||||
name: ${PACKAGE_NAME}
|
||||
@@ -24,8 +24,8 @@ provides:
|
||||
|
||||
contents:
|
||||
# The CLI binaries.
|
||||
- src: dist/vctl
|
||||
dst: /usr/bin/vctl
|
||||
- src: dist/vcm
|
||||
dst: /usr/bin/vcm
|
||||
file_info:
|
||||
mode: 0755
|
||||
owner: root
|
||||
@@ -38,16 +38,16 @@ contents:
|
||||
group: root
|
||||
|
||||
# Shell completions (generated by scripts/build-rpm.sh before packaging).
|
||||
- src: dist/completions/vctl.bash
|
||||
dst: /usr/share/bash-completion/completions/vctl
|
||||
- src: dist/completions/vcm.bash
|
||||
dst: /usr/share/bash-completion/completions/vcm
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: dist/completions/_vctl
|
||||
dst: /usr/share/zsh/site-functions/_vctl
|
||||
- src: dist/completions/_vcm
|
||||
dst: /usr/share/zsh/site-functions/_vcm
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: dist/completions/vctl.fish
|
||||
dst: /usr/share/fish/vendor_completions.d/vctl.fish
|
||||
- src: dist/completions/vcm.fish
|
||||
dst: /usr/share/fish/vendor_completions.d/vcm.fish
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: dist/completions/vctx.bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/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.
|
||||
# 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="${VERSION#v}" # strip a leading v
|
||||
PACKAGE="vault-tools"
|
||||
BINARIES=(vctl vctx)
|
||||
BINARIES=(vcm vctx)
|
||||
DIST="dist"
|
||||
|
||||
for b in "${BINARIES[@]}"; do
|
||||
@@ -37,7 +37,7 @@ export PACKAGE_VERSION="${VERSION}"
|
||||
export PACKAGE_RELEASE="1"
|
||||
export PACKAGE_ARCH="amd64"
|
||||
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_HOMEPAGE="https://git.unkin.net/unkin/vault-tools"
|
||||
export PACKAGE_LICENSE="MIT"
|
||||
|
||||
+59
-15
@@ -2,13 +2,24 @@ package shared
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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
|
||||
// later inspect or revoke the token (accessor, policies) and to reason about
|
||||
// its lifetime (issued/expiry, renewable) — not just the secret itself.
|
||||
@@ -39,7 +50,7 @@ type Token struct {
|
||||
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 {
|
||||
base := os.Getenv("XDG_CACHE_HOME")
|
||||
if base == "" {
|
||||
@@ -82,8 +93,9 @@ func validateContextName(context string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveToken writes a token to its cache path as JSON, creating parent
|
||||
// directories (0700) as needed and writing the file with 0600 permissions.
|
||||
// SaveToken writes a token to its cache path as keyring-sealed JSON, creating
|
||||
// parent directories (0700) as needed and writing the file with 0600
|
||||
// permissions.
|
||||
func SaveToken(t *Token) error {
|
||||
path, err := TokenPath(t.Context)
|
||||
if err != nil {
|
||||
@@ -96,24 +108,38 @@ func SaveToken(t *Token) error {
|
||||
if err != nil {
|
||||
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
|
||||
// written, and create it 0600 from the start (never briefly world-readable).
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
// Write via a unique temp file + rename so a token file is never left
|
||||
// partially written and concurrent writers never share a temp file.
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing token: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
defer func() { _ = os.Remove(f.Name()) }()
|
||||
_, 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 nil
|
||||
}
|
||||
|
||||
// LoadToken reads and decodes a cached token for a context. A missing token
|
||||
// returns an error wrapping os.ErrNotExist so callers can detect "not logged
|
||||
// in" with errors.Is.
|
||||
// LoadToken reads and decrypts a cached token for a context. A missing token
|
||||
// wraps os.ErrNotExist; one that cannot be decrypted (key lost on reboot, or a
|
||||
// legacy plaintext file) wraps ErrLocked. Both mean "run vcm login".
|
||||
func LoadToken(context string) (*Token, error) {
|
||||
path, err := TokenPath(context)
|
||||
if err != nil {
|
||||
@@ -122,17 +148,35 @@ func LoadToken(context string) (*Token, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
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
|
||||
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 &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.
|
||||
func DeleteToken(context string) error {
|
||||
path, err := TokenPath(context)
|
||||
|
||||
+63
-63
@@ -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
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -19,12 +19,10 @@ const (
|
||||
DefaultMethod = "ldap"
|
||||
|
||||
// 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
|
||||
// first one that exists wins.
|
||||
var configFileNames = []string{"vctl.yaml", "config.yaml"}
|
||||
configFileName = "config.yaml"
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -32,7 +30,7 @@ var configFileNames = []string{"vctl.yaml", "config.yaml"}
|
||||
type Context struct {
|
||||
// Address is the Vault API base URL, e.g. https://vault.syd1.au.unkin.net.
|
||||
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"`
|
||||
// User is the login username (LDAP/userpass/...); ignored for token auth.
|
||||
User string `yaml:"user,omitempty"`
|
||||
@@ -40,6 +38,10 @@ type Context struct {
|
||||
Namespace string `yaml:"namespace,omitempty"`
|
||||
// Path overrides the auth mount path (defaults to Method), e.g. "ldap2".
|
||||
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
|
||||
@@ -64,15 +66,26 @@ type Config struct {
|
||||
// ResolvedContext is a Context with all defaults applied, ready to use. Method
|
||||
// and User are always populated.
|
||||
type ResolvedContext struct {
|
||||
Name string
|
||||
Address string
|
||||
Method string
|
||||
User string
|
||||
Namespace string
|
||||
Path string
|
||||
Name string
|
||||
Address string
|
||||
Method string
|
||||
User string
|
||||
Namespace 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 {
|
||||
base := os.Getenv("XDG_CONFIG_HOME")
|
||||
if base == "" {
|
||||
@@ -82,45 +95,32 @@ func ConfigDir() string {
|
||||
return filepath.Join(base, appDir)
|
||||
}
|
||||
|
||||
// ConfigPath returns the path of the first existing config file, or the path
|
||||
// the file would take (the first candidate) when none exists yet.
|
||||
// ConfigPath returns the path of the config file, whether or not it exists.
|
||||
func ConfigPath() string {
|
||||
dir := ConfigDir()
|
||||
for _, name := range configFileNames {
|
||||
p := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return filepath.Join(dir, configFileNames[0])
|
||||
return filepath.Join(ConfigDir(), configFileName)
|
||||
}
|
||||
|
||||
// Load reads and parses the first config file found in ConfigDir. A missing
|
||||
// config file is not an error: an empty Config is returned so callers can give
|
||||
// a helpful "no contexts configured" message.
|
||||
// Load reads and parses the config file. A missing config file is not an
|
||||
// error: an empty Config is returned so callers can give a helpful "no
|
||||
// contexts configured" message.
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{Contexts: map[string]Context{}}
|
||||
|
||||
dir := ConfigDir()
|
||||
for _, name := range configFileNames {
|
||||
p := filepath.Join(dir, name)
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return cfg, fmt.Errorf("reading config %s: %w", p, err)
|
||||
p := ConfigPath()
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
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, fmt.Errorf("reading config %s: %w", p, err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -156,34 +156,34 @@ func (c *Config) Resolve(name string) (ResolvedContext, error) {
|
||||
path := firstNonEmpty(ctx.Path, method)
|
||||
|
||||
return ResolvedContext{
|
||||
Name: name,
|
||||
Address: ctx.Address,
|
||||
Method: method,
|
||||
User: user,
|
||||
Namespace: namespace,
|
||||
Path: path,
|
||||
Name: name,
|
||||
Address: ctx.Address,
|
||||
Method: method,
|
||||
User: user,
|
||||
Namespace: namespace,
|
||||
Path: path,
|
||||
Role: ctx.Role,
|
||||
OIDCListen: firstNonEmpty(ctx.OIDCListen, DefaultOIDCListen),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveWithOverrides is Resolve plus explicit CLI-flag overrides for method
|
||||
// and user; an empty override leaves the resolved value untouched. When the
|
||||
// ResolveWithOverrides is Resolve plus explicit CLI-flag overrides. When the
|
||||
// method is overridden and the context did not pin an explicit auth path, the
|
||||
// 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)
|
||||
if err != nil {
|
||||
return rc, err
|
||||
}
|
||||
if method != "" {
|
||||
pinnedPath := c.Contexts[name].Path != ""
|
||||
rc.Method = method
|
||||
if !pinnedPath {
|
||||
rc.Path = method
|
||||
if o.Method != "" {
|
||||
rc.Method = o.Method
|
||||
if c.Contexts[name].Path == "" {
|
||||
rc.Path = o.Method
|
||||
}
|
||||
}
|
||||
if user != "" {
|
||||
rc.User = user
|
||||
}
|
||||
rc.User = firstNonEmpty(o.User, rc.User)
|
||||
rc.Role = firstNonEmpty(o.Role, rc.Role)
|
||||
rc.OIDCListen = firstNonEmpty(o.Listen, rc.OIDCListen)
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
|
||||
+47
-21
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
// 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 {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
@@ -42,7 +42,7 @@ contexts:
|
||||
`
|
||||
|
||||
func TestLoadAndContextNames(t *testing.T) {
|
||||
withConfigDir(t, "vctl.yaml", sampleConfig)
|
||||
withConfigDir(t, "config.yaml", sampleConfig)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
@@ -63,18 +63,7 @@ func TestLoadAndContextNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPathPrefersVctlYaml(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) {
|
||||
func TestConfigPath(t *testing.T) {
|
||||
base := withConfigDir(t, "config.yaml", sampleConfig)
|
||||
if got, want := ConfigPath(), filepath.Join(base, appDir, "config.yaml"); got != want {
|
||||
t.Errorf("ConfigPath = %q, want %q", got, want)
|
||||
@@ -93,7 +82,7 @@ func TestLoadMissingConfigIsNotError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveAppliesDefaults(t *testing.T) {
|
||||
withConfigDir(t, "vctl.yaml", sampleConfig)
|
||||
withConfigDir(t, "config.yaml", sampleConfig)
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -128,7 +117,7 @@ func TestResolveAppliesDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveUnknownContext(t *testing.T) {
|
||||
withConfigDir(t, "vctl.yaml", sampleConfig)
|
||||
withConfigDir(t, "config.yaml", sampleConfig)
|
||||
cfg, _ := Load()
|
||||
if _, err := cfg.Resolve("nope"); err == nil {
|
||||
t.Error("expected error for unknown context")
|
||||
@@ -136,7 +125,7 @@ func TestResolveUnknownContext(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveMethodDefaultWhenUnset(t *testing.T) {
|
||||
withConfigDir(t, "vctl.yaml", `
|
||||
withConfigDir(t, "config.yaml", `
|
||||
contexts:
|
||||
bare:
|
||||
address: https://vault.example.net
|
||||
@@ -156,11 +145,11 @@ contexts:
|
||||
}
|
||||
|
||||
func TestResolveWithOverrides(t *testing.T) {
|
||||
withConfigDir(t, "vctl.yaml", sampleConfig)
|
||||
withConfigDir(t, "config.yaml", sampleConfig)
|
||||
cfg, _ := Load()
|
||||
|
||||
// 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 {
|
||||
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.
|
||||
rc, err = cfg.ResolveWithOverrides("legacy", "okta", "")
|
||||
rc, err = cfg.ResolveWithOverrides("legacy", Overrides{Method: "okta"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -178,7 +167,7 @@ func TestResolveWithOverrides(t *testing.T) {
|
||||
}
|
||||
|
||||
// Empty overrides leave resolved values untouched.
|
||||
rc, err = cfg.ResolveWithOverrides("sydney", "", "")
|
||||
rc, err = cfg.ResolveWithOverrides("sydney", Overrides{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -186,3 +175,40 @@ func TestResolveWithOverrides(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
}
|
||||
@@ -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
@@ -3,9 +3,11 @@ package shared
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -79,8 +81,8 @@ func doRequest(method, address, path, namespace, token string, payload any) ([]b
|
||||
body = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
url := strings.TrimRight(address, "/") + "/v1/" + strings.TrimLeft(path, "/")
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
reqURL := strings.TrimRight(address, "/") + "/v1/" + strings.TrimLeft(path, "/")
|
||||
req, err := http.NewRequest(method, reqURL, body)
|
||||
if err != nil {
|
||||
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)
|
||||
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() }()
|
||||
|
||||
@@ -133,11 +141,14 @@ func tokenFromAuth(rc ResolvedContext, ar authResponse) *Token {
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
if IsTokenMethod(rc.Method) {
|
||||
return loginWithToken(rc, secret)
|
||||
}
|
||||
if rc.Method == "oidc" {
|
||||
return LoginOIDC(rc)
|
||||
}
|
||||
if !NeedsPassword(rc.Method) {
|
||||
return nil, fmt.Errorf("unsupported auth method %q", rc.Method)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
// 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.
|
||||
//
|
||||
// vctl login sydney
|
||||
// vctl login --all
|
||||
// vctl renew staging/sydney
|
||||
// vctl renew --all
|
||||
// vcm login sydney
|
||||
// vcm login --all
|
||||
// vcm renew staging/sydney
|
||||
// vcm renew --all
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -33,17 +34,16 @@ func main() {
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
var (
|
||||
method string
|
||||
user string
|
||||
all bool
|
||||
ov shared.Overrides
|
||||
all bool
|
||||
)
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "vctl",
|
||||
Use: "vcm",
|
||||
Short: "Manage Vault tokens for multiple vault instances (contexts).",
|
||||
Long: "vctl 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" +
|
||||
"~/.cache/vault/<context> for use by vctx and other tooling.",
|
||||
Long: "vcm logs in to and renews Vault tokens for the contexts defined in\n" +
|
||||
"~/.config/vcm/config.yaml, caching each token under\n" +
|
||||
"~/.cache/vcm/<context> for use by vctx and other tooling.",
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
@@ -66,10 +66,12 @@ func newRootCmd() *cobra.Command {
|
||||
ValidArgsFunction: contextCompletion,
|
||||
SilenceUsage: true,
|
||||
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().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{
|
||||
Use: "renew [context]",
|
||||
@@ -78,17 +80,17 @@ func newRootCmd() *cobra.Command {
|
||||
ValidArgsFunction: contextCompletion,
|
||||
SilenceUsage: true,
|
||||
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")
|
||||
|
||||
// --method / --user apply to both login and renew.
|
||||
for _, c := range []*cobra.Command{loginCmd, renewCmd} {
|
||||
c.Flags().StringVar(&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.Method, "method", "", "Auth method override (default: context/config or "+shared.DefaultMethod+")")
|
||||
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) {
|
||||
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,
|
||||
// reporting per-context success/failure and returning an error only when at
|
||||
// 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()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -143,7 +145,7 @@ func runOverContexts(args []string, all bool, method, user, verb string, fn cont
|
||||
|
||||
var failed int
|
||||
for _, name := range names {
|
||||
rc, err := cfg.ResolveWithOverrides(name, method, user)
|
||||
rc, err := cfg.ResolveWithOverrides(name, ov)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s %s: %v\n", verb, name, err)
|
||||
failed++
|
||||
@@ -251,7 +253,14 @@ func runList(w io.Writer) error {
|
||||
continue
|
||||
}
|
||||
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() {
|
||||
remaining := time.Until(tok.ExpiresAt)
|
||||
if remaining > 0 {
|
||||
@@ -16,23 +16,23 @@ import (
|
||||
"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.
|
||||
func setupVctl(t *testing.T, cfg string) {
|
||||
func setupVcm(t *testing.T, cfg string) {
|
||||
t.Helper()
|
||||
cfgHome := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", cfgHome)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const vctlConfig = `
|
||||
const vcmConfig = `
|
||||
defaults:
|
||||
method: ldap
|
||||
user: ben
|
||||
@@ -47,13 +47,13 @@ contexts:
|
||||
// --- runOverContexts: selection + iteration + aggregation ------------------
|
||||
|
||||
func TestRunOverContextsSingle(t *testing.T) {
|
||||
setupVctl(t, vctlConfig)
|
||||
setupVcm(t, vcmConfig)
|
||||
var seen []string
|
||||
fn := func(rc shared.ResolvedContext) (*shared.Token, error) {
|
||||
seen = append(seen, rc.Name)
|
||||
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)
|
||||
}
|
||||
if len(seen) != 1 || seen[0] != "sydney" {
|
||||
@@ -66,13 +66,13 @@ func TestRunOverContextsSingle(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunOverContextsAllIteratesEveryContext(t *testing.T) {
|
||||
setupVctl(t, vctlConfig)
|
||||
setupVcm(t, vcmConfig)
|
||||
var seen []string
|
||||
fn := func(rc shared.ResolvedContext) (*shared.Token, error) {
|
||||
seen = append(seen, rc.Name)
|
||||
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)
|
||||
}
|
||||
sort.Strings(seen)
|
||||
@@ -88,40 +88,40 @@ func TestRunOverContextsAllIteratesEveryContext(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 }
|
||||
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") {
|
||||
t.Errorf("expected conflict error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOverContextsNoTarget(t *testing.T) {
|
||||
setupVctl(t, vctlConfig)
|
||||
setupVcm(t, vcmConfig)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOverContextsAllEmptyConfig(t *testing.T) {
|
||||
setupVctl(t, "contexts: {}\n")
|
||||
setupVcm(t, "contexts: {}\n")
|
||||
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") {
|
||||
t.Errorf("expected no-contexts error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOverContextsAggregatesFailures(t *testing.T) {
|
||||
setupVctl(t, vctlConfig)
|
||||
setupVcm(t, vcmConfig)
|
||||
fn := func(rc shared.ResolvedContext) (*shared.Token, error) {
|
||||
if rc.Name == "sydney" {
|
||||
return nil, io.ErrUnexpectedEOF // simulate a login failure
|
||||
}
|
||||
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") {
|
||||
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) {
|
||||
// A context missing an address fails resolution and is counted as a failure.
|
||||
setupVctl(t, `
|
||||
setupVcm(t, `
|
||||
contexts:
|
||||
good:
|
||||
address: https://vault.example.net
|
||||
@@ -146,20 +146,20 @@ contexts:
|
||||
fn := func(rc shared.ResolvedContext) (*shared.Token, error) {
|
||||
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") {
|
||||
t.Errorf("expected resolve failure counted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOverContextsAppliesOverrides(t *testing.T) {
|
||||
setupVctl(t, vctlConfig)
|
||||
setupVcm(t, vcmConfig)
|
||||
var got shared.ResolvedContext
|
||||
fn := func(rc shared.ResolvedContext) (*shared.Token, error) {
|
||||
got = rc
|
||||
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)
|
||||
}
|
||||
if got.Method != "okta" || got.User != "someone" || got.Path != "okta" {
|
||||
@@ -237,7 +237,7 @@ func TestDoLoginTokenMethod(t *testing.T) {
|
||||
// --- doRenew ---------------------------------------------------------------
|
||||
|
||||
func TestDoRenewNoCachedToken(t *testing.T) {
|
||||
setupVctl(t, vctlConfig)
|
||||
setupVcm(t, vcmConfig)
|
||||
rc := shared.ResolvedContext{Name: "sydney", Address: "https://vault.example.net"}
|
||||
if _, err := doRenew(rc); err == nil {
|
||||
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) {
|
||||
setupVctl(t, vctlConfig)
|
||||
setupVcm(t, vcmConfig)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/auth/token/renew-self" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
@@ -276,7 +276,7 @@ func TestDoRenewUsesCachedToken(t *testing.T) {
|
||||
// --- runList ---------------------------------------------------------------
|
||||
|
||||
func TestRunListShowsTokenStatus(t *testing.T) {
|
||||
setupVctl(t, vctlConfig)
|
||||
setupVcm(t, vcmConfig)
|
||||
// sydney has a valid token; staging/sydney has none.
|
||||
if err := shared.SaveToken(&shared.Token{
|
||||
Context: "sydney",
|
||||
@@ -300,7 +300,7 @@ func TestRunListShowsTokenStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunListEmptyConfig(t *testing.T) {
|
||||
setupVctl(t, "contexts: {}\n")
|
||||
setupVcm(t, "contexts: {}\n")
|
||||
var buf bytes.Buffer
|
||||
if err := runList(&buf); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -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
@@ -1,19 +1,26 @@
|
||||
// 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
|
||||
// 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 staging/sydney token lookup
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"git.unkin.net/unkin/golib/colour"
|
||||
"git.unkin.net/unkin/vault-tools/shared"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -25,6 +32,14 @@ var version = "dev"
|
||||
// variable so tests can substitute a fake in place of 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() {
|
||||
if err := newRootCmd().Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
@@ -32,15 +47,16 @@ func main() {
|
||||
}
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
var context string
|
||||
var context, format, color string
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "vctx --context <context> <vault args...>",
|
||||
Short: "Run the vault CLI against a named context.",
|
||||
Long: "vctx resolves a context from ~/.config/vault (shared with vctl), loads its\n" +
|
||||
"cached token from ~/.cache/vault/<context>, sets VAULT_ADDR, VAULT_TOKEN and\n" +
|
||||
"VAULT_NAMESPACE for this invocation only, and execs `vault` with the\n" +
|
||||
"remaining arguments.\n\n" +
|
||||
Long: "vctx resolves a context from ~/.config/vcm (shared with vcm), loads its\n" +
|
||||
"cached token from ~/.cache/vcm/<context>, sets VAULT_ADDR, VAULT_TOKEN and\n" +
|
||||
"VAULT_NAMESPACE (and VAULT_FORMAT: yaml for kv get and */creds/*, table\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",
|
||||
// Everything from the first non-flag argument on is the vault command
|
||||
// line; SetInterspersed(false) below stops flag parsing there so flags
|
||||
@@ -48,7 +64,7 @@ func newRootCmd() *cobra.Command {
|
||||
Args: cobra.ArbitraryArgs,
|
||||
SilenceUsage: true,
|
||||
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
|
||||
})
|
||||
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{
|
||||
Use: "version",
|
||||
@@ -77,19 +99,32 @@ func newRootCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
// runVctx validates inputs, builds the vault invocation for the context, and
|
||||
// execs it (replacing this process).
|
||||
func runVctx(context string, args []string) error {
|
||||
// execs it (replacing this process), or runs it as a child and colourises its
|
||||
// stdout when colour is enabled and the output format is json or yaml.
|
||||
func runVctx(context, format, color string, args []string) error {
|
||||
if context == "" {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
// behaviour pass straight through.
|
||||
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
|
||||
// binary, and returns the binary path, argv (vault + args) and the environment
|
||||
// to exec with. baseEnv is the starting environment (normally os.Environ());
|
||||
// the Vault settings are appended so they override any ambient values (exec
|
||||
// semantics: the last assignment of a variable wins).
|
||||
func buildInvocation(context string, args, baseEnv []string) (bin string, argv []string, env []string, err error) {
|
||||
// ambient copies of the keys vctx sets are dropped first, since Go programs
|
||||
// (vault included) honour the first occurrence of a duplicated variable.
|
||||
// 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()
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
env = make([]string, len(baseEnv), len(baseEnv)+3)
|
||||
copy(env, baseEnv)
|
||||
env = append(env,
|
||||
"VAULT_ADDR="+rc.Address,
|
||||
"VAULT_TOKEN="+tok.Token,
|
||||
)
|
||||
set := map[string]string{
|
||||
"VAULT_ADDR": rc.Address,
|
||||
"VAULT_TOKEN": tok.Token,
|
||||
}
|
||||
// Namespace comes from the context, falling back to what the token was
|
||||
// issued under; only set when non-empty.
|
||||
ns := rc.Namespace
|
||||
@@ -135,9 +170,153 @@ func buildInvocation(context string, args, baseEnv []string) (bin string, argv [
|
||||
ns = tok.Namespace
|
||||
}
|
||||
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...)
|
||||
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
@@ -1,12 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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_CACHE_HOME", t.TempDir())
|
||||
|
||||
cfgDir := filepath.Join(cfgHome, "vault")
|
||||
cfgDir := filepath.Join(cfgHome, "vcm")
|
||||
if err := os.MkdirAll(cfgDir, 0o700); err != nil {
|
||||
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)
|
||||
}
|
||||
for name, tok := range tokens {
|
||||
@@ -53,16 +56,20 @@ contexts:
|
||||
namespace: staging
|
||||
`
|
||||
|
||||
// lastVal returns the value of the last occurrence of key in an env slice,
|
||||
// mirroring exec's "last assignment wins" semantics.
|
||||
func lastVal(env []string, key string) (string, bool) {
|
||||
val, ok := "", false
|
||||
// envVal returns key's value from an env slice, failing the test if the key
|
||||
// appears more than once (Go's os/exec children honour the first occurrence).
|
||||
func envVal(t *testing.T, env []string, key string) (string, bool) {
|
||||
t.Helper()
|
||||
val, n := "", 0
|
||||
for _, e := range env {
|
||||
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) {
|
||||
@@ -71,10 +78,10 @@ func TestBuildInvocationArgsAndEnvOverride(t *testing.T) {
|
||||
})
|
||||
|
||||
// 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"}
|
||||
|
||||
bin, argv, env, err := buildInvocation("sydney", args, baseEnv)
|
||||
bin, argv, env, err := buildInvocation("sydney", "", args, baseEnv)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInvocation: %v", err)
|
||||
}
|
||||
@@ -86,17 +93,17 @@ func TestBuildInvocationArgsAndEnvOverride(t *testing.T) {
|
||||
if !reflect.DeepEqual(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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -105,11 +112,11 @@ func TestBuildInvocationNamespaceFromContext(t *testing.T) {
|
||||
setupVctx(t, vctxConfig, map[string]shared.Token{
|
||||
"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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -120,18 +127,18 @@ func TestBuildInvocationNamespaceFallsBackToToken(t *testing.T) {
|
||||
setupVctx(t, vctxConfig, map[string]shared.Token{
|
||||
"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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInvocationMissingToken(t *testing.T) {
|
||||
setupVctx(t, vctxConfig, nil) // no cached tokens
|
||||
_, _, _, err := buildInvocation("sydney", []string{"status"}, nil)
|
||||
_, _, _, err := buildInvocation("sydney", "", []string{"status"}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing cached token")
|
||||
}
|
||||
@@ -142,16 +149,16 @@ func TestBuildInvocationMissingToken(t *testing.T) {
|
||||
|
||||
func TestBuildInvocationUnknownContext(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -170,7 +177,7 @@ func TestRunVctxExecsWithBuiltInvocation(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
if gotBin != vault {
|
||||
@@ -179,7 +186,7 @@ func TestRunVctxExecsWithBuiltInvocation(t *testing.T) {
|
||||
if !reflect.DeepEqual(gotArgv, []string{vault, "kv", "list", "kv/"}) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -192,8 +199,226 @@ func TestRunVctxSurfacesExecError(t *testing.T) {
|
||||
execVault = func(bin string, argv, env []string) error { return errors.New("boom") }
|
||||
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") {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user