services -> domains
This commit is contained in:
30
CLAUDE.md
30
CLAUDE.md
@@ -12,8 +12,8 @@ Wild Central is the networking and coordination platform for the Wild product fa
|
|||||||
- **Tunnels** (cloudflared) — outbound-only public exposure via Cloudflare
|
- **Tunnels** (cloudflared) — outbound-only public exposure via Cloudflare
|
||||||
- **DDNS** (Cloudflare) — A record + CNAME management
|
- **DDNS** (Cloudflare) — A record + CNAME management
|
||||||
- **Security** (CrowdSec) — intrusion detection
|
- **Security** (CrowdSec) — intrusion detection
|
||||||
- **NATS JetStream** — coordination bus for service registration, presence, and events
|
- **NATS JetStream** — coordination bus for domain registration, presence, and events
|
||||||
- **Service Registration** — the contract API for Wild Cloud and Wild Works
|
- **Domain Registration** — the contract API for Wild Cloud and Wild Works
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ Wild Central is a Go service that manages Linux networking daemons via config fi
|
|||||||
|
|
||||||
**Consumers:**
|
**Consumers:**
|
||||||
- **Wild Cloud** — registers k8s instance domains for L4 SNI passthrough
|
- **Wild Cloud** — registers k8s instance domains for L4 SNI passthrough
|
||||||
- **Wild Works** — registers services for L7 HTTP reverse proxy with TLS termination
|
- **Wild Works** — registers domains for L7 HTTP reverse proxy with TLS termination
|
||||||
|
|
||||||
Both connect to Central's NATS (port 4222) or use the HTTP API (port 5055) to register services.
|
Both connect to Central's NATS (port 4222) or use the HTTP API (port 5055) to register services.
|
||||||
|
|
||||||
@@ -52,23 +52,23 @@ make check # Lint + test
|
|||||||
Runtime state is persisted in `{WILD_CENTRAL_DATA_DIR}/`:
|
Runtime state is persisted in `{WILD_CENTRAL_DATA_DIR}/`:
|
||||||
- `state.yaml` — operator, domain, firewall, DDNS, DHCP settings (managed via API)
|
- `state.yaml` — operator, domain, firewall, DDNS, DHCP settings (managed via API)
|
||||||
- `secrets.yaml` — API tokens and credentials
|
- `secrets.yaml` — API tokens and credentials
|
||||||
- `services/` — per-domain service registration files
|
- `domains/` — per-domain registration files
|
||||||
- `nats/` — embedded NATS JetStream data
|
- `nats/` — embedded NATS JetStream data
|
||||||
- `instances/` — Wild Cloud instance configs
|
- `instances/` — Wild Cloud instance configs
|
||||||
|
|
||||||
## Service Registration API
|
## Domain Registration API
|
||||||
|
|
||||||
The key abstraction. Services register with Central to get DNS, proxy, TLS, and public exposure.
|
The key abstraction. Domains register with Central to get DNS, gateway routing, TLS, and public exposure.
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/v1/services Register a service
|
POST /api/v1/domains Register a domain
|
||||||
GET /api/v1/services List all services
|
GET /api/v1/domains List all domains
|
||||||
GET /api/v1/services/{name} Get service details
|
GET /api/v1/domains/{domain} Get domain details
|
||||||
PATCH /api/v1/services/{name} Update service
|
PATCH /api/v1/domains/{domain} Update domain
|
||||||
DELETE /api/v1/services/{name} Deregister service
|
DELETE /api/v1/domains/{domain} Deregister domain
|
||||||
```
|
```
|
||||||
|
|
||||||
### Service payload
|
### Domain payload
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -87,10 +87,10 @@ DELETE /api/v1/services/{name} Deregister service
|
|||||||
### Backend types
|
### Backend types
|
||||||
|
|
||||||
- `tcp-passthrough` — L4, SNI routing, backend handles TLS (Wild Cloud k8s)
|
- `tcp-passthrough` — L4, SNI routing, backend handles TLS (Wild Cloud k8s)
|
||||||
- `http` — L7, Central terminates TLS with wildcard cert (Wild Works services)
|
- `http` — L7, Central terminates TLS with wildcard cert (Wild Works)
|
||||||
- `static` — L7, static file serving (Wild Works frontends)
|
- `dns-only` — DNS resolution only, no gateway routing (SSH, game servers, etc.)
|
||||||
|
|
||||||
### Public
|
### Public
|
||||||
|
|
||||||
- `false` (default) — LAN-visible only (DNS + proxy + TLS)
|
- `false` (default) — LAN-visible only (DNS + gateway + TLS)
|
||||||
- `true` — internet-visible (+ DDNS or tunnel exposure)
|
- `true` — internet-visible (+ DDNS or tunnel exposure)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -25,7 +26,7 @@ import (
|
|||||||
"github.com/wild-cloud/wild-central/internal/network"
|
"github.com/wild-cloud/wild-central/internal/network"
|
||||||
"github.com/wild-cloud/wild-central/internal/nftables"
|
"github.com/wild-cloud/wild-central/internal/nftables"
|
||||||
"github.com/wild-cloud/wild-central/internal/secrets"
|
"github.com/wild-cloud/wild-central/internal/secrets"
|
||||||
"github.com/wild-cloud/wild-central/internal/services"
|
"github.com/wild-cloud/wild-central/internal/domains"
|
||||||
"github.com/wild-cloud/wild-central/internal/sse"
|
"github.com/wild-cloud/wild-central/internal/sse"
|
||||||
"github.com/wild-cloud/wild-central/internal/storage"
|
"github.com/wild-cloud/wild-central/internal/storage"
|
||||||
"github.com/wild-cloud/wild-central/internal/wireguard"
|
"github.com/wild-cloud/wild-central/internal/wireguard"
|
||||||
@@ -46,7 +47,7 @@ type API struct {
|
|||||||
crowdsec *crowdsec.Manager
|
crowdsec *crowdsec.Manager
|
||||||
vpn *wireguard.Manager
|
vpn *wireguard.Manager
|
||||||
certbot *certbot.Manager
|
certbot *certbot.Manager
|
||||||
services *services.Manager // Service registration manager
|
domains *domains.Manager // Domain registration manager
|
||||||
sseManager *sse.Manager // SSE manager for real-time events
|
sseManager *sse.Manager // SSE manager for real-time events
|
||||||
port int // Running API port (for config-driven routes)
|
port int // Running API port (for config-driven routes)
|
||||||
}
|
}
|
||||||
@@ -76,13 +77,13 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
|
|||||||
crowdsec: crowdsec.NewManager(),
|
crowdsec: crowdsec.NewManager(),
|
||||||
vpn: wireguard.NewManager(dataDir, vpnConfigPath),
|
vpn: wireguard.NewManager(dataDir, vpnConfigPath),
|
||||||
certbot: certbot.NewManager(""),
|
certbot: certbot.NewManager(""),
|
||||||
services: services.NewManager(dataDir),
|
domains: domains.NewManager(dataDir),
|
||||||
sseManager: sseManager,
|
sseManager: sseManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire up service registration reconciliation: when services change,
|
// Wire up domain registration reconciliation: when domains change,
|
||||||
// regenerate dnsmasq DNS entries and HAProxy routes.
|
// regenerate dnsmasq DNS entries and HAProxy routes.
|
||||||
api.services.SetReconcileFn(api.reconcileNetworking)
|
api.domains.SetReconcileFn(api.reconcileNetworking)
|
||||||
|
|
||||||
return api, nil
|
return api, nil
|
||||||
}
|
}
|
||||||
@@ -107,30 +108,49 @@ func envOrDefault(key, defaultVal string) string {
|
|||||||
return defaultVal
|
return defaultVal
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartDDNS loads DDNS config and starts the background goroutine.
|
// StartDDNS launches the DDNS background goroutine with a params callback
|
||||||
|
// that reads fresh state (token, public domains, interval) on each tick.
|
||||||
func (api *API) StartDDNS(ctx gocontext.Context) {
|
func (api *API) StartDDNS(ctx gocontext.Context) {
|
||||||
api.ctx = ctx
|
api.ctx = ctx
|
||||||
api.reloadDDNSIfEnabled()
|
api.ddns.Start(ctx, api.ddnsParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reloadDDNSIfEnabled re-reads DDNS config and secrets and restarts the DDNS goroutine.
|
// ddnsParams returns the current DDNS parameters derived from runtime state.
|
||||||
|
// Called by the DDNS runner on each tick — always returns fresh values.
|
||||||
|
func (api *API) ddnsParams() ddns.Params {
|
||||||
|
state, err := config.LoadState(api.statePath())
|
||||||
|
if err != nil || !state.Cloud.DDNS.Enabled {
|
||||||
|
return ddns.Params{}
|
||||||
|
}
|
||||||
|
|
||||||
|
doms, _ := api.domains.List()
|
||||||
|
var records []string
|
||||||
|
for _, dom := range doms {
|
||||||
|
if dom.Public && dom.DomainName != "" {
|
||||||
|
records = append(records, dom.DomainName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ddns.Params{
|
||||||
|
APIToken: api.getCloudflareToken(),
|
||||||
|
Records: records,
|
||||||
|
IntervalMinutes: state.Cloud.DDNS.IntervalMinutes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadDDNSIfEnabled starts the runner if DDNS is enabled, or stops it if disabled.
|
||||||
|
// When already running, triggers an immediate check with fresh params.
|
||||||
func (api *API) reloadDDNSIfEnabled() {
|
func (api *API) reloadDDNSIfEnabled() {
|
||||||
globalCfg, err := config.LoadState(api.statePath())
|
state, _ := config.LoadState(api.statePath())
|
||||||
if err != nil || !globalCfg.Cloud.DDNS.Enabled {
|
if state == nil || !state.Cloud.DDNS.Enabled {
|
||||||
api.ddns.Stop()
|
api.ddns.Stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !api.ddns.GetStatus().Enabled {
|
||||||
apiToken := api.getCloudflareToken()
|
api.ddns.Start(api.ctx, api.ddnsParams)
|
||||||
|
} else {
|
||||||
cfg := ddns.Config{
|
api.ddns.Trigger()
|
||||||
Enabled: true,
|
|
||||||
APIToken: apiToken,
|
|
||||||
Records: globalCfg.Cloud.DDNS.Records,
|
|
||||||
IntervalMinutes: globalCfg.Cloud.DDNS.IntervalMinutes,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
api.ddns.Start(api.ctx, cfg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartCentralStatusBroadcaster starts periodic broadcasting of central status
|
// StartCentralStatusBroadcaster starts periodic broadcasting of central status
|
||||||
@@ -151,9 +171,9 @@ func (api *API) StartCentralStatusBroadcaster(startTime time.Time) {
|
|||||||
func (api *API) RegisterRoutes(r *mux.Router) {
|
func (api *API) RegisterRoutes(r *mux.Router) {
|
||||||
r.Use(RequestLoggingMiddleware)
|
r.Use(RequestLoggingMiddleware)
|
||||||
|
|
||||||
// Global Configuration
|
// Global State (runtime settings — operator, DDNS, DHCP, etc.)
|
||||||
r.HandleFunc("/api/v1/config", api.GetGlobalConfig).Methods("GET")
|
r.HandleFunc("/api/v1/state", api.GetState).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/config", api.UpdateGlobalConfig).Methods("PUT")
|
r.HandleFunc("/api/v1/state", api.UpdateState).Methods("PUT")
|
||||||
|
|
||||||
// Global Secrets
|
// Global Secrets
|
||||||
r.HandleFunc("/api/v1/secrets", api.GetGlobalSecrets).Methods("GET")
|
r.HandleFunc("/api/v1/secrets", api.GetGlobalSecrets).Methods("GET")
|
||||||
@@ -228,13 +248,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.CloudflareGetZoneID).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareSetZoneID).Methods("POST")
|
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareSetZoneID).Methods("POST")
|
||||||
|
|
||||||
// Service registration — domain is the unique key
|
// Domain registration — domain is the unique key
|
||||||
r.HandleFunc("/api/v1/services", api.ServicesListAll).Methods("GET")
|
r.HandleFunc("/api/v1/domains", api.DomainsListAll).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/services", api.ServicesRegister).Methods("POST")
|
r.HandleFunc("/api/v1/domains", api.DomainsRegister).Methods("POST")
|
||||||
r.HandleFunc("/api/v1/services/deregister", api.ServicesDeregisterBySource).Methods("DELETE")
|
r.HandleFunc("/api/v1/domains/deregister", api.DomainsDeregisterBySource).Methods("DELETE")
|
||||||
r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesGet).Methods("GET")
|
r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsGet).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesUpdate).Methods("PATCH")
|
r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsUpdate).Methods("PATCH")
|
||||||
r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesDeregister).Methods("DELETE")
|
r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsDeregister).Methods("DELETE")
|
||||||
|
|
||||||
// SSE events
|
// SSE events
|
||||||
r.HandleFunc("/api/v1/events", api.GlobalEventStream).Methods("GET")
|
r.HandleFunc("/api/v1/events", api.GlobalEventStream).Methods("GET")
|
||||||
@@ -242,10 +262,9 @@ func (api *API) RegisterRoutes(r *mux.Router) {
|
|||||||
|
|
||||||
// --- Global Config/Secrets handlers ---
|
// --- Global Config/Secrets handlers ---
|
||||||
|
|
||||||
// GetGlobalConfig returns the global state wrapped in { configured, config }.
|
// GetState returns the global state wrapped in { configured, state }.
|
||||||
func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) {
|
func (api *API) GetState(w http.ResponseWriter, r *http.Request) {
|
||||||
configPath := api.statePath()
|
data, err := os.ReadFile(api.statePath())
|
||||||
data, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondJSON(w, http.StatusOK, map[string]any{
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
"configured": false,
|
"configured": false,
|
||||||
@@ -253,20 +272,20 @@ func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var configMap map[string]any
|
var stateMap map[string]any
|
||||||
if err := yaml.Unmarshal(data, &configMap); err != nil {
|
if err := yaml.Unmarshal(data, &stateMap); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to parse config")
|
respondError(w, http.StatusInternalServerError, "Failed to parse state")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]any{
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
"configured": true,
|
"configured": true,
|
||||||
"config": configMap,
|
"state": stateMap,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateGlobalConfig updates the global state
|
// UpdateState updates the global state
|
||||||
func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) {
|
func (api *API) UpdateState(w http.ResponseWriter, r *http.Request) {
|
||||||
configPath := api.statePath()
|
configPath := api.statePath()
|
||||||
|
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
@@ -320,7 +339,7 @@ func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Reload DDNS and re-register Central's service if config changed
|
// Reload DDNS and re-register Central's service if config changed
|
||||||
go api.reloadDDNSIfEnabled()
|
go api.reloadDDNSIfEnabled()
|
||||||
go api.EnsureCentralService()
|
go api.EnsureCentralDomain()
|
||||||
|
|
||||||
respondMessage(w, http.StatusOK, "Config updated successfully")
|
respondMessage(w, http.StatusOK, "Config updated successfully")
|
||||||
}
|
}
|
||||||
@@ -380,9 +399,32 @@ func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime
|
|||||||
"uptime": uptime.String(),
|
"uptime": uptime.String(),
|
||||||
"uptimeSeconds": int(uptime.Seconds()),
|
"uptimeSeconds": int(uptime.Seconds()),
|
||||||
"dataDir": dataDir,
|
"dataDir": dataDir,
|
||||||
|
"daemons": getDaemonStatus(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getDaemonStatus checks whether each managed daemon is active.
|
||||||
|
func getDaemonStatus() map[string]map[string]bool {
|
||||||
|
daemons := map[string]string{
|
||||||
|
"dnsmasq": "dnsmasq.service",
|
||||||
|
"haproxy": "haproxy.service",
|
||||||
|
"wireguard": "wg-quick@wg0.service",
|
||||||
|
"crowdsec": "crowdsec.service",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]map[string]bool, len(daemons)+1)
|
||||||
|
for name, unit := range daemons {
|
||||||
|
err := exec.Command("systemctl", "is-active", "--quiet", unit).Run()
|
||||||
|
result[name] = map[string]bool{"active": err == nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nftables has no persistent service — check if the wild-cloud table exists
|
||||||
|
err := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run()
|
||||||
|
result["nftables"] = map[string]bool{"active": err == nil}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// DaemonRestart triggers a graceful self-restart via SIGTERM
|
// DaemonRestart triggers a graceful self-restart via SIGTERM
|
||||||
func (api *API) DaemonRestart(w http.ResponseWriter, r *http.Request) {
|
func (api *API) DaemonRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
respondJSON(w, http.StatusAccepted, map[string]any{"message": "Restarting Wild Central..."})
|
respondJSON(w, http.StatusAccepted, map[string]any{"message": "Restarting Wild Central..."})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/wild-cloud/wild-central/internal/certbot"
|
"github.com/wild-cloud/wild-central/internal/certbot"
|
||||||
"github.com/wild-cloud/wild-central/internal/config"
|
"github.com/wild-cloud/wild-central/internal/config"
|
||||||
"github.com/wild-cloud/wild-central/internal/services"
|
"github.com/wild-cloud/wild-central/internal/domains"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CertStatus returns TLS certificate status for all relevant domains:
|
// CertStatus returns TLS certificate status for all relevant domains:
|
||||||
@@ -24,31 +24,30 @@ func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
cfToken := api.getCloudflareToken()
|
cfToken := api.getCloudflareToken()
|
||||||
|
|
||||||
// Gather cert statuses for all registered services that need TLS termination
|
// Gather cert statuses for all registered domains that need TLS termination
|
||||||
certs := []map[string]any{}
|
certs := []map[string]any{}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|
||||||
svcs, _ := api.services.List()
|
doms, _ := api.domains.List()
|
||||||
for _, svc := range svcs {
|
for _, dom := range doms {
|
||||||
if svc.TLS != services.TLSTerminate || svc.Domain == "" {
|
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if seen[svc.Domain] {
|
if seen[dom.DomainName] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[svc.Domain] = true
|
seen[dom.DomainName] = true
|
||||||
|
|
||||||
status := api.certbot.GetStatus(svc.Domain)
|
status := api.certbot.GetStatus(dom.DomainName)
|
||||||
|
|
||||||
entry := map[string]any{
|
entry := map[string]any{
|
||||||
"domain": svc.Domain,
|
"domain": dom.DomainName,
|
||||||
"service": svc.Domain,
|
"source": dom.Source,
|
||||||
"source": svc.Source,
|
"cert": status,
|
||||||
"cert": status,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if covered by a wildcard cert
|
// Check if covered by a wildcard cert
|
||||||
parts := strings.SplitN(svc.Domain, ".", 2)
|
parts := strings.SplitN(dom.DomainName, ".", 2)
|
||||||
if len(parts) == 2 && !status.Exists {
|
if len(parts) == 2 && !status.Exists {
|
||||||
wildcardBase := parts[1]
|
wildcardBase := parts[1]
|
||||||
wildcardStatus := api.certbot.GetStatus(wildcardBase)
|
wildcardStatus := api.certbot.GetStatus(wildcardBase)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) {
|
func TestGetState_ReturnsWrappedResponse(t *testing.T) {
|
||||||
api, tmpDir := setupTestAPI(t)
|
api, tmpDir := setupTestAPI(t)
|
||||||
|
|
||||||
// Write a config with a central domain
|
// Write a config with a central domain
|
||||||
@@ -30,10 +30,10 @@ func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) {
|
|||||||
data, _ := yaml.Marshal(cfg)
|
data, _ := yaml.Marshal(cfg)
|
||||||
storage.WriteFile(filepath.Join(tmpDir, "state.yaml"), data, 0644)
|
storage.WriteFile(filepath.Join(tmpDir, "state.yaml"), data, 0644)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/v1/config", nil)
|
req := httptest.NewRequest("GET", "/api/v1/state", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
api.GetGlobalConfig(w, req)
|
api.GetState(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
@@ -42,33 +42,33 @@ func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) {
|
|||||||
var resp map[string]any
|
var resp map[string]any
|
||||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
// Must have "configured" and "config" keys
|
// Must have "configured" and "state" keys
|
||||||
if resp["configured"] != true {
|
if resp["configured"] != true {
|
||||||
t.Errorf("expected configured=true, got %v", resp["configured"])
|
t.Errorf("expected configured=true, got %v", resp["configured"])
|
||||||
}
|
}
|
||||||
config, ok := resp["config"].(map[string]any)
|
state, ok := resp["state"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected config to be a map, got %T", resp["config"])
|
t.Fatalf("expected state to be a map, got %T", resp["state"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify nested structure is preserved
|
// Verify nested structure is preserved
|
||||||
cloud, _ := config["cloud"].(map[string]any)
|
cloud, _ := state["cloud"].(map[string]any)
|
||||||
central, _ := cloud["central"].(map[string]any)
|
central, _ := cloud["central"].(map[string]any)
|
||||||
if central["domain"] != "central.example.com" {
|
if central["domain"] != "central.example.com" {
|
||||||
t.Errorf("expected domain central.example.com, got %v", central["domain"])
|
t.Errorf("expected domain central.example.com, got %v", central["domain"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetGlobalConfig_EmptyReturnsNotConfigured(t *testing.T) {
|
func TestGetState_EmptyReturnsNotConfigured(t *testing.T) {
|
||||||
api, tmpDir := setupTestAPI(t)
|
api, tmpDir := setupTestAPI(t)
|
||||||
|
|
||||||
// Ensure no state file exists
|
// Ensure no state file exists
|
||||||
os.Remove(filepath.Join(tmpDir, "state.yaml"))
|
os.Remove(filepath.Join(tmpDir, "state.yaml"))
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/v1/config", nil)
|
req := httptest.NewRequest("GET", "/api/v1/state", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
api.GetGlobalConfig(w, req)
|
api.GetState(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
@@ -146,7 +146,7 @@ func TestGetGlobalSecrets_RawShowsValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateGlobalConfig(t *testing.T) {
|
func TestUpdateState(t *testing.T) {
|
||||||
api, tmpDir := setupTestAPI(t)
|
api, tmpDir := setupTestAPI(t)
|
||||||
|
|
||||||
update := map[string]any{
|
update := map[string]any{
|
||||||
@@ -158,10 +158,10 @@ func TestUpdateGlobalConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
body, _ := json.Marshal(update)
|
body, _ := json.Marshal(update)
|
||||||
|
|
||||||
req := httptest.NewRequest("PUT", "/api/v1/config", bytes.NewReader(body))
|
req := httptest.NewRequest("PUT", "/api/v1/state", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
api.UpdateGlobalConfig(w, req)
|
api.UpdateState(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
|||||||
@@ -16,13 +16,14 @@ func (api *API) DDNSStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, api.ddns.GetStatus())
|
respondJSON(w, http.StatusOK, api.ddns.GetStatus())
|
||||||
}
|
}
|
||||||
|
|
||||||
// DDNSTrigger forces an immediate DDNS IP check and update
|
// DDNSTrigger forces an immediate DDNS check. The runner reads fresh params
|
||||||
|
// (token, records) via its callback, so updated tokens take effect immediately.
|
||||||
func (api *API) DDNSTrigger(w http.ResponseWriter, r *http.Request) {
|
func (api *API) DDNSTrigger(w http.ResponseWriter, r *http.Request) {
|
||||||
if api.ddns == nil {
|
if api.ddns == nil {
|
||||||
respondError(w, http.StatusServiceUnavailable, "DDNS not configured")
|
respondError(w, http.StatusServiceUnavailable, "DDNS not configured")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
api.ddns.Trigger()
|
api.reloadDDNSIfEnabled()
|
||||||
respondJSON(w, http.StatusOK, map[string]string{
|
respondJSON(w, http.StatusOK, map[string]string{
|
||||||
"message": "DDNS update triggered",
|
"message": "DDNS update triggered",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func setupTestDnsmasq(t *testing.T) (*API, string) {
|
|||||||
func TestDnsmasqGenerate(t *testing.T) {
|
func TestDnsmasqGenerate(t *testing.T) {
|
||||||
api, tmpDir := setupTestDnsmasq(t)
|
api, tmpDir := setupTestDnsmasq(t)
|
||||||
|
|
||||||
globalConfig := config.GlobalConfig{}
|
globalConfig := config.State{}
|
||||||
globalConfig.Cloud.Central.Domain = "central.example.com"
|
globalConfig.Cloud.Central.Domain = "central.example.com"
|
||||||
configPath := filepath.Join(tmpDir, "state.yaml")
|
configPath := filepath.Join(tmpDir, "state.yaml")
|
||||||
configData, _ := yaml.Marshal(globalConfig)
|
configData, _ := yaml.Marshal(globalConfig)
|
||||||
|
|||||||
132
internal/api/v1/handlers_domains.go
Normal file
132
internal/api/v1/handlers_domains.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"github.com/wild-cloud/wild-central/internal/domains"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DomainsListAll lists all registered domains.
|
||||||
|
func (api *API) DomainsListAll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
doms, err := api.domains.List()
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list domains: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if doms == nil {
|
||||||
|
doms = []domains.Domain{}
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"domains": doms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainsGet retrieves a domain by its domain name.
|
||||||
|
func (api *API) DomainsGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
domain := mux.Vars(r)["domain"]
|
||||||
|
|
||||||
|
dom, err := api.domains.Get(domain)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, fmt.Sprintf("Domain not found: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, dom)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainsRegister creates or updates a domain registration.
|
||||||
|
func (api *API) DomainsRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var dom domains.Domain
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&dom); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate routes
|
||||||
|
for i, r := range dom.Routes {
|
||||||
|
for _, p := range r.Paths {
|
||||||
|
if !strings.HasPrefix(p, "/") {
|
||||||
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("Route %d: path prefix must start with /: %q", i, p))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, cidr := range r.IPAllow {
|
||||||
|
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("Route %d: invalid CIDR in ipAllow: %q", i, cidr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := api.domains.Register(dom); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to register domain: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the stored version (has defaults applied)
|
||||||
|
stored, _ := api.domains.Get(dom.DomainName)
|
||||||
|
respondJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"message": fmt.Sprintf("Domain %q registered", dom.DomainName),
|
||||||
|
"domain": stored,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainsUpdate applies partial updates to a domain.
|
||||||
|
func (api *API) DomainsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
domain := mux.Vars(r)["domain"]
|
||||||
|
|
||||||
|
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.domains.Update(domain, updates); err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to update domain: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dom, _ := api.domains.Get(domain)
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"message": fmt.Sprintf("Domain %q updated", domain),
|
||||||
|
"domain": dom,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainsDeregister removes a domain registration.
|
||||||
|
func (api *API) DomainsDeregister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
domain := mux.Vars(r)["domain"]
|
||||||
|
|
||||||
|
if err := api.domains.Deregister(domain); err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to deregister domain: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondMessage(w, http.StatusOK, fmt.Sprintf("Domain %q deregistered", domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainsDeregisterBySource removes all domains from a source with a given backend.
|
||||||
|
func (api *API) DomainsDeregisterBySource(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.domains.DeregisterBySource(source, backend); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to deregister: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondMessage(w, http.StatusOK, fmt.Sprintf("Domains from %q deregistered", source))
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestServicesRegister(t *testing.T) {
|
func TestDomainsRegister(t *testing.T) {
|
||||||
api, _ := setupTestAPI(t)
|
api, _ := setupTestAPI(t)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
@@ -22,9 +22,9 @@ func TestServicesRegister(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
api.ServicesRegister(w, req)
|
api.DomainsRegister(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusCreated {
|
if w.Code != http.StatusCreated {
|
||||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||||
@@ -33,19 +33,19 @@ func TestServicesRegister(t *testing.T) {
|
|||||||
var resp map[string]any
|
var resp map[string]any
|
||||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
svc := resp["service"].(map[string]any)
|
dom := resp["domain"].(map[string]any)
|
||||||
if svc["domain"] != "my-api.payne.io" {
|
if dom["domain"] != "my-api.payne.io" {
|
||||||
t.Errorf("expected domain my-api.payne.io, got %v", svc["domain"])
|
t.Errorf("expected domain my-api.payne.io, got %v", dom["domain"])
|
||||||
}
|
}
|
||||||
if svc["tls"] != "terminate" {
|
if dom["tls"] != "terminate" {
|
||||||
t.Errorf("expected tls=terminate default for http, got %v", svc["tls"])
|
t.Errorf("expected tls=terminate default for http, got %v", dom["tls"])
|
||||||
}
|
}
|
||||||
if svc["source"] != "wild-works" {
|
if dom["source"] != "wild-works" {
|
||||||
t.Errorf("expected source wild-works, got %v", svc["source"])
|
t.Errorf("expected source wild-works, got %v", dom["source"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServicesRegister_TCPPassthrough(t *testing.T) {
|
func TestDomainsRegister_TCPPassthrough(t *testing.T) {
|
||||||
api, _ := setupTestAPI(t)
|
api, _ := setupTestAPI(t)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
@@ -59,9 +59,9 @@ func TestServicesRegister_TCPPassthrough(t *testing.T) {
|
|||||||
"public": true,
|
"public": true,
|
||||||
})
|
})
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
api.ServicesRegister(w, req)
|
api.DomainsRegister(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusCreated {
|
if w.Code != http.StatusCreated {
|
||||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||||
@@ -70,21 +70,21 @@ func TestServicesRegister_TCPPassthrough(t *testing.T) {
|
|||||||
var resp map[string]any
|
var resp map[string]any
|
||||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
svc := resp["service"].(map[string]any)
|
dom := resp["domain"].(map[string]any)
|
||||||
if svc["tls"] != "passthrough" {
|
if dom["tls"] != "passthrough" {
|
||||||
t.Errorf("expected tls=passthrough for tcp-passthrough, got %v", svc["tls"])
|
t.Errorf("expected tls=passthrough for tcp-passthrough, got %v", dom["tls"])
|
||||||
}
|
}
|
||||||
if svc["subdomains"] != true {
|
if dom["subdomains"] != true {
|
||||||
t.Errorf("expected subdomains=true, got %v", svc["subdomains"])
|
t.Errorf("expected subdomains=true, got %v", dom["subdomains"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServicesListAll_Empty(t *testing.T) {
|
func TestDomainsListAll_Empty(t *testing.T) {
|
||||||
api, _ := setupTestAPI(t)
|
api, _ := setupTestAPI(t)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/v1/services", nil)
|
req := httptest.NewRequest("GET", "/api/v1/domains", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
api.ServicesListAll(w, req)
|
api.DomainsListAll(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
@@ -92,13 +92,13 @@ func TestServicesListAll_Empty(t *testing.T) {
|
|||||||
|
|
||||||
var resp map[string]any
|
var resp map[string]any
|
||||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
svcs := resp["services"].([]any)
|
doms := resp["domains"].([]any)
|
||||||
if len(svcs) != 0 {
|
if len(doms) != 0 {
|
||||||
t.Errorf("expected empty, got %d", len(svcs))
|
t.Errorf("expected empty, got %d", len(doms))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServicesGet(t *testing.T) {
|
func TestDomainsGet(t *testing.T) {
|
||||||
api, _ := setupTestAPI(t)
|
api, _ := setupTestAPI(t)
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
@@ -107,41 +107,41 @@ func TestServicesGet(t *testing.T) {
|
|||||||
"source": "test",
|
"source": "test",
|
||||||
"backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"},
|
"backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"},
|
||||||
})
|
})
|
||||||
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
api.ServicesRegister(w, req)
|
api.DomainsRegister(w, req)
|
||||||
|
|
||||||
// Get by domain
|
// Get by domain
|
||||||
req = httptest.NewRequest("GET", "/api/v1/services/get-test.example.com", nil)
|
req = httptest.NewRequest("GET", "/api/v1/domains/get-test.example.com", nil)
|
||||||
req = mux.SetURLVars(req, map[string]string{"domain": "get-test.example.com"})
|
req = mux.SetURLVars(req, map[string]string{"domain": "get-test.example.com"})
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
api.ServicesGet(w, req)
|
api.DomainsGet(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var svc map[string]any
|
var dom map[string]any
|
||||||
json.Unmarshal(w.Body.Bytes(), &svc)
|
json.Unmarshal(w.Body.Bytes(), &dom)
|
||||||
if svc["domain"] != "get-test.example.com" {
|
if dom["domain"] != "get-test.example.com" {
|
||||||
t.Errorf("expected domain get-test.example.com, got %v", svc["domain"])
|
t.Errorf("expected domain get-test.example.com, got %v", dom["domain"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServicesGet_NotFound(t *testing.T) {
|
func TestDomainsGet_NotFound(t *testing.T) {
|
||||||
api, _ := setupTestAPI(t)
|
api, _ := setupTestAPI(t)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/v1/services/nonexistent.com", nil)
|
req := httptest.NewRequest("GET", "/api/v1/domains/nonexistent.com", nil)
|
||||||
req = mux.SetURLVars(req, map[string]string{"domain": "nonexistent.com"})
|
req = mux.SetURLVars(req, map[string]string{"domain": "nonexistent.com"})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
api.ServicesGet(w, req)
|
api.DomainsGet(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusNotFound {
|
if w.Code != http.StatusNotFound {
|
||||||
t.Errorf("expected 404, got %d", w.Code)
|
t.Errorf("expected 404, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServicesUpdate(t *testing.T) {
|
func TestDomainsUpdate(t *testing.T) {
|
||||||
api, _ := setupTestAPI(t)
|
api, _ := setupTestAPI(t)
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
@@ -150,16 +150,16 @@ func TestServicesUpdate(t *testing.T) {
|
|||||||
"source": "test",
|
"source": "test",
|
||||||
"backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"},
|
"backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"},
|
||||||
})
|
})
|
||||||
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
api.ServicesRegister(w, req)
|
api.DomainsRegister(w, req)
|
||||||
|
|
||||||
// Update public to true
|
// Update public to true
|
||||||
update, _ := json.Marshal(map[string]any{"public": true})
|
update, _ := json.Marshal(map[string]any{"public": true})
|
||||||
req = httptest.NewRequest("PATCH", "/api/v1/services/update-test.example.com", bytes.NewReader(update))
|
req = httptest.NewRequest("PATCH", "/api/v1/domains/update-test.example.com", bytes.NewReader(update))
|
||||||
req = mux.SetURLVars(req, map[string]string{"domain": "update-test.example.com"})
|
req = mux.SetURLVars(req, map[string]string{"domain": "update-test.example.com"})
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
api.ServicesUpdate(w, req)
|
api.DomainsUpdate(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
@@ -167,13 +167,13 @@ func TestServicesUpdate(t *testing.T) {
|
|||||||
|
|
||||||
var resp map[string]any
|
var resp map[string]any
|
||||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
svc := resp["service"].(map[string]any)
|
dom := resp["domain"].(map[string]any)
|
||||||
if svc["public"] != true {
|
if dom["public"] != true {
|
||||||
t.Errorf("expected public=true, got %v", svc["public"])
|
t.Errorf("expected public=true, got %v", dom["public"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServicesDeregister(t *testing.T) {
|
func TestDomainsDeregister(t *testing.T) {
|
||||||
api, _ := setupTestAPI(t)
|
api, _ := setupTestAPI(t)
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
@@ -182,40 +182,40 @@ func TestServicesDeregister(t *testing.T) {
|
|||||||
"source": "test",
|
"source": "test",
|
||||||
"backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"},
|
"backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"},
|
||||||
})
|
})
|
||||||
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
api.ServicesRegister(w, req)
|
api.DomainsRegister(w, req)
|
||||||
|
|
||||||
// Delete
|
// Delete
|
||||||
req = httptest.NewRequest("DELETE", "/api/v1/services/delete-me.example.com", nil)
|
req = httptest.NewRequest("DELETE", "/api/v1/domains/delete-me.example.com", nil)
|
||||||
req = mux.SetURLVars(req, map[string]string{"domain": "delete-me.example.com"})
|
req = mux.SetURLVars(req, map[string]string{"domain": "delete-me.example.com"})
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
api.ServicesDeregister(w, req)
|
api.DomainsDeregister(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify gone
|
// Verify gone
|
||||||
req = httptest.NewRequest("GET", "/api/v1/services/delete-me.example.com", nil)
|
req = httptest.NewRequest("GET", "/api/v1/domains/delete-me.example.com", nil)
|
||||||
req = mux.SetURLVars(req, map[string]string{"domain": "delete-me.example.com"})
|
req = mux.SetURLVars(req, map[string]string{"domain": "delete-me.example.com"})
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
api.ServicesGet(w, req)
|
api.DomainsGet(w, req)
|
||||||
if w.Code != http.StatusNotFound {
|
if w.Code != http.StatusNotFound {
|
||||||
t.Errorf("expected 404 after delete, got %d", w.Code)
|
t.Errorf("expected 404 after delete, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServicesRegister_Validation(t *testing.T) {
|
func TestDomainsRegister_Validation(t *testing.T) {
|
||||||
api, _ := setupTestAPI(t)
|
api, _ := setupTestAPI(t)
|
||||||
|
|
||||||
// Missing domain
|
// Missing domain
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"backend": map[string]any{"address": "127.0.0.1:80", "type": "http"},
|
"backend": map[string]any{"address": "127.0.0.1:80", "type": "http"},
|
||||||
})
|
})
|
||||||
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
api.ServicesRegister(w, req)
|
api.DomainsRegister(w, req)
|
||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusBadRequest {
|
||||||
t.Errorf("expected 400 for missing domain, got %d", w.Code)
|
t.Errorf("expected 400 for missing domain, got %d", w.Code)
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ func TestHaproxyStats_WhenSocketMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHaproxyGenerate_MissingGlobalConfig(t *testing.T) {
|
func TestHaproxyGenerate_MissingState(t *testing.T) {
|
||||||
api, tmpDir := setupTestHaproxy(t)
|
api, tmpDir := setupTestHaproxy(t)
|
||||||
|
|
||||||
os.Remove(filepath.Join(tmpDir, "state.yaml"))
|
os.Remove(filepath.Join(tmpDir, "state.yaml"))
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func (api *API) vpnAutoUDPPorts() []int {
|
|||||||
// syncNftablesOnly regenerates and applies nftables rules from the current global config
|
// syncNftablesOnly regenerates and applies nftables rules from the current global config
|
||||||
// without touching HAProxy. Called when firewall settings change independently.
|
// without touching HAProxy. Called when firewall settings change independently.
|
||||||
// Runs errors are logged only — the caller does not wait for completion.
|
// Runs errors are logged only — the caller does not wait for completion.
|
||||||
func (api *API) syncNftablesOnly(globalCfg *config.GlobalConfig) {
|
func (api *API) syncNftablesOnly(globalCfg *config.State) {
|
||||||
nftCfg := globalCfg.Cloud.Nftables
|
nftCfg := globalCfg.Cloud.Nftables
|
||||||
|
|
||||||
// Explicitly disabled: flush the wild-cloud table
|
// Explicitly disabled: flush the wild-cloud table
|
||||||
|
|||||||
@@ -8,55 +8,53 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/wild-cloud/wild-central/internal/config"
|
"github.com/wild-cloud/wild-central/internal/config"
|
||||||
|
"github.com/wild-cloud/wild-central/internal/domains"
|
||||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
"github.com/wild-cloud/wild-central/internal/haproxy"
|
||||||
"github.com/wild-cloud/wild-central/internal/services"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestReconciliation_HAProxyConfigFromServices verifies that the reconciliation
|
// TestReconciliation_HAProxyConfigFromDomains verifies that the reconciliation
|
||||||
// logic correctly maps registered services to HAProxy configuration. This
|
// logic correctly maps registered domains to HAProxy configuration. This
|
||||||
// replicates the route-building logic from reconcileNetworking() and asserts
|
// replicates the route-building logic from reconcileNetworking() and asserts
|
||||||
// on the generated config without requiring haproxy or dnsmasq binaries.
|
// on the generated config without requiring haproxy or dnsmasq binaries.
|
||||||
func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
func TestReconciliation_HAProxyConfigFromDomains(t *testing.T) {
|
||||||
api, _ := setupTestAPI(t)
|
api, _ := setupTestAPI(t)
|
||||||
|
|
||||||
// Register a mix of services like a real deployment
|
// Register a mix of domains like a real deployment
|
||||||
testServices := []services.Service{
|
testDomains := []domains.Domain{
|
||||||
{
|
{
|
||||||
Domain: "cloud.payne.io",
|
DomainName: "cloud.payne.io",
|
||||||
Source: "wild-cloud",
|
Source: "wild-cloud",
|
||||||
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough},
|
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
|
||||||
Subdomains: true,
|
Subdomains: true,
|
||||||
Public: true,
|
Public: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Domain: "payne.io",
|
DomainName: "payne.io",
|
||||||
Source: "wild-cloud",
|
Source: "wild-cloud",
|
||||||
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough},
|
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
|
||||||
Subdomains: false,
|
Subdomains: false,
|
||||||
Public: true,
|
Public: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Domain: "wild-cloud.payne.io",
|
DomainName: "wild-cloud.payne.io",
|
||||||
Source: "wild-works",
|
Source: "wild-works",
|
||||||
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP},
|
Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Domain: "my-api.payne.io",
|
DomainName: "my-api.payne.io",
|
||||||
Source: "wild-works",
|
Source: "wild-works",
|
||||||
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP, Health: "/health"},
|
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP, Health: "/health"},
|
||||||
// Public defaults to false
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, svc := range testServices {
|
for _, dom := range testDomains {
|
||||||
if err := api.services.Register(svc); err != nil {
|
if err := api.domains.Register(dom); err != nil {
|
||||||
t.Fatalf("Register %s failed: %v", svc.Domain, err)
|
t.Fatalf("Register %s failed: %v", dom.DomainName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replicate reconcileNetworking route-building logic
|
// Replicate reconcileNetworking route-building logic
|
||||||
svcs, err := api.services.List()
|
doms, err := api.domains.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("List failed: %v", err)
|
t.Fatalf("List failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -64,50 +62,50 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
|||||||
var instanceRoutes []haproxy.InstanceRoute
|
var instanceRoutes []haproxy.InstanceRoute
|
||||||
var httpRoutes []haproxy.HTTPRoute
|
var httpRoutes []haproxy.HTTPRoute
|
||||||
|
|
||||||
for _, svc := range svcs {
|
for _, dom := range doms {
|
||||||
switch svc.Backend.Type {
|
switch dom.Backend.Type {
|
||||||
case services.BackendTCPPassthrough:
|
case domains.BackendTCPPassthrough:
|
||||||
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
|
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
|
||||||
Name: svc.Domain,
|
Name: dom.DomainName,
|
||||||
Domain: svc.Domain,
|
Domain: dom.DomainName,
|
||||||
BackendIP: extractHost(svc.Backend.Address),
|
BackendIP: extractHost(dom.Backend.Address),
|
||||||
Subdomains: svc.Subdomains,
|
Subdomains: dom.Subdomains,
|
||||||
})
|
})
|
||||||
case services.BackendHTTP:
|
case domains.BackendHTTP:
|
||||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||||
Name: svc.Domain,
|
Name: dom.DomainName,
|
||||||
Domain: svc.Domain,
|
Domain: dom.DomainName,
|
||||||
Routes: []haproxy.HTTPRouteBackend{{
|
Routes: []haproxy.HTTPRouteBackend{{
|
||||||
Backend: svc.Backend.Address,
|
Backend: dom.Backend.Address,
|
||||||
HealthPath: svc.Backend.Health,
|
HealthPath: dom.Backend.Health,
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Central registers itself as a service; add it to the test data
|
// Central registers itself as a domain; add it to the test data
|
||||||
if err := api.services.Register(services.Service{
|
if err := api.domains.Register(domains.Domain{
|
||||||
Domain: "central.payne.io",
|
DomainName: "central.payne.io",
|
||||||
Source: "central",
|
Source: "central",
|
||||||
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP},
|
Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP},
|
||||||
TLS: services.TLSTerminate,
|
TLS: domains.TLSTerminate,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("Register central failed: %v", err)
|
t.Fatalf("Register central failed: %v", err)
|
||||||
}
|
}
|
||||||
// Re-list to pick up Central
|
// Re-list to pick up Central
|
||||||
svcs, err = api.services.List()
|
doms, err = api.domains.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("List failed: %v", err)
|
t.Fatalf("List failed: %v", err)
|
||||||
}
|
}
|
||||||
httpRoutes = nil
|
httpRoutes = nil
|
||||||
for _, svc := range svcs {
|
for _, dom := range doms {
|
||||||
if svc.Backend.Type == services.BackendHTTP {
|
if dom.Backend.Type == domains.BackendHTTP {
|
||||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||||
Name: svc.Domain,
|
Name: dom.DomainName,
|
||||||
Domain: svc.Domain,
|
Domain: dom.DomainName,
|
||||||
Routes: []haproxy.HTTPRouteBackend{{
|
Routes: []haproxy.HTTPRouteBackend{{
|
||||||
Backend: svc.Backend.Address,
|
Backend: dom.Backend.Address,
|
||||||
HealthPath: svc.Backend.Health,
|
HealthPath: dom.Backend.Health,
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -120,7 +118,7 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
|||||||
|
|
||||||
// --- Assertions on HAProxy config ---
|
// --- Assertions on HAProxy config ---
|
||||||
|
|
||||||
// L7 services have exact-match ACLs in the SNI frontend
|
// L7 domains have exact-match ACLs in the SNI frontend
|
||||||
if !strings.Contains(cfg, "acl is_l7_central_payne_io req_ssl_sni -m str central.payne.io") {
|
if !strings.Contains(cfg, "acl is_l7_central_payne_io req_ssl_sni -m str central.payne.io") {
|
||||||
t.Errorf("expected L7 exact-match ACL for central.payne.io:\n%s", cfg)
|
t.Errorf("expected L7 exact-match ACL for central.payne.io:\n%s", cfg)
|
||||||
}
|
}
|
||||||
@@ -128,12 +126,12 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
|||||||
t.Errorf("expected L7 exact-match ACL for wild-cloud.payne.io:\n%s", cfg)
|
t.Errorf("expected L7 exact-match ACL for wild-cloud.payne.io:\n%s", cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// L4 services with subdomains:true have wildcard ACLs
|
// L4 domains with subdomains:true have wildcard ACLs
|
||||||
if !strings.Contains(cfg, "req_ssl_sni -m end .cloud.payne.io") {
|
if !strings.Contains(cfg, "req_ssl_sni -m end .cloud.payne.io") {
|
||||||
t.Errorf("expected L4 wildcard ACL for cloud.payne.io (subdomains:true):\n%s", cfg)
|
t.Errorf("expected L4 wildcard ACL for cloud.payne.io (subdomains:true):\n%s", cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// L4 services with subdomains:false have exact-match only
|
// L4 domains with subdomains:false have exact-match only
|
||||||
if !strings.Contains(cfg, "req_ssl_sni -m str payne.io") {
|
if !strings.Contains(cfg, "req_ssl_sni -m str payne.io") {
|
||||||
t.Errorf("expected L4 exact-match ACL for payne.io:\n%s", cfg)
|
t.Errorf("expected L4 exact-match ACL for payne.io:\n%s", cfg)
|
||||||
}
|
}
|
||||||
@@ -168,72 +166,71 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestReconciliation_DnsmasqConfigFromServices verifies that reconciliation
|
// TestReconciliation_DnsmasqConfigFromDomains verifies that reconciliation
|
||||||
// correctly generates dnsmasq config from registered services, including
|
// correctly generates dnsmasq config from registered domains, including
|
||||||
// the critical reach-based local=/ directives.
|
// the critical reach-based local=/ directives.
|
||||||
func TestReconciliation_DnsmasqConfigFromServices(t *testing.T) {
|
func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
|
||||||
api, _ := setupTestAPI(t)
|
api, _ := setupTestAPI(t)
|
||||||
|
|
||||||
centralIP := "192.168.8.151"
|
centralIP := "192.168.8.151"
|
||||||
|
|
||||||
// Register services with different reach and backend types
|
// Register domains with different reach and backend types
|
||||||
testServices := []services.Service{
|
testDomains := []domains.Domain{
|
||||||
{
|
{
|
||||||
// tcp-passthrough, public → DNS points to k8s LB IP, no local=/
|
// tcp-passthrough, public → DNS points to k8s LB IP, no local=/
|
||||||
Domain: "cloud.payne.io",
|
DomainName: "cloud.payne.io",
|
||||||
Source: "wild-cloud",
|
Source: "wild-cloud",
|
||||||
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough},
|
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
|
||||||
Subdomains: true,
|
Subdomains: true,
|
||||||
Public: true,
|
Public: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// http, internal → DNS points to Central IP, has local=/
|
// http, internal → DNS points to Central IP, has local=/
|
||||||
Domain: "my-api.payne.io",
|
DomainName: "my-api.payne.io",
|
||||||
Source: "wild-works",
|
Source: "wild-works",
|
||||||
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP},
|
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// http, public → DNS points to Central IP, NO local=/
|
// http, public → DNS points to Central IP, NO local=/
|
||||||
Domain: "public-app.payne.io",
|
DomainName: "public-app.payne.io",
|
||||||
Source: "wild-works",
|
Source: "wild-works",
|
||||||
Backend: services.Backend{Address: "192.168.8.60:8080", Type: services.BackendHTTP},
|
Backend: domains.Backend{Address: "192.168.8.60:8080", Type: domains.BackendHTTP},
|
||||||
Public: true,
|
Public: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, svc := range testServices {
|
for _, dom := range testDomains {
|
||||||
if err := api.services.Register(svc); err != nil {
|
if err := api.domains.Register(dom); err != nil {
|
||||||
t.Fatalf("Register %s failed: %v", svc.Domain, err)
|
t.Fatalf("Register %s failed: %v", dom.DomainName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build dnsmasq instance configs the same way reconcileNetworking does
|
// Build dnsmasq instance configs the same way reconcileNetworking does
|
||||||
svcs, err := api.services.List()
|
doms, err := api.domains.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("List failed: %v", err)
|
t.Fatalf("List failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
globalCfg := &config.GlobalConfig{}
|
globalCfg := &config.State{}
|
||||||
var instanceConfigs []config.InstanceConfig
|
var instanceConfigs []config.InstanceConfig
|
||||||
|
|
||||||
for _, svc := range svcs {
|
for _, dom := range doms {
|
||||||
if svc.Domain == "" {
|
if dom.DomainName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
dnsIP := centralIP
|
dnsIP := centralIP
|
||||||
if svc.Backend.Type == services.BackendTCPPassthrough {
|
if dom.Backend.Type == domains.BackendTCPPassthrough {
|
||||||
dnsIP = extractHost(svc.Backend.Address)
|
dnsIP = extractHost(dom.Backend.Address)
|
||||||
}
|
}
|
||||||
|
|
||||||
ic := config.InstanceConfig{}
|
ic := config.InstanceConfig{}
|
||||||
ic.Cluster.LoadBalancerIp = dnsIP
|
ic.Cluster.LoadBalancerIp = dnsIP
|
||||||
|
|
||||||
if !svc.Public {
|
if !dom.Public {
|
||||||
ic.Cloud.InternalDomain = svc.Domain
|
ic.Cloud.InternalDomain = dom.DomainName
|
||||||
} else {
|
} else {
|
||||||
ic.Cloud.Domain = svc.Domain
|
ic.Cloud.Domain = dom.DomainName
|
||||||
}
|
}
|
||||||
|
|
||||||
instanceConfigs = append(instanceConfigs, ic)
|
instanceConfigs = append(instanceConfigs, ic)
|
||||||
@@ -243,69 +240,67 @@ func TestReconciliation_DnsmasqConfigFromServices(t *testing.T) {
|
|||||||
|
|
||||||
// --- TCP passthrough (public) → backend IP ---
|
// --- TCP passthrough (public) → backend IP ---
|
||||||
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
|
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
|
||||||
t.Errorf("tcp-passthrough service must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg)
|
t.Errorf("tcp-passthrough domain must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HTTP services → Central IP ---
|
// --- HTTP domains → Central IP ---
|
||||||
if !strings.Contains(dnsmasqCfg, "address=/my-api.payne.io/192.168.8.151") {
|
if !strings.Contains(dnsmasqCfg, "address=/my-api.payne.io/192.168.8.151") {
|
||||||
t.Errorf("http/internal service must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
|
t.Errorf("http/internal domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
if !strings.Contains(dnsmasqCfg, "address=/public-app.payne.io/192.168.8.151") {
|
if !strings.Contains(dnsmasqCfg, "address=/public-app.payne.io/192.168.8.151") {
|
||||||
t.Errorf("http/public service must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
|
t.Errorf("http/public domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- reach:internal → local=/ present ---
|
// --- internal → local=/ present ---
|
||||||
if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") {
|
if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") {
|
||||||
t.Errorf("reach:internal service must have local=/ entry:\n%s", dnsmasqCfg)
|
t.Errorf("internal domain must have local=/ entry:\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- reach:public → NO local=/ ---
|
// --- public → NO local=/ ---
|
||||||
if strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") {
|
if strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") {
|
||||||
t.Errorf("reach:public service must NOT have local=/ entry:\n%s", dnsmasqCfg)
|
t.Errorf("public domain must NOT have local=/ entry:\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
if strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") {
|
if strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") {
|
||||||
t.Errorf("reach:public service must NOT have local=/ entry:\n%s", dnsmasqCfg)
|
t.Errorf("public domain must NOT have local=/ entry:\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestReconciliation_CentralDomainInHAProxy verifies that Central, registered
|
// TestReconciliation_CentralDomainInHAProxy verifies that Central, registered
|
||||||
// as a service, produces correct HAProxy L7 routes.
|
// as a domain, produces correct HAProxy L7 routes.
|
||||||
func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
|
func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
|
||||||
api, _ := setupTestAPI(t)
|
api, _ := setupTestAPI(t)
|
||||||
|
|
||||||
centralPort := 15055
|
centralPort := 15055
|
||||||
|
|
||||||
// Register Central as a service (as EnsureCentralService does)
|
// Register Central as a domain (as EnsureCentralDomain does)
|
||||||
if err := api.services.Register(services.Service{
|
if err := api.domains.Register(domains.Domain{
|
||||||
Domain: "central.payne.io",
|
DomainName: "central.payne.io",
|
||||||
Source: "central",
|
Source: "central",
|
||||||
Backend: services.Backend{Address: fmt.Sprintf("127.0.0.1:%d", centralPort), Type: services.BackendHTTP},
|
Backend: domains.Backend{Address: fmt.Sprintf("127.0.0.1:%d", centralPort), Type: domains.BackendHTTP},
|
||||||
// Public defaults to false
|
TLS: domains.TLSTerminate,
|
||||||
TLS: services.TLSTerminate,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("Register central failed: %v", err)
|
t.Fatalf("Register central failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register another HTTP service
|
// Register another HTTP domain
|
||||||
if err := api.services.Register(services.Service{
|
if err := api.domains.Register(domains.Domain{
|
||||||
Domain: "my-app.payne.io",
|
DomainName: "my-app.payne.io",
|
||||||
Source: "wild-works",
|
Source: "wild-works",
|
||||||
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP},
|
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("Register failed: %v", err)
|
t.Fatalf("Register failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svcs, _ := api.services.List()
|
doms, _ := api.domains.List()
|
||||||
|
|
||||||
var httpRoutes []haproxy.HTTPRoute
|
var httpRoutes []haproxy.HTTPRoute
|
||||||
for _, svc := range svcs {
|
for _, dom := range doms {
|
||||||
if svc.Backend.Type == services.BackendHTTP {
|
if dom.Backend.Type == domains.BackendHTTP {
|
||||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||||
Name: svc.Domain,
|
Name: dom.DomainName,
|
||||||
Domain: svc.Domain,
|
Domain: dom.DomainName,
|
||||||
Routes: []haproxy.HTTPRouteBackend{{
|
Routes: []haproxy.HTTPRouteBackend{{
|
||||||
Backend: svc.Backend.Address,
|
Backend: dom.Backend.Address,
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -330,39 +325,39 @@ func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestReconciliation_TCPPassthroughDNSTarget verifies that tcp-passthrough
|
// TestReconciliation_TCPPassthroughDNSTarget verifies that tcp-passthrough
|
||||||
// services get DNS pointing to the backend IP, not Central's IP.
|
// domains get DNS pointing to the backend IP, not Central's IP.
|
||||||
func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
|
func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
|
||||||
api, _ := setupTestAPI(t)
|
api, _ := setupTestAPI(t)
|
||||||
|
|
||||||
centralIP := "192.168.8.151"
|
centralIP := "192.168.8.151"
|
||||||
|
|
||||||
// Register a k8s instance with tcp-passthrough
|
// Register a k8s instance with tcp-passthrough
|
||||||
if err := api.services.Register(services.Service{
|
if err := api.domains.Register(domains.Domain{
|
||||||
Domain: "cloud.payne.io",
|
DomainName: "cloud.payne.io",
|
||||||
Source: "wild-cloud",
|
Source: "wild-cloud",
|
||||||
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough},
|
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
|
||||||
Subdomains: true,
|
Subdomains: true,
|
||||||
Public: true,
|
Public: true,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("Register failed: %v", err)
|
t.Fatalf("Register failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svcs, _ := api.services.List()
|
doms, _ := api.domains.List()
|
||||||
globalCfg := &config.GlobalConfig{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
var instanceConfigs []config.InstanceConfig
|
var instanceConfigs []config.InstanceConfig
|
||||||
for _, svc := range svcs {
|
for _, dom := range doms {
|
||||||
dnsIP := centralIP
|
dnsIP := centralIP
|
||||||
if svc.Backend.Type == services.BackendTCPPassthrough {
|
if dom.Backend.Type == domains.BackendTCPPassthrough {
|
||||||
dnsIP = extractHost(svc.Backend.Address)
|
dnsIP = extractHost(dom.Backend.Address)
|
||||||
}
|
}
|
||||||
|
|
||||||
ic := config.InstanceConfig{}
|
ic := config.InstanceConfig{}
|
||||||
ic.Cluster.LoadBalancerIp = dnsIP
|
ic.Cluster.LoadBalancerIp = dnsIP
|
||||||
if !svc.Public {
|
if !dom.Public {
|
||||||
ic.Cloud.InternalDomain = svc.Domain
|
ic.Cloud.InternalDomain = dom.DomainName
|
||||||
} else {
|
} else {
|
||||||
ic.Cloud.Domain = svc.Domain
|
ic.Cloud.Domain = dom.DomainName
|
||||||
}
|
}
|
||||||
instanceConfigs = append(instanceConfigs, ic)
|
instanceConfigs = append(instanceConfigs, ic)
|
||||||
}
|
}
|
||||||
@@ -378,6 +373,104 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReconciliation_DNSOnlyHasNoDNSButNoProxy verifies that dns-only domains
|
||||||
|
// get DNS entries but no HAProxy routes.
|
||||||
|
func TestReconciliation_DNSOnlyNoGateway(t *testing.T) {
|
||||||
|
api, _ := setupTestAPI(t)
|
||||||
|
|
||||||
|
centralIP := "192.168.8.151"
|
||||||
|
|
||||||
|
// Register a dns-only domain (e.g. for SSH access)
|
||||||
|
if err := api.domains.Register(domains.Domain{
|
||||||
|
DomainName: "dev.payne.io",
|
||||||
|
Source: "manual",
|
||||||
|
Backend: domains.Backend{Address: "192.168.8.222", Type: domains.BackendDNSOnly},
|
||||||
|
Public: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Register failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also register a normal HTTP domain to verify it still works
|
||||||
|
if err := api.domains.Register(domains.Domain{
|
||||||
|
DomainName: "app.payne.io",
|
||||||
|
Source: "wild-works",
|
||||||
|
Backend: domains.Backend{Address: "127.0.0.1:8080", Type: domains.BackendHTTP},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Register failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
doms, _ := api.domains.List()
|
||||||
|
|
||||||
|
// Build HAProxy routes — dns-only should be absent
|
||||||
|
var instanceRoutes []haproxy.InstanceRoute
|
||||||
|
var httpRoutes []haproxy.HTTPRoute
|
||||||
|
|
||||||
|
for _, dom := range doms {
|
||||||
|
switch dom.Backend.Type {
|
||||||
|
case domains.BackendTCPPassthrough:
|
||||||
|
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
|
||||||
|
Name: dom.DomainName,
|
||||||
|
Domain: dom.DomainName,
|
||||||
|
BackendIP: extractHost(dom.Backend.Address),
|
||||||
|
})
|
||||||
|
case domains.BackendDNSOnly:
|
||||||
|
// dns-only: no gateway routing
|
||||||
|
case domains.BackendHTTP:
|
||||||
|
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||||
|
Name: dom.DomainName,
|
||||||
|
Domain: dom.DomainName,
|
||||||
|
Routes: []haproxy.HTTPRouteBackend{{Backend: dom.Backend.Address}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
|
||||||
|
HTTPRoutes: httpRoutes,
|
||||||
|
CertsDir: "/etc/haproxy/certs/",
|
||||||
|
})
|
||||||
|
|
||||||
|
// dns-only domain must NOT appear in HAProxy config
|
||||||
|
if strings.Contains(cfg, "dev.payne.io") {
|
||||||
|
t.Errorf("dns-only domain must NOT appear in HAProxy config:\n%s", cfg)
|
||||||
|
}
|
||||||
|
// HTTP domain should appear
|
||||||
|
if !strings.Contains(cfg, "app.payne.io") {
|
||||||
|
t.Errorf("http domain should appear in HAProxy config:\n%s", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build DNS config — dns-only should point to its backend IP
|
||||||
|
globalCfg := &config.State{}
|
||||||
|
var instanceConfigs []config.InstanceConfig
|
||||||
|
|
||||||
|
for _, dom := range doms {
|
||||||
|
dnsIP := centralIP
|
||||||
|
bt := dom.Backend.Type
|
||||||
|
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
|
||||||
|
dnsIP = extractHost(dom.Backend.Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
ic := config.InstanceConfig{}
|
||||||
|
ic.Cluster.LoadBalancerIp = dnsIP
|
||||||
|
if !dom.Public {
|
||||||
|
ic.Cloud.InternalDomain = dom.DomainName
|
||||||
|
} else {
|
||||||
|
ic.Cloud.Domain = dom.DomainName
|
||||||
|
}
|
||||||
|
instanceConfigs = append(instanceConfigs, ic)
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs)
|
||||||
|
|
||||||
|
// dns-only domain should resolve to its backend IP (192.168.8.222)
|
||||||
|
if !strings.Contains(dnsmasqCfg, "address=/dev.payne.io/192.168.8.222") {
|
||||||
|
t.Errorf("dns-only domain must point DNS to backend IP:\n%s", dnsmasqCfg)
|
||||||
|
}
|
||||||
|
// HTTP domain should resolve to Central IP
|
||||||
|
if !strings.Contains(dnsmasqCfg, "address=/app.payne.io/192.168.8.151") {
|
||||||
|
t.Errorf("http domain must point DNS to Central IP:\n%s", dnsmasqCfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// writeTestConfig writes a minimal global config with the given Central domain.
|
// writeTestConfig writes a minimal global config with the given Central domain.
|
||||||
func writeTestConfig(t *testing.T, dataDir, centralDomain string) {
|
func writeTestConfig(t *testing.T, dataDir, centralDomain string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -388,85 +481,85 @@ func writeTestConfig(t *testing.T, dataDir, centralDomain string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureCentralService_RegistersWhenDomainConfigured(t *testing.T) {
|
func TestEnsureCentralDomain_RegistersWhenDomainConfigured(t *testing.T) {
|
||||||
api, dataDir := setupTestAPI(t)
|
api, dataDir := setupTestAPI(t)
|
||||||
api.SetPort(15055)
|
api.SetPort(15055)
|
||||||
writeTestConfig(t, dataDir, "central.example.com")
|
writeTestConfig(t, dataDir, "central.example.com")
|
||||||
|
|
||||||
api.EnsureCentralService()
|
api.EnsureCentralDomain()
|
||||||
|
|
||||||
svc, err := api.services.Get("central.example.com")
|
dom, err := api.domains.Get("central.example.com")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected Central service to be registered: %v", err)
|
t.Fatalf("expected Central domain to be registered: %v", err)
|
||||||
}
|
}
|
||||||
if svc.Source != "central" {
|
if dom.Source != "central" {
|
||||||
t.Errorf("source = %q, want %q", svc.Source, "central")
|
t.Errorf("source = %q, want %q", dom.Source, "central")
|
||||||
}
|
}
|
||||||
if svc.Backend.Address != "127.0.0.1:15055" {
|
if dom.Backend.Address != "127.0.0.1:15055" {
|
||||||
t.Errorf("backend = %q, want %q", svc.Backend.Address, "127.0.0.1:15055")
|
t.Errorf("backend = %q, want %q", dom.Backend.Address, "127.0.0.1:15055")
|
||||||
}
|
}
|
||||||
if svc.TLS != services.TLSTerminate {
|
if dom.TLS != domains.TLSTerminate {
|
||||||
t.Errorf("tls = %q, want %q", svc.TLS, services.TLSTerminate)
|
t.Errorf("tls = %q, want %q", dom.TLS, domains.TLSTerminate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureCentralService_Idempotent(t *testing.T) {
|
func TestEnsureCentralDomain_Idempotent(t *testing.T) {
|
||||||
api, dataDir := setupTestAPI(t)
|
api, dataDir := setupTestAPI(t)
|
||||||
api.SetPort(15055)
|
api.SetPort(15055)
|
||||||
writeTestConfig(t, dataDir, "central.example.com")
|
writeTestConfig(t, dataDir, "central.example.com")
|
||||||
|
|
||||||
api.EnsureCentralService()
|
api.EnsureCentralDomain()
|
||||||
api.EnsureCentralService() // second call should be a no-op
|
api.EnsureCentralDomain() // second call should be a no-op
|
||||||
|
|
||||||
svcs, _ := api.services.List()
|
doms, _ := api.domains.List()
|
||||||
count := 0
|
count := 0
|
||||||
for _, s := range svcs {
|
for _, d := range doms {
|
||||||
if s.Source == "central" {
|
if d.Source == "central" {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
t.Errorf("expected 1 central service, got %d", count)
|
t.Errorf("expected 1 central domain, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureCentralService_CleansUpOnDomainChange(t *testing.T) {
|
func TestEnsureCentralDomain_CleansUpOnDomainChange(t *testing.T) {
|
||||||
api, dataDir := setupTestAPI(t)
|
api, dataDir := setupTestAPI(t)
|
||||||
api.SetPort(15055)
|
api.SetPort(15055)
|
||||||
|
|
||||||
// Register with old domain
|
// Register with old domain
|
||||||
writeTestConfig(t, dataDir, "old.example.com")
|
writeTestConfig(t, dataDir, "old.example.com")
|
||||||
api.EnsureCentralService()
|
api.EnsureCentralDomain()
|
||||||
|
|
||||||
// Change domain
|
// Change domain
|
||||||
writeTestConfig(t, dataDir, "new.example.com")
|
writeTestConfig(t, dataDir, "new.example.com")
|
||||||
api.EnsureCentralService()
|
api.EnsureCentralDomain()
|
||||||
|
|
||||||
// Old domain should be gone
|
// Old domain should be gone
|
||||||
if _, err := api.services.Get("old.example.com"); err == nil {
|
if _, err := api.domains.Get("old.example.com"); err == nil {
|
||||||
t.Error("old Central service should have been deregistered")
|
t.Error("old Central domain should have been deregistered")
|
||||||
}
|
}
|
||||||
// New domain should exist
|
// New domain should exist
|
||||||
svc, err := api.services.Get("new.example.com")
|
dom, err := api.domains.Get("new.example.com")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new Central service should be registered: %v", err)
|
t.Fatalf("new Central domain should be registered: %v", err)
|
||||||
}
|
}
|
||||||
if svc.Source != "central" {
|
if dom.Source != "central" {
|
||||||
t.Errorf("source = %q, want %q", svc.Source, "central")
|
t.Errorf("source = %q, want %q", dom.Source, "central")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureCentralService_NoDomainIsNoop(t *testing.T) {
|
func TestEnsureCentralDomain_NoDomainIsNoop(t *testing.T) {
|
||||||
api, _ := setupTestAPI(t)
|
api, _ := setupTestAPI(t)
|
||||||
api.SetPort(15055)
|
api.SetPort(15055)
|
||||||
// Default config has no Central domain
|
// Default config has no Central domain
|
||||||
|
|
||||||
api.EnsureCentralService()
|
api.EnsureCentralDomain()
|
||||||
|
|
||||||
svcs, _ := api.services.List()
|
doms, _ := api.domains.List()
|
||||||
for _, s := range svcs {
|
for _, d := range doms {
|
||||||
if s.Source == "central" {
|
if d.Source == "central" {
|
||||||
t.Error("no Central service should be registered when domain is empty")
|
t.Error("no Central domain should be registered when domain is empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"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 domain.
|
|
||||||
func (api *API) ServicesGet(w http.ResponseWriter, r *http.Request) {
|
|
||||||
domain := mux.Vars(r)["domain"]
|
|
||||||
|
|
||||||
svc, err := api.services.Get(domain)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate routes
|
|
||||||
for i, r := range svc.Routes {
|
|
||||||
for _, p := range r.Paths {
|
|
||||||
if !strings.HasPrefix(p, "/") {
|
|
||||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Route %d: path prefix must start with /: %q", i, p))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, cidr := range r.IPAllow {
|
|
||||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
|
||||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Route %d: invalid CIDR in ipAllow: %q", i, cidr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := api.services.Register(svc); err != nil {
|
|
||||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to register service: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the stored version (has defaults applied)
|
|
||||||
stored, _ := api.services.Get(svc.Domain)
|
|
||||||
respondJSON(w, http.StatusCreated, map[string]any{
|
|
||||||
"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) {
|
|
||||||
domain := mux.Vars(r)["domain"]
|
|
||||||
|
|
||||||
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(domain, updates); err != nil {
|
|
||||||
respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to update service: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
svc, _ := api.services.Get(domain)
|
|
||||||
respondJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"message": fmt.Sprintf("Service %q updated", domain),
|
|
||||||
"service": svc,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServicesDeregister removes a service registration.
|
|
||||||
func (api *API) ServicesDeregister(w http.ResponseWriter, r *http.Request) {
|
|
||||||
domain := mux.Vars(r)["domain"]
|
|
||||||
|
|
||||||
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", 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))
|
|
||||||
}
|
|
||||||
@@ -9,23 +9,23 @@ import (
|
|||||||
|
|
||||||
"github.com/wild-cloud/wild-central/internal/certbot"
|
"github.com/wild-cloud/wild-central/internal/certbot"
|
||||||
"github.com/wild-cloud/wild-central/internal/config"
|
"github.com/wild-cloud/wild-central/internal/config"
|
||||||
|
"github.com/wild-cloud/wild-central/internal/domains"
|
||||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
"github.com/wild-cloud/wild-central/internal/haproxy"
|
||||||
"github.com/wild-cloud/wild-central/internal/network"
|
"github.com/wild-cloud/wild-central/internal/network"
|
||||||
"github.com/wild-cloud/wild-central/internal/services"
|
|
||||||
"github.com/wild-cloud/wild-central/internal/sse"
|
"github.com/wild-cloud/wild-central/internal/sse"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnsureCentralService registers (or updates) Central's own domain as a service
|
// EnsureCentralDomain registers (or updates) Central's own domain as a domain
|
||||||
// so it participates in DNS, HAProxy routing, and TLS cert tracking like any
|
// so it participates in DNS, HAProxy routing, and TLS cert tracking like any
|
||||||
// other service. Called on startup and when global config changes.
|
// other domain. Called on startup and when global config changes.
|
||||||
func (api *API) EnsureCentralService() {
|
func (api *API) EnsureCentralDomain() {
|
||||||
centralDomain := api.getCentralDomain()
|
centralDomain := api.getCentralDomain()
|
||||||
port := api.getRunningPort()
|
port := api.getRunningPort()
|
||||||
backend := fmt.Sprintf("127.0.0.1:%d", port)
|
backend := fmt.Sprintf("127.0.0.1:%d", port)
|
||||||
|
|
||||||
// Check if the current registration already matches — skip if so.
|
// Check if the current registration already matches — skip if so.
|
||||||
if centralDomain != "" {
|
if centralDomain != "" {
|
||||||
if existing, _ := api.services.Get(centralDomain); existing != nil &&
|
if existing, _ := api.domains.Get(centralDomain); existing != nil &&
|
||||||
existing.Source == "central" &&
|
existing.Source == "central" &&
|
||||||
existing.Backend.Address == backend {
|
existing.Backend.Address == backend {
|
||||||
return
|
return
|
||||||
@@ -33,10 +33,10 @@ func (api *API) EnsureCentralService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clean up stale Central registrations (e.g. domain changed).
|
// Clean up stale Central registrations (e.g. domain changed).
|
||||||
svcs, _ := api.services.List()
|
doms, _ := api.domains.List()
|
||||||
for _, svc := range svcs {
|
for _, dom := range doms {
|
||||||
if svc.Source == "central" && svc.Domain != centralDomain {
|
if dom.Source == "central" && dom.DomainName != centralDomain {
|
||||||
_ = api.services.Deregister(svc.Domain)
|
_ = api.domains.Deregister(dom.DomainName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,55 +44,56 @@ func (api *API) EnsureCentralService() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = api.services.Register(services.Service{
|
_ = api.domains.Register(domains.Domain{
|
||||||
Domain: centralDomain,
|
DomainName: centralDomain,
|
||||||
Source: "central",
|
Source: "central",
|
||||||
Backend: services.Backend{
|
Backend: domains.Backend{
|
||||||
Address: backend,
|
Address: backend,
|
||||||
Type: services.BackendHTTP,
|
Type: domains.BackendHTTP,
|
||||||
},
|
},
|
||||||
// Public defaults to false (private)
|
TLS: domains.TLSTerminate,
|
||||||
TLS: services.TLSTerminate,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconcile runs networking reconciliation immediately. Called on startup
|
// Reconcile runs networking reconciliation immediately. Called on startup
|
||||||
// and automatically whenever a service is registered/updated/deregistered.
|
// and automatically whenever a domain is registered/updated/deregistered.
|
||||||
func (api *API) Reconcile() {
|
func (api *API) Reconcile() {
|
||||||
api.reconcileNetworking()
|
api.reconcileNetworking()
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileNetworking reads all registered services and regenerates
|
// reconcileNetworking reads all registered domains and regenerates
|
||||||
// dnsmasq DNS entries and HAProxy routes to match.
|
// dnsmasq DNS entries and HAProxy routes to match.
|
||||||
func (api *API) reconcileNetworking() {
|
func (api *API) reconcileNetworking() {
|
||||||
svcs, err := api.services.List()
|
doms, err := api.domains.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("reconcile: failed to list services", "error", err)
|
slog.Error("reconcile: failed to list domains", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
globalCfg, err := config.LoadState(api.statePath())
|
globalCfg, err := config.LoadState(api.statePath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("reconcile: failed to load state, using empty", "error", err)
|
slog.Warn("reconcile: failed to load state, using empty", "error", err)
|
||||||
globalCfg = &config.GlobalConfig{}
|
globalCfg = &config.State{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build HAProxy routes from registered services
|
// Build HAProxy routes from registered domains
|
||||||
var instanceRoutes []haproxy.InstanceRoute
|
var instanceRoutes []haproxy.InstanceRoute
|
||||||
var httpRoutes []haproxy.HTTPRoute
|
var httpRoutes []haproxy.HTTPRoute
|
||||||
|
|
||||||
for _, svc := range svcs {
|
for _, dom := range doms {
|
||||||
switch svc.EffectiveBackendType() {
|
switch dom.EffectiveBackendType() {
|
||||||
case services.BackendTCPPassthrough:
|
case domains.BackendTCPPassthrough:
|
||||||
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
|
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
|
||||||
Name: svc.Domain,
|
Name: dom.DomainName,
|
||||||
Domain: svc.Domain,
|
Domain: dom.DomainName,
|
||||||
BackendIP: extractHost(svc.EffectiveBackendAddress()),
|
BackendIP: extractHost(dom.EffectiveBackendAddress()),
|
||||||
Subdomains: svc.Subdomains,
|
Subdomains: dom.Subdomains,
|
||||||
})
|
})
|
||||||
case services.BackendHTTP:
|
case domains.BackendDNSOnly:
|
||||||
|
// dns-only: no gateway routing, just DNS resolution
|
||||||
|
case domains.BackendHTTP:
|
||||||
var routeBackends []haproxy.HTTPRouteBackend
|
var routeBackends []haproxy.HTTPRouteBackend
|
||||||
for _, r := range svc.EffectiveRoutes() {
|
for _, r := range dom.EffectiveRoutes() {
|
||||||
routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{
|
routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{
|
||||||
Paths: r.Paths,
|
Paths: r.Paths,
|
||||||
Backend: r.Backend.Address,
|
Backend: r.Backend.Address,
|
||||||
@@ -102,15 +103,15 @@ func (api *API) reconcileNetworking() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||||
Name: svc.Domain,
|
Name: dom.DomainName,
|
||||||
Domain: svc.Domain,
|
Domain: dom.DomainName,
|
||||||
Subdomains: svc.Subdomains,
|
Subdomains: dom.Subdomains,
|
||||||
Routes: routeBackends,
|
Routes: routeBackends,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only include L7 HTTP routes for services that have a cert.
|
// Only include L7 HTTP routes for domains that have a cert.
|
||||||
certsDir := "/etc/haproxy/certs/"
|
certsDir := "/etc/haproxy/certs/"
|
||||||
var activeHTTPRoutes []haproxy.HTTPRoute
|
var activeHTTPRoutes []haproxy.HTTPRoute
|
||||||
for _, route := range httpRoutes {
|
for _, route := range httpRoutes {
|
||||||
@@ -129,10 +130,10 @@ func (api *API) reconcileNetworking() {
|
|||||||
haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, genOpts)
|
haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, genOpts)
|
||||||
|
|
||||||
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
|
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
|
||||||
// Validation failed — identify and exclude broken services, then retry
|
// Validation failed — identify and exclude broken domains, then retry
|
||||||
brokenDomains := haproxy.FindBrokenServices(haproxyCfg, haproxy.ParseValidationErrors(err.Error()))
|
brokenDomains := haproxy.FindBrokenServices(haproxyCfg, haproxy.ParseValidationErrors(err.Error()))
|
||||||
if len(brokenDomains) > 0 {
|
if len(brokenDomains) > 0 {
|
||||||
slog.Error("reconcile: excluding broken services and retrying",
|
slog.Error("reconcile: excluding broken domains and retrying",
|
||||||
"broken", brokenDomains, "error", err)
|
"broken", brokenDomains, "error", err)
|
||||||
|
|
||||||
exclude := map[string]bool{}
|
exclude := map[string]bool{}
|
||||||
@@ -161,22 +162,22 @@ func (api *API) reconcileNetworking() {
|
|||||||
if err := api.haproxy.ReloadService(); err != nil {
|
if err := api.haproxy.ReloadService(); err != nil {
|
||||||
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
|
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
|
||||||
} else {
|
} else {
|
||||||
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated (excluded broken services)")
|
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated (excluded broken domains)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
slog.Error("reconcile: failed to write HAProxy config (no broken services identified)", "error", err)
|
slog.Error("reconcile: failed to write HAProxy config (no broken domains identified)", "error", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := api.haproxy.ReloadService(); err != nil {
|
if err := api.haproxy.ReloadService(); err != nil {
|
||||||
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
|
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
|
||||||
} else {
|
} else {
|
||||||
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated from registered services")
|
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated from registered domains")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate dnsmasq DNS entries from registered services.
|
// Generate dnsmasq DNS entries from registered domains.
|
||||||
// DNS target IP depends on service type:
|
// DNS target IP depends on domain type:
|
||||||
// tcp-passthrough (k8s): DNS → k8s LB IP (LAN traffic goes direct to k8s)
|
// tcp-passthrough (k8s): DNS → k8s LB IP (LAN traffic goes direct to k8s)
|
||||||
// http/static: DNS → Central IP (HAProxy terminates TLS)
|
// http/static: DNS → Central IP (HAProxy terminates TLS)
|
||||||
// Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected
|
// Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected
|
||||||
@@ -190,26 +191,27 @@ func (api *API) reconcileNetworking() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var instanceConfigs []config.InstanceConfig
|
var instanceConfigs []config.InstanceConfig
|
||||||
for _, svc := range svcs {
|
for _, dom := range doms {
|
||||||
if svc.Domain == "" {
|
if dom.DomainName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choose the DNS target IP based on service type
|
// Choose the DNS target IP based on domain type
|
||||||
dnsIP := centralIP
|
dnsIP := centralIP
|
||||||
if svc.EffectiveBackendType() == services.BackendTCPPassthrough {
|
bt := dom.EffectiveBackendType()
|
||||||
// k8s instances: LAN clients connect directly to the k8s LB
|
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
|
||||||
dnsIP = extractHost(svc.EffectiveBackendAddress())
|
// tcp-passthrough/dns-only: DNS resolves to the backend IP directly
|
||||||
|
dnsIP = extractHost(dom.EffectiveBackendAddress())
|
||||||
}
|
}
|
||||||
|
|
||||||
ic := config.InstanceConfig{}
|
ic := config.InstanceConfig{}
|
||||||
ic.Cluster.LoadBalancerIp = dnsIP
|
ic.Cluster.LoadBalancerIp = dnsIP
|
||||||
|
|
||||||
if !svc.Public {
|
if !dom.Public {
|
||||||
// Internal-only: local=/ prevents upstream DNS forwarding
|
// Internal-only: local=/ prevents upstream DNS forwarding
|
||||||
ic.Cloud.InternalDomain = svc.Domain
|
ic.Cloud.InternalDomain = dom.DomainName
|
||||||
} else {
|
} else {
|
||||||
ic.Cloud.Domain = svc.Domain
|
ic.Cloud.Domain = dom.DomainName
|
||||||
}
|
}
|
||||||
|
|
||||||
instanceConfigs = append(instanceConfigs, ic)
|
instanceConfigs = append(instanceConfigs, ic)
|
||||||
@@ -218,51 +220,55 @@ func (api *API) reconcileNetworking() {
|
|||||||
if err := api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs, true); err != nil {
|
if err := api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs, true); err != nil {
|
||||||
slog.Error("reconcile: failed to update dnsmasq", "error", err)
|
slog.Error("reconcile: failed to update dnsmasq", "error", err)
|
||||||
} else {
|
} else {
|
||||||
api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered services")
|
api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered domains")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure TLS certs exist for HTTP services (where Central terminates TLS).
|
// Ensure TLS certs exist for HTTP domains (where Central terminates TLS).
|
||||||
// For services under the gateway domain, a wildcard cert covers them all.
|
// For domains under the gateway domain, a wildcard cert covers them all.
|
||||||
// For others, provision individual certs.
|
// For others, provision individual certs.
|
||||||
if len(httpRoutes) > 0 {
|
if len(httpRoutes) > 0 {
|
||||||
api.ensureTLSCerts(globalCfg, svcs)
|
api.ensureTLSCerts(globalCfg, doms)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nudge DDNS to pick up any domain changes (public toggle, new domains).
|
||||||
|
// The runner reads fresh params on each check via its callback.
|
||||||
|
api.ddns.Trigger()
|
||||||
|
|
||||||
slog.Info("reconcile: networking updated",
|
slog.Info("reconcile: networking updated",
|
||||||
"services", len(svcs),
|
"domains", len(doms),
|
||||||
"l4Routes", len(instanceRoutes),
|
"l4Routes", len(instanceRoutes),
|
||||||
"l7Routes", len(httpRoutes),
|
"l7Routes", len(httpRoutes),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureTLSCerts logs which certificates are missing for registered services.
|
// ensureTLSCerts logs which certificates are missing for registered domains.
|
||||||
// Does NOT auto-provision — cert provisioning is user-initiated via the
|
// Does NOT auto-provision — cert provisioning is user-initiated via the
|
||||||
// Certificates page. This just logs warnings so the operator knows what's needed.
|
// Certificates page. This just logs warnings so the operator knows what's needed.
|
||||||
func (api *API) ensureTLSCerts(globalCfg *config.GlobalConfig, svcs []services.Service) {
|
func (api *API) ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) {
|
||||||
gatewayDomain := ""
|
gatewayDomain := ""
|
||||||
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
|
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
|
||||||
gatewayDomain = parts[1]
|
gatewayDomain = parts[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, svc := range svcs {
|
for _, dom := range doms {
|
||||||
if svc.TLS != services.TLSTerminate || svc.Domain == "" {
|
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if covered by wildcard
|
// Check if covered by wildcard
|
||||||
if gatewayDomain != "" && strings.HasSuffix(svc.Domain, "."+gatewayDomain) {
|
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
|
||||||
wildcardCert := certbot.HAProxyCertPath(gatewayDomain)
|
wildcardCert := certbot.HAProxyCertPath(gatewayDomain)
|
||||||
if _, err := os.Stat(wildcardCert); err != nil {
|
if _, err := os.Stat(wildcardCert); err != nil {
|
||||||
slog.Warn("reconcile/tls: no wildcard cert for gateway domain — provision via Certificates page",
|
slog.Warn("reconcile/tls: no wildcard cert for gateway domain — provision via Certificates page",
|
||||||
"domain", svc.Domain, "needed", "*."+gatewayDomain)
|
"domain", dom.DomainName, "needed", "*."+gatewayDomain)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check individual cert
|
// Check individual cert
|
||||||
if _, err := os.Stat(certbot.HAProxyCertPath(svc.Domain)); err != nil {
|
if _, err := os.Stat(certbot.HAProxyCertPath(dom.DomainName)); err != nil {
|
||||||
slog.Warn("reconcile/tls: no cert for service — provision via Certificates page",
|
slog.Warn("reconcile/tls: no cert for domain — provision via Certificates page",
|
||||||
"domain", svc.Domain)
|
"domain", dom.DomainName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,9 +296,6 @@ func hasCertForDomain(certsDir, domain string) bool {
|
|||||||
}
|
}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if strings.HasSuffix(e.Name(), ".pem") {
|
if strings.HasSuffix(e.Name(), ".pem") {
|
||||||
// There's at least one cert — HAProxy can start with the dir bind.
|
|
||||||
// The specific domain may not have its own cert but HAProxy won't crash.
|
|
||||||
// It will serve whichever cert matches best (or the first one as default).
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,7 +327,7 @@ func (api *API) broadcastDnsmasqEvent(eventType string, message string) {
|
|||||||
event := &sse.Event{
|
event := &sse.Event{
|
||||||
ID: fmt.Sprintf("dnsmasq-%d", time.Now().UnixNano()),
|
ID: fmt.Sprintf("dnsmasq-%d", time.Now().UnixNano()),
|
||||||
Type: eventType,
|
Type: eventType,
|
||||||
InstanceName: "global", // dnsmasq is global/central-level, not instance-specific
|
InstanceName: "global",
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Data: map[string]any{
|
Data: map[string]any{
|
||||||
"message": message,
|
"message": message,
|
||||||
|
|||||||
@@ -79,8 +79,10 @@ type DHCPStaticLease struct {
|
|||||||
Hostname string `yaml:"hostname,omitempty" json:"hostname,omitempty"`
|
Hostname string `yaml:"hostname,omitempty" json:"hostname,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GlobalConfig represents the main configuration structure
|
// State represents Wild Central's runtime state — operator settings, networking
|
||||||
type GlobalConfig struct {
|
// configuration, DDNS, DHCP, etc. Persisted in {dataDir}/state.yaml and mutated
|
||||||
|
// via the API. This is NOT boot-time config (which comes from env vars).
|
||||||
|
type State struct {
|
||||||
Operator struct {
|
Operator struct {
|
||||||
Email string `yaml:"email,omitempty" json:"email,omitempty"`
|
Email string `yaml:"email,omitempty" json:"email,omitempty"`
|
||||||
} `yaml:"operator,omitempty" json:"operator,omitempty"`
|
} `yaml:"operator,omitempty" json:"operator,omitempty"`
|
||||||
@@ -109,22 +111,21 @@ type GlobalConfig struct {
|
|||||||
ExtraPorts []ExtraPort `yaml:"extraPorts,omitempty" json:"extraPorts,omitempty"`
|
ExtraPorts []ExtraPort `yaml:"extraPorts,omitempty" json:"extraPorts,omitempty"`
|
||||||
} `yaml:"nftables,omitempty" json:"nftables,omitempty"`
|
} `yaml:"nftables,omitempty" json:"nftables,omitempty"`
|
||||||
DDNS struct {
|
DDNS struct {
|
||||||
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
||||||
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
|
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
|
||||||
Records []string `yaml:"records,omitempty" json:"records,omitempty"`
|
IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"`
|
||||||
IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"`
|
|
||||||
} `yaml:"ddns,omitempty" json:"ddns,omitempty"`
|
} `yaml:"ddns,omitempty" json:"ddns,omitempty"`
|
||||||
} `yaml:"cloud,omitempty" json:"cloud,omitempty"`
|
} `yaml:"cloud,omitempty" json:"cloud,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadState loads state from the specified path
|
// LoadState loads state from the specified path
|
||||||
func LoadState(configPath string) (*GlobalConfig, error) {
|
func LoadState(configPath string) (*State, error) {
|
||||||
data, err := os.ReadFile(configPath)
|
data, err := os.ReadFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
|
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
config := &GlobalConfig{}
|
config := &State{}
|
||||||
if err := yaml.Unmarshal(data, config); err != nil {
|
if err := yaml.Unmarshal(data, config); err != nil {
|
||||||
return nil, fmt.Errorf("parsing config file: %w", err)
|
return nil, fmt.Errorf("parsing config file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -133,7 +134,7 @@ func LoadState(configPath string) (*GlobalConfig, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveState saves the state to the specified path
|
// SaveState saves the state to the specified path
|
||||||
func SaveState(config *GlobalConfig, configPath string) error {
|
func SaveState(config *State, configPath string) error {
|
||||||
// Ensure the directory exists
|
// Ensure the directory exists
|
||||||
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
||||||
return fmt.Errorf("creating config directory: %w", err)
|
return fmt.Errorf("creating config directory: %w", err)
|
||||||
@@ -148,7 +149,7 @@ func SaveState(config *GlobalConfig, configPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty checks if the configuration is empty or uninitialized
|
// IsEmpty checks if the configuration is empty or uninitialized
|
||||||
func (c *GlobalConfig) IsEmpty() bool {
|
func (c *State) IsEmpty() bool {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestLoadState(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
stateYAML string
|
stateYAML string
|
||||||
verify func(t *testing.T, config *GlobalConfig)
|
verify func(t *testing.T, config *State)
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -25,7 +25,7 @@ cloud:
|
|||||||
central:
|
central:
|
||||||
domain: "central.example.com"
|
domain: "central.example.com"
|
||||||
`,
|
`,
|
||||||
verify: func(t *testing.T, config *GlobalConfig) {
|
verify: func(t *testing.T, config *State) {
|
||||||
if config.Operator.Email != "admin@example.com" {
|
if config.Operator.Email != "admin@example.com" {
|
||||||
t.Error("operator email not loaded correctly")
|
t.Error("operator email not loaded correctly")
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ cloud:
|
|||||||
stateYAML: `operator:
|
stateYAML: `operator:
|
||||||
email: "admin@example.com"
|
email: "admin@example.com"
|
||||||
`,
|
`,
|
||||||
verify: func(t *testing.T, config *GlobalConfig) {
|
verify: func(t *testing.T, config *State) {
|
||||||
if config.Operator.Email != "admin@example.com" {
|
if config.Operator.Email != "admin@example.com" {
|
||||||
t.Error("operator email not loaded correctly")
|
t.Error("operator email not loaded correctly")
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ cloud:
|
|||||||
{
|
{
|
||||||
name: "loads empty state",
|
name: "loads empty state",
|
||||||
stateYAML: "{}\n",
|
stateYAML: "{}\n",
|
||||||
verify: func(t *testing.T, config *GlobalConfig) {
|
verify: func(t *testing.T, config *State) {
|
||||||
if config.Operator.Email != "" {
|
if config.Operator.Email != "" {
|
||||||
t.Error("expected empty operator email")
|
t.Error("expected empty operator email")
|
||||||
}
|
}
|
||||||
@@ -139,13 +139,13 @@ func TestLoadState_Errors(t *testing.T) {
|
|||||||
func TestSaveState(t *testing.T) {
|
func TestSaveState(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
config *GlobalConfig
|
config *State
|
||||||
verify func(t *testing.T, statePath string)
|
verify func(t *testing.T, statePath string)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "saves complete state",
|
name: "saves complete state",
|
||||||
config: func() *GlobalConfig {
|
config: func() *State {
|
||||||
cfg := &GlobalConfig{}
|
cfg := &State{}
|
||||||
cfg.Operator.Email = "admin@example.com"
|
cfg.Operator.Email = "admin@example.com"
|
||||||
cfg.Cloud.Central.Domain = "central.example.com"
|
cfg.Cloud.Central.Domain = "central.example.com"
|
||||||
return cfg
|
return cfg
|
||||||
@@ -166,7 +166,7 @@ func TestSaveState(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "saves empty state",
|
name: "saves empty state",
|
||||||
config: &GlobalConfig{},
|
config: &State{},
|
||||||
verify: func(t *testing.T, statePath string) {
|
verify: func(t *testing.T, statePath string) {
|
||||||
if _, err := os.Stat(statePath); os.IsNotExist(err) {
|
if _, err := os.Stat(statePath); os.IsNotExist(err) {
|
||||||
t.Error("state file not created")
|
t.Error("state file not created")
|
||||||
@@ -221,7 +221,7 @@ func TestSaveState_CreatesDirectory(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
statePath := filepath.Join(tempDir, "nested", "dirs", "state.yaml")
|
statePath := filepath.Join(tempDir, "nested", "dirs", "state.yaml")
|
||||||
|
|
||||||
config := &GlobalConfig{}
|
config := &State{}
|
||||||
err := SaveState(config, statePath)
|
err := SaveState(config, statePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveState failed: %v", err)
|
t.Fatalf("SaveState failed: %v", err)
|
||||||
@@ -238,11 +238,11 @@ func TestSaveState_CreatesDirectory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: GlobalConfig.IsEmpty checks if config is empty
|
// Test: State.IsEmpty checks if config is empty
|
||||||
func TestGlobalConfig_IsEmpty(t *testing.T) {
|
func TestState_IsEmpty(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
config *GlobalConfig
|
config *State
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -252,13 +252,13 @@ func TestGlobalConfig_IsEmpty(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "default config is empty",
|
name: "default config is empty",
|
||||||
config: &GlobalConfig{},
|
config: &State{},
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "config with only central domain is not empty",
|
name: "config with only central domain is not empty",
|
||||||
config: func() *GlobalConfig {
|
config: func() *State {
|
||||||
cfg := &GlobalConfig{}
|
cfg := &State{}
|
||||||
cfg.Cloud.Central.Domain = "central.example.com"
|
cfg.Cloud.Central.Domain = "central.example.com"
|
||||||
return cfg
|
return cfg
|
||||||
}(),
|
}(),
|
||||||
@@ -266,8 +266,8 @@ func TestGlobalConfig_IsEmpty(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "config with only operator email is not empty",
|
name: "config with only operator email is not empty",
|
||||||
config: func() *GlobalConfig {
|
config: func() *State {
|
||||||
cfg := &GlobalConfig{}
|
cfg := &State{}
|
||||||
cfg.Operator.Email = "admin@example.com"
|
cfg.Operator.Email = "admin@example.com"
|
||||||
return cfg
|
return cfg
|
||||||
}(),
|
}(),
|
||||||
@@ -275,8 +275,8 @@ func TestGlobalConfig_IsEmpty(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "config with all fields is not empty",
|
name: "config with all fields is not empty",
|
||||||
config: func() *GlobalConfig {
|
config: func() *State {
|
||||||
cfg := &GlobalConfig{}
|
cfg := &State{}
|
||||||
cfg.Cloud.Central.Domain = "central.example.com"
|
cfg.Cloud.Central.Domain = "central.example.com"
|
||||||
cfg.Operator.Email = "admin@example.com"
|
cfg.Operator.Email = "admin@example.com"
|
||||||
return cfg
|
return cfg
|
||||||
@@ -477,12 +477,12 @@ func TestSaveCloudConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test: Round-trip save and load preserves data
|
// Test: Round-trip save and load preserves data
|
||||||
func TestGlobalConfig_RoundTrip(t *testing.T) {
|
func TestState_RoundTrip(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
statePath := filepath.Join(tempDir, "state.yaml")
|
statePath := filepath.Join(tempDir, "state.yaml")
|
||||||
|
|
||||||
// Create config with all fields
|
// Create config with all fields
|
||||||
original := &GlobalConfig{}
|
original := &State{}
|
||||||
original.Operator.Email = "admin@example.com"
|
original.Operator.Email = "admin@example.com"
|
||||||
original.Cloud.Central.Domain = "central.example.com"
|
original.Cloud.Central.Domain = "central.example.com"
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
func TestExtraPortsJSONDecode_Legacy(t *testing.T) {
|
func TestExtraPortsJSONDecode_Legacy(t *testing.T) {
|
||||||
// Legacy format: plain integer array
|
// Legacy format: plain integer array
|
||||||
input := `{"cloud":{"nftables":{"extraPorts":[22]}}}`
|
input := `{"cloud":{"nftables":{"extraPorts":[22]}}}`
|
||||||
var cfg GlobalConfig
|
var cfg State
|
||||||
if err := json.Unmarshal([]byte(input), &cfg); err != nil {
|
if err := json.Unmarshal([]byte(input), &cfg); err != nil {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
t.Fatalf("unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ func TestExtraPortsJSONDecode_Legacy(t *testing.T) {
|
|||||||
func TestExtraPortsJSONDecode_Structured(t *testing.T) {
|
func TestExtraPortsJSONDecode_Structured(t *testing.T) {
|
||||||
// New structured format
|
// New structured format
|
||||||
input := `{"cloud":{"nftables":{"extraPorts":[{"port":22,"protocol":"tcp","label":"SSH"},{"port":5353,"protocol":"udp"}]}}}`
|
input := `{"cloud":{"nftables":{"extraPorts":[{"port":22,"protocol":"tcp","label":"SSH"},{"port":5353,"protocol":"udp"}]}}}`
|
||||||
var cfg GlobalConfig
|
var cfg State
|
||||||
if err := json.Unmarshal([]byte(input), &cfg); err != nil {
|
if err := json.Unmarshal([]byte(input), &cfg); err != nil {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
t.Fatalf("unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ func TestExtraPortsJSONDecode_Structured(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExtraPortsJSONEncode(t *testing.T) {
|
func TestExtraPortsJSONEncode(t *testing.T) {
|
||||||
var cfg GlobalConfig
|
var cfg State
|
||||||
cfg.Cloud.Nftables.ExtraPorts = []ExtraPort{{Port: 22, Protocol: "tcp", Label: "SSH"}}
|
cfg.Cloud.Nftables.ExtraPorts = []ExtraPort{{Port: 22, Protocol: "tcp", Label: "SSH"}}
|
||||||
out, err := json.Marshal(cfg)
|
out, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -68,7 +68,7 @@ func TestExtraPortsJSONEncode(t *testing.T) {
|
|||||||
func TestExtraPortsYAML_LegacyRoundTrip(t *testing.T) {
|
func TestExtraPortsYAML_LegacyRoundTrip(t *testing.T) {
|
||||||
// Legacy YAML format with plain integers
|
// Legacy YAML format with plain integers
|
||||||
input := "cloud:\n nftables:\n extraPorts:\n - 22\n - 8080\n"
|
input := "cloud:\n nftables:\n extraPorts:\n - 22\n - 8080\n"
|
||||||
var cfg GlobalConfig
|
var cfg State
|
||||||
if err := yaml.Unmarshal([]byte(input), &cfg); err != nil {
|
if err := yaml.Unmarshal([]byte(input), &cfg); err != nil {
|
||||||
t.Fatalf("yaml unmarshal: %v", err)
|
t.Fatalf("yaml unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ func TestExtraPortsYAML_LegacyRoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
func TestExtraPortsYAML_StructuredRoundTrip(t *testing.T) {
|
func TestExtraPortsYAML_StructuredRoundTrip(t *testing.T) {
|
||||||
input := "cloud:\n nftables:\n extraPorts:\n - port: 22\n protocol: tcp\n label: SSH\n - port: 5353\n protocol: udp\n"
|
input := "cloud:\n nftables:\n extraPorts:\n - port: 22\n protocol: tcp\n label: SSH\n - port: 5353\n protocol: udp\n"
|
||||||
var cfg GlobalConfig
|
var cfg State
|
||||||
if err := yaml.Unmarshal([]byte(input), &cfg); err != nil {
|
if err := yaml.Unmarshal([]byte(input), &cfg); err != nil {
|
||||||
t.Fatalf("yaml unmarshal: %v", err)
|
t.Fatalf("yaml unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ func (m *Manager) AddDecision(ip, decType, reason, duration string) error {
|
|||||||
|
|
||||||
// DeleteDecision removes a ban decision by numeric ID
|
// DeleteDecision removes a ban decision by numeric ID
|
||||||
func (m *Manager) DeleteDecision(id int) error {
|
func (m *Manager) DeleteDecision(id int) error {
|
||||||
if _, err := runCscli("decisions", "delete", "-i", fmt.Sprintf("%d", id)); err != nil {
|
if _, err := runCscli("decisions", "delete", "--id", fmt.Sprintf("%d", id)); err != nil {
|
||||||
return fmt.Errorf("cscli decisions delete: %w", err)
|
return fmt.Errorf("cscli decisions delete: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -13,14 +13,18 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds the DDNS configuration
|
// Params holds the runtime parameters for DDNS sync — derived from system state
|
||||||
type Config struct {
|
// (public domains, API token, interval) on each tick via ParamsFunc.
|
||||||
Enabled bool
|
type Params struct {
|
||||||
APIToken string
|
APIToken string
|
||||||
Records []string
|
Records []string
|
||||||
IntervalMinutes int
|
IntervalMinutes int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParamsFunc returns the current DDNS parameters. Called on each tick so the
|
||||||
|
// runner always has fresh state (new tokens, new public domains, etc.).
|
||||||
|
type ParamsFunc func() Params
|
||||||
|
|
||||||
// RecordStatus holds the last-known sync result for a single DNS record.
|
// RecordStatus holds the last-known sync result for a single DNS record.
|
||||||
type RecordStatus struct {
|
type RecordStatus struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -39,13 +43,16 @@ type Status struct {
|
|||||||
Records []RecordStatus `json:"records,omitempty"`
|
Records []RecordStatus `json:"records,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runner manages the DDNS background goroutine and exposes current state
|
// Runner manages the DDNS background goroutine and exposes current state.
|
||||||
|
// The runner pulls fresh parameters via ParamsFunc on each tick, so token
|
||||||
|
// and record changes take effect without restarting the goroutine.
|
||||||
type Runner struct {
|
type Runner struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
status Status
|
status Status
|
||||||
trigger chan struct{}
|
trigger chan struct{}
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
ipFetcher func() (string, error) // injectable for tests
|
paramsFn ParamsFunc
|
||||||
|
ipFetcher func() (string, error) // injectable for tests
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRunner creates a new DDNS runner
|
// NewRunner creates a new DDNS runner
|
||||||
@@ -56,27 +63,30 @@ func NewRunner() *Runner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start launches or restarts the DDNS goroutine with the given config.
|
// Start launches the DDNS goroutine with a params callback.
|
||||||
// Safe to call multiple times — cancels the previous run first.
|
// Safe to call multiple times — cancels the previous run first.
|
||||||
// Enabled is set to true here (not inside Run) to eliminate the race where
|
func (rn *Runner) Start(parentCtx context.Context, paramsFn ParamsFunc) {
|
||||||
// the old goroutine's exit could clear Enabled after the new goroutine starts.
|
|
||||||
func (rn *Runner) Start(parentCtx context.Context, cfg Config) {
|
|
||||||
rn.mu.Lock()
|
rn.mu.Lock()
|
||||||
if rn.cancel != nil {
|
if rn.cancel != nil {
|
||||||
rn.cancel()
|
rn.cancel()
|
||||||
rn.cancel = nil
|
rn.cancel = nil
|
||||||
}
|
}
|
||||||
if !cfg.Enabled || cfg.APIToken == "" || len(cfg.Records) == 0 {
|
rn.paramsFn = paramsFn
|
||||||
|
|
||||||
|
// Validate initial params
|
||||||
|
p := paramsFn()
|
||||||
|
if p.APIToken == "" || len(p.Records) == 0 {
|
||||||
rn.status.Enabled = false
|
rn.status.Enabled = false
|
||||||
rn.mu.Unlock()
|
rn.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(parentCtx)
|
ctx, cancel := context.WithCancel(parentCtx)
|
||||||
rn.cancel = cancel
|
rn.cancel = cancel
|
||||||
rn.status.Enabled = true
|
rn.status.Enabled = true
|
||||||
rn.status.CurrentIP = "" // clear so first check always syncs all records
|
|
||||||
rn.mu.Unlock()
|
rn.mu.Unlock()
|
||||||
go rn.Run(ctx, cfg)
|
|
||||||
|
go rn.run(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop cancels the running DDNS goroutine if one is active.
|
// Stop cancels the running DDNS goroutine if one is active.
|
||||||
@@ -97,7 +107,7 @@ func (rn *Runner) GetStatus() Status {
|
|||||||
return rn.status
|
return rn.status
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger requests an immediate IP check and update (non-blocking)
|
// Trigger requests an immediate check with fresh params (non-blocking)
|
||||||
func (rn *Runner) Trigger() {
|
func (rn *Runner) Trigger() {
|
||||||
select {
|
select {
|
||||||
case rn.trigger <- struct{}{}:
|
case rn.trigger <- struct{}{}:
|
||||||
@@ -105,23 +115,19 @@ func (rn *Runner) Trigger() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the DDNS polling loop. It exits when ctx is cancelled.
|
// run is the DDNS polling loop. Calls paramsFn on each tick for fresh state.
|
||||||
// Enabled state is managed by Start/Stop, not this function.
|
func (rn *Runner) run(ctx context.Context) {
|
||||||
func (rn *Runner) Run(ctx context.Context, cfg Config) {
|
p := rn.paramsFn()
|
||||||
if !cfg.Enabled || cfg.APIToken == "" || len(cfg.Records) == 0 {
|
|
||||||
slog.Info("DDNS disabled or not configured", "component", "ddns")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
interval := time.Duration(cfg.IntervalMinutes) * time.Minute
|
interval := time.Duration(p.IntervalMinutes) * time.Minute
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
interval = 5 * time.Minute
|
interval = 5 * time.Minute
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("DDNS started", "component", "ddns", "records", cfg.Records, "interval", interval)
|
slog.Info("DDNS started", "component", "ddns", "records", p.Records, "interval", interval)
|
||||||
|
|
||||||
// Check immediately on start
|
// Check immediately on start
|
||||||
rn.checkAndUpdate(cfg)
|
rn.checkAndUpdate()
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -132,21 +138,27 @@ func (rn *Runner) Run(ctx context.Context, cfg Config) {
|
|||||||
slog.Info("DDNS stopped", "component", "ddns")
|
slog.Info("DDNS stopped", "component", "ddns")
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
rn.checkAndUpdate(cfg)
|
rn.checkAndUpdate()
|
||||||
case <-rn.trigger:
|
case <-rn.trigger:
|
||||||
rn.checkAndUpdate(cfg)
|
rn.checkAndUpdate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkAndUpdate fetches the current public IP and reconciles all records.
|
// checkAndUpdate fetches fresh params, the current public IP, and syncs all records.
|
||||||
// Records are always verified against Cloudflare, not just when the IP changes,
|
// The updateCloudflareRecord function short-circuits when the A record already matches,
|
||||||
// so external drift (e.g. a router DDNS overwriting with a CNAME) is self-healed.
|
// so there's no wasted API calls when nothing has changed.
|
||||||
func (rn *Runner) checkAndUpdate(cfg Config) {
|
func (rn *Runner) checkAndUpdate() {
|
||||||
rn.mu.Lock()
|
rn.mu.Lock()
|
||||||
rn.status.LastChecked = time.Now()
|
rn.status.LastChecked = time.Now()
|
||||||
rn.mu.Unlock()
|
rn.mu.Unlock()
|
||||||
|
|
||||||
|
p := rn.paramsFn()
|
||||||
|
if p.APIToken == "" || len(p.Records) == 0 {
|
||||||
|
slog.Debug("DDNS: no token or records, skipping", "component", "ddns")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ip, err := rn.ipFetcher()
|
ip, err := rn.ipFetcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rn.mu.Lock()
|
rn.mu.Lock()
|
||||||
@@ -156,21 +168,12 @@ func (rn *Runner) checkAndUpdate(cfg Config) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rn.mu.RLock()
|
slog.Debug("DDNS: syncing records", "component", "ddns", "ip", ip, "records", p.Records)
|
||||||
ipChanged := rn.status.CurrentIP != ip
|
|
||||||
rn.mu.RUnlock()
|
|
||||||
|
|
||||||
if !ipChanged {
|
|
||||||
slog.Debug("DDNS: IP unchanged, skipping update", "component", "ddns", "ip", ip)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("DDNS: IP changed, updating records", "component", "ddns", "newIP", ip, "records", cfg.Records)
|
|
||||||
|
|
||||||
var lastErr string
|
var lastErr string
|
||||||
records := make([]RecordStatus, 0, len(cfg.Records))
|
records := make([]RecordStatus, 0, len(p.Records))
|
||||||
for _, record := range cfg.Records {
|
for _, record := range p.Records {
|
||||||
if err := updateCloudflareRecord(cfg.APIToken, record, ip); err != nil {
|
if err := updateCloudflareRecord(p.APIToken, record, ip); err != nil {
|
||||||
lastErr = fmt.Sprintf("updating %s: %v", record, err)
|
lastErr = fmt.Sprintf("updating %s: %v", record, err)
|
||||||
slog.Error("DDNS: failed to update record", "component", "ddns", "record", record, "error", err)
|
slog.Error("DDNS: failed to update record", "component", "ddns", "record", record, "error", err)
|
||||||
records = append(records, RecordStatus{Name: record, OK: false, Error: err.Error()})
|
records = append(records, RecordStatus{Name: record, OK: false, Error: err.Error()})
|
||||||
|
|||||||
@@ -36,78 +36,12 @@ func TestTrigger_NonBlocking(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_ExitsOnContextCancel(t *testing.T) {
|
func staticParams(token string, records []string) ParamsFunc {
|
||||||
rn := NewRunner()
|
return func() Params {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
return Params{APIToken: token, Records: records, IntervalMinutes: 5}
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
rn.Run(ctx, Config{}) // not enabled — returns immediately
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Error("Run did not exit after context cancel")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_DisabledConfig_ExitsImmediately(t *testing.T) {
|
|
||||||
rn := NewRunner()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
rn.Run(ctx, Config{Enabled: false})
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Error("Run should exit immediately when disabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_NoToken_ExitsImmediately(t *testing.T) {
|
|
||||||
rn := NewRunner()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
rn.Run(ctx, Config{Enabled: true, APIToken: "", Records: []string{"cloud.example.com"}})
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Error("Run should exit immediately when no API token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_NoRecords_ExitsImmediately(t *testing.T) {
|
|
||||||
rn := NewRunner()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
rn.Run(ctx, Config{Enabled: true, APIToken: "token", Records: nil})
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Error("Run should exit immediately when no records configured")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStart_SetsEnabledStatus verifies Enabled=true after Start, false after Stop.
|
|
||||||
// Uses Start (not Run directly) since Start owns the Enabled lifecycle.
|
|
||||||
func TestStart_SetsEnabledStatus(t *testing.T) {
|
func TestStart_SetsEnabledStatus(t *testing.T) {
|
||||||
rn := NewRunner()
|
rn := NewRunner()
|
||||||
started := make(chan struct{}, 1)
|
started := make(chan struct{}, 1)
|
||||||
@@ -119,8 +53,7 @@ func TestStart_SetsEnabledStatus(t *testing.T) {
|
|||||||
return "1.2.3.4", nil
|
return "1.2.3.4", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
rn.Start(context.Background(), staticParams("tok", []string{"a.example.com"}))
|
||||||
rn.Start(ctx, Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}})
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-started:
|
case <-started:
|
||||||
@@ -129,7 +62,7 @@ func TestStart_SetsEnabledStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !rn.GetStatus().Enabled {
|
if !rn.GetStatus().Enabled {
|
||||||
t.Error("expected Enabled=true after Start with valid config")
|
t.Error("expected Enabled=true after Start with valid params")
|
||||||
}
|
}
|
||||||
|
|
||||||
rn.Stop()
|
rn.Stop()
|
||||||
@@ -139,23 +72,20 @@ func TestStart_SetsEnabledStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStart_InvalidConfig_SetsDisabled verifies Enabled=false when Start is called with bad config.
|
func TestStart_InvalidParams_SetsDisabled(t *testing.T) {
|
||||||
func TestStart_InvalidConfig_SetsDisabled(t *testing.T) {
|
for _, pfn := range []ParamsFunc{
|
||||||
for _, cfg := range []Config{
|
func() Params { return Params{} },
|
||||||
{Enabled: false},
|
func() Params { return Params{APIToken: ""} },
|
||||||
{Enabled: true, APIToken: ""},
|
func() Params { return Params{APIToken: "tok", Records: nil} },
|
||||||
{Enabled: true, APIToken: "tok", Records: nil},
|
|
||||||
} {
|
} {
|
||||||
rn := NewRunner()
|
rn := NewRunner()
|
||||||
rn.Start(context.Background(), cfg)
|
rn.Start(context.Background(), pfn)
|
||||||
if rn.GetStatus().Enabled {
|
if rn.GetStatus().Enabled {
|
||||||
t.Errorf("expected Enabled=false for config %+v", cfg)
|
t.Errorf("expected Enabled=false for empty params")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStart_Restart_KeepsEnabled verifies that calling Start twice does not
|
|
||||||
// clear Enabled — the race where the old goroutine's exit sets Enabled=false.
|
|
||||||
func TestStart_Restart_KeepsEnabled(t *testing.T) {
|
func TestStart_Restart_KeepsEnabled(t *testing.T) {
|
||||||
rn := NewRunner()
|
rn := NewRunner()
|
||||||
reached := make(chan struct{}, 2)
|
reached := make(chan struct{}, 2)
|
||||||
@@ -167,11 +97,9 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) {
|
|||||||
return "1.2.3.4", nil
|
return "1.2.3.4", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
pfn := staticParams("tok", []string{"a.example.com"})
|
||||||
cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}
|
rn.Start(context.Background(), pfn)
|
||||||
rn.Start(ctx, cfg)
|
|
||||||
|
|
||||||
// Wait for first goroutine to actually start
|
|
||||||
select {
|
select {
|
||||||
case <-reached:
|
case <-reached:
|
||||||
case <-time.After(time.Second):
|
case <-time.After(time.Second):
|
||||||
@@ -179,9 +107,8 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Restart — old goroutine is cancelled, new one starts
|
// Restart — old goroutine is cancelled, new one starts
|
||||||
rn.Start(ctx, cfg)
|
rn.Start(context.Background(), pfn)
|
||||||
|
|
||||||
// Enabled must still be true regardless of old goroutine's exit timing
|
|
||||||
if !rn.GetStatus().Enabled {
|
if !rn.GetStatus().Enabled {
|
||||||
t.Error("expected Enabled=true after restart")
|
t.Error("expected Enabled=true after restart")
|
||||||
}
|
}
|
||||||
@@ -189,8 +116,6 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) {
|
|||||||
rn.Stop()
|
rn.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// TestStop_ClearsEnabled verifies Stop sets Enabled=false.
|
|
||||||
func TestStop_ClearsEnabled(t *testing.T) {
|
func TestStop_ClearsEnabled(t *testing.T) {
|
||||||
rn := NewRunner()
|
rn := NewRunner()
|
||||||
rn.mu.Lock()
|
rn.mu.Lock()
|
||||||
@@ -204,7 +129,6 @@ func TestStop_ClearsEnabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStart_CancelsAndRestarts verifies calling Start twice cancels the previous goroutine.
|
|
||||||
func TestStart_CancelsAndRestarts(t *testing.T) {
|
func TestStart_CancelsAndRestarts(t *testing.T) {
|
||||||
rn := NewRunner()
|
rn := NewRunner()
|
||||||
|
|
||||||
@@ -219,10 +143,8 @@ func TestStart_CancelsAndRestarts(t *testing.T) {
|
|||||||
return "1.2.3.4", nil
|
return "1.2.3.4", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
pfn := staticParams("tok", []string{"a.example.com"})
|
||||||
cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}
|
rn.Start(context.Background(), pfn)
|
||||||
|
|
||||||
rn.Start(ctx, cfg)
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-firstReady:
|
case <-firstReady:
|
||||||
@@ -230,13 +152,12 @@ func TestStart_CancelsAndRestarts(t *testing.T) {
|
|||||||
t.Fatal("first goroutine did not start")
|
t.Fatal("first goroutine did not start")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unblock the first goroutine so the second Start can cancel it
|
|
||||||
close(block)
|
close(block)
|
||||||
|
|
||||||
// Second Start with disabled config must not deadlock
|
// Second Start with disabled params must not deadlock
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
rn.Start(ctx, Config{Enabled: false})
|
rn.Start(context.Background(), func() Params { return Params{} })
|
||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -247,59 +168,45 @@ func TestStart_CancelsAndRestarts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCheckAndUpdate_UpdatesCurrentIP verifies CurrentIP is set after IP fetch.
|
|
||||||
// Note: this calls updateCloudflareRecord which fails with a fake token,
|
|
||||||
// but CurrentIP is still updated to reflect the fetched IP.
|
|
||||||
func TestCheckAndUpdate_UpdatesCurrentIP(t *testing.T) {
|
func TestCheckAndUpdate_UpdatesCurrentIP(t *testing.T) {
|
||||||
rn := NewRunner()
|
rn := NewRunner()
|
||||||
rn.ipFetcher = func() (string, error) { return "5.6.7.8", nil }
|
rn.ipFetcher = func() (string, error) { return "5.6.7.8", nil }
|
||||||
|
rn.paramsFn = staticParams("fake", []string{"a.example.com"})
|
||||||
|
|
||||||
rn.checkAndUpdate(Config{APIToken: "fake", Records: []string{"a.example.com"}})
|
rn.checkAndUpdate()
|
||||||
|
|
||||||
if rn.GetStatus().CurrentIP != "5.6.7.8" {
|
if rn.GetStatus().CurrentIP != "5.6.7.8" {
|
||||||
t.Errorf("expected CurrentIP=5.6.7.8, got %q", rn.GetStatus().CurrentIP)
|
t.Errorf("expected CurrentIP=5.6.7.8, got %q", rn.GetStatus().CurrentIP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStart_ClearsCurrentIP verifies that Start resets CurrentIP so new records are
|
func TestCheckAndUpdate_SkipsWhenNoParams(t *testing.T) {
|
||||||
// always synced on the first check, even when the public IP hasn't changed.
|
|
||||||
// This catches the bug where adding a new record (e.g. dev.payne.io) would be
|
|
||||||
// skipped because currentIP already matched the fetched IP.
|
|
||||||
func TestStart_ClearsCurrentIP(t *testing.T) {
|
|
||||||
rn := NewRunner()
|
rn := NewRunner()
|
||||||
rn.mu.Lock()
|
rn.ipFetcher = func() (string, error) { return "1.2.3.4", nil }
|
||||||
rn.status.CurrentIP = "1.2.3.4" // simulates a previously running goroutine
|
rn.paramsFn = func() Params { return Params{} }
|
||||||
rn.mu.Unlock()
|
|
||||||
|
|
||||||
rn.Start(context.Background(), Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}})
|
rn.checkAndUpdate()
|
||||||
rn.Stop()
|
|
||||||
|
|
||||||
if rn.GetStatus().CurrentIP != "" {
|
if rn.GetStatus().CurrentIP != "" {
|
||||||
t.Error("Start should clear CurrentIP so the first check syncs all records")
|
t.Error("expected empty CurrentIP when params have no token/records")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCheckAndUpdate_SkipsWhenIPUnchanged verifies no Cloudflare call when IP matches.
|
func TestCheckAndUpdate_ParamsFuncCalledEachTime(t *testing.T) {
|
||||||
func TestCheckAndUpdate_SkipsWhenIPUnchanged(t *testing.T) {
|
|
||||||
rn := NewRunner()
|
rn := NewRunner()
|
||||||
rn.mu.Lock()
|
rn.ipFetcher = func() (string, error) { return "1.2.3.4", nil }
|
||||||
rn.status.CurrentIP = "9.9.9.9"
|
|
||||||
rn.mu.Unlock()
|
|
||||||
|
|
||||||
calls := 0
|
calls := 0
|
||||||
rn.ipFetcher = func() (string, error) {
|
rn.paramsFn = func() Params {
|
||||||
calls++
|
calls++
|
||||||
return "9.9.9.9", nil
|
return Params{APIToken: "fake", Records: []string{"a.example.com"}}
|
||||||
}
|
}
|
||||||
|
|
||||||
prevUpdated := rn.GetStatus().LastUpdated
|
rn.checkAndUpdate()
|
||||||
rn.checkAndUpdate(Config{APIToken: "tok", Records: []string{"a.example.com"}})
|
rn.checkAndUpdate()
|
||||||
|
|
||||||
if rn.GetStatus().LastUpdated != prevUpdated {
|
if calls != 2 {
|
||||||
t.Error("LastUpdated should not change when IP is unchanged")
|
t.Errorf("expected paramsFn called 2 times, got %d", calls)
|
||||||
}
|
|
||||||
if calls != 1 {
|
|
||||||
t.Errorf("expected 1 ipFetcher call, got %d", calls)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func (g *ConfigGenerator) GetConfigPath() string {
|
|||||||
|
|
||||||
// Generate creates a dnsmasq configuration from the app config
|
// Generate creates a dnsmasq configuration from the app config
|
||||||
// It auto-detects the Wild Central server's IP address
|
// It auto-detects the Wild Central server's IP address
|
||||||
func (g *ConfigGenerator) Generate(cfg *config.GlobalConfig, clouds []config.InstanceConfig) string {
|
func (g *ConfigGenerator) Generate(cfg *config.State, clouds []config.InstanceConfig) string {
|
||||||
// Use configured dnsmasq IP (bound to eth0), falling back to auto-detect.
|
// Use configured dnsmasq IP (bound to eth0), falling back to auto-detect.
|
||||||
// Auto-detect can pick wlan0 if that's the default route, which is wrong
|
// Auto-detect can pick wlan0 if that's the default route, which is wrong
|
||||||
// when dnsmasq is only listening on eth0.
|
// when dnsmasq is only listening on eth0.
|
||||||
@@ -104,7 +104,7 @@ log-dhcp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WriteConfig writes the dnsmasq configuration to the specified path
|
// WriteConfig writes the dnsmasq configuration to the specified path
|
||||||
func (g *ConfigGenerator) WriteConfig(cfg *config.GlobalConfig, clouds []config.InstanceConfig, configPath string) error {
|
func (g *ConfigGenerator) WriteConfig(cfg *config.State, clouds []config.InstanceConfig, configPath string) error {
|
||||||
configContent := g.Generate(cfg, clouds)
|
configContent := g.Generate(cfg, clouds)
|
||||||
|
|
||||||
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath)
|
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath)
|
||||||
@@ -212,7 +212,7 @@ func (g *ConfigGenerator) ReadConfig() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateConfig regenerates and writes the dnsmasq configuration for all instances
|
// UpdateConfig regenerates and writes the dnsmasq configuration for all instances
|
||||||
func (g *ConfigGenerator) UpdateConfig(cfg *config.GlobalConfig, instances []config.InstanceConfig, restart bool) error {
|
func (g *ConfigGenerator) UpdateConfig(cfg *config.State, instances []config.InstanceConfig, restart bool) error {
|
||||||
// Generate fresh config from scratch
|
// Generate fresh config from scratch
|
||||||
configContent := g.Generate(cfg, instances)
|
configContent := g.Generate(cfg, instances)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const (
|
|||||||
|
|
||||||
// GenerateMainConfig creates the main dnsmasq configuration with global settings
|
// GenerateMainConfig creates the main dnsmasq configuration with global settings
|
||||||
// and a conf-dir directive to include per-instance configs
|
// and a conf-dir directive to include per-instance configs
|
||||||
func (g *ConfigGenerator) GenerateMainConfig(cfg *config.GlobalConfig) string {
|
func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string {
|
||||||
// Get the Wild Central IP address
|
// Get the Wild Central IP address
|
||||||
dnsIP, err := network.GetWildCentralIP()
|
dnsIP, err := network.GetWildCentralIP()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -264,7 +264,7 @@ func (g *ConfigGenerator) ReloadService() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateToModularConfig migrates from monolithic to modular configuration
|
// UpdateToModularConfig migrates from monolithic to modular configuration
|
||||||
func (g *ConfigGenerator) UpdateToModularConfig(cfg *config.GlobalConfig, instanceNames []string, instances []config.InstanceConfig) error {
|
func (g *ConfigGenerator) UpdateToModularConfig(cfg *config.State, instanceNames []string, instances []config.InstanceConfig) error {
|
||||||
slog.Info("migrating to modular configuration", "component", "dnsmasq")
|
slog.Info("migrating to modular configuration", "component", "dnsmasq")
|
||||||
|
|
||||||
// Ensure instance directory exists
|
// Ensure instance directory exists
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ func TestGenerateMainConfig(t *testing.T) {
|
|||||||
// Test: GenerateMainConfig with DHCP disabled does not include dhcp-range
|
// Test: GenerateMainConfig with DHCP disabled does not include dhcp-range
|
||||||
func TestGenerateMainConfig_DHCPDisabled(t *testing.T) {
|
func TestGenerateMainConfig_DHCPDisabled(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
cfg := &config.GlobalConfig{}
|
cfg := &config.State{}
|
||||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = false
|
cfg.Cloud.Dnsmasq.DHCP.Enabled = false
|
||||||
|
|
||||||
out := g.GenerateMainConfig(cfg)
|
out := g.GenerateMainConfig(cfg)
|
||||||
@@ -204,7 +204,7 @@ func TestGenerateMainConfig_DHCPDisabled(t *testing.T) {
|
|||||||
// Test: GenerateMainConfig with DHCP enabled includes dhcp-range
|
// Test: GenerateMainConfig with DHCP enabled includes dhcp-range
|
||||||
func TestGenerateMainConfig_DHCPEnabled(t *testing.T) {
|
func TestGenerateMainConfig_DHCPEnabled(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
cfg := &config.GlobalConfig{}
|
cfg := &config.State{}
|
||||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||||
@@ -224,7 +224,7 @@ func TestGenerateMainConfig_DHCPEnabled(t *testing.T) {
|
|||||||
// Test: DHCP without gateway omits dhcp-option=3
|
// Test: DHCP without gateway omits dhcp-option=3
|
||||||
func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) {
|
func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
cfg := &config.GlobalConfig{}
|
cfg := &config.State{}
|
||||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||||
@@ -240,7 +240,7 @@ func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) {
|
|||||||
// Test: DHCP explicit gateway is used
|
// Test: DHCP explicit gateway is used
|
||||||
func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
|
func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
cfg := &config.GlobalConfig{}
|
cfg := &config.State{}
|
||||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||||
@@ -256,7 +256,7 @@ func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
|
|||||||
// Test: DHCP static leases appear in output
|
// Test: DHCP static leases appear in output
|
||||||
func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) {
|
func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
cfg := &config.GlobalConfig{}
|
cfg := &config.State{}
|
||||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||||
@@ -278,7 +278,7 @@ func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) {
|
|||||||
// Test: DHCP lease time defaults to 24h when not specified
|
// Test: DHCP lease time defaults to 24h when not specified
|
||||||
func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) {
|
func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
cfg := &config.GlobalConfig{}
|
cfg := &config.State{}
|
||||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||||
@@ -310,7 +310,7 @@ func TestNewConfigGenerator_DefaultPath(t *testing.T) {
|
|||||||
// Test: Service registration — domain set but no internal domain
|
// Test: Service registration — domain set but no internal domain
|
||||||
func TestGenerate_ServiceWithoutInternalDomain(t *testing.T) {
|
func TestGenerate_ServiceWithoutInternalDomain(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.GlobalConfig{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
// Service registration: only domain + backend IP, no internal domain
|
// Service registration: only domain + backend IP, no internal domain
|
||||||
inst := instanceWith("my-api.payne.io", "", "192.168.8.151")
|
inst := instanceWith("my-api.payne.io", "", "192.168.8.151")
|
||||||
@@ -351,7 +351,7 @@ func TestGenerateInstanceConfig_ServiceWithoutInternalDomain(t *testing.T) {
|
|||||||
// Test: reach:internal → local=/ directive present (prevents upstream DNS forwarding)
|
// Test: reach:internal → local=/ directive present (prevents upstream DNS forwarding)
|
||||||
func TestGenerate_ReachInternal_HasLocal(t *testing.T) {
|
func TestGenerate_ReachInternal_HasLocal(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.GlobalConfig{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
// Simulate reconciliation for an internal-reach service:
|
// Simulate reconciliation for an internal-reach service:
|
||||||
// InternalDomain is set (reach:internal), Domain is empty (reach:public)
|
// InternalDomain is set (reach:internal), Domain is empty (reach:public)
|
||||||
@@ -370,7 +370,7 @@ func TestGenerate_ReachInternal_HasLocal(t *testing.T) {
|
|||||||
// Test: reach:public → no local=/ directive (allows upstream DNS forwarding)
|
// Test: reach:public → no local=/ directive (allows upstream DNS forwarding)
|
||||||
func TestGenerate_ReachPublic_NoLocal(t *testing.T) {
|
func TestGenerate_ReachPublic_NoLocal(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.GlobalConfig{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
// Simulate reconciliation for a public-reach service:
|
// Simulate reconciliation for a public-reach service:
|
||||||
// Domain is set (reach:public), InternalDomain is empty
|
// Domain is set (reach:public), InternalDomain is empty
|
||||||
@@ -389,7 +389,7 @@ func TestGenerate_ReachPublic_NoLocal(t *testing.T) {
|
|||||||
// Test: InternalDomain field → local=/ entry for LAN-only resolution
|
// Test: InternalDomain field → local=/ entry for LAN-only resolution
|
||||||
func TestGenerate_InternalDomainOnly_HasLocal(t *testing.T) {
|
func TestGenerate_InternalDomainOnly_HasLocal(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.GlobalConfig{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
// Instance with only an internal domain (no external domain)
|
// Instance with only an internal domain (no external domain)
|
||||||
inst := instanceWith("", "internal.cloud.payne.io", "192.168.8.240")
|
inst := instanceWith("", "internal.cloud.payne.io", "192.168.8.240")
|
||||||
@@ -411,7 +411,7 @@ func TestGenerate_InternalDomainOnly_HasLocal(t *testing.T) {
|
|||||||
// Test: Mix of instances with and without internal domains
|
// Test: Mix of instances with and without internal domains
|
||||||
func TestGenerate_MixedServicesAndInstances(t *testing.T) {
|
func TestGenerate_MixedServicesAndInstances(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.GlobalConfig{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
instances := []config.InstanceConfig{
|
instances := []config.InstanceConfig{
|
||||||
instanceWith("cloud.payne.io", "internal.cloud.payne.io", "192.168.8.240"), // k8s instance
|
instanceWith("cloud.payne.io", "internal.cloud.payne.io", "192.168.8.240"), // k8s instance
|
||||||
@@ -438,7 +438,7 @@ func TestGenerate_MixedServicesAndInstances(t *testing.T) {
|
|||||||
// Test: empty instances list produces base config with no address= entries
|
// Test: empty instances list produces base config with no address= entries
|
||||||
func TestGenerate_EmptyInstances(t *testing.T) {
|
func TestGenerate_EmptyInstances(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.GlobalConfig{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
out := g.Generate(globalCfg, []config.InstanceConfig{})
|
out := g.Generate(globalCfg, []config.InstanceConfig{})
|
||||||
|
|
||||||
@@ -462,7 +462,7 @@ func TestGenerate_EmptyInstances(t *testing.T) {
|
|||||||
// Test: multiple instances each get their own entries
|
// Test: multiple instances each get their own entries
|
||||||
func TestGenerate_MultipleInstances(t *testing.T) {
|
func TestGenerate_MultipleInstances(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.GlobalConfig{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
instances := []config.InstanceConfig{
|
instances := []config.InstanceConfig{
|
||||||
instanceWith("cloud1.example.com", "internal1.example.com", "10.0.0.1"),
|
instanceWith("cloud1.example.com", "internal1.example.com", "10.0.0.1"),
|
||||||
@@ -504,7 +504,7 @@ func TestGenerate_NilConfig(t *testing.T) {
|
|||||||
// Test: when cfg.Cloud.Dnsmasq.IP is set, uses that instead of auto-detect
|
// Test: when cfg.Cloud.Dnsmasq.IP is set, uses that instead of auto-detect
|
||||||
func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
|
func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
cfg := &config.GlobalConfig{}
|
cfg := &config.State{}
|
||||||
cfg.Cloud.Dnsmasq.IP = "192.168.8.100"
|
cfg.Cloud.Dnsmasq.IP = "192.168.8.100"
|
||||||
|
|
||||||
out := g.Generate(cfg, []config.InstanceConfig{})
|
out := g.Generate(cfg, []config.InstanceConfig{})
|
||||||
@@ -517,7 +517,7 @@ func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
|
|||||||
// Test: verify no address=// or local=// entries appear in any test output
|
// Test: verify no address=// or local=// entries appear in any test output
|
||||||
func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
|
func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
cfg := &config.GlobalConfig{}
|
cfg := &config.State{}
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// Package services manages service registrations from Wild Cloud, Wild Works,
|
// Package domains manages domain registrations from Wild Cloud, Wild Works,
|
||||||
// and manual entries. Services register with Central to get DNS, gateway
|
// and manual entries. Domains register with Central to get DNS, gateway
|
||||||
// routing, TLS certificates, and optional public exposure via DDNS.
|
// routing, TLS certificates, and optional public exposure via DDNS.
|
||||||
//
|
//
|
||||||
// Each registration is keyed by its domain — one registration per domain.
|
// Each registration is keyed by its domain — one registration per domain.
|
||||||
// Central registers its own UI domain here on startup (source: "central").
|
// Central registers its own UI domain here on startup (source: "central").
|
||||||
package services
|
package domains
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -17,36 +17,38 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BackendType describes how the gateway handles traffic for this service.
|
// BackendType describes how the gateway handles traffic for this domain.
|
||||||
type BackendType string
|
type BackendType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
BackendTCPPassthrough BackendType = "tcp-passthrough" // L4 SNI passthrough (k8s)
|
BackendTCPPassthrough BackendType = "tcp-passthrough" // L4 SNI passthrough (k8s)
|
||||||
BackendHTTP BackendType = "http" // L7 HTTP reverse proxy
|
BackendHTTP BackendType = "http" // L7 HTTP reverse proxy
|
||||||
|
BackendDNSOnly BackendType = "dns-only" // DNS resolution only, no gateway
|
||||||
)
|
)
|
||||||
|
|
||||||
// TLSMode describes how TLS is handled for the service.
|
// TLSMode describes how TLS is handled for the domain.
|
||||||
type TLSMode string
|
type TLSMode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TLSTerminate TLSMode = "terminate" // Central terminates TLS
|
TLSTerminate TLSMode = "terminate" // Central terminates TLS
|
||||||
TLSPassthrough TLSMode = "passthrough" // Backend handles TLS (k8s traefik)
|
TLSPassthrough TLSMode = "passthrough" // Backend handles TLS (k8s traefik)
|
||||||
|
TLSNone TLSMode = "none" // No TLS (dns-only)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Backend describes the target for a service.
|
// Backend describes the target for a domain.
|
||||||
type Backend struct {
|
type Backend struct {
|
||||||
Address string `yaml:"address" json:"address"` // host:port
|
Address string `yaml:"address" json:"address"` // host:port
|
||||||
Type BackendType `yaml:"type" json:"type"` // tcp-passthrough or http
|
Type BackendType `yaml:"type" json:"type"` // tcp-passthrough or http
|
||||||
Health string `yaml:"health,omitempty" json:"health,omitempty"`
|
Health string `yaml:"health,omitempty" json:"health,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HeaderConfig describes custom request/response headers for L7 HTTP services.
|
// HeaderConfig describes custom request/response headers for L7 HTTP domains.
|
||||||
type HeaderConfig struct {
|
type HeaderConfig struct {
|
||||||
Request map[string]string `yaml:"request,omitempty" json:"request,omitempty"`
|
Request map[string]string `yaml:"request,omitempty" json:"request,omitempty"`
|
||||||
Response map[string]string `yaml:"response,omitempty" json:"response,omitempty"`
|
Response map[string]string `yaml:"response,omitempty" json:"response,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route describes one path→backend mapping within a service.
|
// Route describes one path→backend mapping within a domain.
|
||||||
// Routes are evaluated in order — first match wins.
|
// Routes are evaluated in order — first match wins.
|
||||||
// A route with empty Paths is a catch-all (should be last).
|
// A route with empty Paths is a catch-all (should be last).
|
||||||
type Route struct {
|
type Route struct {
|
||||||
@@ -56,12 +58,12 @@ type Route struct {
|
|||||||
IPAllow []string `yaml:"ipAllow,omitempty" json:"ipAllow,omitempty"` // per-route CIDR whitelist
|
IPAllow []string `yaml:"ipAllow,omitempty" json:"ipAllow,omitempty"` // per-route CIDR whitelist
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service represents a registered service. The Domain is the unique key.
|
// Domain represents a registered domain. The Domain field is the unique key.
|
||||||
//
|
//
|
||||||
// Simple services use Backend directly. Multi-backend services use Routes
|
// Simple domains use Backend directly. Multi-backend domains use Routes
|
||||||
// for path-based splitting (Backend is ignored when Routes is present).
|
// for path-based splitting (Backend is ignored when Routes is present).
|
||||||
type Service struct {
|
type Domain struct {
|
||||||
Domain string `yaml:"domain" json:"domain"` // FQDN — unique key
|
DomainName string `yaml:"domain" json:"domain"` // FQDN — unique key
|
||||||
Source string `yaml:"source,omitempty" json:"source,omitempty"` // who registered: wild-cloud, wild-works, manual
|
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)
|
Backend Backend `yaml:"backend,omitempty" json:"backend,omitempty"` // single backend (simple case)
|
||||||
Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain
|
Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain
|
||||||
@@ -73,58 +75,71 @@ type Service struct {
|
|||||||
Routes []Route `yaml:"routes,omitempty" json:"routes,omitempty"`
|
Routes []Route `yaml:"routes,omitempty" json:"routes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveRoutes returns the service's routing as a normalized []Route.
|
// EffectiveRoutes returns the domain's routing as a normalized []Route.
|
||||||
// Simple services (Backend, no Routes) are wrapped in a single-element slice.
|
// Simple domains (Backend, no Routes) are wrapped in a single-element slice.
|
||||||
func (s *Service) EffectiveRoutes() []Route {
|
func (d *Domain) EffectiveRoutes() []Route {
|
||||||
if len(s.Routes) > 0 {
|
if len(d.Routes) > 0 {
|
||||||
return s.Routes
|
return d.Routes
|
||||||
}
|
}
|
||||||
if s.Backend.Address == "" {
|
if d.Backend.Address == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return []Route{{Backend: s.Backend}}
|
return []Route{{Backend: d.Backend}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveBackendAddress returns the primary backend address for DNS/DDNS purposes.
|
// EffectiveBackendAddress returns the primary backend address for DNS/DDNS purposes.
|
||||||
// For multi-route services, returns the first route's backend.
|
// For multi-route domains, returns the first route's backend.
|
||||||
func (s *Service) EffectiveBackendAddress() string {
|
func (d *Domain) EffectiveBackendAddress() string {
|
||||||
if len(s.Routes) > 0 {
|
if len(d.Routes) > 0 {
|
||||||
return s.Routes[0].Backend.Address
|
return d.Routes[0].Backend.Address
|
||||||
}
|
}
|
||||||
return s.Backend.Address
|
return d.Backend.Address
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveBackendType returns the backend type for routing decisions.
|
// EffectiveBackendType returns the backend type for routing decisions.
|
||||||
func (s *Service) EffectiveBackendType() BackendType {
|
func (d *Domain) EffectiveBackendType() BackendType {
|
||||||
if len(s.Routes) > 0 {
|
if len(d.Routes) > 0 {
|
||||||
return s.Routes[0].Backend.Type
|
return d.Routes[0].Backend.Type
|
||||||
}
|
}
|
||||||
return s.Backend.Type
|
return d.Backend.Type
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager handles service registration CRUD and triggers networking reconciliation.
|
// Manager handles domain registration CRUD and triggers networking reconciliation.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
dataDir string
|
dataDir string
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
reconcileFn func()
|
reconcileFn func()
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new service registration manager.
|
// NewManager creates a new domain registration manager.
|
||||||
func NewManager(dataDir string) *Manager {
|
func NewManager(dataDir string) *Manager {
|
||||||
servicesDir := filepath.Join(dataDir, "services")
|
domainsDir := filepath.Join(dataDir, "domains")
|
||||||
if err := os.MkdirAll(servicesDir, 0755); err != nil {
|
oldServicesDir := filepath.Join(dataDir, "services")
|
||||||
slog.Warn("failed to create services directory", "path", servicesDir, "error", err)
|
|
||||||
|
// 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}
|
return &Manager{dataDir: dataDir}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetReconcileFn sets the function called after service changes to update networking.
|
// SetReconcileFn sets the function called after domain changes to update networking.
|
||||||
func (m *Manager) SetReconcileFn(fn func()) {
|
func (m *Manager) SetReconcileFn(fn func()) {
|
||||||
m.reconcileFn = fn
|
m.reconcileFn = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) servicesDir() string {
|
func (m *Manager) domainsDir() string {
|
||||||
return filepath.Join(m.dataDir, "services")
|
return filepath.Join(m.dataDir, "domains")
|
||||||
}
|
}
|
||||||
|
|
||||||
// domainToFilename converts a domain to a filesystem-safe filename.
|
// domainToFilename converts a domain to a filesystem-safe filename.
|
||||||
@@ -132,31 +147,31 @@ func domainToFilename(domain string) string {
|
|||||||
return strings.ReplaceAll(domain, ".", "_") + ".yaml"
|
return strings.ReplaceAll(domain, ".", "_") + ".yaml"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) servicePath(domain string) string {
|
func (m *Manager) domainPath(domain string) string {
|
||||||
return filepath.Join(m.servicesDir(), domainToFilename(domain))
|
return filepath.Join(m.domainsDir(), domainToFilename(domain))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register creates or updates a service registration.
|
// Register creates or updates a domain registration.
|
||||||
func (m *Manager) Register(svc Service) error {
|
func (m *Manager) Register(dom Domain) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
if svc.Domain == "" {
|
if dom.DomainName == "" {
|
||||||
return fmt.Errorf("domain is required")
|
return fmt.Errorf("domain is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
hasBackend := svc.Backend.Address != ""
|
hasBackend := dom.Backend.Address != ""
|
||||||
hasRoutes := len(svc.Routes) > 0
|
hasRoutes := len(dom.Routes) > 0
|
||||||
|
|
||||||
if hasBackend && hasRoutes {
|
if hasBackend && hasRoutes {
|
||||||
return fmt.Errorf("service must use backend or routes, not both")
|
return fmt.Errorf("domain must use backend or routes, not both")
|
||||||
}
|
}
|
||||||
if !hasBackend && !hasRoutes {
|
if !hasBackend && !hasRoutes {
|
||||||
return fmt.Errorf("backend address or routes required")
|
return fmt.Errorf("backend address or routes required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasRoutes {
|
if hasRoutes {
|
||||||
for i, r := range svc.Routes {
|
for i, r := range dom.Routes {
|
||||||
if r.Backend.Address == "" {
|
if r.Backend.Address == "" {
|
||||||
return fmt.Errorf("route %d: backend address is required", i)
|
return fmt.Errorf("route %d: backend address is required", i)
|
||||||
}
|
}
|
||||||
@@ -165,36 +180,38 @@ func (m *Manager) Register(svc Service) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if svc.Backend.Type == "" {
|
if dom.Backend.Type == "" {
|
||||||
return fmt.Errorf("backend type is required")
|
return fmt.Errorf("backend type is required")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default TLS mode based on backend type
|
// Default TLS mode based on backend type
|
||||||
if svc.TLS == "" {
|
if dom.TLS == "" {
|
||||||
bt := svc.EffectiveBackendType()
|
switch dom.EffectiveBackendType() {
|
||||||
if bt == BackendTCPPassthrough {
|
case BackendTCPPassthrough:
|
||||||
svc.TLS = TLSPassthrough
|
dom.TLS = TLSPassthrough
|
||||||
} else {
|
case BackendDNSOnly:
|
||||||
svc.TLS = TLSTerminate
|
dom.TLS = TLSNone
|
||||||
|
default:
|
||||||
|
dom.TLS = TLSTerminate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default source
|
// Default source
|
||||||
if svc.Source == "" {
|
if dom.Source == "" {
|
||||||
svc.Source = "manual"
|
dom.Source = "manual"
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := yaml.Marshal(svc)
|
data, err := yaml.Marshal(dom)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshaling service: %w", err)
|
return fmt.Errorf("marshaling domain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(m.servicePath(svc.Domain), data, 0644); err != nil {
|
if err := os.WriteFile(m.domainPath(dom.DomainName), data, 0644); err != nil {
|
||||||
return fmt.Errorf("writing service file: %w", err)
|
return fmt.Errorf("writing domain file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("service registered", "domain", svc.Domain, "public", svc.Public, "source", svc.Source, "subdomains", svc.Subdomains)
|
slog.Info("domain registered", "domain", dom.DomainName, "public", dom.Public, "source", dom.Source, "subdomains", dom.Subdomains)
|
||||||
|
|
||||||
if m.reconcileFn != nil {
|
if m.reconcileFn != nil {
|
||||||
go func() {
|
go func() {
|
||||||
@@ -210,21 +227,21 @@ func (m *Manager) Register(svc Service) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deregister removes a service registration by domain.
|
// Deregister removes a domain registration by domain name.
|
||||||
func (m *Manager) Deregister(domain string) error {
|
func (m *Manager) Deregister(domain string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
path := m.servicePath(domain)
|
path := m.domainPath(domain)
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
return fmt.Errorf("service %q not found", domain)
|
return fmt.Errorf("domain %q not found", domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.Remove(path); err != nil {
|
if err := os.Remove(path); err != nil {
|
||||||
return fmt.Errorf("removing service file: %w", err)
|
return fmt.Errorf("removing domain file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("service deregistered", "domain", domain)
|
slog.Info("domain deregistered", "domain", domain)
|
||||||
|
|
||||||
if m.reconcileFn != nil {
|
if m.reconcileFn != nil {
|
||||||
go m.reconcileFn()
|
go m.reconcileFn()
|
||||||
@@ -233,76 +250,76 @@ func (m *Manager) Deregister(domain string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves a service registration by domain.
|
// Get retrieves a domain registration by domain name.
|
||||||
func (m *Manager) Get(domain string) (*Service, error) {
|
func (m *Manager) Get(domain string) (*Domain, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
data, err := os.ReadFile(m.servicePath(domain))
|
data, err := os.ReadFile(m.domainPath(domain))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("service %q not found", domain)
|
return nil, fmt.Errorf("domain %q not found", domain)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("reading service file: %w", err)
|
return nil, fmt.Errorf("reading domain file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var svc Service
|
var dom Domain
|
||||||
if err := yaml.Unmarshal(data, &svc); err != nil {
|
if err := yaml.Unmarshal(data, &dom); err != nil {
|
||||||
return nil, fmt.Errorf("parsing service file: %w", err)
|
return nil, fmt.Errorf("parsing domain file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &svc, nil
|
return &dom, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns all registered services.
|
// List returns all registered domains.
|
||||||
func (m *Manager) List() ([]Service, error) {
|
func (m *Manager) List() ([]Domain, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
entries, err := os.ReadDir(m.servicesDir())
|
entries, err := os.ReadDir(m.domainsDir())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("reading services directory: %w", err)
|
return nil, fmt.Errorf("reading domains directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var services []Service
|
var doms []Domain
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(filepath.Join(m.servicesDir(), entry.Name()))
|
data, err := os.ReadFile(filepath.Join(m.domainsDir(), entry.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("failed to read service file", "file", entry.Name(), "error", err)
|
slog.Warn("failed to read domain file", "file", entry.Name(), "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var svc Service
|
var dom Domain
|
||||||
if err := yaml.Unmarshal(data, &svc); err != nil {
|
if err := yaml.Unmarshal(data, &dom); err != nil {
|
||||||
slog.Warn("failed to parse service file", "file", entry.Name(), "error", err)
|
slog.Warn("failed to parse domain file", "file", entry.Name(), "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
services = append(services, svc)
|
doms = append(doms, dom)
|
||||||
}
|
}
|
||||||
|
|
||||||
return services, nil
|
return doms, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeregisterBySource removes all registrations from a given source that match
|
// DeregisterBySource removes all registrations from a given source that match
|
||||||
// a backend address. Used by consumers to clean up before re-registering.
|
// a backend address. Used by consumers to clean up before re-registering.
|
||||||
func (m *Manager) DeregisterBySource(source, backendAddress string) error {
|
func (m *Manager) DeregisterBySource(source, backendAddress string) error {
|
||||||
svcs, err := m.List()
|
doms, err := m.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, svc := range svcs {
|
for _, dom := range doms {
|
||||||
if svc.Source == source && svc.EffectiveBackendAddress() == backendAddress {
|
if dom.Source == source && dom.EffectiveBackendAddress() == backendAddress {
|
||||||
if err := m.Deregister(svc.Domain); err != nil {
|
if err := m.Deregister(dom.DomainName); err != nil {
|
||||||
slog.Warn("failed to deregister service during cleanup", "domain", svc.Domain, "error", err)
|
slog.Warn("failed to deregister domain during cleanup", "domain", dom.DomainName, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,32 +327,33 @@ func (m *Manager) DeregisterBySource(source, backendAddress string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update applies partial updates to a service registration.
|
// Update applies partial updates to a domain registration.
|
||||||
func (m *Manager) Update(domain string, updates map[string]any) error {
|
func (m *Manager) Update(domain string, updates map[string]any) error {
|
||||||
svc, err := m.Get(domain)
|
dom, err := m.Get(domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if public, ok := updates["public"].(bool); ok {
|
if public, ok := updates["public"].(bool); ok {
|
||||||
svc.Public = public
|
dom.Public = public
|
||||||
}
|
}
|
||||||
if sub, ok := updates["subdomains"].(bool); ok {
|
if sub, ok := updates["subdomains"].(bool); ok {
|
||||||
svc.Subdomains = sub
|
dom.Subdomains = sub
|
||||||
}
|
}
|
||||||
if backend, ok := updates["backend"].(map[string]any); ok {
|
if backend, ok := updates["backend"].(map[string]any); ok {
|
||||||
if addr, ok := backend["address"].(string); ok {
|
if addr, ok := backend["address"].(string); ok {
|
||||||
svc.Backend.Address = addr
|
dom.Backend.Address = addr
|
||||||
}
|
}
|
||||||
if typ, ok := backend["type"].(string); ok {
|
if typ, ok := backend["type"].(string); ok {
|
||||||
svc.Backend.Type = BackendType(typ)
|
dom.Backend.Type = BackendType(typ)
|
||||||
}
|
}
|
||||||
if health, ok := backend["health"].(string); ok {
|
if health, ok := backend["health"].(string); ok {
|
||||||
svc.Backend.Health = health
|
dom.Backend.Health = health
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Routes is a full replacement — partial route updates are too complex
|
if tls, ok := updates["tls"].(string); ok {
|
||||||
// to express via map[string]any. Re-register the full service instead.
|
dom.TLS = TLSMode(tls)
|
||||||
|
}
|
||||||
|
|
||||||
return m.Register(*svc)
|
return m.Register(*dom)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package services
|
package domains
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -9,17 +9,16 @@ import (
|
|||||||
func TestRegisterAndGet(t *testing.T) {
|
func TestRegisterAndGet(t *testing.T) {
|
||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
svc := Service{
|
dom := Domain{
|
||||||
Domain: "my-api.payne.io",
|
DomainName: "my-api.payne.io",
|
||||||
Source: "wild-works",
|
Source: "wild-works",
|
||||||
Backend: Backend{
|
Backend: Backend{
|
||||||
Address: "192.168.8.60:9001",
|
Address: "192.168.8.60:9001",
|
||||||
Type: BackendHTTP,
|
Type: BackendHTTP,
|
||||||
},
|
},
|
||||||
// Public defaults to false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := mgr.Register(svc); err != nil {
|
if err := mgr.Register(dom); err != nil {
|
||||||
t.Fatalf("Register failed: %v", err)
|
t.Fatalf("Register failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,8 +27,8 @@ func TestRegisterAndGet(t *testing.T) {
|
|||||||
t.Fatalf("Get failed: %v", err)
|
t.Fatalf("Get failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Domain != "my-api.payne.io" {
|
if got.DomainName != "my-api.payne.io" {
|
||||||
t.Errorf("expected domain my-api.payne.io, got %s", got.Domain)
|
t.Errorf("expected domain my-api.payne.io, got %s", got.DomainName)
|
||||||
}
|
}
|
||||||
if got.Backend.Address != "192.168.8.60:9001" {
|
if got.Backend.Address != "192.168.8.60:9001" {
|
||||||
t.Errorf("expected backend 192.168.8.60:9001, got %s", got.Backend.Address)
|
t.Errorf("expected backend 192.168.8.60:9001, got %s", got.Backend.Address)
|
||||||
@@ -45,18 +44,18 @@ func TestRegisterAndGet(t *testing.T) {
|
|||||||
func TestRegisterTCPPassthrough(t *testing.T) {
|
func TestRegisterTCPPassthrough(t *testing.T) {
|
||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
svc := Service{
|
dom := Domain{
|
||||||
Domain: "cloud.payne.io",
|
DomainName: "cloud.payne.io",
|
||||||
Source: "wild-cloud",
|
Source: "wild-cloud",
|
||||||
Backend: Backend{
|
Backend: Backend{
|
||||||
Address: "192.168.8.240:443",
|
Address: "192.168.8.240:443",
|
||||||
Type: BackendTCPPassthrough,
|
Type: BackendTCPPassthrough,
|
||||||
},
|
},
|
||||||
Subdomains: true,
|
Subdomains: true,
|
||||||
Public: true,
|
Public: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := mgr.Register(svc); err != nil {
|
if err := mgr.Register(dom); err != nil {
|
||||||
t.Fatalf("Register failed: %v", err)
|
t.Fatalf("Register failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,37 +75,35 @@ func TestRegisterTCPPassthrough(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListServices(t *testing.T) {
|
func TestListDomains(t *testing.T) {
|
||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
for _, domain := range []string{"a.example.com", "b.example.com", "c.example.com"} {
|
for _, domain := range []string{"a.example.com", "b.example.com", "c.example.com"} {
|
||||||
if err := mgr.Register(Service{
|
if err := mgr.Register(Domain{
|
||||||
Domain: domain,
|
DomainName: domain,
|
||||||
Source: "test",
|
Source: "test",
|
||||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("Register %s failed: %v", domain, err)
|
t.Fatalf("Register %s failed: %v", domain, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
svcs, err := mgr.List()
|
doms, err := mgr.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("List failed: %v", err)
|
t.Fatalf("List failed: %v", err)
|
||||||
}
|
}
|
||||||
if len(svcs) != 3 {
|
if len(doms) != 3 {
|
||||||
t.Errorf("expected 3 services, got %d", len(svcs))
|
t.Errorf("expected 3 domains, got %d", len(doms))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeregister(t *testing.T) {
|
func TestDeregister(t *testing.T) {
|
||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
if err := mgr.Register(Service{
|
if err := mgr.Register(Domain{
|
||||||
Domain: "temp.example.com",
|
DomainName: "temp.example.com",
|
||||||
Source: "test",
|
Source: "test",
|
||||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("Register failed: %v", err)
|
t.Fatalf("Register failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -123,45 +120,43 @@ func TestDeregister(t *testing.T) {
|
|||||||
func TestDeregisterBySource(t *testing.T) {
|
func TestDeregisterBySource(t *testing.T) {
|
||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
// Register 3 services from wild-cloud with same backend
|
// Register 3 domains from wild-cloud with same backend
|
||||||
for _, domain := range []string{"cloud.payne.io", "internal.cloud.payne.io", "payne.io"} {
|
for _, domain := range []string{"cloud.payne.io", "internal.cloud.payne.io", "payne.io"} {
|
||||||
mgr.Register(Service{
|
mgr.Register(Domain{
|
||||||
Domain: domain,
|
DomainName: domain,
|
||||||
Source: "wild-cloud",
|
Source: "wild-cloud",
|
||||||
Backend: Backend{Address: "192.168.8.240:443", Type: BackendTCPPassthrough},
|
Backend: Backend{Address: "192.168.8.240:443", Type: BackendTCPPassthrough},
|
||||||
Public: true,
|
Public: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Register 1 service from wild-works (different source)
|
// Register 1 domain from wild-works (different source)
|
||||||
mgr.Register(Service{
|
mgr.Register(Domain{
|
||||||
Domain: "my-api.payne.io",
|
DomainName: "my-api.payne.io",
|
||||||
Source: "wild-works",
|
Source: "wild-works",
|
||||||
Backend: Backend{Address: "127.0.0.1:9001", Type: BackendHTTP},
|
Backend: Backend{Address: "127.0.0.1:9001", Type: BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Deregister all wild-cloud services with backend 192.168.8.240:443
|
// Deregister all wild-cloud domains with backend 192.168.8.240:443
|
||||||
if err := mgr.DeregisterBySource("wild-cloud", "192.168.8.240:443"); err != nil {
|
if err := mgr.DeregisterBySource("wild-cloud", "192.168.8.240:443"); err != nil {
|
||||||
t.Fatalf("DeregisterBySource failed: %v", err)
|
t.Fatalf("DeregisterBySource failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svcs, _ := mgr.List()
|
doms, _ := mgr.List()
|
||||||
if len(svcs) != 1 {
|
if len(doms) != 1 {
|
||||||
t.Errorf("expected 1 remaining service, got %d", len(svcs))
|
t.Errorf("expected 1 remaining domain, got %d", len(doms))
|
||||||
}
|
}
|
||||||
if svcs[0].Domain != "my-api.payne.io" {
|
if doms[0].DomainName != "my-api.payne.io" {
|
||||||
t.Errorf("expected wild-works service to remain, got %s", svcs[0].Domain)
|
t.Errorf("expected wild-works domain to remain, got %s", doms[0].DomainName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdate(t *testing.T) {
|
func TestUpdate(t *testing.T) {
|
||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
mgr.Register(Service{
|
mgr.Register(Domain{
|
||||||
Domain: "updatable.example.com",
|
DomainName: "updatable.example.com",
|
||||||
Source: "test",
|
Source: "test",
|
||||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := mgr.Update("updatable.example.com", map[string]any{
|
if err := mgr.Update("updatable.example.com", map[string]any{
|
||||||
@@ -184,17 +179,17 @@ func TestRegisterValidation(t *testing.T) {
|
|||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
// Missing domain
|
// Missing domain
|
||||||
if err := mgr.Register(Service{Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}}); err == nil {
|
if err := mgr.Register(Domain{Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}}); err == nil {
|
||||||
t.Error("expected error for missing domain")
|
t.Error("expected error for missing domain")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Missing backend address
|
// Missing backend address
|
||||||
if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Type: BackendHTTP}}); err == nil {
|
if err := mgr.Register(Domain{DomainName: "x.com", Backend: Backend{Type: BackendHTTP}}); err == nil {
|
||||||
t.Error("expected error for missing backend address")
|
t.Error("expected error for missing backend address")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Missing backend type
|
// Missing backend type
|
||||||
if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Address: "127.0.0.1:80"}}); err == nil {
|
if err := mgr.Register(Domain{DomainName: "x.com", Backend: Backend{Address: "127.0.0.1:80"}}); err == nil {
|
||||||
t.Error("expected error for missing backend type")
|
t.Error("expected error for missing backend type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,10 +197,9 @@ func TestRegisterValidation(t *testing.T) {
|
|||||||
func TestDefaultSource(t *testing.T) {
|
func TestDefaultSource(t *testing.T) {
|
||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
mgr.Register(Service{
|
mgr.Register(Domain{
|
||||||
Domain: "test.com",
|
DomainName: "test.com",
|
||||||
Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP},
|
Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
got, _ := mgr.Get("test.com")
|
got, _ := mgr.Get("test.com")
|
||||||
@@ -217,31 +211,30 @@ func TestDefaultSource(t *testing.T) {
|
|||||||
func TestRegisterIdempotent(t *testing.T) {
|
func TestRegisterIdempotent(t *testing.T) {
|
||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
svc := Service{
|
dom := Domain{
|
||||||
Domain: "idempotent.example.com",
|
DomainName: "idempotent.example.com",
|
||||||
Source: "test",
|
Source: "test",
|
||||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register twice with same domain
|
// Register twice with same domain
|
||||||
if err := mgr.Register(svc); err != nil {
|
if err := mgr.Register(dom); err != nil {
|
||||||
t.Fatalf("first Register failed: %v", err)
|
t.Fatalf("first Register failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the backend address on second registration
|
// Update the backend address on second registration
|
||||||
svc.Backend.Address = "127.0.0.1:9090"
|
dom.Backend.Address = "127.0.0.1:9090"
|
||||||
if err := mgr.Register(svc); err != nil {
|
if err := mgr.Register(dom); err != nil {
|
||||||
t.Fatalf("second Register failed: %v", err)
|
t.Fatalf("second Register failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should still have only one service
|
// Should still have only one domain
|
||||||
svcs, err := mgr.List()
|
doms, err := mgr.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("List failed: %v", err)
|
t.Fatalf("List failed: %v", err)
|
||||||
}
|
}
|
||||||
if len(svcs) != 1 {
|
if len(doms) != 1 {
|
||||||
t.Errorf("expected 1 service after idempotent register, got %d", len(svcs))
|
t.Errorf("expected 1 domain after idempotent register, got %d", len(doms))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should have the updated address
|
// Should have the updated address
|
||||||
@@ -257,50 +250,48 @@ func TestRegisterIdempotent(t *testing.T) {
|
|||||||
func TestListNoDuplicates(t *testing.T) {
|
func TestListNoDuplicates(t *testing.T) {
|
||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
domains := []string{
|
domainNames := []string{
|
||||||
"alpha.example.com",
|
"alpha.example.com",
|
||||||
"beta.example.com",
|
"beta.example.com",
|
||||||
"gamma.example.com",
|
"gamma.example.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register each domain
|
// Register each domain
|
||||||
for _, domain := range domains {
|
for _, name := range domainNames {
|
||||||
if err := mgr.Register(Service{
|
if err := mgr.Register(Domain{
|
||||||
Domain: domain,
|
DomainName: name,
|
||||||
Source: "test",
|
Source: "test",
|
||||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("Register %s failed: %v", domain, err)
|
t.Fatalf("Register %s failed: %v", name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-register one domain to verify no duplication
|
// Re-register one domain to verify no duplication
|
||||||
if err := mgr.Register(Service{
|
if err := mgr.Register(Domain{
|
||||||
Domain: "beta.example.com",
|
DomainName: "beta.example.com",
|
||||||
Source: "test",
|
Source: "test",
|
||||||
Backend: Backend{Address: "127.0.0.1:9090", Type: BackendHTTP},
|
Backend: Backend{Address: "127.0.0.1:9090", Type: BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("Re-register beta failed: %v", err)
|
t.Fatalf("Re-register beta failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svcs, err := mgr.List()
|
doms, err := mgr.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("List failed: %v", err)
|
t.Fatalf("List failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(svcs) != 3 {
|
if len(doms) != 3 {
|
||||||
t.Errorf("expected 3 unique services, got %d", len(svcs))
|
t.Errorf("expected 3 unique domains, got %d", len(doms))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify no duplicate domains
|
// Verify no duplicate domains
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
for _, svc := range svcs {
|
for _, dom := range doms {
|
||||||
if seen[svc.Domain] {
|
if seen[dom.DomainName] {
|
||||||
t.Errorf("duplicate domain in List(): %s", svc.Domain)
|
t.Errorf("duplicate domain in List(): %s", dom.DomainName)
|
||||||
}
|
}
|
||||||
seen[svc.Domain] = true
|
seen[dom.DomainName] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,18 +334,16 @@ func TestDeregister_NotFound(t *testing.T) {
|
|||||||
func TestDeregisterBySource_NoMatch(t *testing.T) {
|
func TestDeregisterBySource_NoMatch(t *testing.T) {
|
||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
// Register some services
|
mgr.Register(Domain{
|
||||||
mgr.Register(Service{
|
DomainName: "a.example.com",
|
||||||
Domain: "a.example.com",
|
Source: "wild-cloud",
|
||||||
Source: "wild-cloud",
|
Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough},
|
||||||
Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough},
|
Public: true,
|
||||||
Public: true,
|
|
||||||
})
|
})
|
||||||
mgr.Register(Service{
|
mgr.Register(Domain{
|
||||||
Domain: "b.example.com",
|
DomainName: "b.example.com",
|
||||||
Source: "wild-works",
|
Source: "wild-works",
|
||||||
Backend: Backend{Address: "10.0.0.2:8080", Type: BackendHTTP},
|
Backend: Backend{Address: "10.0.0.2:8080", Type: BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Deregister with a source that doesn't match anything
|
// Deregister with a source that doesn't match anything
|
||||||
@@ -363,10 +352,10 @@ func TestDeregisterBySource_NoMatch(t *testing.T) {
|
|||||||
t.Fatalf("DeregisterBySource with no matches should not error: %v", err)
|
t.Fatalf("DeregisterBySource with no matches should not error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// All services should still be present
|
// All domains should still be present
|
||||||
svcs, _ := mgr.List()
|
doms, _ := mgr.List()
|
||||||
if len(svcs) != 2 {
|
if len(doms) != 2 {
|
||||||
t.Errorf("expected 2 services after no-match deregister, got %d", len(svcs))
|
t.Errorf("expected 2 domains after no-match deregister, got %d", len(doms))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,17 +363,17 @@ func TestDeregisterBySource_PartialMatch(t *testing.T) {
|
|||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
// Same source, different backends
|
// Same source, different backends
|
||||||
mgr.Register(Service{
|
mgr.Register(Domain{
|
||||||
Domain: "a.example.com",
|
DomainName: "a.example.com",
|
||||||
Source: "wild-cloud",
|
Source: "wild-cloud",
|
||||||
Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough},
|
Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough},
|
||||||
Public: true,
|
Public: true,
|
||||||
})
|
})
|
||||||
mgr.Register(Service{
|
mgr.Register(Domain{
|
||||||
Domain: "b.example.com",
|
DomainName: "b.example.com",
|
||||||
Source: "wild-cloud",
|
Source: "wild-cloud",
|
||||||
Backend: Backend{Address: "10.0.0.2:443", Type: BackendTCPPassthrough},
|
Backend: Backend{Address: "10.0.0.2:443", Type: BackendTCPPassthrough},
|
||||||
Public: true,
|
Public: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Deregister only the ones matching source AND backend
|
// Deregister only the ones matching source AND backend
|
||||||
@@ -393,12 +382,12 @@ func TestDeregisterBySource_PartialMatch(t *testing.T) {
|
|||||||
t.Fatalf("DeregisterBySource failed: %v", err)
|
t.Fatalf("DeregisterBySource failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svcs, _ := mgr.List()
|
doms, _ := mgr.List()
|
||||||
if len(svcs) != 1 {
|
if len(doms) != 1 {
|
||||||
t.Errorf("expected 1 remaining service, got %d", len(svcs))
|
t.Errorf("expected 1 remaining domain, got %d", len(doms))
|
||||||
}
|
}
|
||||||
if svcs[0].Domain != "b.example.com" {
|
if doms[0].DomainName != "b.example.com" {
|
||||||
t.Errorf("expected b.example.com to remain, got %s", svcs[0].Domain)
|
t.Errorf("expected b.example.com to remain, got %s", doms[0].DomainName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,26 +406,25 @@ func TestRegister_OverwritePreservesFile(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
mgr := NewManager(dir)
|
mgr := NewManager(dir)
|
||||||
|
|
||||||
svc := Service{
|
dom := Domain{
|
||||||
Domain: "overwrite.example.com",
|
DomainName: "overwrite.example.com",
|
||||||
Source: "test",
|
Source: "test",
|
||||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register first time
|
// Register first time
|
||||||
if err := mgr.Register(svc); err != nil {
|
if err := mgr.Register(dom); err != nil {
|
||||||
t.Fatalf("first Register failed: %v", err)
|
t.Fatalf("first Register failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register again with different backend
|
// Register again with different backend
|
||||||
svc.Backend.Address = "127.0.0.1:9090"
|
dom.Backend.Address = "127.0.0.1:9090"
|
||||||
if err := mgr.Register(svc); err != nil {
|
if err := mgr.Register(dom); err != nil {
|
||||||
t.Fatalf("second Register failed: %v", err)
|
t.Fatalf("second Register failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// File count should be exactly 1
|
// File count should be exactly 1
|
||||||
entries, err := os.ReadDir(filepath.Join(dir, "services"))
|
entries, err := os.ReadDir(filepath.Join(dir, "domains"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ReadDir failed: %v", err)
|
t.Fatalf("ReadDir failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -454,21 +442,21 @@ func TestRegister_OverwritePreservesFile(t *testing.T) {
|
|||||||
func TestList_EmptyDir(t *testing.T) {
|
func TestList_EmptyDir(t *testing.T) {
|
||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
svcs, err := mgr.List()
|
doms, err := mgr.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("List on empty dir should not error: %v", err)
|
t.Fatalf("List on empty dir should not error: %v", err)
|
||||||
}
|
}
|
||||||
if len(svcs) != 0 {
|
if len(doms) != 0 {
|
||||||
t.Errorf("expected 0 services from empty dir, got %d", len(svcs))
|
t.Errorf("expected 0 domains from empty dir, got %d", len(doms))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterWithL7Fields(t *testing.T) {
|
func TestRegisterWithL7Fields(t *testing.T) {
|
||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
svc := Service{
|
dom := Domain{
|
||||||
Domain: "keila.cloud.payne.io",
|
DomainName: "keila.cloud.payne.io",
|
||||||
Source: "wild-cloud",
|
Source: "wild-cloud",
|
||||||
Routes: []Route{{
|
Routes: []Route{{
|
||||||
Paths: []string{"/.well-known/matrix", "/_matrix"},
|
Paths: []string{"/.well-known/matrix", "/_matrix"},
|
||||||
Backend: Backend{
|
Backend: Backend{
|
||||||
@@ -487,7 +475,7 @@ func TestRegisterWithL7Fields(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := mgr.Register(svc); err != nil {
|
if err := mgr.Register(dom); err != nil {
|
||||||
t.Fatalf("Register failed: %v", err)
|
t.Fatalf("Register failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,9 +508,9 @@ func TestRegisterWithL7Fields(t *testing.T) {
|
|||||||
func TestUpdateL7Fields(t *testing.T) {
|
func TestUpdateL7Fields(t *testing.T) {
|
||||||
mgr := NewManager(t.TempDir())
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
mgr.Register(Service{
|
mgr.Register(Domain{
|
||||||
Domain: "updatable.example.com",
|
DomainName: "updatable.example.com",
|
||||||
Source: "test",
|
Source: "test",
|
||||||
Routes: []Route{{
|
Routes: []Route{{
|
||||||
Paths: []string{"/api", "/webhook"},
|
Paths: []string{"/api", "/webhook"},
|
||||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||||
@@ -555,28 +543,63 @@ func TestList_IgnoresNonYAML(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
mgr := NewManager(dir)
|
mgr := NewManager(dir)
|
||||||
|
|
||||||
// Register one real service
|
// Register one real domain
|
||||||
mgr.Register(Service{
|
mgr.Register(Domain{
|
||||||
Domain: "real.example.com",
|
DomainName: "real.example.com",
|
||||||
Source: "test",
|
Source: "test",
|
||||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create non-YAML files in the services directory
|
// Create non-YAML files in the domains directory
|
||||||
servicesDir := filepath.Join(dir, "services")
|
domainsDir := filepath.Join(dir, "domains")
|
||||||
os.WriteFile(filepath.Join(servicesDir, "README.md"), []byte("# ignore me"), 0644)
|
os.WriteFile(filepath.Join(domainsDir, "README.md"), []byte("# ignore me"), 0644)
|
||||||
os.WriteFile(filepath.Join(servicesDir, "notes.txt"), []byte("some notes"), 0644)
|
os.WriteFile(filepath.Join(domainsDir, "notes.txt"), []byte("some notes"), 0644)
|
||||||
os.WriteFile(filepath.Join(servicesDir, ".gitkeep"), []byte(""), 0644)
|
os.WriteFile(filepath.Join(domainsDir, ".gitkeep"), []byte(""), 0644)
|
||||||
|
|
||||||
svcs, err := mgr.List()
|
doms, err := mgr.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("List failed: %v", err)
|
t.Fatalf("List failed: %v", err)
|
||||||
}
|
}
|
||||||
if len(svcs) != 1 {
|
if len(doms) != 1 {
|
||||||
t.Errorf("expected 1 service (ignoring non-YAML files), got %d", len(svcs))
|
t.Errorf("expected 1 domain (ignoring non-YAML files), got %d", len(doms))
|
||||||
}
|
}
|
||||||
if svcs[0].Domain != "real.example.com" {
|
if doms[0].DomainName != "real.example.com" {
|
||||||
t.Errorf("expected real.example.com, got %s", svcs[0].Domain)
|
t.Errorf("expected real.example.com, got %s", doms[0].DomainName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterDNSOnly(t *testing.T) {
|
||||||
|
mgr := NewManager(t.TempDir())
|
||||||
|
|
||||||
|
dom := Domain{
|
||||||
|
DomainName: "dev.payne.io",
|
||||||
|
Source: "manual",
|
||||||
|
Backend: Backend{
|
||||||
|
Address: "192.168.8.222",
|
||||||
|
Type: BackendDNSOnly,
|
||||||
|
},
|
||||||
|
Public: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mgr.Register(dom); err != nil {
|
||||||
|
t.Fatalf("Register failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := mgr.Get("dev.payne.io")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.TLS != TLSNone {
|
||||||
|
t.Errorf("expected TLS none for dns-only, got %s", got.TLS)
|
||||||
|
}
|
||||||
|
if got.Backend.Type != BackendDNSOnly {
|
||||||
|
t.Errorf("expected backend type dns-only, got %s", got.Backend.Type)
|
||||||
|
}
|
||||||
|
if got.Backend.Address != "192.168.8.222" {
|
||||||
|
t.Errorf("expected address 192.168.8.222, got %s", got.Backend.Address)
|
||||||
|
}
|
||||||
|
if !got.Public {
|
||||||
|
t.Error("expected public=true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/wild-cloud/wild-central/internal/services"
|
"github.com/wild-cloud/wild-central/internal/domains"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InstanceRoute represents a Wild Cloud instance's ingress routing configuration
|
// InstanceRoute represents a Wild Cloud instance's ingress routing configuration
|
||||||
@@ -36,7 +36,7 @@ type HTTPRouteBackend struct {
|
|||||||
Paths []string // path prefixes (empty = catch-all)
|
Paths []string // path prefixes (empty = catch-all)
|
||||||
Backend string // host:port
|
Backend string // host:port
|
||||||
HealthPath string // optional health check path
|
HealthPath string // optional health check path
|
||||||
Headers *services.HeaderConfig // per-route headers
|
Headers *domains.HeaderConfig // per-route headers
|
||||||
IPAllow []string // per-route CIDR whitelist
|
IPAllow []string // per-route CIDR whitelist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/wild-cloud/wild-central/internal/services"
|
"github.com/wild-cloud/wild-central/internal/domains"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewManager_DefaultPath(t *testing.T) {
|
func TestNewManager_DefaultPath(t *testing.T) {
|
||||||
@@ -606,7 +606,7 @@ func TestGenerateWithOpts_ResponseHeaders(t *testing.T) {
|
|||||||
Domain: "plausible.cloud.payne.io",
|
Domain: "plausible.cloud.payne.io",
|
||||||
Routes: []HTTPRouteBackend{{
|
Routes: []HTTPRouteBackend{{
|
||||||
Backend: "192.168.8.80:80",
|
Backend: "192.168.8.80:80",
|
||||||
Headers: &services.HeaderConfig{
|
Headers: &domains.HeaderConfig{
|
||||||
Response: map[string]string{
|
Response: map[string]string{
|
||||||
"Access-Control-Allow-Private-Network": "true",
|
"Access-Control-Allow-Private-Network": "true",
|
||||||
},
|
},
|
||||||
@@ -631,7 +631,7 @@ func TestGenerateWithOpts_RequestHeaders(t *testing.T) {
|
|||||||
Domain: "api.payne.io",
|
Domain: "api.payne.io",
|
||||||
Routes: []HTTPRouteBackend{{
|
Routes: []HTTPRouteBackend{{
|
||||||
Backend: "192.168.8.80:80",
|
Backend: "192.168.8.80:80",
|
||||||
Headers: &services.HeaderConfig{
|
Headers: &domains.HeaderConfig{
|
||||||
Request: map[string]string{
|
Request: map[string]string{
|
||||||
"X-Forwarded-Proto": "https",
|
"X-Forwarded-Proto": "https",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// KV bucket names
|
// KV bucket names
|
||||||
BucketServices = "wild-services" // service registrations
|
BucketDomains = "wild-domains" // domain registrations
|
||||||
BucketPresence = "wild-presence" // node liveness (TTL keys)
|
BucketPresence = "wild-presence" // node liveness (TTL keys)
|
||||||
|
|
||||||
// Stream names
|
// Stream names
|
||||||
@@ -126,13 +126,13 @@ func (s *Server) ensureBuckets() error {
|
|||||||
|
|
||||||
// Services bucket — no TTL, entries persist until explicitly deleted
|
// Services bucket — no TTL, entries persist until explicitly deleted
|
||||||
_, err := s.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
_, err := s.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||||
Bucket: BucketServices,
|
Bucket: BucketDomains,
|
||||||
Description: "Service registrations from Wild Cloud, Wild Works, and manual entries",
|
Description: "Service registrations from Wild Cloud, Wild Works, and manual entries",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("services bucket: %w", err)
|
return fmt.Errorf("services bucket: %w", err)
|
||||||
}
|
}
|
||||||
slog.Info("NATS KV bucket ready", "bucket", BucketServices)
|
slog.Info("NATS KV bucket ready", "bucket", BucketDomains)
|
||||||
|
|
||||||
// Presence bucket — TTL-based, keys expire when nodes stop renewing
|
// Presence bucket — TTL-based, keys expire when nodes stop renewing
|
||||||
_, err = s.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
_, err = s.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ func TestBucketsCreated(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Verify services bucket exists
|
// Verify domains bucket exists
|
||||||
kv, err := s.js.KeyValue(ctx, BucketServices)
|
kv, err := s.js.KeyValue(ctx, BucketDomains)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("services bucket not found: %v", err)
|
t.Fatalf("domains bucket not found: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write and read a value
|
// Write and read a value
|
||||||
|
|||||||
73
internal/tools/yq.go
Normal file
73
internal/tools/yq.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// YQ provides a wrapper around the yq command-line tool
|
||||||
|
type YQ struct {
|
||||||
|
yqPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewYQ creates a new YQ wrapper
|
||||||
|
func NewYQ() *YQ {
|
||||||
|
path, err := exec.LookPath("yq")
|
||||||
|
if err != nil {
|
||||||
|
path = "yq"
|
||||||
|
}
|
||||||
|
return &YQ{yqPath: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a value from a YAML file using a yq expression
|
||||||
|
func (y *YQ) Get(filePath, expression string) (string, error) {
|
||||||
|
cmd := exec.Command(y.yqPath, expression, filePath)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("yq get failed: %w, stderr: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(stdout.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set sets a value in a YAML file using a yq expression
|
||||||
|
func (y *YQ) Set(filePath, expression, value string) error {
|
||||||
|
if !strings.HasPrefix(expression, ".") {
|
||||||
|
expression = "." + expression
|
||||||
|
}
|
||||||
|
quotedValue := fmt.Sprintf(`"%s"`, strings.ReplaceAll(value, `"`, `\"`))
|
||||||
|
setExpr := fmt.Sprintf("%s = %s", expression, quotedValue)
|
||||||
|
cmd := exec.Command(y.yqPath, "-i", setExpr, filePath)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("yq set failed: %w, stderr: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a key from a YAML file
|
||||||
|
func (y *YQ) Delete(filePath, expression string) error {
|
||||||
|
delExpr := fmt.Sprintf("del(%s)", expression)
|
||||||
|
cmd := exec.Command(y.yqPath, "-i", delExpr, filePath)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("yq delete failed: %w, stderr: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if a YAML file is valid
|
||||||
|
func (y *YQ) Validate(filePath string) error {
|
||||||
|
cmd := exec.Command(y.yqPath, "eval", ".", filePath)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("yaml validation failed: %w, stderr: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -30,9 +30,9 @@ type Config struct {
|
|||||||
CredentialsDir string `yaml:"credentialsDir" json:"credentialsDir"`
|
CredentialsDir string `yaml:"credentialsDir" json:"credentialsDir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicService describes a service that should be exposed through the tunnel.
|
// PublicDomain describes a domain that should be exposed through the tunnel.
|
||||||
type PublicService struct {
|
type PublicDomain struct {
|
||||||
Name string // service name (used as subdomain)
|
Name string // domain name (used as subdomain)
|
||||||
Subdomain string // explicit subdomain override (defaults to Name)
|
Subdomain string // explicit subdomain override (defaults to Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ type cloudflaredConfig struct {
|
|||||||
|
|
||||||
// GenerateConfig renders the cloudflared config.yml for the given public services.
|
// GenerateConfig renders the cloudflared config.yml for the given public services.
|
||||||
// Returns the YAML string, or empty string if tunnel is not configured or no services are public.
|
// Returns the YAML string, or empty string if tunnel is not configured or no services are public.
|
||||||
func (m *Manager) GenerateConfig(cfg Config, services []PublicService) string {
|
func (m *Manager) GenerateConfig(cfg Config, services []PublicDomain) string {
|
||||||
if !cfg.Enabled || cfg.TunnelID == "" || cfg.PublicDomain == "" || cfg.GatewayDomain == "" {
|
if !cfg.Enabled || cfg.TunnelID == "" || cfg.PublicDomain == "" || cfg.GatewayDomain == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ func (m *Manager) GenerateConfig(cfg Config, services []PublicService) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WriteConfig generates and writes the cloudflared config to disk.
|
// WriteConfig generates and writes the cloudflared config to disk.
|
||||||
func (m *Manager) WriteConfig(cfg Config, services []PublicService) error {
|
func (m *Manager) WriteConfig(cfg Config, services []PublicDomain) error {
|
||||||
content := m.GenerateConfig(cfg, services)
|
content := m.GenerateConfig(cfg, services)
|
||||||
if content == "" {
|
if content == "" {
|
||||||
// Remove config if tunnel is disabled or no public services
|
// Remove config if tunnel is disabled or no public services
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func testConfig() Config {
|
|||||||
func TestGenerateConfig_Basic(t *testing.T) {
|
func TestGenerateConfig_Basic(t *testing.T) {
|
||||||
m := NewManager(t.TempDir())
|
m := NewManager(t.TempDir())
|
||||||
|
|
||||||
services := []PublicService{
|
services := []PublicDomain{
|
||||||
{Name: "my-api"},
|
{Name: "my-api"},
|
||||||
{Name: "dashboard"},
|
{Name: "dashboard"},
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ func TestGenerateConfig_Basic(t *testing.T) {
|
|||||||
func TestGenerateConfig_SubdomainOverride(t *testing.T) {
|
func TestGenerateConfig_SubdomainOverride(t *testing.T) {
|
||||||
m := NewManager(t.TempDir())
|
m := NewManager(t.TempDir())
|
||||||
|
|
||||||
services := []PublicService{
|
services := []PublicDomain{
|
||||||
{Name: "internal-name", Subdomain: "public-name"},
|
{Name: "internal-name", Subdomain: "public-name"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ func TestGenerateConfig_Disabled(t *testing.T) {
|
|||||||
cfg := testConfig()
|
cfg := testConfig()
|
||||||
cfg.Enabled = false
|
cfg.Enabled = false
|
||||||
|
|
||||||
out := m.GenerateConfig(cfg, []PublicService{{Name: "my-api"}})
|
out := m.GenerateConfig(cfg, []PublicDomain{{Name: "my-api"}})
|
||||||
if out != "" {
|
if out != "" {
|
||||||
t.Errorf("expected empty config when disabled, got:\n%s", out)
|
t.Errorf("expected empty config when disabled, got:\n%s", out)
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ func TestGenerateConfig_MissingTunnelID(t *testing.T) {
|
|||||||
cfg := testConfig()
|
cfg := testConfig()
|
||||||
cfg.TunnelID = ""
|
cfg.TunnelID = ""
|
||||||
|
|
||||||
out := m.GenerateConfig(cfg, []PublicService{{Name: "my-api"}})
|
out := m.GenerateConfig(cfg, []PublicDomain{{Name: "my-api"}})
|
||||||
if out != "" {
|
if out != "" {
|
||||||
t.Errorf("expected empty config with missing tunnel ID, got:\n%s", out)
|
t.Errorf("expected empty config with missing tunnel ID, got:\n%s", out)
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ func TestWriteConfig(t *testing.T) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
m := NewManager(tmpDir)
|
m := NewManager(tmpDir)
|
||||||
|
|
||||||
services := []PublicService{{Name: "my-api"}}
|
services := []PublicDomain{{Name: "my-api"}}
|
||||||
if err := m.WriteConfig(testConfig(), services); err != nil {
|
if err := m.WriteConfig(testConfig(), services); err != nil {
|
||||||
t.Fatalf("WriteConfig failed: %v", err)
|
t.Fatalf("WriteConfig failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -139,14 +139,14 @@ func TestWriteConfig_RemovesWhenDisabled(t *testing.T) {
|
|||||||
m := NewManager(tmpDir)
|
m := NewManager(tmpDir)
|
||||||
|
|
||||||
// Write config first
|
// Write config first
|
||||||
if err := m.WriteConfig(testConfig(), []PublicService{{Name: "my-api"}}); err != nil {
|
if err := m.WriteConfig(testConfig(), []PublicDomain{{Name: "my-api"}}); err != nil {
|
||||||
t.Fatalf("WriteConfig failed: %v", err)
|
t.Fatalf("WriteConfig failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now disable and write again — should remove the file
|
// Now disable and write again — should remove the file
|
||||||
cfg := testConfig()
|
cfg := testConfig()
|
||||||
cfg.Enabled = false
|
cfg.Enabled = false
|
||||||
if err := m.WriteConfig(cfg, []PublicService{{Name: "my-api"}}); err != nil {
|
if err := m.WriteConfig(cfg, []PublicDomain{{Name: "my-api"}}); err != nil {
|
||||||
t.Fatalf("WriteConfig (disable) failed: %v", err)
|
t.Fatalf("WriteConfig (disable) failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import {
|
|||||||
useSidebar,
|
useSidebar,
|
||||||
} from './ui/sidebar';
|
} from './ui/sidebar';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
import { useCentralStatus } from '../hooks/useCentralStatus';
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
|
const { data: centralStatus } = useCentralStatus();
|
||||||
|
|
||||||
const cycleTheme = () => {
|
const cycleTheme = () => {
|
||||||
if (theme === 'light') {
|
if (theme === 'light') {
|
||||||
@@ -52,16 +54,16 @@ export function AppSidebar() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const servicesItems = [
|
const domainsItems = [
|
||||||
{ to: '/central', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
{ to: '/central', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
||||||
{ to: '/central/services', icon: Globe, label: 'Services' },
|
{ to: '/central/domains', icon: Globe, label: 'Domains' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const centralItems = [
|
const centralItems = [
|
||||||
{ to: '/central/vpn', icon: Lock, label: 'VPN' },
|
{ to: '/central/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const },
|
||||||
{ to: '/central/firewall', icon: Shield, label: 'Firewall' },
|
{ to: '/central/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const },
|
||||||
{ to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec' },
|
{ to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
||||||
{ to: '/central/dhcp', icon: Wifi, label: 'DHCP' },
|
{ to: '/central/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -80,26 +82,33 @@ export function AppSidebar() {
|
|||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{state === 'collapsed' ? (
|
{state === 'collapsed' ? (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{[...servicesItems, ...centralItems].map((item) => (
|
{[...domainsItems, ...centralItems].map((item) => {
|
||||||
<SidebarMenuItem key={item.to}>
|
const daemon = 'daemon' in item ? (item as any).daemon : undefined;
|
||||||
<NavLink to={item.to} end={'end' in item ? (item as any).end : undefined}>
|
const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
|
||||||
{({ isActive }) => (
|
return (
|
||||||
<SidebarMenuButton isActive={isActive} tooltip={item.label}>
|
<SidebarMenuItem key={item.to}>
|
||||||
<item.icon className="h-4 w-4" />
|
<NavLink to={item.to} end={'end' in item ? (item as any).end : undefined}>
|
||||||
<span>{item.label}</span>
|
{({ isActive }) => (
|
||||||
</SidebarMenuButton>
|
<SidebarMenuButton isActive={isActive} tooltip={item.label}>
|
||||||
)}
|
<item.icon className="h-4 w-4" />
|
||||||
</NavLink>
|
<span>{item.label}</span>
|
||||||
</SidebarMenuItem>
|
{active !== undefined && (
|
||||||
))}
|
<span className={`ml-auto h-1.5 w-1.5 rounded-full ${active ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
|
)}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Services</SidebarGroupLabel>
|
<SidebarGroupLabel>Domains</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{servicesItems.map(({ to, icon: Icon, label, end }) => (
|
{domainsItems.map(({ to, icon: Icon, label, end }) => (
|
||||||
<SidebarMenuItem key={to}>
|
<SidebarMenuItem key={to}>
|
||||||
<NavLink to={to} end={end}>
|
<NavLink to={to} end={end}>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
@@ -119,18 +128,24 @@ export function AppSidebar() {
|
|||||||
<SidebarGroupLabel>Central</SidebarGroupLabel>
|
<SidebarGroupLabel>Central</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{centralItems.map(({ to, icon: Icon, label }) => (
|
{centralItems.map(({ to, icon: Icon, label, daemon }) => {
|
||||||
<SidebarMenuItem key={to}>
|
const active = centralStatus?.daemons?.[daemon]?.active;
|
||||||
<NavLink to={to}>
|
return (
|
||||||
{({ isActive }) => (
|
<SidebarMenuItem key={to}>
|
||||||
<SidebarMenuButton isActive={isActive}>
|
<NavLink to={to}>
|
||||||
<Icon className="h-4 w-4" />
|
{({ isActive }) => (
|
||||||
<span className="truncate">{label}</span>
|
<SidebarMenuButton isActive={isActive}>
|
||||||
</SidebarMenuButton>
|
<Icon className="h-4 w-4" />
|
||||||
)}
|
<span className="truncate">{label}</span>
|
||||||
</NavLink>
|
{active !== undefined && (
|
||||||
</SidebarMenuItem>
|
<span className={`ml-auto h-1.5 w-1.5 rounded-full ${active ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
))}
|
)}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Alert, AlertDescription } from './ui/alert';
|
|||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
|
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
|
||||||
RefreshCw, BookOpen, Globe, Router, Server, RotateCw, Key, Plus, X,
|
RefreshCw, BookOpen, Globe, Router, Server, RotateCw, Key,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCloudflare } from '../hooks/useCloudflare';
|
import { useCloudflare } from '../hooks/useCloudflare';
|
||||||
import { useDdns } from '../hooks/useDdns';
|
import { useDdns } from '../hooks/useDdns';
|
||||||
@@ -22,7 +22,7 @@ export function DashboardComponent() {
|
|||||||
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
|
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
|
||||||
const { verification, isLoading: cfLoading } = useCloudflare();
|
const { verification, isLoading: cfLoading } = useCloudflare();
|
||||||
const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns();
|
const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns();
|
||||||
const { config: globalConfig, updateConfig } = useConfig();
|
const { config: globalConfig } = useConfig();
|
||||||
const { config: vpnConfig } = useVpn();
|
const { config: vpnConfig } = useVpn();
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -36,8 +36,6 @@ export function DashboardComponent() {
|
|||||||
|
|
||||||
const [editingToken, setEditingToken] = useState(false);
|
const [editingToken, setEditingToken] = useState(false);
|
||||||
const [tokenValue, setTokenValue] = useState('');
|
const [tokenValue, setTokenValue] = useState('');
|
||||||
const [addingDdnsRecord, setAddingDdnsRecord] = useState(false);
|
|
||||||
const [newDdnsRecord, setNewDdnsRecord] = useState('');
|
|
||||||
|
|
||||||
const updateSecretsMutation = useMutation({
|
const updateSecretsMutation = useMutation({
|
||||||
mutationFn: (values: Record<string, unknown>) => secretsApi.update(values),
|
mutationFn: (values: Record<string, unknown>) => secretsApi.update(values),
|
||||||
@@ -260,76 +258,33 @@ export function DashboardComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Record list with remove buttons */}
|
{/* Record list (read-only — derived from public domains) */}
|
||||||
{globalConfig?.cloud?.ddns?.records?.length ? (
|
{ddnsStatus.records?.length ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{globalConfig.cloud.ddns.records.map((r: string) => {
|
{ddnsStatus.records.map((rs: DdnsRecordStatus) => (
|
||||||
const rs: DdnsRecordStatus | undefined = ddnsStatus.records?.find((s: DdnsRecordStatus) => s.name === r);
|
<div key={rs.name} className="flex items-center gap-2 text-xs">
|
||||||
return (
|
{rs.ok ? (
|
||||||
<div key={r} className="flex items-center gap-2 text-xs group">
|
<CheckCircle className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||||
{rs?.ok ? (
|
) : (
|
||||||
<CheckCircle className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
|
||||||
) : rs && !rs.ok ? (
|
)}
|
||||||
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
|
<span className="font-mono bg-muted px-2 py-0.5 rounded">{rs.name}</span>
|
||||||
) : (
|
{rs.ok && rs.ip && (
|
||||||
<div className="h-3.5 w-3.5 shrink-0" />
|
<span className="text-muted-foreground font-mono">{rs.ip}</span>
|
||||||
)}
|
)}
|
||||||
<span className="font-mono bg-muted px-2 py-0.5 rounded">{r}</span>
|
{!rs.ok && rs.error && (
|
||||||
{rs?.ok && rs.ip && (
|
<span className="text-red-500 truncate" title={rs.error}>{rs.error}</span>
|
||||||
<span className="text-muted-foreground font-mono">{rs.ip}</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
{rs && !rs.ok && rs.error && (
|
))}
|
||||||
<span className="text-red-500 truncate" title={rs.error}>{rs.error}</span>
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
)}
|
Records are derived from public domains. Toggle Public on the Domains page to manage.
|
||||||
<Button
|
</p>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 ml-auto"
|
|
||||||
onClick={async () => {
|
|
||||||
if (!globalConfig) return;
|
|
||||||
const records = (globalConfig.cloud?.ddns?.records || []).filter((rec: string) => rec !== r);
|
|
||||||
await updateConfig({
|
|
||||||
...globalConfig,
|
|
||||||
cloud: { ...globalConfig.cloud, ddns: { ...globalConfig.cloud?.ddns, records } },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Add DDNS record */}
|
|
||||||
{!addingDdnsRecord ? (
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setAddingDdnsRecord(true)} className="gap-1 text-muted-foreground">
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
Add Record
|
|
||||||
</Button>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-2 items-center">
|
<p className="text-xs text-muted-foreground">
|
||||||
<Input
|
No public domains registered. Set a domain to Public on the Domains page to enable DDNS.
|
||||||
value={newDdnsRecord}
|
</p>
|
||||||
onChange={(e) => setNewDdnsRecord(e.target.value)}
|
|
||||||
placeholder="dev.payne.io"
|
|
||||||
className="h-8 text-xs font-mono flex-1"
|
|
||||||
/>
|
|
||||||
<Button size="sm" className="h-8" disabled={!newDdnsRecord} onClick={async () => {
|
|
||||||
if (!globalConfig || !newDdnsRecord) return;
|
|
||||||
const records = [...(globalConfig.cloud?.ddns?.records || []), newDdnsRecord];
|
|
||||||
await updateConfig({
|
|
||||||
...globalConfig,
|
|
||||||
cloud: { ...globalConfig.cloud, ddns: { ...globalConfig.cloud?.ddns, records } },
|
|
||||||
});
|
|
||||||
setNewDdnsRecord('');
|
|
||||||
setAddingDdnsRecord(false);
|
|
||||||
}}>Add</Button>
|
|
||||||
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setAddingDdnsRecord(false); setNewDdnsRecord(''); }}>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -345,7 +300,7 @@ export function DashboardComponent() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground ml-7">
|
<p className="text-sm text-muted-foreground ml-7">
|
||||||
DDNS is not enabled. Configure it in the DDNS settings to keep DNS records in sync with your public IP.
|
DDNS is not enabled. Enable it in the global config to keep public domain DNS records in sync with your public IP.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -378,10 +333,11 @@ export function DashboardComponent() {
|
|||||||
<div className="space-y-3 ml-7">
|
<div className="space-y-3 ml-7">
|
||||||
{/* Service badges */}
|
{/* Service badges */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<ServiceBadge name="dnsmasq" />
|
<ServiceBadge name="dnsmasq" active={centralStatus?.daemons?.dnsmasq?.active} />
|
||||||
<ServiceBadge name="haproxy" />
|
<ServiceBadge name="haproxy" active={centralStatus?.daemons?.haproxy?.active} />
|
||||||
<ServiceBadge name="wireguard" />
|
<ServiceBadge name="nftables" active={centralStatus?.daemons?.nftables?.active} />
|
||||||
<ServiceBadge name="crowdsec" />
|
<ServiceBadge name="wireguard" active={centralStatus?.daemons?.wireguard?.active} />
|
||||||
|
<ServiceBadge name="crowdsec" active={centralStatus?.daemons?.crowdsec?.active} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Version and uptime */}
|
{/* Version and uptime */}
|
||||||
@@ -452,13 +408,12 @@ export function DashboardComponent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inline badge showing a service name with a colored dot. Status is informational only. */
|
/** Inline badge showing a service name with a colored dot reflecting actual daemon status. */
|
||||||
function ServiceBadge({ name }: { name: string }) {
|
function ServiceBadge({ name, active }: { name: string; active?: boolean }) {
|
||||||
// These are presentational — the daemon is running if we can reach the API,
|
const dotColor = active === undefined ? 'bg-gray-400' : active ? 'bg-green-500' : 'bg-red-500';
|
||||||
// and individual service health comes from their own endpoints.
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-medium">
|
<span className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-medium">
|
||||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import {
|
|||||||
Plus, Trash2, X, ChevronDown, ChevronUp, Route, Shield, FileText,
|
Plus, Trash2, X, ChevronDown, ChevronUp, Route, Shield, FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useHaproxy } from '../hooks/useHaproxy';
|
import { useHaproxy } from '../hooks/useHaproxy';
|
||||||
import { useServices } from '../hooks/useServices';
|
import { useDomains } from '../hooks/useDomains';
|
||||||
|
import { useDdns } from '../hooks/useDdns';
|
||||||
import { useCert } from '../hooks/useCert';
|
import { useCert } from '../hooks/useCert';
|
||||||
import { usePageHelp } from '../hooks/usePageHelp';
|
import { usePageHelp } from '../hooks/usePageHelp';
|
||||||
import type { RegisteredService } from '../services/api/networking';
|
import type { RegisteredDomain } from '../services/api/networking';
|
||||||
import type { CertEntry } from '../services/api/cert';
|
import type { CertEntry } from '../services/api/cert';
|
||||||
import { servicesApi, haproxyApi } from '../services/api/networking';
|
import { domainsApi, haproxyApi } from '../services/api/networking';
|
||||||
|
|
||||||
function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: string }) {
|
function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -30,9 +31,9 @@ function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: st
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTlsStatus(service: RegisteredService, certDomains: Set<string>): 'ok' | 'error' | 'na' {
|
function getTlsStatus(service: RegisteredDomain, certDomains: Set<string>): 'ok' | 'error' | 'na' {
|
||||||
const backendType = service.routes?.length ? service.routes[0].backend.type : service.backend.type;
|
const backendType = service.routes?.length ? service.routes[0].backend.type : service.backend.type;
|
||||||
if (service.tls === 'passthrough' || backendType === 'tcp-passthrough') return 'na';
|
if (service.tls === 'passthrough' || service.tls === 'none' || backendType === 'tcp-passthrough' || backendType === 'dns-only') return 'na';
|
||||||
// Check exact match or wildcard coverage
|
// Check exact match or wildcard coverage
|
||||||
if (certDomains.has(service.domain)) return 'ok';
|
if (certDomains.has(service.domain)) return 'ok';
|
||||||
const dotIdx = service.domain.indexOf('.');
|
const dotIdx = service.domain.indexOf('.');
|
||||||
@@ -56,21 +57,17 @@ function findCert(domain: string, certs: CertEntry[]): CertEntry | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasRoutes(service: RegisteredService): boolean {
|
function hasRoutes(service: RegisteredDomain): boolean {
|
||||||
return (service.routes?.length ?? 0) > 0;
|
return (service.routes?.length ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBackendAddress(service: RegisteredService): string {
|
|
||||||
if (hasRoutes(service)) return service.routes![0].backend.address;
|
|
||||||
return service.backend.address;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBackendType(service: RegisteredService): string {
|
function getBackendType(service: RegisteredDomain): string {
|
||||||
if (hasRoutes(service)) return service.routes![0].backend.type;
|
if (hasRoutes(service)) return service.routes![0].backend.type;
|
||||||
return service.backend.type;
|
return service.backend.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServicesComponent() {
|
export function DomainsComponent() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -78,8 +75,9 @@ export function ServicesComponent() {
|
|||||||
generateData: haproxyGenerateData, generateError: haproxyGenerateError,
|
generateData: haproxyGenerateData, generateError: haproxyGenerateError,
|
||||||
restart: restartHaproxy, isRestarting: isHaproxyRestarting,
|
restart: restartHaproxy, isRestarting: isHaproxyRestarting,
|
||||||
} = useHaproxy();
|
} = useHaproxy();
|
||||||
const { services, isLoading: isServicesLoading } = useServices();
|
const { domains, isLoading: isDomainsLoading } = useDomains();
|
||||||
const { status: certStatus, provision: provisionCert, isProvisioning, renew: renewCert, isRenewing } = useCert();
|
const { status: ddnsStatus, trigger: triggerDdns, isTriggering: isDdnsTriggering } = useDdns();
|
||||||
|
const { status: certStatus, provision: provisionCert, renew: renewCert } = useCert();
|
||||||
|
|
||||||
const provisionMutation = useMutation({
|
const provisionMutation = useMutation({
|
||||||
mutationFn: (domain: string) => provisionCert(domain),
|
mutationFn: (domain: string) => provisionCert(domain),
|
||||||
@@ -103,38 +101,38 @@ export function ServicesComponent() {
|
|||||||
const [newBackendAddr, setNewBackendAddr] = useState('');
|
const [newBackendAddr, setNewBackendAddr] = useState('');
|
||||||
const [newPublic, setNewPublic] = useState(true);
|
const [newPublic, setNewPublic] = useState(true);
|
||||||
const [newSubdomains, setNewSubdomains] = useState(false);
|
const [newSubdomains, setNewSubdomains] = useState(false);
|
||||||
const [newTls, setNewTls] = useState<string>('passthrough');
|
const [gatewayMode, setGatewayMode] = useState<'none' | 'reverse-proxy' | 'passthrough'>('reverse-proxy');
|
||||||
|
|
||||||
const certDomains = new Set<string>(
|
const certDomains = new Set<string>(
|
||||||
(certStatus?.certs ?? []).filter(c => c.cert.exists).map(c => c.domain)
|
(certStatus?.certs ?? []).filter(c => c.cert.exists).map(c => c.domain)
|
||||||
);
|
);
|
||||||
|
|
||||||
const registerMutation = useMutation({
|
const registerMutation = useMutation({
|
||||||
mutationFn: (svc: Record<string, unknown>) => servicesApi.register(svc as any),
|
mutationFn: (svc: Record<string, unknown>) => domainsApi.register(svc as any),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['services'] });
|
queryClient.invalidateQueries({ queryKey: ['domains'] });
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
setNewDomain(''); setNewBackendAddr(''); setNewPublic(true); setNewSubdomains(false); setNewTls('passthrough');
|
setNewDomain(''); setNewBackendAddr(''); setNewPublic(true); setNewSubdomains(false); setGatewayMode('reverse-proxy');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ domain, updates }: { domain: string; updates: Record<string, unknown> }) =>
|
mutationFn: ({ domain, updates }: { domain: string; updates: Record<string, unknown> }) =>
|
||||||
servicesApi.register({ ...(services.find(s => s.domain === domain) ?? {}), ...updates } as any),
|
domainsApi.register({ ...(domains.find(d => d.domain === domain) ?? {}), ...updates } as any),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['services'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deregisterMutation = useMutation({
|
const deregisterMutation = useMutation({
|
||||||
mutationFn: (domain: string) => servicesApi.deregister(domain),
|
mutationFn: (domain: string) => domainsApi.deregister(domain),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['services'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
usePageHelp({
|
usePageHelp({
|
||||||
title: 'Services',
|
title: 'Domains',
|
||||||
description: (
|
description: (
|
||||||
<p className="leading-relaxed">
|
<p className="leading-relaxed">
|
||||||
Each service is a domain that Wild Central manages. Central provides DNS resolution,
|
Each domain is managed by Wild Central. For each, configure whether it just resolves to an IP
|
||||||
proxy routing, TLS certificates, and public DNS records based on how you configure each service.
|
or routes through Central's gateway for DNS, proxy routing, TLS, and public exposure.
|
||||||
</p>
|
</p>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -168,12 +166,12 @@ export function ServicesComponent() {
|
|||||||
<Globe className="h-6 w-6 text-primary" />
|
<Globe className="h-6 w-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold">Services</h2>
|
<h2 className="text-2xl font-semibold">Domains</h2>
|
||||||
<p className="text-muted-foreground">Registered domains and their networking status</p>
|
<p className="text-muted-foreground">Registered domains and their networking status</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setShowAddForm(!showAddForm)} className="gap-1">
|
<Button onClick={() => setShowAddForm(!showAddForm)} className="gap-1">
|
||||||
<Plus className="h-4 w-4" />Add Service
|
<Plus className="h-4 w-4" />Add Domain
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -181,16 +179,32 @@ export function ServicesComponent() {
|
|||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<Card className="border-dashed border-primary/50">
|
<Card className="border-dashed border-primary/50">
|
||||||
<CardContent className="p-4 space-y-4">
|
<CardContent className="p-4 space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div>
|
||||||
<div>
|
<Label className="text-xs">Domain</Label>
|
||||||
<Label className="text-xs">Domain</Label>
|
<Input value={newDomain} onChange={e => setNewDomain(e.target.value)} placeholder="dev.payne.io" className="mt-1 font-mono" />
|
||||||
<Input value={newDomain} onChange={e => setNewDomain(e.target.value)} placeholder="cloud.payne.io" className="mt-1 font-mono" />
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<Label className="text-xs mb-2 block">Gateway</Label>
|
||||||
<Label className="text-xs">Backend (host:port)</Label>
|
<div className="flex gap-1 bg-muted rounded-md p-1">
|
||||||
<Input value={newBackendAddr} onChange={e => setNewBackendAddr(e.target.value)} placeholder="192.168.8.240:443" className="mt-1 font-mono" />
|
{([
|
||||||
|
{ value: 'none' as const, label: 'None' },
|
||||||
|
{ value: 'reverse-proxy' as const, label: 'Reverse proxy' },
|
||||||
|
{ value: 'passthrough' as const, label: 'Passthrough' },
|
||||||
|
] as const).map(opt => (
|
||||||
|
<button key={opt.value} type="button"
|
||||||
|
className={`flex-1 text-xs py-1.5 px-2 rounded transition-colors ${gatewayMode === opt.value ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
|
onClick={() => setGatewayMode(opt.value)}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">{gatewayMode === 'none' ? 'Resolves to (IP)' : 'Backend (host:port)'}</Label>
|
||||||
|
<Input value={newBackendAddr} onChange={e => setNewBackendAddr(e.target.value)}
|
||||||
|
placeholder={gatewayMode === 'none' ? '192.168.8.222' : '192.168.8.240:443'}
|
||||||
|
className="mt-1 font-mono" />
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<Switch checked={newPublic} onCheckedChange={setNewPublic} />
|
<Switch checked={newPublic} onCheckedChange={setNewPublic} />
|
||||||
@@ -200,19 +214,19 @@ export function ServicesComponent() {
|
|||||||
<Switch checked={newSubdomains} onCheckedChange={setNewSubdomains} />
|
<Switch checked={newSubdomains} onCheckedChange={setNewSubdomains} />
|
||||||
Include subdomains
|
Include subdomains
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<Switch checked={newTls === 'terminate'} onCheckedChange={v => setNewTls(v ? 'terminate' : 'passthrough')} />
|
|
||||||
Central handles TLS
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowAddForm(false)}><X className="h-3 w-3 mr-1" />Cancel</Button>
|
<Button variant="outline" size="sm" onClick={() => setShowAddForm(false)}><X className="h-3 w-3 mr-1" />Cancel</Button>
|
||||||
<Button size="sm" disabled={!newDomain || !newBackendAddr || registerMutation.isPending}
|
<Button size="sm" disabled={!newDomain || !newBackendAddr || registerMutation.isPending}
|
||||||
onClick={() => registerMutation.mutate({
|
onClick={() => {
|
||||||
domain: newDomain,
|
const backendType = gatewayMode === 'none' ? 'dns-only' : gatewayMode === 'passthrough' ? 'tcp-passthrough' : 'http';
|
||||||
backend: { address: newBackendAddr, type: newTls === 'passthrough' ? 'tcp-passthrough' : 'http' },
|
const tls = gatewayMode === 'none' ? 'none' : gatewayMode === 'passthrough' ? 'passthrough' : 'terminate';
|
||||||
public: newPublic, subdomains: newSubdomains, tls: newTls, source: 'manual',
|
registerMutation.mutate({
|
||||||
})}>
|
domain: newDomain,
|
||||||
|
backend: { address: newBackendAddr, type: backendType },
|
||||||
|
public: newPublic, subdomains: newSubdomains, tls, source: 'manual',
|
||||||
|
});
|
||||||
|
}}>
|
||||||
{registerMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Plus className="h-3 w-3 mr-1" />}
|
{registerMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Plus className="h-3 w-3 mr-1" />}
|
||||||
Register
|
Register
|
||||||
</Button>
|
</Button>
|
||||||
@@ -222,28 +236,32 @@ export function ServicesComponent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading */}
|
{/* Loading */}
|
||||||
{isServicesLoading && (
|
{isDomainsLoading && (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<Loader2 className="h-8 w-8 text-primary mx-auto mb-2 animate-spin" />
|
<Loader2 className="h-8 w-8 text-primary mx-auto mb-2 animate-spin" />
|
||||||
<p className="text-sm text-muted-foreground">Loading services...</p>
|
<p className="text-sm text-muted-foreground">Loading domains...</p>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty */}
|
{/* Empty */}
|
||||||
{!isServicesLoading && services.length === 0 && (
|
{!isDomainsLoading && domains.length === 0 && (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<Globe className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<Globe className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-medium mb-2">No Services</h3>
|
<h3 className="text-lg font-medium mb-2">No Domains</h3>
|
||||||
<p className="text-muted-foreground">Services appear when Wild Cloud or Wild Works registers domains, or add one manually.</p>
|
<p className="text-muted-foreground">Domains appear when Wild Cloud or Wild Works registers them, or add one manually.</p>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Service Cards */}
|
{/* Domain Cards */}
|
||||||
{services.map(service => {
|
{domains.map(service => {
|
||||||
const isExpanded = expandedDomains.has(service.domain);
|
const isExpanded = expandedDomains.has(service.domain);
|
||||||
|
const backendType = getBackendType(service);
|
||||||
|
const isDnsOnly = backendType === 'dns-only';
|
||||||
|
const isPassthrough = service.tls === 'passthrough' || backendType === 'tcp-passthrough';
|
||||||
|
const hasGateway = !isDnsOnly;
|
||||||
const tlsStatus = getTlsStatus(service, certDomains);
|
const tlsStatus = getTlsStatus(service, certDomains);
|
||||||
const ddnsStatus2 = service.public ? 'ok' as const : 'na' as const;
|
const ddnsRecord = ddnsStatus?.records?.find(r => r.name === service.domain);
|
||||||
const isPassthrough = service.tls === 'passthrough' || getBackendType(service) === 'tcp-passthrough';
|
const ddnsStatus2: 'ok' | 'error' | 'na' = !service.public ? 'na' : ddnsRecord?.ok ? 'ok' : ddnsRecord ? 'error' : 'na';
|
||||||
const routeCount = service.routes?.length ?? 0;
|
const routeCount = service.routes?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -261,8 +279,8 @@ export function ServicesComponent() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<StatusDot status="ok" label="DNS" />
|
<StatusDot status="ok" label="DNS" />
|
||||||
<StatusDot status="ok" label="Proxy" />
|
{hasGateway && <StatusDot status="ok" label="Gateway" />}
|
||||||
<StatusDot status={tlsStatus} label="TLS" />
|
{hasGateway && !isPassthrough && <StatusDot status={tlsStatus} label="TLS" />}
|
||||||
<StatusDot status={ddnsStatus2} label="DDNS" />
|
<StatusDot status={ddnsStatus2} label="DDNS" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,7 +293,7 @@ export function ServicesComponent() {
|
|||||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 py-2">
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 py-2">
|
||||||
{!hasRoutes(service) && (
|
{!hasRoutes(service) && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">Backend</Label>
|
<Label className="text-xs text-muted-foreground">{isDnsOnly ? 'Resolves to' : 'Backend'}</Label>
|
||||||
<Input
|
<Input
|
||||||
defaultValue={service.backend.address}
|
defaultValue={service.backend.address}
|
||||||
className="mt-0.5 h-8 font-mono text-sm w-48"
|
className="mt-0.5 h-8 font-mono text-sm w-48"
|
||||||
@@ -310,29 +328,31 @@ export function ServicesComponent() {
|
|||||||
<Label className="text-sm">Subdomains</Label>
|
<Label className="text-sm">Subdomains</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{hasGateway && (
|
||||||
<Switch
|
<div className="flex items-center gap-2">
|
||||||
checked={!isPassthrough}
|
<Switch
|
||||||
onCheckedChange={v => {
|
checked={!isPassthrough}
|
||||||
if (hasRoutes(service)) {
|
onCheckedChange={v => {
|
||||||
updateMutation.mutate({
|
if (hasRoutes(service)) {
|
||||||
domain: service.domain,
|
updateMutation.mutate({
|
||||||
updates: { tls: v ? 'terminate' : 'passthrough' },
|
domain: service.domain,
|
||||||
});
|
updates: { tls: v ? 'terminate' : 'passthrough' },
|
||||||
} else {
|
});
|
||||||
updateMutation.mutate({
|
} else {
|
||||||
domain: service.domain,
|
updateMutation.mutate({
|
||||||
updates: {
|
domain: service.domain,
|
||||||
tls: v ? 'terminate' : 'passthrough',
|
updates: {
|
||||||
backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' },
|
tls: v ? 'terminate' : 'passthrough',
|
||||||
},
|
backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' },
|
||||||
});
|
},
|
||||||
}
|
});
|
||||||
}}
|
}
|
||||||
disabled={updateMutation.isPending}
|
}}
|
||||||
/>
|
disabled={updateMutation.isPending}
|
||||||
<Label className="text-sm">{isPassthrough ? 'TLS Passthrough' : 'Central TLS'}</Label>
|
/>
|
||||||
</div>
|
<Label className="text-sm">{isPassthrough ? 'TLS Passthrough' : 'Central TLS'}</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Routes detail */}
|
{/* Routes detail */}
|
||||||
@@ -355,8 +375,8 @@ export function ServicesComponent() {
|
|||||||
if (val && val !== route.backend.address) {
|
if (val && val !== route.backend.address) {
|
||||||
const updatedRoutes = [...service.routes!];
|
const updatedRoutes = [...service.routes!];
|
||||||
updatedRoutes[i] = { ...route, backend: { ...route.backend, address: val } };
|
updatedRoutes[i] = { ...route, backend: { ...route.backend, address: val } };
|
||||||
servicesApi.register({ ...service, backend: undefined as any, routes: updatedRoutes } as any)
|
domainsApi.register({ ...service, backend: undefined as any, routes: updatedRoutes } as any)
|
||||||
.then(() => queryClient.invalidateQueries({ queryKey: ['services'] }));
|
.then(() => queryClient.invalidateQueries({ queryKey: ['domains'] }));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -401,7 +421,7 @@ export function ServicesComponent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TLS cert info */}
|
{/* TLS cert info */}
|
||||||
{!isPassthrough && (() => {
|
{hasGateway && !isPassthrough && (() => {
|
||||||
const cert = findCert(service.domain, certStatus?.certs ?? []);
|
const cert = findCert(service.domain, certStatus?.certs ?? []);
|
||||||
if (!cert) {
|
if (!cert) {
|
||||||
const provisionDomain = service.subdomains ? `*.${service.domain}` : service.domain;
|
const provisionDomain = service.subdomains ? `*.${service.domain}` : service.domain;
|
||||||
@@ -449,6 +469,25 @@ export function ServicesComponent() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* DDNS error info */}
|
||||||
|
{ddnsStatus2 === 'error' && ddnsRecord && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-red-500">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
DDNS sync failed{ddnsRecord.error ? `: ${ddnsRecord.error}` : ''}.
|
||||||
|
{' '}Check that your Cloudflare API token has access to this domain's zone.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs gap-1 shrink-0 ml-2"
|
||||||
|
disabled={isDdnsTriggering}
|
||||||
|
onClick={() => triggerDdns()}>
|
||||||
|
{isDdnsTriggering ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Source + deregister */}
|
{/* Source + deregister */}
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>Source: {service.source}</span>
|
<span>Source: {service.source}</span>
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '../services/api/client';
|
import { apiClient } from '../services/api/client';
|
||||||
|
|
||||||
|
interface DaemonStatus {
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface CentralStatus {
|
interface CentralStatus {
|
||||||
status: string;
|
status: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -10,6 +14,7 @@ interface CentralStatus {
|
|||||||
appsDir: string;
|
appsDir: string;
|
||||||
setupFiles: string;
|
setupFiles: string;
|
||||||
pendingRestart: boolean;
|
pendingRestart: boolean;
|
||||||
|
daemons?: Record<string, DaemonStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,62 +1,61 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiService } from '../services/api-legacy';
|
import { apiService } from '../services/api-legacy';
|
||||||
import type { GlobalConfig, GlobalConfigResponse } from '../types';
|
import type { CentralState, CentralStateResponse } from '../types';
|
||||||
|
|
||||||
interface CreateConfigResponse {
|
interface CreateConfigResponse {
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing Wild Central global configuration
|
* Hook for managing Wild Central runtime state
|
||||||
* Endpoint: /api/v1/config
|
* Endpoint: /api/v1/state
|
||||||
* File: {dataDir}/state.yaml
|
* File: {dataDir}/state.yaml
|
||||||
*/
|
*/
|
||||||
export const useConfig = () => {
|
export const useConfig = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showConfigSetup, setShowConfigSetup] = useState(false);
|
const [showConfigSetup, setShowConfigSetup] = useState(false);
|
||||||
|
|
||||||
const configQuery = useQuery<GlobalConfigResponse>({
|
const stateQuery = useQuery<CentralStateResponse>({
|
||||||
queryKey: ['globalConfig'],
|
queryKey: ['state'],
|
||||||
queryFn: () => apiService.getConfig(),
|
queryFn: () => apiService.getConfig(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update showConfigSetup based on query data
|
// Update showConfigSetup based on query data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (configQuery.data) {
|
if (stateQuery.data) {
|
||||||
setShowConfigSetup(configQuery.data.configured === false);
|
setShowConfigSetup(stateQuery.data.configured === false);
|
||||||
}
|
}
|
||||||
}, [configQuery.data]);
|
}, [stateQuery.data]);
|
||||||
|
|
||||||
const createConfigMutation = useMutation<CreateConfigResponse, Error, GlobalConfig>({
|
const createStateMutation = useMutation<CreateConfigResponse, Error, CentralState>({
|
||||||
mutationFn: (config) => apiService.createConfig(config),
|
mutationFn: (state) => apiService.createConfig(state),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Invalidate and refetch config after successful creation
|
queryClient.invalidateQueries({ queryKey: ['state'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['globalConfig'] });
|
|
||||||
setShowConfigSetup(false);
|
setShowConfigSetup(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateConfigMutation = useMutation<CreateConfigResponse, Error, GlobalConfig>({
|
const updateStateMutation = useMutation<CreateConfigResponse, Error, CentralState>({
|
||||||
mutationFn: (config) => apiService.updateConfig(config),
|
mutationFn: (state) => apiService.updateConfig(state),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['globalConfig'] });
|
queryClient.invalidateQueries({ queryKey: ['state'] });
|
||||||
// Immediately re-check daemon status so the pending-restart banner appears
|
// Immediately re-check daemon status so the pending-restart banner appears
|
||||||
queryClient.invalidateQueries({ queryKey: ['central', 'status'] });
|
queryClient.invalidateQueries({ queryKey: ['central', 'status'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: configQuery.data?.config || null,
|
config: stateQuery.data?.state || null,
|
||||||
isConfigured: configQuery.data?.configured || false,
|
isConfigured: stateQuery.data?.configured || false,
|
||||||
showConfigSetup,
|
showConfigSetup,
|
||||||
setShowConfigSetup,
|
setShowConfigSetup,
|
||||||
isLoading: configQuery.isLoading,
|
isLoading: stateQuery.isLoading,
|
||||||
isCreating: createConfigMutation.isPending,
|
isCreating: createStateMutation.isPending,
|
||||||
isUpdating: updateConfigMutation.isPending,
|
isUpdating: updateStateMutation.isPending,
|
||||||
error: configQuery.error || createConfigMutation.error || updateConfigMutation.error,
|
error: stateQuery.error || createStateMutation.error || updateStateMutation.error,
|
||||||
createConfig: createConfigMutation.mutate,
|
createConfig: createStateMutation.mutate,
|
||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateStateMutation.mutateAsync,
|
||||||
refetch: configQuery.refetch,
|
refetch: stateQuery.refetch,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ export const useDdns = () => {
|
|||||||
const triggerMutation = useMutation({
|
const triggerMutation = useMutation({
|
||||||
mutationFn: () => ddnsApi.trigger(),
|
mutationFn: () => ddnsApi.trigger(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Refresh status shortly after trigger
|
// Refresh status after trigger — stagger to catch async completion
|
||||||
setTimeout(() => {
|
for (const delay of [1000, 3000, 6000]) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ddns', 'status'] });
|
setTimeout(() => {
|
||||||
}, 2000);
|
queryClient.invalidateQueries({ queryKey: ['ddns', 'status'] });
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { servicesApi } from '../services/api/networking';
|
import { domainsApi } from '../services/api/networking';
|
||||||
|
|
||||||
export const useServices = () => {
|
export const useDomains = () => {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['services'],
|
queryKey: ['domains'],
|
||||||
queryFn: () => servicesApi.list(),
|
queryFn: () => domainsApi.list(),
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
staleTime: 10000,
|
staleTime: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
services: query.data?.services ?? [],
|
domains: query.data?.domains ?? [],
|
||||||
isLoading: query.isLoading,
|
isLoading: query.isLoading,
|
||||||
error: query.error,
|
error: query.error,
|
||||||
};
|
};
|
||||||
10
web/src/router/pages/DomainsPage.tsx
Normal file
10
web/src/router/pages/DomainsPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../components';
|
||||||
|
import { DomainsComponent } from '../../components/DomainsComponent';
|
||||||
|
|
||||||
|
export function DomainsPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<DomainsComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { ErrorBoundary } from '../../components';
|
|
||||||
import { ServicesComponent } from '../../components/ServicesComponent';
|
|
||||||
|
|
||||||
export function ServicesPage() {
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<ServicesComponent />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import type { RouteObject } from 'react-router';
|
|||||||
import { CentralLayout } from './CentralLayout';
|
import { CentralLayout } from './CentralLayout';
|
||||||
import { NotFoundPage } from './pages/NotFoundPage';
|
import { NotFoundPage } from './pages/NotFoundPage';
|
||||||
import { DashboardPage } from './pages/DashboardPage';
|
import { DashboardPage } from './pages/DashboardPage';
|
||||||
import { ServicesPage } from './pages/ServicesPage';
|
import { DomainsPage } from './pages/DomainsPage';
|
||||||
import { FirewallPage } from './pages/FirewallPage';
|
import { FirewallPage } from './pages/FirewallPage';
|
||||||
import { VpnPage } from './pages/VpnPage';
|
import { VpnPage } from './pages/VpnPage';
|
||||||
import { CrowdSecPage } from './pages/CrowdSecPage';
|
import { CrowdSecPage } from './pages/CrowdSecPage';
|
||||||
@@ -19,7 +19,7 @@ export const routes: RouteObject[] = [
|
|||||||
element: <CentralLayout />,
|
element: <CentralLayout />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <DashboardPage /> },
|
{ index: true, element: <DashboardPage /> },
|
||||||
{ path: 'services', element: <ServicesPage /> },
|
{ path: 'domains', element: <DomainsPage /> },
|
||||||
{ path: 'firewall', element: <FirewallPage /> },
|
{ path: 'firewall', element: <FirewallPage /> },
|
||||||
{ path: 'vpn', element: <VpnPage /> },
|
{ path: 'vpn', element: <VpnPage /> },
|
||||||
{ path: 'crowdsec', element: <CrowdSecPage /> },
|
{ path: 'crowdsec', element: <CrowdSecPage /> },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
Status,
|
Status,
|
||||||
GlobalConfig,
|
CentralState,
|
||||||
GlobalConfigResponse,
|
CentralStateResponse,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
NetworkInfo,
|
NetworkInfo,
|
||||||
@@ -51,35 +51,35 @@ class ApiService {
|
|||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Global Config APIs (Wild Central level)
|
// Global Config APIs (Wild Central level)
|
||||||
// Endpoint: /api/v1/config
|
// Endpoint: /api/v1/state
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
async getConfig(): Promise<GlobalConfigResponse> {
|
async getConfig(): Promise<CentralStateResponse> {
|
||||||
return this.request<GlobalConfigResponse>('/api/v1/config');
|
return this.request<CentralStateResponse>('/api/v1/state');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getConfigYaml(): Promise<string> {
|
async getConfigYaml(): Promise<string> {
|
||||||
return this.requestText('/api/v1/config/yaml');
|
return this.requestText('/api/v1/state/yaml');
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateConfigYaml(yamlContent: string): Promise<StatusResponse> {
|
async updateConfigYaml(yamlContent: string): Promise<StatusResponse> {
|
||||||
return this.request<StatusResponse>('/api/v1/config/yaml', {
|
return this.request<StatusResponse>('/api/v1/state/yaml', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
body: yamlContent
|
body: yamlContent
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createConfig(config: GlobalConfig): Promise<StatusResponse> {
|
async createConfig(config: CentralState): Promise<StatusResponse> {
|
||||||
return this.request<StatusResponse>('/api/v1/config', {
|
return this.request<StatusResponse>('/api/v1/state', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(config)
|
body: JSON.stringify(config)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateConfig(config: GlobalConfig): Promise<StatusResponse> {
|
async updateConfig(config: CentralState): Promise<StatusResponse> {
|
||||||
return this.request<StatusResponse>('/api/v1/config', {
|
return this.request<StatusResponse>('/api/v1/state', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(config)
|
body: JSON.stringify(config)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
export { apiClient, ApiError } from './client';
|
export { apiClient, ApiError } from './client';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export { dnsmasqApi } from './dnsmasq';
|
export { dnsmasqApi } from './dnsmasq';
|
||||||
export { servicesApi, haproxyApi, ddnsApi, dhcpApi, secretsApi, crowdSecApi } from './networking';
|
export { domainsApi, haproxyApi, ddnsApi, dhcpApi, secretsApi, crowdSecApi } from './networking';
|
||||||
export type { RegisteredService, HaproxyStatus, HaproxyGenerateResult, DdnsStatus, DhcpLease, DhcpStaticLease, BackendStat, CrowdSecStatus, CrowdSecMachine, CrowdSecBouncer, CrowdSecDecision, CrowdSecProvisionResult } from './networking';
|
export type { RegisteredDomain, HaproxyStatus, HaproxyGenerateResult, DdnsStatus, DhcpLease, DhcpStaticLease, BackendStat, CrowdSecStatus, CrowdSecMachine, CrowdSecBouncer, CrowdSecDecision } from './networking';
|
||||||
export { vpnApi } from './vpn';
|
export { vpnApi } from './vpn';
|
||||||
export type { VpnStatus, VpnConfig, VpnPeer, VpnPeerConfig } from './vpn';
|
export type { VpnStatus, VpnConfig, VpnPeer, VpnPeerConfig } from './vpn';
|
||||||
export { certApi } from './cert';
|
export { certApi } from './cert';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
|
|
||||||
// Services
|
// Domains
|
||||||
|
|
||||||
export interface HeaderConfig {
|
export interface HeaderConfig {
|
||||||
request?: Record<string, string>;
|
request?: Record<string, string>;
|
||||||
@@ -18,7 +18,7 @@ export interface Route {
|
|||||||
ipAllow?: string[];
|
ipAllow?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisteredService {
|
export interface RegisteredDomain {
|
||||||
domain: string;
|
domain: string;
|
||||||
source: string;
|
source: string;
|
||||||
subdomains: boolean;
|
subdomains: boolean;
|
||||||
@@ -32,15 +32,15 @@ export interface RegisteredService {
|
|||||||
routes?: Route[];
|
routes?: Route[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const servicesApi = {
|
export const domainsApi = {
|
||||||
async list(): Promise<{ services: RegisteredService[] }> {
|
async list(): Promise<{ domains: RegisteredDomain[] }> {
|
||||||
return apiClient.get('/api/v1/services');
|
return apiClient.get('/api/v1/domains');
|
||||||
},
|
},
|
||||||
async register(svc: { domain: string; backend: { address: string; type: string; health?: string }; public?: boolean; subdomains?: boolean; source?: string }): Promise<{ message: string; service: RegisteredService }> {
|
async register(dom: { domain: string; backend: { address: string; type: string; health?: string }; public?: boolean; subdomains?: boolean; source?: string; tls?: string }): Promise<{ message: string; domain: RegisteredDomain }> {
|
||||||
return apiClient.post('/api/v1/services', svc);
|
return apiClient.post('/api/v1/domains', dom);
|
||||||
},
|
},
|
||||||
async deregister(domain: string): Promise<{ message: string }> {
|
async deregister(domain: string): Promise<{ message: string }> {
|
||||||
return apiClient.delete(`/api/v1/services/${domain}`);
|
return apiClient.delete(`/api/v1/domains/${domain}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface Status {
|
|||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Global Config Types (Wild Central level)
|
// Global Config Types (Wild Central level)
|
||||||
// Endpoint: /api/v1/config
|
// Endpoint: /api/v1/state
|
||||||
// File: {dataDir}/state.yaml
|
// File: {dataDir}/state.yaml
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ export interface HAProxyCustomRoute {
|
|||||||
backend: string;
|
backend: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GlobalConfig {
|
export interface CentralState {
|
||||||
operator?: {
|
operator?: {
|
||||||
email?: string;
|
email?: string;
|
||||||
};
|
};
|
||||||
@@ -48,15 +48,14 @@ export interface GlobalConfig {
|
|||||||
ddns?: {
|
ddns?: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
records?: string[];
|
|
||||||
intervalMinutes?: number;
|
intervalMinutes?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GlobalConfigResponse {
|
export interface CentralStateResponse {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
config?: GlobalConfig;
|
state?: CentralState;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user