| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "strings" |
| 7 | |
| 8 | "github.com/spf13/viper" |
| 9 | ) |
| 10 | |
| 11 | // ResolveValue resolves a configuration value. If it starts with "@", |
| 12 | // the remainder is treated as a file path and the file contents are returned. |
| 13 | // Otherwise the value is returned as-is. |
| 14 | func ResolveValue(value string) (string, error) { |
| 15 | path, ok := strings.CutPrefix(value, "@") |
| 16 | if !ok { |
| 17 | return value, nil |
| 18 | } |
| 19 | |
| 20 | data, err := os.ReadFile(path) |
| 21 | if err != nil { |
| 22 | return "", fmt.Errorf("read %s: %w", path, err) |
| 23 | } |
| 24 | return strings.TrimSpace(string(data)), nil |
| 25 | } |
| 26 | |
| 27 | // Module represents a Go module configuration. |
| 28 | type Module struct { |
| 29 | VCS string `json:"vcs"` |
| 30 | Repo string `json:"repo"` |
| 31 | Web string `json:"web"` |
| 32 | Private bool `json:"private,omitempty"` |
| 33 | } |
| 34 | |
| 35 | // FallbackMode controls behavior for modules not defined in config. |
| 36 | type FallbackMode string |
| 37 | |
| 38 | const ( |
| 39 | FallbackNone FallbackMode = "" |
| 40 | |
| 41 | FallbackRedirect FallbackMode = "redirect" // 302 redirect to upstream |
| 42 | FallbackSync FallbackMode = "sync" // fetch upstream, cache synchronously, then serve |
| 43 | FallbackAsync FallbackMode = "async" // fetch upstream, serve immediately, cache in background |
| 44 | ) |
| 45 | |
| 46 | // RequiresUpstream reports whether the mode needs an upstream URL. |
| 47 | func (m FallbackMode) RequiresUpstream() bool { |
| 48 | switch m { |
| 49 | case FallbackRedirect, FallbackSync, FallbackAsync: |
| 50 | return true |
| 51 | } |
| 52 | return false |
| 53 | } |
| 54 | |
| 55 | type FallbackConfig struct { |
| 56 | Mode FallbackMode `mapstructure:"mode"` |
| 57 | Upstream string `mapstructure:"upstream"` |
| 58 | } |
| 59 | |
| 60 | // S3Config is kept for backward compatibility with NewS3Storage. |
| 61 | type S3Config struct { |
| 62 | Endpoint string `mapstructure:"endpoint"` |
| 63 | Bucket string `mapstructure:"bucket"` |
| 64 | Region string `mapstructure:"region"` |
| 65 | UsePathStyle bool `mapstructure:"use_path_style"` |
| 66 | } |
| 67 | |
| 68 | // StorageConfig supports multiple storage backends. |
| 69 | type StorageConfig struct { |
| 70 | Type string `mapstructure:"type"` // "memory", "disk", "s3" (default: "s3") |
| 71 | Root string `mapstructure:"root"` // disk only |
| 72 | Endpoint string `mapstructure:"endpoint"` // s3 only |
| 73 | Bucket string `mapstructure:"bucket"` // s3 only |
| 74 | Region string `mapstructure:"region"` // s3 only |
| 75 | UsePathStyle bool `mapstructure:"use_path_style"` // s3 only: path-style addressing for Garage, MinIO, etc. |
| 76 | MaxBytes int64 `mapstructure:"max_bytes"` // memory only (0 = unlimited) |
| 77 | } |
| 78 | |
| 79 | // S3 returns S3Config extracted from StorageConfig. |
| 80 | func (c *StorageConfig) S3() S3Config { |
| 81 | return S3Config{Endpoint: c.Endpoint, Bucket: c.Bucket, Region: c.Region, UsePathStyle: c.UsePathStyle} |
| 82 | } |
| 83 | |
| 84 | type SumdbConfig struct { |
| 85 | Enabled bool `mapstructure:"enabled"` |
| 86 | Key string `mapstructure:"key"` |
| 87 | Upstream string `mapstructure:"upstream"` // upstream sumdb URL for non-local lookups |
| 88 | } |
| 89 | |
| 90 | // OIDCConfig configures OpenID Connect authentication for the admin UI. |
| 91 | type OIDCConfig struct { |
| 92 | Issuer string `mapstructure:"issuer"` |
| 93 | ClientID string `mapstructure:"client_id"` |
| 94 | ClientSecret string `mapstructure:"client_secret"` // supports @file |
| 95 | RedirectURL string `mapstructure:"redirect_url"` |
| 96 | } |
| 97 | |
| 98 | type InstrumentationConfig struct { |
| 99 | Listen string `mapstructure:"listen"` |
| 100 | MetricsPath string `mapstructure:"metrics_path"` |
| 101 | HealthPath string `mapstructure:"health_path"` |
| 102 | } |
| 103 | |
| 104 | // DatabaseConfig configures the module store database backend. |
| 105 | type DatabaseConfig struct { |
| 106 | Type string `mapstructure:"type"` // "sqlite" (default), "postgres" |
| 107 | DSN string `mapstructure:"dsn"` // file path for sqlite, connection string for postgres |
| 108 | } |
| 109 | |
| 110 | type Config struct { |
| 111 | Host string `mapstructure:"host"` |
| 112 | Database *DatabaseConfig `mapstructure:"database"` |
| 113 | Storage *StorageConfig `mapstructure:"storage"` |
| 114 | Fallback *FallbackConfig `mapstructure:"fallback"` |
| 115 | Sumdb *SumdbConfig `mapstructure:"sumdb"` |
| 116 | Instrumentation *InstrumentationConfig `mapstructure:"instrumentation"` |
| 117 | OIDC *OIDCConfig `mapstructure:"oidc"` |
| 118 | Listen string `mapstructure:"listen"` |
| 119 | Cache string `mapstructure:"cache"` |
| 120 | AuthTokens string `mapstructure:"auth_tokens"` |
| 121 | AdminToken string `mapstructure:"admin_token"` |
| 122 | } |
| 123 | |
| 124 | // Dump returns a human-readable configuration summary with secrets masked. |
| 125 | func (cfg *Config) Dump() string { |
| 126 | var b strings.Builder |
| 127 | |
| 128 | fmt.Fprintf(&b, "host: %s\n", cfg.Host) |
| 129 | fmt.Fprintf(&b, "listen: %s\n", cfg.Listen) |
| 130 | fmt.Fprintf(&b, "cache: %s\n", cfg.Cache) |
| 131 | fmt.Fprintf(&b, "admin_token: %s\n", mask(cfg.AdminToken)) |
| 132 | fmt.Fprintf(&b, "auth_tokens: %s\n", mask(cfg.AuthTokens)) |
| 133 | |
| 134 | if cfg.Database != nil { |
| 135 | fmt.Fprintf(&b, "database:\n") |
| 136 | fmt.Fprintf(&b, " type: %s\n", cfg.Database.Type) |
| 137 | fmt.Fprintf(&b, " dsn: %s\n", maskDSN(cfg.Database.Type, cfg.Database.DSN)) |
| 138 | } |
| 139 | |
| 140 | if cfg.Storage != nil { |
| 141 | t := cfg.Storage.Type |
| 142 | if t == "" { |
| 143 | t = "s3" |
| 144 | } |
| 145 | fmt.Fprintf(&b, "storage:\n") |
| 146 | fmt.Fprintf(&b, " type: %s\n", t) |
| 147 | switch t { |
| 148 | case "s3": |
| 149 | fmt.Fprintf(&b, " endpoint: %s\n", cfg.Storage.Endpoint) |
| 150 | fmt.Fprintf(&b, " bucket: %s\n", cfg.Storage.Bucket) |
| 151 | fmt.Fprintf(&b, " region: %s\n", cfg.Storage.Region) |
| 152 | fmt.Fprintf(&b, " use_path_style: %v\n", cfg.Storage.UsePathStyle) |
| 153 | case "disk": |
| 154 | fmt.Fprintf(&b, " root: %s\n", cfg.Storage.Root) |
| 155 | case "memory": |
| 156 | fmt.Fprintf(&b, " max_bytes: %d\n", cfg.Storage.MaxBytes) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | if cfg.Fallback != nil { |
| 161 | fmt.Fprintf(&b, "fallback:\n") |
| 162 | fmt.Fprintf(&b, " mode: %s\n", cfg.Fallback.Mode) |
| 163 | fmt.Fprintf(&b, " upstream: %s\n", cfg.Fallback.Upstream) |
| 164 | } |
| 165 | |
| 166 | if cfg.Sumdb != nil { |
| 167 | fmt.Fprintf(&b, "sumdb:\n") |
| 168 | fmt.Fprintf(&b, " enabled: %v\n", cfg.Sumdb.Enabled) |
| 169 | fmt.Fprintf(&b, " key: %s\n", mask(cfg.Sumdb.Key)) |
| 170 | fmt.Fprintf(&b, " upstream: %s\n", cfg.Sumdb.Upstream) |
| 171 | } |
| 172 | |
| 173 | if cfg.Instrumentation != nil { |
| 174 | fmt.Fprintf(&b, "instrumentation:\n") |
| 175 | fmt.Fprintf(&b, " listen: %s\n", cfg.Instrumentation.Listen) |
| 176 | fmt.Fprintf(&b, " metrics_path: %s\n", cfg.Instrumentation.MetricsPath) |
| 177 | fmt.Fprintf(&b, " health_path: %s\n", cfg.Instrumentation.HealthPath) |
| 178 | } |
| 179 | |
| 180 | if cfg.OIDC != nil { |
| 181 | fmt.Fprintf(&b, "oidc:\n") |
| 182 | fmt.Fprintf(&b, " issuer: %s\n", cfg.OIDC.Issuer) |
| 183 | fmt.Fprintf(&b, " client_id: %s\n", cfg.OIDC.ClientID) |
| 184 | fmt.Fprintf(&b, " client_secret: %s\n", mask(cfg.OIDC.ClientSecret)) |
| 185 | fmt.Fprintf(&b, " redirect_url: %s\n", cfg.OIDC.RedirectURL) |
| 186 | } |
| 187 | |
| 188 | return b.String() |
| 189 | } |
| 190 | |
| 191 | func mask(s string) string { |
| 192 | if s == "" { |
| 193 | return "(not set)" |
| 194 | } |
| 195 | if strings.HasPrefix(s, "@") { |
| 196 | return s // file reference, safe to show |
| 197 | } |
| 198 | if len(s) <= 4 { |
| 199 | return "****" |
| 200 | } |
| 201 | return s[:2] + "****" + s[len(s)-2:] |
| 202 | } |
| 203 | |
| 204 | func maskDSN(dbType, dsn string) string { |
| 205 | if dbType == "sqlite" { |
| 206 | return dsn // file path, safe to show |
| 207 | } |
| 208 | // Mask password in connection strings like postgres://user:pass@host/db |
| 209 | if at := strings.Index(dsn, "@"); at > 0 { |
| 210 | prefix := dsn[:at] |
| 211 | if colon := strings.LastIndex(prefix, ":"); colon > 0 { |
| 212 | return prefix[:colon+1] + "****" + dsn[at:] |
| 213 | } |
| 214 | } |
| 215 | return dsn |
| 216 | } |
| 217 | |
| 218 | var validKeys = map[string]bool{ |
| 219 | "host": true, |
| 220 | "listen": true, |
| 221 | "cache": true, |
| 222 | "database": true, |
| 223 | "database.type": true, |
| 224 | "database.dsn": true, |
| 225 | "auth_tokens": true, |
| 226 | "admin_token": true, |
| 227 | "sumdb": true, |
| 228 | "sumdb.enabled": true, |
| 229 | "sumdb.key": true, |
| 230 | "sumdb.upstream": true, |
| 231 | "instrumentation": true, |
| 232 | "instrumentation.listen": true, |
| 233 | "instrumentation.metrics_path": true, |
| 234 | "instrumentation.health_path": true, |
| 235 | "storage": true, |
| 236 | "storage.type": true, |
| 237 | "storage.root": true, |
| 238 | "storage.endpoint": true, |
| 239 | "storage.bucket": true, |
| 240 | "storage.region": true, |
| 241 | "storage.use_path_style": true, |
| 242 | "storage.max_bytes": true, |
| 243 | "fallback": true, |
| 244 | "fallback.mode": true, |
| 245 | "fallback.upstream": true, |
| 246 | "oidc": true, |
| 247 | "oidc.issuer": true, |
| 248 | "oidc.client_id": true, |
| 249 | "oidc.client_secret": true, |
| 250 | "oidc.redirect_url": true, |
| 251 | } |
| 252 | |
| 253 | // Load reads configuration from a YAML file and environment variables. |
| 254 | func Load(path string) (*Config, error) { |
| 255 | v := viper.New() |
| 256 | |
| 257 | v.SetDefault("listen", ":8080") |
| 258 | v.SetDefault("cache", "./cache") |
| 259 | |
| 260 | // Bind each config key to its exact CURATOR_* env var. |
| 261 | // We avoid SetEnvKeyReplacer/AutomaticEnv because "." → "_" |
| 262 | // is ambiguous for keys with underscores in the leaf name |
| 263 | // (e.g., storage.use_path_style vs storage.use.path.style), |
| 264 | // and SetDefault for parent sections clobbers env-bound children. |
| 265 | for key := range validKeys { |
| 266 | // Skip parent section keys (e.g., "storage", "sumdb") — binding |
| 267 | // them to CURATOR_STORAGE (unset) makes Viper treat the whole |
| 268 | // section as nil, hiding env-bound child keys during Unmarshal. |
| 269 | if !strings.Contains(key, ".") { |
| 270 | continue |
| 271 | } |
| 272 | envVar := "CURATOR_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_")) |
| 273 | _ = v.BindEnv(key, envVar) |
| 274 | } |
| 275 | // Bind top-level keys that aren't sections. |
| 276 | for _, key := range []string{"host", "listen", "cache", "admin_token", "auth_tokens"} { |
| 277 | _ = v.BindEnv(key, "CURATOR_"+strings.ToUpper(key)) |
| 278 | } |
| 279 | |
| 280 | if path != "" { |
| 281 | v.SetConfigFile(path) |
| 282 | if err := v.ReadInConfig(); err != nil { |
| 283 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { |
| 284 | return nil, fmt.Errorf("read config: %w", err) |
| 285 | } |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | if err := validateKeys(v); err != nil { |
| 290 | return nil, err |
| 291 | } |
| 292 | |
| 293 | var cfg Config |
| 294 | if err := v.Unmarshal(&cfg); err != nil { |
| 295 | return nil, fmt.Errorf("parse config: %w", err) |
| 296 | } |
| 297 | |
| 298 | // Database defaults. |
| 299 | if cfg.Database == nil { |
| 300 | cfg.Database = &DatabaseConfig{} |
| 301 | } |
| 302 | if cfg.Database.Type == "" { |
| 303 | cfg.Database.Type = "sqlite" |
| 304 | } |
| 305 | |
| 306 | switch cfg.Database.Type { |
| 307 | case "sqlite": |
| 308 | if cfg.Database.DSN == "" { |
| 309 | cfg.Database.DSN = "curator.db" |
| 310 | } |
| 311 | case "postgres": |
| 312 | if cfg.Database.DSN == "" { |
| 313 | return nil, fmt.Errorf("config: database.dsn is required for postgres") |
| 314 | } |
| 315 | default: |
| 316 | return nil, fmt.Errorf("config: unknown database type %q (use sqlite or postgres)", cfg.Database.Type) |
| 317 | } |
| 318 | |
| 319 | if cfg.Host == "" { |
| 320 | return nil, fmt.Errorf("config: host is required (set via config file or CURATOR_HOST)") |
| 321 | } |
| 322 | |
| 323 | if cfg.Sumdb != nil && cfg.Sumdb.Enabled && cfg.Sumdb.Key == "" { |
| 324 | return nil, fmt.Errorf("config: sumdb.enabled is true but sumdb.key is not set") |
| 325 | } |
| 326 | |
| 327 | // Storage validation. |
| 328 | if cfg.Storage != nil { |
| 329 | switch cfg.Storage.Type { |
| 330 | case "s3", "": |
| 331 | if cfg.Storage.Bucket == "" { |
| 332 | return nil, fmt.Errorf("config: storage.bucket is required for s3 storage") |
| 333 | } |
| 334 | case "disk": |
| 335 | if cfg.Storage.Root == "" { |
| 336 | return nil, fmt.Errorf("config: storage.root is required for disk storage") |
| 337 | } |
| 338 | case "memory": |
| 339 | // no required fields |
| 340 | default: |
| 341 | return nil, fmt.Errorf("config: unknown storage type %q (use s3, disk, or memory)", cfg.Storage.Type) |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | // Fallback validation. |
| 346 | if cfg.Fallback != nil && cfg.Fallback.Mode.RequiresUpstream() && cfg.Fallback.Upstream == "" { |
| 347 | return nil, fmt.Errorf("config: fallback mode %q requires upstream URL", cfg.Fallback.Mode) |
| 348 | } |
| 349 | |
| 350 | // OIDC validation. |
| 351 | if cfg.OIDC != nil && cfg.OIDC.Issuer != "" { |
| 352 | if cfg.OIDC.ClientID == "" { |
| 353 | return nil, fmt.Errorf("config: oidc.client_id is required when oidc.issuer is set") |
| 354 | } |
| 355 | if cfg.OIDC.ClientSecret == "" { |
| 356 | return nil, fmt.Errorf("config: oidc.client_secret is required when oidc.issuer is set") |
| 357 | } |
| 358 | if cfg.OIDC.RedirectURL == "" { |
| 359 | return nil, fmt.Errorf("config: oidc.redirect_url is required when oidc.issuer is set") |
| 360 | } |
| 361 | } |
| 362 | |
| 363 | return &cfg, nil |
| 364 | } |
| 365 | |
| 366 | // FallbackMode returns the configured fallback mode, or "" if none. |
| 367 | func (cfg *Config) FallbackMode() FallbackMode { |
| 368 | if cfg.Fallback != nil { |
| 369 | return cfg.Fallback.Mode |
| 370 | } |
| 371 | return FallbackNone |
| 372 | } |
| 373 | |
| 374 | func validateKeys(v *viper.Viper) error { |
| 375 | for _, key := range v.AllKeys() { |
| 376 | if !validKeys[key] { |
| 377 | return fmt.Errorf("config: unknown key %q", key) |
| 378 | } |
| 379 | } |
| 380 | return nil |
| 381 | } |
| 382 | |