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:
+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
|
||||
}
|
||||
Reference in New Issue
Block a user