// Package domains manages domain registrations from Wild Cloud, Wild Works, // and manual entries. Domains register with Central to get DNS, gateway // routing, TLS certificates, and optional public exposure via DDNS. // // Each registration is keyed by its domain — one registration per domain. // Central registers its own UI domain here on startup (source: "central"). package domains import ( "fmt" "log/slog" "os" "path/filepath" "strings" "sync" "gopkg.in/yaml.v3" ) // BackendType describes how the gateway handles traffic for this domain. type BackendType string const ( BackendTCPPassthrough BackendType = "tcp-passthrough" // L4 SNI passthrough (k8s) BackendHTTP BackendType = "http" // L7 HTTP reverse proxy BackendDNSOnly BackendType = "dns-only" // DNS resolution only, no gateway ) // TLSMode describes how TLS is handled for the domain. type TLSMode string const ( TLSTerminate TLSMode = "terminate" // Central terminates TLS TLSPassthrough TLSMode = "passthrough" // Backend handles TLS (k8s traefik) TLSNone TLSMode = "none" // No TLS (dns-only) ) // Backend describes the target for a domain. type Backend struct { Address string `yaml:"address" json:"address"` // host:port Type BackendType `yaml:"type" json:"type"` // tcp-passthrough or http Health string `yaml:"health,omitempty" json:"health,omitempty"` } // HeaderConfig describes custom request/response headers for L7 HTTP domains. type HeaderConfig struct { Request map[string]string `yaml:"request,omitempty" json:"request,omitempty"` Response map[string]string `yaml:"response,omitempty" json:"response,omitempty"` } // Route describes one path→backend mapping within a domain. // Routes are evaluated in order — first match wins. // A route with empty Paths is a catch-all (should be last). type Route struct { Paths []string `yaml:"paths,omitempty" json:"paths,omitempty"` // path prefixes (empty = catch-all) Backend Backend `yaml:"backend" json:"backend"` // where to route Headers *HeaderConfig `yaml:"headers,omitempty" json:"headers,omitempty"` // per-route headers IPAllow []string `yaml:"ipAllow,omitempty" json:"ipAllow,omitempty"` // per-route CIDR whitelist } // AuthConfig describes forward-auth protection for an L7 HTTP domain. // Only applicable to domains with backend type "http" (L7 TLS termination). type AuthConfig struct { Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` Policy string `yaml:"policy,omitempty" json:"policy,omitempty"` // one_factor, two_factor, bypass } // Domain represents a registered domain. The Domain field is the unique key. // // Simple domains use Backend directly. Multi-backend domains use Routes // for path-based splitting (Backend is ignored when Routes is present). type Domain struct { DomainName string `yaml:"domain" json:"domain"` // FQDN — unique key Source string `yaml:"source,omitempty" json:"source,omitempty"` // who registered: wild-cloud, wild-works, manual Backend Backend `yaml:"backend,omitempty" json:"backend,omitempty"` // single backend (simple case) Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain Public bool `yaml:"public,omitempty" json:"public,omitempty"` // true = internet-visible (DDNS + external), false = LAN only TLS TLSMode `yaml:"tls,omitempty" json:"tls,omitempty"` // passthrough or terminate Auth *AuthConfig `yaml:"auth,omitempty" json:"auth,omitempty"` // forward-auth protection (L7 HTTP only) // Multi-backend path routing. When present, Backend is ignored. // Each route maps path prefixes to a specific backend with its own L7 options. Routes []Route `yaml:"routes,omitempty" json:"routes,omitempty"` } // EffectiveRoutes returns the domain's routing as a normalized []Route. // Simple domains (Backend, no Routes) are wrapped in a single-element slice. func (d *Domain) EffectiveRoutes() []Route { if len(d.Routes) > 0 { return d.Routes } if d.Backend.Address == "" { return nil } return []Route{{Backend: d.Backend}} } // EffectiveBackendAddress returns the primary backend address for DNS/DDNS purposes. // For multi-route domains, returns the first route's backend. func (d *Domain) EffectiveBackendAddress() string { if len(d.Routes) > 0 { return d.Routes[0].Backend.Address } return d.Backend.Address } // EffectiveBackendType returns the backend type for routing decisions. func (d *Domain) EffectiveBackendType() BackendType { if len(d.Routes) > 0 { return d.Routes[0].Backend.Type } return d.Backend.Type } // Manager handles domain registration CRUD and triggers networking reconciliation. type Manager struct { dataDir string mu sync.RWMutex reconcileFn func() } // NewManager creates a new domain registration manager. func NewManager(dataDir string) *Manager { domainsDir := filepath.Join(dataDir, "domains") oldServicesDir := filepath.Join(dataDir, "services") // Migrate from old "services" directory if it exists if _, err := os.Stat(oldServicesDir); err == nil { if _, err := os.Stat(domainsDir); os.IsNotExist(err) { if err := os.Rename(oldServicesDir, domainsDir); err != nil { slog.Warn("failed to migrate services dir to domains", "error", err) } else { slog.Info("migrated data directory", "from", oldServicesDir, "to", domainsDir) } } } if err := os.MkdirAll(domainsDir, 0755); err != nil { slog.Warn("failed to create domains directory", "path", domainsDir, "error", err) } return &Manager{dataDir: dataDir} } // SetReconcileFn sets the function called after domain changes to update networking. func (m *Manager) SetReconcileFn(fn func()) { m.reconcileFn = fn } func (m *Manager) domainsDir() string { return filepath.Join(m.dataDir, "domains") } // domainToFilename converts a domain to a filesystem-safe filename. func domainToFilename(domain string) string { return strings.ReplaceAll(domain, ".", "_") + ".yaml" } func (m *Manager) domainPath(domain string) string { return filepath.Join(m.domainsDir(), domainToFilename(domain)) } // Register creates or updates a domain registration. func (m *Manager) Register(dom Domain) error { m.mu.Lock() defer m.mu.Unlock() if dom.DomainName == "" { return fmt.Errorf("domain is required") } hasBackend := dom.Backend.Address != "" hasRoutes := len(dom.Routes) > 0 if hasBackend && hasRoutes { return fmt.Errorf("domain must use backend or routes, not both") } if !hasBackend && !hasRoutes { return fmt.Errorf("backend address or routes required") } if hasRoutes { for i, r := range dom.Routes { if r.Backend.Address == "" { return fmt.Errorf("route %d: backend address is required", i) } if r.Backend.Type == "" { return fmt.Errorf("route %d: backend type is required", i) } } } else { if dom.Backend.Type == "" { return fmt.Errorf("backend type is required") } } // Default TLS mode based on backend type if dom.TLS == "" { switch dom.EffectiveBackendType() { case BackendTCPPassthrough: dom.TLS = TLSPassthrough case BackendDNSOnly: dom.TLS = TLSNone default: dom.TLS = TLSTerminate } } // Default source if dom.Source == "" { dom.Source = "manual" } data, err := yaml.Marshal(dom) if err != nil { return fmt.Errorf("marshaling domain: %w", err) } if err := os.WriteFile(m.domainPath(dom.DomainName), data, 0644); err != nil { return fmt.Errorf("writing domain file: %w", err) } slog.Info("domain registered", "domain", dom.DomainName, "public", dom.Public, "source", dom.Source, "subdomains", dom.Subdomains) if m.reconcileFn != nil { go func() { defer func() { if r := recover(); r != nil { slog.Error("reconcile panicked", "error", r) } }() m.reconcileFn() }() } return nil } // Deregister removes a domain registration by domain name. func (m *Manager) Deregister(domain string) error { m.mu.Lock() defer m.mu.Unlock() path := m.domainPath(domain) if _, err := os.Stat(path); os.IsNotExist(err) { return fmt.Errorf("domain %q not found", domain) } if err := os.Remove(path); err != nil { return fmt.Errorf("removing domain file: %w", err) } slog.Info("domain deregistered", "domain", domain) if m.reconcileFn != nil { go m.reconcileFn() } return nil } // Get retrieves a domain registration by domain name. func (m *Manager) Get(domain string) (*Domain, error) { m.mu.RLock() defer m.mu.RUnlock() data, err := os.ReadFile(m.domainPath(domain)) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("domain %q not found", domain) } return nil, fmt.Errorf("reading domain file: %w", err) } var dom Domain if err := yaml.Unmarshal(data, &dom); err != nil { return nil, fmt.Errorf("parsing domain file: %w", err) } return &dom, nil } // List returns all registered domains. func (m *Manager) List() ([]Domain, error) { m.mu.RLock() defer m.mu.RUnlock() entries, err := os.ReadDir(m.domainsDir()) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("reading domains directory: %w", err) } var doms []Domain for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" { continue } data, err := os.ReadFile(filepath.Join(m.domainsDir(), entry.Name())) if err != nil { slog.Warn("failed to read domain file", "file", entry.Name(), "error", err) continue } var dom Domain if err := yaml.Unmarshal(data, &dom); err != nil { slog.Warn("failed to parse domain file", "file", entry.Name(), "error", err) continue } doms = append(doms, dom) } return doms, nil } // DeregisterBySource removes all registrations from a given source that match // a backend address. Used by consumers to clean up before re-registering. func (m *Manager) DeregisterBySource(source, backendAddress string) error { doms, err := m.List() if err != nil { return err } for _, dom := range doms { if dom.Source == source && dom.EffectiveBackendAddress() == backendAddress { if err := m.Deregister(dom.DomainName); err != nil { slog.Warn("failed to deregister domain during cleanup", "domain", dom.DomainName, "error", err) } } } return nil } // Update applies partial updates to a domain registration. func (m *Manager) Update(domain string, updates map[string]any) error { dom, err := m.Get(domain) if err != nil { return err } if public, ok := updates["public"].(bool); ok { dom.Public = public } if sub, ok := updates["subdomains"].(bool); ok { dom.Subdomains = sub } if backend, ok := updates["backend"].(map[string]any); ok { if addr, ok := backend["address"].(string); ok { dom.Backend.Address = addr } if typ, ok := backend["type"].(string); ok { dom.Backend.Type = BackendType(typ) } if health, ok := backend["health"].(string); ok { dom.Backend.Health = health } } if tls, ok := updates["tls"].(string); ok { dom.TLS = TLSMode(tls) } if auth, ok := updates["auth"].(map[string]any); ok { if dom.Auth == nil { dom.Auth = &AuthConfig{} } if enabled, ok := auth["enabled"].(bool); ok { dom.Auth.Enabled = enabled } if policy, ok := auth["policy"].(string); ok { dom.Auth.Policy = policy } // Clear auth if disabled and no policy if !dom.Auth.Enabled && dom.Auth.Policy == "" { dom.Auth = nil } } return m.Register(*dom) }