- Auto-generate random 32-char bearer token on first startup, stored in
secrets.yaml as api.bearerToken
- BearerAuthMiddleware checks Authorization: Bearer <token> on all /api/
endpoints except /health, /health/reconcile, /events (SSE), and non-API
paths (frontend static files)
- Development mode (WILD_CENTRAL_ENV=development) skips auth entirely
- Web app ApiClient: add setToken/clearToken/hasToken methods, persist
token in localStorage, automatically include Authorization header on
all API requests
- Token can be found in secrets.yaml for CLI/automation use
Config injection prevention:
- Add FQDN validation for domain names (RFC 1123) in Register/Update —
rejects newlines, spaces, shell metacharacters that could inject into
HAProxy/dnsmasq configs
- Add backend address validation (valid host:port format, valid IP or
hostname, port 1-65535). DNS-only backends allow bare IPs.
- Add header key/value validation — keys must be HTTP token chars only,
values must not contain newlines or NULs
- Add WireGuard peer name validation (alphanumeric + hyphens + underscores)
- Add defense-in-depth domain validation in certbot Provision()
Secrets protection:
- Remove ?raw=true bypass on GET /api/v1/secrets — secrets are now always
redacted in API responses regardless of query parameters
- Update test to verify redaction cannot be bypassed
NATS authentication:
- Generate random auth token on first startup, store in secrets.yaml
- Pass token to embedded NATS server via Authorization option
- Internal client connects with the same token
- External NATS clients (Wild Cloud) must now authenticate
Security headers:
- Add X-Content-Type-Options: nosniff
- Add X-Frame-Options: DENY
- Add Cache-Control: no-store
SafeApply pattern (validate → backup → write → reload → verify → rollback):
- HAProxy: SafeApply wraps existing validate+write+reload with backup and
post-reload health check; rolls back to .bak on failure
- dnsmasq: SafeApply validates via dnsmasq --test, backs up, atomic writes,
restarts, verifies daemon is active; rolls back on failure
- nftables: SafeApply validates via nft -c, backs up, atomic writes, applies
to kernel, verifies table loaded; rolls back on failure
Health tracking:
- Add SubsystemHealth and Health structs to reconciler
- Track per-subsystem status (ok/degraded/error) after each reconcile
- Detect recovery: previous error → current ok broadcasts recovery event
- GET /api/v1/health/reconcile endpoint exposes health state
- HAProxy tracks excluded domains as "degraded" state
SSE error events:
- Broadcast haproxy:error, dnsmasq:error on SafeApply failure
- Broadcast haproxy:recovered, dnsmasq:recovered on recovery from error
Convergence loop:
- 5-minute periodic reconcile drives system toward desired state
- Catches config drift, daemon crashes, transient failures
- Serialized by reconcile mutex — no race with event-driven reconciles
Startup:
- CheckPrerequisites verifies required (dnsmasq, haproxy) and optional
(wg, authelia, cscli, cloudflared, nft) binaries before first reconcile
Adds Pi-hole-style DNS filtering using dnsmasq's native address=/ directives,
which block domains and all subdomains. Users subscribe to blocklists by URL
or upload files, with suggested lists from Hagezi, Steven Black, and OISD.
Background runner refreshes lists on a configurable interval (default 24h).
Read the configured Central domain from state.yaml and add it as an
HTTPS origin so the web UI at e.g. https://central.payne.io works
without manually setting WILD_CENTRAL_CORS_ORIGINS.
Linter-contributed improvements:
- EnsureCentralService() method: registers Central's own domain as a
service from config, with cleanup on domain change
- Tests for EnsureCentralService: registration, idempotency, domain
change cleanup, no-domain-is-noop
- Improved reconciliation tests with Central as a registered service
- Certbot handler cleanup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reconcile() was called before SetPort(), so getRunningPort() returned
the default 5055 (Wild Cloud's port) instead of 15055. Moved
Reconcile() after SetPort(port) so Central's config-driven HAProxy
route points to the actual running port.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests added (13 new):
- HAProxy ACL ordering: L7 before L4 wildcard, L4 exact before L4 wildcard
- HAProxy subdomains: false=exact only, true=wildcard+exact
- Reconciliation integration: HAProxy config from services, DNS config
from services, Central config domain injection, correct DNS target IPs
- dnsmasq: reach:internal has local=/, reach:public does not
- Services: idempotent registration, no duplicates in list
Bug fix:
- Run initial reconciliation on startup so Central's own domain (from
config) gets HAProxy + DNS routes even with zero registered services.
Previously, Central only got routes after the first service was
registered, causing 503 on fresh restart.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rewrite HAProxy config generation with correct ACL ordering:
1. L7 HTTP exact matches first (central, wild-cloud app)
2. L4 exact matches (payne.io, civilsociety.dev)
3. L4 wildcard matches (*.cloud.payne.io) — only when subdomains:true
4. Default → L7 termination
Key changes:
- InstanceRoute: removed ExtraDomains, added Subdomains bool
- GenerateOpts: removed CentralDomain (Central is now just another
HTTPRoute injected by reconciliation), renamed WildcardCert→CertsDir
- Central's domain comes from config, injected as HTTPRoute with
the actual running port (no more hardcoded 5055)
- Added API.SetPort() so reconciliation knows the running port
- subdomains:false → exact match only (no *.domain shadowing)
New tests: TestGenerate_WithSubdomains, TestGenerate_ACLOrdering
(verifies L7 exact matches come before L4 wildcards).
Fixes: BUG 1 (central routing to wrong port), BUG 2 (wild-cloud
getting traefik cert), BUG 3 (*.payne.io too broad).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On startup, Wild Central registers its own domain (from config
cloud.central.domain) as an HTTP service with itself. This creates
the DNS entry, proxy route, and TLS cert needed to access the
Central UI via its domain name.
Registration is idempotent — safe on every restart. Skips silently
if no domain is configured. Logs a warning if registration fails.
Verified: after startup, /api/v1/services shows wild-central
registered with domain central.payne.io.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Allow overriding the default API port (5055) for development and
testing when the production daemon is already running.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Central now starts an embedded NATS JetStream server as its core
coordination bus. This provides:
- wild-services KV bucket: service registrations from Cloud/Works
- wild-presence KV bucket: node liveness with TTL-based keys
- wild-events stream: events from all sources (24h retention)
NATS is started in main.go before the API and shut down on SIGTERM.
Cloud and Works will connect to Central's NATS (port 4222) to register
services and maintain presence.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract the Central networking functionality from wild-cloud/api into
a standalone service. Wild Central manages DNS (dnsmasq), gateway
(HAProxy), firewall (nftables), VPN (WireGuard), TLS (certbot),
security (CrowdSec), and DDNS — all the network appliance concerns.
All Cloud-specific code (instances, clusters, nodes, apps, backups,
operations, kubectl/talosctl tooling) has been removed. The API struct
and route registration contain only Central endpoints. Tests updated
to match the new API signature.
Builds and all tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>