1// Package config implements the configuration management use case.
2package config
3
4import (
5 "cmp"
6 "context"
7 "fmt"
8 "os"
9 "path/filepath"
10 "reflect"
11 "regexp"
12 "slices"
13 "strconv"
14 "strings"
15 "sync"
16 "sync/atomic"
17 "time"
18
19 "github.com/bnema/zerowrap"
20 "github.com/fsnotify/fsnotify"
21 "github.com/pelletier/go-toml/v2"
22 "github.com/spf13/viper"
23
24 "github.com/bnema/gordon/internal/boundaries/out"
25 "github.com/bnema/gordon/internal/domain"
26)
27
28// Config holds the loaded configuration.
29type Config struct {
30 ServerPort int
31 RegistryPort int
32 RegistryDomain string
33 DataDir string
34 AutoRouteEnabled bool
35 AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"`
36 PreviewEnabled bool
37 PreviewTTL time.Duration
38 PreviewSeparator string
39 PreviewTagPatterns []string
40 PreviewDataCopy bool
41 PreviewEnvCopy bool
42 NetworkIsolation bool
43 NetworkPrefix string
44 Routes map[string]routeConfig
45 ExternalRoutes map[string]string // domain -> "host:port"
46 RegistryAuthEnabled bool
47 RegistryAuthUsername string
48 RegistryAuthPassword string
49 VolumeAutoCreate bool
50 VolumePrefix string
51 VolumePreserve bool
52 NetworkGroups map[string][]string
53 Attachments map[string][]string
54}
55
56type routeConfig struct {
57 Image string `toml:"image"`
58 HTTPS bool `toml:"https"`
59}
60
61// Service implements the ConfigService interface.
62type Service struct {
63 viper *viper.Viper
64 eventBus out.EventPublisher
65 config Config
66 mu sync.RWMutex
67 lastSaveTime int64 // Unix nano timestamp of last save (to debounce file watcher)
68 debounceDelay int64 // Debounce delay in nanoseconds (default 500ms)
69}
70
71// NewService creates a new config service.
72func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service {
73 return &Service{
74 viper: v,
75 eventBus: eventBus,
76 debounceDelay: int64(500 * time.Millisecond), // 500ms debounce for file watcher
77 }
78}
79
80// Load loads the configuration from the configured source.
81func (s *Service) Load(ctx context.Context) error {
82 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
83 zerowrap.FieldLayer: "usecase",
84 zerowrap.FieldUseCase: "Load",
85 })
86 log := zerowrap.FromCtx(ctx)
87
88 s.mu.Lock()
89 defer s.mu.Unlock()
90
91 newConfig := s.loadConfigValues()
92
93 routes, err := loadCanonicalRoutes(s.viper.Get("routes"))
94 if err != nil {
95 return log.WrapErr(err, "failed to load routes")
96 }
97 newConfig.Routes = routes
98 newConfig.ExternalRoutes = loadStringMap(s.viper.Get("external_routes"))
99 newConfig.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
100 newConfig.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
101
102 s.config = newConfig
103
104 log.Info().
105 Int("server_port", s.config.ServerPort).
106 Int("registry_port", s.config.RegistryPort).
107 Int(zerowrap.FieldCount, len(s.config.Routes)).
108 Msg("configuration loaded")
109
110 return nil
111}
112
113// Reload re-reads the configuration file from disk and loads it into memory.
114// This should be used when you want to pick up external changes to the config file.
115// It differs from Load() which only loads from cached viper values.
116func (s *Service) Reload(ctx context.Context) error {
117 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
118 zerowrap.FieldLayer: "usecase",
119 zerowrap.FieldUseCase: "Reload",
120 })
121 log := zerowrap.FromCtx(ctx)
122
123 // Re-read the config file from disk
124 if err := s.viper.ReadInConfig(); err != nil {
125 return log.WrapErr(err, "failed to read config file")
126 }
127
128 // Load the values into memory
129 return s.Load(ctx)
130}
131
132// loadConfigValues loads simple config values from viper.
133func (s *Service) loadConfigValues() Config {
134 // Prefer gordon_domain over registry_domain
135 registryDomain := s.viper.GetString("server.gordon_domain")
136 if registryDomain == "" {
137 registryDomain = s.viper.GetString("server.registry_domain")
138 }
139
140 // Backward compat: try new keys first, fall back to old
141 autoEnabled := s.viper.GetBool("auto.enabled")
142 if !autoEnabled {
143 autoEnabled = s.viper.GetBool("auto_route.enabled")
144 }
145 allowedDomains := s.viper.GetStringSlice("auto.allowed_domains")
146 if len(allowedDomains) == 0 {
147 allowedDomains = s.viper.GetStringSlice("auto_route_allowed_domains")
148 }
149
150 // Preview config
151 previewTTLStr := s.viper.GetString("auto.preview.ttl")
152 previewTTL, err := time.ParseDuration(previewTTLStr)
153 if err != nil && previewTTLStr != "" {
154 log := zerowrap.FromCtx(context.Background())
155 log.Warn().Err(err).Str("ttl", previewTTLStr).Msg("invalid preview TTL format, using default 48h (use Go duration strings, e.g. \"168h\" for 7 days)")
156 }
157 if previewTTL == 0 {
158 previewTTL = 48 * time.Hour
159 }
160 previewSep := s.viper.GetString("auto.preview.separator")
161 if previewSep == "" {
162 previewSep = "--"
163 }
164
165 return Config{
166 ServerPort: s.viper.GetInt("server.port"),
167 RegistryPort: s.viper.GetInt("server.registry_port"),
168 RegistryDomain: registryDomain,
169 DataDir: s.viper.GetString("server.data_dir"),
170 AutoRouteEnabled: autoEnabled,
171 AutoRouteAllowedDomains: append([]string{}, allowedDomains...),
172 PreviewEnabled: s.viper.GetBool("auto.preview.enabled"),
173 PreviewTTL: previewTTL,
174 PreviewSeparator: previewSep,
175 PreviewTagPatterns: append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...),
176 PreviewDataCopy: s.viper.GetBool("auto.preview.data_copy"),
177 PreviewEnvCopy: !s.viper.IsSet("auto.preview.env_copy") || s.viper.GetBool("auto.preview.env_copy"),
178 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
179 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
180 RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),
181 RegistryAuthUsername: s.viper.GetString("auth.username"),
182 RegistryAuthPassword: s.viper.GetString("auth.password"),
183 VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),
184 VolumePrefix: s.viper.GetString("volumes.prefix"),
185 VolumePreserve: s.viper.GetBool("volumes.preserve"),
186 Routes: make(map[string]routeConfig),
187 ExternalRoutes: make(map[string]string),
188 NetworkGroups: make(map[string][]string),
189 Attachments: make(map[string][]string),
190 }
191}
192
193// loadStringMap loads a map[string]string from a viper value.
194func loadStringMap(raw any) map[string]string {
195 result := make(map[string]string)
196 if raw == nil {
197 return result
198 }
199 m, ok := raw.(map[string]any)
200 if !ok {
201 return result
202 }
203 for k, v := range m {
204 if vs, ok := v.(string); ok {
205 result[k] = vs
206 }
207 }
208 return result
209}
210
211// loadStringArrayMap loads a map[string][]string from a viper value.
212func loadStringArrayMap(raw any) map[string][]string {
213 result := make(map[string][]string)
214 if raw == nil {
215 return result
216 }
217 m, ok := raw.(map[string]any)
218 if !ok {
219 return result
220 }
221 for k, v := range m {
222 if arr, ok := v.([]any); ok {
223 var strs []string
224 for _, item := range arr {
225 if s, ok := item.(string); ok {
226 strs = append(strs, s)
227 }
228 }
229 result[k] = strs
230 }
231 }
232 return result
233}
234
235func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
236 result := make(map[string]routeConfig)
237 if raw == nil {
238 return result, nil
239 }
240
241 routes, ok := raw.(map[string]any)
242 if !ok {
243 return nil, fmt.Errorf("routes section must be a table, got %T", raw)
244 }
245
246 for key, value := range routes {
247 if strings.HasPrefix(key, "http://") {
248 continue
249 }
250
251 route, err := parseCanonicalRouteEntry(key, value)
252 if err != nil {
253 return nil, err
254 }
255 result[key] = route
256 }
257
258 for key, value := range routes {
259 if !strings.HasPrefix(key, "http://") {
260 continue
261 }
262
263 domainName := strings.TrimPrefix(key, "http://")
264 if _, exists := result[domainName]; exists {
265 continue
266 }
267
268 route, err := parseLegacyRouteEntry(domainName, value)
269 if err != nil {
270 return nil, err
271 }
272 result[domainName] = route
273 }
274
275 return result, nil
276}
277
278func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) {
279 if !domain.IsValidRouteDomain(domainName) {
280 return routeConfig{}, fmt.Errorf("invalid route key %q: %w", domainName, domain.ErrRouteDomainInvalid)
281 }
282
283 switch value := raw.(type) {
284 case string:
285 if value == "" {
286 return routeConfig{}, fmt.Errorf("route %q has empty image", domainName)
287 }
288 return routeConfig{Image: value, HTTPS: true}, nil
289 case map[string]any:
290 return parseRouteTable(domainName, value)
291 default:
292 return routeConfig{}, fmt.Errorf("route %q has unsupported value type %T", domainName, raw)
293 }
294}
295
296func parseLegacyRouteEntry(domainName string, raw any) (routeConfig, error) {
297 if !domain.IsValidRouteDomain(domainName) {
298 return routeConfig{}, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid)
299 }
300
301 value, ok := raw.(string)
302 if !ok {
303 return routeConfig{}, fmt.Errorf("legacy route %q must be a string", legacyRouteStorageKey(domainName))
304 }
305 if value == "" {
306 return routeConfig{}, fmt.Errorf("legacy route %q has empty image", legacyRouteStorageKey(domainName))
307 }
308
309 return routeConfig{Image: value, HTTPS: false}, nil
310}
311
312func parseRouteTable(domainName string, raw map[string]any) (routeConfig, error) {
313 imageValue, ok := raw["image"].(string)
314 if !ok || imageValue == "" {
315 return routeConfig{}, fmt.Errorf("route %q has invalid image field", domainName)
316 }
317
318 httpsValue, ok := raw["https"]
319 if !ok {
320 return routeConfig{Image: imageValue, HTTPS: true}, nil
321 }
322 https, ok := httpsValue.(bool)
323 if !ok {
324 return routeConfig{}, fmt.Errorf("route %q has invalid https field", domainName)
325 }
326
327 return routeConfig{Image: imageValue, HTTPS: https}, nil
328}
329
330// GetRoutes returns all configured routes.
331func (s *Service) GetRoutes(_ context.Context) []domain.Route {
332 s.mu.RLock()
333 defer s.mu.RUnlock()
334
335 var routes []domain.Route
336 for domainName, route := range s.config.Routes {
337 if strings.HasPrefix(domainName, "http://") {
338 continue
339 }
340 routes = append(routes, domain.Route{Domain: domainName, Image: route.Image, HTTPS: route.HTTPS})
341 }
342
343 return routes
344}
345
346// GetRoute returns a single route by domain.
347func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route, error) {
348 s.mu.RLock()
349 defer s.mu.RUnlock()
350
351 routeCfg, ok := s.config.Routes[domainName]
352 if !ok {
353 return nil, domain.ErrRouteNotFound
354 }
355
356 routeValue := domain.Route{Domain: domainName, Image: routeCfg.Image, HTTPS: routeCfg.HTTPS}
357 route := &routeValue
358
359 return route, nil
360}
361
362// FindRoutesByImage returns all routes whose image matches the given image name.
363// Image names are normalized by stripping the registry domain prefix before comparison.
364// When the input has no tag, only the name portion is compared (i.e. "myapp" matches "myapp:latest").
365func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route {
366 s.mu.RLock()
367 defer s.mu.RUnlock()
368
369 var routes []domain.Route
370 for domainName, route := range s.config.Routes {
371 if strings.HasPrefix(domainName, "http://") {
372 continue
373 }
374 if matchesImageName(imageName, route.Image, s.config.RegistryDomain) {
375 routes = append(routes, domain.Route{Domain: domainName, Image: route.Image, HTTPS: route.HTTPS})
376 }
377 }
378
379 return routes
380}
381
382// FindAttachmentTargetsByImage returns all attachment targets whose image matches the given image name.
383func (s *Service) FindAttachmentTargetsByImage(_ context.Context, imageName string) []string {
384 s.mu.RLock()
385 defer s.mu.RUnlock()
386
387 var targets []string
388 for target, images := range s.config.Attachments {
389 for _, image := range images {
390 if matchesImageName(imageName, image, s.config.RegistryDomain) {
391 targets = append(targets, target)
392 break
393 }
394 }
395 }
396
397 return targets
398}
399
400func matchesImageName(inputImage, candidateImage, registryDomain string) bool {
401 normalizedInput := NormalizeRegistryImage(inputImage, registryDomain)
402 inputName, inputHasTag := splitImageNameTag(normalizedInput)
403 normalizedCandidate := NormalizeRegistryImage(candidateImage, registryDomain)
404
405 if inputHasTag {
406 return strings.EqualFold(normalizedCandidate, normalizedInput)
407 }
408
409 candidateName, _ := splitImageNameTag(normalizedCandidate)
410 return strings.EqualFold(candidateName, inputName)
411}
412
413// splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present.
414func splitImageNameTag(image string) (name string, hasTag bool) {
415 if idx := strings.LastIndex(image, ":"); idx != -1 {
416 return image[:idx], true
417 }
418 return image, false
419}
420
421// NormalizeRegistryImage strips the registry domain prefix from an image name for comparison.
422func NormalizeRegistryImage(imageName, registryDomain string) string {
423 registryDomain = strings.TrimSuffix(registryDomain, "/")
424 if registryDomain == "" {
425 return imageName
426 }
427
428 prefix := registryDomain + "/"
429 if strings.HasPrefix(imageName, prefix) {
430 return strings.TrimPrefix(imageName, prefix)
431 }
432
433 return imageName
434}
435
436// NormalizeBootstrapImage converts a user-supplied image argument into
437// the canonical "registry/name" format expected by push.
438// Bare names get the registry domain prepended; tags are stripped.
439func NormalizeBootstrapImage(image, registryDomain string) (string, error) {
440 if image == "" {
441 return "", fmt.Errorf("image is required")
442 }
443 if registryDomain == "" {
444 return "", fmt.Errorf("registry domain is not configured")
445 }
446
447 registryDomain = strings.TrimSuffix(registryDomain, "/")
448
449 if slashIdx := strings.LastIndex(image, "/"); slashIdx != -1 {
450 nameTag := image[slashIdx+1:]
451 if colonIdx := strings.LastIndex(nameTag, ":"); colonIdx != -1 {
452 image = image[:slashIdx+1] + nameTag[:colonIdx]
453 }
454 } else {
455 if colonIdx := strings.LastIndex(image, ":"); colonIdx != -1 {
456 image = image[:colonIdx]
457 }
458 }
459
460 if !strings.Contains(image, "/") {
461 image = registryDomain + "/" + image
462 }
463
464 return image, nil
465}
466
467func legacyRouteStorageKey(domainName string) string {
468 return "http://" + domainName
469}
470
471func cloneRouteConfigs(routes map[string]routeConfig) map[string]routeConfig {
472 if routes == nil {
473 return make(map[string]routeConfig)
474 }
475
476 cloned := make(map[string]routeConfig, len(routes))
477 for key, route := range routes {
478 cloned[key] = route
479 }
480
481 return cloned
482}
483
484// AddRoute adds a new route to the configuration and persists it.
485func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
486 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
487 zerowrap.FieldLayer: "usecase",
488 zerowrap.FieldUseCase: "AddRoute",
489 "domain": route.Domain,
490 })
491 log := zerowrap.FromCtx(ctx)
492
493 // Validate route
494 if route.Domain == "" {
495 return domain.ErrRouteDomainEmpty
496 }
497 if !domain.IsValidRouteDomain(route.Domain) {
498 return domain.ErrRouteDomainInvalid
499 }
500 if route.Image == "" {
501 return domain.ErrRouteImageEmpty
502 }
503
504 // Store previous value for rollback
505 s.mu.Lock()
506 currentConfig := s.config
507 if currentConfig.Routes == nil {
508 currentConfig.Routes = make(map[string]routeConfig)
509 }
510 previousCanonicalRoute, canonicalExisted := currentConfig.Routes[route.Domain]
511 legacyKey := legacyRouteStorageKey(route.Domain)
512 _, legacyExisted := currentConfig.Routes[legacyKey]
513 newRoute := routeConfig{Image: route.Image, HTTPS: route.HTTPS}
514 if canonicalExisted && previousCanonicalRoute == newRoute && !legacyExisted {
515 s.mu.Unlock()
516 return nil
517 }
518 nextRoutes := cloneRouteConfigs(currentConfig.Routes)
519 nextRoutes[route.Domain] = newRoute
520 if legacyExisted {
521 delete(nextRoutes, legacyKey)
522 }
523
524 nextConfig := currentConfig
525 nextConfig.Routes = nextRoutes
526
527 if err := s.persistConfig(ctx, nextConfig); err != nil {
528 log.Warn().Err(err).Msg("failed to persist route to disk")
529 s.mu.Unlock()
530 return err
531 }
532
533 s.config.Routes = nextRoutes
534 s.mu.Unlock()
535
536 log.Info().Str("image", route.Image).Msg("route added to configuration")
537 return nil
538}
539
540// UpdateRoute updates an existing route and persists it.
541func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
542 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
543 zerowrap.FieldLayer: "usecase",
544 zerowrap.FieldUseCase: "UpdateRoute",
545 "domain": route.Domain,
546 })
547 log := zerowrap.FromCtx(ctx)
548
549 // Validate route
550 if route.Domain == "" {
551 return domain.ErrRouteDomainEmpty
552 }
553 if !domain.IsValidRouteDomain(route.Domain) {
554 return domain.ErrRouteDomainInvalid
555 }
556 if route.Image == "" {
557 return domain.ErrRouteImageEmpty
558 }
559
560 // Store previous value for rollback
561 s.mu.Lock()
562 currentConfig := s.config
563 previousRoute, ok := currentConfig.Routes[route.Domain]
564 if !ok {
565 s.mu.Unlock()
566 return domain.ErrRouteNotFound
567 }
568 updatedRoute := previousRoute
569 legacyKey := legacyRouteStorageKey(route.Domain)
570 nextRoutes := cloneRouteConfigs(currentConfig.Routes)
571 delete(nextRoutes, legacyKey)
572 updatedRoute.Image = route.Image
573 updatedRoute.HTTPS = route.HTTPS
574 nextRoutes[route.Domain] = updatedRoute
575
576 nextConfig := currentConfig
577 nextConfig.Routes = nextRoutes
578
579 if err := s.persistConfig(ctx, nextConfig); err != nil {
580 log.Warn().Err(err).Msg("failed to persist route update to disk")
581 s.mu.Unlock()
582 return err
583 }
584
585 s.config.Routes = nextRoutes
586 s.mu.Unlock()
587
588 log.Info().Str("image", route.Image).Msg("route updated")
589 return nil
590}
591
592// RemoveRoute removes a route from the configuration and persists it.
593func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
594 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
595 zerowrap.FieldLayer: "usecase",
596 zerowrap.FieldUseCase: "RemoveRoute",
597 "domain": domainName,
598 })
599 log := zerowrap.FromCtx(ctx)
600
601 if domainName == "" {
602 return domain.ErrRouteDomainEmpty
603 }
604 if !domain.IsValidRouteDomain(domainName) {
605 return domain.ErrRouteDomainInvalid
606 }
607
608 // Store previous value for rollback
609 s.mu.Lock()
610 currentConfig := s.config
611 _, ok := currentConfig.Routes[domainName]
612 if !ok {
613 s.mu.Unlock()
614 return domain.ErrRouteNotFound
615 }
616 legacyKey := legacyRouteStorageKey(domainName)
617 nextRoutes := cloneRouteConfigs(currentConfig.Routes)
618 delete(nextRoutes, legacyKey)
619 delete(nextRoutes, domainName)
620
621 nextConfig := currentConfig
622 nextConfig.Routes = nextRoutes
623
624 if err := s.persistConfig(ctx, nextConfig); err != nil {
625 log.Warn().Err(err).Msg("failed to persist route removal to disk")
626 s.mu.Unlock()
627 return err
628 }
629
630 s.config.Routes = nextRoutes
631 s.mu.Unlock()
632
633 log.Info().Msg("route removed")
634 return nil
635}
636
637// Save persists the current configuration to disk.
638func (s *Service) Save(ctx context.Context) error {
639 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
640 zerowrap.FieldLayer: "usecase",
641 zerowrap.FieldUseCase: "SaveConfig",
642 })
643
644 s.mu.Lock()
645 defer s.mu.Unlock()
646
647 return s.persistConfig(ctx, s.config)
648}
649
650func (s *Service) persistConfig(ctx context.Context, config Config) error {
651 log := zerowrap.FromCtx(ctx)
652
653 configFile := s.viper.ConfigFileUsed()
654 if configFile == "" {
655 return log.WrapErr(fmt.Errorf("no config file path"), "cannot save config")
656 }
657
658 if err := backupConfigFile(configFile); err != nil {
659 log.Warn().Err(err).Msg("failed to create config backup")
660 }
661
662 snapshot := s.snapshotCriticalFields()
663
664 if err := s.writeConfigSurgical(configFile, config); err != nil {
665 return log.WrapErr(err, "failed to write config")
666 }
667
668 atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano())
669
670 if err := s.viper.ReadInConfig(); err != nil {
671 return log.WrapErr(err, "failed to re-read config after save")
672 }
673
674 if err := s.verifyCriticalFields(snapshot); err != nil {
675 log.Error().Err(err).Msg("config corruption detected after save, restoring backup")
676 if restoreErr := restoreConfigBackup(configFile); restoreErr != nil {
677 log.Error().Err(restoreErr).Msg("CRITICAL: failed to restore config backup")
678 } else if reloadErr := s.viper.ReadInConfig(); reloadErr != nil {
679 log.Error().Err(reloadErr).Msg("failed to reload config after restore")
680 }
681 return log.WrapErr(err, "config verification failed after save")
682 }
683
684 log.Info().Msg("configuration saved to disk")
685 return nil
686}
687
688type configSnapshot struct {
689 values map[string]any
690}
691
692// mutableSections lists config sections that are intentionally modified by service operations.
693var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups.", "auto_route_allowed_domains", "auto."}
694
695func isMutableKey(key string) bool {
696 for _, prefix := range mutableSections {
697 if strings.HasPrefix(key, prefix) {
698 return true
699 }
700 }
701
702 switch key {
703 case "routes", "external_routes", "attachments", "network_groups", "auto_route_allowed_domains", "auto":
704 return true
705 }
706
707 return false
708}
709
710func (s *Service) snapshotCriticalFields() configSnapshot {
711 snap := configSnapshot{values: make(map[string]any)}
712 for _, key := range s.viper.AllKeys() {
713 if isMutableKey(key) {
714 continue
715 }
716 snap.values[key] = s.viper.Get(key)
717 }
718 return snap
719}
720
721func (s *Service) verifyCriticalFields(snap configSnapshot) error {
722 for key, oldVal := range snap.values {
723 if oldVal == nil {
724 continue
725 }
726 newVal := s.viper.Get(key)
727 if !reflect.DeepEqual(oldVal, newVal) {
728 return fmt.Errorf("config key %q changed from %v to %v after save", key, oldVal, newVal)
729 }
730 }
731
732 return nil
733}
734
735func (s *Service) writeConfigSurgical(configFile string, snapshotConfig Config) error {
736 data, err := os.ReadFile(configFile)
737 var configMap map[string]any
738 if err != nil {
739 if os.IsNotExist(err) {
740 configMap = s.viper.AllSettings()
741 } else {
742 return fmt.Errorf("failed to read config file: %w", err)
743 }
744 }
745
746 if configMap == nil {
747 configMap = make(map[string]any)
748 }
749 if len(data) > 0 {
750 if err := toml.Unmarshal(data, &configMap); err != nil {
751 return fmt.Errorf("failed to parse config file: %w", err)
752 }
753 }
754
755 delete(configMap, "routes")
756 configMap["external_routes"] = snapshotConfig.ExternalRoutes
757 configMap["attachments"] = snapshotConfig.Attachments
758 configMap["network_groups"] = snapshotConfig.NetworkGroups
759 applyAutoAllowedDomains(configMap, snapshotConfig.AutoRouteAllowedDomains)
760
761 out, err := toml.Marshal(configMap)
762 if err != nil {
763 return fmt.Errorf("failed to marshal config: %w", err)
764 }
765
766 routes, err := canonicalRoutesForSave(snapshotConfig.Routes)
767 if err != nil {
768 return err
769 }
770 routesOut := renderCanonicalRoutesSection(routes)
771 if routesOut != "" {
772 if len(out) > 0 {
773 out = append(out, '\n')
774 }
775 out = append(out, []byte(routesOut)...)
776 }
777
778 configDir := filepath.Dir(configFile)
779 tmpFile, err := os.CreateTemp(configDir, ".gordon-config-*.tmp")
780 if err != nil {
781 return fmt.Errorf("failed to create temp config file: %w", err)
782 }
783 tmpPath := tmpFile.Name()
784
785 if _, writeErr := tmpFile.Write(out); writeErr != nil {
786 _ = tmpFile.Close()
787 _ = os.Remove(tmpPath)
788 return fmt.Errorf("failed to write temp config: %w", writeErr)
789 }
790 if chmodErr := tmpFile.Chmod(0600); chmodErr != nil {
791 _ = tmpFile.Close()
792 _ = os.Remove(tmpPath)
793 return fmt.Errorf("failed to set temp config permissions: %w", chmodErr)
794 }
795 if closeErr := tmpFile.Close(); closeErr != nil {
796 _ = os.Remove(tmpPath)
797 return fmt.Errorf("failed to close temp config: %w", closeErr)
798 }
799 if renameErr := os.Rename(tmpPath, configFile); renameErr != nil {
800 _ = os.Remove(tmpPath)
801 return fmt.Errorf("failed to rename temp config: %w", renameErr)
802 }
803
804 return nil
805}
806
807func applyAutoAllowedDomains(config map[string]any, allowedDomains []string) {
808 // Write to both old and new keys for backward compat
809 config["auto_route_allowed_domains"] = allowedDomains
810 if autoSection, ok := config["auto"].(map[string]any); ok {
811 autoSection["allowed_domains"] = allowedDomains
812 return
813 }
814
815 config["auto"] = map[string]any{
816 "allowed_domains": allowedDomains,
817 }
818}
819
820func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConfig, error) {
821 result := make(map[string]routeConfig)
822 if routes == nil {
823 return result, nil
824 }
825
826 for key, route := range routes {
827 if strings.HasPrefix(key, "http://") {
828 continue
829 }
830 if !domain.IsValidRouteDomain(key) {
831 return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid)
832 }
833 result[key] = route
834 }
835
836 for key, route := range routes {
837 if !strings.HasPrefix(key, "http://") {
838 continue
839 }
840
841 domainName := strings.TrimPrefix(key, "http://")
842 if !domain.IsValidRouteDomain(domainName) {
843 return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid)
844 }
845 if _, exists := result[domainName]; exists {
846 continue
847 }
848 route.HTTPS = false
849 result[domainName] = route
850 }
851
852 return result, nil
853}
854
855func renderCanonicalRoutesSection(routes map[string]routeConfig) string {
856 if len(routes) == 0 {
857 return ""
858 }
859
860 keys := make([]string, 0, len(routes))
861 for key := range routes {
862 keys = append(keys, key)
863 }
864 slices.Sort(keys)
865
866 var b strings.Builder
867 b.WriteString("[routes]\n")
868 for _, key := range keys {
869 route := routes[key]
870 b.WriteString(strconv.Quote(key))
871 b.WriteString(" = { image = ")
872 b.WriteString(strconv.Quote(route.Image))
873 b.WriteString(", https = ")
874 if route.HTTPS {
875 b.WriteString("true")
876 } else {
877 b.WriteString("false")
878 }
879 b.WriteString(" }\n")
880 }
881
882 return b.String()
883}
884
885func backupConfigFile(configFile string) error {
886 src, err := os.ReadFile(configFile)
887 if err != nil {
888 if os.IsNotExist(err) {
889 return nil
890 }
891 return fmt.Errorf("failed to read config for backup: %w", err)
892 }
893
894 backupPath := fmt.Sprintf("%s.bak.%d", configFile, time.Now().UnixNano())
895 if err := os.WriteFile(backupPath, src, 0600); err != nil {
896 return fmt.Errorf("failed to write backup config: %w", err)
897 }
898 if err := cleanupOldBackups(configFile, 5); err != nil {
899 return fmt.Errorf("failed to clean up old backups: %w", err)
900 }
901
902 return nil
903}
904
905// cleanupOldBackups removes old backup files, keeping only the most recent `keep` backups.
906// It only removes files matching the pattern `<configFile>.bak.<numeric-timestamp>`.
907func cleanupOldBackups(configFile string, keep int) error {
908 dir := filepath.Dir(configFile)
909 base := filepath.Base(configFile)
910 entries, err := os.ReadDir(dir)
911 if err != nil {
912 return fmt.Errorf("failed to read backup directory: %w", err)
913 }
914
915 prefix := base + ".bak."
916 type tsBackup struct {
917 ts int64
918 name string
919 }
920 var backups []tsBackup
921 for _, entry := range entries {
922 if entry.IsDir() {
923 continue
924 }
925 name := entry.Name()
926 if !strings.HasPrefix(name, prefix) {
927 continue
928 }
929 suffix := strings.TrimPrefix(name, prefix)
930 ts, err := strconv.ParseInt(suffix, 10, 64)
931 if err != nil {
932 continue
933 }
934 backups = append(backups, tsBackup{ts: ts, name: name})
935 }
936
937 slices.SortFunc(backups, func(a, b tsBackup) int {
938 return cmp.Compare(b.ts, a.ts)
939 })
940 if len(backups) <= keep {
941 return nil
942 }
943
944 var firstErr error
945 for _, backup := range backups[keep:] {
946 backupPath := filepath.Join(dir, backup.name)
947 if err := os.Remove(backupPath); err != nil && firstErr == nil {
948 firstErr = fmt.Errorf("failed to remove old backup %s: %w", backupPath, err)
949 }
950 }
951
952 return firstErr
953}
954
955func restoreConfigBackup(configFile string) error {
956 dir := filepath.Dir(configFile)
957 base := filepath.Base(configFile)
958 entries, err := os.ReadDir(dir)
959 if err != nil {
960 return fmt.Errorf("failed to read backup directory: %w", err)
961 }
962
963 prefix := base + ".bak."
964 type tsBackup struct {
965 ts int64
966 name string
967 }
968 var backups []tsBackup
969 for _, entry := range entries {
970 if entry.IsDir() {
971 continue
972 }
973 name := entry.Name()
974 if !strings.HasPrefix(name, prefix) {
975 continue
976 }
977 suffix := strings.TrimPrefix(name, prefix)
978 ts, err := strconv.ParseInt(suffix, 10, 64)
979 if err != nil {
980 continue
981 }
982 backups = append(backups, tsBackup{ts: ts, name: name})
983 }
984
985 slices.SortFunc(backups, func(a, b tsBackup) int {
986 return cmp.Compare(b.ts, a.ts)
987 })
988
989 backupPath := configFile + ".bak"
990 if len(backups) > 0 {
991 backupPath = filepath.Join(dir, backups[0].name)
992 }
993
994 src, err := os.ReadFile(backupPath)
995 if err != nil {
996 return fmt.Errorf("failed to read backup: %w", err)
997 }
998
999 if err := os.WriteFile(configFile, src, 0600); err != nil {
1000 return fmt.Errorf("failed to restore backup: %w", err)
1001 }
1002
1003 return nil
1004}
1005
1006// Watch starts watching for configuration changes.
1007func (s *Service) Watch(ctx context.Context, onChange func()) error {
1008 log := zerowrap.FromCtx(ctx)
1009
1010 s.viper.OnConfigChange(func(e fsnotify.Event) {
1011 // Check if this event is within the debounce window of our own Save
1012 lastSave := atomic.LoadInt64(&s.lastSaveTime)
1013 if lastSave > 0 && time.Now().UnixNano()-lastSave < s.debounceDelay {
1014 log.Debug().Str("file", e.Name).Msg("skipping config reload (triggered by save)")
1015 return
1016 }
1017
1018 log.Info().Str("file", e.Name).Msg("config file changed")
1019
1020 if err := s.viper.ReadInConfig(); err != nil {
1021 log.WrapErr(err, "failed to reload config")
1022 return
1023 }
1024
1025 if err := s.Load(ctx); err != nil {
1026 log.WrapErr(err, "failed to load updated config")
1027 return
1028 }
1029
1030 if onChange != nil {
1031 onChange()
1032 }
1033 })
1034
1035 s.viper.WatchConfig()
1036 log.Info().Msg("watching for configuration changes")
1037
1038 return nil
1039}
1040
1041// GetServerPort returns the configured server port.
1042func (s *Service) GetServerPort() int {
1043 s.mu.RLock()
1044 defer s.mu.RUnlock()
1045 return s.config.ServerPort
1046}
1047
1048// GetRegistryPort returns the configured registry port.
1049func (s *Service) GetRegistryPort() int {
1050 s.mu.RLock()
1051 defer s.mu.RUnlock()
1052 return s.config.RegistryPort
1053}
1054
1055// GetRegistryDomain returns the configured registry domain.
1056func (s *Service) GetRegistryDomain() string {
1057 s.mu.RLock()
1058 defer s.mu.RUnlock()
1059 return s.config.RegistryDomain
1060}
1061
1062// GetDataDir returns the configured data directory.
1063func (s *Service) GetDataDir() string {
1064 s.mu.RLock()
1065 defer s.mu.RUnlock()
1066 return s.config.DataDir
1067}
1068
1069// IsAutoRouteEnabled returns whether auto-route is enabled.
1070func (s *Service) IsAutoRouteEnabled() bool {
1071 s.mu.RLock()
1072 defer s.mu.RUnlock()
1073 return s.config.AutoRouteEnabled
1074}
1075
1076// IsNetworkIsolationEnabled returns whether network isolation is enabled.
1077func (s *Service) IsNetworkIsolationEnabled() bool {
1078 s.mu.RLock()
1079 defer s.mu.RUnlock()
1080 return s.config.NetworkIsolation
1081}
1082
1083// GetNetworkPrefix returns the prefix for created networks.
1084func (s *Service) GetNetworkPrefix() string {
1085 s.mu.RLock()
1086 defer s.mu.RUnlock()
1087 return s.config.NetworkPrefix
1088}
1089
1090// GetConfig returns a copy of the current configuration.
1091func (s *Service) GetConfig() Config {
1092 s.mu.RLock()
1093 defer s.mu.RUnlock()
1094 return s.config
1095}
1096
1097// GetVolumeConfig returns volume configuration.
1098func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) {
1099 s.mu.RLock()
1100 defer s.mu.RUnlock()
1101 return s.config.VolumeAutoCreate, s.config.VolumePrefix, s.config.VolumePreserve
1102}
1103
1104// GetNetworkGroups returns network group configuration.
1105func (s *Service) GetNetworkGroups() map[string][]string {
1106 s.mu.RLock()
1107 defer s.mu.RUnlock()
1108
1109 result := make(map[string][]string)
1110 for k, v := range s.config.NetworkGroups {
1111 result[k] = append([]string{}, v...)
1112 }
1113 return result
1114}
1115
1116// GetAttachmentConfig returns a consistent snapshot of attachments and network
1117// groups under a single lock, preventing cross-field races during config reloads.
1118func (s *Service) GetAttachmentConfig() out.AttachmentConfigSnapshot {
1119 s.mu.RLock()
1120 defer s.mu.RUnlock()
1121
1122 attachments := make(map[string][]string, len(s.config.Attachments))
1123 for k, v := range s.config.Attachments {
1124 attachments[k] = append([]string{}, v...)
1125 }
1126
1127 networkGroups := make(map[string][]string, len(s.config.NetworkGroups))
1128 for k, v := range s.config.NetworkGroups {
1129 networkGroups[k] = append([]string{}, v...)
1130 }
1131
1132 return out.AttachmentConfigSnapshot{
1133 Attachments: attachments,
1134 NetworkGroups: networkGroups,
1135 }
1136}
1137
1138// GetAllAttachments returns all configured attachments.
1139func (s *Service) GetAllAttachments(_ context.Context) map[string][]string {
1140 s.mu.RLock()
1141 defer s.mu.RUnlock()
1142
1143 result := make(map[string][]string)
1144 for k, v := range s.config.Attachments {
1145 result[k] = append([]string{}, v...)
1146 }
1147 return result
1148}
1149
1150// GetAttachmentsFor returns attachments for a specific domain or network group.
1151func (s *Service) GetAttachmentsFor(_ context.Context, domainOrGroup string) ([]string, error) {
1152 s.mu.RLock()
1153 defer s.mu.RUnlock()
1154
1155 images, exists := s.config.Attachments[domainOrGroup]
1156 if !exists {
1157 return nil, domain.ErrAttachmentNotFound
1158 }
1159
1160 return append([]string{}, images...), nil
1161}
1162
1163// AddAttachment adds an image to a domain/group's attachments.
1164func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
1165 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1166 zerowrap.FieldLayer: "usecase",
1167 zerowrap.FieldUseCase: "AddAttachment",
1168 "target": domainOrGroup,
1169 "image": image,
1170 })
1171 log := zerowrap.FromCtx(ctx)
1172
1173 // Validate input
1174 if domainOrGroup == "" {
1175 return domain.ErrAttachmentTargetEmpty
1176 }
1177 if image == "" {
1178 return domain.ErrAttachmentImageEmpty
1179 }
1180
1181 s.mu.Lock()
1182
1183 // Initialize map if needed
1184 if s.config.Attachments == nil {
1185 s.config.Attachments = make(map[string][]string)
1186 }
1187
1188 // Check if already exists
1189 existing := s.config.Attachments[domainOrGroup]
1190 for _, img := range existing {
1191 if img == image {
1192 s.mu.Unlock()
1193 return domain.ErrAttachmentExists
1194 }
1195 }
1196
1197 // Store previous value for rollback
1198 previousImages := append([]string{}, existing...)
1199 hadKey := len(existing) > 0
1200
1201 // Add the image
1202 s.config.Attachments[domainOrGroup] = append(existing, image)
1203 s.mu.Unlock()
1204
1205 // Persist to disk - rollback on failure
1206 if err := s.Save(ctx); err != nil {
1207 log.Warn().Err(err).Msg("failed to persist attachment to disk, rolling back")
1208 s.mu.Lock()
1209 if hadKey {
1210 s.config.Attachments[domainOrGroup] = previousImages
1211 } else {
1212 delete(s.config.Attachments, domainOrGroup)
1213 }
1214 s.mu.Unlock()
1215 return err
1216 }
1217
1218 log.Info().Msg("attachment added to configuration")
1219 return nil
1220}
1221
1222// RemoveAttachment removes an image from a domain/group's attachments.
1223func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
1224 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1225 zerowrap.FieldLayer: "usecase",
1226 zerowrap.FieldUseCase: "RemoveAttachment",
1227 "target": domainOrGroup,
1228 "image": image,
1229 })
1230 log := zerowrap.FromCtx(ctx)
1231
1232 // Validate input
1233 if domainOrGroup == "" {
1234 return domain.ErrAttachmentTargetEmpty
1235 }
1236 if image == "" {
1237 return domain.ErrAttachmentImageEmpty
1238 }
1239
1240 s.mu.Lock()
1241
1242 existing, exists := s.config.Attachments[domainOrGroup]
1243 if !exists {
1244 s.mu.Unlock()
1245 return domain.ErrAttachmentNotFound
1246 }
1247
1248 // Find and remove the image
1249 found := false
1250 newImages := make([]string, 0, len(existing))
1251 for _, img := range existing {
1252 if img == image {
1253 found = true
1254 } else {
1255 newImages = append(newImages, img)
1256 }
1257 }
1258
1259 if !found {
1260 s.mu.Unlock()
1261 return domain.ErrAttachmentNotFound
1262 }
1263
1264 // Store previous value for rollback
1265 previousImages := existing
1266
1267 // Update or remove the key
1268 if len(newImages) == 0 {
1269 delete(s.config.Attachments, domainOrGroup)
1270 } else {
1271 s.config.Attachments[domainOrGroup] = newImages
1272 }
1273 s.mu.Unlock()
1274
1275 // Persist to disk - rollback on failure
1276 if err := s.Save(ctx); err != nil {
1277 log.Warn().Err(err).Msg("failed to persist attachment removal to disk, rolling back")
1278 s.mu.Lock()
1279 s.config.Attachments[domainOrGroup] = previousImages
1280 s.mu.Unlock()
1281 return err
1282 }
1283
1284 log.Info().Msg("attachment removed from configuration")
1285 return nil
1286}
1287
1288// GetPreviewConfig returns the preview environment configuration.
1289func (s *Service) GetPreviewConfig() domain.PreviewConfig {
1290 s.mu.RLock()
1291 defer s.mu.RUnlock()
1292 return domain.PreviewConfig{
1293 Enabled: s.config.PreviewEnabled,
1294 TTL: s.config.PreviewTTL,
1295 Separator: s.config.PreviewSeparator,
1296 TagPatterns: s.config.PreviewTagPatterns,
1297 DataCopy: s.config.PreviewDataCopy,
1298 EnvCopy: s.config.PreviewEnvCopy,
1299 }
1300}
1301
1302// IsPreviewEnabled returns whether preview environments are enabled.
1303func (s *Service) IsPreviewEnabled() bool {
1304 s.mu.RLock()
1305 defer s.mu.RUnlock()
1306 return s.config.PreviewEnabled
1307}
1308
1309// IsAutoEnabled returns whether the auto feature is enabled (alias for IsAutoRouteEnabled).
1310func (s *Service) IsAutoEnabled() bool {
1311 return s.IsAutoRouteEnabled()
1312}
1313
1314// GetPreviewTagPatterns returns the configured tag patterns for preview environments.
1315func (s *Service) GetPreviewTagPatterns() []string {
1316 s.mu.RLock()
1317 defer s.mu.RUnlock()
1318 return append([]string{}, s.config.PreviewTagPatterns...)
1319}
1320
1321// GetAllowedDomains returns the auto-route allowed domain patterns.
1322func (s *Service) GetAllowedDomains() []string {
1323 s.mu.RLock()
1324 defer s.mu.RUnlock()
1325 return append([]string{}, s.config.AutoRouteAllowedDomains...)
1326}
1327
1328// GetAutoRouteAllowedDomains returns the configured auto-route domain allowlist.
1329func (s *Service) GetAutoRouteAllowedDomains(_ context.Context) ([]string, error) {
1330 s.mu.RLock()
1331 defer s.mu.RUnlock()
1332
1333 return append([]string{}, s.config.AutoRouteAllowedDomains...), nil
1334}
1335
1336// AddAutoRouteAllowedDomain adds a domain pattern to the auto-route allowlist.
1337func (s *Service) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1338 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1339 zerowrap.FieldLayer: "usecase",
1340 zerowrap.FieldUseCase: "AddAutoRouteAllowedDomain",
1341 "pattern": pattern,
1342 })
1343 log := zerowrap.FromCtx(ctx)
1344
1345 if err := validateDomainPattern(pattern); err != nil {
1346 return err
1347 }
1348
1349 s.mu.Lock()
1350 for _, existing := range s.config.AutoRouteAllowedDomains {
1351 if existing == pattern {
1352 s.mu.Unlock()
1353 return nil
1354 }
1355 }
1356 snapshot := make([]string, len(s.config.AutoRouteAllowedDomains))
1357 copy(snapshot, s.config.AutoRouteAllowedDomains)
1358 s.config.AutoRouteAllowedDomains = append(s.config.AutoRouteAllowedDomains, pattern)
1359 s.mu.Unlock()
1360
1361 if err := s.Save(ctx); err != nil {
1362 log.Warn().Err(err).Msg("failed to persist allowed domain, rolling back")
1363 s.mu.Lock()
1364 s.config.AutoRouteAllowedDomains = snapshot
1365 s.mu.Unlock()
1366 return err
1367 }
1368
1369 log.Info().Msg("auto-route allowed domain added")
1370 return nil
1371}
1372
1373// RemoveAutoRouteAllowedDomain removes a domain pattern from the auto-route allowlist.
1374func (s *Service) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1375 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1376 zerowrap.FieldLayer: "usecase",
1377 zerowrap.FieldUseCase: "RemoveAutoRouteAllowedDomain",
1378 "pattern": pattern,
1379 })
1380 log := zerowrap.FromCtx(ctx)
1381
1382 s.mu.Lock()
1383 previous := append([]string{}, s.config.AutoRouteAllowedDomains...)
1384 filtered := make([]string, 0, len(s.config.AutoRouteAllowedDomains))
1385 removed := false
1386 for _, existing := range s.config.AutoRouteAllowedDomains {
1387 if existing == pattern {
1388 removed = true
1389 continue
1390 }
1391 filtered = append(filtered, existing)
1392 }
1393 s.config.AutoRouteAllowedDomains = filtered
1394 s.mu.Unlock()
1395
1396 if !removed {
1397 log.Debug().Msg("pattern not found in allowlist")
1398 return nil
1399 }
1400
1401 if err := s.Save(ctx); err != nil {
1402 log.Warn().Err(err).Msg("failed to persist allowed domain removal, rolling back")
1403 s.mu.Lock()
1404 s.config.AutoRouteAllowedDomains = previous
1405 s.mu.Unlock()
1406 return err
1407 }
1408
1409 log.Info().Msg("auto-route allowed domain removed")
1410 return nil
1411}
1412
1413func validateDomainPattern(pattern string) error {
1414 if pattern == "" {
1415 return fmt.Errorf("%w: pattern is required", domain.ErrInvalidDomainPattern)
1416 }
1417 if pattern == "*" {
1418 return nil
1419 }
1420 if pattern != strings.ToLower(pattern) {
1421 return fmt.Errorf("%w: must be lowercase", domain.ErrInvalidDomainPattern)
1422 }
1423 if strings.HasSuffix(pattern, ".") {
1424 return fmt.Errorf("%w: must not have trailing dots", domain.ErrInvalidDomainPattern)
1425 }
1426 if strings.Contains(pattern, "**") {
1427 return fmt.Errorf("%w: must not contain double wildcards", domain.ErrInvalidDomainPattern)
1428 }
1429 if strings.Count(pattern, "*") > 1 {
1430 return fmt.Errorf("%w: must not contain multiple wildcards", domain.ErrInvalidDomainPattern)
1431 }
1432 if strings.Contains(pattern, "*") {
1433 if !strings.HasPrefix(pattern, "*.") {
1434 return fmt.Errorf("%w: wildcard must be in *.domain form", domain.ErrInvalidDomainPattern)
1435 }
1436 if !isValidExactDomain(strings.TrimPrefix(pattern, "*.")) {
1437 return fmt.Errorf("%w: invalid domain after wildcard", domain.ErrInvalidDomainPattern)
1438 }
1439 return nil
1440 }
1441 if !isValidExactDomain(pattern) {
1442 return fmt.Errorf("%w: invalid domain format", domain.ErrInvalidDomainPattern)
1443 }
1444 return nil
1445}
1446
1447var exactDomainPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`)
1448
1449func isValidExactDomain(pattern string) bool {
1450 return exactDomainPattern.MatchString(pattern)
1451}
1452
1453// GetExternalRoutes returns all configured external routes.
1454func (s *Service) GetExternalRoutes() map[string]string {
1455 s.mu.RLock()
1456 defer s.mu.RUnlock()
1457
1458 result := make(map[string]string)
1459 for k, v := range s.config.ExternalRoutes {
1460 result[k] = v
1461 }
1462 return result
1463}
1464
1465// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
1466func ExtractDomainFromImageName(imageName string) (string, bool) {
1467 parts := strings.Split(imageName, ":")
1468 imageNamePart := parts[0]
1469
1470 // Simple domain check - contains at least one dot and valid characters
1471 if strings.Contains(imageNamePart, ".") && !strings.HasPrefix(imageNamePart, ".") && !strings.HasSuffix(imageNamePart, ".") {
1472 // Additional check: should not look like a registry path
1473 if !strings.Contains(imageNamePart, "/") || strings.Count(imageNamePart, ".") > 0 {
1474 return imageNamePart, true
1475 }
1476 }
1477
1478 return "", false
1479}