From e936102eab9fb629354c2753b11135e33e2a2643 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 8 Jul 2026 23:33:30 +0000 Subject: [PATCH] feat: Add service registration API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the service registration abstraction — the key contract between Central and its consumers (Wild Cloud, Wild Works). Services register with Central to get DNS, gateway routing, TLS, and public exposure. - internal/services/manager.go: CRUD for service registrations stored as YAML files, with reach model (off/internal/public), backend types (tcp-passthrough/http/static), and TLS modes - internal/api/v1/handlers_services.go: HTTP endpoints POST/GET/PATCH/DELETE /api/v1/services - Full test coverage for registration, update, deregister, validation Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/api/v1/handlers.go | 12 +- internal/api/v1/handlers_services.go | 94 ++++++++++ internal/services/manager.go | 251 +++++++++++++++++++++++++++ internal/services/manager_test.go | 161 +++++++++++++++++ 4 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 internal/api/v1/handlers_services.go create mode 100644 internal/services/manager.go create mode 100644 internal/services/manager_test.go diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index e5148a0..7e32e62 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -24,6 +24,7 @@ import ( "github.com/wild-cloud/wild-central/internal/network" "github.com/wild-cloud/wild-central/internal/nftables" "github.com/wild-cloud/wild-central/internal/secrets" + "github.com/wild-cloud/wild-central/internal/services" "github.com/wild-cloud/wild-central/internal/sse" "github.com/wild-cloud/wild-central/internal/storage" "github.com/wild-cloud/wild-central/internal/wireguard" @@ -44,7 +45,8 @@ type API struct { crowdsec *crowdsec.Manager vpn *wireguard.Manager certbot *certbot.Manager - sseManager *sse.Manager // SSE manager for real-time events + services *services.Manager // Service registration manager + sseManager *sse.Manager // SSE manager for real-time events } // NewAPI creates a new Central API handler with all dependencies @@ -76,6 +78,7 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) { crowdsec: crowdsec.NewManager(), vpn: wireguard.NewManager(dataDir, vpnConfigPath), certbot: certbot.NewManager(""), + services: services.NewManager(dataDir), sseManager: sseManager, } @@ -205,6 +208,13 @@ func (api *API) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecGetBouncers).Methods("GET") r.HandleFunc("/api/v1/crowdsec/bouncers/{name}", api.CrowdSecDeleteBouncer).Methods("DELETE") + // Service registration + 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") + // SSE events r.HandleFunc("/api/v1/events", api.GlobalEventStream).Methods("GET") } diff --git a/internal/api/v1/handlers_services.go b/internal/api/v1/handlers_services.go new file mode 100644 index 0000000..0b5aba3 --- /dev/null +++ b/internal/api/v1/handlers_services.go @@ -0,0 +1,94 @@ +package v1 + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/gorilla/mux" + + "github.com/wild-cloud/wild-central/internal/services" +) + +// ServicesListAll lists all registered services. +func (api *API) ServicesListAll(w http.ResponseWriter, r *http.Request) { + svcs, err := api.services.List() + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list services: %v", err)) + return + } + + if svcs == nil { + svcs = []services.Service{} + } + + respondJSON(w, http.StatusOK, map[string]any{ + "services": svcs, + }) +} + +// ServicesGet retrieves a service by name. +func (api *API) ServicesGet(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + + svc, err := api.services.Get(name) + if err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Service not found: %v", err)) + return + } + + respondJSON(w, http.StatusOK, svc) +} + +// ServicesRegister creates or updates a service registration. +func (api *API) ServicesRegister(w http.ResponseWriter, r *http.Request) { + var svc services.Service + if err := json.NewDecoder(r.Body).Decode(&svc); err != nil { + respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err)) + return + } + + if err := api.services.Register(svc); err != nil { + respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to register service: %v", err)) + return + } + + respondJSON(w, http.StatusCreated, map[string]any{ + "message": fmt.Sprintf("Service %q registered", svc.Name), + "service": svc, + }) +} + +// ServicesUpdate applies partial updates to a service. +func (api *API) ServicesUpdate(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + + var updates map[string]any + if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { + respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err)) + return + } + + if err := api.services.Update(name, updates); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to update service: %v", err)) + return + } + + svc, _ := api.services.Get(name) + respondJSON(w, http.StatusOK, map[string]any{ + "message": fmt.Sprintf("Service %q updated", name), + "service": svc, + }) +} + +// ServicesDeregister removes a service registration. +func (api *API) ServicesDeregister(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + + if err := api.services.Deregister(name); 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)) +} diff --git a/internal/services/manager.go b/internal/services/manager.go new file mode 100644 index 0000000..86c2a49 --- /dev/null +++ b/internal/services/manager.go @@ -0,0 +1,251 @@ +// 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). +// +// Registrations are stored as YAML files on disk and will later be backed by +// NATS JetStream KV for real-time coordination. +package services + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "sync" + + "gopkg.in/yaml.v3" +) + +// Reach describes how a service is exposed. +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 +) + +// BackendType describes how the gateway handles traffic for this service. +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) + 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") +} + +// Service represents a registered service. +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"` +} + +// 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 +} + +// NewManager creates a new service registration manager. +func NewManager(dataDir string) *Manager { + servicesDir := filepath.Join(dataDir, "services") + if err := os.MkdirAll(servicesDir, 0755); err != nil { + slog.Warn("failed to create services directory", "path", servicesDir, "error", err) + } + return &Manager{dataDir: dataDir} +} + +// SetReconcileFn sets the function called after service changes to update networking. +func (m *Manager) SetReconcileFn(fn func()) { + m.reconcileFn = fn +} + +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") +} + +// Register creates or updates a service registration. +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") + } + 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 + } + // Default TLS mode based on backend type + if svc.TLS == "" { + if svc.Backend.Type == BackendTCPPassthrough { + svc.TLS = TLSPassthrough + } else { + svc.TLS = TLSTerminate + } + } + + 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 { + return fmt.Errorf("writing service file: %w", err) + } + + slog.Info("service registered", "name", svc.Name, "domain", svc.Domain, "reach", svc.Reach, "source", svc.Source) + + if m.reconcileFn != nil { + go m.reconcileFn() + } + + return nil +} + +// Deregister removes a service registration. +func (m *Manager) Deregister(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + path := m.servicePath(name) + if _, err := os.Stat(path); os.IsNotExist(err) { + return fmt.Errorf("service %q not found", name) + } + + if err := os.Remove(path); err != nil { + return fmt.Errorf("removing service file: %w", err) + } + + slog.Info("service deregistered", "name", name) + + if m.reconcileFn != nil { + go m.reconcileFn() + } + + return nil +} + +// Get retrieves a service registration by name. +func (m *Manager) Get(name string) (*Service, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + data, err := os.ReadFile(m.servicePath(name)) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("service %q not found", name) + } + return nil, fmt.Errorf("reading service file: %w", err) + } + + var svc Service + if err := yaml.Unmarshal(data, &svc); err != nil { + return nil, fmt.Errorf("parsing service file: %w", err) + } + + return &svc, nil +} + +// List returns all registered services. +func (m *Manager) List() ([]Service, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + entries, err := os.ReadDir(m.servicesDir()) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading services directory: %w", err) + } + + var services []Service + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" { + continue + } + + data, err := os.ReadFile(filepath.Join(m.servicesDir(), entry.Name())) + if err != nil { + slog.Warn("failed to read service file", "file", entry.Name(), "error", err) + continue + } + + var svc Service + if err := yaml.Unmarshal(data, &svc); err != nil { + slog.Warn("failed to parse service file", "file", entry.Name(), "error", err) + continue + } + + services = append(services, svc) + } + + 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) + 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 backend, ok := updates["backend"].(map[string]any); ok { + if addr, ok := backend["address"].(string); ok { + svc.Backend.Address = addr + } + if typ, ok := backend["type"].(string); ok { + svc.Backend.Type = BackendType(typ) + } + if health, ok := backend["health"].(string); ok { + svc.Backend.Health = health + } + } + + return m.Register(*svc) +} diff --git a/internal/services/manager_test.go b/internal/services/manager_test.go new file mode 100644 index 0000000..a5e9faa --- /dev/null +++ b/internal/services/manager_test.go @@ -0,0 +1,161 @@ +package services + +import ( + "testing" +) + +func TestRegisterAndGet(t *testing.T) { + mgr := NewManager(t.TempDir()) + + svc := Service{ + Name: "my-api", + Source: "wild-works", + Domain: "my-api.payne.io", + Backend: Backend{ + Address: "192.168.8.60:9001", + Type: BackendHTTP, + Health: "/health", + }, + Reach: ReachInternal, + } + + if err := mgr.Register(svc); err != nil { + t.Fatalf("Register failed: %v", err) + } + + got, err := mgr.Get("my-api") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if got.Domain != "my-api.payne.io" { + t.Errorf("expected domain my-api.payne.io, got %s", got.Domain) + } + 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) + } +} + +func TestRegisterTCPPassthrough(t *testing.T) { + mgr := NewManager(t.TempDir()) + + svc := Service{ + Name: "payne-cloud", + Source: "wild-cloud", + Domain: "cloud.payne.io", + Backend: Backend{ + Address: "192.168.8.100:443", + Type: BackendTCPPassthrough, + }, + Reach: ReachInternal, + } + + if err := mgr.Register(svc); err != nil { + t.Fatalf("Register failed: %v", err) + } + + got, err := mgr.Get("payne-cloud") + 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) + } +} + +func TestListServices(t *testing.T) { + mgr := NewManager(t.TempDir()) + + for _, name := range []string{"svc-a", "svc-b", "svc-c"} { + if err := mgr.Register(Service{ + Name: name, + 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) + } + } + + svcs, err := mgr.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(svcs) != 3 { + t.Errorf("expected 3 services, got %d", len(svcs)) + } +} + +func TestDeregister(t *testing.T) { + mgr := NewManager(t.TempDir()) + + if err := mgr.Register(Service{ + Name: "temp", + Source: "test", + Domain: "temp.example.com", + 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 { + t.Fatalf("Deregister failed: %v", err) + } + + if _, err := mgr.Get("temp"); err == nil { + t.Error("expected error after deregister, got nil") + } +} + +func TestUpdate(t *testing.T) { + mgr := NewManager(t.TempDir()) + + if err := mgr.Register(Service{ + Name: "updatable", + Source: "test", + Domain: "updatable.example.com", + 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", + }); err != nil { + t.Fatalf("Update failed: %v", err) + } + + got, _ := mgr.Get("updatable") + if got.Reach != ReachPublic { + t.Errorf("expected reach public after update, got %s", got.Reach) + } +} + +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 { + 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") + } +}