Files
vault-tools/shared/cache.go
T
unkinben 123faf8bbf
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
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
2026-07-26 23:28:05 +10:00

147 lines
4.8 KiB
Go

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
}