1package app
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strings"
8
9 "github.com/bnema/zerowrap"
10 "github.com/spf13/viper"
11
12 "github.com/bnema/gordon/internal/adapters/out/telemetry"
13 "github.com/bnema/gordon/internal/domain"
14 "github.com/bnema/gordon/internal/usecase/publictls"
15 servicecfg "github.com/bnema/gordon/internal/usecase/services"
16 "github.com/bnema/gordon/internal/usecase/traffic"
17)
18
19// Config holds the application configuration.
20type Config struct {
21 Server struct {
22 Port int `mapstructure:"port"`
23 RegistryPort int `mapstructure:"registry_port"`
24 GordonDomain string `mapstructure:"gordon_domain"`
25 RegistryDomain string `mapstructure:"registry_domain"`
26 LegacyRegistryDomains []string `mapstructure:"legacy_registry_domains"`
27 TLSPort int `mapstructure:"tls_port"`
28 TLSCertFile string `mapstructure:"tls_cert_file"`
29 TLSKeyFile string `mapstructure:"tls_key_file"`
30 ForceHTTPSRedirect bool `mapstructure:"force_https_redirect"`
31 DataDir string `mapstructure:"data_dir"`
32 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
33 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
34 MaxBlobSize string `mapstructure:"max_blob_size"` // e.g., "1GB", "2GB"
35 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
36 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
37 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
38 ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"`
39 RegistryListenAddr string `mapstructure:"registry_listen_address"`
40 } `mapstructure:"server"`
41
42 Apps struct {
43 // RevisionRetention bounds unreferenced-revision GC per app.
44 // Zero or negative restores the compiled default (8).
45 RevisionRetention int `mapstructure:"revision_retention"`
46 } `mapstructure:"apps"`
47
48 // AppMounts declares administrative bind policies keyed by stable mount
49 // name, referenced by name from an app manifest's [[services.<name>.bind]].
50 AppMounts map[string]AppMountPolicy `mapstructure:"app_mounts"`
51
52 // AppDevices declares administrative device grants keyed by stable
53 // logical device name, referenced by name from an app manifest's
54 // `devices` list.
55 AppDevices map[string]AppDevicePolicy `mapstructure:"app_devices"`
56
57 Logging struct {
58 Level string `mapstructure:"level"`
59 Format string `mapstructure:"format"`
60 File struct {
61 Enabled bool `mapstructure:"enabled"`
62 Path string `mapstructure:"path"`
63 MaxSize int `mapstructure:"max_size"`
64 MaxBackups int `mapstructure:"max_backups"`
65 MaxAge int `mapstructure:"max_age"`
66 } `mapstructure:"file"`
67 ContainerLogs struct {
68 Enabled bool `mapstructure:"enabled"`
69 Dir string `mapstructure:"dir"`
70 MaxSize int `mapstructure:"max_size"`
71 MaxBackups int `mapstructure:"max_backups"`
72 MaxAge int `mapstructure:"max_age"`
73 } `mapstructure:"container_logs"`
74 AccessLog struct {
75 Enabled bool `mapstructure:"enabled"`
76 Format string `mapstructure:"format"`
77 Output string `mapstructure:"output"`
78 FilePath string `mapstructure:"file_path"`
79 MaxSize int `mapstructure:"max_size"`
80 MaxBackups int `mapstructure:"max_backups"`
81 MaxAge int `mapstructure:"max_age"`
82 ExcludeHealthChecks bool `mapstructure:"exclude_health_checks"`
83 SyslogIdentifier string `mapstructure:"syslog_identifier"`
84 } `mapstructure:"access_log"`
85 } `mapstructure:"logging"`
86
87 Volumes struct {
88 AutoCreate bool `mapstructure:"auto_create"`
89 Prefix string `mapstructure:"prefix"`
90 Preserve bool `mapstructure:"preserve"`
91 } `mapstructure:"volumes"`
92
93 Auth struct {
94 Enabled bool `mapstructure:"enabled"`
95 Type string `mapstructure:"type"` // only "token" is supported
96 SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
97 Username string `mapstructure:"username"`
98 TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
99 TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
100 AccessTokenTTL string `mapstructure:"access_token_ttl"` // e.g., "15m", "30m" (default: 15m)
101 } `mapstructure:"auth"`
102
103 API struct {
104 RateLimit struct {
105 Enabled bool `mapstructure:"enabled"`
106 GlobalRPS float64 `mapstructure:"global_rps"`
107 PerIPRPS float64 `mapstructure:"per_ip_rps"`
108 Burst int `mapstructure:"burst"`
109 TrustedProxies []string `mapstructure:"trusted_proxies"`
110 } `mapstructure:"rate_limit"`
111 } `mapstructure:"api"`
112
113 EntryPoints map[string]traffic.EntryPointConfig `mapstructure:"entrypoints"`
114 Traffic traffic.Config `mapstructure:"traffic"`
115 NetworkServices []traffic.NetworkServiceConfig `mapstructure:"network_services"`
116 Services []servicecfg.Config `mapstructure:"services"`
117
118 Backups struct {
119 // Legacy database backup keys. Prefer backups.databases.* for new configs.
120 Enabled bool `mapstructure:"enabled"`
121 Schedule string `mapstructure:"schedule"`
122 StorageDir string `mapstructure:"storage_dir"`
123 Retention struct {
124 Hourly int `mapstructure:"hourly"`
125 Daily int `mapstructure:"daily"`
126 Weekly int `mapstructure:"weekly"`
127 Monthly int `mapstructure:"monthly"`
128 } `mapstructure:"retention"`
129 Databases struct {
130 Enabled bool `mapstructure:"enabled"`
131 Schedule string `mapstructure:"schedule"`
132 StorageDir string `mapstructure:"storage_dir"`
133 Retention struct {
134 Hourly int `mapstructure:"hourly"`
135 Daily int `mapstructure:"daily"`
136 Weekly int `mapstructure:"weekly"`
137 Monthly int `mapstructure:"monthly"`
138 } `mapstructure:"retention"`
139 } `mapstructure:"databases"`
140 Volumes struct {
141 Enabled bool `mapstructure:"enabled"`
142 Interval string `mapstructure:"interval"`
143 Compression string `mapstructure:"compression"`
144 Timeout string `mapstructure:"timeout"`
145 MaxConcurrency int `mapstructure:"max_concurrency"`
146 HelperImage string `mapstructure:"helper_image"`
147 S3 struct {
148 Bucket string `mapstructure:"bucket"`
149 Region string `mapstructure:"region"`
150 Prefix string `mapstructure:"prefix"`
151 Endpoint string `mapstructure:"endpoint"`
152 PathStyle bool `mapstructure:"path_style"`
153 SSEAlgorithm string `mapstructure:"sse_algorithm"`
154 SSEKMSKeyID string `mapstructure:"sse_kms_key_id"`
155 } `mapstructure:"s3"`
156 Retention struct {
157 Keep int `mapstructure:"keep"`
158 } `mapstructure:"retention"`
159 } `mapstructure:"volumes"`
160 } `mapstructure:"backups"`
161
162 Images struct {
163 AllowedRegistries []string `mapstructure:"allowed_registries"`
164 RequireDigest bool `mapstructure:"require_digest"`
165 Prune struct {
166 Enabled bool `mapstructure:"enabled"`
167 Schedule string `mapstructure:"schedule"`
168 KeepLast int `mapstructure:"keep_last"`
169 } `mapstructure:"prune"`
170 } `mapstructure:"images"`
171
172 Containers struct {
173 MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"
174 CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core
175 PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512
176 SecurityProfile string `mapstructure:"security_profile"` // compat or strict
177 } `mapstructure:"containers"`
178
179 NetworkIsolation struct {
180 Enabled bool `mapstructure:"enabled"`
181 Prefix string `mapstructure:"network_prefix"`
182 Internal bool `mapstructure:"internal"`
183 } `mapstructure:"network_isolation"`
184
185 Telemetry telemetry.Config `mapstructure:"telemetry"`
186
187 TLS struct {
188 ACME struct {
189 Enabled bool `mapstructure:"enabled"`
190 Email string `mapstructure:"email"`
191 Challenge string `mapstructure:"challenge"`
192 ObtainBatchSize int `mapstructure:"obtain_batch_size"`
193 } `mapstructure:"acme"`
194 } `mapstructure:"tls"`
195
196 DNS struct {
197 Resolvers []string `mapstructure:"resolvers"`
198 PropagationTimeout string `mapstructure:"propagation_timeout"`
199 PollingInterval string `mapstructure:"polling_interval"`
200 } `mapstructure:"dns"`
201}
202
203func warnDeprecatedConfigKeys(v *viper.Viper, log zerowrap.Logger) {
204 for _, key := range []string{"server.tls_enabled", "server.force_hsts"} {
205 if v.IsSet(key) {
206 log.Warn().Str("key", key).Msg("deprecated config key — Gordon now uses an internal CA with automatic TLS; remove this from your config")
207 }
208 }
209}
210
211// initConfig loads configuration from file.
212func initConfig(configPath string) (*viper.Viper, Config, error) {
213 v := viper.New()
214 if err := loadConfig(v, configPath); err != nil {
215 return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
216 }
217
218 var cfg Config
219 if err := v.Unmarshal(&cfg); err != nil {
220 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
221 }
222 if err := validateEntrypointMigration(v, cfg); err != nil {
223 return nil, Config{}, err
224 }
225 if err := validateRetiredAppConfig(v); err != nil {
226 return nil, Config{}, err
227 }
228 if _, err := buildAppMountPolicies(cfg); err != nil {
229 return nil, Config{}, err
230 }
231 if err := validateDeviceConfig(v, cfg); err != nil {
232 return nil, Config{}, err
233 }
234
235 return v, cfg, nil
236}
237
238// retiredAppConfigKeys are pre-v3 application keys removed with the
239// declarative-apps cutover. Presence fails boot/reload closed with a
240// config-retired diagnostic naming the fix — never a silent migration.
241// Keys that remain live installation-level settings are deliberately
242// absent: top-level "services" still declares standalone L4 workloads
243// (see config.services), so it must never be rejected here.
244var retiredAppConfigKeys = []struct {
245 key string
246 hint string
247}{
248 {"routes", "declare [[services.<name>.http]] in an app file, then 'gordon apps apply'"},
249 {"attachments", "declare [services.<name>] + volumes; attachments are removed"},
250 {"network_groups", "declare [[network.shared]] with ownership verification"},
251 {"service_routes", "declare [[services.<name>.http]] instead"},
252 {"auto", "feature removed; declare explicit interfaces"},
253 {"auto_route", "feature removed; declare explicit interfaces"},
254 {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"},
255 {"previews", "staging is an ordinary app file"},
256 {"env", "remove the [env] section; declare [env] and [services.<name>.secrets] in app files"},
257}
258
259// validateRetiredAppConfig rejects obsolete application configuration
260// BEFORE any app/runtime mutation, at startup and reload. InConfig
261// (not IsSet) targets explicit file keys only — installation defaults
262// registered via SetDefault must not trip the rejection.
263func validateRetiredAppConfig(v *viper.Viper) error {
264 diagnostics := make([]string, 0, len(retiredAppConfigKeys))
265 for _, retired := range retiredAppConfigKeys {
266 if v.InConfig(retired.key) {
267 diagnostics = append(diagnostics, fmt.Sprintf("key %q was removed in v3; %s", retired.key, retired.hint))
268 }
269 }
270 if len(diagnostics) > 0 {
271 return fmt.Errorf("config-retired: %s", strings.Join(diagnostics, "; "))
272 }
273 return nil
274}
275
276func validateEntrypointMigration(v *viper.Viper, cfg Config) error {
277 if len(cfg.EntryPoints) > 0 {
278 return nil
279 }
280
281 legacyKeys := make([]string, 0, 2)
282 for _, key := range []string{"server.port", "server.tls_port"} {
283 if v.IsSet(key) {
284 legacyKeys = append(legacyKeys, key)
285 }
286 }
287 if len(legacyKeys) == 0 {
288 return nil
289 }
290
291 return fmt.Errorf("legacy %s configuration requires at least one [entrypoints] entry; see docs/upgrading.md", strings.Join(legacyKeys, " or "))
292}
293
294// resolveLogFilePath returns the configured log file path or a default.
295func resolveLogFilePath(cfg Config) string {
296 if cfg.Logging.File.Path != "" {
297 return cfg.Logging.File.Path
298 }
299 dataDir := cfg.Server.DataDir
300 if dataDir == "" {
301 dataDir = DefaultDataDir()
302 }
303 return filepath.Join(dataDir, "logs", "gordon.log")
304}
305
306// resolveRuntimeConfig converts a server.runtime config value to a socket path.
307// "auto" or "" means auto-detect.
308// Named runtimes ("podman", "docker") are resolved to well-known socket paths.
309// URI schemes (unix://) are stripped so callers receive a bare path.
310func resolveRuntimeConfig(value string) string {
311 if value == "" || value == "auto" {
312 return ""
313 }
314 // Named runtimes: resolve to well-known socket paths.
315 switch value {
316 case "podman":
317 // Check XDG_RUNTIME_DIR first (rootless Podman).
318 if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
319 candidate := filepath.Join(xdg, "podman", "podman.sock")
320 if _, err := os.Stat(candidate); err == nil {
321 return candidate
322 }
323 }
324 // Fallback to system-wide Podman socket.
325 return "/run/podman/podman.sock"
326 case "docker":
327 return "/var/run/docker.sock"
328 }
329 // Explicit socket path — strip URI scheme if present.
330 if socketPath, ok := strings.CutPrefix(value, "unix://"); ok {
331 return socketPath
332 }
333 return value
334}
335
336func resolveDataDir(dataDir string) string {
337 if dataDir == "" {
338 return DefaultDataDir()
339 }
340 return dataDir
341}
342
343func resolveRegistryDomains(cfg Config) (string, []string) {
344 registryDomain := cfg.Server.RegistryDomain
345 if registryDomain == "" {
346 registryDomain = cfg.Server.GordonDomain
347 }
348 return registryDomain, append([]string{}, cfg.Server.LegacyRegistryDomains...)
349}
350
351// loadConfig loads configuration from file and sets defaults.
352func loadConfig(v *viper.Viper, configPath string) error {
353 v.SetDefault("server.registry_port", 5000)
354 v.SetDefault("server.legacy_registry_domains", []string{})
355 v.SetDefault("server.tls_cert_file", "")
356 v.SetDefault("server.tls_key_file", "")
357 v.SetDefault("tls.acme.enabled", false)
358 v.SetDefault("tls.acme.email", "")
359 v.SetDefault("tls.acme.challenge", "auto")
360 v.SetDefault("tls.acme.obtain_batch_size", 1)
361 v.SetDefault("dns.resolvers", publictls.DefaultDNSResolvers)
362 v.SetDefault("dns.propagation_timeout", "5m")
363 v.SetDefault("dns.polling_interval", "5s")
364 v.SetDefault("server.force_https_redirect", false)
365 v.SetDefault("server.data_dir", DefaultDataDir())
366 v.SetDefault("server.runtime", "auto")
367 v.SetDefault("logging.level", "info")
368 v.SetDefault("logging.format", "console")
369 v.SetDefault("logging.file.enabled", false)
370 v.SetDefault("logging.file.max_size", 100)
371 v.SetDefault("logging.file.max_backups", 3)
372 v.SetDefault("logging.file.max_age", 28)
373 v.SetDefault("logging.container_logs.enabled", true)
374 v.SetDefault("logging.container_logs.dir", "")
375 v.SetDefault("logging.container_logs.max_size", 100)
376 v.SetDefault("logging.container_logs.max_backups", 3)
377 v.SetDefault("logging.container_logs.max_age", 28)
378 v.SetDefault("logging.access_log.enabled", false)
379 v.SetDefault("logging.access_log.format", "json")
380 v.SetDefault("logging.access_log.output", "stdout")
381 v.SetDefault("logging.access_log.file_path", "")
382 v.SetDefault("logging.access_log.max_size", 100)
383 v.SetDefault("logging.access_log.max_backups", 3)
384 v.SetDefault("logging.access_log.max_age", 28)
385 v.SetDefault("logging.access_log.exclude_health_checks", true)
386 v.SetDefault("logging.access_log.syslog_identifier", "gordon-access")
387 v.SetDefault("auth.enabled", true)
388 // Note: auth.type defaults to "token" (the only supported mode)
389 v.SetDefault("auth.secrets_backend", "")
390 v.SetDefault("auth.token_expiry", "720h")
391 v.SetDefault("api.rate_limit.enabled", true)
392 v.SetDefault("api.rate_limit.global_rps", 500)
393 v.SetDefault("api.rate_limit.per_ip_rps", 50)
394 v.SetDefault("api.rate_limit.burst", 100)
395 v.SetDefault("auto_route.enabled", false)
396 v.SetDefault("network_isolation.enabled", true)
397 v.SetDefault("network_isolation.network_prefix", "gordon")
398 v.SetDefault("network_isolation.internal", false)
399 v.SetDefault("volumes.auto_create", true)
400 v.SetDefault("volumes.prefix", "gordon")
401 v.SetDefault("volumes.preserve", true)
402 v.SetDefault("backups.databases.enabled", false)
403 v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily))
404 v.SetDefault("backups.databases.storage_dir", "")
405 v.SetDefault("backups.databases.retention.hourly", 0)
406 v.SetDefault("backups.databases.retention.daily", 0)
407 v.SetDefault("backups.databases.retention.weekly", 0)
408 v.SetDefault("backups.databases.retention.monthly", 0)
409 v.SetDefault("backups.volumes.enabled", false)
410 v.SetDefault("backups.volumes.interval", "24h")
411 v.SetDefault("backups.volumes.compression", string(domain.VolumeBackupCompressionGzip))
412 v.SetDefault("backups.volumes.timeout", "2h")
413 v.SetDefault("backups.volumes.max_concurrency", 2)
414 v.SetDefault("backups.volumes.helper_image", "alpine:3.20")
415 v.SetDefault("backups.volumes.s3.bucket", "")
416 v.SetDefault("backups.volumes.s3.region", "")
417 v.SetDefault("backups.volumes.s3.prefix", "")
418 v.SetDefault("backups.volumes.s3.endpoint", "")
419 v.SetDefault("backups.volumes.s3.path_style", false)
420 v.SetDefault("backups.volumes.s3.sse_algorithm", "")
421 v.SetDefault("backups.volumes.s3.sse_kms_key_id", "")
422 v.SetDefault("backups.volumes.retention.keep", 14)
423 v.SetDefault("images.allowed_registries", []string{})
424 v.SetDefault("images.require_digest", false)
425 v.SetDefault("images.prune.enabled", false)
426 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
427 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
428 v.SetDefault("containers.security_profile", "compat")
429 v.SetDefault("telemetry.enabled", false)
430 v.SetDefault("telemetry.endpoint", "")
431 v.SetDefault("telemetry.auth_token", "")
432 v.SetDefault("telemetry.traces", true)
433 v.SetDefault("telemetry.metrics", true)
434 v.SetDefault("telemetry.logs", true)
435 v.SetDefault("telemetry.trace_sample_rate", 1.0)
436
437 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
438 v.SetDefault("server.registry_allowed_ips", []string{})
439 v.SetDefault("server.proxy_allowed_ips", []string{})
440 v.SetDefault("server.registry_listen_address", "")
441
442 ConfigureViper(v, configPath)
443
444 if err := v.ReadInConfig(); err != nil {
445 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
446 return fmt.Errorf("failed to read config file: %w", err)
447 }
448 }
449
450 v.SetEnvPrefix("GORDON")
451 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
452 v.AutomaticEnv()
453
454 return nil
455}