rickub/clipublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/rickub/cli.git
git clone ssh://git@rickub.com/rickub/cli.git
config.go · 252 lines · 6.9 KBGo Blame HistoryRaw
Initial import of the rickub CLI as a standalone public project 1a1d430 Olivier Girardot 10h ago1// 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.
16package config
17
18import (
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.
32const DefaultHost = "https://rickub.com"
33
34// Environment variables consulted when resolving host/token.
35const (
36 EnvToken = "RICKUB_TOKEN"
37 EnvHost = "RICKUB_HOST"
38)
39
40// HostConfig is the per-host state stored in the config file.
41type HostConfig struct {
42 Token string `yaml:"token,omitempty"`
43}
44
45// Config is the persisted CLI configuration.
46type 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.
58func 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.
73func 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.
86func 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).
95func 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.
111func (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).
120func (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.
134func (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.
142func (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.
158func (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.
178func 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.
196func 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.
208func 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.
229func 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.
246func 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}