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
Initial import of the rickub CLI as a standalone public project 1a1d430Unverified · on main · Olivier Girardot · 9h ago
config.go · 252 lines · 6.9 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
// 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
}