Compare commits
10 Commits
v0.2.0
...
d35d0854cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d35d0854cb | ||
|
|
1176c7715a | ||
|
|
17748ce09c | ||
|
|
b45c0d6a81 | ||
|
|
c87125d003 | ||
|
|
d5457ae2dc | ||
|
|
a90af10932 | ||
|
|
03ce1f3c42 | ||
|
|
21b963b8ec | ||
|
|
0a50e2d7f0 |
22
TODO.md
22
TODO.md
@@ -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/
|
||||
@@ -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.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
|
||||
r.HandleFunc("/api/v1/daemon/restart", api.DaemonRestart).Methods("POST")
|
||||
|
||||
|
||||
44
internal/api/v1/handlers_integrations.go
Normal file
44
internal/api/v1/handlers_integrations.go
Normal 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.",
|
||||
})
|
||||
}
|
||||
@@ -50,6 +50,20 @@ func (api *API) vpnAutoUDPPorts() []int {
|
||||
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
|
||||
// without touching HAProxy. Called when firewall settings change independently.
|
||||
// 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)
|
||||
extraUDP = append(extraUDP, api.vpnAutoUDPPorts()...)
|
||||
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 {
|
||||
slog.Error("failed to write nftables rules", "component", "nftables-sync", "error", err)
|
||||
return
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestHAProxyCertPath(t *testing.T) {
|
||||
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"},
|
||||
{"sub.example.com", "/etc/haproxy/certs/sub.example.com.pem"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@@ -34,15 +34,31 @@ func TestHAProxyCertPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCertPaths(t *testing.T) {
|
||||
domain := "example.com"
|
||||
certPath := CertPath(domain)
|
||||
keyPath := KeyPath(domain)
|
||||
|
||||
if certPath != "/etc/letsencrypt/live/example.com/fullchain.pem" {
|
||||
t.Errorf("CertPath = %q", certPath)
|
||||
tests := []struct {
|
||||
domain string
|
||||
wantCert string
|
||||
wantKey string
|
||||
}{
|
||||
{
|
||||
"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" {
|
||||
t.Errorf("KeyPath = %q", keyPath)
|
||||
for _, tt := range tests {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -388,3 +388,63 @@ func (c *cfClient) deleteRecord(zoneID, recordID string) error {
|
||||
resp.Body.Close()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -226,3 +226,15 @@ func TestGetStatus_ReflectsManualUpdate(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ func ParseLine(line string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// dnsmasq format: address=/domain/0.0.0.0 or server=/domain/
|
||||
if strings.HasPrefix(line, "address=/") || strings.HasPrefix(line, "server=/") {
|
||||
// dnsmasq format: address=/domain/... or server=/domain/ or local=/domain/
|
||||
if strings.HasPrefix(line, "address=/") || strings.HasPrefix(line, "server=/") || strings.HasPrefix(line, "local=/") {
|
||||
parts := strings.SplitN(line, "/", 4)
|
||||
if len(parts) >= 3 && parts[1] != "" {
|
||||
return validateDomain(parts[1])
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestParseLine_DnsmasqFormat(t *testing.T) {
|
||||
{"address=/ads.example.com/0.0.0.0", "ads.example.com", true},
|
||||
{"address=/tracker.example.com/", "tracker.example.com", true},
|
||||
{"server=/blocked.example.com/", "blocked.example.com", true},
|
||||
{"local=/blocked.example.com/", "blocked.example.com", true},
|
||||
{"address=//", "", false},
|
||||
}
|
||||
|
||||
|
||||
@@ -303,8 +303,8 @@ log-dhcp
|
||||
// SafeApply validates, backs up, writes, restarts, and verifies the dnsmasq config.
|
||||
// On restart or verification failure, rolls back to the previous config.
|
||||
func (g *Manager) SafeApply(content string) error {
|
||||
// Validate via temp file
|
||||
tmpFile := g.configPath + ".validate"
|
||||
// Validate via temp file (use /tmp to avoid stray files in dnsmasq.d if process crashes)
|
||||
tmpFile := "/tmp/wild-central-dnsmasq-validate.tmp"
|
||||
if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -576,6 +576,8 @@ func (m *Manager) SafeApply(content string) error {
|
||||
return fmt.Errorf("health check failed (rolled back): %w", err)
|
||||
}
|
||||
|
||||
os.Remove(m.configPath + ".bak")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ func (m *Manager) GetRulesPath() string {
|
||||
// Policy is always "accept" — the allowed_tcp_ports set documents what is in use
|
||||
// and enables strict WAN filtering when wanInterface is configured.
|
||||
// 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)
|
||||
seen := make(map[int]bool)
|
||||
var tcpPorts []int
|
||||
@@ -96,7 +97,8 @@ func (m *Manager) Generate(haproxyPorts []int, extraTCPPorts []int, extraUDPPort
|
||||
sb.WriteString(" }\n\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(" ct state { established, related } accept\n")
|
||||
|
||||
@@ -107,6 +109,23 @@ func (m *Manager) Generate(haproxyPorts []int, extraTCPPorts []int, extraUDPPort
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
return sb.String()
|
||||
@@ -180,6 +199,8 @@ func (m *Manager) SafeApply(content string) error {
|
||||
return fmt.Errorf("verification failed (rolled back): %w", err)
|
||||
}
|
||||
|
||||
os.Remove(m.rulesPath + ".bak")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -37,13 +37,13 @@ func TestPortsToStr(t *testing.T) {
|
||||
|
||||
func TestGenerate_AlwaysIncludesStructure(t *testing.T) {
|
||||
m := NewManager("")
|
||||
out := m.Generate([]int{80, 443}, nil, nil, "")
|
||||
out := m.Generate([]int{80, 443}, nil, nil, "", "")
|
||||
|
||||
for _, want := range []string{
|
||||
"table inet wild-central",
|
||||
"set allowed_tcp_ports",
|
||||
"chain input",
|
||||
"type filter hook input priority filter; policy accept;",
|
||||
"type filter hook input priority -1; policy accept;",
|
||||
"iif \"lo\" accept",
|
||||
"ct state { established, related } accept",
|
||||
} {
|
||||
@@ -55,7 +55,7 @@ func TestGenerate_AlwaysIncludesStructure(t *testing.T) {
|
||||
|
||||
func TestGenerate_WithPorts_IncludesElements(t *testing.T) {
|
||||
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 }") {
|
||||
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) {
|
||||
m := NewManager("")
|
||||
out := m.Generate([]int{}, nil, nil, "")
|
||||
out := m.Generate([]int{}, nil, nil, "", "")
|
||||
|
||||
// TCP set should have no elements when no ports given
|
||||
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) {
|
||||
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") {
|
||||
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) {
|
||||
m := NewManager("")
|
||||
out := m.Generate([]int{80, 443}, nil, nil, "")
|
||||
out := m.Generate([]int{80, 443}, nil, nil, "", "")
|
||||
|
||||
if strings.Contains(out, "drop") {
|
||||
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) {
|
||||
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"`) {
|
||||
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) {
|
||||
m := NewManager("")
|
||||
// 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") {
|
||||
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) {
|
||||
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
|
||||
if !strings.Contains(out, "5353") {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
"github.com/wild-cloud/wild-central/internal/authelia"
|
||||
"github.com/wild-cloud/wild-central/internal/certbot"
|
||||
"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/domains"
|
||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
||||
@@ -205,6 +208,7 @@ func (r *Reconciler) Reconcile() {
|
||||
}
|
||||
|
||||
r.DDNS.Trigger()
|
||||
r.ensureCloudflareCNAMEs(doms)
|
||||
|
||||
r.health.UpdatedAt = time.Now()
|
||||
|
||||
@@ -431,26 +435,31 @@ func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Doma
|
||||
gatewayDomain = parts[1]
|
||||
}
|
||||
|
||||
// Collect unique missing certs. Use hasCertForDomain which checks
|
||||
// both individual certs AND wildcard coverage (e.g., *.civilsociety.dev
|
||||
// covers git.civilsociety.dev — no individual cert needed).
|
||||
missing := map[string]bool{}
|
||||
// Collect certs that need provisioning: missing OR expiring within 30 days.
|
||||
// Uses hasCertForDomain for existence (checks individual + wildcard coverage)
|
||||
// and certExpiresWithin for expiry checking.
|
||||
const renewThreshold = 30 * 24 * time.Hour
|
||||
needsProvision := map[string]bool{}
|
||||
for _, dom := range doms {
|
||||
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
|
||||
continue
|
||||
}
|
||||
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
|
||||
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
|
||||
missing["*."+gatewayDomain] = true
|
||||
needsProvision["*."+gatewayDomain] = true
|
||||
} else {
|
||||
missing[dom.DomainName] = true
|
||||
needsProvision[dom.DomainName] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) == 0 {
|
||||
if len(needsProvision) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -462,28 +471,28 @@ func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Doma
|
||||
}
|
||||
|
||||
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
|
||||
for domain := range missing {
|
||||
for domain := range needsProvision {
|
||||
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)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Auto-provision missing certs
|
||||
// Auto-provision missing/expiring certs
|
||||
if err := r.Certs.EnsureCredentials(cfToken); err != nil {
|
||||
slog.Error("failed to write certbot credentials", "component", "reconcile", "error", err)
|
||||
var names []string
|
||||
for domain := range missing {
|
||||
for domain := range needsProvision {
|
||||
names = append(names, domain)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
var stillMissing []string
|
||||
for domain := range missing {
|
||||
for domain := range needsProvision {
|
||||
provisionDomain := domain
|
||||
if strings.HasPrefix(domain, "*.") {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 —
|
||||
// either an individual cert (<domain>.pem) or a wildcard cert that covers it.
|
||||
func hasCertForDomain(domain string) bool {
|
||||
@@ -523,6 +557,38 @@ func isValidCertFile(path string) bool {
|
||||
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 {
|
||||
for i := len(addr) - 1; i >= 0; i-- {
|
||||
if addr[i] == ':' {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/config"
|
||||
"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 ---
|
||||
|
||||
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) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
|
||||
@@ -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
|
||||
// the client peer config (GeneratePeerConfigText). Setting it here would
|
||||
// cause wg-quick to reconfigure the server's own DNS resolver.
|
||||
// When LAN CIDR is configured: allow forwarding between wg0 and LAN, and masquerade.
|
||||
// Uses iptables (iptables-nft on modern systems) for reliable PostUp/PreDown syntax.
|
||||
if cfg.LanCIDR != "" {
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
// VPN forwarding and masquerade rules are managed in the nftables table
|
||||
// (inet wild-central) rather than iptables PostUp/PreDown, so they survive
|
||||
// Docker restarts which flush the iptables-nft compatibility tables.
|
||||
for _, p := range peers {
|
||||
sb.WriteString("\n[Peer]\n")
|
||||
fmt.Fprintf(&sb, "# %s\n", p.Name)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 -->
|
||||
<circle cx="16" cy="16" r="4" fill="white"/>
|
||||
<!-- Radiating connections -->
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
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 {
|
||||
Sidebar,
|
||||
@@ -95,6 +95,7 @@ export function AppSidebar() {
|
||||
{ to: '/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' 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: '/integrations', icon: Plug, label: 'Integrations', daemon: undefined },
|
||||
];
|
||||
|
||||
const advancedItems = [
|
||||
@@ -171,7 +172,7 @@ export function AppSidebar() {
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{centralItems.map(({ to, icon: Icon, label, daemon }) => {
|
||||
const active = centralStatus?.daemons?.[daemon]?.active;
|
||||
const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
|
||||
return (
|
||||
<SidebarMenuItem key={to}>
|
||||
<NavLink to={to}>
|
||||
|
||||
150
web/src/components/IntegrationsComponent.tsx
Normal file
150
web/src/components/IntegrationsComponent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
web/src/router/pages/IntegrationsPage.tsx
Normal file
10
web/src/router/pages/IntegrationsPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { IntegrationsComponent } from '../../components/IntegrationsComponent';
|
||||
|
||||
export function IntegrationsPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<IntegrationsComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { CrowdSecPage } from './pages/CrowdSecPage';
|
||||
import { DhcpPage } from './pages/DhcpPage';
|
||||
import { DnsFilterPage } from './pages/DnsFilterPage';
|
||||
import { AutheliaPage } from './pages/AutheliaPage';
|
||||
import { IntegrationsPage } from './pages/IntegrationsPage';
|
||||
import {
|
||||
HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage,
|
||||
CrowdsecPage as CrowdsecAdvancedPage,
|
||||
@@ -30,6 +31,7 @@ export const routes: RouteObject[] = [
|
||||
{ path: 'auth', element: <AutheliaPage /> },
|
||||
{ path: 'dhcp', element: <DhcpPage /> },
|
||||
{ path: 'dns-filter', element: <DnsFilterPage /> },
|
||||
{ path: 'integrations', element: <IntegrationsPage /> },
|
||||
{ path: 'advanced/haproxy', element: <HaproxyPage /> },
|
||||
{ path: 'advanced/dnsmasq', element: <DnsmasqPage /> },
|
||||
{ path: 'advanced/nftables', element: <NftablesPage /> },
|
||||
|
||||
23
web/src/services/api/integrations.ts
Normal file
23
web/src/services/api/integrations.ts
Normal 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');
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user