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