1package config
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "regexp"
8 "strings"
9
10 "github.com/rs/zerolog/log"
11 "github.com/spf13/viper"
12)
13
14type Config struct {
15 Server ServerConfig `mapstructure:"server"`
16 RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`
17 Routes map[string]string `mapstructure:"routes"`
18 AutoRoute AutoRouteConfig `mapstructure:"auto_route"`
19 Env EnvConfig `mapstructure:"env"`
20 Logging LoggingConfig `mapstructure:"logging"`
21 Volumes VolumeConfig `mapstructure:"volumes"`
22 Attachments map[string][]string `mapstructure:"attachments"`
23 NetworkGroups map[string][]string `mapstructure:"network_groups"`
24 NetworkIsolation NetworkIsolationConfig `mapstructure:"network_isolation"`
25}
26
27type ServerConfig struct {
28 Port int `mapstructure:"port"`
29 RegistryPort int `mapstructure:"registry_port"`
30 RegistryDomain string `mapstructure:"registry_domain"`
31 Runtime string `mapstructure:"runtime"`
32 SocketPath string `mapstructure:"socket_path"`
33 SSLEmail string `mapstructure:"ssl_email"`
34 DataDir string `mapstructure:"data_dir"`
35}
36
37type RegistryAuthConfig struct {
38 Enabled bool `mapstructure:"enabled"`
39 Username string `mapstructure:"username"`
40 Password string `mapstructure:"password"`
41}
42
43type AutoRouteConfig struct {
44 Enabled bool `mapstructure:"enabled"`
45}
46
47type EnvConfig struct {
48 Dir string `mapstructure:"dir"`
49 Providers []string `mapstructure:"providers"`
50}
51
52type LoggingConfig struct {
53 Enabled bool `mapstructure:"enabled"`
54 Level string `mapstructure:"level"`
55 Dir string `mapstructure:"dir"`
56 MainLogFile string `mapstructure:"main_log_file"`
57 ProxyLogFile string `mapstructure:"proxy_log_file"`
58 ContainerLogDir string `mapstructure:"container_log_dir"`
59 MaxSize int `mapstructure:"max_size"`
60 MaxBackups int `mapstructure:"max_backups"`
61 MaxAge int `mapstructure:"max_age"`
62 Compress bool `mapstructure:"compress"`
63}
64
65type VolumeConfig struct {
66 AutoCreate bool `mapstructure:"auto_create"`
67 Prefix string `mapstructure:"prefix"`
68 Preserve bool `mapstructure:"preserve"`
69}
70
71type NetworkIsolationConfig struct {
72 Enabled bool `mapstructure:"enabled"`
73 NetworkPrefix string `mapstructure:"network_prefix"`
74 DNSSuffix string `mapstructure:"dns_suffix"`
75}
76
77type SecretProvider struct {
78 Type string `mapstructure:"type"`
79 Config map[string]string `mapstructure:"config"`
80}
81
82type Route struct {
83 Domain string
84 Image string
85 HTTPS bool
86}
87
88func Load() (*Config, error) {
89 var cfg Config
90
91 // Set defaults
92 viper.SetDefault("server.port", 8080)
93 viper.SetDefault("server.registry_port", 5000)
94 viper.SetDefault("server.runtime", "auto")
95 viper.SetDefault("server.socket_path", "")
96
97 // Set data_dir default based on environment
98 defaultDataDir := getDefaultDataDir()
99 viper.SetDefault("server.data_dir", defaultDataDir)
100 viper.SetDefault("registry_auth.enabled", true)
101 viper.SetDefault("auto_route.enabled", false)
102 viper.SetDefault("env.dir", filepath.Join(defaultDataDir, "env"))
103 viper.SetDefault("env.providers", []string{"pass", "sops"})
104
105 // Logging defaults
106 viper.SetDefault("logging.enabled", true)
107 viper.SetDefault("logging.level", "info")
108 viper.SetDefault("logging.dir", filepath.Join(defaultDataDir, "logs"))
109 viper.SetDefault("logging.main_log_file", "gordon.log")
110 viper.SetDefault("logging.proxy_log_file", "proxy.log")
111 viper.SetDefault("logging.container_log_dir", "containers")
112 viper.SetDefault("logging.max_size", 100) // 100MB
113 viper.SetDefault("logging.max_backups", 3) // Keep 3 old files
114 viper.SetDefault("logging.max_age", 28) // 28 days
115 viper.SetDefault("logging.compress", true)
116
117 // Volume defaults
118 viper.SetDefault("volumes.auto_create", true)
119 viper.SetDefault("volumes.prefix", "gordon")
120 viper.SetDefault("volumes.preserve", true)
121
122 // Network isolation defaults
123 viper.SetDefault("network_isolation.enabled", true)
124 viper.SetDefault("network_isolation.network_prefix", "gordon")
125 viper.SetDefault("network_isolation.dns_suffix", ".internal")
126
127 // Handle the routes manually since Viper struggles with domain names
128 cfg.Routes = make(map[string]string)
129 cfg.Attachments = make(map[string][]string)
130 cfg.NetworkGroups = make(map[string][]string)
131
132 // Get server config first
133 if err := viper.UnmarshalKey("server", &cfg.Server); err != nil {
134 return nil, fmt.Errorf("unable to decode server config: %v", err)
135 }
136
137 // If data_dir is empty after loading config, use the default
138 if cfg.Server.DataDir == "" {
139 cfg.Server.DataDir = defaultDataDir
140 log.Debug().Str("data_dir", cfg.Server.DataDir).Msg("Config had empty data_dir, using default")
141 }
142
143 // If registry_port is 0 after loading config, use the default
144 if cfg.Server.RegistryPort == 0 {
145 cfg.Server.RegistryPort = 5000
146 log.Debug().Int("registry_port", cfg.Server.RegistryPort).Msg("Config had empty registry_port, using default")
147 }
148
149 // Get registry auth config
150 if err := viper.UnmarshalKey("registry_auth", &cfg.RegistryAuth); err != nil {
151 return nil, fmt.Errorf("unable to decode registry auth config: %v", err)
152 }
153
154 // Get auto route config
155 if err := viper.UnmarshalKey("auto_route", &cfg.AutoRoute); err != nil {
156 return nil, fmt.Errorf("unable to decode auto route config: %v", err)
157 }
158
159 // Get env config
160 if err := viper.UnmarshalKey("env", &cfg.Env); err != nil {
161 return nil, fmt.Errorf("unable to decode env config: %v", err)
162 }
163
164 // If env.dir is empty after loading config, use the default
165 if cfg.Env.Dir == "" {
166 cfg.Env.Dir = filepath.Join(cfg.Server.DataDir, "env")
167 log.Debug().Str("env_dir", cfg.Env.Dir).Msg("Config had empty env.dir, using default")
168 }
169
170 // Get logging config
171 if err := viper.UnmarshalKey("logging", &cfg.Logging); err != nil {
172 return nil, fmt.Errorf("unable to decode logging config: %v", err)
173 }
174
175 // If logging.dir is empty after loading config, use the default
176 if cfg.Logging.Dir == "" {
177 cfg.Logging.Dir = filepath.Join(cfg.Server.DataDir, "logs")
178 log.Debug().Str("logging_dir", cfg.Logging.Dir).Msg("Config had empty logging.dir, using default")
179 }
180
181 // Get volumes config
182 if err := viper.UnmarshalKey("volumes", &cfg.Volumes); err != nil {
183 return nil, fmt.Errorf("unable to decode volumes config: %v", err)
184 }
185
186 // Get network isolation config
187 if err := viper.UnmarshalKey("network_isolation", &cfg.NetworkIsolation); err != nil {
188 return nil, fmt.Errorf("unable to decode network isolation config: %v", err)
189 }
190
191 // Get routes manually from the raw config
192 routesRaw := viper.Get("routes")
193 if routesRaw != nil {
194 if routes, ok := routesRaw.(map[string]interface{}); ok {
195 for domain, image := range routes {
196 if imageStr, ok := image.(string); ok {
197 cfg.Routes[domain] = imageStr
198 }
199 }
200 }
201 }
202
203 // Get attachments manually from the raw config
204 attachmentsRaw := viper.Get("attachments")
205 if attachmentsRaw != nil {
206 if attachments, ok := attachmentsRaw.(map[string]interface{}); ok {
207 for identifier, services := range attachments {
208 if servicesList, ok := services.([]interface{}); ok {
209 var serviceStrings []string
210 for _, service := range servicesList {
211 if serviceStr, ok := service.(string); ok {
212 serviceStrings = append(serviceStrings, serviceStr)
213 }
214 }
215 cfg.Attachments[identifier] = serviceStrings
216 }
217 }
218 }
219 }
220
221 // Get network_groups manually from the raw config
222 networkGroupsRaw := viper.Get("network_groups")
223 if networkGroupsRaw != nil {
224 if networkGroups, ok := networkGroupsRaw.(map[string]interface{}); ok {
225 for groupName, domains := range networkGroups {
226 if domainsList, ok := domains.([]interface{}); ok {
227 var domainStrings []string
228 for _, domain := range domainsList {
229 if domainStr, ok := domain.(string); ok {
230 domainStrings = append(domainStrings, domainStr)
231 }
232 }
233 cfg.NetworkGroups[groupName] = domainStrings
234 }
235 }
236 }
237 }
238
239 // Note: ssl_email is optional and only needed for future Let's Encrypt integration
240 // Currently using Cloudflare for HTTPS
241
242 validRuntimes := []string{"auto", "docker", "podman", "podman-rootless"}
243 isValid := false
244 for _, valid := range validRuntimes {
245 if cfg.Server.Runtime == valid {
246 isValid = true
247 break
248 }
249 }
250 if !isValid {
251 return nil, fmt.Errorf("server.runtime must be one of: %s", strings.Join(validRuntimes, ", "))
252 }
253
254 // Validate registry auth config
255 if cfg.RegistryAuth.Enabled {
256 if cfg.RegistryAuth.Username == "" || cfg.RegistryAuth.Password == "" {
257 return nil, fmt.Errorf("Registry auth enabled but username/password not provided")
258 }
259 }
260
261 // Validate registry domain if provided
262 if cfg.Server.RegistryDomain != "" {
263 // Basic domain validation - should not contain protocol or paths
264 if strings.Contains(cfg.Server.RegistryDomain, "://") || strings.Contains(cfg.Server.RegistryDomain, "/") {
265 return nil, fmt.Errorf("registry_domain should be just the domain name (e.g. 'registry.example.com')")
266 }
267 }
268
269 // Validate env config
270 if err := validateEnvConfig(&cfg.Env); err != nil {
271 return nil, fmt.Errorf("invalid env config: %w", err)
272 }
273
274 return &cfg, nil
275}
276
277func (c *Config) GetRoutes() []Route {
278 var routes []Route
279
280 for domain, image := range c.Routes {
281 route := Route{
282 Image: image,
283 HTTPS: true, // Default to HTTPS
284 }
285
286 // Check if domain has http:// prefix
287 if strings.HasPrefix(domain, "http://") {
288 route.Domain = strings.TrimPrefix(domain, "http://")
289 route.HTTPS = false
290 } else {
291 route.Domain = domain
292 }
293
294 routes = append(routes, route)
295 }
296
297 return routes
298}
299
300// getDefaultDataDir returns a platform-appropriate default data directory
301func getDefaultDataDir() string {
302 uid := os.Getuid()
303 log.Debug().Int("uid", uid).Msg("Detecting default data directory")
304
305 // Check if we're running in a rootless environment
306 if uid != 0 {
307 // For rootless environments, use user's home directory or current directory
308 if homeDir, err := os.UserHomeDir(); err == nil {
309 dataDir := filepath.Join(homeDir, ".local/share/gordon")
310 log.Debug().Str("data_dir", dataDir).Msg("Using user data directory for rootless environment")
311 return dataDir
312 }
313 log.Debug().Msg("Failed to get user home directory, falling back to ./data")
314 } else {
315 log.Debug().Msg("Running as root, using ./data")
316 }
317
318 // For root or when home directory is not available, use relative path
319 return "./data"
320}
321
322// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest"
323func ExtractDomainFromImageName(imageName string) (string, bool) {
324 // Split by colon to separate image name from tag
325 parts := strings.Split(imageName, ":")
326 imageNamePart := parts[0]
327
328 // Domain pattern: should contain at least one dot and valid domain characters
329 domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
330
331 if domainPattern.MatchString(imageNamePart) {
332 return imageNamePart, true
333 }
334
335 return "", false
336}
337
338// AddRoute adds a new route to the configuration and saves it
339func (c *Config) AddRoute(domain, image string) error {
340 if c.Routes == nil {
341 c.Routes = make(map[string]string)
342 }
343
344 c.Routes[domain] = image
345
346 // Read the current config file to preserve formatting
347 configFile := viper.ConfigFileUsed()
348 if configFile == "" {
349 return fmt.Errorf("no config file path available")
350 }
351
352 // Read existing content
353 content, err := os.ReadFile(configFile)
354 if err != nil {
355 return fmt.Errorf("failed to read config file: %w", err)
356 }
357
358 // Parse and update only the routes section while preserving other formatting
359 lines := strings.Split(string(content), "\n")
360 var newLines []string
361 inRoutesSection := false
362 routeAdded := false
363
364 for _, line := range lines {
365 trimmed := strings.TrimSpace(line)
366
367 // Check if we're entering or leaving the routes section
368 if trimmed == "[routes]" {
369 inRoutesSection = true
370 newLines = append(newLines, line)
371 continue
372 }
373
374 // Check if we're entering a new section
375 if strings.HasPrefix(trimmed, "[") && trimmed != "[routes]" {
376 // If we were in routes section and haven't added the route yet, add it
377 if inRoutesSection && !routeAdded {
378 newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
379 routeAdded = true
380 }
381 inRoutesSection = false
382 }
383
384 // If we're in routes section, check if this route already exists (check both quote styles)
385 if inRoutesSection && (strings.Contains(trimmed, fmt.Sprintf("'%s'", domain)) || strings.Contains(trimmed, fmt.Sprintf("\"%s\"", domain))) {
386 // Update existing route using double quotes for consistency
387 newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
388 routeAdded = true
389 } else {
390 newLines = append(newLines, line)
391 }
392 }
393
394 // If we were in routes section at the end and haven't added the route yet, add it
395 if inRoutesSection && !routeAdded {
396 newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
397 }
398
399 // Write the updated content back
400 newContent := strings.Join(newLines, "\n")
401 if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil {
402 return fmt.Errorf("failed to write config file: %w", err)
403 }
404
405 log.Info().
406 Str("domain", domain).
407 Str("image", image).
408 Msg("Auto-added route to configuration")
409
410 // Create env file for the new route (if env loader is available)
411 // This will be called from the auto-route handler which has access to the env loader
412
413 return nil
414}
415
416// UpdateRoute updates an existing route configuration
417func (c *Config) UpdateRoute(domain, image string) error {
418 if c.Routes == nil {
419 c.Routes = make(map[string]string)
420 }
421
422 // Check if route exists
423 if _, exists := c.Routes[domain]; !exists {
424 return fmt.Errorf("route %s does not exist", domain)
425 }
426
427 c.Routes[domain] = image
428
429 // Read the current config file to preserve formatting
430 configFile := viper.ConfigFileUsed()
431 if configFile == "" {
432 return fmt.Errorf("no config file path available")
433 }
434
435 // Read existing content
436 content, err := os.ReadFile(configFile)
437 if err != nil {
438 return fmt.Errorf("failed to read config file: %w", err)
439 }
440
441 // Parse and update only the routes section while preserving other formatting
442 lines := strings.Split(string(content), "\n")
443 var newLines []string
444 inRoutesSection := false
445 routeUpdated := false
446
447 for _, line := range lines {
448 trimmed := strings.TrimSpace(line)
449
450 // Check if we're entering or leaving the routes section
451 if trimmed == "[routes]" {
452 inRoutesSection = true
453 newLines = append(newLines, line)
454 continue
455 }
456
457 // Check if we're entering a new section
458 if strings.HasPrefix(trimmed, "[") && trimmed != "[routes]" {
459 inRoutesSection = false
460 }
461
462 // If we're in routes section, check if this route matches (check both quote styles)
463 if inRoutesSection && (strings.Contains(trimmed, fmt.Sprintf("'%s'", domain)) || strings.Contains(trimmed, fmt.Sprintf("\"%s\"", domain))) {
464 // Update existing route using double quotes for consistency
465 newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
466 routeUpdated = true
467 } else {
468 newLines = append(newLines, line)
469 }
470 }
471
472 if !routeUpdated {
473 return fmt.Errorf("route %s not found in config file", domain)
474 }
475
476 // Write the updated content back
477 newContent := strings.Join(newLines, "\n")
478 if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil {
479 return fmt.Errorf("failed to write config file: %w", err)
480 }
481
482 log.Info().
483 Str("domain", domain).
484 Str("image", image).
485 Msg("Updated route configuration")
486
487 return nil
488}
489
490// validateEnvConfig validates the environment configuration
491func validateEnvConfig(envCfg *EnvConfig) error {
492 // Validate env directory path
493 if envCfg.Dir != "" {
494 if filepath.IsAbs(envCfg.Dir) {
495 // For absolute paths, check if the directory is accessible
496 if info, err := os.Stat(envCfg.Dir); err != nil {
497 if !os.IsNotExist(err) {
498 return fmt.Errorf("env.dir path is not accessible: %w", err)
499 }
500 // Directory doesn't exist - that's okay, it will be created
501 } else if !info.IsDir() {
502 return fmt.Errorf("env.dir path exists but is not a directory: %s", envCfg.Dir)
503 }
504 }
505 // For relative paths, we can't easily validate without changing working directory
506 }
507
508 // Validate secret providers configuration
509 validProviders := []string{"pass", "sops"}
510 for _, providerName := range envCfg.Providers {
511 if providerName == "" {
512 return fmt.Errorf("empty provider name in env.providers")
513 }
514
515 isValid := false
516 for _, validType := range validProviders {
517 if providerName == validType {
518 isValid = true
519 break
520 }
521 }
522 if !isValid {
523 return fmt.Errorf("unsupported provider '%s' in env.providers. Supported providers: %s",
524 providerName, strings.Join(validProviders, ", "))
525 }
526 }
527
528 return nil
529}