diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73d38fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# 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-* +/vctx-* +sha256sums.txt +# build output: binaries, completions, RPM +dist/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2e63b82 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-merge-conflict + - id: mixed-line-ending + args: [--fix=lf] + + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-vet + - id: go-unit-tests diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..74123b5 --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: build + image: golang:1.25 + commands: + - make build + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml new file mode 100644 index 0000000..7cc0f7d --- /dev/null +++ b/.woodpecker/release.yaml @@ -0,0 +1,153 @@ +when: + - event: tag + +steps: + - name: test + image: golang:1.25 + commands: + - go test -race ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + 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. + - name: build + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - make build VERSION=${CI_COMMIT_TAG} + # Shell variables/expansions are escaped as $$ so Woodpecker leaves them + # 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 + name="$${entry%%:*}"; pkg="$${entry##*:}" + for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do + os="$${osarch%/*}"; arch="$${osarch#*/}" + GOOS="$$os" GOARCH="$$arch" \ + go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" \ + -o "$${name}-$${os}-$${arch}" "$$pkg" + done + done + depends_on: [test] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + # Package the built binaries + generated shell completions into an RPM. + - name: package + image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest + commands: + - ./scripts/build-rpm.sh ${CI_COMMIT_TAG} + depends_on: [build] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + # Publish the RPM to the artifactapi local rpm repo (a real yum repo; + # repodata regenerates automatically). + - name: upload-rpm + image: git.unkin.net/unkin/almalinux9-base:20260606 + commands: + - | + HOST="https://artifactapi.k8s.syd1.au.unkin.net" + REPO="rpm-internal" + for rpm in dist/*.rpm; do + FILE=$$(basename "$$rpm") + # artifactapi has no HEAD route (returns 405); probe with GET against + # the served path (RPMs are stored under Packages/) to avoid re-upload. + code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true) + if [ "$$code" = "200" ]; then + echo "$$FILE already exists in $$REPO (HTTP $$code); skipping upload" + continue + fi + echo "Uploading $$FILE to $$REPO (existence probe returned $$code)" + curl -f -X PUT \ + "$$HOST/api/v2/remotes/$$REPO/files/$$FILE" \ + -H "Content-Type: application/x-rpm" \ + --data-binary @"$$rpm" + done + depends_on: [package] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 500m + + # Cut a Gitea release with the cross-platform binaries attached. + - name: release + image: git.unkin.net/unkin/almalinux9-base:20260606 + environment: + RELEASER_TOKEN: + from_secret: RELEASER_TOKEN + commands: + - | + curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea + tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check + # $$ escapes shell vars/substitutions so Woodpecker doesn't blank them + # at parse time; ${CI_COMMIT_TAG}/${CI_REPO} are real Woodpecker vars. + # Find the previous release tag for the changelog range. Several tags can + # point at the same commit, so we skip tags on the current commit and + # pick the newest semver tag that is a real ancestor of this one. + CUR_SHA=$$(git rev-list -n1 "${CI_COMMIT_TAG}") + PREV_TAG="" + for t in $$(git tag --sort=-v:refname); do + [ "$$t" = "${CI_COMMIT_TAG}" ] && continue + [ "$$(git rev-list -n1 "$$t")" = "$$CUR_SHA" ] && continue + if git merge-base --is-ancestor "$$t" "${CI_COMMIT_TAG}" 2>/dev/null; then + PREV_TAG="$$t"; break + fi + done + if [ -n "$$PREV_TAG" ]; then + NOTES=$$(git log "$${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s") + else + 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 + # 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" + [ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM" + sha256sum $$ASSETS > sha256sums.txt + tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \ + --login gitea --repo "${CI_REPO}" + depends_on: [upload-rpm] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 500m diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..5e179a7 --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,33 @@ +when: + - event: pull_request + +steps: + - name: lint + image: golangci/golangci-lint:latest + commands: + - golangci-lint run ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: test + image: golang:1.25 + commands: + - go test -v -race ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..66cba8b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,66 @@ +# AGENTS.md + +## Project Overview + +`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`, + `list`), with `--all`, `--method` and `--user` flags. Caches each token under + `~/.cache/vault/` as JSON (token + accessor + policies + TTL/expiry). +- **`vctx`** — thin wrapper around the real `vault` CLI: + `vctx --context ` 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. + +## Structure + +``` +shared/ # config.go, cache.go, vault.go (+ *_test.go) +vctl/main.go # vctl CLI (module main package) +vctx/main.go # vctx CLI (module main package) +docs/vctl.md # per-command docs +docs/vctx.md +go.mod # module git.unkin.net/unkin/vault-tools +Makefile # build / test / lint / completions / rpm / version-bump +packaging/nfpm.yaml# nfpm spec (envsubst-templated) for the RPM (both binaries) +scripts/build-rpm.sh +.woodpecker/ # CI: build, test, pre-commit (PR) + release (tag) +dist/ # build output: binaries, completions, RPM (not committed) +``` + +Each tool is a separate `main` package under its own folder, so `make build` +builds each with its own `-o` (a single `go build ./...` can't emit multiple +mains to one file). + +## Build / test + +```bash +make build # -> dist/vctl, dist/vctx +make test # go test -race ./... +make completions # -> dist/completions/{vctl,vctx}.{bash,fish}, _{vctl,vctx} +make rpm # build + nfpm RPM (bundles completions) +go build ./... && go test ./... +``` + +## Conventions + +- Config/cache dirs honour `$XDG_CONFIG_HOME` / `$XDG_CACHE_HOME`. +- Context names may contain slashes (`staging/sydney`) and nest in the cache; + names are validated to prevent path traversal (see `shared/cache.go`). +- Token files are `0600`, their parent dirs `0700`; writes are atomic + (temp file + rename). +- `shared/vault.go` is a small hand-rolled Vault HTTP client (no + `hashicorp/vault/api` dependency) — only `cobra`, `yaml.v3`, `x/term`. + +## Releasing + +Releases run in Woodpecker on `v*` tags. `make patch|minor|major` tags + pushes. +The release pipeline cross-compiles both binaries, builds an RPM (with +completions), PUTs it to the artifactapi `rpm-internal` yum repo, and cuts a +Gitea release. Every pipeline step sets k8s resources and +`serviceAccountName: default`; the release step uses the `RELEASER_TOKEN` +Woodpecker secret. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..afa68a4 --- /dev/null +++ b/Makefile @@ -0,0 +1,75 @@ +# vault-tools: a Go monorepo of Vault token CLIs (vctl + vctx) sharing shared/. +# Each tool is a separate main package under its own subfolder. +BINARIES := vctl vctx +DIST := dist +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)" +OS ?= $(shell go env GOOS) +ARCH ?= $(shell go env GOARCH) + +.PHONY: all build test lint fmt clean install completions rpm rpm-package patch minor major _tag + +all: build + +# Build every binary into dist/ so the nfpm packaging step +# (scripts/build-rpm.sh) can find them. Each tool is its own main package under +# ./, so they are built individually with their own -o. +build: + @for b in $(BINARIES); do \ + echo "building $$b"; \ + CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$$b ./$$b || exit 1; \ + done + +test: + go test -v -race ./... + +lint: + golangci-lint run ./... + +fmt: + gofmt -w . + +clean: + rm -rf $(DIST) $(BINARIES) + +install: + go install $(GOFLAGS) ./... + +# Generate bash/zsh/fish completions for every binary into dist/completions. +completions: build + @mkdir -p $(DIST)/completions + @for b in $(BINARIES); do \ + $(DIST)/$$b completion bash > $(DIST)/completions/$$b.bash; \ + $(DIST)/$$b completion zsh > $(DIST)/completions/_$$b; \ + $(DIST)/$$b completion fish > $(DIST)/completions/$$b.fish; \ + done + +# Build the binaries then package them (with completions) into an RPM via nfpm. +rpm: build rpm-package + +# Package already-built binaries into an RPM (used by CI after the build step). +rpm-package: + ./scripts/build-rpm.sh $(VERSION) + +# Bump helpers — read the latest semver tag and create the next one. +# If no tag exists yet, start from v0.0.0. +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +_tag: + git push origin $(TAG) diff --git a/README.md b/README.md index 5fde8ee..84dc431 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,100 @@ # vault-tools -Monorepo of Go CLI tools for managing Vault tokens across multiple vault instances: vctl (login/renew per-context tokens) and vctx (context-aware wrapper around the vault CLI). \ No newline at end of file +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 +knows about it. + +| Tool | Purpose | +| -------------------- | ----------------------------------------------------------------------- | +| [`vctl`](docs/vctl.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. + +## Quick start + +```bash +# 1. Configure your vaults +mkdir -p ~/.config/vault +cat > ~/.config/vault/vctl.yaml <<'YAML' +defaults: + method: ldap + user: ben +contexts: + sydney: + address: https://vault.syd1.au.unkin.net + staging/sydney: + address: https://vault-staging.syd1.au.unkin.net + 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 + +# 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 + +# See what's configured and which tokens are still valid +vctl list +``` + +## Configuration + +Both tools read the first of `~/.config/vault/vctl.yaml` or +`~/.config/vault/config.yaml` that exists (`$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 +rules. + +Tokens are cached under `~/.cache/vault/` (`$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. + +## Layout + +``` +shared/ # config parsing, token cache, Vault HTTP client (shared lib + tests) +vctl/ # vctl 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) +scripts/build-rpm.sh +.woodpecker/ # CI: build, test, pre-commit (PR) + release (tag) +Makefile # build / test / completions / rpm / version-bump targets +``` + +## Building + +```bash +make build # build vctl + 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) +``` + +## Releasing + +Releases run in Woodpecker on a `v*` tag. Bump and tag with: + +```bash +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 +(bundling shell completions), PUTs the RPM to the artifactapi local `rpm-internal` +yum repo, and cuts a Gitea release with the binaries + checksums attached. + +## Installation + +Install the RPM from the internal yum repo (ships both binaries plus +bash/zsh/fish completions), or grab a prebuilt binary from the Gitea release +assets. diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..7a98ba9 --- /dev/null +++ b/doc.go @@ -0,0 +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 +// 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 diff --git a/docs/vctl.md b/docs/vctl.md new file mode 100644 index 0000000..2e70dd1 --- /dev/null +++ b/docs/vctl.md @@ -0,0 +1,140 @@ +# vctl + +`vctl` 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/` for use by +[`vctx`](vctx.md) and other tooling. + +## Synopsis + +``` +vctl login # log in to one context +vctl login --all # log in to every configured context +vctl renew # 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 +``` + +## Flags + +Both `login` and `renew` accept: + +| Flag | Default | Description | +| -------------- | -------------------------------- | -------------------------------------------------- | +| `--all` | `false` | Operate on every configured context. | +| `--method ` | context/config, else `ldap` | Override the auth method (`ldap`, `userpass`, `okta`, `radius`, `token`). | +| `--user ` | context/config, else `$USER` | Override the login username. | + +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`.) + +```yaml +# ~/.config/vault/vctl.yaml +defaults: + method: ldap # applied to any context that doesn't set its own + user: ben + namespace: "" + +contexts: + sydney: + address: https://vault.syd1.au.unkin.net + + staging/sydney: # slashes are allowed; they nest on disk + address: https://vault-staging.syd1.au.unkin.net + namespace: staging + user: svc-ben # per-context override + + legacy: + address: https://vault-legacy.example.net + method: userpass + path: userpass2 # override the auth mount path (default = method) +``` + +### Field resolution + +For each context, every field is resolved with the chain +**context value → file `defaults` → built-in default**: + +- `method` — built-in default `ldap`. +- `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`. + +`--method` / `--user` on the command line override the resolved values. When +`--method` changes the method and the context did not pin an explicit `path`, +the auth path follows the new method. + +## Auth methods + +- **Password methods** (`ldap`, `userpass`, `okta`, `radius`): `vctl` prompts + for a password (no echo) and POSTs to `auth//login/`. +- **`token`**: `vctl` prompts for a raw Vault token (no echo), verifies it with + `auth/token/lookup-self`, and caches its details. + +## Token cache + +Tokens are written to `~/.cache/vault/` (honouring `$XDG_CACHE_HOME`) +as JSON. Slash contexts nest: `staging/sydney` → +`~/.cache/vault/staging/sydney`. Parent directories are created `0700` and token +files are written `0600`. + +Each file stores enough to inspect and later revoke the token: + +```json +{ + "context": "staging/sydney", + "address": "https://vault-staging.syd1.au.unkin.net", + "namespace": "staging", + "token": "s....", + "accessor": "hmac-...", + "policies": ["default", "kv-read"], + "renewable": true, + "lease_duration_seconds": 3600, + "issued_at": "2026-07-26T12:00:00Z", + "expires_at": "2026-07-26T13:00:00Z" +} +``` + +The `accessor` lets you revoke the token later without exposing the secret +(`vault token revoke -accessor `). + +## Examples + +```bash +# Log in to one context (prompts for password) +vctl login sydney + +# Log in everywhere at once +vctl login --all + +# Log in to a context overriding the method + user +vctl login sydney --method okta --user someone.else + +# Renew a slash-named context +vctl renew staging/sydney + +# Renew everything that currently has a cached token +vctl renew --all + +# See what is configured and which tokens are still valid +vctl list +``` + +## Shell completion + +```bash +vctl completion bash > /etc/bash_completion.d/vctl +vctl completion zsh > ~/.zsh/completions/_vctl +``` + +Context names complete dynamically from the config file (the RPM installs these +completions automatically). diff --git a/docs/vctx.md b/docs/vctx.md new file mode 100644 index 0000000..f7bc723 --- /dev/null +++ b/docs/vctx.md @@ -0,0 +1,82 @@ +# 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)), +sets `VAULT_ADDR`, `VAULT_TOKEN` and `VAULT_NAMESPACE` for that single +invocation only, and execs `vault` with the remaining arguments. + +## Synopsis + +``` +vctx --context +vctx version +``` + +## How it works + +``` +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). +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) +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. + +The ambient `VAULT_ADDR` / `VAULT_TOKEN` in your shell are ignored for the call +— `vctx` always targets the chosen context. + +## The `--context` flag + +`--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` +untouched, so vault's own flags work normally: + +```bash +vctx --context sydney kv get -field=password secret/db +vctx --context staging/sydney token lookup +vctx --context sydney -help # 'vctx --context X' then vault sees -help +``` + +Context names support slashes (e.g. `staging/sydney`), matching the config and +the on-disk token cache. + +## Examples + +```bash +# Write a secret to the sydney vault +vctx --context sydney kv put kv/foo/bar secret=baz + +# Read a single field +vctx --context sydney kv get -field=secret kv/foo/bar + +# Operate against a namespaced, slash-named context +vctx --context staging/sydney kv list kv/ + +# Inspect the token vctx would use +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 `). + +## Shell completion + +```bash +vctx completion bash > /etc/bash_completion.d/vctx +vctx completion zsh > ~/.zsh/completions/_vctx +``` + +The `--context` value completes dynamically from the configured context names, +so `vctx --context ` lists your vaults (the RPM installs these completions +automatically). diff --git a/examples/vctl.yaml b/examples/vctl.yaml new file mode 100644 index 0000000..ed066a0 --- /dev/null +++ b/examples/vctl.yaml @@ -0,0 +1,25 @@ +# 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) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4e91f6d --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module git.unkin.net/unkin/vault-tools + +go 1.25 + +require ( + github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.30.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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..09ec722 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +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= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +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= +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= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..277d4b7 --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,64 @@ +--- +# nfpm config for building the vault-tools RPM (vctl + vctx). +# Rendered through envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`. + +name: ${PACKAGE_NAME} +version: ${PACKAGE_VERSION} +release: ${PACKAGE_RELEASE} +arch: ${PACKAGE_ARCH} +platform: ${PACKAGE_PLATFORM} +section: default +priority: extra +description: "${PACKAGE_DESCRIPTION}" + +maintainer: ${PACKAGE_MAINTAINER} +homepage: ${PACKAGE_HOMEPAGE} +license: ${PACKAGE_LICENSE} + +disable_globbing: false + +replaces: + - vault-tools +provides: + - vault-tools + +contents: + # The CLI binaries. + - src: dist/vctl + dst: /usr/bin/vctl + file_info: + mode: 0755 + owner: root + group: root + - src: dist/vctx + dst: /usr/bin/vctx + file_info: + mode: 0755 + owner: root + group: root + + # Shell completions (generated by scripts/build-rpm.sh before packaging). + - src: dist/completions/vctl.bash + dst: /usr/share/bash-completion/completions/vctl + file_info: + mode: 0644 + - src: dist/completions/_vctl + dst: /usr/share/zsh/site-functions/_vctl + file_info: + mode: 0644 + - src: dist/completions/vctl.fish + dst: /usr/share/fish/vendor_completions.d/vctl.fish + file_info: + mode: 0644 + - src: dist/completions/vctx.bash + dst: /usr/share/bash-completion/completions/vctx + file_info: + mode: 0644 + - src: dist/completions/_vctx + dst: /usr/share/zsh/site-functions/_vctx + file_info: + mode: 0644 + - src: dist/completions/vctx.fish + dst: /usr/share/fish/vendor_completions.d/vctx.fish + file_info: + mode: 0644 diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh new file mode 100755 index 0000000..da6ca97 --- /dev/null +++ b/scripts/build-rpm.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# Package the (already built) vctl 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) +# +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +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) +DIST="dist" + +for b in "${BINARIES[@]}"; do + if [ ! -f "${DIST}/${b}" ]; then + echo "ERROR: ${DIST}/${b} not found; run 'make build' first" >&2 + exit 1 + fi +done + +# Generate shell completions from the freshly built binaries so they always +# match the shipped flags/subcommands. +COMP_DIR="${DIST}/completions" +mkdir -p "${COMP_DIR}" +for b in "${BINARIES[@]}"; do + "./${DIST}/${b}" completion bash >"${COMP_DIR}/${b}.bash" + "./${DIST}/${b}" completion zsh >"${COMP_DIR}/_${b}" + "./${DIST}/${b}" completion fish >"${COMP_DIR}/${b}.fish" +done + +export PACKAGE_NAME="${PACKAGE}" +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_MAINTAINER="Ben Vincent " +export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-tools" +export PACKAGE_LICENSE="MIT" + +envsubst "${DIST}/nfpm.yaml" +nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm + +echo "Built:" +ls -1 "${DIST}"/*.rpm diff --git a/shared/cache.go b/shared/cache.go new file mode 100644 index 0000000..397e3ae --- /dev/null +++ b/shared/cache.go @@ -0,0 +1,146 @@ +package shared + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// 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. +type Token struct { + // Context is the name of the context this token belongs to. + Context string `json:"context"` + // Address is the Vault address the token is valid against. + Address string `json:"address"` + // Namespace is the Vault namespace the token was issued in (may be empty). + Namespace string `json:"namespace,omitempty"` + + // Token is the client token secret. + Token string `json:"token"` + // Accessor identifies the token without exposing it — enough to revoke it + // via /auth/token/revoke-accessor. + Accessor string `json:"accessor,omitempty"` + // Policies are the policies attached to the token. + Policies []string `json:"policies,omitempty"` + + // Renewable reports whether the token can be renewed. + Renewable bool `json:"renewable"` + // LeaseDurationSeconds is the TTL granted at issue/renew time. + LeaseDurationSeconds int `json:"lease_duration_seconds,omitempty"` + + // IssuedAt is when this token was obtained (login) or last renewed. + IssuedAt time.Time `json:"issued_at"` + // ExpiresAt is IssuedAt + LeaseDuration; zero for non-expiring tokens. + ExpiresAt time.Time `json:"expires_at,omitempty"` +} + +// CacheDir returns the XDG_CACHE_HOME/vault directory that holds token files. +func CacheDir() string { + base := os.Getenv("XDG_CACHE_HOME") + if base == "" { + home, _ := os.UserHomeDir() + base = filepath.Join(home, ".cache") + } + return filepath.Join(base, appDir) +} + +// TokenPath returns the on-disk path for a context's cached token. A context +// name with slashes (e.g. "staging/sydney") maps to a nested path under +// CacheDir. The name is validated to prevent escaping the cache directory. +func TokenPath(context string) (string, error) { + if err := validateContextName(context); err != nil { + return "", err + } + // Use forward slashes as path separators regardless of platform, matching + // how contexts are written in the config file. + rel := filepath.FromSlash(context) + return filepath.Join(CacheDir(), rel), nil +} + +// validateContextName rejects names that could escape the cache directory or +// are otherwise unusable as a relative path. +func validateContextName(context string) error { + if context == "" { + return fmt.Errorf("empty context name") + } + if strings.HasPrefix(context, "/") || filepath.IsAbs(context) { + return fmt.Errorf("context name %q must not be absolute", context) + } + for _, seg := range strings.Split(context, "/") { + if seg == "" { + return fmt.Errorf("context name %q has an empty path segment", context) + } + if seg == "." || seg == ".." { + return fmt.Errorf("context name %q must not contain %q segments", context, seg) + } + } + 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. +func SaveToken(t *Token) error { + path, err := TokenPath(t.Context) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("creating cache dir: %w", err) + } + data, err := json.MarshalIndent(t, "", " ") + if err != nil { + return fmt.Errorf("encoding token: %w", err) + } + data = append(data, '\n') + + // 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 { + return fmt.Errorf("writing token: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + 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. +func LoadToken(context string) (*Token, error) { + path, err := TokenPath(context) + if err != nil { + return nil, err + } + 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("reading token %s: %w", path, err) + } + var t Token + if err := json.Unmarshal(data, &t); err != nil { + return nil, fmt.Errorf("parsing token %s: %w", path, err) + } + return &t, nil +} + +// DeleteToken removes a context's cached token file, if present. +func DeleteToken(context string) error { + path, err := TokenPath(context) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing token %s: %w", path, err) + } + return nil +} diff --git a/shared/cache_test.go b/shared/cache_test.go new file mode 100644 index 0000000..22f39c6 --- /dev/null +++ b/shared/cache_test.go @@ -0,0 +1,120 @@ +package shared + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestTokenPathSimpleAndSlash(t *testing.T) { + base := t.TempDir() + t.Setenv("XDG_CACHE_HOME", base) + + cases := map[string]string{ + "sydney": filepath.Join(base, appDir, "sydney"), + "staging/sydney": filepath.Join(base, appDir, "staging", "sydney"), + "a/b/c": filepath.Join(base, appDir, "a", "b", "c"), + } + for ctx, want := range cases { + got, err := TokenPath(ctx) + if err != nil { + t.Fatalf("TokenPath(%q): %v", ctx, err) + } + if got != want { + t.Errorf("TokenPath(%q) = %q, want %q", ctx, got, want) + } + } +} + +func TestTokenPathRejectsTraversal(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + bad := []string{"", "/etc/passwd", "../escape", "a/../../b", "a//b", "foo/", "./x"} + for _, ctx := range bad { + if _, err := TokenPath(ctx); err == nil { + t.Errorf("TokenPath(%q) expected error, got nil", ctx) + } + } +} + +func TestSaveLoadRoundTripSlashContext(t *testing.T) { + base := t.TempDir() + t.Setenv("XDG_CACHE_HOME", base) + + tok := &Token{ + Context: "staging/sydney", + Address: "https://vault-staging.syd1.au.unkin.net", + Namespace: "staging", + Token: "s.abcdef123456", + Accessor: "acc-123", + Policies: []string{"default", "kv-read"}, + Renewable: true, + LeaseDurationSeconds: 3600, + IssuedAt: time.Now().UTC().Truncate(time.Second), + ExpiresAt: time.Now().UTC().Add(time.Hour).Truncate(time.Second), + } + if err := SaveToken(tok); err != nil { + t.Fatalf("SaveToken: %v", err) + } + + // Parent dir for a slash context must be created 0700, file 0600. + path, _ := TokenPath("staging/sydney") + if runtime.GOOS != "windows" { + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if perm := fi.Mode().Perm(); perm != 0o600 { + t.Errorf("token file perm = %o, want 600", perm) + } + di, err := os.Stat(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } + if perm := di.Mode().Perm(); perm != 0o700 { + t.Errorf("token dir perm = %o, want 700", perm) + } + } + + got, err := LoadToken("staging/sydney") + if err != nil { + t.Fatalf("LoadToken: %v", err) + } + if got.Token != tok.Token || got.Accessor != tok.Accessor || got.Namespace != tok.Namespace { + t.Errorf("round-trip mismatch: %+v vs %+v", got, tok) + } + if len(got.Policies) != 2 || got.Policies[1] != "kv-read" { + t.Errorf("policies mismatch: %v", got.Policies) + } + if !got.Renewable { + t.Error("renewable lost in round-trip") + } +} + +func TestLoadTokenMissingIsNotExist(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + _, err := LoadToken("never-logged-in") + if err == nil { + t.Fatal("expected error for missing token") + } + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("error should wrap os.ErrNotExist, got %v", err) + } +} + +func TestDeleteToken(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + tok := &Token{Context: "sydney", Address: "https://v", Token: "s.x"} + if err := SaveToken(tok); err != nil { + t.Fatal(err) + } + if err := DeleteToken("sydney"); err != nil { + t.Fatalf("DeleteToken: %v", err) + } + // Deleting again is a no-op (not-exist tolerated). + if err := DeleteToken("sydney"); err != nil { + t.Errorf("second DeleteToken: %v", err) + } +} diff --git a/shared/config.go b/shared/config.go new file mode 100644 index 0000000..bc8e86d --- /dev/null +++ b/shared/config.go @@ -0,0 +1,204 @@ +// Package shared holds the plumbing common to the vault-tools CLIs (vctl 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. +package shared + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "gopkg.in/yaml.v3" +) + +const ( + // DefaultMethod is the auth method used when neither the context nor the + // file-level defaults specify one. + DefaultMethod = "ldap" + + // appDir is the per-user config/cache subdirectory both tools live under. + appDir = "vault" +) + +// configFileNames are the accepted config file basenames, tried in order. The +// first one that exists wins. +var configFileNames = []string{"vctl.yaml", "config.yaml"} + +// Context is a single vault instance the tools can target. Every field except +// Address is optional and, when empty, falls back to the file-level Defaults +// and finally the built-in defaults. +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 string `yaml:"method,omitempty"` + // User is the login username (LDAP/userpass/...); ignored for token auth. + User string `yaml:"user,omitempty"` + // Namespace is the Vault namespace (X-Vault-Namespace) for the context. + Namespace string `yaml:"namespace,omitempty"` + // Path overrides the auth mount path (defaults to Method), e.g. "ldap2". + Path string `yaml:"path,omitempty"` +} + +// Defaults holds file-level fallbacks applied to every context that does not +// set its own value. +type Defaults struct { + Method string `yaml:"method,omitempty"` + User string `yaml:"user,omitempty"` + Namespace string `yaml:"namespace,omitempty"` +} + +// Config is the parsed config file: file-level defaults plus a map of named +// contexts. Context names may contain slashes (e.g. "staging/sydney"), which +// map to nested cache paths. +type Config struct { + Defaults Defaults `yaml:"defaults"` + Contexts map[string]Context `yaml:"contexts"` + + // path records the file this config was loaded from (empty if none found). + path string +} + +// 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 +} + +// ConfigDir returns the XDG_CONFIG_HOME/vault directory. +func ConfigDir() string { + base := os.Getenv("XDG_CONFIG_HOME") + if base == "" { + home, _ := os.UserHomeDir() + base = filepath.Join(home, ".config") + } + 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. +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]) +} + +// 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. +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) + } + if err := yaml.Unmarshal(data, cfg); err != nil { + return cfg, fmt.Errorf("parsing config %s: %w", p, err) + } + if cfg.Contexts == nil { + cfg.Contexts = map[string]Context{} + } + cfg.path = p + return cfg, nil + } + + return cfg, nil +} + +// Path returns the file this config was loaded from, or "" if none was found. +func (c *Config) Path() string { return c.path } + +// ContextNames returns the configured context names, sorted. Used to drive +// shell completion for the --context flag / context arguments. +func (c *Config) ContextNames() []string { + names := make([]string, 0, len(c.Contexts)) + for n := range c.Contexts { + names = append(names, n) + } + sort.Strings(names) + return names +} + +// Resolve looks up a context by name and applies the fallback chain for each +// field: context value < file defaults < built-in default. Method and User are +// guaranteed non-empty in the result (User defaults to $USER). +func (c *Config) Resolve(name string) (ResolvedContext, error) { + ctx, ok := c.Contexts[name] + if !ok { + return ResolvedContext{}, fmt.Errorf("no context %q in %s", name, displayPath(c.path)) + } + if ctx.Address == "" { + return ResolvedContext{}, fmt.Errorf("context %q has no address", name) + } + + method := firstNonEmpty(ctx.Method, c.Defaults.Method, DefaultMethod) + user := firstNonEmpty(ctx.User, c.Defaults.User, os.Getenv("USER")) + namespace := firstNonEmpty(ctx.Namespace, c.Defaults.Namespace) + path := firstNonEmpty(ctx.Path, method) + + return ResolvedContext{ + Name: name, + Address: ctx.Address, + Method: method, + User: user, + Namespace: namespace, + Path: path, + }, nil +} + +// ResolveWithOverrides is Resolve plus explicit CLI-flag overrides for method +// and user; an empty override leaves the resolved value untouched. When the +// method is overridden and the context did not pin an explicit auth path, the +// auth path follows the new method. +func (c *Config) ResolveWithOverrides(name, method, user string) (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 user != "" { + rc.User = user + } + return rc, nil +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +func displayPath(p string) string { + if p == "" { + return ConfigPath() + " (not found)" + } + return p +} diff --git a/shared/config_test.go b/shared/config_test.go new file mode 100644 index 0000000..fc6615e --- /dev/null +++ b/shared/config_test.go @@ -0,0 +1,188 @@ +package shared + +import ( + "os" + "path/filepath" + "testing" +) + +// withConfigDir points XDG_CONFIG_HOME at a temp dir and writes the given +// config file into /vault/, returning the temp base. +func withConfigDir(t *testing.T, name, content string) string { + t.Helper() + base := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", base) + dir := filepath.Join(base, appDir) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if content != "" { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + return base +} + +const sampleConfig = ` +defaults: + method: ldap + user: ben +contexts: + sydney: + address: https://vault.syd1.au.unkin.net + staging/sydney: + address: https://vault-staging.syd1.au.unkin.net + namespace: staging + user: svc-ben + legacy: + address: https://vault-legacy.example.net + method: userpass + path: userpass2 +` + +func TestLoadAndContextNames(t *testing.T) { + withConfigDir(t, "vctl.yaml", sampleConfig) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + got := cfg.ContextNames() + want := []string{"legacy", "staging/sydney", "sydney"} + if len(got) != len(want) { + t.Fatalf("ContextNames = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("ContextNames[%d] = %q, want %q (%v)", i, got[i], want[i], got) + } + } + if cfg.Path() == "" { + t.Error("Path() is empty after loading a config") + } +} + +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) { + 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) + } +} + +func TestLoadMissingConfigIsNotError(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + cfg, err := Load() + if err != nil { + t.Fatalf("Load with no file: %v", err) + } + if len(cfg.ContextNames()) != 0 { + t.Errorf("expected no contexts, got %v", cfg.ContextNames()) + } +} + +func TestResolveAppliesDefaults(t *testing.T) { + withConfigDir(t, "vctl.yaml", sampleConfig) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + + // sydney: inherits method+user from defaults, no namespace, path == method. + rc, err := cfg.Resolve("sydney") + if err != nil { + t.Fatal(err) + } + if rc.Method != "ldap" || rc.User != "ben" || rc.Namespace != "" || rc.Path != "ldap" { + t.Errorf("sydney resolved = %+v", rc) + } + + // staging/sydney: overrides user + namespace, inherits method. + rc, err = cfg.Resolve("staging/sydney") + if err != nil { + t.Fatal(err) + } + if rc.Method != "ldap" || rc.User != "svc-ben" || rc.Namespace != "staging" { + t.Errorf("staging/sydney resolved = %+v", rc) + } + + // legacy: explicit method + custom auth path. + rc, err = cfg.Resolve("legacy") + if err != nil { + t.Fatal(err) + } + if rc.Method != "userpass" || rc.Path != "userpass2" { + t.Errorf("legacy resolved = %+v", rc) + } +} + +func TestResolveUnknownContext(t *testing.T) { + withConfigDir(t, "vctl.yaml", sampleConfig) + cfg, _ := Load() + if _, err := cfg.Resolve("nope"); err == nil { + t.Error("expected error for unknown context") + } +} + +func TestResolveMethodDefaultWhenUnset(t *testing.T) { + withConfigDir(t, "vctl.yaml", ` +contexts: + bare: + address: https://vault.example.net +`) + t.Setenv("USER", "alice") + cfg, _ := Load() + rc, err := cfg.Resolve("bare") + if err != nil { + t.Fatal(err) + } + if rc.Method != DefaultMethod { + t.Errorf("method = %q, want %q", rc.Method, DefaultMethod) + } + if rc.User != "alice" { + t.Errorf("user = %q, want alice ($USER fallback)", rc.User) + } +} + +func TestResolveWithOverrides(t *testing.T) { + withConfigDir(t, "vctl.yaml", sampleConfig) + cfg, _ := Load() + + // Overriding the method also moves the auth path (context did not pin one). + rc, err := cfg.ResolveWithOverrides("sydney", "okta", "otheruser") + if err != nil { + t.Fatal(err) + } + if rc.Method != "okta" || rc.Path != "okta" || rc.User != "otheruser" { + t.Errorf("override resolved = %+v", rc) + } + + // legacy pins path=userpass2, so a method override must NOT change the path. + rc, err = cfg.ResolveWithOverrides("legacy", "okta", "") + if err != nil { + t.Fatal(err) + } + if rc.Method != "okta" || rc.Path != "userpass2" { + t.Errorf("pinned-path override resolved = %+v", rc) + } + + // Empty overrides leave resolved values untouched. + rc, err = cfg.ResolveWithOverrides("sydney", "", "") + if err != nil { + t.Fatal(err) + } + if rc.Method != "ldap" || rc.User != "ben" { + t.Errorf("no-op override resolved = %+v", rc) + } +} diff --git a/shared/vault.go b/shared/vault.go new file mode 100644 index 0000000..bb55264 --- /dev/null +++ b/shared/vault.go @@ -0,0 +1,220 @@ +package shared + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// httpClient is the shared client for Vault API calls with a sane timeout. +var httpClient = &http.Client{Timeout: 30 * time.Second} + +// passwordMethods are auth methods whose login takes a username in the path and +// a password in the body (POST auth//login/ {"password": ...}). +var passwordMethods = map[string]bool{ + "ldap": true, + "userpass": true, + "okta": true, + "radius": true, +} + +// NeedsPassword reports whether an auth method prompts for a password. +func NeedsPassword(method string) bool { return passwordMethods[method] } + +// IsTokenMethod reports whether the method authenticates with a raw token the +// user pastes in, rather than a username/password login. +func IsTokenMethod(method string) bool { return method == "token" } + +// authResponse models the /auth block returned by a Vault login/renew call. +type authResponse struct { + Auth struct { + ClientToken string `json:"client_token"` + Accessor string `json:"accessor"` + Policies []string `json:"policies"` + TokenPolicies []string `json:"token_policies"` + LeaseDuration int `json:"lease_duration"` + Renewable bool `json:"renewable"` + } `json:"auth"` +} + +// lookupResponse models the /auth/token/lookup-self data block, used when the +// method is a raw token (no /auth block is returned by a login call). +type lookupResponse struct { + Data struct { + Accessor string `json:"accessor"` + Policies []string `json:"policies"` + TTL int `json:"ttl"` + Renewable bool `json:"renewable"` + DisplayName string `json:"display_name"` + } `json:"data"` +} + +// vaultError decodes Vault's {"errors": [...]} response body into a message. +func vaultError(status int, body []byte) error { + var e struct { + Errors []string `json:"errors"` + } + if json.Unmarshal(body, &e) == nil && len(e.Errors) > 0 { + return fmt.Errorf("vault returned HTTP %d: %s", status, strings.Join(e.Errors, "; ")) + } + msg := strings.TrimSpace(string(body)) + if msg == "" { + return fmt.Errorf("vault returned HTTP %d", status) + } + return fmt.Errorf("vault returned HTTP %d: %s", status, msg) +} + +// doRequest performs a Vault API request and returns the response body on 2xx. +func doRequest(method, address, path, namespace, token string, payload any) ([]byte, error) { + var body io.Reader + if payload != nil { + b, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("encoding request: %w", err) + } + body = bytes.NewReader(b) + } + + url := strings.TrimRight(address, "/") + "/v1/" + strings.TrimLeft(path, "/") + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + if token != "" { + req.Header.Set("X-Vault-Token", token) + } + if namespace != "" { + req.Header.Set("X-Vault-Namespace", namespace) + } + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request to %s failed: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() + + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, vaultError(resp.StatusCode, data) + } + return data, nil +} + +// tokenFromAuth builds a cache Token from a resolved context and an /auth block. +func tokenFromAuth(rc ResolvedContext, ar authResponse) *Token { + policies := ar.Auth.TokenPolicies + if len(policies) == 0 { + policies = ar.Auth.Policies + } + now := time.Now().UTC() + t := &Token{ + Context: rc.Name, + Address: rc.Address, + Namespace: rc.Namespace, + Token: ar.Auth.ClientToken, + Accessor: ar.Auth.Accessor, + Policies: policies, + Renewable: ar.Auth.Renewable, + LeaseDurationSeconds: ar.Auth.LeaseDuration, + IssuedAt: now, + } + if ar.Auth.LeaseDuration > 0 { + t.ExpiresAt = now.Add(time.Duration(ar.Auth.LeaseDuration) * time.Second) + } + return t +} + +// 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). +func Login(rc ResolvedContext, secret string) (*Token, error) { + if IsTokenMethod(rc.Method) { + return loginWithToken(rc, secret) + } + if !NeedsPassword(rc.Method) { + return nil, fmt.Errorf("unsupported auth method %q", rc.Method) + } + if rc.User == "" { + return nil, fmt.Errorf("context %q: no user for %s login", rc.Name, rc.Method) + } + + path := fmt.Sprintf("auth/%s/login/%s", rc.Path, rc.User) + data, err := doRequest(http.MethodPost, rc.Address, path, rc.Namespace, "", map[string]string{"password": secret}) + if err != nil { + return nil, err + } + var ar authResponse + if err := json.Unmarshal(data, &ar); err != nil { + return nil, fmt.Errorf("decoding login response: %w", err) + } + if ar.Auth.ClientToken == "" { + return nil, fmt.Errorf("login for context %q returned no token", rc.Name) + } + return tokenFromAuth(rc, ar), nil +} + +// loginWithToken adopts a raw client token, verifying it and filling in details +// via /auth/token/lookup-self. +func loginWithToken(rc ResolvedContext, token string) (*Token, error) { + if token == "" { + return nil, fmt.Errorf("context %q: empty token", rc.Name) + } + data, err := doRequest(http.MethodGet, rc.Address, "auth/token/lookup-self", rc.Namespace, token, nil) + if err != nil { + return nil, err + } + var lr lookupResponse + if err := json.Unmarshal(data, &lr); err != nil { + return nil, fmt.Errorf("decoding token lookup: %w", err) + } + now := time.Now().UTC() + t := &Token{ + Context: rc.Name, + Address: rc.Address, + Namespace: rc.Namespace, + Token: token, + Accessor: lr.Data.Accessor, + Policies: lr.Data.Policies, + Renewable: lr.Data.Renewable, + LeaseDurationSeconds: lr.Data.TTL, + IssuedAt: now, + } + if lr.Data.TTL > 0 { + t.ExpiresAt = now.Add(time.Duration(lr.Data.TTL) * time.Second) + } + return t, nil +} + +// Renew renews the given cached token against its context and returns the +// updated Token (new lease/expiry), preserving the accessor from the prior +// token when the renew response omits it. +func Renew(rc ResolvedContext, prev *Token) (*Token, error) { + if prev == nil || prev.Token == "" { + return nil, fmt.Errorf("context %q: no token to renew", rc.Name) + } + data, err := doRequest(http.MethodPost, rc.Address, "auth/token/renew-self", rc.Namespace, prev.Token, map[string]string{}) + if err != nil { + return nil, err + } + var ar authResponse + if err := json.Unmarshal(data, &ar); err != nil { + return nil, fmt.Errorf("decoding renew response: %w", err) + } + t := tokenFromAuth(rc, ar) + // renew-self echoes the same client token; guard against an empty echo and + // carry over the accessor if the response omitted it. + if t.Token == "" { + t.Token = prev.Token + } + if t.Accessor == "" { + t.Accessor = prev.Accessor + } + return t, nil +} diff --git a/shared/vault_test.go b/shared/vault_test.go new file mode 100644 index 0000000..bb99954 --- /dev/null +++ b/shared/vault_test.go @@ -0,0 +1,147 @@ +package shared + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNeedsPasswordAndTokenMethod(t *testing.T) { + for _, m := range []string{"ldap", "userpass", "okta", "radius"} { + if !NeedsPassword(m) { + t.Errorf("NeedsPassword(%q) = false, want true", m) + } + } + if NeedsPassword("token") { + t.Error("NeedsPassword(token) should be false") + } + if !IsTokenMethod("token") { + t.Error("IsTokenMethod(token) should be true") + } + if IsTokenMethod("ldap") { + t.Error("IsTokenMethod(ldap) should be false") + } +} + +func TestLoginPasswordMethod(t *testing.T) { + var gotPath, gotNS string + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotNS = r.Header.Get("X-Vault-Namespace") + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotBody) + _, _ = io.WriteString(w, `{"auth":{"client_token":"s.tok","accessor":"acc","token_policies":["default","kv"],"lease_duration":3600,"renewable":true}}`) + })) + defer srv.Close() + + rc := ResolvedContext{Name: "sydney", Address: srv.URL, Method: "ldap", Path: "ldap", User: "ben", Namespace: "team-a"} + tok, err := Login(rc, "hunter2") + if err != nil { + t.Fatalf("Login: %v", err) + } + if gotPath != "/v1/auth/ldap/login/ben" { + t.Errorf("login path = %q", gotPath) + } + if gotNS != "team-a" { + t.Errorf("namespace header = %q", gotNS) + } + if gotBody["password"] != "hunter2" { + t.Errorf("password body = %v", gotBody) + } + if tok.Token != "s.tok" || tok.Accessor != "acc" || !tok.Renewable { + t.Errorf("token = %+v", tok) + } + if len(tok.Policies) != 2 || tok.Policies[0] != "default" { + t.Errorf("policies = %v", tok.Policies) + } + if tok.ExpiresAt.IsZero() { + t.Error("ExpiresAt should be set from lease_duration") + } +} + +func TestLoginCustomAuthPath(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = io.WriteString(w, `{"auth":{"client_token":"s.x","lease_duration":60}}`) + })) + defer srv.Close() + + rc := ResolvedContext{Name: "legacy", Address: srv.URL, Method: "userpass", Path: "userpass2", User: "svc"} + if _, err := Login(rc, "pw"); err != nil { + t.Fatal(err) + } + if gotPath != "/v1/auth/userpass2/login/svc" { + t.Errorf("login path = %q, want custom auth path", gotPath) + } +} + +func TestLoginTokenMethodUsesLookupSelf(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/auth/token/lookup-self" { + t.Errorf("unexpected path %q", r.URL.Path) + } + if r.Header.Get("X-Vault-Token") != "s.raw" { + t.Errorf("token header = %q", r.Header.Get("X-Vault-Token")) + } + _, _ = io.WriteString(w, `{"data":{"accessor":"acc2","policies":["root"],"ttl":0,"renewable":false}}`) + })) + defer srv.Close() + + rc := ResolvedContext{Name: "root-ctx", Address: srv.URL, Method: "token"} + tok, err := Login(rc, "s.raw") + if err != nil { + t.Fatalf("Login(token): %v", err) + } + if tok.Token != "s.raw" || tok.Accessor != "acc2" { + t.Errorf("token = %+v", tok) + } + if !tok.ExpiresAt.IsZero() { + t.Error("ttl=0 should leave ExpiresAt zero") + } +} + +func TestRenewPreservesAccessor(t *testing.T) { + 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) + } + // renew response omits accessor; Renew should carry it from prev. + _, _ = io.WriteString(w, `{"auth":{"client_token":"s.tok","lease_duration":7200,"renewable":true}}`) + })) + defer srv.Close() + + rc := ResolvedContext{Name: "sydney", Address: srv.URL} + prev := &Token{Context: "sydney", Address: srv.URL, Token: "s.tok", Accessor: "acc-prev"} + tok, err := Renew(rc, prev) + if err != nil { + t.Fatalf("Renew: %v", err) + } + if tok.Accessor != "acc-prev" { + t.Errorf("accessor = %q, want carried-over acc-prev", tok.Accessor) + } + if tok.LeaseDurationSeconds != 7200 { + t.Errorf("lease = %d, want 7200", tok.LeaseDurationSeconds) + } +} + +func TestLoginSurfacesVaultError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"errors":["ldap operation failed"]}`) + })) + defer srv.Close() + + rc := ResolvedContext{Name: "sydney", Address: srv.URL, Method: "ldap", Path: "ldap", User: "ben"} + _, err := Login(rc, "bad") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "ldap operation failed") { + t.Errorf("error should surface vault message, got %v", err) + } +} diff --git a/vctl/main.go b/vctl/main.go new file mode 100644 index 0000000..5fd1ae3 --- /dev/null +++ b/vctl/main.go @@ -0,0 +1,269 @@ +// Command vctl 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/ for use by vctx +// and other tooling. +// +// vctl login sydney +// vctl login --all +// vctl renew staging/sydney +// vctl renew --all +package main + +import ( + "fmt" + "io" + "os" + "strings" + "time" + + "git.unkin.net/unkin/vault-tools/shared" + + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var version = "dev" + +func main() { + if err := newRootCmd().Execute(); err != nil { + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + var ( + method string + user string + all bool + ) + + root := &cobra.Command{ + Use: "vctl", + 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/ for use by vctx and other tooling.", + SilenceUsage: true, + } + + // contextCompletion completes context names from the config file. + contextCompletion := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + cfg, err := shared.Load() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cfg.ContextNames(), cobra.ShellCompDirectiveNoFileComp + } + + loginCmd := &cobra.Command{ + Use: "login [context]", + Short: "Log in to a context (or --all) and cache the token", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: contextCompletion, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runOverContexts(args, all, method, user, "login", doLogin) + }, + } + loginCmd.Flags().BoolVar(&all, "all", false, "Log in to every configured context") + + renewCmd := &cobra.Command{ + Use: "renew [context]", + Short: "Renew a context's cached token (or --all)", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: contextCompletion, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runOverContexts(args, all, method, user, "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.RegisterFlagCompletionFunc("method", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"ldap", "userpass", "okta", "radius", "token"}, cobra.ShellCompDirectiveNoFileComp + }) + } + + listCmd := &cobra.Command{ + Use: "list", + Short: "List configured contexts and their cached-token status", + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { return runList(os.Stdout) }, + } + + root.AddCommand(loginCmd, renewCmd, listCmd, versionCmd()) + return root +} + +func versionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the version", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) }, + SilenceUsage: true, + } +} + +// contextAction performs login or renew for a single resolved context. +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 { + cfg, err := shared.Load() + if err != nil { + return err + } + + var names []string + switch { + case all && len(args) > 0: + return fmt.Errorf("give a context or --all, not both") + case all: + names = cfg.ContextNames() + if len(names) == 0 { + return fmt.Errorf("no contexts configured in %s", cfg.Path()) + } + case len(args) == 1: + names = []string{args[0]} + default: + return fmt.Errorf("give a context name or --all") + } + + var failed int + for _, name := range names { + rc, err := cfg.ResolveWithOverrides(name, method, user) + if err != nil { + fmt.Fprintf(os.Stderr, "%s %s: %v\n", verb, name, err) + failed++ + continue + } + tok, err := fn(rc) + if err != nil { + fmt.Fprintf(os.Stderr, "%s %s: %v\n", verb, name, err) + failed++ + continue + } + if err := shared.SaveToken(tok); err != nil { + fmt.Fprintf(os.Stderr, "%s %s: %v\n", verb, name, err) + failed++ + continue + } + fmt.Printf("%s: %s ok (%s)\n", name, verb, tokenSummary(tok)) + } + + if failed > 0 { + return fmt.Errorf("%d of %d context(s) failed", failed, len(names)) + } + return nil +} + +func doLogin(rc shared.ResolvedContext) (*shared.Token, error) { + secret := "" + switch { + case shared.IsTokenMethod(rc.Method): + s, err := promptSecret(fmt.Sprintf("Vault token for %s: ", rc.Name)) + if err != nil { + return nil, err + } + secret = s + case shared.NeedsPassword(rc.Method): + s, err := promptSecret(fmt.Sprintf("Password for %s@%s (%s): ", rc.User, rc.Name, rc.Method)) + if err != nil { + return nil, err + } + secret = s + } + return shared.Login(rc, secret) +} + +func doRenew(rc shared.ResolvedContext) (*shared.Token, error) { + prev, err := shared.LoadToken(rc.Name) + if err != nil { + return nil, err + } + return shared.Renew(rc, prev) +} + +// promptSecret reads a secret from the terminal without echoing it. It is a +// package variable so tests can substitute a fake prompt. +var promptSecret = func(prompt string) (string, error) { + fmt.Fprint(os.Stderr, prompt) + fd := int(os.Stdin.Fd()) + if !term.IsTerminal(fd) { + return "", fmt.Errorf("cannot prompt for secret: stdin is not a terminal") + } + b, err := term.ReadPassword(fd) + fmt.Fprintln(os.Stderr) + if err != nil { + return "", fmt.Errorf("reading secret: %w", err) + } + return strings.TrimRight(string(b), "\r\n"), nil +} + +func tokenSummary(t *shared.Token) string { + parts := []string{"accessor=" + short(t.Accessor)} + if t.LeaseDurationSeconds > 0 { + parts = append(parts, "ttl="+(time.Duration(t.LeaseDurationSeconds)*time.Second).String()) + } + if len(t.Policies) > 0 { + parts = append(parts, "policies="+strings.Join(t.Policies, ",")) + } + parts = append(parts, fmt.Sprintf("renewable=%t", t.Renewable)) + return strings.Join(parts, " ") +} + +func short(s string) string { + if len(s) > 8 { + return s[:8] + "..." + } + return s +} + +// runList prints each configured context, its address, and whether a valid +// cached token exists (with remaining TTL). Output goes to w so it can be +// captured in tests. +func runList(w io.Writer) error { + cfg, err := shared.Load() + if err != nil { + return err + } + names := cfg.ContextNames() + if len(names) == 0 { + fmt.Fprintf(w, "no contexts configured in %s\n", cfg.Path()) + return nil + } + for _, name := range names { + rc, err := cfg.Resolve(name) + if err != nil { + fmt.Fprintf(w, "%-24s %v\n", name, err) + continue + } + status := "no token" + if tok, err := shared.LoadToken(name); err == nil { + if !tok.ExpiresAt.IsZero() { + remaining := time.Until(tok.ExpiresAt) + if remaining > 0 { + status = "token valid, expires in " + remaining.Round(time.Second).String() + } else { + status = "token EXPIRED" + } + } else { + status = "token cached" + } + } + fmt.Fprintf(w, "%-24s %-40s %s\n", name, rc.Address, status) + } + return nil +} diff --git a/vctl/main_test.go b/vctl/main_test.go new file mode 100644 index 0000000..1919255 --- /dev/null +++ b/vctl/main_test.go @@ -0,0 +1,336 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "git.unkin.net/unkin/vault-tools/shared" +) + +// setupVctl 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) { + t.Helper() + cfgHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", cfgHome) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + cfgDir := filepath.Join(cfgHome, "vault") + 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 { + t.Fatal(err) + } +} + +const vctlConfig = ` +defaults: + method: ldap + user: ben +contexts: + sydney: + address: https://vault.syd1.au.unkin.net + staging/sydney: + address: https://vault-staging.syd1.au.unkin.net + namespace: staging +` + +// --- runOverContexts: selection + iteration + aggregation ------------------ + +func TestRunOverContextsSingle(t *testing.T) { + setupVctl(t, vctlConfig) + 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 { + t.Fatalf("runOverContexts: %v", err) + } + if len(seen) != 1 || seen[0] != "sydney" { + t.Errorf("fn called for %v, want [sydney]", seen) + } + // Token must have been persisted to the cache. + if _, err := shared.LoadToken("sydney"); err != nil { + t.Errorf("token not saved: %v", err) + } +} + +func TestRunOverContextsAllIteratesEveryContext(t *testing.T) { + setupVctl(t, vctlConfig) + 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 { + t.Fatalf("runOverContexts --all: %v", err) + } + sort.Strings(seen) + want := []string{"staging/sydney", "sydney"} + if strings.Join(seen, ",") != strings.Join(want, ",") { + t.Errorf("fn called for %v, want %v", seen, want) + } + for _, n := range want { + if _, err := shared.LoadToken(n); err != nil { + t.Errorf("token for %q not saved: %v", n, err) + } + } +} + +func TestRunOverContextsContextAndAllConflict(t *testing.T) { + setupVctl(t, vctlConfig) + fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil } + err := runOverContexts([]string{"sydney"}, true, "", "", "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) + fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil } + if err := runOverContexts(nil, false, "", "", "login", fn); err == nil { + t.Error("expected error when neither context nor --all given") + } +} + +func TestRunOverContextsAllEmptyConfig(t *testing.T) { + setupVctl(t, "contexts: {}\n") + fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil } + err := runOverContexts(nil, true, "", "", "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) + 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) + if err == nil || !strings.Contains(err.Error(), "1 of 2") { + t.Errorf("expected '1 of 2' aggregate error, got %v", err) + } + // The context that succeeded must still have been saved. + if _, err := shared.LoadToken("staging/sydney"); err != nil { + t.Errorf("successful context not saved despite sibling failure: %v", err) + } + // The failed one must not have a token. + if _, err := shared.LoadToken("sydney"); err == nil { + t.Error("failed context should not have a saved token") + } +} + +func TestRunOverContextsResolveErrorCounts(t *testing.T) { + // A context missing an address fails resolution and is counted as a failure. + setupVctl(t, ` +contexts: + good: + address: https://vault.example.net + bad: {} +`) + 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) + 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) + 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 { + t.Fatal(err) + } + if got.Method != "okta" || got.User != "someone" || got.Path != "okta" { + t.Errorf("overrides not applied: %+v", got) + } +} + +// --- doLogin: method branching + prompt injection -------------------------- + +func TestDoLoginPasswordMethod(t *testing.T) { + var gotPath string + var gotBody = map[string]string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &gotBody) + _, _ = io.WriteString(w, `{"auth":{"client_token":"s.new","accessor":"acc","token_policies":["default"],"lease_duration":3600,"renewable":true}}`) + })) + defer srv.Close() + + var prompted string + orig := promptSecret + promptSecret = func(prompt string) (string, error) { prompted = prompt; return "hunter2", nil } + t.Cleanup(func() { promptSecret = orig }) + + rc := shared.ResolvedContext{Name: "sydney", Address: srv.URL, Method: "ldap", Path: "ldap", User: "ben"} + tok, err := doLogin(rc) + if err != nil { + t.Fatalf("doLogin: %v", err) + } + if gotPath != "/v1/auth/ldap/login/ben" { + t.Errorf("login path = %q", gotPath) + } + if gotBody["password"] != "hunter2" { + t.Errorf("password not sent from prompt: %v", gotBody) + } + if !strings.Contains(prompted, "Password") { + t.Errorf("password method should prompt for a password, got %q", prompted) + } + if tok.Token != "s.new" { + t.Errorf("token = %q", tok.Token) + } +} + +func TestDoLoginTokenMethod(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/auth/token/lookup-self" { + t.Errorf("token method should call lookup-self, got %q", r.URL.Path) + } + if r.Header.Get("X-Vault-Token") != "s.pasted" { + t.Errorf("token header = %q", r.Header.Get("X-Vault-Token")) + } + _, _ = io.WriteString(w, `{"data":{"accessor":"acc","policies":["root"],"ttl":0,"renewable":false}}`) + })) + defer srv.Close() + + var prompted string + orig := promptSecret + promptSecret = func(prompt string) (string, error) { prompted = prompt; return "s.pasted", nil } + t.Cleanup(func() { promptSecret = orig }) + + rc := shared.ResolvedContext{Name: "root-ctx", Address: srv.URL, Method: "token"} + tok, err := doLogin(rc) + if err != nil { + t.Fatalf("doLogin(token): %v", err) + } + if !strings.Contains(prompted, "token") { + t.Errorf("token method should prompt for a token, got %q", prompted) + } + if tok.Token != "s.pasted" || tok.Accessor != "acc" { + t.Errorf("token = %+v", tok) + } +} + +// --- doRenew --------------------------------------------------------------- + +func TestDoRenewNoCachedToken(t *testing.T) { + setupVctl(t, vctlConfig) + 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") + } +} + +func TestDoRenewUsesCachedToken(t *testing.T) { + setupVctl(t, vctlConfig) + 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) + } + if r.Header.Get("X-Vault-Token") != "s.cached" { + t.Errorf("renew must use cached token, got %q", r.Header.Get("X-Vault-Token")) + } + _, _ = io.WriteString(w, `{"auth":{"client_token":"s.cached","lease_duration":7200,"renewable":true}}`) + })) + defer srv.Close() + + if err := shared.SaveToken(&shared.Token{Context: "sydney", Address: srv.URL, Token: "s.cached", Accessor: "acc-old"}); err != nil { + t.Fatal(err) + } + rc := shared.ResolvedContext{Name: "sydney", Address: srv.URL} + tok, err := doRenew(rc) + if err != nil { + t.Fatalf("doRenew: %v", err) + } + if tok.LeaseDurationSeconds != 7200 { + t.Errorf("lease = %d, want 7200", tok.LeaseDurationSeconds) + } + if tok.Accessor != "acc-old" { + t.Errorf("accessor should carry over, got %q", tok.Accessor) + } +} + +// --- runList --------------------------------------------------------------- + +func TestRunListShowsTokenStatus(t *testing.T) { + setupVctl(t, vctlConfig) + // sydney has a valid token; staging/sydney has none. + if err := shared.SaveToken(&shared.Token{ + Context: "sydney", + Address: "https://vault.syd1.au.unkin.net", + Token: "s.x", + ExpiresAt: time.Now().Add(30 * time.Minute), + }); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if err := runList(&buf); err != nil { + t.Fatalf("runList: %v", err) + } + out := buf.String() + if !strings.Contains(out, "sydney") || !strings.Contains(out, "token valid, expires in") { + t.Errorf("expected valid-token status for sydney:\n%s", out) + } + if !strings.Contains(out, "staging/sydney") || !strings.Contains(out, "no token") { + t.Errorf("expected 'no token' for staging/sydney:\n%s", out) + } +} + +func TestRunListEmptyConfig(t *testing.T) { + setupVctl(t, "contexts: {}\n") + var buf bytes.Buffer + if err := runList(&buf); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "no contexts configured") { + t.Errorf("expected no-contexts message, got %q", buf.String()) + } +} + +// --- pure helpers ---------------------------------------------------------- + +func TestShort(t *testing.T) { + if got := short("abcdefghij"); got != "abcdefgh..." { + t.Errorf("short(long) = %q", got) + } + if got := short("abc"); got != "abc" { + t.Errorf("short(short) = %q", got) + } +} + +func TestTokenSummary(t *testing.T) { + s := tokenSummary(&shared.Token{ + Accessor: "accessor-123456", + LeaseDurationSeconds: 3600, + Policies: []string{"default", "kv"}, + Renewable: true, + }) + for _, want := range []string{"accessor=accessor", "ttl=1h0m0s", "policies=default,kv", "renewable=true"} { + if !strings.Contains(s, want) { + t.Errorf("summary %q missing %q", s, want) + } + } +} diff --git a/vctx/main.go b/vctx/main.go new file mode 100644 index 0000000..a1725e9 --- /dev/null +++ b/vctx/main.go @@ -0,0 +1,143 @@ +// 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 +// VAULT_ADDR / VAULT_TOKEN / VAULT_NAMESPACE for that single invocation, and +// execs `vault` with the remaining arguments: +// +// vctx --context sydney kv put kv/foo/bar secret=baz +// vctx --context staging/sydney token lookup +package main + +import ( + "fmt" + "os" + "os/exec" + "syscall" + + "git.unkin.net/unkin/vault-tools/shared" + + "github.com/spf13/cobra" +) + +var version = "dev" + +// execVault replaces the current process with the vault binary. It is a package +// variable so tests can substitute a fake in place of syscall.Exec. +var execVault = syscall.Exec + +func main() { + if err := newRootCmd().Execute(); err != nil { + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + var context string + + root := &cobra.Command{ + Use: "vctx --context ", + 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/, sets VAULT_ADDR, VAULT_TOKEN and\n" + + "VAULT_NAMESPACE for this invocation only, and execs `vault` with the\n" + + "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 + // meant for vault (e.g. `kv get -field=foo`) are passed through untouched. + Args: cobra.ArbitraryArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runVctx(context, args) + }, + } + + // Do not treat vault flags interspersed with args as vctx flags: only the + // leading --context is ours; everything from the first positional on is the + // vault command line. + root.Flags().SetInterspersed(false) + root.Flags().StringVar(&context, "context", "", "Vault context to target (required)") + _ = root.RegisterFlagCompletionFunc("context", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + cfg, err := shared.Load() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cfg.ContextNames(), cobra.ShellCompDirectiveNoFileComp + }) + + root.AddCommand(&cobra.Command{ + Use: "version", + Short: "Print the version", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) }, + SilenceUsage: true, + }) + + return root +} + +// runVctx validates inputs, builds the vault invocation for the context, and +// execs it (replacing this process). +func runVctx(context string, args []string) error { + if context == "" { + return fmt.Errorf("--context is required") + } + 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()) + if err != nil { + return err + } + // Replace this process with vault so its exit status, signals and TTY + // behaviour pass straight through. + if err := execVault(bin, argv, env); err != nil { + return fmt.Errorf("exec vault: %w", err) + } + return nil +} + +// 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) { + cfg, err := shared.Load() + if err != nil { + return "", nil, nil, err + } + rc, err := cfg.Resolve(context) + if err != nil { + return "", nil, nil, err + } + tok, err := shared.LoadToken(context) + if err != nil { + return "", nil, nil, err + } + + vaultBin, err := exec.LookPath("vault") + if err != nil { + 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, + ) + // Namespace comes from the context, falling back to what the token was + // issued under; only set when non-empty. + ns := rc.Namespace + if ns == "" { + ns = tok.Namespace + } + if ns != "" { + env = append(env, "VAULT_NAMESPACE="+ns) + } + + argv = append([]string{vaultBin}, args...) + return vaultBin, argv, env, nil +} diff --git a/vctx/main_test.go b/vctx/main_test.go new file mode 100644 index 0000000..2ba810e --- /dev/null +++ b/vctx/main_test.go @@ -0,0 +1,199 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "git.unkin.net/unkin/vault-tools/shared" +) + +// setupVctx points XDG dirs at temp locations, writes a config file and the +// given cached tokens, and puts a fake `vault` binary on PATH. It returns the +// fake vault's absolute path. +func setupVctx(t *testing.T, cfg string, tokens map[string]shared.Token) string { + t.Helper() + cfgHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", cfgHome) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + cfgDir := filepath.Join(cfgHome, "vault") + 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 { + t.Fatal(err) + } + for name, tok := range tokens { + tk := tok + tk.Context = name + if err := shared.SaveToken(&tk); err != nil { + t.Fatal(err) + } + } + + binDir := t.TempDir() + vault := filepath.Join(binDir, "vault") + if err := os.WriteFile(vault, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + return vault +} + +const vctxConfig = ` +contexts: + sydney: + address: https://vault.syd1.au.unkin.net + staging/sydney: + address: https://vault-staging.syd1.au.unkin.net + 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 + for _, e := range env { + if strings.HasPrefix(e, key+"=") { + val, ok = e[len(key)+1:], true + } + } + return val, ok +} + +func TestBuildInvocationArgsAndEnvOverride(t *testing.T) { + vault := setupVctx(t, vctxConfig, map[string]shared.Token{ + "sydney": {Address: "https://vault.syd1.au.unkin.net", Token: "s.SYDTOK"}, + }) + + // Ambient VAULT_ADDR/VAULT_TOKEN must be overridden by the context's values. + baseEnv := []string{"HOME=/home/x", "VAULT_ADDR=ambient", "VAULT_TOKEN=ambient"} + args := []string{"kv", "get", "-field=foo", "secret/x"} + + bin, argv, env, err := buildInvocation("sydney", args, baseEnv) + if err != nil { + t.Fatalf("buildInvocation: %v", err) + } + if bin != vault { + t.Errorf("bin = %q, want %q", bin, vault) + } + // argv is vault + the untouched args (vault flags like -field pass through). + wantArgv := append([]string{vault}, args...) + 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" { + t.Errorf("VAULT_ADDR = %q, want context override to win", v) + } + if v, _ := lastVal(env, "VAULT_TOKEN"); v != "s.SYDTOK" { + t.Errorf("VAULT_TOKEN = %q, want s.SYDTOK", v) + } + if _, ok := lastVal(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" { + t.Errorf("HOME = %q, base env not preserved", v) + } +} + +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) + if err != nil { + t.Fatalf("buildInvocation: %v", err) + } + if v, _ := lastVal(env, "VAULT_NAMESPACE"); v != "staging" { + t.Errorf("VAULT_NAMESPACE = %q, want staging", v) + } +} + +func TestBuildInvocationNamespaceFallsBackToToken(t *testing.T) { + // Context has no namespace, but the cached token records one — vctx should + // fall back to the token's namespace. + 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) + if err != nil { + t.Fatalf("buildInvocation: %v", err) + } + if v, _ := lastVal(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) + if err == nil { + t.Fatal("expected error for missing cached token") + } + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("error should wrap os.ErrNotExist, got %v", err) + } +} + +func TestBuildInvocationUnknownContext(t *testing.T) { + setupVctx(t, vctxConfig, 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") { + t.Errorf("empty context: got %v", err) + } + if err := runVctx("sydney", nil); err == nil || !strings.Contains(err.Error(), "no vault arguments") { + t.Errorf("no args: got %v", err) + } +} + +func TestRunVctxExecsWithBuiltInvocation(t *testing.T) { + vault := setupVctx(t, vctxConfig, map[string]shared.Token{ + "sydney": {Address: "https://vault.syd1.au.unkin.net", Token: "s.SYDTOK"}, + }) + + var gotBin string + var gotArgv, gotEnv []string + orig := execVault + execVault = func(bin string, argv, env []string) error { + gotBin, gotArgv, gotEnv = bin, argv, env + return nil + } + t.Cleanup(func() { execVault = orig }) + + if err := runVctx("sydney", []string{"kv", "list", "kv/"}); err != nil { + t.Fatalf("runVctx: %v", err) + } + if gotBin != vault { + t.Errorf("exec bin = %q, want %q", gotBin, vault) + } + if !reflect.DeepEqual(gotArgv, []string{vault, "kv", "list", "kv/"}) { + t.Errorf("exec argv = %v", gotArgv) + } + if v, _ := lastVal(gotEnv, "VAULT_TOKEN"); v != "s.SYDTOK" { + t.Errorf("exec env VAULT_TOKEN = %q", v) + } +} + +func TestRunVctxSurfacesExecError(t *testing.T) { + setupVctx(t, vctxConfig, map[string]shared.Token{ + "sydney": {Address: "https://vault.syd1.au.unkin.net", Token: "s.X"}, + }) + orig := execVault + execVault = func(bin string, argv, env []string) error { return errors.New("boom") } + t.Cleanup(func() { execVault = orig }) + + err := runVctx("sydney", []string{"status"}) + if err == nil || !strings.Contains(err.Error(), "exec vault") { + t.Errorf("expected wrapped exec error, got %v", err) + } +}