| Initial import of the rickub CLI as a standalone public project 1a1d430 Olivier Girardot 10h ago | 1 | // Package config handles rickub CLI configuration and token storage. |
| 2 | // |
| 3 | // Configuration is persisted to ~/.config/rickub/config.yaml (mode 0600) and |
| 4 | // holds the active API host plus, per host, the personal access token minted |
| 5 | // for it. Tokens are bound to the host they were issued against: a token saved |
| 6 | // for https://rickub.com is never sent to some other host that a --host flag or |
| 7 | // RICKUB_HOST happens to name. At runtime the effective host and token are |
| 8 | // resolved with a fixed precedence so a flag or environment variable can always |
| 9 | // override the stored config: |
| 10 | // |
| 11 | // host: --host flag → RICKUB_HOST env → config file → DefaultHost |
| 12 | // token: --token flag → RICKUB_TOKEN env → config file entry for that host |
| 13 | // |
| 14 | // A token supplied explicitly (flag or env) is honoured for whatever host is in |
| 15 | // effect — the caller asked for it. Only the stored token is host-bound. |
| 16 | package config |
| 17 | |
| 18 | import ( |
| 19 | "errors" |
| 20 | "fmt" |
| 21 | "io" |
| 22 | "net" |
| 23 | "net/url" |
| 24 | "os" |
| 25 | "path/filepath" |
| 26 | "strings" |
| 27 | |
| 28 | "gopkg.in/yaml.v3" |
| 29 | ) |
| 30 | |
| 31 | // DefaultHost is the production API host used when nothing else is configured. |
| 32 | const DefaultHost = "https://rickub.com" |
| 33 | |
| 34 | // Environment variables consulted when resolving host/token. |
| 35 | const ( |
| 36 | EnvToken = "RICKUB_TOKEN" |
| 37 | EnvHost = "RICKUB_HOST" |
| 38 | ) |
| 39 | |
| 40 | // HostConfig is the per-host state stored in the config file. |
| 41 | type HostConfig struct { |
| 42 | Token string `yaml:"token,omitempty"` |
| 43 | } |
| 44 | |
| 45 | // Config is the persisted CLI configuration. |
| 46 | type Config struct { |
| 47 | // Host is the active host, used when neither --host nor RICKUB_HOST is set. |
| 48 | Host string `yaml:"host,omitempty"` |
| 49 | // Hosts maps a normalized host URL to the credentials minted for it. |
| 50 | Hosts map[string]HostConfig `yaml:"hosts,omitempty"` |
| 51 | } |
| 52 | |
| 53 | // NormalizeHost canonicalizes a host URL for use as a config key and for |
| 54 | // comparing the effective host against the host a token was saved for. It trims |
| 55 | // surrounding space and trailing slashes and lowercases the scheme and |
| 56 | // authority (which are case-insensitive per RFC 3986); anything it cannot parse |
| 57 | // is returned trimmed but otherwise untouched. |
| 58 | func NormalizeHost(host string) string { |
| 59 | host = strings.TrimRight(strings.TrimSpace(host), "/") |
| 60 | if host == "" { |
| 61 | return "" |
| 62 | } |
| 63 | u, err := url.Parse(host) |
| 64 | if err != nil || u.Host == "" { |
| 65 | return host |
| 66 | } |
| 67 | u.Scheme = strings.ToLower(u.Scheme) |
| 68 | u.Host = strings.ToLower(u.Host) |
| 69 | return strings.TrimRight(u.String(), "/") |
| 70 | } |
| 71 | |
| 72 | // Path returns the config file path, honouring XDG_CONFIG_HOME. |
| 73 | func Path() (string, error) { |
| 74 | if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { |
| 75 | return filepath.Join(xdg, "rickub", "config.yaml"), nil |
| 76 | } |
| 77 | home, err := os.UserHomeDir() |
| 78 | if err != nil { |
| 79 | return "", err |
| 80 | } |
| 81 | return filepath.Join(home, ".config", "rickub", "config.yaml"), nil |
| 82 | } |
| 83 | |
| 84 | // Load reads the config file. A missing file is not an error: it returns an |
| 85 | // empty Config so callers can rely on flag/env resolution alone. |
| 86 | func Load() (*Config, error) { |
| 87 | path, err := Path() |
| 88 | if err != nil { |
| 89 | return nil, err |
| 90 | } |
| 91 | return LoadFrom(path) |
| 92 | } |
| 93 | |
| 94 | // LoadFrom reads config from an explicit path (used by tests). |
| 95 | func LoadFrom(path string) (*Config, error) { |
| 96 | data, err := os.ReadFile(path) |
| 97 | if err != nil { |
| 98 | if errors.Is(err, os.ErrNotExist) { |
| 99 | return &Config{}, nil |
| 100 | } |
| 101 | return nil, err |
| 102 | } |
| 103 | var c Config |
| 104 | if err := yaml.Unmarshal(data, &c); err != nil { |
| 105 | return nil, fmt.Errorf("parse %s: %w", path, err) |
| 106 | } |
| 107 | return &c, nil |
| 108 | } |
| 109 | |
| 110 | // Save writes the config file (mode 0600), creating parent dirs as needed. |
| 111 | func (c *Config) Save() error { |
| 112 | path, err := Path() |
| 113 | if err != nil { |
| 114 | return err |
| 115 | } |
| 116 | return c.SaveTo(path) |
| 117 | } |
| 118 | |
| 119 | // SaveTo writes config to an explicit path (used by tests). |
| 120 | func (c *Config) SaveTo(path string) error { |
| 121 | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| 122 | return err |
| 123 | } |
| 124 | data, err := yaml.Marshal(c) |
| 125 | if err != nil { |
| 126 | return err |
| 127 | } |
| 128 | // Write 0600 so the token is not world-readable. |
| 129 | return os.WriteFile(path, data, 0o600) |
| 130 | } |
| 131 | |
| 132 | // TokenFor returns the stored token minted for host, or "" if none is stored |
| 133 | // for exactly that host. |
| 134 | func (c *Config) TokenFor(host string) string { |
| 135 | if c == nil { |
| 136 | return "" |
| 137 | } |
| 138 | return c.Hosts[NormalizeHost(host)].Token |
| 139 | } |
| 140 | |
| 141 | // SetToken stores token as the credential for host and makes host active. |
| 142 | func (c *Config) SetToken(host, token string) { |
| 143 | host = NormalizeHost(host) |
| 144 | if host == "" { |
| 145 | return |
| 146 | } |
| 147 | if c.Hosts == nil { |
| 148 | c.Hosts = make(map[string]HostConfig) |
| 149 | } |
| 150 | entry := c.Hosts[host] |
| 151 | entry.Token = token |
| 152 | c.Hosts[host] = entry |
| 153 | c.Host = host |
| 154 | } |
| 155 | |
| 156 | // ClearToken removes the stored token for host. It reports whether one was |
| 157 | // removed. |
| 158 | func (c *Config) ClearToken(host string) bool { |
| 159 | host = NormalizeHost(host) |
| 160 | if c == nil || c.Hosts == nil { |
| 161 | return false |
| 162 | } |
| 163 | entry, ok := c.Hosts[host] |
| 164 | if !ok || entry.Token == "" { |
| 165 | return false |
| 166 | } |
| 167 | entry.Token = "" |
| 168 | if entry == (HostConfig{}) { |
| 169 | delete(c.Hosts, host) |
| 170 | } else { |
| 171 | c.Hosts[host] = entry |
| 172 | } |
| 173 | return true |
| 174 | } |
| 175 | |
| 176 | // ResolveHost applies the host precedence: flag → env → config → default. |
| 177 | // The returned host is normalized and never has a trailing slash. |
| 178 | func ResolveHost(flagHost string, cfg *Config) string { |
| 179 | host := DefaultHost |
| 180 | if cfg != nil && cfg.Host != "" { |
| 181 | host = cfg.Host |
| 182 | } |
| 183 | if env := os.Getenv(EnvHost); env != "" { |
| 184 | host = env |
| 185 | } |
| 186 | if flagHost != "" { |
| 187 | host = flagHost |
| 188 | } |
| 189 | return NormalizeHost(host) |
| 190 | } |
| 191 | |
| 192 | // ResolveToken applies the token precedence: flag → env → stored token for |
| 193 | // host. An explicit flag or environment token is honoured for any host; the |
| 194 | // stored token is returned only when host matches the host it was saved for, so |
| 195 | // pointing --host / RICKUB_HOST at another server cannot leak it. |
| 196 | func ResolveToken(flagToken string, cfg *Config, host string) string { |
| 197 | if flagToken != "" { |
| 198 | return flagToken |
| 199 | } |
| 200 | if env := os.Getenv(EnvToken); env != "" { |
| 201 | return env |
| 202 | } |
| 203 | return cfg.TokenFor(host) |
| 204 | } |
| 205 | |
| 206 | // IsLoopbackHost reports whether host addresses the local machine, where |
| 207 | // sending a token over plain HTTP does not put it on the wire. |
| 208 | func IsLoopbackHost(host string) bool { |
| 209 | h := NormalizeHost(host) |
| 210 | u, err := url.Parse(h) |
| 211 | if err != nil { |
| 212 | return false |
| 213 | } |
| 214 | hostname := u.Hostname() |
| 215 | if hostname == "" { |
| 216 | hostname = h |
| 217 | } |
| 218 | if strings.EqualFold(hostname, "localhost") { |
| 219 | return true |
| 220 | } |
| 221 | if ip := net.ParseIP(hostname); ip != nil { |
| 222 | return ip.IsLoopback() |
| 223 | } |
| 224 | return false |
| 225 | } |
| 226 | |
| 227 | // IsInsecureHost reports whether sending a token to host would put it on the |
| 228 | // wire in the clear: a non-https scheme on a non-loopback address. |
| 229 | func IsInsecureHost(host string) bool { |
| 230 | h := NormalizeHost(host) |
| 231 | if h == "" { |
| 232 | return false |
| 233 | } |
| 234 | u, err := url.Parse(h) |
| 235 | if err != nil { |
| 236 | return false |
| 237 | } |
| 238 | if strings.EqualFold(u.Scheme, "https") { |
| 239 | return false |
| 240 | } |
| 241 | return !IsLoopbackHost(h) |
| 242 | } |
| 243 | |
| 244 | // WarnIfInsecure prints a warning to w when a token is about to be sent to host |
| 245 | // over an unencrypted connection. It reports whether it warned. |
| 246 | func WarnIfInsecure(w io.Writer, host string) bool { |
| 247 | if !IsInsecureHost(host) { |
| 248 | return false |
| 249 | } |
| 250 | fmt.Fprintf(w, "warning: sending your token to %s over an unencrypted connection; anyone on the network path can read it\n", NormalizeHost(host)) |
| 251 | return true |
| 252 | } |