// Package shared holds the plumbing common to the vault-tools CLIs (vcm 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/vcm/ 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 = "vcm" configFileName = "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, oidc). 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"` // Role is the OIDC role to log in with (empty = the mount's default_role). Role string `yaml:"role,omitempty"` // OIDCListen is the local OIDC callback listener (default localhost:8250). OIDCListen string `yaml:"oidc_listen,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 Role string OIDCListen string } // Overrides are CLI-flag values that replace resolved context fields; empty // fields leave the resolved value untouched. type Overrides struct { Method string User string Role string Listen string } // ConfigDir returns the XDG_CONFIG_HOME/vcm 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 config file, whether or not it exists. func ConfigPath() string { return filepath.Join(ConfigDir(), configFileName) } // Load reads and parses the config file. 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{}} p := ConfigPath() data, err := os.ReadFile(p) if err != nil { if os.IsNotExist(err) { return cfg, nil } 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 } // 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, Role: ctx.Role, OIDCListen: firstNonEmpty(ctx.OIDCListen, DefaultOIDCListen), }, nil } // ResolveWithOverrides is Resolve plus explicit CLI-flag overrides. 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 string, o Overrides) (ResolvedContext, error) { rc, err := c.Resolve(name) if err != nil { return rc, err } if o.Method != "" { rc.Method = o.Method if c.Contexts[name].Path == "" { rc.Path = o.Method } } rc.User = firstNonEmpty(o.User, rc.User) rc.Role = firstNonEmpty(o.Role, rc.Role) rc.OIDCListen = firstNonEmpty(o.Listen, rc.OIDCListen) 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 }