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