Compare commits

..

5 Commits

Author SHA1 Message Date
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
16 changed files with 411 additions and 17 deletions

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.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")

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

@@ -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

View File

@@ -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",
},
}
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)
}
if keyPath != "/etc/letsencrypt/live/example.com/privkey.pem" {
t.Errorf("KeyPath = %q", keyPath)
}
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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])

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=/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},
}

View File

@@ -15,6 +15,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 +206,7 @@ func (r *Reconciler) Reconcile() {
}
r.DDNS.Trigger()
r.ensureCloudflareCNAMEs(doms)
r.health.UpdatedAt = time.Now()
@@ -503,6 +505,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 {

View File

@@ -243,6 +243,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) {

View File

@@ -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

View File

@@ -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}>

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 { 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 /> },

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');
},
};