// 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/ 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/ 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 }