// Package config handles rickub CLI configuration and token storage. // // Configuration is persisted to ~/.config/rickub/config.yaml (mode 0600) and // holds the active API host plus, per host, the personal access token minted // for it. Tokens are bound to the host they were issued against: a token saved // for https://rickub.com is never sent to some other host that a --host flag or // RICKUB_HOST happens to name. At runtime the effective host and token are // resolved with a fixed precedence so a flag or environment variable can always // override the stored config: // // host: --host flag → RICKUB_HOST env → config file → DefaultHost // token: --token flag → RICKUB_TOKEN env → config file entry for that host // // A token supplied explicitly (flag or env) is honoured for whatever host is in // effect — the caller asked for it. Only the stored token is host-bound. package config import ( "errors" "fmt" "io" "net" "net/url" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" ) // DefaultHost is the production API host used when nothing else is configured. const DefaultHost = "https://rickub.com" // Environment variables consulted when resolving host/token. const ( EnvToken = "RICKUB_TOKEN" EnvHost = "RICKUB_HOST" ) // HostConfig is the per-host state stored in the config file. type HostConfig struct { Token string `yaml:"token,omitempty"` } // Config is the persisted CLI configuration. type Config struct { // Host is the active host, used when neither --host nor RICKUB_HOST is set. Host string `yaml:"host,omitempty"` // Hosts maps a normalized host URL to the credentials minted for it. Hosts map[string]HostConfig `yaml:"hosts,omitempty"` } // NormalizeHost canonicalizes a host URL for use as a config key and for // comparing the effective host against the host a token was saved for. It trims // surrounding space and trailing slashes and lowercases the scheme and // authority (which are case-insensitive per RFC 3986); anything it cannot parse // is returned trimmed but otherwise untouched. func NormalizeHost(host string) string { host = strings.TrimRight(strings.TrimSpace(host), "/") if host == "" { return "" } u, err := url.Parse(host) if err != nil || u.Host == "" { return host } u.Scheme = strings.ToLower(u.Scheme) u.Host = strings.ToLower(u.Host) return strings.TrimRight(u.String(), "/") } // Path returns the config file path, honouring XDG_CONFIG_HOME. func Path() (string, error) { if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { return filepath.Join(xdg, "rickub", "config.yaml"), nil } home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".config", "rickub", "config.yaml"), nil } // Load reads the config file. A missing file is not an error: it returns an // empty Config so callers can rely on flag/env resolution alone. func Load() (*Config, error) { path, err := Path() if err != nil { return nil, err } return LoadFrom(path) } // LoadFrom reads config from an explicit path (used by tests). func LoadFrom(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return &Config{}, nil } return nil, err } var c Config if err := yaml.Unmarshal(data, &c); err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } return &c, nil } // Save writes the config file (mode 0600), creating parent dirs as needed. func (c *Config) Save() error { path, err := Path() if err != nil { return err } return c.SaveTo(path) } // SaveTo writes config to an explicit path (used by tests). func (c *Config) SaveTo(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } data, err := yaml.Marshal(c) if err != nil { return err } // Write 0600 so the token is not world-readable. return os.WriteFile(path, data, 0o600) } // TokenFor returns the stored token minted for host, or "" if none is stored // for exactly that host. func (c *Config) TokenFor(host string) string { if c == nil { return "" } return c.Hosts[NormalizeHost(host)].Token } // SetToken stores token as the credential for host and makes host active. func (c *Config) SetToken(host, token string) { host = NormalizeHost(host) if host == "" { return } if c.Hosts == nil { c.Hosts = make(map[string]HostConfig) } entry := c.Hosts[host] entry.Token = token c.Hosts[host] = entry c.Host = host } // ClearToken removes the stored token for host. It reports whether one was // removed. func (c *Config) ClearToken(host string) bool { host = NormalizeHost(host) if c == nil || c.Hosts == nil { return false } entry, ok := c.Hosts[host] if !ok || entry.Token == "" { return false } entry.Token = "" if entry == (HostConfig{}) { delete(c.Hosts, host) } else { c.Hosts[host] = entry } return true } // ResolveHost applies the host precedence: flag → env → config → default. // The returned host is normalized and never has a trailing slash. func ResolveHost(flagHost string, cfg *Config) string { host := DefaultHost if cfg != nil && cfg.Host != "" { host = cfg.Host } if env := os.Getenv(EnvHost); env != "" { host = env } if flagHost != "" { host = flagHost } return NormalizeHost(host) } // ResolveToken applies the token precedence: flag → env → stored token for // host. An explicit flag or environment token is honoured for any host; the // stored token is returned only when host matches the host it was saved for, so // pointing --host / RICKUB_HOST at another server cannot leak it. func ResolveToken(flagToken string, cfg *Config, host string) string { if flagToken != "" { return flagToken } if env := os.Getenv(EnvToken); env != "" { return env } return cfg.TokenFor(host) } // IsLoopbackHost reports whether host addresses the local machine, where // sending a token over plain HTTP does not put it on the wire. func IsLoopbackHost(host string) bool { h := NormalizeHost(host) u, err := url.Parse(h) if err != nil { return false } hostname := u.Hostname() if hostname == "" { hostname = h } if strings.EqualFold(hostname, "localhost") { return true } if ip := net.ParseIP(hostname); ip != nil { return ip.IsLoopback() } return false } // IsInsecureHost reports whether sending a token to host would put it on the // wire in the clear: a non-https scheme on a non-loopback address. func IsInsecureHost(host string) bool { h := NormalizeHost(host) if h == "" { return false } u, err := url.Parse(h) if err != nil { return false } if strings.EqualFold(u.Scheme, "https") { return false } return !IsLoopbackHost(h) } // WarnIfInsecure prints a warning to w when a token is about to be sent to host // over an unencrypted connection. It reports whether it warned. func WarnIfInsecure(w io.Writer, host string) bool { if !IsInsecureHost(host) { return false } fmt.Fprintf(w, "warning: sending your token to %s over an unencrypted connection; anyone on the network path can read it\n", NormalizeHost(host)) return true }