Add vctl and vctx Vault token CLIs #1
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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/<context>` as JSON (token + accessor + policies + TTL/expiry).
|
||||||
|
- **`vctx`** — thin wrapper around the real `vault` CLI:
|
||||||
|
`vctx --context <ctx> <vault args...>` resolves the context, sets
|
||||||
|
`VAULT_ADDR`/`VAULT_TOKEN`/`VAULT_NAMESPACE`, and execs `vault`.
|
||||||
|
|
||||||
|
Both tools share the `shared/` package (config parsing, token cache, Vault HTTP
|
||||||
|
client) and read the SAME config file (`~/.config/vault/vctl.yaml` or
|
||||||
|
`config.yaml`) and token cache so one `~/.config/vault/` configures everything.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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
|
||||||
|
# ./<tool>, 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)
|
||||||
@@ -1,3 +1,100 @@
|
|||||||
# vault-tools
|
# 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).
|
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/<context>` (`$XDG_CACHE_HOME` honoured)
|
||||||
|
as JSON with restrictive permissions (files `0600`, dirs `0700`), storing the
|
||||||
|
token plus its accessor, policies, TTL/expiry and renewable flag.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -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
|
||||||
+140
@@ -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/<context>` for use by
|
||||||
|
[`vctx`](vctx.md) and other tooling.
|
||||||
|
|
||||||
|
## Synopsis
|
||||||
|
|
||||||
|
```
|
||||||
|
vctl login <context> # log in to one context
|
||||||
|
vctl login --all # log in to every configured context
|
||||||
|
vctl renew <context> # renew one context's cached token
|
||||||
|
vctl renew --all # renew every context that has a cached token
|
||||||
|
vctl list # show contexts + cached-token status
|
||||||
|
vctl version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flags
|
||||||
|
|
||||||
|
Both `login` and `renew` accept:
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
| -------------- | -------------------------------- | -------------------------------------------------- |
|
||||||
|
| `--all` | `false` | Operate on every configured context. |
|
||||||
|
| `--method <m>` | context/config, else `ldap` | Override the auth method (`ldap`, `userpass`, `okta`, `radius`, `token`). |
|
||||||
|
| `--user <u>` | 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/<path>/login/<user>`.
|
||||||
|
- **`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/<context>` (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 <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).
|
||||||
@@ -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 <context> <any vault args...>
|
||||||
|
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 <context>`).
|
||||||
|
|
||||||
|
## 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 <TAB>` lists your vaults (the RPM installs these completions
|
||||||
|
automatically).
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
Executable
+49
@@ -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 <ben@unkin.net>"
|
||||||
|
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-tools"
|
||||||
|
export PACKAGE_LICENSE="MIT"
|
||||||
|
|
||||||
|
envsubst <packaging/nfpm.yaml >"${DIST}/nfpm.yaml"
|
||||||
|
nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm
|
||||||
|
|
||||||
|
echo "Built:"
|
||||||
|
ls -1 "${DIST}"/*.rpm
|
||||||
+146
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 <tmp>/vault/<name>, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+220
@@ -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/<path>/login/<user> {"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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+269
@@ -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/<context> 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/<context> 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
@@ -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 <context> <vault args...>",
|
||||||
|
Short: "Run the vault CLI against a named context.",
|
||||||
|
Long: "vctx resolves a context from ~/.config/vault (shared with vctl), loads its\n" +
|
||||||
|
"cached token from ~/.cache/vault/<context>, sets VAULT_ADDR, VAULT_TOKEN and\n" +
|
||||||
|
"VAULT_NAMESPACE for this invocation only, and execs `vault` with the\n" +
|
||||||
|
"remaining arguments.\n\n" +
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user