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