Compare commits

..

10 Commits

Author SHA1 Message Date
Paul Payne
d35d0854cb fix: Move VPN masquerade rules from iptables PostUp to nftables
iptables-nft rules added by WireGuard's PostUp were being flushed by
Docker on startup, breaking VPN LAN routing until the next reboot.
The nftables inet wild-central table survives Docker restarts.
2026-10-23 13:32:41 +00:00
Paul Payne
1176c7715a chore: Remove TODO.md (items tracked elsewhere) 2026-10-15 07:24:01 +00:00
Paul Payne
17748ce09c feat: Auto-renew TLS certs expiring within 30 days
ensureTLSCerts now checks certificate expiry in addition to existence.
Certs that expire within 30 days are scheduled for renewal alongside
missing certs.
2026-10-15 07:23:55 +00:00
Paul Payne
b45c0d6a81 fix: Set nftables input chain priority to -1 for CrowdSec compatibility
CrowdSec's blacklist chain runs at priority 0. Our loopback accept rule
needs to run first so local traffic isn't blocked. Also cleans up .bak
files after successful SafeApply.
2026-10-15 07:23:48 +00:00
Paul Payne
c87125d003 fix: Clean up .bak files after SafeApply to prevent dnsmasq loading stale configs
dnsmasq loads every file in /etc/dnsmasq.d/ (skipping only .dpkg-*).
SafeApply was leaving .bak backup files after successful applies, causing
duplicate/conflicting DNS records. Also moves dnsmasq's validation temp
file to /tmp so a crash can't leave a .validate file in the conf dir.
2026-10-15 07:23:40 +00:00
Paul Payne
d5457ae2dc fix: Normalize wildcard domain paths for certbot cert storage
Certbot stores wildcard certs under the base domain (e.g.,
*.example.com → /etc/letsencrypt/live/example.com/), but the path
helpers were using the raw wildcard domain. This caused deploy hooks
to reference nonexistent paths, silently failing and leaving certs
unrenewable.
2026-09-10 22:23:05 +00:00
Paul Payne
a90af10932 fix: Handle dnsmasq local=/ directive in DNS filter parser
The parser only recognized address=/ and server=/ prefixes but not
local=/, which is the format used by Hagezi blocklists. This caused
~270k domains to be stored with the raw "local=/domain/" string as
the domain name, breaking allow-list matching.
2026-08-19 23:11:50 +00:00
Paul Payne
03ce1f3c42 feat: Add Integrations page with API token management
New Integrations page in Wild Central UI for managing API tokens.
Provides token display, copy, and rotation so Wild Cloud and other
services can easily retrieve their authentication credentials.

- GET /api/v1/integrations returns current API token
- POST /api/v1/integrations/api-token/rotate generates a new token
- Frontend with show/hide toggle, copy button, and usage instructions
- Added to sidebar navigation under Central section
2026-08-01 23:25:42 +00:00
Paul Payne
21b963b8ec Implement EnsureCNAME function and tests for wildcard CNAME creation in Cloudflare 2026-08-01 20:30:07 +00:00
Paul Payne
0a50e2d7f0 Update favicon color and corner radius in SVG 2026-08-01 20:29:45 +00:00
23 changed files with 615 additions and 77 deletions

22
TODO.md
View File

@@ -1,22 +0,0 @@
# TODO
## Response headers don't work with host ACL conditions
`http-response set-header ... if host_X` doesn't work because `hdr(host)` is a request-phase fetch that HAProxy can't evaluate in the response phase. The headers are generated but silently ignored (HAProxy logs a WARNING).
Fix: capture the host in a transaction variable during the request phase, then use it in response rules:
```haproxy
http-request set-var(txn.host) hdr(host)
acl is_keila var(txn.host) -i keila.cloud.payne.io
http-response set-header Access-Control-Allow-Origin "https://cloud.payne.io" if is_keila
```
Affects: keila (CORS headers), plausible (Private-Network-Access header).
## Input validation for header values
Header values with special characters (newlines, NULs) should be rejected at registration time. Currently values are Go-`%q`-quoted in the HAProxy output which handles spaces/commas but may produce backslash escapes that HAProxy doesn't understand for edge cases.
## APT package: Authelia bare-metal installation
When building the wild-central apt package in `dist/`, include Authelia as a dependency or document its installation. Bare-metal installation instructions: https://www.authelia.com/integration/deployment/bare-metal/

View File

@@ -278,6 +278,10 @@ func (api *API) RegisterRoutes(r *mux.Router, bearerToken string, devMode bool)
r.HandleFunc("/api/v1/secrets", api.GetGlobalSecrets).Methods("GET") r.HandleFunc("/api/v1/secrets", api.GetGlobalSecrets).Methods("GET")
r.HandleFunc("/api/v1/secrets", api.UpdateGlobalSecrets).Methods("PUT") r.HandleFunc("/api/v1/secrets", api.UpdateGlobalSecrets).Methods("PUT")
// Integrations (API token management)
r.HandleFunc("/api/v1/integrations", api.GetIntegrations).Methods("GET")
r.HandleFunc("/api/v1/integrations/api-token/rotate", api.RotateAPIToken).Methods("POST")
// Daemon control // Daemon control
r.HandleFunc("/api/v1/daemon/restart", api.DaemonRestart).Methods("POST") r.HandleFunc("/api/v1/daemon/restart", api.DaemonRestart).Methods("POST")

View File

@@ -0,0 +1,44 @@
package v1
import (
"log/slog"
"net/http"
"github.com/wild-cloud/wild-central/internal/secrets"
)
// GetIntegrations returns integration info including the API bearer token.
func (api *API) GetIntegrations(w http.ResponseWriter, r *http.Request) {
token, err := api.secrets.GetSecret("api.bearerToken")
if err != nil {
token = ""
}
respondJSON(w, http.StatusOK, map[string]any{
"apiToken": map[string]any{
"token": token,
"description": "Bearer token for authenticating with the Wild Central API. Used by Wild Cloud and other services.",
},
})
}
// RotateAPIToken generates a new API bearer token and saves it to secrets.
func (api *API) RotateAPIToken(w http.ResponseWriter, r *http.Request) {
token, err := secrets.GenerateSecret(secrets.DefaultSecretLength)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to generate token")
return
}
if err := api.secrets.SetSecret("api.bearerToken", token); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save token")
return
}
slog.Info("API bearer token rotated")
respondJSON(w, http.StatusOK, map[string]any{
"token": token,
"message": "API token rotated. Services using the old token will need to be updated.",
})
}

View File

@@ -50,6 +50,20 @@ func (api *API) vpnAutoUDPPorts() []int {
return []int{cfg.ListenPort} return []int{cfg.ListenPort}
} }
// vpnCIDR returns the VPN subnet CIDR (e.g. "10.8.0.0/24") when VPN is enabled
// with LAN routing configured. Used to generate nftables masquerade rules.
func (api *API) vpnCIDR() string {
cfg, err := api.vpn.GetConfig()
if err != nil || !cfg.Enabled || cfg.LanCIDR == "" {
return ""
}
_, vpnNet, err := net.ParseCIDR(cfg.Address)
if err != nil {
return ""
}
return vpnNet.String()
}
// 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.
@@ -76,7 +90,8 @@ func (api *API) syncNftablesOnly(globalCfg *config.State) {
extraTCP, extraUDP := config.SplitExtraPorts(nftCfg.ExtraPorts) extraTCP, extraUDP := config.SplitExtraPorts(nftCfg.ExtraPorts)
extraUDP = append(extraUDP, api.vpnAutoUDPPorts()...) extraUDP = append(extraUDP, api.vpnAutoUDPPorts()...)
ports := api.haproxy.GetListenPorts(customRoutes) ports := api.haproxy.GetListenPorts(customRoutes)
nftContent := api.nftables.Generate(ports, extraTCP, extraUDP, nftCfg.WANInterface) vpnCIDR := api.vpnCIDR()
nftContent := api.nftables.Generate(ports, extraTCP, extraUDP, nftCfg.WANInterface, vpnCIDR)
if err := api.nftables.WriteRules(nftContent); err != nil { if err := api.nftables.WriteRules(nftContent); err != nil {
slog.Error("failed to write nftables rules", "component", "nftables-sync", "error", err) slog.Error("failed to write nftables rules", "component", "nftables-sync", "error", err)
return return

View File

@@ -164,19 +164,26 @@ func parseCertOutput(output string, status *CertStatus) {
} }
} }
// certName returns the name certbot uses to store a certificate.
// Certbot strips the "*." prefix from wildcard domains, so
// *.example.com is stored under "example.com".
func certName(domain string) string {
return strings.TrimPrefix(domain, "*.")
}
// CertPath returns the fullchain.pem path for a domain. // CertPath returns the fullchain.pem path for a domain.
func CertPath(domain string) string { func CertPath(domain string) string {
return fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", domain) return fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", certName(domain))
} }
// KeyPath returns the privkey.pem path for a domain. // KeyPath returns the privkey.pem path for a domain.
func KeyPath(domain string) string { func KeyPath(domain string) string {
return fmt.Sprintf("/etc/letsencrypt/live/%s/privkey.pem", domain) return fmt.Sprintf("/etc/letsencrypt/live/%s/privkey.pem", certName(domain))
} }
// HAProxyCertPath returns the combined PEM path for HAProxy TLS termination. // HAProxyCertPath returns the combined PEM path for HAProxy TLS termination.
func HAProxyCertPath(domain string) string { func HAProxyCertPath(domain string) string {
return fmt.Sprintf("/etc/haproxy/certs/%s.pem", domain) return fmt.Sprintf("/etc/haproxy/certs/%s.pem", certName(domain))
} }
// BuildHAProxyCert concatenates fullchain.pem + privkey.pem into a single PEM // BuildHAProxyCert concatenates fullchain.pem + privkey.pem into a single PEM

View File

@@ -22,7 +22,7 @@ func TestHAProxyCertPath(t *testing.T) {
want string want string
}{ }{
{"example.com", "/etc/haproxy/certs/example.com.pem"}, {"example.com", "/etc/haproxy/certs/example.com.pem"},
{"*.example.com", "/etc/haproxy/certs/*.example.com.pem"}, {"*.example.com", "/etc/haproxy/certs/example.com.pem"},
{"sub.example.com", "/etc/haproxy/certs/sub.example.com.pem"}, {"sub.example.com", "/etc/haproxy/certs/sub.example.com.pem"},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -34,15 +34,31 @@ func TestHAProxyCertPath(t *testing.T) {
} }
func TestCertPaths(t *testing.T) { func TestCertPaths(t *testing.T) {
domain := "example.com" tests := []struct {
certPath := CertPath(domain) domain string
keyPath := KeyPath(domain) wantCert string
wantKey string
if certPath != "/etc/letsencrypt/live/example.com/fullchain.pem" { }{
t.Errorf("CertPath = %q", certPath) {
"example.com",
"/etc/letsencrypt/live/example.com/fullchain.pem",
"/etc/letsencrypt/live/example.com/privkey.pem",
},
{
"*.example.com",
"/etc/letsencrypt/live/example.com/fullchain.pem",
"/etc/letsencrypt/live/example.com/privkey.pem",
},
} }
if keyPath != "/etc/letsencrypt/live/example.com/privkey.pem" { for _, tt := range tests {
t.Errorf("KeyPath = %q", keyPath) certPath := CertPath(tt.domain)
keyPath := KeyPath(tt.domain)
if certPath != tt.wantCert {
t.Errorf("CertPath(%q) = %q, want %q", tt.domain, certPath, tt.wantCert)
}
if keyPath != tt.wantKey {
t.Errorf("KeyPath(%q) = %q, want %q", tt.domain, keyPath, tt.wantKey)
}
} }
} }

View File

@@ -388,3 +388,63 @@ func (c *cfClient) deleteRecord(zoneID, recordID string) error {
resp.Body.Close() resp.Body.Close()
return nil return nil
} }
// EnsureCNAME creates a CNAME record (name → target) in Cloudflare if one
// doesn't already exist with the correct target. Used by the reconciler to
// set up wildcard CNAMEs (e.g., *.cloud.payne.io → cloud.payne.io) for
// public domains with subdomains enabled.
func EnsureCNAME(apiToken, name, target string) error {
cf := &cfClient{apiToken: apiToken}
parts := strings.Split(name, ".")
// Strip leading wildcard for zone extraction: *.cloud.payne.io → payne.io
nonWild := parts
if parts[0] == "*" {
nonWild = parts[1:]
}
if len(nonWild) < 2 {
return fmt.Errorf("invalid CNAME name: %s", name)
}
zoneName := strings.Join(nonWild[len(nonWild)-2:], ".")
zoneID, err := cf.getZoneID(zoneName)
if err != nil {
return fmt.Errorf("getting zone ID for %s: %w", zoneName, err)
}
// Check for existing CNAME — skip if already correct
if recordID, content, err := cf.getRecordID(zoneID, name, "CNAME"); err == nil {
if content == target {
return nil // already up to date
}
// Wrong target — update it
return cf.patchCNAME(zoneID, recordID, name, target)
}
slog.Info("creating wildcard CNAME", "component", "ddns", "name", name, "target", target)
return cf.createCNAME(zoneID, name, target)
}
func (c *cfClient) createCNAME(zoneID, name, target string) error {
body, _ := json.Marshal(map[string]string{"type": "CNAME", "name": name, "content": target})
resp, err := c.do(http.MethodPost,
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID),
bytes.NewReader(body))
if err != nil {
return fmt.Errorf("creating CNAME: %w", err)
}
resp.Body.Close()
return nil
}
func (c *cfClient) patchCNAME(zoneID, recordID, name, target string) error {
body, _ := json.Marshal(map[string]string{"type": "CNAME", "name": name, "content": target})
resp, err := c.do(http.MethodPatch,
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID),
bytes.NewReader(body))
if err != nil {
return fmt.Errorf("updating CNAME: %w", err)
}
resp.Body.Close()
return nil
}

View File

@@ -226,3 +226,15 @@ func TestGetStatus_ReflectsManualUpdate(t *testing.T) {
t.Error("expected Enabled=true") t.Error("expected Enabled=true")
} }
} }
func TestEnsureCNAME_InvalidName(t *testing.T) {
// Single-label name should fail before any API call
if err := EnsureCNAME("fake-token", "localhost", "target"); err == nil {
t.Error("expected error for single-label CNAME name")
}
// Wildcard with single-label domain should also fail
if err := EnsureCNAME("fake-token", "*.localhost", "target"); err == nil {
t.Error("expected error for wildcard with single-label domain")
}
}

View File

@@ -16,8 +16,8 @@ func ParseLine(line string) (string, bool) {
return "", false return "", false
} }
// dnsmasq format: address=/domain/0.0.0.0 or server=/domain/ // dnsmasq format: address=/domain/... or server=/domain/ or local=/domain/
if strings.HasPrefix(line, "address=/") || strings.HasPrefix(line, "server=/") { if strings.HasPrefix(line, "address=/") || strings.HasPrefix(line, "server=/") || strings.HasPrefix(line, "local=/") {
parts := strings.SplitN(line, "/", 4) parts := strings.SplitN(line, "/", 4)
if len(parts) >= 3 && parts[1] != "" { if len(parts) >= 3 && parts[1] != "" {
return validateDomain(parts[1]) return validateDomain(parts[1])

View File

@@ -39,6 +39,7 @@ func TestParseLine_DnsmasqFormat(t *testing.T) {
{"address=/ads.example.com/0.0.0.0", "ads.example.com", true}, {"address=/ads.example.com/0.0.0.0", "ads.example.com", true},
{"address=/tracker.example.com/", "tracker.example.com", true}, {"address=/tracker.example.com/", "tracker.example.com", true},
{"server=/blocked.example.com/", "blocked.example.com", true}, {"server=/blocked.example.com/", "blocked.example.com", true},
{"local=/blocked.example.com/", "blocked.example.com", true},
{"address=//", "", false}, {"address=//", "", false},
} }

View File

@@ -303,8 +303,8 @@ log-dhcp
// SafeApply validates, backs up, writes, restarts, and verifies the dnsmasq config. // SafeApply validates, backs up, writes, restarts, and verifies the dnsmasq config.
// On restart or verification failure, rolls back to the previous config. // On restart or verification failure, rolls back to the previous config.
func (g *Manager) SafeApply(content string) error { func (g *Manager) SafeApply(content string) error {
// Validate via temp file // Validate via temp file (use /tmp to avoid stray files in dnsmasq.d if process crashes)
tmpFile := g.configPath + ".validate" tmpFile := "/tmp/wild-central-dnsmasq-validate.tmp"
if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
return fmt.Errorf("writing validation file: %w", err) return fmt.Errorf("writing validation file: %w", err)
} }
@@ -336,6 +336,10 @@ func (g *Manager) SafeApply(content string) error {
return fmt.Errorf("health check failed (rolled back): %w", err) return fmt.Errorf("health check failed (rolled back): %w", err)
} }
// Clean up backup — dnsmasq loads every file in its conf dir,
// so stale .bak files cause duplicate/conflicting records.
os.Remove(g.configPath + ".bak")
return nil return nil
} }

View File

@@ -576,6 +576,8 @@ func (m *Manager) SafeApply(content string) error {
return fmt.Errorf("health check failed (rolled back): %w", err) return fmt.Errorf("health check failed (rolled back): %w", err)
} }
os.Remove(m.configPath + ".bak")
return nil return nil
} }

View File

@@ -35,7 +35,8 @@ func (m *Manager) GetRulesPath() string {
// Policy is always "accept" — the allowed_tcp_ports set documents what is in use // Policy is always "accept" — the allowed_tcp_ports set documents what is in use
// and enables strict WAN filtering when wanInterface is configured. // and enables strict WAN filtering when wanInterface is configured.
// extraTCPPorts and extraUDPPorts are user-specified ports to allow beyond the HAProxy set. // extraTCPPorts and extraUDPPorts are user-specified ports to allow beyond the HAProxy set.
func (m *Manager) Generate(haproxyPorts []int, extraTCPPorts []int, extraUDPPorts []int, wanInterface string) string { // vpnCIDR, when non-empty (e.g. "10.8.0.0/24"), adds forward and masquerade rules for VPN traffic.
func (m *Manager) Generate(haproxyPorts []int, extraTCPPorts []int, extraUDPPorts []int, wanInterface string, vpnCIDR string) string {
// Merge and deduplicate TCP ports (HAProxy + extra TCP) // Merge and deduplicate TCP ports (HAProxy + extra TCP)
seen := make(map[int]bool) seen := make(map[int]bool)
var tcpPorts []int var tcpPorts []int
@@ -96,7 +97,8 @@ func (m *Manager) Generate(haproxyPorts []int, extraTCPPorts []int, extraUDPPort
sb.WriteString(" }\n\n") sb.WriteString(" }\n\n")
sb.WriteString(" chain input {\n") sb.WriteString(" chain input {\n")
sb.WriteString(" type filter hook input priority filter; policy accept;\n\n") sb.WriteString(" # Priority -1 so loopback accept runs before CrowdSec's blacklist chain (priority 0)\n")
sb.WriteString(" type filter hook input priority -1; policy accept;\n\n")
sb.WriteString(" iif \"lo\" accept\n") sb.WriteString(" iif \"lo\" accept\n")
sb.WriteString(" ct state { established, related } accept\n") sb.WriteString(" ct state { established, related } accept\n")
@@ -107,6 +109,23 @@ func (m *Manager) Generate(haproxyPorts []int, extraTCPPorts []int, extraUDPPort
} }
sb.WriteString(" }\n") sb.WriteString(" }\n")
// VPN forwarding and masquerade: when vpnCIDR is set, allow forwarding between
// wg0 and LAN, and masquerade VPN traffic leaving the VPN subnet.
// These rules live in the nftables table (not iptables PostUp) so they survive
// Docker restarts, which flush the iptables-nft compatibility tables.
if vpnCIDR != "" {
sb.WriteString("\n chain vpn-forward {\n")
sb.WriteString(" type filter hook forward priority filter; policy accept;\n")
sb.WriteString(" iif \"wg0\" accept\n")
sb.WriteString(" oif \"wg0\" ct state { established, related } accept\n")
sb.WriteString(" }\n")
sb.WriteString("\n chain vpn-postrouting {\n")
sb.WriteString(" type nat hook postrouting priority srcnat; policy accept;\n")
fmt.Fprintf(&sb, " ip saddr %s ip daddr != %s masquerade\n", vpnCIDR, vpnCIDR)
sb.WriteString(" }\n")
}
sb.WriteString("}\n") sb.WriteString("}\n")
return sb.String() return sb.String()
@@ -180,6 +199,8 @@ func (m *Manager) SafeApply(content string) error {
return fmt.Errorf("verification failed (rolled back): %w", err) return fmt.Errorf("verification failed (rolled back): %w", err)
} }
os.Remove(m.rulesPath + ".bak")
return nil return nil
} }

View File

@@ -37,13 +37,13 @@ func TestPortsToStr(t *testing.T) {
func TestGenerate_AlwaysIncludesStructure(t *testing.T) { func TestGenerate_AlwaysIncludesStructure(t *testing.T) {
m := NewManager("") m := NewManager("")
out := m.Generate([]int{80, 443}, nil, nil, "") out := m.Generate([]int{80, 443}, nil, nil, "", "")
for _, want := range []string{ for _, want := range []string{
"table inet wild-central", "table inet wild-central",
"set allowed_tcp_ports", "set allowed_tcp_ports",
"chain input", "chain input",
"type filter hook input priority filter; policy accept;", "type filter hook input priority -1; policy accept;",
"iif \"lo\" accept", "iif \"lo\" accept",
"ct state { established, related } accept", "ct state { established, related } accept",
} { } {
@@ -55,7 +55,7 @@ func TestGenerate_AlwaysIncludesStructure(t *testing.T) {
func TestGenerate_WithPorts_IncludesElements(t *testing.T) { func TestGenerate_WithPorts_IncludesElements(t *testing.T) {
m := NewManager("") m := NewManager("")
out := m.Generate([]int{80, 443, 8404}, nil, nil, "") out := m.Generate([]int{80, 443, 8404}, nil, nil, "", "")
if !strings.Contains(out, "elements = { 80, 443, 8404 }") { if !strings.Contains(out, "elements = { 80, 443, 8404 }") {
t.Errorf("expected elements line with ports, got:\n%s", out) t.Errorf("expected elements line with ports, got:\n%s", out)
@@ -64,7 +64,7 @@ func TestGenerate_WithPorts_IncludesElements(t *testing.T) {
func TestGenerate_NoPorts_TCPSetHasNoElements(t *testing.T) { func TestGenerate_NoPorts_TCPSetHasNoElements(t *testing.T) {
m := NewManager("") m := NewManager("")
out := m.Generate([]int{}, nil, nil, "") out := m.Generate([]int{}, nil, nil, "", "")
// TCP set should have no elements when no ports given // TCP set should have no elements when no ports given
if strings.Contains(out, "allowed_tcp_ports") && strings.Contains(out, "elements") { if strings.Contains(out, "allowed_tcp_ports") && strings.Contains(out, "elements") {
@@ -83,7 +83,7 @@ func TestGenerate_NoPorts_TCPSetHasNoElements(t *testing.T) {
func TestGenerate_ExtraPortsMerged(t *testing.T) { func TestGenerate_ExtraPortsMerged(t *testing.T) {
m := NewManager("") m := NewManager("")
out := m.Generate([]int{80, 443}, []int{22, 443}, nil, "") // 443 deduplicated out := m.Generate([]int{80, 443}, []int{22, 443}, nil, "", "") // 443 deduplicated
if !strings.Contains(out, "22") { if !strings.Contains(out, "22") {
t.Errorf("expected extra port 22 in output, got:\n%s", out) t.Errorf("expected extra port 22 in output, got:\n%s", out)
@@ -97,7 +97,7 @@ func TestGenerate_ExtraPortsMerged(t *testing.T) {
func TestGenerate_NoWANInterface_NoDropRules(t *testing.T) { func TestGenerate_NoWANInterface_NoDropRules(t *testing.T) {
m := NewManager("") m := NewManager("")
out := m.Generate([]int{80, 443}, nil, nil, "") out := m.Generate([]int{80, 443}, nil, nil, "", "")
if strings.Contains(out, "drop") { if strings.Contains(out, "drop") {
t.Errorf("expected no drop rules without WAN interface, got:\n%s", out) t.Errorf("expected no drop rules without WAN interface, got:\n%s", out)
@@ -106,7 +106,7 @@ func TestGenerate_NoWANInterface_NoDropRules(t *testing.T) {
func TestGenerate_WithWANInterface_IncludesDropRules(t *testing.T) { func TestGenerate_WithWANInterface_IncludesDropRules(t *testing.T) {
m := NewManager("") m := NewManager("")
out := m.Generate([]int{80, 443}, nil, nil, "eth0") out := m.Generate([]int{80, 443}, nil, nil, "eth0", "")
if !strings.Contains(out, `iif "eth0"`) { if !strings.Contains(out, `iif "eth0"`) {
t.Errorf("expected WAN interface in rules, got:\n%s", out) t.Errorf("expected WAN interface in rules, got:\n%s", out)
@@ -129,7 +129,7 @@ func TestGenerate_WithWANInterface_IncludesDropRules(t *testing.T) {
func TestGenerate_AlwaysIncludesUDPSet(t *testing.T) { func TestGenerate_AlwaysIncludesUDPSet(t *testing.T) {
m := NewManager("") m := NewManager("")
// No WAN interface — permissive mode, but UDP set should still be visible // No WAN interface — permissive mode, but UDP set should still be visible
out := m.Generate([]int{80, 443}, nil, []int{51820}, "") out := m.Generate([]int{80, 443}, nil, []int{51820}, "", "")
if !strings.Contains(out, "set allowed_udp_ports") { if !strings.Contains(out, "set allowed_udp_ports") {
t.Errorf("expected allowed_udp_ports set even without WAN interface, got:\n%s", out) t.Errorf("expected allowed_udp_ports set even without WAN interface, got:\n%s", out)
@@ -143,9 +143,39 @@ func TestGenerate_AlwaysIncludesUDPSet(t *testing.T) {
} }
} }
func TestGenerate_WithVPNCIDR_IncludesMasquerade(t *testing.T) {
m := NewManager("")
out := m.Generate([]int{80, 443}, nil, nil, "", "10.8.0.0/24")
if !strings.Contains(out, "chain vpn-forward") {
t.Errorf("expected vpn-forward chain, got:\n%s", out)
}
if !strings.Contains(out, `iif "wg0" accept`) {
t.Errorf("expected wg0 accept rule, got:\n%s", out)
}
if !strings.Contains(out, "chain vpn-postrouting") {
t.Errorf("expected vpn-postrouting chain, got:\n%s", out)
}
if !strings.Contains(out, "ip saddr 10.8.0.0/24 ip daddr != 10.8.0.0/24 masquerade") {
t.Errorf("expected masquerade rule with VPN CIDR, got:\n%s", out)
}
}
func TestGenerate_NoVPNCIDR_NoMasquerade(t *testing.T) {
m := NewManager("")
out := m.Generate([]int{80, 443}, nil, nil, "", "")
if strings.Contains(out, "masquerade") {
t.Errorf("expected no masquerade without VPN CIDR, got:\n%s", out)
}
if strings.Contains(out, "vpn-forward") {
t.Errorf("expected no vpn-forward chain without VPN CIDR, got:\n%s", out)
}
}
func TestGenerate_ExtraUDPPorts(t *testing.T) { func TestGenerate_ExtraUDPPorts(t *testing.T) {
m := NewManager("") m := NewManager("")
out := m.Generate([]int{80, 443}, nil, []int{5353}, "eth0") out := m.Generate([]int{80, 443}, nil, []int{5353}, "eth0", "")
// User UDP port should appear in the UDP exception list // User UDP port should appear in the UDP exception list
if !strings.Contains(out, "5353") { if !strings.Contains(out, "5353") {

View File

@@ -4,6 +4,8 @@
package reconcile package reconcile
import ( import (
"crypto/x509"
"encoding/pem"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
@@ -15,6 +17,7 @@ import (
"github.com/wild-cloud/wild-central/internal/authelia" "github.com/wild-cloud/wild-central/internal/authelia"
"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/ddns"
"github.com/wild-cloud/wild-central/internal/dnsmasq" "github.com/wild-cloud/wild-central/internal/dnsmasq"
"github.com/wild-cloud/wild-central/internal/domains" "github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/haproxy" "github.com/wild-cloud/wild-central/internal/haproxy"
@@ -205,6 +208,7 @@ func (r *Reconciler) Reconcile() {
} }
r.DDNS.Trigger() r.DDNS.Trigger()
r.ensureCloudflareCNAMEs(doms)
r.health.UpdatedAt = time.Now() r.health.UpdatedAt = time.Now()
@@ -431,26 +435,31 @@ func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Doma
gatewayDomain = parts[1] gatewayDomain = parts[1]
} }
// Collect unique missing certs. Use hasCertForDomain which checks // Collect certs that need provisioning: missing OR expiring within 30 days.
// both individual certs AND wildcard coverage (e.g., *.civilsociety.dev // Uses hasCertForDomain for existence (checks individual + wildcard coverage)
// covers git.civilsociety.dev — no individual cert needed). // and certExpiresWithin for expiry checking.
missing := map[string]bool{} const renewThreshold = 30 * 24 * time.Hour
needsProvision := map[string]bool{}
for _, dom := range doms { for _, dom := range doms {
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" { if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
continue continue
} }
if hasCertForDomain(dom.DomainName) { if hasCertForDomain(dom.DomainName) {
continue // Cert exists — check if it's expiring soon
if path := certPathForDomain(dom.DomainName); path != "" && !certExpiresWithin(path, renewThreshold) {
continue
}
slog.Info("cert expired or expiring soon, scheduling renewal", "component", "reconcile", "domain", dom.DomainName)
} }
// Determine what cert to provision: wildcard for gateway subdomains, individual otherwise // Determine what cert to provision: wildcard for gateway subdomains, individual otherwise
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) { if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
missing["*."+gatewayDomain] = true needsProvision["*."+gatewayDomain] = true
} else { } else {
missing[dom.DomainName] = true needsProvision[dom.DomainName] = true
} }
} }
if len(missing) == 0 { if len(needsProvision) == 0 {
return nil return nil
} }
@@ -462,28 +471,28 @@ func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Doma
} }
if cfToken == "" || email == "" || r.Certs == nil { if cfToken == "" || email == "" || r.Certs == nil {
// Can't auto-provision — report what's missing // Can't auto-provision — report what's needed
var names []string var names []string
for domain := range missing { for domain := range needsProvision {
names = append(names, domain) names = append(names, domain)
slog.Warn("missing cert (auto-provision unavailable — configure Cloudflare token and operator email)", slog.Warn("cert needs provisioning (auto-provision unavailable — configure Cloudflare token and operator email)",
"component", "reconcile", "domain", domain) "component", "reconcile", "domain", domain)
} }
return names return names
} }
// Auto-provision missing certs // Auto-provision missing/expiring certs
if err := r.Certs.EnsureCredentials(cfToken); err != nil { if err := r.Certs.EnsureCredentials(cfToken); err != nil {
slog.Error("failed to write certbot credentials", "component", "reconcile", "error", err) slog.Error("failed to write certbot credentials", "component", "reconcile", "error", err)
var names []string var names []string
for domain := range missing { for domain := range needsProvision {
names = append(names, domain) names = append(names, domain)
} }
return names return names
} }
var stillMissing []string var stillMissing []string
for domain := range missing { for domain := range needsProvision {
provisionDomain := domain provisionDomain := domain
if strings.HasPrefix(domain, "*.") { if strings.HasPrefix(domain, "*.") {
provisionDomain = domain // certbot handles wildcard with -d *.example.com provisionDomain = domain // certbot handles wildcard with -d *.example.com
@@ -503,6 +512,31 @@ func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Doma
return stillMissing return stillMissing
} }
// ensureCloudflareCNAMEs creates wildcard CNAME records in Cloudflare for
// public domains with subdomains enabled (e.g., *.cloud.payne.io → cloud.payne.io).
// This makes subdomains resolvable on the public internet without individual
// DNS records per app.
func (r *Reconciler) ensureCloudflareCNAMEs(doms []domains.Domain) {
cfToken := ""
if r.GetCloudflareToken != nil {
cfToken = r.GetCloudflareToken()
}
if cfToken == "" {
return
}
for _, dom := range doms {
if !dom.Public || !dom.Subdomains || dom.DomainName == "" {
continue
}
cname := "*." + dom.DomainName
if err := ddns.EnsureCNAME(cfToken, cname, dom.DomainName); err != nil {
slog.Warn("failed to ensure wildcard CNAME", "component", "reconcile",
"cname", cname, "target", dom.DomainName, "error", err)
}
}
}
// hasCertForDomain checks if a valid (non-empty) cert exists for a domain — // hasCertForDomain checks if a valid (non-empty) cert exists for a domain —
// either an individual cert (<domain>.pem) or a wildcard cert that covers it. // either an individual cert (<domain>.pem) or a wildcard cert that covers it.
func hasCertForDomain(domain string) bool { func hasCertForDomain(domain string) bool {
@@ -523,6 +557,38 @@ func isValidCertFile(path string) bool {
return err == nil && info.Size() > 0 return err == nil && info.Size() > 0
} }
// certPathForDomain returns the HAProxy PEM path covering a domain, or "" if none exists.
// Checks individual cert first, then wildcard.
func certPathForDomain(domain string) string {
if path := certbot.HAProxyCertPath(domain); isValidCertFile(path) {
return path
}
if parts := strings.SplitN(domain, ".", 2); len(parts) == 2 {
if path := certbot.HAProxyCertPath(parts[1]); isValidCertFile(path) {
return path
}
}
return ""
}
// certExpiresWithin checks if the cert at path expires within the given threshold.
// Returns true if the cert is expired or will expire within the threshold.
func certExpiresWithin(path string, threshold time.Duration) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
block, _ := pem.Decode(data)
if block == nil {
return false
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return false
}
return time.Until(cert.NotAfter) < threshold
}
func extractHost(addr string) string { func extractHost(addr string) string {
for i := len(addr) - 1; i >= 0; i-- { for i := len(addr) - 1; i >= 0; i-- {
if addr[i] == ':' { if addr[i] == ':' {

View File

@@ -1,9 +1,17 @@
package reconcile package reconcile
import ( import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/dnsmasq" "github.com/wild-cloud/wild-central/internal/dnsmasq"
@@ -243,6 +251,43 @@ func TestReconcile_DNSEntriesBuiltFromDomains(t *testing.T) {
} }
} }
func TestReconcile_CNAMEsSkippedWithoutToken(t *testing.T) {
// A public wildcard domain should not panic or error when no CF token is configured.
doms := []domains.Domain{
{
DomainName: "cloud.example.com",
Backend: domains.Backend{Address: "192.168.1.10:80", Type: domains.BackendHTTP},
Subdomains: true,
Public: true,
TLS: domains.TLSTerminate,
},
}
r, _, _, ddns := newTestReconciler(t, doms)
// GetCloudflareToken is nil — ensureCloudflareCNAMEs should silently return
r.Reconcile()
if !ddns.triggerCalled {
t.Error("expected DDNS.Trigger() to be called")
}
}
func TestEnsureCloudflareCNAMEs_FiltersCorrectly(t *testing.T) {
// Track which domains EnsureCNAME would be called for by using a token
// that would fail at the API level. We verify the method doesn't attempt
// CNAMEs for non-qualifying domains by checking it returns cleanly
// (no token = no API calls).
doms := []domains.Domain{
{DomainName: "cloud.example.com", Subdomains: true, Public: true}, // qualifies
{DomainName: "app.example.com", Subdomains: false, Public: true}, // no subdomains
{DomainName: "internal.example.com", Subdomains: true, Public: false}, // not public
{DomainName: "", Subdomains: true, Public: true}, // empty name
}
r, _, _, _ := newTestReconciler(t, doms)
// No token — method returns immediately without attempting any CNAME operations
r.ensureCloudflareCNAMEs(doms)
// No panic, no error — success
}
// --- Helper tests --- // --- Helper tests ---
func TestIsValidCertFile_Valid(t *testing.T) { func TestIsValidCertFile_Valid(t *testing.T) {
@@ -273,6 +318,60 @@ func TestIsValidCertFile_Missing(t *testing.T) {
} }
} }
func writeSelfSignedCert(t *testing.T, path string, notAfter time.Time) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: notAfter,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: der})
}
func TestCertExpiresWithin_Expired(t *testing.T) {
path := filepath.Join(t.TempDir(), "expired.pem")
writeSelfSignedCert(t, path, time.Now().Add(-24*time.Hour))
if !certExpiresWithin(path, 30*24*time.Hour) {
t.Error("expected expired cert to return true")
}
}
func TestCertExpiresWithin_ExpiringSoon(t *testing.T) {
path := filepath.Join(t.TempDir(), "expiring.pem")
writeSelfSignedCert(t, path, time.Now().Add(10*24*time.Hour))
if !certExpiresWithin(path, 30*24*time.Hour) {
t.Error("expected cert expiring in 10 days to return true for 30-day threshold")
}
}
func TestCertExpiresWithin_Valid(t *testing.T) {
path := filepath.Join(t.TempDir(), "valid.pem")
writeSelfSignedCert(t, path, time.Now().Add(60*24*time.Hour))
if certExpiresWithin(path, 30*24*time.Hour) {
t.Error("expected cert with 60 days left to return false for 30-day threshold")
}
}
func TestCertExpiresWithin_Missing(t *testing.T) {
if certExpiresWithin("/nonexistent/cert.pem", 30*24*time.Hour) {
t.Error("expected missing file to return false")
}
}
func TestExtractHost(t *testing.T) { func TestExtractHost(t *testing.T) {
tests := []struct { tests := []struct {
input, want string input, want string

View File

@@ -460,16 +460,9 @@ func (m *Manager) renderWgConfig(cfg *Config, srv *secrets, peers []*Peer) strin
// DNS is intentionally omitted from the server config — it belongs only in // DNS is intentionally omitted from the server config — it belongs only in
// the client peer config (GeneratePeerConfigText). Setting it here would // the client peer config (GeneratePeerConfigText). Setting it here would
// cause wg-quick to reconfigure the server's own DNS resolver. // cause wg-quick to reconfigure the server's own DNS resolver.
// When LAN CIDR is configured: allow forwarding between wg0 and LAN, and masquerade. // VPN forwarding and masquerade rules are managed in the nftables table
// Uses iptables (iptables-nft on modern systems) for reliable PostUp/PreDown syntax. // (inet wild-central) rather than iptables PostUp/PreDown, so they survive
if cfg.LanCIDR != "" { // Docker restarts which flush the iptables-nft compatibility tables.
_, vpnNet, _ := net.ParseCIDR(cfg.Address)
if vpnNet != nil {
vpnCIDR := vpnNet.String()
fmt.Fprintf(&sb, "PostUp = iptables -I FORWARD -i wg0 -j ACCEPT; iptables -I FORWARD -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -s %s ! -d %s -j MASQUERADE\n", vpnCIDR, vpnCIDR)
fmt.Fprintf(&sb, "PreDown = iptables -D FORWARD -i wg0 -j ACCEPT 2>/dev/null; iptables -D FORWARD -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null; iptables -t nat -D POSTROUTING -s %s ! -d %s -j MASQUERADE 2>/dev/null; true\n", vpnCIDR, vpnCIDR)
}
}
for _, p := range peers { for _, p := range peers {
sb.WriteString("\n[Peer]\n") sb.WriteString("\n[Peer]\n")
fmt.Fprintf(&sb, "# %s\n", p.Name) fmt.Fprintf(&sb, "# %s\n", p.Name)

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#2563eb"/> <rect width="32" height="32" rx="6" fill="#d97706"/>
<!-- Central node --> <!-- Central node -->
<circle cx="16" cy="16" r="4" fill="white"/> <circle cx="16" cy="16" r="4" fill="white"/>
<!-- Radiating connections --> <!-- Radiating connections -->

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { NavLink } from 'react-router'; import { NavLink } from 'react-router';
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, ShieldCheck, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound } from 'lucide-react'; import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, ShieldCheck, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound, Plug } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { import {
Sidebar, Sidebar,
@@ -95,6 +95,7 @@ export function AppSidebar() {
{ to: '/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const }, { to: '/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
{ to: '/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const }, { to: '/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const },
{ to: '/dns-filter', icon: ShieldBan, label: 'DNS Filter', daemon: 'dnsmasq' as const }, { to: '/dns-filter', icon: ShieldBan, label: 'DNS Filter', daemon: 'dnsmasq' as const },
{ to: '/integrations', icon: Plug, label: 'Integrations', daemon: undefined },
]; ];
const advancedItems = [ const advancedItems = [
@@ -171,7 +172,7 @@ export function AppSidebar() {
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{centralItems.map(({ to, icon: Icon, label, daemon }) => { {centralItems.map(({ to, icon: Icon, label, daemon }) => {
const active = centralStatus?.daemons?.[daemon]?.active; const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
return ( return (
<SidebarMenuItem key={to}> <SidebarMenuItem key={to}>
<NavLink to={to}> <NavLink to={to}>

View File

@@ -0,0 +1,150 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Key, RotateCw, Loader2, AlertCircle, CheckCircle, Eye, EyeOff } from 'lucide-react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Alert, AlertDescription } from './ui/alert';
import { CopyButton } from './CopyButton';
import { integrationsApi } from '../services/api/integrations';
export function IntegrationsComponent() {
const queryClient = useQueryClient();
const [showToken, setShowToken] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const { data, isLoading, error } = useQuery({
queryKey: ['integrations'],
queryFn: () => integrationsApi.get(),
});
const rotateMutation = useMutation({
mutationFn: () => integrationsApi.rotateApiToken(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['integrations'] });
setSuccessMessage('API token rotated. Update the token in any connected services.');
setTimeout(() => setSuccessMessage(null), 8000);
},
});
if (isLoading) {
return (
<Card className="p-6">
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</Card>
);
}
if (error) {
return (
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Integrations</h3>
<p className="text-muted-foreground">{(error as Error)?.message || 'An error occurred'}</p>
</Card>
);
}
const token = data?.apiToken?.token || '';
const maskedToken = token ? `${token.slice(0, 6)}${'*'.repeat(Math.max(0, token.length - 6))}` : '';
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Key className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Integrations</h2>
<p className="text-muted-foreground">
Manage API tokens and service connections
</p>
</div>
</div>
{successMessage && (
<Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-700 dark:text-green-300">{successMessage}</AlertDescription>
</Alert>
)}
{rotateMutation.error && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{(rotateMutation.error as Error)?.message || 'Failed to rotate token'}</AlertDescription>
</Alert>
)}
<Card className="p-6 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold">API Token</h3>
<p className="text-sm text-muted-foreground mt-1">
Bearer token for authenticating with the Wild Central API
</p>
</div>
<Badge variant="outline" className="gap-1">
{token ? (
<><CheckCircle className="h-3 w-3 text-green-500" /> Configured</>
) : (
<><AlertCircle className="h-3 w-3 text-red-500" /> Not Set</>
)}
</Badge>
</div>
{token && (
<>
<div className="flex items-center gap-2 mb-4">
<code className="flex-1 bg-muted rounded-md p-3 font-mono text-sm break-all">
{showToken ? token : maskedToken}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => setShowToken(!showToken)}
title={showToken ? 'Hide token' : 'Show token'}
>
{showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<CopyButton content={token} label="Copy" />
</div>
<div className="bg-muted/50 rounded-md p-4 mb-4">
<p className="text-sm font-medium mb-2">Usage</p>
<p className="text-sm text-muted-foreground mb-3">
Services like Wild Cloud use this token to authenticate with Wild Central's API.
Set it as the <code className="font-mono bg-muted px-1 rounded">WILD_CENTRAL_TOKEN</code> environment variable.
</p>
<code className="block bg-muted rounded-md p-2 font-mono text-xs break-all">
WILD_CENTRAL_TOKEN={showToken ? token : maskedToken}
</code>
</div>
</>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => rotateMutation.mutate()}
disabled={rotateMutation.isPending}
>
{rotateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCw className="h-4 w-4" />
)}
{token ? 'Rotate Token' : 'Generate Token'}
</Button>
{token && (
<p className="text-xs text-muted-foreground">
Rotating the token will invalidate the current one. Connected services will need to be updated.
</p>
)}
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { IntegrationsComponent } from '../../components/IntegrationsComponent';
export function IntegrationsPage() {
return (
<ErrorBoundary>
<IntegrationsComponent />
</ErrorBoundary>
);
}

View File

@@ -9,6 +9,7 @@ import { CrowdSecPage } from './pages/CrowdSecPage';
import { DhcpPage } from './pages/DhcpPage'; import { DhcpPage } from './pages/DhcpPage';
import { DnsFilterPage } from './pages/DnsFilterPage'; import { DnsFilterPage } from './pages/DnsFilterPage';
import { AutheliaPage } from './pages/AutheliaPage'; import { AutheliaPage } from './pages/AutheliaPage';
import { IntegrationsPage } from './pages/IntegrationsPage';
import { import {
HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage, HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage,
CrowdsecPage as CrowdsecAdvancedPage, CrowdsecPage as CrowdsecAdvancedPage,
@@ -30,6 +31,7 @@ export const routes: RouteObject[] = [
{ path: 'auth', element: <AutheliaPage /> }, { path: 'auth', element: <AutheliaPage /> },
{ path: 'dhcp', element: <DhcpPage /> }, { path: 'dhcp', element: <DhcpPage /> },
{ path: 'dns-filter', element: <DnsFilterPage /> }, { path: 'dns-filter', element: <DnsFilterPage /> },
{ path: 'integrations', element: <IntegrationsPage /> },
{ path: 'advanced/haproxy', element: <HaproxyPage /> }, { path: 'advanced/haproxy', element: <HaproxyPage /> },
{ path: 'advanced/dnsmasq', element: <DnsmasqPage /> }, { path: 'advanced/dnsmasq', element: <DnsmasqPage /> },
{ path: 'advanced/nftables', element: <NftablesPage /> }, { path: 'advanced/nftables', element: <NftablesPage /> },

View File

@@ -0,0 +1,23 @@
import { apiClient } from './client';
export interface IntegrationsResponse {
apiToken: {
token: string;
description: string;
};
}
export interface RotateTokenResponse {
token: string;
message: string;
}
export const integrationsApi = {
async get(): Promise<IntegrationsResponse> {
return apiClient.get('/api/v1/integrations');
},
async rotateApiToken(): Promise<RotateTokenResponse> {
return apiClient.post('/api/v1/integrations/api-token/rotate');
},
};