Go · 16693 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 Notif NotifConfig `toml:"notif"`
41 RateLimit RateLimitConfig `toml:"ratelimit"`
42 }
43
44 // RateLimitConfig configures runtime rate-limit budgets for surfaces that
45 // don't carry a domain-specific limiter. The /api/v1/ JSON surface uses
46 // the API.* sub-block; future surfaces (search, git transports) get their
47 // own sub-blocks here as they're factored out of their handlers.
48 type RateLimitConfig struct {
49 API APIRateLimitConfig `toml:"api"`
50 }
51
52 // APIRateLimitConfig sets the per-hour budgets for /api/v1/* requests.
53 // AuthedPerHour applies when the caller presents a valid PAT (keyed by
54 // token id). AnonPerHour applies to unauthenticated callers (keyed by
55 // remote IP). Defaults are GitHub-aligned: 5000/h authed, 60/h anon —
56 // operators tune via SHITHUB_RATELIMIT__API__AUTHED_PER_HOUR /
57 // SHITHUB_RATELIMIT__API__ANON_PER_HOUR.
58 type APIRateLimitConfig struct {
59 AuthedPerHour int `toml:"authed_per_hour"`
60 AnonPerHour int `toml:"anon_per_hour"`
61 }
62
63 // NotifConfig configures the S29 notification surface. UnsubscribeKeyB64
64 // is the base64-encoded HMAC-SHA256 key that signs one-click
65 // unsubscribe URLs (RFC 8058). When empty (dev default), the
66 // fan-out worker derives a deterministic key from the session key so
67 // links are stable across restarts without operator action — this
68 // derivation is NOT suitable for prod and the wiring layer logs a
69 // warning when it fires.
70 type NotifConfig struct {
71 UnsubscribeKeyB64 string `toml:"unsubscribe_key_b64"`
72 }
73
74 // WebConfig holds HTTP server settings.
75 type WebConfig struct {
76 Addr string `toml:"addr"`
77 ReadTimeout time.Duration `toml:"read_timeout"`
78 WriteTimeout time.Duration `toml:"write_timeout"`
79 ShutdownTimeout time.Duration `toml:"shutdown_timeout"`
80 }
81
82 // DBConfig holds Postgres settings.
83 type DBConfig struct {
84 URL string `toml:"url"`
85 MaxConns int `toml:"max_conns"`
86 MinConns int `toml:"min_conns"`
87 ConnectTimeout time.Duration `toml:"connect_timeout"`
88 }
89
90 // LogConfig holds slog settings.
91 type LogConfig struct {
92 Level string `toml:"level"` // debug | info | warn | error
93 Format string `toml:"format"` // text (dev) | json (prod)
94 }
95
96 // MetricsConfig configures the /metrics endpoint.
97 type MetricsConfig struct {
98 Enabled bool `toml:"enabled"`
99 BasicAuthUser string `toml:"basic_auth_user"`
100 BasicAuthPass string `toml:"basic_auth_pass"`
101 }
102
103 // TracingConfig configures the OpenTelemetry exporter.
104 type TracingConfig struct {
105 Enabled bool `toml:"enabled"`
106 Endpoint string `toml:"endpoint"` // OTLP HTTP endpoint
107 SampleRate float64 `toml:"sample_rate"`
108 ServiceName string `toml:"service_name"`
109 }
110
111 // ErrorReportingConfig configures the Sentry/GlitchTip-protocol DSN.
112 type ErrorReportingConfig struct {
113 DSN string `toml:"dsn"`
114 Environment string `toml:"environment"`
115 Release string `toml:"release"`
116 }
117
118 // SessionConfig configures the cookie session store.
119 type SessionConfig struct {
120 KeyB64 string `toml:"key_b64"`
121 MaxAge time.Duration `toml:"max_age"`
122 Secure bool `toml:"secure"`
123 }
124
125 // StorageConfig configures repo filesystem storage and S3-compatible
126 // object storage. The S3 block targets MinIO in dev/test and DigitalOcean
127 // Spaces in prod (force_path_style true for MinIO, false for Spaces).
128 type StorageConfig struct {
129 ReposRoot string `toml:"repos_root"` // filesystem root for bare repos
130 S3 S3StorageConfig `toml:"s3"`
131 }
132
133 // AuthConfig configures the email/password auth surface.
134 type AuthConfig struct {
135 RequireEmailVerification bool `toml:"require_email_verification"`
136 BaseURL string `toml:"base_url"` // e.g. https://shithub.example
137 SiteName string `toml:"site_name"`
138 EmailFrom string `toml:"email_from"`
139 EmailBackend string `toml:"email_backend"` // stdout | smtp | postmark
140 SMTP SMTPConfig `toml:"smtp"`
141 Postmark PostmarkConfig `toml:"postmark"`
142 Argon2 Argon2Config `toml:"argon2"`
143 TOTPKeyB64 string `toml:"totp_key_b64"` // base64 32-byte AEAD key for at-rest TOTP secrets
144 SSH SSHAuthConfig `toml:"ssh"`
145 }
146
147 // SSHAuthConfig flips whether the repo pages render an SSH clone URL
148 // alongside the HTTPS one. The actual SSH service (sshd Match-User-git
149 // + AuthorizedKeysCommand → shithubd ssh-authkeys) is operator-side
150 // and orthogonal to this flag — the flag just controls the UI surface.
151 // Operators who haven't wired SSH should leave Enabled=false so users
152 // don't get a clone URL that doesn't work.
153 type SSHAuthConfig struct {
154 Enabled bool `toml:"enabled"`
155 Host string `toml:"host"` // e.g. "git@shithub.sh" — the userinfo + hostname shown in the SSH clone URL
156 }
157
158 // SMTPConfig holds plain-SMTP backend settings (e.g. MailHog in dev).
159 type SMTPConfig struct {
160 Addr string `toml:"addr"` // host:port
161 Username string `toml:"username"`
162 Password string `toml:"password"`
163 }
164
165 // PostmarkConfig holds Postmark transactional API settings.
166 type PostmarkConfig struct {
167 ServerToken string `toml:"server_token"`
168 }
169
170 // Argon2Config tunes the password hasher's cost. Defaults match the
171 // internal/auth/password Defaults() — operators bump these on faster
172 // hardware to keep the per-hash cost in the 100–300 ms band.
173 type Argon2Config struct {
174 MemoryKiB uint32 `toml:"memory_kib"`
175 Time uint32 `toml:"time"`
176 Threads uint8 `toml:"threads"`
177 }
178
179 // S3StorageConfig holds the S3-compatible endpoint settings.
180 type S3StorageConfig struct {
181 Endpoint string `toml:"endpoint"` // host[:port], no scheme
182 Region string `toml:"region"` // e.g. "us-east-1", "nyc3"
183 AccessKeyID string `toml:"access_key_id"` //
184 SecretAccessKey string `toml:"secret_access_key"` //
185 Bucket string `toml:"bucket"` // e.g. "shithub-dev"
186 UseSSL bool `toml:"use_ssl"` // true for Spaces, false for local MinIO
187 ForcePathStyle bool `toml:"force_path_style"` // true for MinIO, false for Spaces
188 }
189
190 // Defaults returns the zero-config baseline.
191 func Defaults() Config {
192 return Config{
193 Env: "dev",
194 Web: WebConfig{
195 Addr: ":8080",
196 ReadTimeout: 30 * time.Second,
197 WriteTimeout: 30 * time.Second,
198 ShutdownTimeout: 10 * time.Second,
199 },
200 DB: DBConfig{
201 MaxConns: 10,
202 MinConns: 0,
203 ConnectTimeout: 5 * time.Second,
204 },
205 Log: LogConfig{
206 Level: "info",
207 Format: "text",
208 },
209 Metrics: MetricsConfig{
210 Enabled: true,
211 },
212 Tracing: TracingConfig{
213 Enabled: false,
214 SampleRate: 0.05,
215 ServiceName: "shithubd",
216 },
217 Session: SessionConfig{
218 MaxAge: 30 * 24 * time.Hour,
219 Secure: false,
220 },
221 Storage: StorageConfig{
222 ReposRoot: "/data/repos",
223 S3: S3StorageConfig{
224 Region: "us-east-1",
225 ForcePathStyle: true,
226 },
227 },
228 RateLimit: RateLimitConfig{
229 API: APIRateLimitConfig{
230 AuthedPerHour: 5000,
231 AnonPerHour: 60,
232 },
233 },
234 Auth: AuthConfig{
235 RequireEmailVerification: true,
236 BaseURL: "http://127.0.0.1:8080",
237 SiteName: "shithub",
238 EmailFrom: "shithub <noreply@shithub.local>",
239 EmailBackend: "stdout",
240 SMTP: SMTPConfig{
241 Addr: "127.0.0.1:1025",
242 },
243 Argon2: Argon2Config{
244 MemoryKiB: 64 * 1024,
245 Time: 3,
246 Threads: 2,
247 },
248 },
249 }
250 }
251
252 // Load resolves configuration in the documented precedence order. CLI
253 // overrides may be nil. The TOML file is optional — its absence is not an
254 // error; its existence with bad syntax IS.
255 func Load(cliOverrides map[string]string) (Config, error) {
256 cfg := Defaults()
257 if err := mergeFile(&cfg); err != nil {
258 return cfg, err
259 }
260 if err := mergeEnv(&cfg, os.Environ()); err != nil {
261 return cfg, err
262 }
263 if err := mergeFlags(&cfg, cliOverrides); err != nil {
264 return cfg, err
265 }
266 applyAliases(&cfg)
267 if err := Validate(&cfg); err != nil {
268 return cfg, err
269 }
270 return cfg, nil
271 }
272
273 // applyAliases honors well-known env-var aliases that don't follow the
274 // nested-key convention. SHITHUB_DATABASE_URL is the S01-era name for
275 // db.url and remains supported.
276 func applyAliases(cfg *Config) {
277 if cfg.DB.URL == "" {
278 if v := os.Getenv("SHITHUB_DATABASE_URL"); v != "" {
279 cfg.DB.URL = v
280 }
281 }
282 if cfg.Session.KeyB64 == "" {
283 if v := os.Getenv("SHITHUB_SESSION_KEY"); v != "" {
284 cfg.Session.KeyB64 = v
285 }
286 }
287 if cfg.Auth.TOTPKeyB64 == "" {
288 if v := os.Getenv("SHITHUB_TOTP_KEY"); v != "" {
289 cfg.Auth.TOTPKeyB64 = v
290 }
291 }
292 }
293
294 // Validate enforces invariants. Errors are precise enough to point at the
295 // offending key.
296 func Validate(c *Config) error {
297 switch strings.ToLower(c.Env) {
298 case "dev", "staging", "prod":
299 c.Env = strings.ToLower(c.Env)
300 default:
301 return fmt.Errorf("config: env: must be dev|staging|prod, got %q", c.Env)
302 }
303 switch strings.ToLower(c.Log.Level) {
304 case "debug", "info", "warn", "error":
305 c.Log.Level = strings.ToLower(c.Log.Level)
306 default:
307 return fmt.Errorf("config: log.level: must be debug|info|warn|error, got %q", c.Log.Level)
308 }
309 switch strings.ToLower(c.Log.Format) {
310 case "text", "json":
311 c.Log.Format = strings.ToLower(c.Log.Format)
312 default:
313 return fmt.Errorf("config: log.format: must be text|json, got %q", c.Log.Format)
314 }
315 if c.Web.Addr == "" {
316 return errors.New("config: web.addr is required")
317 }
318 if c.Tracing.Enabled && c.Tracing.Endpoint == "" {
319 return errors.New("config: tracing.endpoint is required when tracing.enabled=true")
320 }
321 if c.Tracing.SampleRate < 0 || c.Tracing.SampleRate > 1 {
322 return fmt.Errorf("config: tracing.sample_rate: must be in [0, 1], got %v", c.Tracing.SampleRate)
323 }
324 if c.Storage.ReposRoot == "" {
325 return errors.New("config: storage.repos_root is required")
326 }
327 if err := validateS3(c.Storage.S3); err != nil {
328 return err
329 }
330 switch c.Auth.EmailBackend {
331 case "stdout", "smtp", "postmark":
332 default:
333 return fmt.Errorf("config: auth.email_backend: must be stdout|smtp|postmark, got %q", c.Auth.EmailBackend)
334 }
335 if c.Auth.EmailBackend == "smtp" && c.Auth.SMTP.Addr == "" {
336 return errors.New("config: auth.smtp.addr is required when email_backend=smtp")
337 }
338 if c.Auth.EmailBackend == "postmark" && c.Auth.Postmark.ServerToken == "" {
339 return errors.New("config: auth.postmark.server_token is required when email_backend=postmark")
340 }
341 if c.Auth.BaseURL == "" {
342 return errors.New("config: auth.base_url is required (used in email links)")
343 }
344 if c.Auth.SiteName == "" {
345 return errors.New("config: auth.site_name is required")
346 }
347 if c.Auth.EmailFrom == "" {
348 return errors.New("config: auth.email_from is required")
349 }
350 if c.RateLimit.API.AuthedPerHour < 0 {
351 return fmt.Errorf("config: ratelimit.api.authed_per_hour: must be >= 0, got %d", c.RateLimit.API.AuthedPerHour)
352 }
353 if c.RateLimit.API.AnonPerHour < 0 {
354 return fmt.Errorf("config: ratelimit.api.anon_per_hour: must be >= 0, got %d", c.RateLimit.API.AnonPerHour)
355 }
356 if c.RateLimit.API.AuthedPerHour == 0 {
357 c.RateLimit.API.AuthedPerHour = 5000
358 }
359 if c.RateLimit.API.AnonPerHour == 0 {
360 c.RateLimit.API.AnonPerHour = 60
361 }
362 return nil
363 }
364
365 // validateS3 enforces all-or-nothing on the S3 block: if any of
366 // endpoint/bucket/access keys are set, all must be set.
367 func validateS3(s S3StorageConfig) error {
368 any := s.Endpoint != "" || s.Bucket != "" || s.AccessKeyID != "" || s.SecretAccessKey != ""
369 if !any {
370 return nil
371 }
372 missing := []string{}
373 if s.Endpoint == "" {
374 missing = append(missing, "endpoint")
375 }
376 if s.Bucket == "" {
377 missing = append(missing, "bucket")
378 }
379 if s.AccessKeyID == "" {
380 missing = append(missing, "access_key_id")
381 }
382 if s.SecretAccessKey == "" {
383 missing = append(missing, "secret_access_key")
384 }
385 if len(missing) > 0 {
386 return fmt.Errorf("config: storage.s3: incomplete configuration, missing: %s", strings.Join(missing, ", "))
387 }
388 return nil
389 }
390
391 // mergeFile reads the TOML file (when present) over cfg.
392 func mergeFile(cfg *Config) error {
393 path := os.Getenv("SHITHUB_CONFIG")
394 if path == "" {
395 path = "/etc/shithub/config.toml"
396 }
397 body, err := os.ReadFile(path) //nolint:gosec // operator-supplied path
398 if err != nil {
399 if errors.Is(err, os.ErrNotExist) {
400 return nil
401 }
402 return fmt.Errorf("config: read %s: %w", path, err)
403 }
404 if _, err := toml.Decode(string(body), cfg); err != nil {
405 return fmt.Errorf("config: parse %s: %w", path, err)
406 }
407 return nil
408 }
409
410 // mergeEnv overrides cfg from environment variables. Naming convention:
411 // SHITHUB_<area>__<key> (double-underscore separates nested levels).
412 // Single-segment keys also accept SHITHUB_<key>.
413 func mergeEnv(cfg *Config, environ []string) error {
414 envMap := make(map[string]string, len(environ))
415 for _, kv := range environ {
416 if !strings.HasPrefix(kv, "SHITHUB_") {
417 continue
418 }
419 eq := strings.IndexByte(kv, '=')
420 if eq < 0 {
421 continue
422 }
423 key := strings.TrimPrefix(kv[:eq], "SHITHUB_")
424 envMap[key] = kv[eq+1:]
425 }
426 return walkAndApply(reflect.ValueOf(cfg).Elem(), reflect.TypeOf(*cfg), "", envMap)
427 }
428
429 // mergeFlags applies CLI overrides. Keys use TOML notation
430 // ("web.addr", "tracing.endpoint", etc.).
431 func mergeFlags(cfg *Config, overrides map[string]string) error {
432 if len(overrides) == 0 {
433 return nil
434 }
435 envStyle := make(map[string]string, len(overrides))
436 for k, v := range overrides {
437 envStyle[strings.ToUpper(strings.ReplaceAll(k, ".", "__"))] = v
438 }
439 return walkAndApply(reflect.ValueOf(cfg).Elem(), reflect.TypeOf(*cfg), "", envStyle)
440 }
441
442 // walkAndApply walks struct fields recursively, applying values from src
443 // keyed by uppercased dot-then-double-underscore-joined paths.
444 func walkAndApply(v reflect.Value, t reflect.Type, prefix string, src map[string]string) error {
445 for i := 0; i < t.NumField(); i++ {
446 field := t.Field(i)
447 tag := field.Tag.Get("toml")
448 if tag == "" || tag == "-" {
449 continue
450 }
451 fieldPath := strings.ToUpper(tag)
452 if prefix != "" {
453 fieldPath = prefix + "__" + fieldPath
454 }
455 fv := v.Field(i)
456
457 if field.Type.Kind() == reflect.Struct && field.Type != reflect.TypeOf(time.Duration(0)) {
458 if err := walkAndApply(fv, field.Type, fieldPath, src); err != nil {
459 return err
460 }
461 continue
462 }
463
464 raw, ok := src[fieldPath]
465 if !ok {
466 continue
467 }
468 if err := setField(fv, field.Type, raw); err != nil {
469 return fmt.Errorf("config: %s: %w", strings.ReplaceAll(strings.ToLower(fieldPath), "__", "."), err)
470 }
471 }
472 return nil
473 }
474
475 func setField(v reflect.Value, t reflect.Type, raw string) error {
476 switch t.Kind() {
477 case reflect.String:
478 v.SetString(raw)
479 case reflect.Bool:
480 b, err := strconv.ParseBool(raw)
481 if err != nil {
482 return fmt.Errorf("invalid bool: %w", err)
483 }
484 v.SetBool(b)
485 case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
486 if t == reflect.TypeOf(time.Duration(0)) {
487 d, err := time.ParseDuration(raw)
488 if err != nil {
489 return fmt.Errorf("invalid duration: %w", err)
490 }
491 v.SetInt(int64(d))
492 return nil
493 }
494 n, err := strconv.ParseInt(raw, 10, 64)
495 if err != nil {
496 return fmt.Errorf("invalid int: %w", err)
497 }
498 v.SetInt(n)
499 case reflect.Float32, reflect.Float64:
500 f, err := strconv.ParseFloat(raw, 64)
501 if err != nil {
502 return fmt.Errorf("invalid float: %w", err)
503 }
504 v.SetFloat(f)
505 default:
506 return fmt.Errorf("unsupported field kind %s", t.Kind())
507 }
508 return nil
509 }
510