// Package shared holds the plumbing common to the vault-tools CLIs (vctl and // vctx): config-file parsing, the on-disk token cache, and a small Vault HTTP // API client. Both tools read the SAME config file and token cache so a single // ~/.config/vault/ configures every tool in the family. package shared import ( "fmt" "os" "path/filepath" "sort" "gopkg.in/yaml.v3" ) const ( // DefaultMethod is the auth method used when neither the context nor the // file-level defaults specify one. DefaultMethod = "ldap" // appDir is the per-user config/cache subdirectory both tools live under. appDir = "vault" ) // configFileNames are the accepted config file basenames, tried in order. The // first one that exists wins. var configFileNames = []string{"vctl.yaml", "config.yaml"} // Context is a single vault instance the tools can target. Every field except // Address is optional and, when empty, falls back to the file-level Defaults // and finally the built-in defaults. type Context struct { // Address is the Vault API base URL, e.g. https://vault.syd1.au.unkin.net. Address string `yaml:"address"` // Method is the auth method (ldap, userpass, okta, radius, token, ...). Method string `yaml:"method,omitempty"` // User is the login username (LDAP/userpass/...); ignored for token auth. User string `yaml:"user,omitempty"` // Namespace is the Vault namespace (X-Vault-Namespace) for the context. Namespace string `yaml:"namespace,omitempty"` // Path overrides the auth mount path (defaults to Method), e.g. "ldap2". Path string `yaml:"path,omitempty"` } // Defaults holds file-level fallbacks applied to every context that does not // set its own value. type Defaults struct { Method string `yaml:"method,omitempty"` User string `yaml:"user,omitempty"` Namespace string `yaml:"namespace,omitempty"` } // Config is the parsed config file: file-level defaults plus a map of named // contexts. Context names may contain slashes (e.g. "staging/sydney"), which // map to nested cache paths. type Config struct { Defaults Defaults `yaml:"defaults"` Contexts map[string]Context `yaml:"contexts"` // path records the file this config was loaded from (empty if none found). path string } // ResolvedContext is a Context with all defaults applied, ready to use. Method // and User are always populated. type ResolvedContext struct { Name string Address string Method string User string Namespace string Path string } // ConfigDir returns the XDG_CONFIG_HOME/vault directory. func ConfigDir() string { base := os.Getenv("XDG_CONFIG_HOME") if base == "" { home, _ := os.UserHomeDir() base = filepath.Join(home, ".config") } return filepath.Join(base, appDir) } // ConfigPath returns the path of the first existing config file, or the path // the file would take (the first candidate) when none exists yet. func ConfigPath() string { dir := ConfigDir() for _, name := range configFileNames { p := filepath.Join(dir, name) if _, err := os.Stat(p); err == nil { return p } } return filepath.Join(dir, configFileNames[0]) } // Load reads and parses the first config file found in ConfigDir. A missing // config file is not an error: an empty Config is returned so callers can give // a helpful "no contexts configured" message. func Load() (*Config, error) { cfg := &Config{Contexts: map[string]Context{}} dir := ConfigDir() for _, name := range configFileNames { p := filepath.Join(dir, name) data, err := os.ReadFile(p) if err != nil { if os.IsNotExist(err) { continue } return cfg, fmt.Errorf("reading config %s: %w", p, err) } if err := yaml.Unmarshal(data, cfg); err != nil { return cfg, fmt.Errorf("parsing config %s: %w", p, err) } if cfg.Contexts == nil { cfg.Contexts = map[string]Context{} } cfg.path = p return cfg, nil } return cfg, nil } // Path returns the file this config was loaded from, or "" if none was found. func (c *Config) Path() string { return c.path } // ContextNames returns the configured context names, sorted. Used to drive // shell completion for the --context flag / context arguments. func (c *Config) ContextNames() []string { names := make([]string, 0, len(c.Contexts)) for n := range c.Contexts { names = append(names, n) } sort.Strings(names) return names } // Resolve looks up a context by name and applies the fallback chain for each // field: context value < file defaults < built-in default. Method and User are // guaranteed non-empty in the result (User defaults to $USER). func (c *Config) Resolve(name string) (ResolvedContext, error) { ctx, ok := c.Contexts[name] if !ok { return ResolvedContext{}, fmt.Errorf("no context %q in %s", name, displayPath(c.path)) } if ctx.Address == "" { return ResolvedContext{}, fmt.Errorf("context %q has no address", name) } method := firstNonEmpty(ctx.Method, c.Defaults.Method, DefaultMethod) user := firstNonEmpty(ctx.User, c.Defaults.User, os.Getenv("USER")) namespace := firstNonEmpty(ctx.Namespace, c.Defaults.Namespace) path := firstNonEmpty(ctx.Path, method) return ResolvedContext{ Name: name, Address: ctx.Address, Method: method, User: user, Namespace: namespace, Path: path, }, nil } // ResolveWithOverrides is Resolve plus explicit CLI-flag overrides for method // and user; an empty override leaves the resolved value untouched. When the // method is overridden and the context did not pin an explicit auth path, the // auth path follows the new method. func (c *Config) ResolveWithOverrides(name, method, user string) (ResolvedContext, error) { rc, err := c.Resolve(name) if err != nil { return rc, err } if method != "" { pinnedPath := c.Contexts[name].Path != "" rc.Method = method if !pinnedPath { rc.Path = method } } if user != "" { rc.User = user } return rc, nil } func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" } func displayPath(p string) string { if p == "" { return ConfigPath() + " (not found)" } return p }