- Added internal/vault/client.go for creating a Vault client with configuration settings. - Introduced internal/vault/errors.go to classify Vault API errors for better UI handling. - Created internal/vault/kv.go to manage KV secrets, including listing, reading, writing, and deleting operations. - Implemented internal/vault/mounts.go to list and describe secret engine mounts. - Developed internal/vault/service.go to provide a unified entry point for Vault operations. - Added internal/vault/kv_test.go for comprehensive testing of KV operations. - Introduced internal/ui/toast.go for transient notifications in the UI. - Added renovate.json for dependency management and updates.
234 lines
7.5 KiB
Go
234 lines
7.5 KiB
Go
// Package cli wires urfave/cli/v3 commands around internal/config,
|
|
// internal/vault, internal/token, and internal/auth so every operation
|
|
// (login, list, read, write, status, logout, and the "ui" command that
|
|
// launches the Bubbletea TUI) is available both from the terminal directly
|
|
// and, unchanged, from inside the TUI's screens.
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/hashicorp/vault/api"
|
|
"github.com/urfave/cli/v3"
|
|
|
|
"git.morlana.online/f.weber/vault-tui/internal/auth"
|
|
"git.morlana.online/f.weber/vault-tui/internal/config"
|
|
"git.morlana.online/f.weber/vault-tui/internal/token"
|
|
vaultsvc "git.morlana.online/f.weber/vault-tui/internal/vault"
|
|
)
|
|
|
|
// App is the resolved, shared state every command's Action reads from. It
|
|
// is built once in the root Command's Before hook and threaded through
|
|
// context.Context (see appKey), which is the idiomatic urfave/cli/v3
|
|
// pattern since BeforeFunc returns the (possibly-derived) context that
|
|
// subsequent Action funcs receive.
|
|
type App struct {
|
|
ConfigPath string
|
|
File *config.File
|
|
Settings *config.Settings
|
|
Client *api.Client // unauthenticated until EnsureLoggedIn/SetToken
|
|
Store token.Store
|
|
|
|
// Overrides is the flag layer bootstrap resolved Settings with. Kept
|
|
// around so SwitchProfile can re-run config.Resolve for a different
|
|
// profile with identical flag/env precedence.
|
|
Overrides config.Overrides
|
|
}
|
|
|
|
type appKeyType struct{}
|
|
|
|
var appKey = appKeyType{}
|
|
|
|
func appFrom(ctx context.Context) (*App, error) {
|
|
a, ok := ctx.Value(appKey).(*App)
|
|
if !ok || a == nil {
|
|
return nil, fmt.Errorf("internal error: app not initialized")
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// overridesFromCommand reads the global flags declared in root.go into a
|
|
// config.Overrides — the "flag" layer of config.Resolve's precedence chain.
|
|
func overridesFromCommand(cmd *cli.Command) config.Overrides {
|
|
ov := config.Overrides{
|
|
Profile: cmd.String("profile"),
|
|
Address: cmd.String("address"),
|
|
Namespace: cmd.String("namespace"),
|
|
Token: cmd.String("token"),
|
|
CACert: cmd.String("ca-cert"),
|
|
ClientCert: cmd.String("client-cert"),
|
|
ClientKey: cmd.String("client-key"),
|
|
NoEnv: cmd.Bool("no-env"),
|
|
NoColor: cmd.Bool("no-color"),
|
|
}
|
|
if cmd.IsSet("tls-skip-verify") {
|
|
v := cmd.Bool("tls-skip-verify")
|
|
ov.SkipVerify = &v
|
|
}
|
|
if cmd.IsSet("read-only") {
|
|
v := cmd.Bool("read-only")
|
|
ov.ReadOnly = &v
|
|
} else if cmd.IsSet("write") {
|
|
v := !cmd.Bool("write")
|
|
ov.ReadOnly = &v
|
|
}
|
|
return ov
|
|
}
|
|
|
|
// bootstrap builds an *App from global flags: loads the config file,
|
|
// resolves Settings, and builds an (unauthenticated) *api.Client. It does
|
|
// NOT resolve or attach a token — see EnsureLoggedIn, which most commands
|
|
// call explicitly so that `vault-tui config init` and similar can run
|
|
// before any Vault connectivity exists.
|
|
func bootstrap(cmd *cli.Command) (*App, error) {
|
|
path, err := config.ResolvePath(cmd.String("config"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// First-run UX: if there is genuinely no config yet, an interactive
|
|
// session gets an inline wizard instead of a wall of "no profile
|
|
// configured" errors — but never for `config ...` itself (that
|
|
// subtree has its own explicit `init`) and never in a non-interactive
|
|
// context (CI, pipes), where the tool must stay driven entirely by
|
|
// flags/env instead of blocking on stdin.
|
|
leaf := cmd.Args().First()
|
|
if !config.Exists(path) && IsInteractive() && leaf != "config" {
|
|
if _, err := runWizard(path); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
file, err := config.Load(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ov := overridesFromCommand(cmd)
|
|
settings, err := config.Resolve(file, ov.Profile, ov, config.OSEnviron)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client, err := vaultsvc.NewClient(settings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
store, err := storeFor(settings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &App{ConfigPath: path, File: file, Settings: settings, Client: client, Store: store, Overrides: ov}, nil
|
|
}
|
|
|
|
// SwitchProfile re-resolves Settings for a different profile using the same
|
|
// flag/env overrides bootstrap ran with, and rebuilds Client and Store to
|
|
// match — what lets the TUI's profile picker switch profiles in place
|
|
// instead of requiring a restart. The caller is responsible for driving a
|
|
// fresh login afterwards; this does not touch authentication state beyond
|
|
// replacing the (unauthenticated) Client.
|
|
func (a *App) SwitchProfile(profile string) error {
|
|
ov := a.Overrides
|
|
ov.Profile = profile
|
|
settings, err := config.Resolve(a.File, profile, ov, config.OSEnviron)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client, err := vaultsvc.NewClient(settings)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
store, err := storeFor(settings)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.Settings, a.Client, a.Store = settings, client, store
|
|
return nil
|
|
}
|
|
|
|
func storeFor(s *config.Settings) (token.Store, error) {
|
|
switch s.Token.Storage {
|
|
case "none":
|
|
return token.NewNoneStore(), nil
|
|
case "profile":
|
|
if s.Token.File != "" {
|
|
return token.NewFileStore(expandHome(s.Token.File)), nil
|
|
}
|
|
dir, err := config.StateDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return token.NewProfileStore(dir, s.Profile), nil
|
|
default: // "vault-cli" or unset
|
|
return token.NewVaultCLIStore()
|
|
}
|
|
}
|
|
|
|
func expandHome(p string) string {
|
|
if len(p) >= 2 && p[:2] == "~/" {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
return home + p[1:]
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// EnsureLoggedIn resolves a token via internal/token.Resolve (flag > env >
|
|
// config > store), validates it with token.Lookup, and sets it on a.Client.
|
|
// If no token can be resolved at all, it returns a plain error telling the
|
|
// caller to run `vault-tui login` — headless commands never block on stdin
|
|
// here; interactive login is `login`'s job, not every command's.
|
|
func (a *App) EnsureLoggedIn(ctx context.Context, flagToken string) (*token.Info, error) {
|
|
envTok, _ := config.OSEnviron(config.EnvToken)
|
|
resolved, err := token.Resolve(ctx, token.Options{
|
|
Flag: flagToken,
|
|
Env: envTok,
|
|
Profile: a.File.Profiles[a.Settings.Profile],
|
|
Store: a.Store,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resolved.Token == "" {
|
|
return nil, fmt.Errorf("no Vault token found (checked --token, VAULT_TOKEN, and %s) — run `vault-tui login`", a.Store.Kind())
|
|
}
|
|
a.Client.SetToken(resolved.Token)
|
|
|
|
info, err := token.Lookup(ctx, a.Client)
|
|
if err != nil {
|
|
a.Client.ClearToken()
|
|
return nil, fmt.Errorf("resolved a token via %s but Vault rejected it: %w", resolved.Origin, err)
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
// Service builds the KV-facing Service around an already-authenticated
|
|
// client. Call after EnsureLoggedIn.
|
|
func (a *App) Service() *vaultsvc.Service {
|
|
return vaultsvc.New(a.Client, a.Settings.ReadOnly)
|
|
}
|
|
|
|
// Method resolves an auth.Method by name, special-casing "oidc" so that
|
|
// per-profile OIDC settings (port, callback host, timeouts — see
|
|
// config.OIDCOpts) are honoured even though the globally registered "oidc"
|
|
// entry (for picker/help listing) only carries library defaults.
|
|
func (a *App) Method(name string) (auth.Method, error) {
|
|
if name == "" {
|
|
name = a.Settings.Auth.Method
|
|
}
|
|
if name == "" {
|
|
return nil, fmt.Errorf("no auth method configured for profile %q (set auth.method or pass -method)", a.Settings.Profile)
|
|
}
|
|
if name == "oidc" {
|
|
return a.oidcMethod(), nil
|
|
}
|
|
m, ok := auth.Default().Get(name)
|
|
if !ok {
|
|
if reason, unavail := auth.Default().Unavailable(name); unavail {
|
|
return nil, fmt.Errorf("auth method %q is unavailable in this build: %s", name, reason)
|
|
}
|
|
return nil, fmt.Errorf("unknown auth method %q", name)
|
|
}
|
|
return m, nil
|
|
}
|