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 StorageClass string `mapstructure:"storage_class"`
165 } `mapstructure:"s3"`
166 Retention struct {
167 Keep int `mapstructure:"keep"`
168 } `mapstructure:"retention"`
169 } `mapstructure:"volumes"`
170 } `mapstructure:"backups"`
171
172 Images struct {
173 AllowedRegistries []string `mapstructure:"allowed_registries"`
174 RequireDigest bool `mapstructure:"require_digest"`
175 Prune struct {
176 Enabled bool `mapstructure:"enabled"`
177 Schedule string `mapstructure:"schedule"`
178 KeepLast int `mapstructure:"keep_last"`
179 } `mapstructure:"prune"`
180 } `mapstructure:"images"`
181
182 Containers struct {
183 MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"
184 CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core
185 PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512
186 SecurityProfile string `mapstructure:"security_profile"` // compat or strict
187 } `mapstructure:"containers"`
188
189 NetworkIsolation struct {
190 Enabled bool `mapstructure:"enabled"`
191 Prefix string `mapstructure:"network_prefix"`
192 Internal bool `mapstructure:"internal"`
193 } `mapstructure:"network_isolation"`
194
195 Telemetry telemetry.Config `mapstructure:"telemetry"`
196
197 TLS struct {
198 ACME struct {
199 Enabled bool `mapstructure:"enabled"`
200 Email string `mapstructure:"email"`
201 Challenge string `mapstructure:"challenge"`
202 ObtainBatchSize int `mapstructure:"obtain_batch_size"`
203 } `mapstructure:"acme"`
204 } `mapstructure:"tls"`
205
206 DNS struct {
207 Resolvers []string `mapstructure:"resolvers"`
208 PropagationTimeout string `mapstructure:"propagation_timeout"`
209 PollingInterval string `mapstructure:"polling_interval"`
210 } `mapstructure:"dns"`
211}
212
213func warnDeprecatedConfigKeys(v *viper.Viper, log zerowrap.Logger) {
214 for _, key := range []string{"server.tls_enabled", "server.force_hsts"} {
215 if v.IsSet(key) {
216 log.Warn().Str("key", key).Msg("deprecated config key — Gordon now uses an internal CA with automatic TLS; remove this from your config")
217 }
218 }
219}
220
221// initConfig loads configuration from file.
222func initConfig(configPath string) (*viper.Viper, Config, error) {
223 v := viper.New()
224 if err := loadConfig(v, configPath); err != nil {
225 return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
226 }
227
228 var cfg Config
229 if err := v.Unmarshal(&cfg); err != nil {
230 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
231 }
232 if err := validateEntrypointMigration(v, cfg); err != nil {
233 return nil, Config{}, err
234 }
235 if err := validateRetiredAppConfig(v); err != nil {
236 return nil, Config{}, err
237 }
238 if err := validateHTTPTimeouts(cfg); err != nil {
239 return nil, Config{}, err
240 }
241 if _, err := buildAppMountPolicies(cfg); err != nil {
242 return nil, Config{}, err
243 }
244 if err := validateDeviceConfig(v, cfg); err != nil {
245 return nil, Config{}, err
246 }
247
248 return v, cfg, nil
249}
250
251// retiredAppConfigKeys are pre-v3 application keys removed with the
252// declarative-apps cutover. Presence fails boot/reload closed with a
253// config-retired diagnostic naming the fix — never a silent migration.
254// Keys that remain live installation-level settings are deliberately
255// absent: top-level "services" still declares standalone L4 workloads
256// (see config.services), so it must never be rejected here.
257var retiredAppConfigKeys = []struct {
258 key string
259 hint string
260}{
261 {"routes", "declare [[services.<name>.http]] in an app file, then 'gordon apps apply'"},
262 {"attachments", "declare [services.<name>] + volumes; attachments are removed"},
263 {"network_groups", "declare [[network.shared]] with ownership verification"},
264 {"service_routes", "declare [[services.<name>.http]] instead"},
265 {"auto", "feature removed; declare explicit interfaces"},
266 {"auto_route", "feature removed; declare explicit interfaces"},
267 {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"},
268 {"previews", "staging is an ordinary app file"},
269 {"env", "remove the [env] section; declare [env] and [services.<name>.secrets] in app files"},
270}
271
272// validateRetiredAppConfig rejects obsolete application configuration
273// BEFORE any app/runtime mutation, at startup and reload. InConfig
274// (not IsSet) targets explicit file keys only — installation defaults
275// registered via SetDefault must not trip the rejection.
276func validateRetiredAppConfig(v *viper.Viper) error {
277 diagnostics := make([]string, 0, len(retiredAppConfigKeys))
278 for _, retired := range retiredAppConfigKeys {
279 if v.InConfig(retired.key) {
280 diagnostics = append(diagnostics, fmt.Sprintf("key %q was removed in v3; %s", retired.key, retired.hint))
281 }
282 }
283 if len(diagnostics) > 0 {
284 return fmt.Errorf("config-retired: %s", strings.Join(diagnostics, "; "))
285 }
286 return nil
287}
288
289func validateEntrypointMigration(v *viper.Viper, cfg Config) error {
290 if len(cfg.EntryPoints) > 0 {
291 return nil
292 }
293
294 legacyKeys := make([]string, 0, 2)
295 for _, key := range []string{"server.port", "server.tls_port"} {
296 if v.IsSet(key) {
297 legacyKeys = append(legacyKeys, key)
298 }
299 }
300 if len(legacyKeys) == 0 {
301 return nil
302 }
303
304 return fmt.Errorf("legacy %s configuration requires at least one [entrypoints] entry; see docs/upgrading.md", strings.Join(legacyKeys, " or "))
305}
306
307// resolveLogFilePath returns the configured log file path or a default.
308func resolveLogFilePath(cfg Config) string {
309 if cfg.Logging.File.Path != "" {
310 return cfg.Logging.File.Path
311 }
312 dataDir := cfg.Server.DataDir
313 if dataDir == "" {
314 dataDir = DefaultDataDir()
315 }
316 return filepath.Join(dataDir, "logs", "gordon.log")
317}
318
319// resolveRuntimeConfig converts a server.runtime config value to a socket path.
320// "auto" or "" means auto-detect.
321// Named runtimes ("podman", "docker") are resolved to well-known socket paths.
322// URI schemes (unix://) are stripped so callers receive a bare path.
323func resolveRuntimeConfig(value string) string {
324 if value == "" || value == "auto" {
325 return ""
326 }
327 // Named runtimes: resolve to well-known socket paths.
328 switch value {
329 case "podman":
330 // Check XDG_RUNTIME_DIR first (rootless Podman).
331 if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
332 candidate := filepath.Join(xdg, "podman", "podman.sock")
333 if _, err := os.Stat(candidate); err == nil {
334 return candidate
335 }
336 }
337 // Fallback to system-wide Podman socket.
338 return "/run/podman/podman.sock"
339 case "docker":
340 return "/var/run/docker.sock"
341 }
342 // Explicit socket path — strip URI scheme if present.
343 if socketPath, ok := strings.CutPrefix(value, "unix://"); ok {
344 return socketPath
345 }
346 return value
347}
348
349func resolveDataDir(dataDir string) string {
350 if dataDir == "" {
351 return DefaultDataDir()
352 }
353 return dataDir
354}
355
356func resolveRegistryDomains(cfg Config) (string, []string) {
357 registryDomain := cfg.Server.RegistryDomain
358 if registryDomain == "" {
359 registryDomain = cfg.Server.GordonDomain
360 }
361 return registryDomain, append([]string{}, cfg.Server.LegacyRegistryDomains...)
362}
363
364// loadConfig loads configuration from file and sets defaults.
365func loadConfig(v *viper.Viper, configPath string) error {
366 v.SetDefault("server.registry_port", 5000)
367 v.SetDefault("server.legacy_registry_domains", []string{})
368 v.SetDefault("server.tls_cert_file", "")
369 v.SetDefault("server.tls_key_file", "")
370 v.SetDefault("tls.acme.enabled", false)
371 v.SetDefault("tls.acme.email", "")
372 v.SetDefault("tls.acme.challenge", "auto")
373 v.SetDefault("tls.acme.obtain_batch_size", 1)
374 v.SetDefault("dns.resolvers", publictls.DefaultDNSResolvers)
375 v.SetDefault("dns.propagation_timeout", "5m")
376 v.SetDefault("dns.polling_interval", "5s")
377 v.SetDefault("server.force_https_redirect", false)
378 v.SetDefault("server.data_dir", DefaultDataDir())
379 v.SetDefault("server.runtime", "auto")
380 v.SetDefault("logging.level", "info")
381 v.SetDefault("logging.format", "console")
382 v.SetDefault("logging.file.enabled", false)
383 v.SetDefault("logging.file.max_size", 100)
384 v.SetDefault("logging.file.max_backups", 3)
385 v.SetDefault("logging.file.max_age", 28)
386 v.SetDefault("logging.container_logs.enabled", true)
387 v.SetDefault("logging.container_logs.dir", "")
388 v.SetDefault("logging.container_logs.max_size", 100)
389 v.SetDefault("logging.container_logs.max_backups", 3)
390 v.SetDefault("logging.container_logs.max_age", 28)
391 v.SetDefault("logging.access_log.enabled", false)
392 v.SetDefault("logging.access_log.format", "json")
393 v.SetDefault("logging.access_log.output", "stdout")
394 v.SetDefault("logging.access_log.file_path", "")
395 v.SetDefault("logging.access_log.max_size", 100)
396 v.SetDefault("logging.access_log.max_backups", 3)
397 v.SetDefault("logging.access_log.max_age", 28)
398 v.SetDefault("logging.access_log.exclude_health_checks", true)
399 v.SetDefault("logging.access_log.syslog_identifier", "gordon-access")
400 v.SetDefault("auth.enabled", true)
401 // Note: auth.type defaults to "token" (the only supported mode)
402 v.SetDefault("auth.secrets_backend", "")
403 v.SetDefault("auth.token_expiry", "720h")
404 v.SetDefault("api.rate_limit.enabled", true)
405 v.SetDefault("api.rate_limit.global_rps", 500)
406 v.SetDefault("api.rate_limit.per_ip_rps", 50)
407 v.SetDefault("api.rate_limit.burst", 100)
408 v.SetDefault("auto_route.enabled", false)
409 v.SetDefault("network_isolation.enabled", true)
410 v.SetDefault("network_isolation.network_prefix", "gordon")
411 v.SetDefault("network_isolation.internal", false)
412 v.SetDefault("volumes.auto_create", true)
413 v.SetDefault("volumes.prefix", "gordon")
414 v.SetDefault("volumes.preserve", true)
415 v.SetDefault("backups.databases.enabled", false)
416 v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily))
417 v.SetDefault("backups.databases.storage_dir", "")
418 v.SetDefault("backups.databases.retention.hourly", 0)
419 v.SetDefault("backups.databases.retention.daily", 0)
420 v.SetDefault("backups.databases.retention.weekly", 0)
421 v.SetDefault("backups.databases.retention.monthly", 0)
422 v.SetDefault("backups.volumes.enabled", false)
423 v.SetDefault("backups.volumes.interval", "24h")
424 v.SetDefault("backups.volumes.compression", string(domain.VolumeBackupCompressionGzip))
425 v.SetDefault("backups.volumes.timeout", "2h")
426 v.SetDefault("backups.volumes.max_concurrency", 2)
427 v.SetDefault("backups.volumes.helper_image", "alpine:3.20")
428 v.SetDefault("backups.volumes.s3.bucket", "")
429 v.SetDefault("backups.volumes.s3.region", "")
430 v.SetDefault("backups.volumes.s3.prefix", "")
431 v.SetDefault("backups.volumes.s3.endpoint", "")
432 v.SetDefault("backups.volumes.s3.path_style", false)
433 v.SetDefault("backups.volumes.s3.sse_algorithm", "")
434 v.SetDefault("backups.volumes.s3.sse_kms_key_id", "")
435 v.SetDefault("backups.volumes.s3.storage_class", "")
436 v.SetDefault("backups.volumes.retention.keep", 14)
437 v.SetDefault("images.allowed_registries", []string{})
438 v.SetDefault("images.require_digest", false)
439 v.SetDefault("images.prune.enabled", false)
440 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
441 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
442 v.SetDefault("containers.security_profile", "compat")
443 v.SetDefault("telemetry.enabled", false)
444 v.SetDefault("telemetry.endpoint", "")
445 v.SetDefault("telemetry.auth_token", "")
446 v.SetDefault("telemetry.traces", true)
447 v.SetDefault("telemetry.metrics", true)
448 v.SetDefault("telemetry.logs", true)
449 v.SetDefault("telemetry.trace_sample_rate", 1.0)
450
451 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
452 v.SetDefault("server.registry_allowed_ips", []string{})
453 v.SetDefault("server.proxy_allowed_ips", []string{})
454 v.SetDefault("server.registry_listen_address", "")
455
456 // HTTP timeouts. Every key needs a default so GORDON_* env overrides apply.
457 v.SetDefault("server.http.read_header_timeout", domain.DefaultRegistryReadHeaderTimeout.String())
458 v.SetDefault("server.http.read_timeout", domain.DefaultRegistryReadTimeout.String())
459 v.SetDefault("server.http.write_timeout", domain.DefaultRegistryWriteTimeout.String())
460 v.SetDefault("server.http.idle_timeout", domain.DefaultRegistryIdleTimeout.String())
461 v.SetDefault("traffic.http.read_header_timeout", domain.DefaultEntryPointReadHeaderTimeout.String())
462 v.SetDefault("traffic.http.idle_timeout", domain.DefaultEntryPointIdleTimeout.String())
463 v.SetDefault("traffic.http.transfer_idle_timeout", domain.DefaultEntryPointTransferIdleTimeout.String())
464 v.SetDefault("traffic.tcp.dial_timeout", "10s")
465 v.SetDefault("traffic.tcp.idle_timeout", "5m")
466 v.SetDefault("traffic.tcp.drain_timeout", "30s")
467 v.SetDefault("traffic.udp.idle_timeout", "30s")
468 v.SetDefault("traffic.udp.drain_timeout", "30s")
469 v.SetDefault("traffic.tcp.max_connections", 0) // 0 = runtime default
470 v.SetDefault("traffic.udp.max_sessions", 0) // 0 = runtime default
471
472 ConfigureViper(v, configPath)
473
474 if err := v.ReadInConfig(); err != nil {
475 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
476 return fmt.Errorf("failed to read config file: %w", err)
477 }
478 }
479
480 v.SetEnvPrefix("GORDON")
481 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
482 v.AutomaticEnv()
483
484 return nil
485}
486
487// validateHTTPTimeouts rejects malformed, zero, or negative HTTP timeouts at
488// config load, before any listener is started.
489func validateHTTPTimeouts(cfg Config) error {
490 if _, err := buildRegistryHTTPTimeouts(cfg); err != nil {
491 return err
492 }
493 if _, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP); err != nil {
494 return err
495 }
496 return nil
497}
498
499// buildRegistryHTTPTimeouts parses server.http into validated registry server
500// timeouts. Empty values select the defaults; malformed, zero, or negative
501// values are rejected.
502func buildRegistryHTTPTimeouts(cfg Config) (domain.RegistryHTTPTimeouts, error) {
503 defaults := domain.DefaultRegistryHTTPTimeouts()
504 var (
505 t domain.RegistryHTTPTimeouts
506 err error
507 )
508 if t.ReadHeaderTimeout, err = parsePositiveDuration(cfg.Server.HTTP.ReadHeaderTimeout, defaults.ReadHeaderTimeout, "server.http.read_header_timeout"); err != nil {
509 return t, err
510 }
511 if t.ReadTimeout, err = parsePositiveDuration(cfg.Server.HTTP.ReadTimeout, defaults.ReadTimeout, "server.http.read_timeout"); err != nil {
512 return t, err
513 }
514 if t.WriteTimeout, err = parsePositiveDuration(cfg.Server.HTTP.WriteTimeout, defaults.WriteTimeout, "server.http.write_timeout"); err != nil {
515 return t, err
516 }
517 if t.IdleTimeout, err = parsePositiveDuration(cfg.Server.HTTP.IdleTimeout, defaults.IdleTimeout, "server.http.idle_timeout"); err != nil {
518 return t, err
519 }
520 return t, t.Validate()
521}
522
523func parsePositiveDuration(value string, fallback time.Duration, key string) (time.Duration, error) {
524 value = strings.TrimSpace(value)
525 if value == "" {
526 return fallback, nil
527 }
528 d, err := time.ParseDuration(value)
529 if err != nil {
530 return 0, fmt.Errorf("invalid %s: %w", key, err)
531 }
532 if d <= 0 {
533 return 0, fmt.Errorf("invalid %s: must be positive", key)
534 }
535 return d, nil
536}