Add vctl and vctx Vault token CLIs
Introduces the vault-tools monorepo: two Go CLIs that share a config file (~/.config/vault) and token cache (~/.cache/vault) for working with multiple Vault instances (contexts). - add shared/ library: config parsing (vctl.yaml/config.yaml, per-context overrides, slash contexts), token cache (0600/0700, atomic writes, path- traversal guards), and a small hand-rolled Vault HTTP client (login/renew) - add vctl: login/renew (single or --all), list, --method/--user overrides, no-echo password/token prompts, dynamic context completion - add vctx: resolve a context, set VAULT_ADDR/VAULT_TOKEN/VAULT_NAMESPACE and exec the vault CLI, passing remaining args through untouched - add unit tests across shared/, vctl and vctx command layers (config resolution, cache paths, vault client, --all iteration + error aggregation, vctx arg pass-through and env construction via fakeable exec/prompt seams) - add Makefile (build/test/completions/rpm, patch|minor|major version bumps), nfpm RPM packaging bundling bash/zsh/fish completions for both binaries - add Woodpecker pipelines: build/test/pre-commit on PRs, and a tag release that cross-compiles, builds+uploads the RPM to artifactapi, and cuts a Gitea release (serviceAccountName default, k8s resources on every step) - add README, per-command docs (docs/vctl.md, docs/vctx.md), AGENTS.md and an example config Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user