Go · 13981 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package config owns the layered configuration loader.
4 //
5 // Precedence (lowest → highest):
6 //
7 // 1. Built-in defaults (this file)
8 // 2. TOML file (path from $SHITHUB_CONFIG, falling back to /etc/shithub/config.toml)
9 // 3. Environment variables (SHITHUB_<area>_<key>; nested keys joined with "__")
10 // 4. CLI flag overrides handed in by the caller
11 //
12 // The Config struct is the single source of truth for what is configurable.
13 // Every consumer reads from this struct rather than from os.Getenv directly.
14 package config
15
16 import (
17 "errors"
18 "fmt"
19 "os"
20 "reflect"
21 "strconv"
22 "strings"
23 "time"
24
25 "github.com/BurntSushi/toml"
26 )
27
28 // Config is the typed root.
29 type Config struct {
30 Env string `toml:"env"` // "dev", "staging", "prod"
31 Web WebConfig `toml:"web"`
32 DB DBConfig `toml:"db"`
33 Log LogConfig `toml:"log"`
34 Metrics MetricsConfig `toml:"metrics"`
35 Tracing TracingConfig `toml:"tracing"`
36 ErrorReporting ErrorReportingConfig `toml:"error_reporting"`
37 Session SessionConfig `toml:"session"`
38 Storage StorageConfig `toml:"storage"`
39 Auth AuthConfig `toml:"auth"`
40 }
41
42 // WebConfig holds HTTP server settings.
43 type WebConfig struct {
44 Addr string `toml:"addr"`
45 ReadTimeout time.Duration `toml:"read_timeout"`
46 WriteTimeout time.Duration `toml:"write_timeout"`
47 ShutdownTimeout time.Duration `toml:"shutdown_timeout"`
48 }
49
50 // DBConfig holds Postgres settings.
51 type DBConfig struct {
52 URL string `toml:"url"`
53 MaxConns int `toml:"max_conns"`
54 MinConns int `toml:"min_conns"`
55 ConnectTimeout time.Duration `toml:"connect_timeout"`
56 }
57
58 // LogConfig holds slog settings.
59 type LogConfig struct {
60 Level string `toml:"level"` // debug | info | warn | error
61 Format string `toml:"format"` // text (dev) | json (prod)
62 }
63
64 // MetricsConfig configures the /metrics endpoint.
65 type MetricsConfig struct {
66 Enabled bool `toml:"enabled"`
67 BasicAuthUser string `toml:"basic_auth_user"`
68 BasicAuthPass string `toml:"basic_auth_pass"`
69 }
70
71 // TracingConfig configures the OpenTelemetry exporter.
72 type TracingConfig struct {
73 Enabled bool `toml:"enabled"`
74 Endpoint string `toml:"endpoint"` // OTLP HTTP endpoint
75 SampleRate float64 `toml:"sample_rate"`
76 ServiceName string `toml:"service_name"`
77 }
78
79 // ErrorReportingConfig configures the Sentry/GlitchTip-protocol DSN.
80 type ErrorReportingConfig struct {
81 DSN string `toml:"dsn"`
82 Environment string `toml:"environment"`
83 Release string `toml:"release"`
84 }
85
86 // SessionConfig configures the cookie session store.
87 type SessionConfig struct {
88 KeyB64 string `toml:"key_b64"`
89 MaxAge time.Duration `toml:"max_age"`
90 Secure bool `toml:"secure"`
91 }
92
93 // StorageConfig configures repo filesystem storage and S3-compatible
94 // object storage. The S3 block targets MinIO in dev/test and DigitalOcean
95 // Spaces in prod (force_path_style true for MinIO, false for Spaces).
96 type StorageConfig struct {
97 ReposRoot string `toml:"repos_root"` // filesystem root for bare repos
98 S3 S3StorageConfig `toml:"s3"`
99 }
100
101 // AuthConfig configures the email/password auth surface.
102 type AuthConfig struct {
103 RequireEmailVerification bool `toml:"require_email_verification"`
104 BaseURL string `toml:"base_url"` // e.g. https://shithub.example
105 SiteName string `toml:"site_name"`
106 EmailFrom string `toml:"email_from"`
107 EmailBackend string `toml:"email_backend"` // stdout | smtp | postmark
108 SMTP SMTPConfig `toml:"smtp"`
109 Postmark PostmarkConfig `toml:"postmark"`
110 Argon2 Argon2Config `toml:"argon2"`
111 TOTPKeyB64 string `toml:"totp_key_b64"` // base64 32-byte AEAD key for at-rest TOTP secrets
112 }
113
114 // SMTPConfig holds plain-SMTP backend settings (e.g. MailHog in dev).
115 type SMTPConfig struct {
116 Addr string `toml:"addr"` // host:port
117 Username string `toml:"username"`
118 Password string `toml:"password"`
119 }
120
121 // PostmarkConfig holds Postmark transactional API settings.
122 type PostmarkConfig struct {
123 ServerToken string `toml:"server_token"`
124 }
125
126 // Argon2Config tunes the password hasher's cost. Defaults match the
127 // internal/auth/password Defaults() — operators bump these on faster
128 // hardware to keep the per-hash cost in the 100–300 ms band.
129 type Argon2Config struct {
130 MemoryKiB uint32 `toml:"memory_kib"`
131 Time uint32 `toml:"time"`
132 Threads uint8 `toml:"threads"`
133 }
134
135 // S3StorageConfig holds the S3-compatible endpoint settings.
136 type S3StorageConfig struct {
137 Endpoint string `toml:"endpoint"` // host[:port], no scheme
138 Region string `toml:"region"` // e.g. "us-east-1", "nyc3"
139 AccessKeyID string `toml:"access_key_id"` //
140 SecretAccessKey string `toml:"secret_access_key"` //
141 Bucket string `toml:"bucket"` // e.g. "shithub-dev"
142 UseSSL bool `toml:"use_ssl"` // true for Spaces, false for local MinIO
143 ForcePathStyle bool `toml:"force_path_style"` // true for MinIO, false for Spaces
144 }
145
146 // Defaults returns the zero-config baseline.
147 func Defaults() Config {
148 return Config{
149 Env: "dev",
150 Web: WebConfig{
151 Addr: ":8080",
152 ReadTimeout: 30 * time.Second,
153 WriteTimeout: 30 * time.Second,
154 ShutdownTimeout: 10 * time.Second,
155 },
156 DB: DBConfig{
157 MaxConns: 10,
158 MinConns: 0,
159 ConnectTimeout: 5 * time.Second,
160 },
161 Log: LogConfig{
162 Level: "info",
163 Format: "text",
164 },
165 Metrics: MetricsConfig{
166 Enabled: true,
167 },
168 Tracing: TracingConfig{
169 Enabled: false,
170 SampleRate: 0.05,
171 ServiceName: "shithubd",
172 },
173 Session: SessionConfig{
174 MaxAge: 30 * 24 * time.Hour,
175 Secure: false,
176 },
177 Storage: StorageConfig{
178 ReposRoot: "/data/repos",
179 S3: S3StorageConfig{
180 Region: "us-east-1",
181 ForcePathStyle: true,
182 },
183 },
184 Auth: AuthConfig{
185 RequireEmailVerification: true,
186 BaseURL: "http://127.0.0.1:8080",
187 SiteName: "shithub",
188 EmailFrom: "shithub <noreply@shithub.local>",
189 EmailBackend: "stdout",
190 SMTP: SMTPConfig{
191 Addr: "127.0.0.1:1025",
192 },
193 Argon2: Argon2Config{
194 MemoryKiB: 64 * 1024,
195 Time: 3,
196 Threads: 2,
197 },
198 },
199 }
200 }
201
202 // Load resolves configuration in the documented precedence order. CLI
203 // overrides may be nil. The TOML file is optional — its absence is not an
204 // error; its existence with bad syntax IS.
205 func Load(cliOverrides map[string]string) (Config, error) {
206 cfg := Defaults()
207 if err := mergeFile(&cfg); err != nil {
208 return cfg, err
209 }
210 if err := mergeEnv(&cfg, os.Environ()); err != nil {
211 return cfg, err
212 }
213 if err := mergeFlags(&cfg, cliOverrides); err != nil {
214 return cfg, err
215 }
216 applyAliases(&cfg)
217 if err := Validate(&cfg); err != nil {
218 return cfg, err
219 }
220 return cfg, nil
221 }
222
223 // applyAliases honors well-known env-var aliases that don't follow the
224 // nested-key convention. SHITHUB_DATABASE_URL is the S01-era name for
225 // db.url and remains supported.
226 func applyAliases(cfg *Config) {
227 if cfg.DB.URL == "" {
228 if v := os.Getenv("SHITHUB_DATABASE_URL"); v != "" {
229 cfg.DB.URL = v
230 }
231 }
232 if cfg.Session.KeyB64 == "" {
233 if v := os.Getenv("SHITHUB_SESSION_KEY"); v != "" {
234 cfg.Session.KeyB64 = v
235 }
236 }
237 if cfg.Auth.TOTPKeyB64 == "" {
238 if v := os.Getenv("SHITHUB_TOTP_KEY"); v != "" {
239 cfg.Auth.TOTPKeyB64 = v
240 }
241 }
242 }
243
244 // Validate enforces invariants. Errors are precise enough to point at the
245 // offending key.
246 func Validate(c *Config) error {
247 switch strings.ToLower(c.Env) {
248 case "dev", "staging", "prod":
249 c.Env = strings.ToLower(c.Env)
250 default:
251 return fmt.Errorf("config: env: must be dev|staging|prod, got %q", c.Env)
252 }
253 switch strings.ToLower(c.Log.Level) {
254 case "debug", "info", "warn", "error":
255 c.Log.Level = strings.ToLower(c.Log.Level)
256 default:
257 return fmt.Errorf("config: log.level: must be debug|info|warn|error, got %q", c.Log.Level)
258 }
259 switch strings.ToLower(c.Log.Format) {
260 case "text", "json":
261 c.Log.Format = strings.ToLower(c.Log.Format)
262 default:
263 return fmt.Errorf("config: log.format: must be text|json, got %q", c.Log.Format)
264 }
265 if c.Web.Addr == "" {
266 return errors.New("config: web.addr is required")
267 }
268 if c.Tracing.Enabled && c.Tracing.Endpoint == "" {
269 return errors.New("config: tracing.endpoint is required when tracing.enabled=true")
270 }
271 if c.Tracing.SampleRate < 0 || c.Tracing.SampleRate > 1 {
272 return fmt.Errorf("config: tracing.sample_rate: must be in [0, 1], got %v", c.Tracing.SampleRate)
273 }
274 if c.Storage.ReposRoot == "" {
275 return errors.New("config: storage.repos_root is required")
276 }
277 if err := validateS3(c.Storage.S3); err != nil {
278 return err
279 }
280 switch c.Auth.EmailBackend {
281 case "stdout", "smtp", "postmark":
282 default:
283 return fmt.Errorf("config: auth.email_backend: must be stdout|smtp|postmark, got %q", c.Auth.EmailBackend)
284 }
285 if c.Auth.EmailBackend == "smtp" && c.Auth.SMTP.Addr == "" {
286 return errors.New("config: auth.smtp.addr is required when email_backend=smtp")
287 }
288 if c.Auth.EmailBackend == "postmark" && c.Auth.Postmark.ServerToken == "" {
289 return errors.New("config: auth.postmark.server_token is required when email_backend=postmark")
290 }
291 if c.Auth.BaseURL == "" {
292 return errors.New("config: auth.base_url is required (used in email links)")
293 }
294 if c.Auth.SiteName == "" {
295 return errors.New("config: auth.site_name is required")
296 }
297 if c.Auth.EmailFrom == "" {
298 return errors.New("config: auth.email_from is required")
299 }
300 return nil
301 }
302
303 // validateS3 enforces all-or-nothing on the S3 block: if any of
304 // endpoint/bucket/access keys are set, all must be set.
305 func validateS3(s S3StorageConfig) error {
306 any := s.Endpoint != "" || s.Bucket != "" || s.AccessKeyID != "" || s.SecretAccessKey != ""
307 if !any {
308 return nil
309 }
310 missing := []string{}
311 if s.Endpoint == "" {
312 missing = append(missing, "endpoint")
313 }
314 if s.Bucket == "" {
315 missing = append(missing, "bucket")
316 }
317 if s.AccessKeyID == "" {
318 missing = append(missing, "access_key_id")
319 }
320 if s.SecretAccessKey == "" {
321 missing = append(missing, "secret_access_key")
322 }
323 if len(missing) > 0 {
324 return fmt.Errorf("config: storage.s3: incomplete configuration, missing: %s", strings.Join(missing, ", "))
325 }
326 return nil
327 }
328
329 // mergeFile reads the TOML file (when present) over cfg.
330 func mergeFile(cfg *Config) error {
331 path := os.Getenv("SHITHUB_CONFIG")
332 if path == "" {
333 path = "/etc/shithub/config.toml"
334 }
335 body, err := os.ReadFile(path) //nolint:gosec // operator-supplied path
336 if err != nil {
337 if errors.Is(err, os.ErrNotExist) {
338 return nil
339 }
340 return fmt.Errorf("config: read %s: %w", path, err)
341 }
342 if _, err := toml.Decode(string(body), cfg); err != nil {
343 return fmt.Errorf("config: parse %s: %w", path, err)
344 }
345 return nil
346 }
347
348 // mergeEnv overrides cfg from environment variables. Naming convention:
349 // SHITHUB_<area>__<key> (double-underscore separates nested levels).
350 // Single-segment keys also accept SHITHUB_<key>.
351 func mergeEnv(cfg *Config, environ []string) error {
352 envMap := make(map[string]string, len(environ))
353 for _, kv := range environ {
354 if !strings.HasPrefix(kv, "SHITHUB_") {
355 continue
356 }
357 eq := strings.IndexByte(kv, '=')
358 if eq < 0 {
359 continue
360 }
361 key := strings.TrimPrefix(kv[:eq], "SHITHUB_")
362 envMap[key] = kv[eq+1:]
363 }
364 return walkAndApply(reflect.ValueOf(cfg).Elem(), reflect.TypeOf(*cfg), "", envMap)
365 }
366
367 // mergeFlags applies CLI overrides. Keys use TOML notation
368 // ("web.addr", "tracing.endpoint", etc.).
369 func mergeFlags(cfg *Config, overrides map[string]string) error {
370 if len(overrides) == 0 {
371 return nil
372 }
373 envStyle := make(map[string]string, len(overrides))
374 for k, v := range overrides {
375 envStyle[strings.ToUpper(strings.ReplaceAll(k, ".", "__"))] = v
376 }
377 return walkAndApply(reflect.ValueOf(cfg).Elem(), reflect.TypeOf(*cfg), "", envStyle)
378 }
379
380 // walkAndApply walks struct fields recursively, applying values from src
381 // keyed by uppercased dot-then-double-underscore-joined paths.
382 func walkAndApply(v reflect.Value, t reflect.Type, prefix string, src map[string]string) error {
383 for i := 0; i < t.NumField(); i++ {
384 field := t.Field(i)
385 tag := field.Tag.Get("toml")
386 if tag == "" || tag == "-" {
387 continue
388 }
389 fieldPath := strings.ToUpper(tag)
390 if prefix != "" {
391 fieldPath = prefix + "__" + fieldPath
392 }
393 fv := v.Field(i)
394
395 if field.Type.Kind() == reflect.Struct && field.Type != reflect.TypeOf(time.Duration(0)) {
396 if err := walkAndApply(fv, field.Type, fieldPath, src); err != nil {
397 return err
398 }
399 continue
400 }
401
402 raw, ok := src[fieldPath]
403 if !ok {
404 continue
405 }
406 if err := setField(fv, field.Type, raw); err != nil {
407 return fmt.Errorf("config: %s: %w", strings.ReplaceAll(strings.ToLower(fieldPath), "__", "."), err)
408 }
409 }
410 return nil
411 }
412
413 func setField(v reflect.Value, t reflect.Type, raw string) error {
414 switch t.Kind() {
415 case reflect.String:
416 v.SetString(raw)
417 case reflect.Bool:
418 b, err := strconv.ParseBool(raw)
419 if err != nil {
420 return fmt.Errorf("invalid bool: %w", err)
421 }
422 v.SetBool(b)
423 case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
424 if t == reflect.TypeOf(time.Duration(0)) {
425 d, err := time.ParseDuration(raw)
426 if err != nil {
427 return fmt.Errorf("invalid duration: %w", err)
428 }
429 v.SetInt(int64(d))
430 return nil
431 }
432 n, err := strconv.ParseInt(raw, 10, 64)
433 if err != nil {
434 return fmt.Errorf("invalid int: %w", err)
435 }
436 v.SetInt(n)
437 case reflect.Float32, reflect.Float64:
438 f, err := strconv.ParseFloat(raw, 64)
439 if err != nil {
440 return fmt.Errorf("invalid float: %w", err)
441 }
442 v.SetFloat(f)
443 default:
444 return fmt.Errorf("unsupported field kind %s", t.Kind())
445 }
446 return nil
447 }
448