refactor: Replace 'reach' field with 'public' boolean in service registration and related components
This commit is contained in:
@@ -25,7 +25,7 @@ The domain is the unique key. One registration per domain.
|
||||
| `backend.type` | string | yes | — | `tcp-passthrough` or `http`. |
|
||||
| `backend.health` | string | no | — | Health check path for L7 services (e.g., `/health`). |
|
||||
| `subdomains` | bool | no | `false` | If true, also routes `*.domain` traffic to this backend. |
|
||||
| `reach` | string | yes | — | `internal` or `public`. |
|
||||
| `public` | bool | no | `false` | If true, domain gets public DNS + internet exposure. |
|
||||
| `tls` | string | no | inferred | `passthrough` or `terminate`. Defaults based on backend type. |
|
||||
|
||||
## User-facing concepts
|
||||
@@ -34,9 +34,9 @@ The API fields map to three user-visible controls:
|
||||
|
||||
### Public / Private
|
||||
|
||||
`reach: "public"` or `reach: "internal"`
|
||||
`public: true` or `public: false`
|
||||
|
||||
- **Private** (internal): Domain resolves only on the LAN. No public DNS record. Accessible only from your local network or VPN.
|
||||
- **Private** (default): Domain resolves only on the LAN. No public DNS record. Accessible only from your local network or VPN.
|
||||
- **Public**: Domain resolves on the LAN AND has a public DNS A record. Accessible from the internet through Central's HAProxy.
|
||||
|
||||
### Subdomains
|
||||
@@ -75,7 +75,7 @@ When a service is registered, Central automatically:
|
||||
"source": "wild-cloud",
|
||||
"backend": {"address": "192.168.8.240:443", "type": "tcp-passthrough"},
|
||||
"subdomains": true,
|
||||
"reach": "public"
|
||||
"public": true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -87,8 +87,7 @@ Central creates: LAN DNS → 192.168.8.240, public DNS A record, HAProxy L4 SNI
|
||||
{
|
||||
"domain": "wild-cloud.payne.io",
|
||||
"source": "wild-cloud",
|
||||
"backend": {"address": "127.0.0.1:5055", "type": "http"},
|
||||
"reach": "internal"
|
||||
"backend": {"address": "127.0.0.1:5055", "type": "http"}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -102,7 +101,7 @@ Central creates: LAN DNS → Central IP (with `local=/`), HAProxy L7 reverse pro
|
||||
"source": "wild-cloud",
|
||||
"backend": {"address": "192.168.8.240:443", "type": "tcp-passthrough"},
|
||||
"subdomains": false,
|
||||
"reach": "public"
|
||||
"public": true
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -221,8 +221,10 @@ func (api *API) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/v1/crowdsec/decisions/{id}", api.CrowdSecDeleteDecision).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/crowdsec/decisions/ip/{ip}", api.CrowdSecDeleteDecisionByIP).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/crowdsec/machines", api.CrowdSecGetMachines).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/machines", api.CrowdSecAddMachine).Methods("POST")
|
||||
r.HandleFunc("/api/v1/crowdsec/machines/{name}", api.CrowdSecDeleteMachine).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecGetBouncers).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecAddBouncer).Methods("POST")
|
||||
r.HandleFunc("/api/v1/crowdsec/bouncers/{name}", api.CrowdSecDeleteBouncer).Methods("DELETE")
|
||||
|
||||
// Cloudflare management
|
||||
|
||||
@@ -170,5 +170,46 @@ func (api *API) CrowdSecDeleteBouncer(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]string{"message": "Bouncer deleted"})
|
||||
}
|
||||
|
||||
// Note: CrowdSecProvision (instance-specific provisioning) has been removed.
|
||||
// Instance provisioning will be handled by the Wild Cloud API, not Wild Central.
|
||||
// CrowdSecAddMachine registers a new CrowdSec agent machine with pre-generated credentials.
|
||||
// Body: { "name": "...", "password": "..." }
|
||||
func (api *API) CrowdSecAddMachine(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Password == "" {
|
||||
respondError(w, http.StatusBadRequest, "name and password are required")
|
||||
return
|
||||
}
|
||||
if err := api.crowdsec.AddMachine(req.Name, req.Password); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add machine: %v", err))
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, map[string]string{"message": fmt.Sprintf("Machine %s registered", req.Name)})
|
||||
}
|
||||
|
||||
// CrowdSecAddBouncer registers a new bouncer with a pre-generated API key.
|
||||
// Body: { "name": "...", "apiKey": "..." }
|
||||
func (api *API) CrowdSecAddBouncer(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
APIKey string `json:"apiKey"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.APIKey == "" {
|
||||
respondError(w, http.StatusBadRequest, "name and apiKey are required")
|
||||
return
|
||||
}
|
||||
if err := api.crowdsec.AddBouncer(req.Name, req.APIKey); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add bouncer: %v", err))
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, map[string]string{"message": fmt.Sprintf("Bouncer %s registered", req.Name)})
|
||||
}
|
||||
|
||||
@@ -26,26 +26,26 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
||||
Source: "wild-cloud",
|
||||
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough},
|
||||
Subdomains: true,
|
||||
Reach: services.ReachPublic,
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
Domain: "payne.io",
|
||||
Source: "wild-cloud",
|
||||
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough},
|
||||
Subdomains: false,
|
||||
Reach: services.ReachPublic,
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
Domain: "wild-cloud.payne.io",
|
||||
Source: "wild-works",
|
||||
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP},
|
||||
Reach: services.ReachInternal,
|
||||
// Public defaults to false
|
||||
},
|
||||
{
|
||||
Domain: "my-api.payne.io",
|
||||
Source: "wild-works",
|
||||
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP, Health: "/health"},
|
||||
Reach: services.ReachInternal,
|
||||
// Public defaults to false
|
||||
},
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
||||
Domain: "central.payne.io",
|
||||
Source: "central",
|
||||
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP},
|
||||
Reach: services.ReachInternal,
|
||||
// Public defaults to false
|
||||
TLS: services.TLSTerminate,
|
||||
}); err != nil {
|
||||
t.Fatalf("Register central failed: %v", err)
|
||||
@@ -181,21 +181,21 @@ func TestReconciliation_DnsmasqConfigFromServices(t *testing.T) {
|
||||
Source: "wild-cloud",
|
||||
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough},
|
||||
Subdomains: true,
|
||||
Reach: services.ReachPublic,
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
// http, internal → DNS points to Central IP, has local=/
|
||||
Domain: "my-api.payne.io",
|
||||
Source: "wild-works",
|
||||
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP},
|
||||
Reach: services.ReachInternal,
|
||||
// Public defaults to false
|
||||
},
|
||||
{
|
||||
// http, public → DNS points to Central IP, NO local=/
|
||||
Domain: "public-app.payne.io",
|
||||
Source: "wild-works",
|
||||
Backend: services.Backend{Address: "192.168.8.60:8080", Type: services.BackendHTTP},
|
||||
Reach: services.ReachPublic,
|
||||
Public: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ func TestReconciliation_DnsmasqConfigFromServices(t *testing.T) {
|
||||
ic := config.InstanceConfig{}
|
||||
ic.Cluster.LoadBalancerIp = dnsIP
|
||||
|
||||
if svc.Reach == services.ReachInternal {
|
||||
if !svc.Public {
|
||||
ic.Cloud.InternalDomain = svc.Domain
|
||||
} else {
|
||||
ic.Cloud.Domain = svc.Domain
|
||||
@@ -277,7 +277,7 @@ func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
|
||||
Domain: "central.payne.io",
|
||||
Source: "central",
|
||||
Backend: services.Backend{Address: fmt.Sprintf("127.0.0.1:%d", centralPort), Type: services.BackendHTTP},
|
||||
Reach: services.ReachInternal,
|
||||
// Public defaults to false
|
||||
TLS: services.TLSTerminate,
|
||||
}); err != nil {
|
||||
t.Fatalf("Register central failed: %v", err)
|
||||
@@ -288,7 +288,7 @@ func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
|
||||
Domain: "my-app.payne.io",
|
||||
Source: "wild-works",
|
||||
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP},
|
||||
Reach: services.ReachInternal,
|
||||
// Public defaults to false
|
||||
}); err != nil {
|
||||
t.Fatalf("Register failed: %v", err)
|
||||
}
|
||||
@@ -337,7 +337,7 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
|
||||
Source: "wild-cloud",
|
||||
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough},
|
||||
Subdomains: true,
|
||||
Reach: services.ReachPublic,
|
||||
Public: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("Register failed: %v", err)
|
||||
}
|
||||
@@ -354,7 +354,7 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
|
||||
|
||||
ic := config.InstanceConfig{}
|
||||
ic.Cluster.LoadBalancerIp = dnsIP
|
||||
if svc.Reach == services.ReachInternal {
|
||||
if !svc.Public {
|
||||
ic.Cloud.InternalDomain = svc.Domain
|
||||
} else {
|
||||
ic.Cloud.Domain = svc.Domain
|
||||
|
||||
@@ -20,7 +20,6 @@ func TestServicesRegister(t *testing.T) {
|
||||
"address": "192.168.8.60:9001",
|
||||
"type": "http",
|
||||
},
|
||||
"reach": "internal",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body))
|
||||
@@ -57,7 +56,7 @@ func TestServicesRegister_TCPPassthrough(t *testing.T) {
|
||||
"type": "tcp-passthrough",
|
||||
},
|
||||
"subdomains": true,
|
||||
"reach": "public",
|
||||
"public": true,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body))
|
||||
@@ -107,7 +106,6 @@ func TestServicesGet(t *testing.T) {
|
||||
"domain": "get-test.example.com",
|
||||
"source": "test",
|
||||
"backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"},
|
||||
"reach": "internal",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -151,14 +149,13 @@ func TestServicesUpdate(t *testing.T) {
|
||||
"domain": "update-test.example.com",
|
||||
"source": "test",
|
||||
"backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"},
|
||||
"reach": "internal",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
api.ServicesRegister(w, req)
|
||||
|
||||
// Update reach to public
|
||||
update, _ := json.Marshal(map[string]any{"reach": "public"})
|
||||
// Update public to true
|
||||
update, _ := json.Marshal(map[string]any{"public": true})
|
||||
req = httptest.NewRequest("PATCH", "/api/v1/services/update-test.example.com", bytes.NewReader(update))
|
||||
req = mux.SetURLVars(req, map[string]string{"domain": "update-test.example.com"})
|
||||
w = httptest.NewRecorder()
|
||||
@@ -171,8 +168,8 @@ func TestServicesUpdate(t *testing.T) {
|
||||
var resp map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
svc := resp["service"].(map[string]any)
|
||||
if svc["reach"] != "public" {
|
||||
t.Errorf("expected reach=public, got %v", svc["reach"])
|
||||
if svc["public"] != true {
|
||||
t.Errorf("expected public=true, got %v", svc["public"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +181,6 @@ func TestServicesDeregister(t *testing.T) {
|
||||
"domain": "delete-me.example.com",
|
||||
"source": "test",
|
||||
"backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"},
|
||||
"reach": "internal",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -216,7 +212,6 @@ func TestServicesRegister_Validation(t *testing.T) {
|
||||
// Missing domain
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"backend": map[string]any{"address": "127.0.0.1:80", "type": "http"},
|
||||
"reach": "internal",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -52,7 +52,7 @@ func (api *API) EnsureCentralService() {
|
||||
Address: backend,
|
||||
Type: services.BackendHTTP,
|
||||
},
|
||||
Reach: services.ReachInternal,
|
||||
// Public defaults to false (private)
|
||||
TLS: services.TLSTerminate,
|
||||
})
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func (api *API) reconcileNetworking() {
|
||||
ic := config.InstanceConfig{}
|
||||
ic.Cluster.LoadBalancerIp = dnsIP
|
||||
|
||||
if svc.Reach == services.ReachInternal {
|
||||
if !svc.Public {
|
||||
// Internal-only: local=/ prevents upstream DNS forwarding
|
||||
ic.Cloud.InternalDomain = svc.Domain
|
||||
} else {
|
||||
|
||||
@@ -17,14 +17,6 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Reach describes how a service is exposed.
|
||||
type Reach string
|
||||
|
||||
const (
|
||||
ReachInternal Reach = "internal" // LAN-visible — DNS + proxy + TLS
|
||||
ReachPublic Reach = "public" // internet-visible — + DDNS + external HAProxy
|
||||
)
|
||||
|
||||
// BackendType describes how the gateway handles traffic for this service.
|
||||
type BackendType string
|
||||
|
||||
@@ -54,7 +46,7 @@ type Service struct {
|
||||
Source string `yaml:"source,omitempty" json:"source,omitempty"` // who registered: wild-cloud, wild-works, manual
|
||||
Backend Backend `yaml:"backend" json:"backend"` // where to route
|
||||
Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain
|
||||
Reach Reach `yaml:"reach" json:"reach"` // internal or public
|
||||
Public bool `yaml:"public,omitempty" json:"public,omitempty"` // true = internet-visible (DDNS + external), false = LAN only
|
||||
TLS TLSMode `yaml:"tls,omitempty" json:"tls,omitempty"` // passthrough or terminate
|
||||
}
|
||||
|
||||
@@ -106,9 +98,7 @@ func (m *Manager) Register(svc Service) error {
|
||||
if svc.Backend.Type == "" {
|
||||
return fmt.Errorf("backend type is required")
|
||||
}
|
||||
if svc.Reach == "" {
|
||||
return fmt.Errorf("reach is required")
|
||||
}
|
||||
// Public field is a bool — no validation needed (defaults to false)
|
||||
|
||||
// Default TLS mode based on backend type
|
||||
if svc.TLS == "" {
|
||||
@@ -133,7 +123,7 @@ func (m *Manager) Register(svc Service) error {
|
||||
return fmt.Errorf("writing service file: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("service registered", "domain", svc.Domain, "reach", svc.Reach, "source", svc.Source, "subdomains", svc.Subdomains)
|
||||
slog.Info("service registered", "domain", svc.Domain, "public", svc.Public, "source", svc.Source, "subdomains", svc.Subdomains)
|
||||
|
||||
if m.reconcileFn != nil {
|
||||
go m.reconcileFn()
|
||||
@@ -249,8 +239,8 @@ func (m *Manager) Update(domain string, updates map[string]any) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if reach, ok := updates["reach"].(string); ok {
|
||||
svc.Reach = Reach(reach)
|
||||
if public, ok := updates["public"].(bool); ok {
|
||||
svc.Public = public
|
||||
}
|
||||
if sub, ok := updates["subdomains"].(bool); ok {
|
||||
svc.Subdomains = sub
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestRegisterAndGet(t *testing.T) {
|
||||
Address: "192.168.8.60:9001",
|
||||
Type: BackendHTTP,
|
||||
},
|
||||
Reach: ReachInternal,
|
||||
// Public defaults to false
|
||||
}
|
||||
|
||||
if err := mgr.Register(svc); err != nil {
|
||||
@@ -53,7 +53,7 @@ func TestRegisterTCPPassthrough(t *testing.T) {
|
||||
Type: BackendTCPPassthrough,
|
||||
},
|
||||
Subdomains: true,
|
||||
Reach: ReachPublic,
|
||||
Public: true,
|
||||
}
|
||||
|
||||
if err := mgr.Register(svc); err != nil {
|
||||
@@ -71,8 +71,8 @@ func TestRegisterTCPPassthrough(t *testing.T) {
|
||||
if !got.Subdomains {
|
||||
t.Error("expected subdomains=true")
|
||||
}
|
||||
if got.Reach != ReachPublic {
|
||||
t.Errorf("expected reach public, got %s", got.Reach)
|
||||
if !got.Public {
|
||||
t.Error("expected public=true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestListServices(t *testing.T) {
|
||||
Domain: domain,
|
||||
Source: "test",
|
||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||
Reach: ReachInternal,
|
||||
// Public defaults to false
|
||||
}); err != nil {
|
||||
t.Fatalf("Register %s failed: %v", domain, err)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestDeregister(t *testing.T) {
|
||||
Domain: "temp.example.com",
|
||||
Source: "test",
|
||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||
Reach: ReachInternal,
|
||||
// Public defaults to false
|
||||
}); err != nil {
|
||||
t.Fatalf("Register failed: %v", err)
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func TestDeregisterBySource(t *testing.T) {
|
||||
Domain: domain,
|
||||
Source: "wild-cloud",
|
||||
Backend: Backend{Address: "192.168.8.240:443", Type: BackendTCPPassthrough},
|
||||
Reach: ReachPublic,
|
||||
Public: true,
|
||||
})
|
||||
}
|
||||
// Register 1 service from wild-works (different source)
|
||||
@@ -137,7 +137,7 @@ func TestDeregisterBySource(t *testing.T) {
|
||||
Domain: "my-api.payne.io",
|
||||
Source: "wild-works",
|
||||
Backend: Backend{Address: "127.0.0.1:9001", Type: BackendHTTP},
|
||||
Reach: ReachInternal,
|
||||
// Public defaults to false
|
||||
})
|
||||
|
||||
// Deregister all wild-cloud services with backend 192.168.8.240:443
|
||||
@@ -161,19 +161,19 @@ func TestUpdate(t *testing.T) {
|
||||
Domain: "updatable.example.com",
|
||||
Source: "test",
|
||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||
Reach: ReachInternal,
|
||||
// Public defaults to false
|
||||
})
|
||||
|
||||
if err := mgr.Update("updatable.example.com", map[string]any{
|
||||
"reach": "public",
|
||||
"public": true,
|
||||
"subdomains": true,
|
||||
}); err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
|
||||
got, _ := mgr.Get("updatable.example.com")
|
||||
if got.Reach != ReachPublic {
|
||||
t.Errorf("expected reach public after update, got %s", got.Reach)
|
||||
if !got.Public {
|
||||
t.Error("expected public=true after update")
|
||||
}
|
||||
if !got.Subdomains {
|
||||
t.Error("expected subdomains=true after update")
|
||||
@@ -184,24 +184,19 @@ func TestRegisterValidation(t *testing.T) {
|
||||
mgr := NewManager(t.TempDir())
|
||||
|
||||
// Missing domain
|
||||
if err := mgr.Register(Service{Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}, Reach: ReachInternal}); err == nil {
|
||||
if err := mgr.Register(Service{Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}}); err == nil {
|
||||
t.Error("expected error for missing domain")
|
||||
}
|
||||
|
||||
// Missing backend address
|
||||
if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Type: BackendHTTP}, Reach: ReachInternal}); err == nil {
|
||||
if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Type: BackendHTTP}}); err == nil {
|
||||
t.Error("expected error for missing backend address")
|
||||
}
|
||||
|
||||
// Missing backend type
|
||||
if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Address: "127.0.0.1:80"}, Reach: ReachInternal}); err == nil {
|
||||
if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Address: "127.0.0.1:80"}}); err == nil {
|
||||
t.Error("expected error for missing backend type")
|
||||
}
|
||||
|
||||
// Missing reach
|
||||
if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}}); err == nil {
|
||||
t.Error("expected error for missing reach")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSource(t *testing.T) {
|
||||
@@ -210,7 +205,7 @@ func TestDefaultSource(t *testing.T) {
|
||||
mgr.Register(Service{
|
||||
Domain: "test.com",
|
||||
Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP},
|
||||
Reach: ReachInternal,
|
||||
// Public defaults to false
|
||||
})
|
||||
|
||||
got, _ := mgr.Get("test.com")
|
||||
@@ -226,7 +221,7 @@ func TestRegisterIdempotent(t *testing.T) {
|
||||
Domain: "idempotent.example.com",
|
||||
Source: "test",
|
||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||
Reach: ReachInternal,
|
||||
// Public defaults to false
|
||||
}
|
||||
|
||||
// Register twice with same domain
|
||||
@@ -274,7 +269,7 @@ func TestListNoDuplicates(t *testing.T) {
|
||||
Domain: domain,
|
||||
Source: "test",
|
||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||
Reach: ReachInternal,
|
||||
// Public defaults to false
|
||||
}); err != nil {
|
||||
t.Fatalf("Register %s failed: %v", domain, err)
|
||||
}
|
||||
@@ -285,7 +280,7 @@ func TestListNoDuplicates(t *testing.T) {
|
||||
Domain: "beta.example.com",
|
||||
Source: "test",
|
||||
Backend: Backend{Address: "127.0.0.1:9090", Type: BackendHTTP},
|
||||
Reach: ReachInternal,
|
||||
// Public defaults to false
|
||||
}); err != nil {
|
||||
t.Fatalf("Re-register beta failed: %v", err)
|
||||
}
|
||||
@@ -353,13 +348,13 @@ func TestDeregisterBySource_NoMatch(t *testing.T) {
|
||||
Domain: "a.example.com",
|
||||
Source: "wild-cloud",
|
||||
Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough},
|
||||
Reach: ReachPublic,
|
||||
Public: true,
|
||||
})
|
||||
mgr.Register(Service{
|
||||
Domain: "b.example.com",
|
||||
Source: "wild-works",
|
||||
Backend: Backend{Address: "10.0.0.2:8080", Type: BackendHTTP},
|
||||
Reach: ReachInternal,
|
||||
// Public defaults to false
|
||||
})
|
||||
|
||||
// Deregister with a source that doesn't match anything
|
||||
@@ -383,13 +378,13 @@ func TestDeregisterBySource_PartialMatch(t *testing.T) {
|
||||
Domain: "a.example.com",
|
||||
Source: "wild-cloud",
|
||||
Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough},
|
||||
Reach: ReachPublic,
|
||||
Public: true,
|
||||
})
|
||||
mgr.Register(Service{
|
||||
Domain: "b.example.com",
|
||||
Source: "wild-cloud",
|
||||
Backend: Backend{Address: "10.0.0.2:443", Type: BackendTCPPassthrough},
|
||||
Reach: ReachPublic,
|
||||
Public: true,
|
||||
})
|
||||
|
||||
// Deregister only the ones matching source AND backend
|
||||
@@ -411,7 +406,7 @@ func TestUpdate_NotFound(t *testing.T) {
|
||||
mgr := NewManager(t.TempDir())
|
||||
|
||||
err := mgr.Update("nonexistent.example.com", map[string]any{
|
||||
"reach": "public",
|
||||
"public": true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error when updating non-existent domain, got nil")
|
||||
@@ -426,7 +421,7 @@ func TestRegister_OverwritePreservesFile(t *testing.T) {
|
||||
Domain: "overwrite.example.com",
|
||||
Source: "test",
|
||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||
Reach: ReachInternal,
|
||||
// Public defaults to false
|
||||
}
|
||||
|
||||
// Register first time
|
||||
@@ -477,7 +472,7 @@ func TestList_IgnoresNonYAML(t *testing.T) {
|
||||
Domain: "real.example.com",
|
||||
Source: "test",
|
||||
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
|
||||
Reach: ReachInternal,
|
||||
// Public defaults to false
|
||||
})
|
||||
|
||||
// Create non-YAML files in the services directory
|
||||
|
||||
@@ -143,22 +143,8 @@ export function CrowdSecComponent() {
|
||||
deleteDecision,
|
||||
deleteBouncer,
|
||||
deleteMachine,
|
||||
provision,
|
||||
isProvisioning,
|
||||
} = useCrowdSec();
|
||||
|
||||
// Derive instance names from CrowdSec machines/bouncers with "wc-" prefix convention
|
||||
const instances = [...new Set([
|
||||
...(status?.machines ?? [])
|
||||
.filter(m => m.machineId.startsWith('wc-'))
|
||||
.map(m => m.machineId.replace(/^wc-/, '')),
|
||||
...(status?.bouncers ?? [])
|
||||
.filter(b => b.name.startsWith('wc-bouncer-'))
|
||||
.map(b => b.name.replace(/^wc-bouncer-/, '').split('@')[0]),
|
||||
])].sort();
|
||||
|
||||
const [provisionResults, setProvisionResults] = useState<Record<string, { ok: boolean; message: string }>>({});
|
||||
const [provisioningInstance, setProvisioningInstance] = useState<string | null>(null);
|
||||
const [blockSuccess, setBlockSuccess] = useState<string | null>(null);
|
||||
const [timescale, setTimescale] = useState<Timescale>('24h');
|
||||
|
||||
@@ -167,20 +153,6 @@ export function CrowdSecComponent() {
|
||||
});
|
||||
const selectedType = watch('type');
|
||||
|
||||
function handleProvision(instanceName: string) {
|
||||
setProvisioningInstance(instanceName);
|
||||
provision(instanceName, {
|
||||
onSuccess: (data) => {
|
||||
setProvisionResults((prev) => ({ ...prev, [instanceName]: { ok: true, message: data.message } }));
|
||||
setProvisioningInstance(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setProvisionResults((prev) => ({ ...prev, [instanceName]: { ok: false, message: (err as Error).message } }));
|
||||
setProvisioningInstance(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onBlockSubmit(values: BlockFormValues) {
|
||||
setBlockSuccess(null);
|
||||
const req: AddDecisionRequest = { ip: values.ip, type: values.type, reason: values.reason, duration: values.duration };
|
||||
@@ -707,88 +679,6 @@ export function CrowdSecComponent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Provision Instances */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle>Provision Instances</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<strong>Provisioning</strong> connects a Wild Cloud instance's CrowdSec agent to this LAPI —
|
||||
generating credentials, registering agent and bouncer, and writing the LAPI URL into the
|
||||
instance's app config. After provisioning, redeploy the CrowdSec app on the instance.
|
||||
Safe to re-run.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{instances.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No instances found.</p>
|
||||
) : (
|
||||
instances.map((instanceName) => {
|
||||
const agentName = `wc-${instanceName}`;
|
||||
const bouncerName = `wc-bouncer-${instanceName}`;
|
||||
const agent = status.machines.find((m) => m.machineId === agentName);
|
||||
const bouncer = status.bouncers.find((b) => b.name === bouncerName);
|
||||
const isActive = provisioningInstance === instanceName;
|
||||
const result = provisionResults[instanceName];
|
||||
|
||||
return (
|
||||
<div key={instanceName} className="rounded border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm font-medium">{instanceName}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={agent ? 'outline' : 'default'}
|
||||
onClick={() => handleProvision(instanceName)}
|
||||
disabled={isProvisioning || isActive}
|
||||
className="gap-2 h-7"
|
||||
>
|
||||
{isActive
|
||||
? <><Loader2 className="h-3 w-3 animate-spin" />Provisioning…</>
|
||||
: agent
|
||||
? <><Zap className="h-3 w-3" />Re-provision</>
|
||||
: <><Zap className="h-3 w-3" />Provision</>
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">Agent:</span>
|
||||
{agent ? (
|
||||
<Badge variant={agent.isValidated ? 'default' : 'secondary'} className="text-xs h-4">
|
||||
{agent.isValidated ? 'validated' : 'pending'}
|
||||
{agent.last_heartbeat && ` · ${formatRelativeTime(agent.last_heartbeat)}`}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">not provisioned</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">Bouncer:</span>
|
||||
{bouncer ? (
|
||||
<Badge variant={bouncer.revoked ? 'secondary' : 'default'} className="text-xs h-4">
|
||||
{bouncer.revoked ? 'revoked' : 'active'}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">not provisioned</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{result && (
|
||||
<Alert variant={result.ok ? 'default' : 'error'} className="py-2">
|
||||
{result.ok ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
|
||||
<AlertDescription className="text-xs">{result.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@ export function ServicesComponent() {
|
||||
// Add form state
|
||||
const [newDomain, setNewDomain] = useState('');
|
||||
const [newBackendAddr, setNewBackendAddr] = useState('');
|
||||
const [newReach, setNewReach] = useState<string>('public');
|
||||
const [newPublic, setNewPublic] = useState(true);
|
||||
const [newSubdomains, setNewSubdomains] = useState(false);
|
||||
const [newTls, setNewTls] = useState<string>('passthrough');
|
||||
|
||||
@@ -68,7 +68,7 @@ export function ServicesComponent() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['services'] });
|
||||
setShowAddForm(false);
|
||||
setNewDomain(''); setNewBackendAddr(''); setNewReach('public'); setNewSubdomains(false); setNewTls('passthrough');
|
||||
setNewDomain(''); setNewBackendAddr(''); setNewPublic(true); setNewSubdomains(false); setNewTls('passthrough');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -147,7 +147,7 @@ export function ServicesComponent() {
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={newReach === 'public'} onCheckedChange={v => setNewReach(v ? 'public' : 'internal')} />
|
||||
<Switch checked={newPublic} onCheckedChange={setNewPublic} />
|
||||
Public
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
@@ -165,7 +165,7 @@ export function ServicesComponent() {
|
||||
onClick={() => registerMutation.mutate({
|
||||
domain: newDomain,
|
||||
backend: { address: newBackendAddr, type: newTls === 'passthrough' ? 'tcp-passthrough' : 'http' },
|
||||
reach: newReach, subdomains: newSubdomains, tls: newTls, source: 'manual',
|
||||
public: newPublic, subdomains: newSubdomains, tls: newTls, source: 'manual',
|
||||
})}>
|
||||
{registerMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Plus className="h-3 w-3 mr-1" />}
|
||||
Register
|
||||
@@ -196,7 +196,7 @@ export function ServicesComponent() {
|
||||
{services.map(service => {
|
||||
const isExpanded = expandedDomains.has(service.domain);
|
||||
const tlsStatus = getTlsStatus(service, certDomains);
|
||||
const ddnsStatus2 = service.reach === 'public' ? 'ok' as const : 'na' as const;
|
||||
const ddnsStatus2 = service.public ? 'ok' as const : 'na' as const;
|
||||
const isPassthrough = service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough';
|
||||
|
||||
return (
|
||||
@@ -242,11 +242,11 @@ export function ServicesComponent() {
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={service.reach === 'public'}
|
||||
onCheckedChange={v => updateMutation.mutate({ domain: service.domain, updates: { reach: v ? 'public' : 'internal' } })}
|
||||
checked={service.public}
|
||||
onCheckedChange={v => updateMutation.mutate({ domain: service.domain, updates: { public: v } })}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
<Label className="text-sm">{service.reach === 'public' ? 'Public' : 'Private'}</Label>
|
||||
<Label className="text-sm">{service.public ? 'Public' : 'Private'}</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -81,13 +81,6 @@ export const useCrowdSec = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const provisionMutation = useMutation({
|
||||
mutationFn: (instanceName: string) => crowdSecApi.provision(instanceName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['crowdsec', 'status'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: statusQuery.data,
|
||||
isLoadingStatus: statusQuery.isLoading,
|
||||
@@ -121,8 +114,5 @@ export const useCrowdSec = () => {
|
||||
|
||||
deleteMachine: deleteMachineMutation.mutate,
|
||||
isDeletingMachine: deleteMachineMutation.isPending,
|
||||
|
||||
provision: provisionMutation.mutate,
|
||||
isProvisioning: provisionMutation.isPending,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface RegisteredService {
|
||||
type: string;
|
||||
health?: string;
|
||||
};
|
||||
reach: string;
|
||||
public: boolean;
|
||||
tls?: string;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const servicesApi = {
|
||||
async list(): Promise<{ services: RegisteredService[] }> {
|
||||
return apiClient.get('/api/v1/services');
|
||||
},
|
||||
async register(svc: { domain: string; backend: { address: string; type: string; health?: string }; reach: string; subdomains?: boolean; source?: string }): Promise<{ message: string; service: RegisteredService }> {
|
||||
async register(svc: { domain: string; backend: { address: string; type: string; health?: string }; public?: boolean; subdomains?: boolean; source?: string }): Promise<{ message: string; service: RegisteredService }> {
|
||||
return apiClient.post('/api/v1/services', svc);
|
||||
},
|
||||
async deregister(domain: string): Promise<{ message: string }> {
|
||||
@@ -225,12 +225,6 @@ export interface CrowdSecStatus {
|
||||
bouncers: CrowdSecBouncer[];
|
||||
}
|
||||
|
||||
export interface CrowdSecProvisionResult {
|
||||
message: string;
|
||||
centralLapiUrl: string;
|
||||
agentUsername: string;
|
||||
}
|
||||
|
||||
export const crowdSecApi = {
|
||||
async getStatus(): Promise<CrowdSecStatus> {
|
||||
return apiClient.get('/api/v1/crowdsec/status');
|
||||
@@ -275,10 +269,6 @@ export const crowdSecApi = {
|
||||
async deleteDecisionByIP(ip: string): Promise<{ message: string }> {
|
||||
return apiClient.delete(`/api/v1/crowdsec/decisions/ip/${encodeURIComponent(ip)}`);
|
||||
},
|
||||
|
||||
async provision(instanceName: string): Promise<CrowdSecProvisionResult> {
|
||||
return apiClient.post(`/api/v1/crowdsec/provision/${encodeURIComponent(instanceName)}`);
|
||||
},
|
||||
};
|
||||
|
||||
// Global Secrets
|
||||
|
||||
Reference in New Issue
Block a user