190 lines
5.5 KiB
Go
190 lines
5.5 KiB
Go
// 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, ...).
|
|
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/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,
|
|
}, 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
|
|
}
|