2.0.0 4803e67

config.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close