Commit Graph

16 Commits

Author SHA1 Message Date
Paul Payne
a0ab8faa1c Add SafeApply lifecycle, health tracking, convergence loop, and startup checks
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
2026-07-14 11:44:44 +00:00
Paul Payne
88acd437a1 Add resiliency primitives: atomic writes, reconcile mutex, state backup
- Add storage.WriteFileAtomic (temp + rename) and storage.CopyFile helpers
- Convert all 8 production config writers to atomic writes: config/state.yaml,
  dnsmasq, nftables, domains, wireguard (config + secrets + peers + wg0.conf),
  tunnel/cloudflared
- Add sync.Mutex to Reconciler to serialize concurrent Reconcile() calls
  triggered by domain registration goroutines
- Add state.yaml backup (.bak) before every write; LoadState falls back to
  backup if primary is corrupted
- Reconciler refuses to use empty config on corruption (only on first run
  when no state file exists yet)
2026-07-14 11:37:33 +00:00
Paul Payne
3172e56288 Standardize codebase consistency: naming, JSON tags, logging, error wrapping
- JSON tags: fix snake_case to camelCase in dnsmasq (configFile, domainsConfigured,
  lastRestart), crowdsec Machine (lastPush, lastHeartbeat), network (primaryIP,
  primaryInterface). cscli raw parsing structs keep snake_case to match CLI output.
- Error wrapping: fix %v to %w in enableAuthelia for proper error chain preservation
- Naming: rename dnsmasq.ConfigGenerator to dnsmasq.Manager (matches all other packages),
  rename ServiceStatus to Status in dnsmasq and haproxy (matches authelia, crowdsec, etc.)
- Logging: standardize all slog calls to use "component" key instead of message prefixes.
  Affects reconcile, dnsfilter, ddns — now consistent with dnsmasq, haproxy, nftables, sse.
2026-07-14 04:38:48 +00:00
Paul Payne
428d47f876 Refactor architecture: extract reconciler, add interfaces, reduce complexity, improve test coverage
Architecture:
- Extract reconcileNetworking into internal/reconcile package with 7 consumer-side interfaces
- Add locked modifyState helper to fix state.yaml read-modify-write race condition
- Extract CrowdSec and VPN handler groups with interfaces documenting dependency surface
- Replace raw map[string]any YAML manipulation with typed AddDHCPStaticLease/RemoveDHCPStaticLease
- Extract Cloudflare API functions into cfClient struct, eliminating repeated auth boilerplate

Complexity reduction:
- haproxy.GenerateWithOpts: 50 → 6 (extracted 8 focused helpers)
- reconcile.Reconcile: 42 → 11 (extracted buildRoutes, buildDNSEntries, writeHAProxyConfig)
- AutheliaUpdateConfig: 25 → 10 (extracted enableAuthelia/disableAuthelia)

Test coverage improvements:
- reconcile: 4.5% → 56.8% (stub-based tests for route building, DNS entries, orchestration)
- dnsfilter: 10.7% → 45.6% (Manager.Compile, AddList, ToggleList, custom entries)
- config: 67.3% → 89.8% (DHCP static lease mutation tests)
2026-07-14 04:21:30 +00:00
Paul Payne
1e7d93256e Support custom domains. Fix host-record/address resolution. 2026-07-13 00:26:13 +00:00
Paul Payne
518cdbbce5 Add DNS filtering with dnsmasq address=/ directives for wildcard blocking
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).
2026-07-12 12:44:19 +00:00
Paul Payne
5d8fe6a754 Removes more Wild Cloud cruft. 2026-07-11 23:21:23 +00:00
Paul Payne
f9d87ff975 Replace InstanceConfig with DNSEntry for dnsmasq config generation
InstanceConfig was a ~50-field struct designed for Wild Cloud k8s instances,
but only 3 fields were ever read by dnsmasq (the sole consumer). The
reconciliation bridge constructed fake InstanceConfig objects from registered
domains just to satisfy this interface.

Replace with DNSEntry{Domain, IP} — a 2-field struct purpose-built for
dnsmasq. All domains get local=/ directives to prevent AAAA queries from
leaking to upstream DNS (Happy Eyeballs / RFC 8305 latency on LAN).

Removed dead code: InstanceConfig, NodeConfig, LoadCloudConfig,
SaveCloudConfig, DeepMerge, LoadMergedInstanceConfig, EnsureInstanceConfig,
instance path helpers, instanceLoadBalancerIP, GenerateInstanceConfig,
and all modular per-instance dnsmasq methods.
2026-07-11 20:42:33 +00:00
Paul Payne
79c0c32b98 services -> domains 2026-07-10 20:46:22 +00:00
Paul Payne
5c26c7530a It's state, not config. 2026-07-10 05:21:03 +00:00
Paul Payne
234a8f8462 test: Add 22 unit tests across dnsmasq, haproxy, services
dnsmasq (6 new, 30.8% → 32%):
- Empty instances, multiple instances, nil config, configured IP
- No leaked empty entries (address=//, local=//)
- Empty fields don't produce broken entries

Also fixed a bug: empty Domain/InternalDomain fields in commented-out
entries (no LB IP case) now guarded against producing # local=// and
# address=// directives.

haproxy (7 new):
- No HTTPS frontend with no routes
- HTTP-only and L4-only route scenarios
- CertsDir customization and default
- Custom TCP route ports
- Health check directive in L7 backends

services (8 new, 72.7% → 75.8%):
- Not-found errors for Get, Deregister, Update
- DeregisterBySource partial match (source AND backend)
- Overwrite preserves file count
- Empty dir and non-YAML file handling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 21:07:12 +00:00
Paul Payne
e5cd6583f2 feat: Add comprehensive tests + fix startup reconciliation
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>
2026-07-09 20:55:27 +00:00
Paul Payne
170500e173 fix: Use configured dnsmasq IP instead of auto-detect
The auto-detect picks the default route interface (wlan0 at
192.168.8.152) but dnsmasq is bound to eth0 (192.168.8.151).
Now uses cloud.dnsmasq.ip from config as the Central IP for DNS
entries, falling back to auto-detect only if not configured.

Fixes DNS resolution failures when dnsmasq config points services
to the wrong interface IP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 19:24:13 +00:00
Paul Payne
900d25bab2 fix: Reload dnsmasq instead of restart to avoid DNS downtime
Use systemctl reload-or-restart instead of restart. dnsmasq supports
SIGHUP to reload config without dropping DNS service, preventing
transient DNS resolution failures during reconciliation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 15:54:34 +00:00
Paul Payne
e4e1a15f92 fix: Prevent broken dnsmasq entries for services without internal domain
Services registered via the service API have a domain but no internal
domain (that's a Wild Cloud instance concept). The dnsmasq generator
was producing broken entries like `local=//` and `address=//`.

Fix: skip internal domain entries when InternalDomain is empty, in
both config.go and config_modular.go. Also fix DNS entries to point
to Central's IP (where HAProxy listens) rather than the backend
address — all traffic flows through Central.

Added 3 tests covering: service-only, mixed services+instances.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 14:11:24 +00:00
Paul Payne
beb643f76f feat: Extract Wild Central as standalone Go service
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>
2026-07-08 23:31:16 +00:00