diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index ba85a64..dc41984 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -113,7 +113,6 @@ func (api *API) RegisterSelf(port int) { } svc := services.Service{ - Name: "wild-central", Source: "wild-central", Domain: domain, Backend: services.Backend{ @@ -251,12 +250,13 @@ func (api *API) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareGetZoneID).Methods("GET") r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareSetZoneID).Methods("POST") - // Service registration + // Service registration — domain is the unique key r.HandleFunc("/api/v1/services", api.ServicesListAll).Methods("GET") r.HandleFunc("/api/v1/services", api.ServicesRegister).Methods("POST") - r.HandleFunc("/api/v1/services/{name}", api.ServicesGet).Methods("GET") - r.HandleFunc("/api/v1/services/{name}", api.ServicesUpdate).Methods("PATCH") - r.HandleFunc("/api/v1/services/{name}", api.ServicesDeregister).Methods("DELETE") + r.HandleFunc("/api/v1/services/deregister", api.ServicesDeregisterBySource).Methods("DELETE") + r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesGet).Methods("GET") + r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesUpdate).Methods("PATCH") + r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesDeregister).Methods("DELETE") // SSE events r.HandleFunc("/api/v1/events", api.GlobalEventStream).Methods("GET") diff --git a/internal/api/v1/handlers_certbot.go b/internal/api/v1/handlers_certbot.go index b00d92b..f367b8e 100644 --- a/internal/api/v1/handlers_certbot.go +++ b/internal/api/v1/handlers_certbot.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/wild-cloud/wild-central/internal/config" + "github.com/wild-cloud/wild-central/internal/services" ) // CertStatus returns TLS certificate status for all relevant domains: @@ -27,7 +28,7 @@ func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) { svcs, _ := api.services.List() for _, svc := range svcs { - if svc.TLS != "terminate" || svc.Domain == "" { + if svc.TLS != services.TLSTerminate || svc.Domain == "" { continue } if seen[svc.Domain] { @@ -39,7 +40,7 @@ func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) { entry := map[string]any{ "domain": svc.Domain, - "service": svc.Name, + "service": svc.Domain, "source": svc.Source, "cert": status, } diff --git a/internal/api/v1/handlers_services.go b/internal/api/v1/handlers_services.go index 0ae28fc..9d00de9 100644 --- a/internal/api/v1/handlers_services.go +++ b/internal/api/v1/handlers_services.go @@ -27,11 +27,11 @@ func (api *API) ServicesListAll(w http.ResponseWriter, r *http.Request) { }) } -// ServicesGet retrieves a service by name. +// ServicesGet retrieves a service by domain. func (api *API) ServicesGet(w http.ResponseWriter, r *http.Request) { - name := mux.Vars(r)["name"] + domain := mux.Vars(r)["domain"] - svc, err := api.services.Get(name) + svc, err := api.services.Get(domain) if err != nil { respondError(w, http.StatusNotFound, fmt.Sprintf("Service not found: %v", err)) return @@ -54,16 +54,16 @@ func (api *API) ServicesRegister(w http.ResponseWriter, r *http.Request) { } // Return the stored version (has defaults applied) - stored, _ := api.services.Get(svc.Name) + stored, _ := api.services.Get(svc.Domain) respondJSON(w, http.StatusCreated, map[string]any{ - "message": fmt.Sprintf("Service %q registered", svc.Name), + "message": fmt.Sprintf("Service %q registered", svc.Domain), "service": stored, }) } // ServicesUpdate applies partial updates to a service. func (api *API) ServicesUpdate(w http.ResponseWriter, r *http.Request) { - name := mux.Vars(r)["name"] + domain := mux.Vars(r)["domain"] var updates map[string]any if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { @@ -71,26 +71,44 @@ func (api *API) ServicesUpdate(w http.ResponseWriter, r *http.Request) { return } - if err := api.services.Update(name, updates); err != nil { + if err := api.services.Update(domain, updates); err != nil { respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to update service: %v", err)) return } - svc, _ := api.services.Get(name) + svc, _ := api.services.Get(domain) respondJSON(w, http.StatusOK, map[string]any{ - "message": fmt.Sprintf("Service %q updated", name), + "message": fmt.Sprintf("Service %q updated", domain), "service": svc, }) } // ServicesDeregister removes a service registration. func (api *API) ServicesDeregister(w http.ResponseWriter, r *http.Request) { - name := mux.Vars(r)["name"] + domain := mux.Vars(r)["domain"] - if err := api.services.Deregister(name); err != nil { + if err := api.services.Deregister(domain); err != nil { respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to deregister service: %v", err)) return } - respondMessage(w, http.StatusOK, fmt.Sprintf("Service %q deregistered", name)) + respondMessage(w, http.StatusOK, fmt.Sprintf("Service %q deregistered", domain)) +} + +// ServicesDeregisterBySource removes all services from a source with a given backend. +func (api *API) ServicesDeregisterBySource(w http.ResponseWriter, r *http.Request) { + source := r.URL.Query().Get("source") + backend := r.URL.Query().Get("backend") + + if source == "" { + respondError(w, http.StatusBadRequest, "source query parameter is required") + return + } + + if err := api.services.DeregisterBySource(source, backend); err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to deregister: %v", err)) + return + } + + respondMessage(w, http.StatusOK, fmt.Sprintf("Services from %q deregistered", source)) } diff --git a/internal/api/v1/handlers_services_test.go b/internal/api/v1/handlers_services_test.go index 182c0f7..0757f62 100644 --- a/internal/api/v1/handlers_services_test.go +++ b/internal/api/v1/handlers_services_test.go @@ -14,20 +14,17 @@ func TestServicesRegister(t *testing.T) { api, _ := setupTestAPI(t) body, _ := json.Marshal(map[string]any{ - "name": "my-api", - "source": "wild-works", "domain": "my-api.payne.io", + "source": "wild-works", "backend": map[string]any{ "address": "192.168.8.60:9001", "type": "http", - "health": "/health", }, "reach": "internal", }) req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) w := httptest.NewRecorder() - api.ServicesRegister(w, req) if w.Code != http.StatusCreated { @@ -37,194 +34,15 @@ func TestServicesRegister(t *testing.T) { var resp map[string]any json.Unmarshal(w.Body.Bytes(), &resp) - svc, ok := resp["service"].(map[string]any) - if !ok { - t.Fatal("expected service in response") - } - if svc["name"] != "my-api" { - t.Errorf("expected name my-api, got %v", svc["name"]) + svc := resp["service"].(map[string]any) + if svc["domain"] != "my-api.payne.io" { + t.Errorf("expected domain my-api.payne.io, got %v", svc["domain"]) } if svc["tls"] != "terminate" { - t.Errorf("expected tls=terminate default for http backend, got %v", svc["tls"]) + t.Errorf("expected tls=terminate default for http, got %v", svc["tls"]) } -} - -func TestServicesListAll_Empty(t *testing.T) { - api, _ := setupTestAPI(t) - - req := httptest.NewRequest("GET", "/api/v1/services", nil) - w := httptest.NewRecorder() - - api.ServicesListAll(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var resp map[string]any - json.Unmarshal(w.Body.Bytes(), &resp) - - svcs, ok := resp["services"].([]any) - if !ok { - t.Fatal("expected services array") - } - if len(svcs) != 0 { - t.Errorf("expected empty services, got %d", len(svcs)) - } -} - -func TestServicesListAll_WithServices(t *testing.T) { - api, _ := setupTestAPI(t) - - // Register two services - for _, name := range []string{"svc-a", "svc-b"} { - body, _ := json.Marshal(map[string]any{ - "name": name, - "source": "test", - "domain": name + ".example.com", - "backend": map[string]any{"address": "127.0.0.1:8080", "type": "http"}, - "reach": "internal", - }) - req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) - w := httptest.NewRecorder() - api.ServicesRegister(w, req) - if w.Code != http.StatusCreated { - t.Fatalf("register %s failed: %d", name, w.Code) - } - } - - req := httptest.NewRequest("GET", "/api/v1/services", nil) - w := httptest.NewRecorder() - - api.ServicesListAll(w, req) - - var resp map[string]any - json.Unmarshal(w.Body.Bytes(), &resp) - - svcs := resp["services"].([]any) - if len(svcs) != 2 { - t.Errorf("expected 2 services, got %d", len(svcs)) - } -} - -func TestServicesGet(t *testing.T) { - api, _ := setupTestAPI(t) - - // Register - body, _ := json.Marshal(map[string]any{ - "name": "get-test", - "source": "test", - "domain": "get-test.example.com", - "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, - "reach": "internal", - }) - req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) - w := httptest.NewRecorder() - api.ServicesRegister(w, req) - - // Get - req = httptest.NewRequest("GET", "/api/v1/services/get-test", nil) - req = mux.SetURLVars(req, map[string]string{"name": "get-test"}) - w = httptest.NewRecorder() - - api.ServicesGet(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var svc map[string]any - json.Unmarshal(w.Body.Bytes(), &svc) - - if svc["domain"] != "get-test.example.com" { - t.Errorf("expected domain get-test.example.com, got %v", svc["domain"]) - } -} - -func TestServicesGet_NotFound(t *testing.T) { - api, _ := setupTestAPI(t) - - req := httptest.NewRequest("GET", "/api/v1/services/nonexistent", nil) - req = mux.SetURLVars(req, map[string]string{"name": "nonexistent"}) - w := httptest.NewRecorder() - - api.ServicesGet(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("expected 404, got %d", w.Code) - } -} - -func TestServicesUpdate(t *testing.T) { - api, _ := setupTestAPI(t) - - // Register - body, _ := json.Marshal(map[string]any{ - "name": "update-test", - "source": "test", - "domain": "update-test.example.com", - "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, - "reach": "internal", - }) - req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) - w := httptest.NewRecorder() - api.ServicesRegister(w, req) - - // Update reach to public - update, _ := json.Marshal(map[string]any{"reach": "public"}) - req = httptest.NewRequest("PATCH", "/api/v1/services/update-test", bytes.NewReader(update)) - req = mux.SetURLVars(req, map[string]string{"name": "update-test"}) - w = httptest.NewRecorder() - - api.ServicesUpdate(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp map[string]any - json.Unmarshal(w.Body.Bytes(), &resp) - - svc := resp["service"].(map[string]any) - if svc["reach"] != "public" { - t.Errorf("expected reach=public after update, got %v", svc["reach"]) - } -} - -func TestServicesDeregister(t *testing.T) { - api, _ := setupTestAPI(t) - - // Register - body, _ := json.Marshal(map[string]any{ - "name": "delete-me", - "source": "test", - "domain": "delete-me.example.com", - "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, - "reach": "internal", - }) - req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) - w := httptest.NewRecorder() - api.ServicesRegister(w, req) - - // Delete - req = httptest.NewRequest("DELETE", "/api/v1/services/delete-me", nil) - req = mux.SetURLVars(req, map[string]string{"name": "delete-me"}) - w = httptest.NewRecorder() - - api.ServicesDeregister(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - // Verify it's gone - req = httptest.NewRequest("GET", "/api/v1/services/delete-me", nil) - req = mux.SetURLVars(req, map[string]string{"name": "delete-me"}) - w = httptest.NewRecorder() - api.ServicesGet(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("expected 404 after delete, got %d", w.Code) + if svc["source"] != "wild-works" { + t.Errorf("expected source wild-works, got %v", svc["source"]) } } @@ -232,19 +50,18 @@ func TestServicesRegister_TCPPassthrough(t *testing.T) { api, _ := setupTestAPI(t) body, _ := json.Marshal(map[string]any{ - "name": "cloud-instance", - "source": "wild-cloud", "domain": "cloud.payne.io", + "source": "wild-cloud", "backend": map[string]any{ - "address": "192.168.8.100:443", + "address": "192.168.8.240:443", "type": "tcp-passthrough", }, - "reach": "internal", + "subdomains": true, + "reach": "public", }) req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) w := httptest.NewRecorder() - api.ServicesRegister(w, req) if w.Code != http.StatusCreated { @@ -258,21 +75,153 @@ func TestServicesRegister_TCPPassthrough(t *testing.T) { if svc["tls"] != "passthrough" { t.Errorf("expected tls=passthrough for tcp-passthrough, got %v", svc["tls"]) } + if svc["subdomains"] != true { + t.Errorf("expected subdomains=true, got %v", svc["subdomains"]) + } } -func TestServicesRegister_Validation(t *testing.T) { +func TestServicesListAll_Empty(t *testing.T) { api, _ := setupTestAPI(t) - // Missing name + req := httptest.NewRequest("GET", "/api/v1/services", nil) + w := httptest.NewRecorder() + api.ServicesListAll(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + svcs := resp["services"].([]any) + if len(svcs) != 0 { + t.Errorf("expected empty, got %d", len(svcs)) + } +} + +func TestServicesGet(t *testing.T) { + api, _ := setupTestAPI(t) + + // Register body, _ := json.Marshal(map[string]any{ - "domain": "x.com", - "backend": map[string]any{"address": "127.0.0.1:80", "type": "http"}, + "domain": "get-test.example.com", + "source": "test", + "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, + "reach": "internal", }) req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) w := httptest.NewRecorder() api.ServicesRegister(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 for missing name, got %d", w.Code) + // Get by domain + req = httptest.NewRequest("GET", "/api/v1/services/get-test.example.com", nil) + req = mux.SetURLVars(req, map[string]string{"domain": "get-test.example.com"}) + w = httptest.NewRecorder() + api.ServicesGet(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var svc map[string]any + json.Unmarshal(w.Body.Bytes(), &svc) + if svc["domain"] != "get-test.example.com" { + t.Errorf("expected domain get-test.example.com, got %v", svc["domain"]) + } +} + +func TestServicesGet_NotFound(t *testing.T) { + api, _ := setupTestAPI(t) + + req := httptest.NewRequest("GET", "/api/v1/services/nonexistent.com", nil) + req = mux.SetURLVars(req, map[string]string{"domain": "nonexistent.com"}) + w := httptest.NewRecorder() + api.ServicesGet(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestServicesUpdate(t *testing.T) { + api, _ := setupTestAPI(t) + + // Register + body, _ := json.Marshal(map[string]any{ + "domain": "update-test.example.com", + "source": "test", + "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, + "reach": "internal", + }) + req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + w := httptest.NewRecorder() + api.ServicesRegister(w, req) + + // Update reach to public + update, _ := json.Marshal(map[string]any{"reach": "public"}) + req = httptest.NewRequest("PATCH", "/api/v1/services/update-test.example.com", bytes.NewReader(update)) + req = mux.SetURLVars(req, map[string]string{"domain": "update-test.example.com"}) + w = httptest.NewRecorder() + api.ServicesUpdate(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + svc := resp["service"].(map[string]any) + if svc["reach"] != "public" { + t.Errorf("expected reach=public, got %v", svc["reach"]) + } +} + +func TestServicesDeregister(t *testing.T) { + api, _ := setupTestAPI(t) + + // Register + body, _ := json.Marshal(map[string]any{ + "domain": "delete-me.example.com", + "source": "test", + "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, + "reach": "internal", + }) + req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + w := httptest.NewRecorder() + api.ServicesRegister(w, req) + + // Delete + req = httptest.NewRequest("DELETE", "/api/v1/services/delete-me.example.com", nil) + req = mux.SetURLVars(req, map[string]string{"domain": "delete-me.example.com"}) + w = httptest.NewRecorder() + api.ServicesDeregister(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Verify gone + req = httptest.NewRequest("GET", "/api/v1/services/delete-me.example.com", nil) + req = mux.SetURLVars(req, map[string]string{"domain": "delete-me.example.com"}) + w = httptest.NewRecorder() + api.ServicesGet(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 after delete, got %d", w.Code) + } +} + +func TestServicesRegister_Validation(t *testing.T) { + api, _ := setupTestAPI(t) + + // Missing domain + body, _ := json.Marshal(map[string]any{ + "backend": map[string]any{"address": "127.0.0.1:80", "type": "http"}, + "reach": "internal", + }) + req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + w := httptest.NewRecorder() + api.ServicesRegister(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for missing domain, got %d", w.Code) } } diff --git a/internal/api/v1/helpers.go b/internal/api/v1/helpers.go index 1ecf332..7959890 100644 --- a/internal/api/v1/helpers.go +++ b/internal/api/v1/helpers.go @@ -38,21 +38,16 @@ func (api *API) reconcileNetworking() { var httpRoutes []haproxy.HTTPRoute for _, svc := range svcs { - if svc.Reach == services.ReachOff { - continue - } - switch svc.Backend.Type { case services.BackendTCPPassthrough: instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{ - Name: svc.Name, - Domain: svc.Domain, - BackendIP: extractHost(svc.Backend.Address), - ExtraDomains: svc.ExtraDomains, + Name: svc.Domain, + Domain: svc.Domain, + BackendIP: extractHost(svc.Backend.Address), }) - case services.BackendHTTP, services.BackendStatic: + case services.BackendHTTP: httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ - Name: svc.Name, + Name: svc.Domain, Domain: svc.Domain, Backend: svc.Backend.Address, HealthPath: svc.Backend.Health, @@ -116,7 +111,7 @@ func (api *API) reconcileNetworking() { var instanceConfigs []config.InstanceConfig for _, svc := range svcs { - if svc.Reach == services.ReachOff || svc.Domain == "" { + if svc.Domain == "" { continue } @@ -127,17 +122,14 @@ func (api *API) reconcileNetworking() { dnsIP = extractHost(svc.Backend.Address) } - // Primary domain ic := config.InstanceConfig{} - ic.Cloud.Domain = svc.Domain ic.Cluster.LoadBalancerIp = dnsIP - // Extra domains (e.g., internal.cloud.payne.io) - for _, extra := range svc.ExtraDomains { - if strings.HasPrefix(extra, "internal.") { - // Internal-only domain: local=/ prevents upstream DNS forwarding - ic.Cloud.InternalDomain = extra - } + if svc.Reach == services.ReachInternal { + // Internal-only: local=/ prevents upstream DNS forwarding + ic.Cloud.InternalDomain = svc.Domain + } else { + ic.Cloud.Domain = svc.Domain } instanceConfigs = append(instanceConfigs, ic) @@ -182,7 +174,7 @@ func (api *API) ensureTLSCerts(globalCfg *config.GlobalConfig, svcs []services.S wildcardCert := certbot.HAProxyCertPath(gatewayDomain) if _, err := os.Stat(wildcardCert); err != nil { slog.Warn("reconcile/tls: no wildcard cert for gateway domain — provision via Certificates page", - "service", svc.Name, "domain", svc.Domain, "needed", "*."+gatewayDomain) + "domain", svc.Domain, "needed", "*."+gatewayDomain) } continue } @@ -190,7 +182,7 @@ func (api *API) ensureTLSCerts(globalCfg *config.GlobalConfig, svcs []services.S // Check individual cert if _, err := os.Stat(certbot.HAProxyCertPath(svc.Domain)); err != nil { slog.Warn("reconcile/tls: no cert for service — provision via Certificates page", - "service", svc.Name, "domain", svc.Domain) + "domain", svc.Domain) } } } diff --git a/internal/services/manager.go b/internal/services/manager.go index 86c2a49..a529095 100644 --- a/internal/services/manager.go +++ b/internal/services/manager.go @@ -1,9 +1,9 @@ // Package services manages service registrations from Wild Cloud, Wild Works, // and manual entries. Services register with Central to get DNS, gateway -// routing, TLS certificates, and optional public exposure (tunnels or direct). +// routing, TLS certificates, and optional public exposure via DDNS. // -// Registrations are stored as YAML files on disk and will later be backed by -// NATS JetStream KV for real-time coordination. +// Each registration is keyed by its domain — one registration per domain. +// Central's own services (its UI, VPN, firewall) come from config, not here. package services import ( @@ -11,6 +11,7 @@ import ( "log/slog" "os" "path/filepath" + "strings" "sync" "gopkg.in/yaml.v3" @@ -20,9 +21,8 @@ import ( type Reach string const ( - ReachOff Reach = "off" // localhost only — no DNS, no proxy ReachInternal Reach = "internal" // LAN-visible — DNS + proxy + TLS - ReachPublic Reach = "public" // internet-visible — + tunnel or direct exposure + ReachPublic Reach = "public" // internet-visible — + DDNS + external HAProxy ) // BackendType describes how the gateway handles traffic for this service. @@ -31,41 +31,38 @@ type BackendType string const ( BackendTCPPassthrough BackendType = "tcp-passthrough" // L4 SNI passthrough (k8s) BackendHTTP BackendType = "http" // L7 HTTP reverse proxy - BackendStatic BackendType = "static" // L7 static file serving ) // TLSMode describes how TLS is handled for the service. type TLSMode string const ( - TLSTerminate TLSMode = "terminate" // Central terminates TLS (wildcard cert) + TLSTerminate TLSMode = "terminate" // Central terminates TLS TLSPassthrough TLSMode = "passthrough" // Backend handles TLS (k8s traefik) ) // Backend describes the target for a service. type Backend struct { - Address string `yaml:"address" json:"address"` // host:port (e.g., "192.168.8.60:9001") - Type BackendType `yaml:"type" json:"type"` // how to route - Health string `yaml:"health,omitempty" json:"health,omitempty"` // health check path (e.g., "/health") + 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"` } -// Service represents a registered service. +// Service represents a registered service. The Domain is the unique key. type Service struct { - Name string `yaml:"name" json:"name"` - Source string `yaml:"source" json:"source"` // "wild-cloud", "wild-works", "manual" - SourceNode string `yaml:"sourceNode,omitempty" json:"sourceNode,omitempty"` // IP of the node running this service - Domain string `yaml:"domain" json:"domain"` // FQDN (e.g., "my-api.payne.io") - ExtraDomains []string `yaml:"extraDomains,omitempty" json:"extraDomains,omitempty"` // additional domains - Backend Backend `yaml:"backend" json:"backend"` - Reach Reach `yaml:"reach" json:"reach"` - TLS TLSMode `yaml:"tls,omitempty" json:"tls,omitempty"` + Domain 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" json:"backend"` // where to route + Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain + Reach Reach `yaml:"reach" json:"reach"` // internal or public + TLS TLSMode `yaml:"tls,omitempty" json:"tls,omitempty"` // passthrough or terminate } // Manager handles service registration CRUD and triggers networking reconciliation. type Manager struct { dataDir string mu sync.RWMutex - reconcileFn func() // called after service changes to update DNS/proxy/TLS + reconcileFn func() } // NewManager creates a new service registration manager. @@ -86,8 +83,13 @@ func (m *Manager) servicesDir() string { return filepath.Join(m.dataDir, "services") } -func (m *Manager) servicePath(name string) string { - return filepath.Join(m.servicesDir(), name+".yaml") +// domainToFilename converts a domain to a filesystem-safe filename. +func domainToFilename(domain string) string { + return strings.ReplaceAll(domain, ".", "_") + ".yaml" +} + +func (m *Manager) servicePath(domain string) string { + return filepath.Join(m.servicesDir(), domainToFilename(domain)) } // Register creates or updates a service registration. @@ -95,24 +97,19 @@ func (m *Manager) Register(svc Service) error { m.mu.Lock() defer m.mu.Unlock() - if svc.Name == "" { - return fmt.Errorf("service name is required") - } if svc.Domain == "" { - return fmt.Errorf("service domain is required") + return fmt.Errorf("domain is required") } if svc.Backend.Address == "" { return fmt.Errorf("backend address is required") } - - // Default reach to internal - if svc.Reach == "" { - svc.Reach = ReachInternal - } - // Default backend type to http if svc.Backend.Type == "" { - svc.Backend.Type = BackendHTTP + return fmt.Errorf("backend type is required") } + if svc.Reach == "" { + return fmt.Errorf("reach is required") + } + // Default TLS mode based on backend type if svc.TLS == "" { if svc.Backend.Type == BackendTCPPassthrough { @@ -122,16 +119,21 @@ func (m *Manager) Register(svc Service) error { } } + // Default source + if svc.Source == "" { + svc.Source = "manual" + } + data, err := yaml.Marshal(svc) if err != nil { return fmt.Errorf("marshaling service: %w", err) } - if err := os.WriteFile(m.servicePath(svc.Name), data, 0644); err != nil { + if err := os.WriteFile(m.servicePath(svc.Domain), data, 0644); err != nil { return fmt.Errorf("writing service file: %w", err) } - slog.Info("service registered", "name", svc.Name, "domain", svc.Domain, "reach", svc.Reach, "source", svc.Source) + slog.Info("service registered", "domain", svc.Domain, "reach", svc.Reach, "source", svc.Source, "subdomains", svc.Subdomains) if m.reconcileFn != nil { go m.reconcileFn() @@ -140,21 +142,21 @@ func (m *Manager) Register(svc Service) error { return nil } -// Deregister removes a service registration. -func (m *Manager) Deregister(name string) error { +// Deregister removes a service registration by domain. +func (m *Manager) Deregister(domain string) error { m.mu.Lock() defer m.mu.Unlock() - path := m.servicePath(name) + path := m.servicePath(domain) if _, err := os.Stat(path); os.IsNotExist(err) { - return fmt.Errorf("service %q not found", name) + return fmt.Errorf("service %q not found", domain) } if err := os.Remove(path); err != nil { return fmt.Errorf("removing service file: %w", err) } - slog.Info("service deregistered", "name", name) + slog.Info("service deregistered", "domain", domain) if m.reconcileFn != nil { go m.reconcileFn() @@ -163,15 +165,15 @@ func (m *Manager) Deregister(name string) error { return nil } -// Get retrieves a service registration by name. -func (m *Manager) Get(name string) (*Service, error) { +// Get retrieves a service registration by domain. +func (m *Manager) Get(domain string) (*Service, error) { m.mu.RLock() defer m.mu.RUnlock() - data, err := os.ReadFile(m.servicePath(name)) + data, err := os.ReadFile(m.servicePath(domain)) if err != nil { if os.IsNotExist(err) { - return nil, fmt.Errorf("service %q not found", name) + return nil, fmt.Errorf("service %q not found", domain) } return nil, fmt.Errorf("reading service file: %w", err) } @@ -221,19 +223,37 @@ func (m *Manager) List() ([]Service, error) { return services, nil } -// Update applies partial updates to a service registration. -func (m *Manager) Update(name string, updates map[string]any) error { - svc, err := m.Get(name) +// 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 { + svcs, err := m.List() + if err != nil { + return err + } + + for _, svc := range svcs { + if svc.Source == source && svc.Backend.Address == backendAddress { + if err := m.Deregister(svc.Domain); err != nil { + slog.Warn("failed to deregister service during cleanup", "domain", svc.Domain, "error", err) + } + } + } + + return nil +} + +// Update applies partial updates to a service registration. +func (m *Manager) Update(domain string, updates map[string]any) error { + svc, err := m.Get(domain) if err != nil { return err } - // Apply known fields if reach, ok := updates["reach"].(string); ok { svc.Reach = Reach(reach) } - if domain, ok := updates["domain"].(string); ok { - svc.Domain = domain + if sub, ok := updates["subdomains"].(bool); ok { + svc.Subdomains = sub } if backend, ok := updates["backend"].(map[string]any); ok { if addr, ok := backend["address"].(string); ok { diff --git a/internal/services/manager_test.go b/internal/services/manager_test.go index a5e9faa..c189427 100644 --- a/internal/services/manager_test.go +++ b/internal/services/manager_test.go @@ -8,13 +8,11 @@ func TestRegisterAndGet(t *testing.T) { mgr := NewManager(t.TempDir()) svc := Service{ - Name: "my-api", - Source: "wild-works", Domain: "my-api.payne.io", + Source: "wild-works", Backend: Backend{ Address: "192.168.8.60:9001", Type: BackendHTTP, - Health: "/health", }, Reach: ReachInternal, } @@ -23,7 +21,7 @@ func TestRegisterAndGet(t *testing.T) { t.Fatalf("Register failed: %v", err) } - got, err := mgr.Get("my-api") + got, err := mgr.Get("my-api.payne.io") if err != nil { t.Fatalf("Get failed: %v", err) } @@ -34,54 +32,59 @@ func TestRegisterAndGet(t *testing.T) { if got.Backend.Address != "192.168.8.60:9001" { t.Errorf("expected backend 192.168.8.60:9001, got %s", got.Backend.Address) } - if got.Reach != ReachInternal { - t.Errorf("expected reach internal, got %s", got.Reach) - } if got.TLS != TLSTerminate { t.Errorf("expected TLS terminate (default for http), got %s", got.TLS) } + if got.Source != "wild-works" { + t.Errorf("expected source wild-works, got %s", got.Source) + } } func TestRegisterTCPPassthrough(t *testing.T) { mgr := NewManager(t.TempDir()) svc := Service{ - Name: "payne-cloud", - Source: "wild-cloud", Domain: "cloud.payne.io", + Source: "wild-cloud", Backend: Backend{ - Address: "192.168.8.100:443", + Address: "192.168.8.240:443", Type: BackendTCPPassthrough, }, - Reach: ReachInternal, + Subdomains: true, + Reach: ReachPublic, } if err := mgr.Register(svc); err != nil { t.Fatalf("Register failed: %v", err) } - got, err := mgr.Get("payne-cloud") + got, err := mgr.Get("cloud.payne.io") if err != nil { t.Fatalf("Get failed: %v", err) } if got.TLS != TLSPassthrough { - t.Errorf("expected TLS passthrough (default for tcp-passthrough), got %s", got.TLS) + t.Errorf("expected TLS passthrough for tcp-passthrough, got %s", got.TLS) + } + if !got.Subdomains { + t.Error("expected subdomains=true") + } + if got.Reach != ReachPublic { + t.Errorf("expected reach public, got %s", got.Reach) } } func TestListServices(t *testing.T) { mgr := NewManager(t.TempDir()) - for _, name := range []string{"svc-a", "svc-b", "svc-c"} { + for _, domain := range []string{"a.example.com", "b.example.com", "c.example.com"} { if err := mgr.Register(Service{ - Name: name, + Domain: domain, Source: "test", - Domain: name + ".example.com", Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, Reach: ReachInternal, }); err != nil { - t.Fatalf("Register %s failed: %v", name, err) + t.Fatalf("Register %s failed: %v", domain, err) } } @@ -98,64 +101,136 @@ func TestDeregister(t *testing.T) { mgr := NewManager(t.TempDir()) if err := mgr.Register(Service{ - Name: "temp", - Source: "test", Domain: "temp.example.com", + Source: "test", Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, Reach: ReachInternal, }); err != nil { t.Fatalf("Register failed: %v", err) } - if err := mgr.Deregister("temp"); err != nil { + if err := mgr.Deregister("temp.example.com"); err != nil { t.Fatalf("Deregister failed: %v", err) } - if _, err := mgr.Get("temp"); err == nil { + if _, err := mgr.Get("temp.example.com"); err == nil { t.Error("expected error after deregister, got nil") } } +func TestDeregisterBySource(t *testing.T) { + mgr := NewManager(t.TempDir()) + + // Register 3 services from wild-cloud with same backend + for _, domain := range []string{"cloud.payne.io", "internal.cloud.payne.io", "payne.io"} { + mgr.Register(Service{ + Domain: domain, + Source: "wild-cloud", + Backend: Backend{Address: "192.168.8.240:443", Type: BackendTCPPassthrough}, + Reach: ReachPublic, + }) + } + // Register 1 service from wild-works (different source) + mgr.Register(Service{ + Domain: "my-api.payne.io", + Source: "wild-works", + Backend: Backend{Address: "127.0.0.1:9001", Type: BackendHTTP}, + Reach: ReachInternal, + }) + + // Deregister all wild-cloud services with backend 192.168.8.240:443 + if err := mgr.DeregisterBySource("wild-cloud", "192.168.8.240:443"); err != nil { + t.Fatalf("DeregisterBySource failed: %v", err) + } + + svcs, _ := mgr.List() + if len(svcs) != 1 { + t.Errorf("expected 1 remaining service, got %d", len(svcs)) + } + if svcs[0].Domain != "my-api.payne.io" { + t.Errorf("expected wild-works service to remain, got %s", svcs[0].Domain) + } +} + func TestUpdate(t *testing.T) { mgr := NewManager(t.TempDir()) - if err := mgr.Register(Service{ - Name: "updatable", - Source: "test", + mgr.Register(Service{ Domain: "updatable.example.com", + Source: "test", Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, Reach: ReachInternal, - }); err != nil { - t.Fatalf("Register failed: %v", err) - } + }) - if err := mgr.Update("updatable", map[string]any{ - "reach": "public", + if err := mgr.Update("updatable.example.com", map[string]any{ + "reach": "public", + "subdomains": true, }); err != nil { t.Fatalf("Update failed: %v", err) } - got, _ := mgr.Get("updatable") + got, _ := mgr.Get("updatable.example.com") if got.Reach != ReachPublic { t.Errorf("expected reach public after update, got %s", got.Reach) } + if !got.Subdomains { + t.Error("expected subdomains=true after update") + } } func TestRegisterValidation(t *testing.T) { mgr := NewManager(t.TempDir()) - // Missing name - if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Address: "127.0.0.1:80"}}); err == nil { - t.Error("expected error for missing name") - } - // Missing domain - if err := mgr.Register(Service{Name: "x", Backend: Backend{Address: "127.0.0.1:80"}}); err == nil { + if err := mgr.Register(Service{Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}, Reach: ReachInternal}); err == nil { t.Error("expected error for missing domain") } - // Missing backend - if err := mgr.Register(Service{Name: "x", Domain: "x.com"}); err == nil { - t.Error("expected error for missing backend") + // Missing backend address + if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Type: BackendHTTP}, Reach: ReachInternal}); err == nil { + t.Error("expected error for missing backend address") + } + + // Missing backend type + if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Address: "127.0.0.1:80"}, Reach: ReachInternal}); err == nil { + t.Error("expected error for missing backend type") + } + + // Missing reach + if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}}); err == nil { + t.Error("expected error for missing reach") + } +} + +func TestDefaultSource(t *testing.T) { + mgr := NewManager(t.TempDir()) + + mgr.Register(Service{ + Domain: "test.com", + Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}, + Reach: ReachInternal, + }) + + got, _ := mgr.Get("test.com") + if got.Source != "manual" { + t.Errorf("expected default source 'manual', got %s", got.Source) + } +} + +func TestDomainToFilename(t *testing.T) { + tests := []struct { + domain string + expected string + }{ + {"cloud.payne.io", "cloud_payne_io.yaml"}, + {"internal.cloud.payne.io", "internal_cloud_payne_io.yaml"}, + {"payne.io", "payne_io.yaml"}, + {"example.com", "example_com.yaml"}, + } + for _, tt := range tests { + got := domainToFilename(tt.domain) + if got != tt.expected { + t.Errorf("domainToFilename(%q) = %q, want %q", tt.domain, got, tt.expected) + } } }