Add vctl and vctx Vault token CLIs
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful

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:
2026-07-26 23:28:05 +10:00
parent db3d80a21c
commit 123faf8bbf
27 changed files with 2854 additions and 1 deletions
+269
View File
@@ -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
}
+336
View File
@@ -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)
}
}
}