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