Compare commits

..

33 Commits

Author SHA1 Message Date
Paul Payne
c85d27a5d1 Use default vite port 5173 for Central web (Cloud uses 5174) 2026-07-14 15:51:29 +00:00
Paul Payne
a98557cb55 Simplify .envrc.example, dev env vars moved to parent 2026-07-14 15:49:37 +00:00
Paul Payne
0437494715 Remove dist .envrc, update env var hints to be generic 2026-07-14 15:38:07 +00:00
Paul Payne
e8b6449df3 Bump version to 0.2.0 2026-07-14 15:32:51 +00:00
Paul Payne
245d260a2b Rename wild-cloud → wild-central for all managed config files and nftables table
Completes the naming separation: nftables table, rules file, dnsmasq config,
resolved config, systemd unit, sudoers rule, and temp files now all use
the wild-central name.
2026-07-14 15:31:50 +00:00
Paul Payne
282d255c0a Gitignore .envrc, add .envrc.example
.envrc contains local config (data dir paths) and should not be tracked.
Add .envrc.example documenting available env vars with defaults.
2026-07-14 15:12:57 +00:00
Paul Payne
519df610e8 Use standard ports for dev: API 5055, NATS 4222 (same as prod) 2026-07-14 15:01:32 +00:00
Paul Payne
93700d7956 Separate Central data dir from Cloud — use /var/lib/wild-central (prod default) 2026-07-14 14:33:40 +00:00
Paul Payne
6f85d25362 Add login screen for API token authentication
AuthGate component wraps the app layout:
- On load, checks if saved token is valid by calling a protected endpoint
- If auth not required (dev mode), passes through immediately
- If auth required and no valid token, shows a clean login screen
- Token input with password field, stored in localStorage on success
- Shows guidance: "Find your token in secrets.yaml under api.bearerToken"
- Handles connection errors gracefully (shows app anyway)
2026-07-14 13:21:34 +00:00
Paul Payne
f5a030fd44 Add bearer token API authentication
- 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
2026-07-14 12:45:22 +00:00
Paul Payne
60f3ca4a3a Security hardening: input validation, secrets protection, NATS auth
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
2026-07-14 12:38:17 +00:00
Paul Payne
4500a1a45e Add certbot status to dashboard services and sidebar nav indicator
- Add certbot to getDaemonStatus: checks binary availability and version
- Dashboard: add Certificates card to services grid
- Sidebar: show green/red status dot for certbot on Certificates nav item
2026-07-14 12:18:07 +00:00
Paul Payne
8dd9e117bc Fix cert auto-provisioning to check wildcard coverage before provisioning
The auto-provisioner was checking for individual cert files (e.g.,
git.civilsociety.dev.pem) without checking if a wildcard cert already
covers the domain (*.civilsociety.dev.pem). This caused redundant
individual certs to be provisioned for domains already covered by
a wildcard.

Now uses hasCertForDomain() which checks both individual AND wildcard
cert coverage before deciding a cert is missing.
2026-07-14 12:10:49 +00:00
Paul Payne
947991da7f Add TLS Certificates page to Advanced section of web UI
New page at /advanced/certificates showing:
- Overview card with central domain, cert count, Cloudflare token status
- Auto-provision readiness badge (requires CF token + operator email)
- Table of all domains needing TLS with: domain, source, expiry date,
  issuer, and status badges (days left / covered by wildcard / missing)
- Per-domain "Provision" button for missing certs when auto-provision ready
- "Provision" and "Renew All" action buttons
- Guidance alert when prerequisites are missing

Also fixes pre-existing type errors:
- AppSidebar: guard daemon index access when undefined
- DomainsComponent: fix circular type reference in filter state
- DomainTopology: remove unused isWildcard variable
2026-07-14 12:08:43 +00:00
Paul Payne
a85bfbfe9e Auto-provision TLS certificates during reconciliation
Replace the passive "log warning about missing certs" approach with active
auto-provisioning:

- When Cloudflare token and operator email are configured, automatically
  provision missing certs via certbot DNS-01 during reconciliation
- When credentials aren't available, log actionable guidance (no longer
  references a non-existent "Certificates page")
- Track TLS health in reconciler Health struct (ok/degraded with missing
  cert list)
- Broadcast tls:recovered SSE event when all certs become available
- Add CertManager interface and GetCloudflareToken callback to reconciler

The convergence loop (5 min) continuously retries failed provisions,
so transient DNS-01 failures self-heal on the next tick.
2026-07-14 11:59:22 +00:00
Paul Payne
2bb162a794 Add per-subsystem config validation for wireguard, authelia, nftables, tunnel
- wireguard: ValidateConfig checks ListenPort range, Address/LanCIDR CIDR
  format. SaveConfig now validates before writing.
- authelia: ValidateConfigOpts checks required fields (Domain, JWTSecret,
  SessionSecret) and StorageEncKey minimum length (20 chars). GenerateConfig
  now validates before generating.
- nftables: ValidateWANInterface checks interface exists via net.InterfaceByName
  before generating rules that reference it.
- tunnel: ValidateConfig checks TunnelID, PublicDomain, GatewayDomain are set
  and credentials file exists. WriteConfig now validates before generating.
2026-07-14 11:54:58 +00:00
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
65c7e56b0a Fix APT repository configuration and update signing key instructions 2026-07-13 00:25:17 +00:00
Paul Payne
d0b645b9b5 Add error handling for Cloudflare API response status codes 2026-07-12 21:55:43 +00:00
Paul Payne
1d1d6c605c Adds dist. 2026-07-12 21:40:15 +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
134c01fe5e Add SMTP support for Authelia notifications and fix config persistence
SMTP: Add host, port, username, sender, and password fields to the
Authelia configuration. Uses modern address format (submission:// for
STARTTLS on 587, smtps:// for implicit TLS on 465) with explicit TLS
server name. Falls back to filesystem notifier when SMTP is not
configured.

Fixes: Config changes now persist before attempting service restart,
so a restart failure (e.g. bad SMTP credentials) no longer prevents
saving. The specific Authelia error is extracted from the journal and
shown in the UI.

Frontend: SMTP fields added to the Authentication config card. Form
no longer continuously resets from server state while user is editing.
OIDC client edit UI added (pencil icon).
2026-07-12 12:42:36 +00:00
Paul Payne
d796a79f79 Add Authelia as centralized authentication and OIDC provider
Authelia runs as a managed native service on Wild Central, providing
two integration patterns for network services:

- Forward-auth via HAProxy for apps without native SSO (Lua auth-request
  script intercepts requests, redirects unauthenticated users to login portal)
- OIDC provider for apps with native support (Gitea, Grafana, etc.)

Backend: new internal/authelia/ package with service manager, config
generation, file-based user management (argon2id), and OIDC client
management. API endpoints for status, config, users CRUD, and OIDC
clients CRUD.

HAProxy: config generator extended with lua-load, auth-request
directives, and Authelia backend. Auth directives scoped to session
cookie domain — only subdomains of the auth portal's parent are
eligible for forward-auth.

Frontend: Authentication page with enable/disable, user management,
OIDC client management (add/edit/delete), and protected domains
toggles. Advanced subsystem page for raw config view. Dashboard
service card and sidebar entries.
2026-07-12 11:47:37 +00:00
Paul Payne
24bb976652 Replace generic cloud-lightning logo with custom network nexus mark
The new icon represents Wild Central's role as a network hub —
a central node radiating connections to services. Updated favicon,
sidebar logo component, theme color, and PWA manifest name.
2026-07-12 04:25:41 +00:00
Paul Payne
43d407bf2e UX improvements. 2026-07-12 00:40:19 +00:00
Paul Payne
994c9fbfdf Better UX on domain forms. 2026-07-11 23:50:24 +00:00
Paul Payne
a48d955dc0 Remove unused DDNS functionality from DashboardComponent 2026-07-11 23:45:59 +00:00
Paul Payne
6490fef5d5 Added domain form func. 2026-07-11 23:40:56 +00:00
Paul Payne
9e8f23aab7 Update routing and sidebar links to remove '/central' prefix 2026-07-11 23:40:34 +00:00
111 changed files with 10589 additions and 1756 deletions

7
.envrc
View File

@@ -1,7 +0,0 @@
# Wild Central dev environment
# Shares data dir with Wild Cloud for development
export WILD_CENTRAL_ENV=development
export WILD_CENTRAL_DATA_DIR=$HOME/repos/wild-cloud-dev/wild-cloud-redmond-data
export WILD_CENTRAL_PORT=15055
export WILD_CENTRAL_NATS_PORT=14222
export WILD_CENTRAL_VITE_URL=http://localhost:5174

8
.envrc.example Normal file
View File

@@ -0,0 +1,8 @@
# Wild Central dev environment
# Most dev env vars are set in the parent wild-cloud-dev/.envrc
# Only create a local .envrc if you need to override defaults:
# export WILD_CENTRAL_DATA_DIR=/var/lib/wild-central
# export WILD_CENTRAL_PORT=5055
# export WILD_CENTRAL_NATS_PORT=4222
# export WILD_CENTRAL_VITE_URL=http://localhost:5173

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ coverage.out
coverage.html
.claude/
CLAUDE.md
.envrc

View File

@@ -16,3 +16,7 @@ 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/

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.2.0

30
dist/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Local dev secrets
.envrc
# Build artifacts
build/
dist/
*.deb
__debug*
# APT repository build artifacts
.aptly/
.aptly.conf
# Go build cache
*.o
*.a
*.so
# Test binary
*.test
# Coverage
*.out
*.cover
*.coverage
# Go workspace
go.work
go.work.sum
gpg/wild-central.gpg

164
dist/Makefile vendored Normal file
View File

@@ -0,0 +1,164 @@
# Default target
.DEFAULT_GOAL := help
# Build configuration
VERSION := $(shell cat ../VERSION 2>/dev/null || echo "0.0.0-dev")
BUILD_DIR := build
DIST_DIR := dist
DEB_DIR := debian-package
# Git information
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
EDGE_VERSION := 0.0.0~edge.$(shell date -u '+%Y%m%d%H%M%S').$(GIT_COMMIT)
# Source paths
API_SOURCE := ..
WEB_SOURCE := ../web
# Output files
ARM64_BINARY := $(BUILD_DIR)/wild-central-arm64
AMD64_BINARY := $(BUILD_DIR)/wild-central-amd64
WEB_DIST_MARKER := $(BUILD_DIR)/.web-built
ARM64_DEB := $(DIST_DIR)/packages/wild-central_$(VERSION)_arm64.deb
AMD64_DEB := $(DIST_DIR)/packages/wild-central_$(VERSION)_amd64.deb
# Gitea release configuration
GITEA_URL := https://git.civilsociety.dev
GITEA_OWNER := wild-cloud
GITEA_REPO := wild-central
RELEASE_TAG := v$(VERSION)
.PHONY: help clean version build build-all package package-all repo deploy-repo release release-edge
help:
@echo "Wild Central Package Builder"
@echo ""
@echo "This directory builds .deb packages for Wild Central."
@echo "For development, use the parent directory:"
@echo " - make dev (Go daemon)"
@echo " - cd web && pnpm dev (React frontend)"
@echo ""
@echo "Build targets:"
@echo " build - Build for current architecture (amd64)"
@echo " build-all - Build all architectures"
@echo ""
@echo "Package targets:"
@echo " package - Create .deb package (amd64)"
@echo " package-all - Create all .deb packages"
@echo ""
@echo "Repository targets:"
@echo " deploy-repo - Deploy repository to server"
@echo ""
@echo "Release targets:"
@echo " release - Tag HEAD as v$$(cat ../VERSION), publish stable Gitea release + APT"
@echo " release-edge - Publish rolling edge Gitea release + APT (no version bump needed)"
@echo ""
@echo "Utilities:"
@echo " clean - Remove all build artifacts"
@echo " version - Show build information"
@echo ""
@echo " Current stable version: $(VERSION) (from ../VERSION)"
@echo " Current edge version: $(EDGE_VERSION)"
@echo ""
@echo "Environment variables:"
@echo " GITEA_TOKEN - Gitea API token (required for release)"
@echo " APTLY_URL - Aptly instance URL (required for APT deployment)"
@echo " APTLY_PASS - Aptly API password"
@echo " These must be set in your environment"
clean:
@echo "Cleaning build artifacts..."
@rm -rf $(BUILD_DIR) $(DIST_DIR) $(DEB_DIR)-* $(DEB_DIR)
version:
@echo "Version: $(VERSION)"
@echo "Git Commit: $(GIT_COMMIT)"
@echo "Build Time: $(BUILD_TIME)"
# Web app build - track with marker file
$(WEB_DIST_MARKER): $(shell find $(WEB_SOURCE)/src -type f 2>/dev/null | head -100) $(WEB_SOURCE)/package.json
@echo "Building web application..."
@ARCH=amd64 VERSION=$(VERSION) ./scripts/build-package.sh
@touch $(WEB_DIST_MARKER)
# ARM64 binary
$(ARM64_BINARY): $(shell find $(API_SOURCE) -name '*.go' -not -path '*/dist/*' -not -path '*/web/*' 2>/dev/null | head -100)
@echo "Building arm64 binary..."
@mkdir -p $(BUILD_DIR)
@cd $(API_SOURCE) && GOOS=linux GOARCH=arm64 go build -ldflags="-X main.Version=$(VERSION)" -o dist/$(ARM64_BINARY) .
# AMD64 binary
$(AMD64_BINARY): $(shell find $(API_SOURCE) -name '*.go' -not -path '*/dist/*' -not -path '*/web/*' 2>/dev/null | head -100)
@echo "Building amd64 binary..."
@mkdir -p $(BUILD_DIR)
@cd $(API_SOURCE) && GOOS=linux GOARCH=amd64 go build -ldflags="-X main.Version=$(VERSION)" -o dist/$(AMD64_BINARY) .
# ARM64 package
$(ARM64_DEB): $(ARM64_BINARY) $(WEB_DIST_MARKER)
@echo "Creating arm64 .deb package..."
@rm -f $(DIST_DIR)/packages/wild-central_*_arm64.deb
@./scripts/package-deb.sh arm64 $(ARM64_BINARY) $(VERSION)
# AMD64 package
$(AMD64_DEB): $(AMD64_BINARY) $(WEB_DIST_MARKER)
@echo "Creating amd64 .deb package..."
@rm -f $(DIST_DIR)/packages/wild-central_*_amd64.deb
@./scripts/package-deb.sh amd64 $(AMD64_BINARY) $(VERSION)
# Convenience targets
build: $(AMD64_BINARY) $(WEB_DIST_MARKER)
build-all: $(ARM64_BINARY) $(AMD64_BINARY) $(WEB_DIST_MARKER)
package: $(AMD64_DEB)
package-all: $(ARM64_DEB) $(AMD64_DEB)
deploy-repo: $(ARM64_DEB) $(AMD64_DEB)
@echo "Deploying APT repository..."
@if [ -z "$(APTLY_URL)" ] || [ -z "$(APTLY_PASS)" ]; then \
echo "Error: APTLY_URL and APTLY_PASS must be set"; \
echo " These must be set in your environment"; \
exit 1; \
fi
@./scripts/deploy-apt-repository.sh
release: $(ARM64_DEB) $(AMD64_DEB)
@echo "Creating stable release $(RELEASE_TAG)..."
@if [ -z "$(GITEA_TOKEN)" ]; then \
echo "Error: GITEA_TOKEN environment variable not set"; \
echo " Get a token from $(GITEA_URL)/user/settings/applications"; \
echo " These must be set in your environment"; \
exit 1; \
fi
@if git tag | grep -q "^$(RELEASE_TAG)$$"; then \
echo "Tag $(RELEASE_TAG) already exists — updating release assets only"; \
else \
echo "Tagging HEAD as $(RELEASE_TAG)..."; \
git tag -a $(RELEASE_TAG) -m "Wild Central $(VERSION)"; \
git push origin $(RELEASE_TAG); \
fi
@./scripts/create-gitea-release.sh "$(GITEA_URL)" "$(GITEA_OWNER)" "$(GITEA_REPO)" "$(RELEASE_TAG)" "$(VERSION)" "$(GITEA_TOKEN)" stable
@if [ -n "$(APTLY_URL)" ] && [ -n "$(APTLY_PASS)" ]; then \
./scripts/deploy-apt-repository.sh; \
else \
echo "Skipping APT deployment (APTLY_URL/APTLY_PASS not set)"; \
fi
release-edge: $(ARM64_DEB) $(AMD64_DEB)
@echo "Creating edge release ($(EDGE_VERSION))..."
@if [ -z "$(GITEA_TOKEN)" ]; then \
echo "Error: GITEA_TOKEN environment variable not set"; \
echo " These must be set in your environment"; \
exit 1; \
fi
@echo "Force-pushing edge tag to HEAD..."
@git tag -f edge
@git push origin edge --force
@./scripts/create-gitea-release.sh "$(GITEA_URL)" "$(GITEA_OWNER)" "$(GITEA_REPO)" "edge" "$(EDGE_VERSION)" "$(GITEA_TOKEN)" edge
@if [ -n "$(APTLY_URL)" ] && [ -n "$(APTLY_PASS)" ]; then \
DIST=edge ./scripts/deploy-apt-repository.sh; \
else \
echo "Skipping APT deployment (APTLY_URL/APTLY_PASS not set)"; \
fi

76
dist/README.md vendored Normal file
View File

@@ -0,0 +1,76 @@
# Packaging Wild Central
## Installation
### APT Repository (recommended)
```bash
# Import the signing key
curl -fsSL https://aptly.cloud.payne.io/public.key \
| gpg --dearmor | sudo tee /usr/share/keyrings/wild.gpg > /dev/null
# Add the repository
sudo tee /etc/apt/sources.list.d/wild.sources <<EOF
Types: deb
URIs: https://aptly.cloud.payne.io
Suites: stable
Components: main
Signed-By: /usr/share/keyrings/wild.gpg
EOF
sudo apt update && sudo apt install wild-central
```
Use `Suites: edge` for rolling builds from HEAD.
### Manual .deb install
```bash
sudo dpkg -i wild-central_*.deb
sudo apt-get install -f
```
## Quick Start
1. **Start the service**:
```bash
sudo systemctl enable --now wild-central
```
2. **Access the web interface**: open `http://your-server-ip` in your browser
3. **Configure** (optional):
```bash
sudo cp /etc/wild-central/config.yaml.example /etc/wild-central/config.yaml
sudo nano /etc/wild-central/config.yaml
sudo systemctl restart wild-central
```
## Release Artifacts
Each release includes:
- `wild-central_{VERSION}_{arch}.deb` — full install (daemon + web UI)
- `wild-central-{arch}` — standalone daemon binary
- `SHA256SUMS` — checksums for all artifacts
## Developer Tooling
```bash
make help # Show all targets and current version info
make package-all # Build all .deb packages
make release # Stable release: tag + Gitea + APT stable
make release-edge # Edge release: force-tag + Gitea + APT edge
make deploy-repo # Deploy packages to APT repository only
```
Directory structure:
```
build/ Intermediate build artifacts
dist/bin/ Standalone binaries
dist/packages/ .deb packages
```
Environment variables (must be set in your environment):
- `GITEA_TOKEN` — required for `make release` / `make release-edge`
- `APTLY_URL` / `APTLY_PASS` — required for APT deployment

12
dist/debian/DEBIAN/control vendored Normal file
View File

@@ -0,0 +1,12 @@
Package: wild-central
Version: VERSION_PLACEHOLDER
Section: net
Priority: optional
Architecture: ARCH_PLACEHOLDER
Homepage: https://mywildcloud.org
Pre-Depends: dnsmasq, haproxy, nftables, wireguard-tools, certbot, python3-certbot-dns-cloudflare
Depends: adduser, nginx
Maintainer: Wild Cloud Team <paul@payne.io>
Description: Wild Central - Networking and Coordination Platform
Manages DNS, gateway routing, firewall, VPN, TLS certificates, and tunnels
for Wild Cloud and other self-hosted services on the local network.

262
dist/debian/DEBIAN/postinst vendored Normal file
View File

@@ -0,0 +1,262 @@
#!/bin/bash
set -e
case "$1" in
configure)
echo "Configuring wild-central..."
# Create wildcloud user if it doesn't exist
if ! id wildcloud >/dev/null 2>&1; then
useradd --system --home-dir /var/lib/wild-central --create-home --shell /bin/false wildcloud
fi
# Create required directories
mkdir -p /var/lib/wild-central
mkdir -p /var/log/wild-central
mkdir -p /etc/wild-central
# Set ownership
chown -R wildcloud:wildcloud /var/lib/wild-central
chown wildcloud:wildcloud /var/log/wild-central
# Set up dnsmasq directories and permissions
mkdir -p /etc/dnsmasq.d
mkdir -p /etc/dnsmasq.d/wild-cloud-instances
# Give wildcloud group write access to dnsmasq.d directory
chgrp wildcloud /etc/dnsmasq.d
chmod 775 /etc/dnsmasq.d
# Create or fix ownership of wild-central.conf
if [ ! -f /etc/dnsmasq.d/wild-central.conf ]; then
touch /etc/dnsmasq.d/wild-central.conf
echo "Created /etc/dnsmasq.d/wild-central.conf"
else
echo "Found existing /etc/dnsmasq.d/wild-central.conf - updating ownership"
fi
chown wildcloud:wildcloud /etc/dnsmasq.d/wild-central.conf
chmod 644 /etc/dnsmasq.d/wild-central.conf
# Set ownership and permissions for instance configs directory
chown wildcloud:wildcloud /etc/dnsmasq.d/wild-cloud-instances
chmod 755 /etc/dnsmasq.d/wild-cloud-instances
echo "Created /etc/dnsmasq.d/wild-cloud-instances/ for per-instance DNS configs"
# Set up systemd-resolved configuration directory and file
mkdir -p /etc/systemd/resolved.conf.d
if [ ! -f /etc/systemd/resolved.conf.d/wild-central.conf ]; then
touch /etc/systemd/resolved.conf.d/wild-central.conf
echo "Created /etc/systemd/resolved.conf.d/wild-central.conf"
else
echo "Found existing /etc/systemd/resolved.conf.d/wild-central.conf"
fi
chown wildcloud:wildcloud /etc/systemd/resolved.conf.d/wild-central.conf
chmod 644 /etc/systemd/resolved.conf.d/wild-central.conf
# Ensure /etc/resolv.conf is a symlink to systemd-resolved stub
# Skip in Docker/container environments where resolv.conf might be bind-mounted
if [ ! -L /etc/resolv.conf ] || [ "$(readlink /etc/resolv.conf)" != "/run/systemd/resolve/stub-resolv.conf" ]; then
echo "Configuring /etc/resolv.conf to use systemd-resolved stub mode"
if [ -f /etc/resolv.conf ] && [ ! -L /etc/resolv.conf ]; then
cp /etc/resolv.conf /etc/resolv.conf.wild-backup 2>/dev/null || echo "Note: Could not backup /etc/resolv.conf (may be read-only)"
fi
if rm -f /etc/resolv.conf 2>/dev/null; then
ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
echo "Set /etc/resolv.conf to use systemd-resolved stub mode"
else
echo "Note: /etc/resolv.conf is read-only or busy (container environment?), skipping symlink setup"
fi
fi
# Set up HAProxy configuration directory
mkdir -p /etc/haproxy
if [ ! -f /etc/haproxy/haproxy.cfg ]; then
touch /etc/haproxy/haproxy.cfg
echo "Created /etc/haproxy/haproxy.cfg"
fi
chown wildcloud:wildcloud /etc/haproxy
chown wildcloud:wildcloud /etc/haproxy/haproxy.cfg
chmod 644 /etc/haproxy/haproxy.cfg
# Set up HAProxy TLS certs directory
mkdir -p /etc/haproxy/certs
chown wildcloud:wildcloud /etc/haproxy/certs
chmod 700 /etc/haproxy/certs
echo "Configured HAProxy for Wild Central management"
# Set up WireGuard configuration directory
mkdir -p /etc/wireguard
chown wildcloud:wildcloud /etc/wireguard
chmod 700 /etc/wireguard
if [ ! -f /etc/wireguard/wg0.conf ]; then
touch /etc/wireguard/wg0.conf
echo "Created /etc/wireguard/wg0.conf"
fi
chown wildcloud:wildcloud /etc/wireguard/wg0.conf
chmod 600 /etc/wireguard/wg0.conf
echo "Configured WireGuard for Wild Central management"
# Set up nftables configuration directory
mkdir -p /etc/nftables.d
if [ ! -f /etc/nftables.d/wild-central.nft ]; then
touch /etc/nftables.d/wild-central.nft
echo "Created /etc/nftables.d/wild-central.nft"
fi
chown wildcloud:wildcloud /etc/nftables.d/wild-central.nft
chmod 644 /etc/nftables.d/wild-central.nft
# Ensure nftables.conf includes wild-central rules
if [ -f /etc/nftables.conf ] && ! grep -q 'wild-central.nft' /etc/nftables.conf; then
echo 'include "/etc/nftables.d/wild-central.nft"' >> /etc/nftables.conf
echo "Added Wild Central nftables include to /etc/nftables.conf"
fi
echo "Configured nftables for Wild Central management"
# Install CrowdSec if not already installed
if ! command -v cscli >/dev/null 2>&1; then
echo "Installing CrowdSec..."
curl -s https://packagecloud.io/install/repositories/crowdsec/crowdsec/script.deb.sh | os=ubuntu dist=noble bash
apt-get install -y crowdsec
echo "Installed CrowdSec"
else
echo "CrowdSec already installed: $(cscli version 2>/dev/null | head -1 || echo 'unknown')"
fi
# Configure CrowdSec LAPI to listen on all interfaces
CROWDSEC_CONFIG=/etc/crowdsec/config.yaml
if [ -f "$CROWDSEC_CONFIG" ] && command -v yq >/dev/null 2>&1; then
yq e -i '.api.server.listen_uri = "0.0.0.0:8080"' "$CROWDSEC_CONFIG"
echo "Configured CrowdSec LAPI to listen on 0.0.0.0:8080"
fi
# Ensure CrowdSec is running so the firewall bouncer can register with LAPI
if [ -d /run/systemd/system ]; then
systemctl start crowdsec 2>/dev/null || true
sleep 2
fi
# Install CrowdSec firewall bouncer
if ! command -v crowdsec-firewall-bouncer >/dev/null 2>&1; then
echo "Installing CrowdSec firewall bouncer..."
apt-get install -y crowdsec-firewall-bouncer
echo "Installed CrowdSec firewall bouncer"
else
echo "CrowdSec firewall bouncer already installed"
fi
# Install Authelia if not already installed
if ! command -v authelia >/dev/null 2>&1; then
echo "Installing Authelia..."
curl -fsSL https://apt.authelia.com/organization/signing.asc | \
gpg --dearmor -o /usr/share/keyrings/authelia-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/authelia-archive-keyring.gpg] \
https://apt.authelia.com/integration/apt/repo/stable/debian debian main" \
> /etc/apt/sources.list.d/authelia.list
apt-get update -qq
apt-get install -y authelia
echo "Installed Authelia"
else
echo "Authelia already installed"
fi
# Install HAProxy Lua scripts for Authelia auth-request forwarding
mkdir -p /etc/haproxy/lua
if [ ! -f /etc/haproxy/lua/haproxy-auth-request.lua ]; then
curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua \
https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua
echo "Installed haproxy-auth-request.lua"
fi
if [ ! -f /etc/haproxy/lua/haproxy-lua-http.lua ]; then
curl -fsSL -o /etc/haproxy/lua/haproxy-lua-http.lua \
https://raw.githubusercontent.com/haproxytech/haproxy-lua-http/master/http.lua
echo "Installed haproxy-lua-http.lua"
fi
apt-get install -y lua-json 2>/dev/null || true
# Configure Authelia systemd override to use Wild Central config path
mkdir -p /etc/systemd/system/authelia.service.d
cat > /etc/systemd/system/authelia.service.d/wild-central.conf << 'AUTHELIA_EOF'
[Service]
ExecStart=
ExecStart=/usr/bin/authelia --config /var/lib/wild-central/authelia/configuration.yml
AUTHELIA_EOF
echo "Configured Authelia systemd override"
# Create Authelia data directory
mkdir -p /var/lib/wild-central/authelia
chown wildcloud:wildcloud /var/lib/wild-central/authelia
chmod 700 /var/lib/wild-central/authelia
# Install sudoers rules for privileged operations
cat > /etc/sudoers.d/wild-central << 'SUDOERS_EOF'
wildcloud ALL=(ALL) NOPASSWD: /usr/sbin/nft -c -f *, /usr/sbin/nft list table inet wild-central, /usr/bin/cscli bouncers *, /usr/bin/cscli machines *, /usr/bin/cscli decisions *, /usr/bin/cscli alerts *, /usr/bin/cscli version, /usr/bin/wg-quick up wg0, /usr/bin/wg-quick down wg0, /usr/bin/wg show wg0, /usr/bin/certbot *, /usr/sbin/nginx -t, /usr/bin/systemctl reload nginx
SUDOERS_EOF
chmod 440 /etc/sudoers.d/wild-central
echo "Installed sudoers rules for Wild Central"
# Install polkit rules for service management
mkdir -p /etc/polkit-1/rules.d
cat > /etc/polkit-1/rules.d/50-wild-central.rules << 'POLKIT_EOF'
/* Allow wildcloud user to manage Wild Central services */
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
(action.lookup("unit") == "dnsmasq.service" ||
action.lookup("unit") == "systemd-resolved.service" ||
action.lookup("unit") == "haproxy.service" ||
action.lookup("unit") == "wild-central-nftables-reload.service" ||
action.lookup("unit") == "crowdsec.service" ||
action.lookup("unit") == "crowdsec-firewall-bouncer.service" ||
action.lookup("unit") == "wg-quick@wg0.service" ||
action.lookup("unit") == "cloudflared.service" ||
action.lookup("unit") == "authelia.service" ||
action.lookup("unit") == "wild-central.service") &&
subject.user == "wildcloud") {
return polkit.Result.YES;
}
});
POLKIT_EOF
chmod 644 /etc/polkit-1/rules.d/50-wild-central.rules
echo "Installed polkit rules for service management"
# Enable nginx site (disable default site, enable wild-central)
if [ -f /etc/nginx/sites-enabled/default ]; then
rm -f /etc/nginx/sites-enabled/default
echo "Disabled default nginx site"
fi
if [ ! -L /etc/nginx/sites-enabled/wild-central ]; then
ln -sf /etc/nginx/sites-available/wild-central /etc/nginx/sites-enabled/wild-central
echo "Enabled wild-central nginx site"
fi
# Reload nginx to pick up the new site
if systemctl is-active --quiet nginx; then
nginx -t && systemctl reload nginx
echo "Reloaded nginx configuration"
fi
# Enable and start the service
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
systemctl enable wild-central.service
echo "wild-central configured successfully"
echo "Start the service with: sudo systemctl start wild-central"
echo "View logs with: sudo journalctl -u wild-central -f"
else
echo "Note: systemd not detected (container environment?), skipping service enable"
echo "wild-central installed successfully"
fi
;;
abort-upgrade|abort-remove|abort-deconfigure)
;;
*)
echo "postinst called with unknown argument \`$1'" >&2
exit 1
;;
esac
exit 0

80
dist/debian/DEBIAN/postrm vendored Normal file
View File

@@ -0,0 +1,80 @@
#!/bin/bash
set -e
case "$1" in
purge)
echo "Purging wild-central configuration..."
# Remove polkit rules
if [ -f /etc/polkit-1/rules.d/50-wild-central.rules ]; then
rm -f /etc/polkit-1/rules.d/50-wild-central.rules
echo "Removed polkit rules"
fi
# Remove sudoers file
if [ -f /etc/sudoers.d/wild-central ]; then
rm -f /etc/sudoers.d/wild-central
fi
# Remove dnsmasq configuration
if [ -f /etc/dnsmasq.d/wild-central.conf ]; then
rm -f /etc/dnsmasq.d/wild-central.conf
echo "Removed dnsmasq configuration"
fi
# Remove systemd-resolved configuration
if [ -f /etc/systemd/resolved.conf.d/wild-central.conf ]; then
rm -f /etc/systemd/resolved.conf.d/wild-central.conf
echo "Removed systemd-resolved configuration"
systemctl restart systemd-resolved 2>/dev/null || true
fi
# Restore /etc/resolv.conf if we have a backup
if [ -f /etc/resolv.conf.wild-backup ]; then
echo "Restoring original /etc/resolv.conf from backup"
rm -f /etc/resolv.conf
mv /etc/resolv.conf.wild-backup /etc/resolv.conf
fi
# Remove configuration directory
if [ -d /etc/wild-central ]; then
rm -rf /etc/wild-central
fi
# Remove log directory
if [ -d /var/log/wild-central ]; then
rm -rf /var/log/wild-central
fi
# Remove lib directory
if [ -d /var/lib/wild-central ]; then
rm -rf /var/lib/wild-central
fi
# Remove nginx site
if [ -L /etc/nginx/sites-enabled/wild-central ]; then
rm -f /etc/nginx/sites-enabled/wild-central
echo "Disabled wild-central nginx site"
fi
# Only remove wildcloud user if wild-cloud package is not installed
if ! dpkg -s wild-cloud >/dev/null 2>&1; then
if id wildcloud >/dev/null 2>&1; then
userdel wildcloud || true
fi
fi
echo "wild-central purged successfully"
;;
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
;;
*)
echo "postrm called with unknown argument \`$1'" >&2
exit 1
;;
esac
exit 0

28
dist/debian/DEBIAN/prerm vendored Normal file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
set -e
case "$1" in
remove|upgrade|deconfigure)
echo "Stopping wild-central service..."
# Stop and disable the service
if systemctl is-active --quiet wild-central; then
systemctl stop wild-central
fi
if systemctl is-enabled --quiet wild-central; then
systemctl disable wild-central
fi
;;
failed-upgrade)
;;
*)
echo "prerm called with unknown argument \`$1'" >&2
exit 1
;;
esac
exit 0

View File

@@ -0,0 +1,34 @@
server {
listen 80;
server_name _;
# Wild Central Management Interface
root /var/www/html/wild-central;
index index.html;
# API proxy to wild-central service
location /api/ {
proxy_pass http://localhost:5055;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SSE events proxy (long-lived connections)
location /api/v1/events {
proxy_pass http://localhost:5055;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
}
# Static files - SPA routing support
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Apply Wild Central nftables rules
Documentation=https://mywildcloud.org
After=network.target nftables.service
[Service]
Type=oneshot
ExecStart=/usr/sbin/nft -f /etc/nftables.d/wild-central.nft
RemainAfterExit=no

View File

@@ -0,0 +1,28 @@
[Unit]
Description=Wild Central Service
Documentation=https://mywildcloud.org
After=network.target
Wants=network.target
[Service]
Type=simple
User=wildcloud
Group=wildcloud
ExecStart=/usr/bin/wild-central
Restart=always
RestartSec=5
Environment=WILD_CENTRAL_DATA_DIR=/var/lib/wild-central
Environment=WILD_CENTRAL_STATIC_DIR=/var/www/html/wild-central
StandardOutput=journal
StandardError=journal
SyslogIdentifier=wild-central
# Security settings
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
ProtectHome=yes
ReadWritePaths=/etc/wild-central /var/lib/wild-central /var/log/wild-central /etc/dnsmasq.d /etc/systemd/resolved.conf.d /etc/haproxy /etc/nftables.d /etc/wireguard
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,42 @@
cloud:
router:
ip: 192.168.8.1
dnsmasq:
ip: 192.168.8.50
interface: eth0
# DHCP server (disabled by default — enable to take over DHCP from your router)
# dhcp:
# enabled: false
# rangeStart: "192.168.8.100"
# rangeEnd: "192.168.8.200"
# leaseTime: "24h"
# gateway: "" # defaults to cloud.router.ip
# staticLeases: # managed via POST /api/v1/dnsmasq/dhcp/static
# - mac: "aa:bb:cc:dd:ee:ff"
# ip: "192.168.8.10"
# hostname: "node1"
# HAProxy ingress proxy — routes external traffic by SNI.
# Use POST /api/v1/haproxy/generate to apply after changing this config.
haproxy:
# Optional custom TCP routes for non-Wild Cloud services on your network.
# customRoutes:
# - name: civil_ssh
# port: 2222 # external listen port
# backend: 192.168.8.10:22
# nftables host firewall — tracks ports HAProxy is serving.
# Set wanInterface to enable strict WAN filtering (drops all unrecognised inbound traffic).
# Leave unset for default accept-all policy (safe during initial setup).
# nftables:
# wanInterface: eth0
# Dynamic DNS — updates DNS A records when your public IP changes.
# API token goes in secrets.yaml under cloudflare.apiToken.
# ddns:
# enabled: false
# provider: cloudflare
# records:
# - cloud.example.com
# - cloud2.example.com
# intervalMinutes: 5

71
dist/scripts/build-package.sh vendored Executable file
View File

@@ -0,0 +1,71 @@
#!/bin/bash
set -e
# Build configuration
VERSION="${VERSION:-0.1.0}"
ARCH="${ARCH:-amd64}"
BUILD_DIR="build"
SRC_DIR="$BUILD_DIR/src"
BINARY_NAME="wild-central"
# Source paths (relative to dist directory)
API_SOURCE=".."
WEBAPP_SOURCE="../web"
echo "Building Wild Central v${VERSION} for ${ARCH}"
echo "================================================"
# Clean and create build directories
echo "Preparing build directories..."
rm -rf "$SRC_DIR"
mkdir -p "$SRC_DIR"
mkdir -p "$BUILD_DIR"
# Copy API source
echo ""
echo "Copying API source..."
rsync -a --exclude='dist/' --exclude='web/' "$API_SOURCE/" "$SRC_DIR/api/"
cd "$SRC_DIR/api"
# Build the daemon
echo "Building daemon binary..."
if [ "$ARCH" = "arm64" ]; then
GOOS=linux GOARCH=arm64 go build -ldflags="-X main.Version=$VERSION" -o "../../$BINARY_NAME" .
elif [ "$ARCH" = "amd64" ]; then
GOOS=linux GOARCH=amd64 go build -ldflags="-X main.Version=$VERSION" -o "../../$BINARY_NAME" .
else
echo "Unsupported architecture: $ARCH"
exit 1
fi
cd - > /dev/null
echo "Daemon binary built: $BUILD_DIR/$BINARY_NAME"
# Copy web app source
echo ""
echo "Copying web app source..."
cp -r "$WEBAPP_SOURCE" "$SRC_DIR/web"
cd "$SRC_DIR/web"
# Remove any local env overrides
rm -f .env.local .env.*.local
# Build the web app
echo "Building web application..."
pnpm install --frozen-lockfile
# For production builds, use empty base URL so the app uses relative paths
echo "VITE_API_BASE_URL=" > .env.production.local
pnpm run build --mode production || {
echo "Build with type checking failed, trying without type check..."
pnpm exec vite build
}
cd - > /dev/null
echo "Web app built: $SRC_DIR/web/dist/"
echo ""
echo "Build complete!"
echo " Binary: $BUILD_DIR/$BINARY_NAME"
echo " Web App: $SRC_DIR/web/dist/"

189
dist/scripts/create-gitea-release.sh vendored Executable file
View File

@@ -0,0 +1,189 @@
#!/bin/bash
set -euo pipefail
GITEA_URL="$1"
OWNER="$2"
REPO="$3"
TAG="$4"
VERSION="$5"
TOKEN="$6"
CHANNEL="${7:-stable}" # "stable" or "edge"
API_URL="${GITEA_URL}/api/v1"
PACKAGES_DIR="dist/packages"
BINARIES_DIR="dist/bin"
SKIP_RELEASE_CREATION=false
echo "Release configuration:"
echo " Repository: ${OWNER}/${REPO}"
echo " Tag: ${TAG}"
echo " Version: ${VERSION}"
echo ""
# Check if release already exists
echo "Checking if release ${TAG} exists..."
RELEASE_ID=$(curl -s -H "Authorization: token ${TOKEN}" \
"${API_URL}/repos/${OWNER}/${REPO}/releases/tags/${TAG}" \
| jq -r '.id // empty')
if [ -n "$RELEASE_ID" ]; then
echo "Release ${TAG} exists (ID: ${RELEASE_ID})"
echo "Updating existing release assets..."
# Get existing assets
EXISTING_ASSETS=$(curl -s -H "Authorization: token ${TOKEN}" \
"${API_URL}/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets" \
| jq -r '.[].id')
# Delete old assets
for ASSET_ID in $EXISTING_ASSETS; do
ASSET_NAME=$(curl -s -H "Authorization: token ${TOKEN}" \
"${API_URL}/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}" \
| jq -r '.name')
if [[ "$ASSET_NAME" == *.deb ]] || [[ "$ASSET_NAME" == wild-central-* ]] || [[ "$ASSET_NAME" == "SHA256SUMS" ]]; then
echo " Deleting old asset: ${ASSET_NAME}"
curl -s -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_URL}/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
done
echo "Cleaned up old package assets"
SKIP_RELEASE_CREATION=true
fi
# Extract changelog section for this version
CHANGELOG_SECTION=""
if [ -f "../CHANGELOG.md" ]; then
CHANGELOG_SECTION=$(awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" ../CHANGELOG.md | sed '/^$/d')
fi
INSTALL_DOCS="### Installation
#### Full Installation (.deb package)
Download the appropriate .deb package for your architecture:
- **arm64**: \`wild-central_${VERSION}_arm64.deb\` — Raspberry Pi 4/5, ARM servers
- **amd64**: \`wild-central_${VERSION}_amd64.deb\` — x86_64 systems
\`\`\`bash
wget ${GITEA_URL}/${OWNER}/${REPO}/releases/download/v${VERSION}/wild-central_${VERSION}_amd64.deb
sudo dpkg -i wild-central_${VERSION}_amd64.deb
sudo apt-get install -f
sudo systemctl enable --now wild-central
\`\`\`
#### Standalone Binary
- \`wild-central-{arch}\` — Daemon binary only (Docker/K8s)
#### Verification
\`\`\`bash
wget ${GITEA_URL}/${OWNER}/${REPO}/releases/download/v${VERSION}/SHA256SUMS
sha256sum -c SHA256SUMS
\`\`\`"
if [ -n "$CHANGELOG_SECTION" ]; then
BODY="${CHANGELOG_SECTION}
---
${INSTALL_DOCS}"
else
BODY="${INSTALL_DOCS}"
fi
# Set release name and prerelease flag based on channel
if [ "$CHANNEL" = "edge" ]; then
RELEASE_NAME="Wild Central (edge) — ${VERSION}"
IS_PRERELEASE=true
else
RELEASE_NAME="Wild Central ${VERSION}"
IS_PRERELEASE=false
fi
# Create new release if it doesn't exist
if [ "$SKIP_RELEASE_CREATION" != "true" ]; then
echo "Creating new release ${TAG}..."
RELEASE_DATA=$(jq -n \
--arg tag "${TAG}" \
--arg name "${RELEASE_NAME}" \
--arg body "${BODY}" \
--argjson prerelease "${IS_PRERELEASE}" \
'{"tag_name": $tag, "name": $name, "body": $body, "draft": false, "prerelease": $prerelease}')
RELEASE_RESPONSE=$(curl -s -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "${RELEASE_DATA}" \
"${API_URL}/repos/${OWNER}/${REPO}/releases")
RELEASE_ID=$(echo "${RELEASE_RESPONSE}" | jq -r '.id')
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "Failed to create release"
echo "${RELEASE_RESPONSE}" | jq .
exit 1
fi
echo "Created release ${TAG} (ID: ${RELEASE_ID})"
fi
# Generate checksums
echo ""
echo "Generating checksums..."
cd dist
sha256sum packages/*.deb bin/wild-central-* > SHA256SUMS
echo "Created SHA256SUMS"
cd ..
# Upload artifacts
echo ""
echo "Uploading release artifacts..."
upload_file() {
local FILE="$1"
local FILENAME=$(basename "$FILE")
echo " Uploading ${FILENAME}..."
UPLOAD_RESPONSE=$(curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${FILE}" \
"${API_URL}/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}")
ASSET_ID=$(echo "${UPLOAD_RESPONSE}" | jq -r '.id')
if [ -z "$ASSET_ID" ] || [ "$ASSET_ID" = "null" ]; then
echo " Failed to upload ${FILENAME}"
echo "${UPLOAD_RESPONSE}" | jq .
return 1
else
echo " Uploaded ${FILENAME}"
return 0
fi
}
# Upload .deb packages
for DEB in ${PACKAGES_DIR}/wild-central_${VERSION}_*.deb; do
if [ ! -f "$DEB" ]; then
echo "No .deb files found for version ${VERSION} in ${PACKAGES_DIR}"
exit 1
fi
upload_file "$DEB"
done
# Upload standalone daemon binaries
for BINARY in ${BINARIES_DIR}/wild-central-*; do
if [ -f "$BINARY" ]; then
upload_file "$BINARY"
fi
done
# Upload checksums
upload_file "dist/SHA256SUMS"
echo ""
echo "Release complete!"
echo "View at: ${GITEA_URL}/${OWNER}/${REPO}/releases/tag/${TAG}"

141
dist/scripts/deploy-apt-repository.sh vendored Executable file
View File

@@ -0,0 +1,141 @@
#!/bin/bash
set -euo pipefail
# Required environment variables:
# APTLY_URL - Aptly server URL (e.g. https://aptly.cloud2.payne.io)
# APTLY_PASS - Aptly API password
# Optional:
# APTLY_USER - API username (default: aptly)
# APTLY_GPG_KEY - GPG key fingerprint for signing; if unset, signing is skipped
APTLY_URL="${APTLY_URL:?APTLY_URL is required}"
APTLY_PASS="${APTLY_PASS:?APTLY_PASS is required}"
APTLY_USER="${APTLY_USER:-aptly}"
APTLY_GPG_KEY="${APTLY_GPG_KEY:-}"
VERSION=$(cat ../VERSION 2>/dev/null || echo "0.0.0-dev")
REPO_NAME="wild"
DIST="${DIST:-stable}"
COMPONENT="main"
PACKAGE_DIR="dist/packages"
SNAP_NAME="wild-central-${VERSION}"
AUTH="${APTLY_USER}:${APTLY_PASS}"
PACKAGE_GLOB="wild-central_${VERSION}_*.deb"
echo "Deploying Wild Central ${VERSION} to APT repository..."
echo " URL: ${APTLY_URL}"
# Signing config — use server-side GPG key
# The key must exist in Aptly's GPG keyring (auto-generated by the init-gpg container)
# Get the fingerprint: curl -sf ${APTLY_URL}/public.key | gpg --with-colons --import-options show-only --import | grep fpr | cut -d: -f10
if [ -n "$APTLY_GPG_KEY" ]; then
SIGNING="{\"GpgKey\":\"${APTLY_GPG_KEY}\",\"Batch\":true}"
else
echo "APTLY_GPG_KEY not set — publishing without GPG signature"
SIGNING="{\"Skip\":true}"
fi
api() {
local METHOD="$1"
local ENDPOINT="$2"
local DATA="${3:-}"
if [ -n "$DATA" ]; then
curl -sf --http1.1 -u "$AUTH" -X "$METHOD" \
-H 'Content-Type: application/json' \
-d "$DATA" \
"${APTLY_URL}/api${ENDPOINT}"
else
curl -sf --http1.1 -u "$AUTH" -X "$METHOD" "${APTLY_URL}/api${ENDPOINT}"
fi
}
# 1. Ensure local repo exists
echo ""
echo "Ensuring repository '${REPO_NAME}' exists..."
if ! api GET "/repos/${REPO_NAME}" > /dev/null 2>&1; then
api POST "/repos" \
"{\"Name\":\"${REPO_NAME}\",\"DefaultDistribution\":\"${DIST}\",\"DefaultComponent\":\"${COMPONENT}\"}" \
> /dev/null
echo " Created '${REPO_NAME}'"
else
echo " '${REPO_NAME}' already exists"
fi
# 2. Upload .deb files
echo ""
echo "Uploading packages..."
UPLOAD_DIR="${REPO_NAME}-${VERSION}"
FOUND=0
for DEB in "${PACKAGE_DIR}"/${PACKAGE_GLOB}; do
[ -f "$DEB" ] || continue
FOUND=1
FILENAME=$(basename "$DEB")
echo " ${FILENAME}..."
curl -sf --http1.1 -u "$AUTH" -X POST \
-F "file=@${DEB}" \
"${APTLY_URL}/api/files/${UPLOAD_DIR}" > /dev/null
done
if [ "$FOUND" -eq 0 ]; then
echo "No .deb files found for version ${VERSION} in ${PACKAGE_DIR}"
exit 1
fi
echo " Uploaded"
# 3. Add packages to repo
echo ""
echo "Adding packages to '${REPO_NAME}'..."
api POST "/repos/${REPO_NAME}/file/${UPLOAD_DIR}" > /dev/null
echo " Added"
# 4. Clean up uploaded files from aptly's staging area
api DELETE "/files/${UPLOAD_DIR}" > /dev/null 2>&1 || true
# 5. Create snapshot for this version
echo ""
echo "Snapshotting as '${SNAP_NAME}'..."
if api GET "/snapshots/${SNAP_NAME}" > /dev/null 2>&1; then
echo " Snapshot '${SNAP_NAME}' already exists, using as-is"
else
api POST "/repos/${REPO_NAME}/snapshots" \
"{\"Name\":\"${SNAP_NAME}\",\"Description\":\"Wild Central ${VERSION}\"}" \
> /dev/null
echo " Snapshot '${SNAP_NAME}' created"
fi
# 6. Publish or update
echo ""
PUBLISH_PREFIX=$(api GET "/publish" \
| jq -r ".[] | select(.Distribution == \"${DIST}\") | .Prefix" 2>/dev/null \
| head -1)
UPDATE_BODY="{\"Snapshots\":[{\"Component\":\"${COMPONENT}\",\"Name\":\"${SNAP_NAME}\"}],\"Signing\":${SIGNING},\"ForceOverwrite\":true}"
if [ -z "$PUBLISH_PREFIX" ]; then
echo "Publishing '${DIST}' for the first time..."
api POST "/publish" \
"{\"SourceKind\":\"snapshot\",\"Sources\":[{\"Component\":\"${COMPONENT}\",\"Name\":\"${SNAP_NAME}\"}],\"Distribution\":\"${DIST}\",\"Architectures\":[\"amd64\",\"arm64\"],\"Signing\":${SIGNING}}" \
> /dev/null
else
echo "Updating published '${DIST}'..."
URL_PREFIX=$(echo "$PUBLISH_PREFIX" | sed 's/^\.$/_/')
api PUT "/publish/${URL_PREFIX}/${DIST}" "$UPDATE_BODY" > /dev/null
fi
echo " Published"
echo ""
echo "Wild Central ${VERSION} is live."
echo ""
echo "To install on a new device:"
echo ""
echo " curl -fsSL ${APTLY_URL}/public.key \\"
echo " | gpg --dearmor | sudo tee /usr/share/keyrings/wild.gpg > /dev/null"
echo ""
echo " sudo tee /etc/apt/sources.list.d/wild.sources <<EOF"
echo " Types: deb"
echo " URIs: ${APTLY_URL}"
echo " Suites: ${DIST}"
echo " Components: ${COMPONENT}"
echo " Signed-By: /usr/share/keyrings/wild.gpg"
echo " EOF"
echo ""
echo " sudo apt update && sudo apt install wild-central"

49
dist/scripts/package-deb.sh vendored Executable file
View File

@@ -0,0 +1,49 @@
#!/bin/bash
set -euo pipefail
ARCH="$1"
BINARY_PATH="$2"
VERSION="$3"
BUILD_DIR="build"
DIST_DIR="dist"
DEB_DIR="debian-package"
WEB_DIST="${BUILD_DIR}/src/web/dist"
echo "Creating .deb package for ${ARCH}..."
# Create directories
mkdir -p "${DIST_DIR}/bin" "${DIST_DIR}/packages"
# Copy debian package structure
cp -r debian/ "${BUILD_DIR}/${DEB_DIR}-${ARCH}/"
# Copy binary to correct location
mkdir -p "${BUILD_DIR}/${DEB_DIR}-${ARCH}/usr/bin"
cp "${BINARY_PATH}" "${BUILD_DIR}/${DEB_DIR}-${ARCH}/usr/bin/wild-central"
# Copy static web files from built web app
mkdir -p "${BUILD_DIR}/${DEB_DIR}-${ARCH}/var/www/html/wild-central"
if [ -d "${WEB_DIST}" ]; then
cp -r "${WEB_DIST}"/* "${BUILD_DIR}/${DEB_DIR}-${ARCH}/var/www/html/wild-central/"
echo "Copied web app files"
else
echo "Warning: Web app dist not found at ${WEB_DIST}"
fi
# Set script permissions
chmod 755 "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/postinst"
chmod 755 "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/prerm"
chmod 755 "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/postrm"
# Substitute placeholders in control file
sed -i "s/VERSION_PLACEHOLDER/${VERSION}/g" "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/control"
sed -i "s/ARCH_PLACEHOLDER/${ARCH}/g" "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/control"
# Build package and copy to dist directories
dpkg-deb --root-owner-group --build "${BUILD_DIR}/${DEB_DIR}-${ARCH}" "${BUILD_DIR}/wild-central_${VERSION}_${ARCH}.deb"
cp "${BINARY_PATH}" "${DIST_DIR}/bin/wild-central-${ARCH}"
cp "${BUILD_DIR}/wild-central_${VERSION}_${ARCH}.deb" "${DIST_DIR}/packages/"
echo "Package created: ${DIST_DIR}/packages/wild-central_${VERSION}_${ARCH}.deb"
echo "Daemon binary copied: ${DIST_DIR}/bin/wild-central-${ARCH}"

View File

@@ -113,8 +113,8 @@ When a service is registered, Central automatically:
| Effect | Passthrough | Terminate |
|--------|-------------|-----------|
| **LAN DNS** | `address=/<domain>/<backend-IP>` — direct to backend | `address=/<domain>/<central-IP>` — through Central |
| **LAN DNS (private)** | Also `local=/<domain>/` — prevents upstream forwarding | Same |
| **LAN DNS** | `host-record=<domain>,<backend-IP>` (exact) or `address=/<domain>/<backend-IP>` (wildcard if subdomains:true) | `host-record=<domain>,<central-IP>` (exact) or `address=/<domain>/<central-IP>` (wildcard if subdomains:true) |
| **AAAA filtering** | `filter-AAAA` globally strips IPv6 from upstream responses (prevents Happy Eyeballs on IPv4-only LAN) | Same |
| **Public DNS** | DDNS A record if public | Same |
| **Proxy** | L4 SNI passthrough. Subdomains adds `*.domain` matching. | L7 HTTP reverse proxy by Host header. Routes add path-based ACLs. |
| **TLS** | Backend handles — Central passes through | Central provisions cert via Let's Encrypt |
@@ -147,7 +147,7 @@ Central creates: LAN DNS → Central IP, public DNS A record, HAProxy L7 TLS ter
}
```
Central creates: LAN DNS → Central IP (with `local=/`), HAProxy L7 reverse proxy, TLS cert via certbot. No public DNS.
Central creates: LAN DNS → Central IP (host-record=, exact match), HAProxy L7 reverse proxy, TLS cert via certbot. No public DNS.
### Service with custom response headers

2
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/gorilla/mux v1.8.1
github.com/nats-io/nats-server/v2 v2.14.3
github.com/nats-io/nats.go v1.52.0
golang.org/x/crypto v0.53.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -19,6 +20,5 @@ require (
github.com/nats-io/jwt/v2 v2.8.2 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/sys v0.46.0 // indirect
)

View File

@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
@@ -16,45 +17,52 @@ import (
"github.com/gorilla/mux"
"gopkg.in/yaml.v3"
"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/crowdsec"
"github.com/wild-cloud/wild-central/internal/ddns"
"github.com/wild-cloud/wild-central/internal/dnsfilter"
"github.com/wild-cloud/wild-central/internal/dnsmasq"
"github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/haproxy"
"github.com/wild-cloud/wild-central/internal/network"
"github.com/wild-cloud/wild-central/internal/nftables"
"github.com/wild-cloud/wild-central/internal/reconcile"
"github.com/wild-cloud/wild-central/internal/secrets"
"github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/sse"
"github.com/wild-cloud/wild-central/internal/wireguard"
)
// API holds all dependencies for Central API handlers
type API struct {
dataDir string
version string
allowedOrigins []string
ctx gocontext.Context // parent context for restartable goroutines
secrets *secrets.Manager
dnsmasq *dnsmasq.ConfigGenerator
haproxy *haproxy.Manager
nftables *nftables.Manager
ddns *ddns.Runner
crowdsec *crowdsec.Manager
vpn *wireguard.Manager
certbot *certbot.Manager
domains *domains.Manager // Domain registration manager
sseManager *sse.Manager // SSE manager for real-time events
port int // Running API port (for config-driven routes)
dataDir string
version string
allowedOrigins []string
ctx gocontext.Context // parent context for restartable goroutines
reconciler *reconcile.Reconciler
secrets *secrets.Manager
dnsmasq *dnsmasq.Manager
haproxy *haproxy.Manager
nftables *nftables.Manager
ddns *ddns.Runner
crowdsec *crowdsec.Manager
vpn *wireguard.Manager
certbot *certbot.Manager
authelia *authelia.Manager // Authelia authentication service manager
dnsFilter *dnsfilter.Manager // DNS filtering manager
dnsFilterRunner *dnsfilter.Runner // DNS filter background update runner
domains *domains.Manager // Domain registration manager
sseManager *sse.Manager // SSE manager for real-time events
port int // Running API port (for config-driven routes)
}
// NewAPI creates a new Central API handler with all dependencies
func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
// Determine config paths from env or defaults
dnsmasqConfigPath := envOrDefault("WILD_CENTRAL_DNSMASQ_CONFIG_PATH", "/etc/dnsmasq.d/wild-cloud.conf")
dnsmasqConfigPath := envOrDefault("WILD_CENTRAL_DNSMASQ_CONFIG_PATH", "/etc/dnsmasq.d/wild-central.conf")
haproxyConfigPath := envOrDefault("WILD_CENTRAL_HAPROXY_CONFIG_PATH", "/etc/haproxy/haproxy.cfg")
nftablesRulesPath := envOrDefault("WILD_CENTRAL_NFTABLES_RULES_PATH", "/etc/nftables.d/wild-cloud.nft")
nftablesRulesPath := envOrDefault("WILD_CENTRAL_NFTABLES_RULES_PATH", "/etc/nftables.d/wild-central.nft")
vpnConfigPath := envOrDefault("WILD_CENTRAL_VPN_CONFIG_PATH", "/etc/wireguard/wg0.conf")
sseManager := sse.NewManager()
@@ -64,20 +72,43 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
version: version,
allowedOrigins: allowedOrigins,
secrets: secrets.NewManager(filepath.Join(dataDir, "secrets.yaml")),
dnsmasq: dnsmasq.NewConfigGenerator(dnsmasqConfigPath),
dnsmasq: dnsmasq.NewManager(dnsmasqConfigPath),
haproxy: haproxy.NewManager(haproxyConfigPath),
nftables: nftables.NewManager(nftablesRulesPath),
ddns: ddns.NewRunner(),
crowdsec: crowdsec.NewManager(),
vpn: wireguard.NewManager(dataDir, vpnConfigPath),
certbot: certbot.NewManager(""),
authelia: authelia.NewManager(filepath.Join(dataDir, "authelia")),
dnsFilter: dnsfilter.NewManager(filepath.Join(dataDir, "dns-filter")),
domains: domains.NewManager(dataDir),
sseManager: sseManager,
}
// The compiled blocklist must be readable by dnsmasq which runs as its
// own user. Use /var/lib/wild-central/dns-filter/ (world-readable) rather
// than the data dir which may be under a user home with 0750 permissions.
filterDir := envOrDefault("WILD_CENTRAL_FILTER_DIR", "/var/lib/wild-central/dns-filter")
api.dnsFilter.SetHostsFilePath(filepath.Join(filterDir, "blocked.conf"))
// Build the reconciler — the core domain→config→daemon pipeline
api.reconciler = &reconcile.Reconciler{
Domains: api.domains,
HAProxy: api.haproxy,
DNS: api.dnsmasq,
Auth: api.authelia,
DDNS: api.ddns,
DNSFilter: api.dnsFilter,
SSE: sseManager,
StatePath: filepath.Join(dataDir, "state.yaml"),
GenerateAutheliaConfig: api.generateAutheliaConfig,
Certs: api.certbot,
GetCloudflareToken: api.getCloudflareToken,
}
// Wire up domain registration reconciliation: when domains change,
// regenerate dnsmasq DNS entries and HAProxy routes.
api.domains.SetReconcileFn(api.reconcileNetworking)
api.domains.SetReconcileFn(api.reconciler.Reconcile)
return api, nil
}
@@ -109,6 +140,30 @@ func (api *API) StartDDNS(ctx gocontext.Context) {
api.ddns.Start(ctx, api.ddnsParams)
}
// StartDNSFilter launches the DNS filter background goroutine if enabled.
func (api *API) StartDNSFilter(ctx gocontext.Context) {
api.dnsFilterRunner = dnsfilter.NewRunner(api.dnsFilter, func() {
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("failed to reload dnsmasq after update", "component", "dns-filter", "error", err)
}
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter lists updated")
})
state, err := config.LoadState(api.statePath())
if err != nil || !state.Cloud.DNSFilter.Enabled {
return
}
_ = api.dnsFilter.EnsureDataDir()
interval := state.Cloud.DNSFilter.IntervalHours
if interval <= 0 {
interval = 24
}
api.dnsFilterRunner.Start(ctx, interval)
}
// ddnsParams returns the current DDNS parameters derived from runtime state.
// Called by the DDNS runner on each tick — always returns fresh values.
func (api *API) ddnsParams() ddns.Params {
@@ -162,9 +217,57 @@ func (api *API) StartCentralStatusBroadcaster(startTime time.Time) {
}
// RegisterRoutes registers all Central API routes
func (api *API) RegisterRoutes(r *mux.Router) {
// ReconcileHealth returns the health state from the last reconciliation.
func (api *API) ReconcileHealth(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, api.reconciler.GetHealth())
}
// CheckPrerequisites verifies that required daemons are available on the system.
// Non-fatal — logs errors for required and info for optional missing binaries.
func (api *API) CheckPrerequisites() {
for _, bin := range []string{"dnsmasq", "haproxy"} {
if _, err := exec.LookPath(bin); err != nil {
slog.Error("required daemon not found", "component", "startup", "binary", bin)
}
}
for _, bin := range []string{"wg", "authelia", "cscli", "cloudflared", "nft"} {
if _, err := exec.LookPath(bin); err != nil {
slog.Info("optional daemon not found (some features unavailable)", "component", "startup", "binary", bin)
}
}
}
// StartConvergenceLoop starts a periodic reconciliation loop that continuously
// drives the system toward desired state. Catches config drift, daemon crashes,
// and transient failures.
func (api *API) StartConvergenceLoop(ctx gocontext.Context, interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
api.reconciler.Reconcile()
}
}
}()
slog.Info("convergence loop started", "component", "reconcile", "interval", interval)
}
func (api *API) RegisterRoutes(r *mux.Router, bearerToken string, devMode bool) {
r.Use(RequestLoggingMiddleware)
// Bearer token authentication — protects all /api/ routes.
// Dev mode skips auth for ease of development.
if !devMode && bearerToken != "" {
r.Use(BearerAuthMiddleware(bearerToken))
}
// Health (accessible even with auth — middleware exempts /health)
r.HandleFunc("/api/v1/health/reconcile", api.ReconcileHealth).Methods("GET")
// Resource-oriented settings (persisted in state.yaml)
r.HandleFunc("/api/v1/operator", api.GetOperator).Methods("GET")
r.HandleFunc("/api/v1/operator", api.UpdateOperator).Methods("PUT")
@@ -221,15 +324,16 @@ func (api *API) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/v1/ddns/trigger", api.DDNSTrigger).Methods("POST")
// WireGuard VPN management
r.HandleFunc("/api/v1/vpn/status", api.VpnStatus).Methods("GET")
r.HandleFunc("/api/v1/vpn/config", api.VpnGetConfig).Methods("GET")
r.HandleFunc("/api/v1/vpn/config", api.VpnUpdateConfig).Methods("PUT")
r.HandleFunc("/api/v1/vpn/keygen", api.VpnGenerateKeypair).Methods("POST")
r.HandleFunc("/api/v1/vpn/apply", api.VpnApply).Methods("POST")
r.HandleFunc("/api/v1/vpn/peers", api.VpnListPeers).Methods("GET")
r.HandleFunc("/api/v1/vpn/peers", api.VpnAddPeer).Methods("POST")
r.HandleFunc("/api/v1/vpn/peers/{id}/config", api.VpnGetPeerConfig).Methods("GET")
r.HandleFunc("/api/v1/vpn/peers/{id}", api.VpnDeletePeer).Methods("DELETE")
vpn := &vpnHandlers{vpn: api.vpn, statePath: api.statePath(), syncNftables: api.syncNftablesOnly}
r.HandleFunc("/api/v1/vpn/status", vpn.Status).Methods("GET")
r.HandleFunc("/api/v1/vpn/config", vpn.GetConfig).Methods("GET")
r.HandleFunc("/api/v1/vpn/config", vpn.UpdateConfig).Methods("PUT")
r.HandleFunc("/api/v1/vpn/keygen", vpn.GenerateKeypair).Methods("POST")
r.HandleFunc("/api/v1/vpn/apply", vpn.Apply).Methods("POST")
r.HandleFunc("/api/v1/vpn/peers", vpn.ListPeers).Methods("GET")
r.HandleFunc("/api/v1/vpn/peers", vpn.AddPeer).Methods("POST")
r.HandleFunc("/api/v1/vpn/peers/{id}/config", vpn.GetPeerConfig).Methods("GET")
r.HandleFunc("/api/v1/vpn/peers/{id}", vpn.DeletePeer).Methods("DELETE")
// TLS certificate management
r.HandleFunc("/api/v1/cert/status", api.CertStatus).Methods("GET")
@@ -237,25 +341,55 @@ func (api *API) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/v1/cert/renew", api.CertRenew).Methods("POST")
// CrowdSec LAPI management
r.HandleFunc("/api/v1/crowdsec/status", api.CrowdSecStatus).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/summary", api.CrowdSecGetSummary).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/alerts", api.CrowdSecGetAlerts).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/decisions", api.CrowdSecGetDecisions).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/decisions", api.CrowdSecAddDecision).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/decisions/{id}", api.CrowdSecDeleteDecision).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/decisions/ip/{ip}", api.CrowdSecDeleteDecisionByIP).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/machines", api.CrowdSecGetMachines).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/machines", api.CrowdSecAddMachine).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/machines/{name}", api.CrowdSecDeleteMachine).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecGetBouncers).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecAddBouncer).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/bouncers/{name}", api.CrowdSecDeleteBouncer).Methods("DELETE")
csec := &crowdsecHandlers{crowdsec: api.crowdsec}
r.HandleFunc("/api/v1/crowdsec/status", csec.Status).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/summary", csec.GetSummary).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/alerts", csec.GetAlerts).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/decisions", csec.GetDecisions).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/decisions", csec.AddDecision).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/decisions/{id}", csec.DeleteDecision).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/decisions/ip/{ip}", csec.DeleteDecisionByIP).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/machines", csec.GetMachines).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/machines", csec.AddMachine).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/machines/{name}", csec.DeleteMachine).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/bouncers", csec.GetBouncers).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/bouncers", csec.AddBouncer).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/bouncers/{name}", csec.DeleteBouncer).Methods("DELETE")
// Cloudflare management
r.HandleFunc("/api/v1/cloudflare/verify", api.CloudflareVerify).Methods("GET")
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareGetZoneID).Methods("GET")
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareSetZoneID).Methods("POST")
// Authelia authentication management
r.HandleFunc("/api/v1/authelia/status", api.AutheliaStatus).Methods("GET")
r.HandleFunc("/api/v1/authelia/config", api.AutheliaGetConfig).Methods("GET")
r.HandleFunc("/api/v1/authelia/config", api.AutheliaUpdateConfig).Methods("PUT")
r.HandleFunc("/api/v1/authelia/restart", api.AutheliaRestart).Methods("POST")
r.HandleFunc("/api/v1/authelia/users", api.AutheliaListUsers).Methods("GET")
r.HandleFunc("/api/v1/authelia/users", api.AutheliaCreateUser).Methods("POST")
r.HandleFunc("/api/v1/authelia/users/{username}", api.AutheliaUpdateUser).Methods("PUT")
r.HandleFunc("/api/v1/authelia/users/{username}", api.AutheliaDeleteUser).Methods("DELETE")
r.HandleFunc("/api/v1/authelia/oidc/clients", api.AutheliaListOIDCClients).Methods("GET")
r.HandleFunc("/api/v1/authelia/oidc/clients", api.AutheliaCreateOIDCClient).Methods("POST")
r.HandleFunc("/api/v1/authelia/oidc/clients/{clientId}", api.AutheliaUpdateOIDCClient).Methods("PUT")
r.HandleFunc("/api/v1/authelia/oidc/clients/{clientId}", api.AutheliaDeleteOIDCClient).Methods("DELETE")
// DNS Filtering
r.HandleFunc("/api/v1/dns-filter/status", api.DNSFilterStatus).Methods("GET")
r.HandleFunc("/api/v1/dns-filter/config", api.DNSFilterGetConfig).Methods("GET")
r.HandleFunc("/api/v1/dns-filter/config", api.DNSFilterUpdateConfig).Methods("PUT")
r.HandleFunc("/api/v1/dns-filter/lists", api.DNSFilterGetLists).Methods("GET")
r.HandleFunc("/api/v1/dns-filter/lists", api.DNSFilterAddList).Methods("POST")
r.HandleFunc("/api/v1/dns-filter/lists/upload", api.DNSFilterUploadList).Methods("POST")
r.HandleFunc("/api/v1/dns-filter/lists/suggested", api.DNSFilterGetSuggested).Methods("GET")
r.HandleFunc("/api/v1/dns-filter/lists/{id}", api.DNSFilterRemoveList).Methods("DELETE")
r.HandleFunc("/api/v1/dns-filter/lists/{id}/toggle", api.DNSFilterToggleList).Methods("PUT")
r.HandleFunc("/api/v1/dns-filter/custom", api.DNSFilterGetCustomEntries).Methods("GET")
r.HandleFunc("/api/v1/dns-filter/custom", api.DNSFilterSetCustomEntry).Methods("POST")
r.HandleFunc("/api/v1/dns-filter/custom/{domain:.+}", api.DNSFilterRemoveCustomEntry).Methods("DELETE")
r.HandleFunc("/api/v1/dns-filter/update", api.DNSFilterTriggerUpdate).Methods("POST")
// Domain registration — domain is the unique key
r.HandleFunc("/api/v1/domains", api.DomainsListAll).Methods("GET")
r.HandleFunc("/api/v1/domains", api.DomainsRegister).Methods("POST")
@@ -278,11 +412,7 @@ func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) {
return
}
showRaw := r.URL.Query().Get("raw") == "true"
if !showRaw {
redactSecrets(secretsMap)
}
redactSecrets(secretsMap)
respondJSON(w, http.StatusOK, secretsMap)
}
@@ -329,24 +459,112 @@ func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime
})
}
// getDaemonStatus checks whether each managed daemon is active.
func getDaemonStatus() map[string]map[string]bool {
daemons := map[string]string{
"dnsmasq": "dnsmasq.service",
"haproxy": "haproxy.service",
"wireguard": "wg-quick@wg0.service",
"crowdsec": "crowdsec.service",
// getDaemonStatus checks whether each managed daemon is active and gets its version.
func getDaemonStatus() map[string]map[string]any {
type daemonInfo struct {
unit string
verCmd []string // command to get version
verParse func(string) string // extract version from output
}
result := make(map[string]map[string]bool, len(daemons)+1)
for name, unit := range daemons {
err := exec.Command("systemctl", "is-active", "--quiet", unit).Run()
result[name] = map[string]bool{"active": err == nil}
firstWord := func(s string) string {
if i := strings.IndexByte(s, ' '); i > 0 {
return s[:i]
}
return strings.TrimSpace(s)
}
// nftables has no persistent service — check if the wild-cloud table exists
err := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run()
result["nftables"] = map[string]bool{"active": err == nil}
daemons := map[string]daemonInfo{
"dnsmasq": {
unit: "dnsmasq.service",
verCmd: []string{"dnsmasq", "--version"},
verParse: func(out string) string {
// "Dnsmasq version 2.90 ..."
if i := strings.Index(out, "version "); i >= 0 {
return firstWord(out[i+8:])
}
return ""
},
},
"haproxy": {
unit: "haproxy.service",
verCmd: []string{"haproxy", "-v"},
verParse: func(out string) string {
// "HAProxy version 2.8.5-1 ..."
if i := strings.Index(out, "version "); i >= 0 {
return firstWord(out[i+8:])
}
return ""
},
},
"wireguard": {
unit: "wg-quick@wg0.service",
verCmd: []string{"wg", "--version"},
verParse: func(out string) string {
// "wireguard-tools v1.0.20210914 - ..."
if i := strings.Index(out, " v"); i >= 0 {
return firstWord(out[i+1:])
}
return ""
},
},
"crowdsec": {
unit: "crowdsec.service",
verCmd: []string{"cscli", "version", "--raw"},
verParse: func(out string) string {
return firstWord(out)
},
},
"authelia": {
unit: "authelia.service",
verCmd: []string{"authelia", "--version"},
verParse: func(out string) string {
// "authelia version 4.x.x" or just "4.x.x"
if after, found := strings.CutPrefix(out, "authelia version "); found {
return firstWord(after)
}
return firstWord(out)
},
},
}
result := make(map[string]map[string]any, len(daemons)+1)
for name, info := range daemons {
err := exec.Command("systemctl", "is-active", "--quiet", info.unit).Run()
entry := map[string]any{"active": err == nil}
if out, verErr := exec.Command(info.verCmd[0], info.verCmd[1:]...).Output(); verErr == nil {
if v := info.verParse(string(out)); v != "" {
entry["version"] = v
}
}
result[name] = entry
}
// nftables has no persistent service — check if the wild-central table exists
nftErr := exec.Command("sudo", "nft", "list", "table", "inet", "wild-central").Run()
nftEntry := map[string]any{"active": nftErr == nil}
if out, verErr := exec.Command("nft", "--version").Output(); verErr == nil {
// "nftables v1.0.9 (Old Doc Yak #3)"
s := string(out)
if i := strings.Index(s, " v"); i >= 0 {
nftEntry["version"] = firstWord(s[i+1:])
}
}
result["nftables"] = nftEntry
// certbot is a CLI tool, not a daemon — check if installed
certbotEntry := map[string]any{"active": false}
if _, err := exec.LookPath("certbot"); err == nil {
certbotEntry["active"] = true
if out, verErr := exec.Command("certbot", "--version").Output(); verErr == nil {
// "certbot 2.11.0"
s := strings.TrimSpace(string(out))
if _, after, found := strings.Cut(s, " "); found {
certbotEntry["version"] = after
}
}
}
result["certbot"] = certbotEntry
return result
}

View File

@@ -0,0 +1,474 @@
package v1
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/internal/authelia"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/domains"
)
// AutheliaStatus returns the current Authelia service status
func (api *API) AutheliaStatus(w http.ResponseWriter, r *http.Request) {
status, err := api.authelia.GetStatus()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get authelia status: %v", err))
return
}
state, _ := config.LoadState(api.statePath())
enabled := false
domain := ""
defaultPolicy := ""
if state != nil {
enabled = state.Cloud.Authelia.Enabled
domain = state.Cloud.Authelia.Domain
defaultPolicy = state.Cloud.Authelia.DefaultPolicy
}
smtp := struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Sender string `json:"sender"`
}{}
if state != nil {
smtp.Host = state.Cloud.Authelia.SMTP.Host
smtp.Port = state.Cloud.Authelia.SMTP.Port
smtp.Username = state.Cloud.Authelia.SMTP.Username
smtp.Sender = state.Cloud.Authelia.SMTP.Sender
}
respondJSON(w, http.StatusOK, map[string]any{
"active": status.Active,
"version": status.Version,
"enabled": enabled,
"domain": domain,
"defaultPolicy": defaultPolicy,
"userCount": api.authelia.UserCount(),
"clientCount": api.authelia.OIDCClientCount(),
"installed": api.authelia.IsInstalled(),
"smtp": smtp,
})
}
// AutheliaGetConfig returns the raw Authelia configuration for the advanced view
func (api *API) AutheliaGetConfig(w http.ResponseWriter, r *http.Request) {
content, err := api.authelia.ReadConfig()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read authelia config: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"content": content})
}
// AutheliaUpdateConfig enables/disables Authelia and updates its configuration
func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) {
var req struct {
Enabled *bool `json:"enabled"`
Domain *string `json:"domain"`
DefaultPolicy *string `json:"defaultPolicy"`
SMTPHost *string `json:"smtpHost"`
SMTPPort *int `json:"smtpPort"`
SMTPUsername *string `json:"smtpUsername"`
SMTPSender *string `json:"smtpSender"`
SMTPPassword *string `json:"smtpPassword"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
state, err := config.LoadState(api.statePath())
if err != nil {
state = &config.State{}
}
if req.Domain != nil {
state.Cloud.Authelia.Domain = *req.Domain
}
if req.DefaultPolicy != nil {
state.Cloud.Authelia.DefaultPolicy = *req.DefaultPolicy
}
if req.Enabled != nil {
state.Cloud.Authelia.Enabled = *req.Enabled
}
if req.SMTPHost != nil {
state.Cloud.Authelia.SMTP.Host = *req.SMTPHost
}
if req.SMTPPort != nil {
state.Cloud.Authelia.SMTP.Port = *req.SMTPPort
}
if req.SMTPUsername != nil {
state.Cloud.Authelia.SMTP.Username = *req.SMTPUsername
}
if req.SMTPSender != nil {
state.Cloud.Authelia.SMTP.Sender = *req.SMTPSender
}
if req.SMTPPassword != nil && *req.SMTPPassword != "" {
_ = api.secrets.SetSecret("authelia.smtpPassword", *req.SMTPPassword)
}
if state.Cloud.Authelia.Enabled {
if err := api.enableAuthelia(state); err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
} else {
api.disableAuthelia(state)
}
if err := config.SaveState(state, api.statePath()); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
go api.reconciler.Reconcile()
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
respondMessage(w, http.StatusOK, "Authelia configuration updated")
}
// enableAuthelia validates prerequisites, sets up services, generates config,
// and starts Authelia. Returns a user-facing error message or nil.
func (api *API) enableAuthelia(state *config.State) error {
if !api.authelia.IsInstalled() {
return fmt.Errorf("Authelia is not installed. Install it first (apt install authelia).")
}
if state.Cloud.Authelia.Domain == "" {
return fmt.Errorf("Auth portal domain is required when enabling Authelia")
}
if !api.authelia.HasLuaScript() {
return fmt.Errorf(
"HAProxy auth-request Lua script not found at /etc/haproxy/lua/haproxy-auth-request.lua. " +
"Install it: sudo mkdir -p /etc/haproxy/lua && sudo curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua " +
"https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua")
}
if err := api.authelia.EnsureDataDir(); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
if err := api.ensureAutheliaSecrets(); err != nil {
return fmt.Errorf("failed to generate secrets: %w", err)
}
if err := api.authelia.EnsureJWKS(); err != nil {
return fmt.Errorf("failed to generate JWKS: %w", err)
}
if err := api.authelia.EnsureUsersDB(); err != nil {
return fmt.Errorf("failed to initialize user database: %w", err)
}
if err := api.generateAutheliaConfig(state); err != nil {
return fmt.Errorf("failed to generate config: %w", err)
}
api.ensureAuthDomain(state.Cloud.Authelia.Domain)
if api.authelia.UserCount() > 0 {
if err := api.authelia.RestartService(); err != nil {
return fmt.Errorf("authelia failed to start: %w", err)
}
}
return nil
}
// disableAuthelia stops the service and removes auth domain registrations.
func (api *API) disableAuthelia(_ *config.State) {
_ = api.authelia.StopService()
api.deregisterAuthDomain()
}
// AutheliaRestart restarts the Authelia service
func (api *API) AutheliaRestart(w http.ResponseWriter, r *http.Request) {
if err := api.authelia.RestartService(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to restart authelia: %v", err))
return
}
api.broadcastAutheliaEvent("authelia:restart", "Authelia service restarted")
respondMessage(w, http.StatusOK, "Authelia restarted")
}
// --- User management ---
// AutheliaListUsers returns all Authelia users (passwords omitted)
func (api *API) AutheliaListUsers(w http.ResponseWriter, r *http.Request) {
users, err := api.authelia.ListUsers()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list users: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"users": users})
}
// AutheliaCreateUser creates a new Authelia user
func (api *API) AutheliaCreateUser(w http.ResponseWriter, r *http.Request) {
var user authelia.User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := api.authelia.CreateUser(user); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to create user: %v", err))
return
}
slog.Info("authelia user created", "username", user.Username)
// If Authelia is enabled but not running (was waiting for first user), start it
state, _ := config.LoadState(api.statePath())
if state != nil && state.Cloud.Authelia.Enabled {
status, _ := api.authelia.GetStatus()
if status != nil && !status.Active {
if err := api.authelia.RestartService(); err != nil {
slog.Warn("failed to start authelia after first user", "error", err)
}
}
}
api.broadcastAutheliaEvent("authelia:user", fmt.Sprintf("User %q created", user.Username))
respondMessage(w, http.StatusCreated, fmt.Sprintf("User %q created", user.Username))
}
// AutheliaUpdateUser updates an existing Authelia user
func (api *API) AutheliaUpdateUser(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
var updates authelia.UserUpdate
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := api.authelia.UpdateUser(username, updates); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to update user: %v", err))
return
}
slog.Info("authelia user updated", "username", username)
respondMessage(w, http.StatusOK, fmt.Sprintf("User %q updated", username))
}
// AutheliaDeleteUser deletes an Authelia user
func (api *API) AutheliaDeleteUser(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
if err := api.authelia.DeleteUser(username); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to delete user: %v", err))
return
}
slog.Info("authelia user deleted", "username", username)
api.broadcastAutheliaEvent("authelia:user", fmt.Sprintf("User %q deleted", username))
respondMessage(w, http.StatusOK, fmt.Sprintf("User %q deleted", username))
}
// --- OIDC client management ---
// AutheliaListOIDCClients returns all registered OIDC clients (secrets redacted)
func (api *API) AutheliaListOIDCClients(w http.ResponseWriter, r *http.Request) {
clients, err := api.authelia.ListOIDCClients()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list OIDC clients: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"clients": clients})
}
// AutheliaCreateOIDCClient creates a new OIDC client and returns the secret once
func (api *API) AutheliaCreateOIDCClient(w http.ResponseWriter, r *http.Request) {
var client authelia.OIDCClient
if err := json.NewDecoder(r.Body).Decode(&client); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
secret, err := api.authelia.AddOIDCClient(client)
if err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to create OIDC client: %v", err))
return
}
// Regenerate full config (adds OIDC section) and restart
if err := api.regenAndRestartAuthelia(); err != nil {
slog.Warn("failed to restart authelia after OIDC client create", "error", err)
}
slog.Info("authelia OIDC client created", "clientId", client.ID)
api.broadcastAutheliaEvent("authelia:oidc", fmt.Sprintf("OIDC client %q created", client.ID))
respondJSON(w, http.StatusCreated, map[string]any{
"clientId": client.ID,
"clientSecret": secret,
"message": "OIDC client created. Save the client secret — it will not be shown again.",
})
}
// AutheliaUpdateOIDCClient updates an OIDC client
func (api *API) AutheliaUpdateOIDCClient(w http.ResponseWriter, r *http.Request) {
clientID := mux.Vars(r)["clientId"]
var updates authelia.OIDCClientUpdate
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := api.authelia.UpdateOIDCClient(clientID, updates); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to update OIDC client: %v", err))
return
}
if err := api.regenAndRestartAuthelia(); err != nil {
slog.Warn("failed to restart authelia after OIDC client update", "error", err)
}
slog.Info("authelia OIDC client updated", "clientId", clientID)
respondMessage(w, http.StatusOK, fmt.Sprintf("OIDC client %q updated", clientID))
}
// AutheliaDeleteOIDCClient deletes an OIDC client
func (api *API) AutheliaDeleteOIDCClient(w http.ResponseWriter, r *http.Request) {
clientID := mux.Vars(r)["clientId"]
if err := api.authelia.DeleteOIDCClient(clientID); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to delete OIDC client: %v", err))
return
}
if err := api.regenAndRestartAuthelia(); err != nil {
slog.Warn("failed to restart authelia after OIDC client delete", "error", err)
}
slog.Info("authelia OIDC client deleted", "clientId", clientID)
api.broadcastAutheliaEvent("authelia:oidc", fmt.Sprintf("OIDC client %q deleted", clientID))
respondMessage(w, http.StatusOK, fmt.Sprintf("OIDC client %q deleted", clientID))
}
// --- Internal helpers ---
// regenAndRestartAuthelia regenerates the full Authelia config and restarts the service.
// Used after OIDC client changes to ensure the config includes the OIDC section.
func (api *API) regenAndRestartAuthelia() error {
state, err := config.LoadState(api.statePath())
if err != nil || !state.Cloud.Authelia.Enabled {
return nil
}
if err := api.generateAutheliaConfig(state); err != nil {
return fmt.Errorf("regenerating config: %w", err)
}
if api.authelia.UserCount() > 0 {
return api.authelia.RestartService()
}
return nil
}
// ensureAutheliaSecrets generates Authelia secrets if they don't already exist
func (api *API) ensureAutheliaSecrets() error {
keys := []string{
"authelia.jwtSecret",
"authelia.sessionSecret",
"authelia.storageEncryptionKey",
"authelia.oidcHmacSecret",
}
for _, key := range keys {
existing, _ := api.secrets.GetSecret(key)
if existing != "" {
continue
}
secret, err := authelia.GenerateSecret(32)
if err != nil {
return fmt.Errorf("generating %s: %w", key, err)
}
if err := api.secrets.SetSecret(key, secret); err != nil {
return fmt.Errorf("saving %s: %w", key, err)
}
}
return nil
}
// generateAutheliaConfig reads secrets and state, then generates the Authelia config
func (api *API) generateAutheliaConfig(state *config.State) error {
jwtSecret, _ := api.secrets.GetSecret("authelia.jwtSecret")
sessionSecret, _ := api.secrets.GetSecret("authelia.sessionSecret")
storageEncKey, _ := api.secrets.GetSecret("authelia.storageEncryptionKey")
oidcHmac, _ := api.secrets.GetSecret("authelia.oidcHmacSecret")
// Read OIDC clients from the clients file (with secrets for config generation)
clients, _ := api.authelia.ReadOIDCClients()
defaultPolicy := state.Cloud.Authelia.DefaultPolicy
if defaultPolicy == "" {
defaultPolicy = "one_factor"
}
sessionDomain := authelia.SessionDomainFromAuthDomain(state.Cloud.Authelia.Domain)
smtpPassword, _ := api.secrets.GetSecret("authelia.smtpPassword")
opts := authelia.ConfigOpts{
Domain: state.Cloud.Authelia.Domain,
JWTSecret: jwtSecret,
SessionSecret: sessionSecret,
StorageEncKey: storageEncKey,
OIDCHMACSecret: oidcHmac,
DefaultPolicy: defaultPolicy,
SessionDomain: sessionDomain,
OIDCClients: clients,
SMTPHost: state.Cloud.Authelia.SMTP.Host,
SMTPPort: state.Cloud.Authelia.SMTP.Port,
SMTPUsername: state.Cloud.Authelia.SMTP.Username,
SMTPSender: state.Cloud.Authelia.SMTP.Sender,
SMTPPassword: smtpPassword,
}
return api.authelia.GenerateConfig(opts)
}
// ensureAuthDomain registers the Authelia login portal as a domain
func (api *API) ensureAuthDomain(authDomain string) {
if authDomain == "" {
return
}
// Clean up stale authelia registrations
doms, _ := api.domains.List()
for _, dom := range doms {
if dom.Source == "authelia" && dom.DomainName != authDomain {
_ = api.domains.Deregister(dom.DomainName)
}
}
_ = api.domains.Register(domains.Domain{
DomainName: authDomain,
Source: "authelia",
Routes: []domains.Route{
{
Backend: domains.Backend{
Address: "127.0.0.1:9091",
Type: domains.BackendHTTP,
},
Headers: &domains.HeaderConfig{
Request: map[string]string{
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": authDomain,
},
},
},
},
TLS: domains.TLSTerminate,
})
}
// deregisterAuthDomain removes all authelia-sourced domain registrations
func (api *API) deregisterAuthDomain() {
doms, _ := api.domains.List()
for _, dom := range doms {
if dom.Source == "authelia" {
_ = api.domains.Deregister(dom.DomainName)
}
}
}

View File

@@ -148,7 +148,7 @@ func (api *API) CertProvision(w http.ResponseWriter, r *http.Request) {
}
// Trigger reconciliation so HAProxy picks up the new cert
go api.reconcileNetworking()
go api.reconciler.Reconcile()
status := api.certbot.GetStatus(domain)
respondJSON(w, http.StatusOK, map[string]any{
@@ -171,7 +171,7 @@ func (api *API) CertRenew(w http.ResponseWriter, r *http.Request) {
}
// Trigger reconciliation
go api.reconcileNetworking()
go api.reconciler.Reconcile()
respondJSON(w, http.StatusOK, map[string]string{"message": "Certificates renewed"})
}
@@ -184,4 +184,3 @@ func (api *API) getCentralDomain() string {
}
return globalCfg.Cloud.Central.Domain
}

View File

@@ -2,7 +2,9 @@ package v1
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
type cloudflareZone struct {
@@ -10,12 +12,19 @@ type cloudflareZone struct {
Name string `json:"name"`
}
type cloudflarePermissions struct {
Zone bool `json:"zone"` // can list zones
DNS bool `json:"dns"` // can read/edit DNS records
}
type cloudflareVerifyResponse struct {
TokenConfigured bool `json:"tokenConfigured"`
TokenValid bool `json:"tokenValid"`
TokenStatus string `json:"tokenStatus"`
Zones []cloudflareZone `json:"zones"`
Error string `json:"error,omitempty"`
TokenConfigured bool `json:"tokenConfigured"`
TokenValid bool `json:"tokenValid"`
TokenStatus string `json:"tokenStatus"`
Zones []cloudflareZone `json:"zones"`
RequiredZones []string `json:"requiredZones"`
Permissions cloudflarePermissions `json:"permissions"`
Error string `json:"error,omitempty"`
}
// CloudflareVerify checks whether the stored Cloudflare API token is valid
@@ -47,6 +56,15 @@ func (api *API) CloudflareVerify(w http.ResponseWriter, r *http.Request) {
return
}
resp.Zones = zones
resp.Permissions.Zone = true
// Test DNS access on the first available zone
if len(zones) > 0 {
resp.Permissions.DNS = testCloudflareDNSAccess(token, zones[0].ID)
}
// Derive required zones from registered domains
resp.RequiredZones = api.getRequiredZones()
respondJSON(w, http.StatusOK, resp)
}
@@ -78,6 +96,36 @@ func (api *API) CloudflareSetZoneID(w http.ResponseWriter, r *http.Request) {
respondMessage(w, http.StatusOK, "Zone ID saved")
}
// getRequiredZones extracts unique root zones from all registered domains.
// e.g., "app.cloud.payne.io" → "payne.io"
func (api *API) getRequiredZones() []string {
doms, err := api.domains.List()
if err != nil || len(doms) == 0 {
return nil
}
seen := make(map[string]bool)
var zones []string
for _, dom := range doms {
zone := rootZone(dom.DomainName)
if zone != "" && !seen[zone] {
seen[zone] = true
zones = append(zones, zone)
}
}
return zones
}
// rootZone extracts the registrable zone from a FQDN.
// "app.cloud.payne.io" → "payne.io", "foo.example.com" → "example.com"
func rootZone(domain string) string {
parts := strings.Split(domain, ".")
if len(parts) < 2 {
return ""
}
return strings.Join(parts[len(parts)-2:], ".")
}
func verifyCloudflareToken(apiToken string) (string, error) {
req, err := http.NewRequest(http.MethodGet,
"https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
@@ -103,6 +151,24 @@ func verifyCloudflareToken(apiToken string) (string, error) {
return result.Result.Status, nil
}
// testCloudflareDNSAccess checks if the token can list DNS records for a zone.
func testCloudflareDNSAccess(apiToken, zoneID string) bool {
req, err := http.NewRequest(http.MethodGet,
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?per_page=1", zoneID), nil)
if err != nil {
return false
}
req.Header.Set("Authorization", "Bearer "+apiToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
func listCloudflareZones(apiToken string) ([]cloudflareZone, error) {
req, err := http.NewRequest(http.MethodGet,
"https://api.cloudflare.com/client/v4/zones", nil)

View File

@@ -138,7 +138,7 @@ func TestGetGlobalSecrets_RedactsLeafValues(t *testing.T) {
}
}
func TestGetGlobalSecrets_RawShowsValues(t *testing.T) {
func TestGetGlobalSecrets_AlwaysRedacted(t *testing.T) {
api, tmpDir := setupTestAPI(t)
secrets := map[string]any{
@@ -149,6 +149,7 @@ func TestGetGlobalSecrets_RawShowsValues(t *testing.T) {
data, _ := yaml.Marshal(secrets)
os.WriteFile(filepath.Join(tmpDir, "secrets.yaml"), data, 0600)
// Even with ?raw=true, secrets must be redacted (bypass removed for security)
req := httptest.NewRequest("GET", "/api/v1/secrets?raw=true", nil)
w := httptest.NewRecorder()
@@ -158,8 +159,8 @@ func TestGetGlobalSecrets_RawShowsValues(t *testing.T) {
json.Unmarshal(w.Body.Bytes(), &resp)
cf := resp["cloudflare"].(map[string]any)
if cf["apiToken"] != "my-token" {
t.Errorf("expected raw apiToken, got %v", cf["apiToken"])
if cf["apiToken"] == "my-token" {
t.Error("expected apiToken to be redacted, but got raw value")
}
}

View File

@@ -8,12 +8,36 @@ import (
"strings"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/internal/crowdsec"
)
// CrowdSecManager is the interface for CrowdSec operations used by these handlers.
type CrowdSecManager interface {
GetStatus() (*crowdsec.Status, error)
GetBanSummary() (*crowdsec.BanSummary, error)
GetDecisions() ([]crowdsec.Decision, error)
AddDecision(ip, decType, reason, duration string) error
DeleteDecision(id int) error
DeleteDecisionByIP(ip string) error
GetAlerts(limit int) ([]crowdsec.Alert, error)
GetMachines() ([]crowdsec.Machine, error)
AddMachine(name, password string) error
DeleteMachine(name string) error
GetBouncers() ([]crowdsec.Bouncer, error)
AddBouncer(name, apiKey string) error
DeleteBouncer(name string) error
}
// crowdsecHandlers groups CrowdSec HTTP handlers with their single dependency.
type crowdsecHandlers struct {
crowdsec CrowdSecManager
}
// CrowdSecStatus returns whether CrowdSec is running on Wild Central
// and lists registered machines and bouncers.
func (api *API) CrowdSecStatus(w http.ResponseWriter, r *http.Request) {
status, err := api.crowdsec.GetStatus()
func (h *crowdsecHandlers) Status(w http.ResponseWriter, r *http.Request) {
status, err := h.crowdsec.GetStatus()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CrowdSec status: %v", err))
return
@@ -21,10 +45,9 @@ func (api *API) CrowdSecStatus(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, status)
}
// CrowdSecGetSummary returns aggregated ban counts by scenario across all active decisions.
// This includes CAPI community bans — can return tens of thousands of entries.
func (api *API) CrowdSecGetSummary(w http.ResponseWriter, r *http.Request) {
summary, err := api.crowdsec.GetBanSummary()
// GetSummary returns aggregated ban counts by scenario across all active decisions.
func (h *crowdsecHandlers) GetSummary(w http.ResponseWriter, r *http.Request) {
summary, err := h.crowdsec.GetBanSummary()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get ban summary: %v", err))
return
@@ -32,9 +55,9 @@ func (api *API) CrowdSecGetSummary(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, summary)
}
// CrowdSecGetDecisions returns active ban/captcha decisions
func (api *API) CrowdSecGetDecisions(w http.ResponseWriter, r *http.Request) {
decisions, err := api.crowdsec.GetDecisions()
// GetDecisions returns active ban/captcha decisions
func (h *crowdsecHandlers) GetDecisions(w http.ResponseWriter, r *http.Request) {
decisions, err := h.crowdsec.GetDecisions()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get decisions: %v", err))
return
@@ -42,24 +65,23 @@ func (api *API) CrowdSecGetDecisions(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"decisions": decisions})
}
// CrowdSecDeleteDecision removes a ban decision by numeric ID
func (api *API) CrowdSecDeleteDecision(w http.ResponseWriter, r *http.Request) {
// DeleteDecision removes a ban decision by numeric ID
func (h *crowdsecHandlers) DeleteDecision(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
respondError(w, http.StatusBadRequest, "invalid decision ID")
return
}
if err := api.crowdsec.DeleteDecision(id); err != nil {
if err := h.crowdsec.DeleteDecision(id); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete decision: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Decision deleted"})
}
// CrowdSecAddDecision adds a manual ban or allow decision for an IP.
// Body: { "ip": "1.2.3.4", "type": "ban", "reason": "manual", "duration": "24h" }
func (api *API) CrowdSecAddDecision(w http.ResponseWriter, r *http.Request) {
// AddDecision adds a manual ban or allow decision for an IP.
func (h *crowdsecHandlers) AddDecision(w http.ResponseWriter, r *http.Request) {
var req struct {
IP string `json:"ip"`
Type string `json:"type"`
@@ -84,22 +106,22 @@ func (api *API) CrowdSecAddDecision(w http.ResponseWriter, r *http.Request) {
if req.Duration == "" {
req.Duration = "24h"
}
if err := api.crowdsec.AddDecision(req.IP, req.Type, req.Reason, req.Duration); err != nil {
if err := h.crowdsec.AddDecision(req.IP, req.Type, req.Reason, req.Duration); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add decision: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": fmt.Sprintf("Decision added: %s %s for %s", req.Type, req.IP, req.Duration)})
}
// CrowdSecDeleteDecisionByIP removes all decisions for a specific IP address
func (api *API) CrowdSecDeleteDecisionByIP(w http.ResponseWriter, r *http.Request) {
// DeleteDecisionByIP removes all decisions for a specific IP address
func (h *crowdsecHandlers) DeleteDecisionByIP(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
ip := vars["ip"]
if ip == "" {
respondError(w, http.StatusBadRequest, "ip is required")
return
}
if err := api.crowdsec.DeleteDecisionByIP(ip); err != nil {
if err := h.crowdsec.DeleteDecisionByIP(ip); err != nil {
// cscli exits non-zero when there's nothing to delete — treat as success
if strings.Contains(err.Error(), "no decision") || strings.Contains(err.Error(), "0 decision") {
respondJSON(w, http.StatusOK, map[string]string{"message": "No active decision for " + ip})
@@ -111,16 +133,15 @@ func (api *API) CrowdSecDeleteDecisionByIP(w http.ResponseWriter, r *http.Reques
respondJSON(w, http.StatusOK, map[string]string{"message": "Decisions removed for " + ip})
}
// CrowdSecGetAlerts returns recent detection events from registered agents.
// Query param: limit (default 50)
func (api *API) CrowdSecGetAlerts(w http.ResponseWriter, r *http.Request) {
// GetAlerts returns recent detection events from registered agents.
func (h *crowdsecHandlers) GetAlerts(w http.ResponseWriter, r *http.Request) {
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 {
limit = n
}
}
alerts, err := api.crowdsec.GetAlerts(limit)
alerts, err := h.crowdsec.GetAlerts(limit)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get alerts: %v", err))
return
@@ -128,9 +149,9 @@ func (api *API) CrowdSecGetAlerts(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"alerts": alerts})
}
// CrowdSecGetMachines returns all registered CrowdSec agent machines
func (api *API) CrowdSecGetMachines(w http.ResponseWriter, r *http.Request) {
machines, err := api.crowdsec.GetMachines()
// GetMachines returns all registered CrowdSec agent machines
func (h *crowdsecHandlers) GetMachines(w http.ResponseWriter, r *http.Request) {
machines, err := h.crowdsec.GetMachines()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get machines: %v", err))
return
@@ -138,20 +159,20 @@ func (api *API) CrowdSecGetMachines(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"machines": machines})
}
// CrowdSecDeleteMachine removes a registered machine
func (api *API) CrowdSecDeleteMachine(w http.ResponseWriter, r *http.Request) {
// DeleteMachine removes a registered machine
func (h *crowdsecHandlers) DeleteMachine(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name := vars["name"]
if err := api.crowdsec.DeleteMachine(name); err != nil {
if err := h.crowdsec.DeleteMachine(name); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete machine: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Machine deleted"})
}
// CrowdSecGetBouncers returns all registered bouncers
func (api *API) CrowdSecGetBouncers(w http.ResponseWriter, r *http.Request) {
bouncers, err := api.crowdsec.GetBouncers()
// GetBouncers returns all registered bouncers
func (h *crowdsecHandlers) GetBouncers(w http.ResponseWriter, r *http.Request) {
bouncers, err := h.crowdsec.GetBouncers()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get bouncers: %v", err))
return
@@ -159,20 +180,19 @@ func (api *API) CrowdSecGetBouncers(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"bouncers": bouncers})
}
// CrowdSecDeleteBouncer removes a registered bouncer
func (api *API) CrowdSecDeleteBouncer(w http.ResponseWriter, r *http.Request) {
// DeleteBouncer removes a registered bouncer
func (h *crowdsecHandlers) DeleteBouncer(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name := vars["name"]
if err := api.crowdsec.DeleteBouncer(name); err != nil {
if err := h.crowdsec.DeleteBouncer(name); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete bouncer: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Bouncer deleted"})
}
// CrowdSecAddMachine registers a new CrowdSec agent machine with pre-generated credentials.
// Body: { "name": "...", "password": "..." }
func (api *API) CrowdSecAddMachine(w http.ResponseWriter, r *http.Request) {
// AddMachine registers a new CrowdSec agent machine with pre-generated credentials.
func (h *crowdsecHandlers) AddMachine(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Password string `json:"password"`
@@ -185,16 +205,15 @@ func (api *API) CrowdSecAddMachine(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "name and password are required")
return
}
if err := api.crowdsec.AddMachine(req.Name, req.Password); err != nil {
if err := h.crowdsec.AddMachine(req.Name, req.Password); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add machine: %v", err))
return
}
respondJSON(w, http.StatusCreated, map[string]string{"message": fmt.Sprintf("Machine %s registered", req.Name)})
}
// CrowdSecAddBouncer registers a new bouncer with a pre-generated API key.
// Body: { "name": "...", "apiKey": "..." }
func (api *API) CrowdSecAddBouncer(w http.ResponseWriter, r *http.Request) {
// AddBouncer registers a new bouncer with a pre-generated API key.
func (h *crowdsecHandlers) AddBouncer(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
APIKey string `json:"apiKey"`
@@ -207,7 +226,7 @@ func (api *API) CrowdSecAddBouncer(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "name and apiKey are required")
return
}
if err := api.crowdsec.AddBouncer(req.Name, req.APIKey); err != nil {
if err := h.crowdsec.AddBouncer(req.Name, req.APIKey); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add bouncer: %v", err))
return
}

View File

@@ -0,0 +1,351 @@
package v1
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/dnsfilter"
)
// DNSFilterStatus returns the current DNS filter status and stats.
func (api *API) DNSFilterStatus(w http.ResponseWriter, r *http.Request) {
state, err := config.LoadState(api.statePath())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load state")
return
}
stats := api.dnsFilter.GetStats()
stats.Enabled = state.Cloud.DNSFilter.Enabled
if stats.Enabled {
stats.QueryStats = api.dnsFilter.GetQueryStats()
}
respondJSON(w, http.StatusOK, stats)
}
// DNSFilterGetConfig returns the DNS filter configuration from state.yaml.
func (api *API) DNSFilterGetConfig(w http.ResponseWriter, r *http.Request) {
state, err := config.LoadState(api.statePath())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load state")
return
}
respondJSON(w, http.StatusOK, state.Cloud.DNSFilter)
}
// DNSFilterUpdateConfig enables/disables DNS filtering and updates settings.
func (api *API) DNSFilterUpdateConfig(w http.ResponseWriter, r *http.Request) {
var req struct {
Enabled *bool `json:"enabled"`
IntervalHours *int `json:"intervalHours"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
state, err := config.LoadState(api.statePath())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load state")
return
}
if req.Enabled != nil {
state.Cloud.DNSFilter.Enabled = *req.Enabled
}
if req.IntervalHours != nil {
state.Cloud.DNSFilter.IntervalHours = *req.IntervalHours
}
if err := config.SaveState(state, api.statePath()); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
if state.Cloud.DNSFilter.Enabled {
if err := api.dnsFilter.EnsureDataDir(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create data dir: %v", err))
return
}
// Start or restart the runner
interval := state.Cloud.DNSFilter.IntervalHours
if interval <= 0 {
interval = 24
}
api.dnsFilterRunner.Start(api.ctx, interval)
slog.Info("enabled", "component", "dns-filter", "interval", interval)
} else {
// Stop the runner and clear addn-hosts
api.dnsFilterRunner.Stop()
api.dnsmasq.SetFilterConfPath("")
slog.Info("disabled", "component", "dns-filter")
}
// Reconcile to add/remove addn-hosts from dnsmasq config
go api.reconciler.Reconcile()
api.broadcastDNSFilterEvent("dns-filter:config", "DNS filter configuration updated")
respondMessage(w, http.StatusOK, "DNS filter configuration updated")
}
// DNSFilterGetLists returns all subscribed blocklists.
func (api *API) DNSFilterGetLists(w http.ResponseWriter, r *http.Request) {
fd, err := api.dnsFilter.LoadFilterData()
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load filter data")
return
}
respondJSON(w, http.StatusOK, map[string]any{
"lists": fd.Lists,
})
}
// DNSFilterAddList adds a URL-based blocklist subscription.
func (api *API) DNSFilterAddList(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"url"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.URL == "" || req.Name == "" {
respondError(w, http.StatusBadRequest, "URL and name are required")
return
}
bl := dnsfilter.Blocklist{
URL: req.URL,
Name: req.Name,
}
if err := api.dnsFilter.AddList(bl); err != nil {
respondError(w, http.StatusConflict, err.Error())
return
}
// Trigger update if runner is active
if api.dnsFilterRunner.IsRunning() {
api.dnsFilterRunner.Trigger()
}
api.broadcastDNSFilterEvent("dns-filter:list-added", fmt.Sprintf("Added list: %s", req.Name))
respondMessage(w, http.StatusCreated, "List added")
}
// DNSFilterRemoveList removes a blocklist by ID.
func (api *API) DNSFilterRemoveList(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
if id == "" {
respondError(w, http.StatusBadRequest, "List ID is required")
return
}
if err := api.dnsFilter.RemoveList(id); err != nil {
respondError(w, http.StatusNotFound, err.Error())
return
}
// Recompile without the removed list
go func() {
if _, err := api.dnsFilter.Compile(); err != nil {
slog.Warn("recompile after removal failed", "component", "dns-filter", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("reload after removal failed", "component", "dns-filter", "error", err)
}
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter lists updated")
}()
respondMessage(w, http.StatusOK, "List removed")
}
// DNSFilterToggleList enables or disables a list.
func (api *API) DNSFilterToggleList(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
var req struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
if err := api.dnsFilter.ToggleList(id, req.Enabled); err != nil {
respondError(w, http.StatusNotFound, err.Error())
return
}
// Recompile with updated list status
go func() {
if _, err := api.dnsFilter.Compile(); err != nil {
slog.Warn("recompile after toggle failed", "component", "dns-filter", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("reload after toggle failed", "component", "dns-filter", "error", err)
}
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter lists updated")
}()
respondMessage(w, http.StatusOK, "List toggled")
}
// DNSFilterGetCustomEntries returns all custom allow/block entries.
func (api *API) DNSFilterGetCustomEntries(w http.ResponseWriter, r *http.Request) {
fd, err := api.dnsFilter.LoadFilterData()
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load filter data")
return
}
respondJSON(w, http.StatusOK, map[string]any{
"customEntries": fd.CustomEntries,
})
}
// DNSFilterSetCustomEntry adds or updates a custom allow/block entry.
func (api *API) DNSFilterSetCustomEntry(w http.ResponseWriter, r *http.Request) {
var req dnsfilter.CustomEntry
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.Domain == "" {
respondError(w, http.StatusBadRequest, "Domain is required")
return
}
if err := api.dnsFilter.SetCustomEntry(req); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
// Recompile with updated custom entries
go func() {
if _, err := api.dnsFilter.Compile(); err != nil {
slog.Warn("recompile after custom entry failed", "component", "dns-filter", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("reload after custom entry failed", "component", "dns-filter", "error", err)
}
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter custom entry updated")
}()
respondMessage(w, http.StatusOK, "Custom entry saved")
}
// DNSFilterRemoveCustomEntry removes a custom entry by domain.
func (api *API) DNSFilterRemoveCustomEntry(w http.ResponseWriter, r *http.Request) {
domain := mux.Vars(r)["domain"]
if domain == "" {
respondError(w, http.StatusBadRequest, "Domain is required")
return
}
if err := api.dnsFilter.RemoveCustomEntry(domain); err != nil {
respondError(w, http.StatusNotFound, err.Error())
return
}
// Recompile
go func() {
if _, err := api.dnsFilter.Compile(); err != nil {
slog.Warn("recompile after custom removal failed", "component", "dns-filter", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("reload after custom removal failed", "component", "dns-filter", "error", err)
}
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter custom entry removed")
}()
respondMessage(w, http.StatusOK, "Custom entry removed")
}
// DNSFilterTriggerUpdate triggers an immediate list update.
func (api *API) DNSFilterTriggerUpdate(w http.ResponseWriter, r *http.Request) {
if !api.dnsFilterRunner.IsRunning() {
respondError(w, http.StatusBadRequest, "DNS filter is not enabled")
return
}
api.dnsFilterRunner.Trigger()
respondJSON(w, http.StatusAccepted, map[string]string{
"message": "Update triggered",
})
}
// DNSFilterUploadList handles file upload for a blocklist.
func (api *API) DNSFilterUploadList(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(50 << 20); err != nil { // 50MB limit
respondError(w, http.StatusBadRequest, "Failed to parse upload")
return
}
name := r.FormValue("name")
if name == "" {
respondError(w, http.StatusBadRequest, "Name is required")
return
}
file, _, err := r.FormFile("file")
if err != nil {
respondError(w, http.StatusBadRequest, "File is required")
return
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, 50<<20))
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to read file")
return
}
id, err := api.dnsFilter.AddUploadedList(name, content)
if err != nil {
respondError(w, http.StatusConflict, err.Error())
return
}
// Recompile with new list
go func() {
if _, err := api.dnsFilter.Compile(); err != nil {
slog.Warn("recompile after upload failed", "component", "dns-filter", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("reload after upload failed", "component", "dns-filter", "error", err)
}
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter list uploaded")
}()
respondJSON(w, http.StatusCreated, map[string]string{
"id": id,
"message": "List uploaded",
})
}
// DNSFilterGetSuggested returns well-known blocklists for easy adding.
func (api *API) DNSFilterGetSuggested(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{
"lists": dnsfilter.SuggestedLists(),
})
}

View File

@@ -11,10 +11,8 @@ import (
"time"
"github.com/gorilla/mux"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/storage"
)
// DnsmasqStatus returns the status of the dnsmasq service
@@ -259,8 +257,10 @@ func (api *API) DnsmasqDHCPAddStatic(w http.ResponseWriter, r *http.Request) {
return
}
globalConfigPath := api.statePath()
if err := api.addDHCPStaticLease(globalConfigPath, req); err != nil {
if err := api.modifyState(func(state *config.State) error {
state.AddDHCPStaticLease(req)
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add static lease: %v", err))
return
}
@@ -280,8 +280,10 @@ func (api *API) DnsmasqDHCPDeleteStatic(w http.ResponseWriter, r *http.Request)
return
}
globalConfigPath := api.statePath()
if err := api.removeDHCPStaticLease(globalConfigPath, mac); err != nil {
if err := api.modifyState(func(state *config.State) error {
state.RemoveDHCPStaticLease(mac)
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove static lease: %v", err))
return
}
@@ -292,113 +294,3 @@ func (api *API) DnsmasqDHCPDeleteStatic(w http.ResponseWriter, r *http.Request)
respondJSON(w, http.StatusOK, map[string]string{"message": "Static lease removed successfully"})
}
// addDHCPStaticLease adds or replaces a static lease entry in the global config file
func (api *API) addDHCPStaticLease(configPath string, lease config.DHCPStaticLease) error {
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("reading config: %w", err)
}
var cfg map[string]any
if err := yaml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parsing config: %w", err)
}
if cfg == nil {
cfg = make(map[string]any)
}
// Navigate/create the path cloud.dnsmasq.dhcp.staticLeases
cloud := getOrCreateMap(cfg, "cloud")
dnsmasqMap := getOrCreateMap(cloud, "dnsmasq")
dhcpMap := getOrCreateMap(dnsmasqMap, "dhcp")
leases, _ := dhcpMap["staticLeases"].([]any)
// Replace existing entry with same MAC, or append
newEntry := map[string]any{"mac": lease.MAC, "ip": lease.IP}
if lease.Hostname != "" {
newEntry["hostname"] = lease.Hostname
}
replaced := false
for i, l := range leases {
if m, ok := l.(map[string]any); ok {
if m["mac"] == lease.MAC {
leases[i] = newEntry
replaced = true
break
}
}
}
if !replaced {
leases = append(leases, newEntry)
}
dhcpMap["staticLeases"] = leases
out, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
lockPath := configPath + ".lock"
return storage.WithLock(lockPath, func() error {
return storage.WriteFile(configPath, out, 0644)
})
}
// removeDHCPStaticLease removes a static lease entry by MAC from the global config file
func (api *API) removeDHCPStaticLease(configPath string, mac string) error {
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("reading config: %w", err)
}
var cfg map[string]any
if err := yaml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parsing config: %w", err)
}
cloud, _ := cfg["cloud"].(map[string]any)
if cloud == nil {
return nil
}
dnsmasqMap, _ := cloud["dnsmasq"].(map[string]any)
if dnsmasqMap == nil {
return nil
}
dhcpMap, _ := dnsmasqMap["dhcp"].(map[string]any)
if dhcpMap == nil {
return nil
}
leases, _ := dhcpMap["staticLeases"].([]any)
var filtered []any
for _, l := range leases {
if m, ok := l.(map[string]any); ok {
if m["mac"] != mac {
filtered = append(filtered, l)
}
}
}
dhcpMap["staticLeases"] = filtered
out, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
lockPath := configPath + ".lock"
return storage.WithLock(lockPath, func() error {
return storage.WriteFile(configPath, out, 0644)
})
}
// getOrCreateMap returns the map at key in parent, creating it if absent
func getOrCreateMap(parent map[string]any, key string) map[string]any {
if v, ok := parent[key].(map[string]any); ok {
return v
}
m := make(map[string]any)
parent[key] = m
return m
}

View File

@@ -47,7 +47,7 @@ func (api *API) HaproxyRestart(w http.ResponseWriter, r *http.Request) {
// HaproxyGenerate regenerates HAProxy config from all WC instance data and custom routes,
// then updates nftables to match. Both operations happen together so ports stay in sync.
func (api *API) HaproxyGenerate(w http.ResponseWriter, r *http.Request) {
api.reconcileNetworking()
api.reconciler.Reconcile()
content, err := api.haproxy.ReadConfig()
if err != nil {
@@ -68,5 +68,3 @@ func (api *API) HaproxyStats(w http.ResponseWriter, r *http.Request) {
}
respondJSON(w, http.StatusOK, map[string]any{"backends": stats})
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/wild-cloud/wild-central/internal/haproxy"
)
// NftablesStatus returns the current wild-cloud nftables table contents
// NftablesStatus returns the current wild-central nftables table contents
func (api *API) NftablesStatus(w http.ResponseWriter, r *http.Request) {
rules, err := api.nftables.GetStatus()
if err != nil {
@@ -56,7 +56,7 @@ func (api *API) vpnAutoUDPPorts() []int {
func (api *API) syncNftablesOnly(globalCfg *config.State) {
nftCfg := globalCfg.Cloud.Nftables
// Explicitly disabled: flush the wild-cloud table
// Explicitly disabled: flush the wild-central table
if nftCfg.Enabled != nil && !*nftCfg.Enabled {
if err := api.nftables.WriteDisabledRules(); err != nil {
slog.Error("failed to write disabled nftables rules", "component", "nftables-sync", "error", err)

View File

@@ -22,7 +22,7 @@ func setupTestNftables(t *testing.T) (*API, string) {
}
// TestNftablesStatus_ReturnsOK verifies the status endpoint returns 200.
// GetStatus() runs `nft list table inet wild-cloud` and silently returns an
// GetStatus() runs `nft list table inet wild-central` and silently returns an
// empty string if nft is not installed or the table doesn't exist, so this
// endpoint always returns 200.
func TestNftablesStatus_ReturnsOK(t *testing.T) {
@@ -109,7 +109,7 @@ func TestNftablesApply_ServiceUnavailable(t *testing.T) {
api.NftablesApply(w, req)
// ApplyRules calls `systemctl start wild-cloud-nftables-reload.service`
// ApplyRules calls `systemctl start wild-central-nftables-reload.service`
// which requires polkit/root — always fails in test environments.
if w.Code != http.StatusInternalServerError {
// If it happened to succeed (running on the actual Wild Central device

View File

@@ -13,6 +13,16 @@ import (
"github.com/wild-cloud/wild-central/internal/haproxy"
)
// extractHost gets the host part from a host:port string (test helper)
func extractHost(addr string) string {
for i := len(addr) - 1; i >= 0; i-- {
if addr[i] == ':' {
return addr[:i]
}
}
return addr
}
// TestReconciliation_HAProxyConfigFromDomains verifies that the reconciliation
// logic correctly maps registered domains to HAProxy configuration. This
// replicates the route-building logic from reconcileNetworking() and asserts
@@ -168,17 +178,18 @@ func TestReconciliation_HAProxyConfigFromDomains(t *testing.T) {
}
// TestReconciliation_DnsmasqConfigFromDomains verifies that reconciliation
// correctly generates dnsmasq config from registered domains, including
// the critical reach-based local=/ directives.
// correctly generates dnsmasq config from registered domains. Wildcard
// domains (subdomains:true) get address=/, exact-match domains get
// host-record=. No local=/ directives — AAAA is handled by filter-AAAA.
func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
api, _ := setupTestAPI(t)
centralIP := "192.168.8.151"
// Register domains with different reach and backend types
// Register domains with different backend types and subdomain settings
testDomains := []domains.Domain{
{
// tcp-passthrough, public → DNS points to k8s LB IP, no local=/
// tcp-passthrough, wildcard → address=/ with backend IP
DomainName: "cloud.payne.io",
Source: "wild-cloud",
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
@@ -186,13 +197,13 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
Public: true,
},
{
// http, internal → DNS points to Central IP, has local=/
// http, exact → host-record= with Central IP
DomainName: "my-api.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
},
{
// http, public → DNS points to Central IP, NO local=/
// http, exact → host-record= with Central IP
DomainName: "public-app.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:8080", Type: domains.BackendHTTP},
@@ -226,35 +237,35 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
Domain: dom.DomainName,
IP: dnsIP,
Wildcard: dom.Subdomains,
})
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
// --- TCP passthrough (public) → backend IP ---
// --- Wildcard (subdomains:true) → address=/ with backend IP ---
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("tcp-passthrough domain must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg)
t.Errorf("wildcard domain must use address=/ with backend IP:\n%s", dnsmasqCfg)
}
// --- HTTP domains → Central IP ---
if !strings.Contains(dnsmasqCfg, "address=/my-api.payne.io/192.168.8.151") {
t.Errorf("http/internal domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
// --- Exact-match HTTP domains → host-record= with Central IP ---
if !strings.Contains(dnsmasqCfg, "host-record=my-api.payne.io,192.168.8.151") {
t.Errorf("exact-match http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
}
if !strings.Contains(dnsmasqCfg, "address=/public-app.payne.io/192.168.8.151") {
t.Errorf("http/public domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
if !strings.Contains(dnsmasqCfg, "host-record=public-app.payne.io,192.168.8.151") {
t.Errorf("exact-match http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
}
// --- All domains get local=/ to prevent AAAA leaking upstream (Happy Eyeballs) ---
if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") {
t.Errorf("domain must have local=/ entry:\n%s", dnsmasqCfg)
// --- No local=/ directives (AAAA handled by filter-AAAA) ---
if strings.Contains(dnsmasqCfg, "local=/") {
t.Errorf("must not produce local=/ directives:\n%s", dnsmasqCfg)
}
if !strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") {
t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg)
}
if !strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") {
t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg)
// --- filter-AAAA present ---
if !strings.Contains(dnsmasqCfg, "filter-AAAA") {
t.Errorf("must include filter-AAAA directive:\n%s", dnsmasqCfg)
}
}
@@ -345,19 +356,20 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
dnsIP = extractHost(dom.Backend.Address)
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
Domain: dom.DomainName,
IP: dnsIP,
Wildcard: dom.Subdomains,
})
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
// TCP passthrough → DNS points to backend (k8s LB), NOT central
// TCP passthrough with subdomains → address=/ to backend (k8s LB), NOT central
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("tcp-passthrough DNS must point to backend IP 192.168.8.240:\n%s", dnsmasqCfg)
t.Errorf("wildcard tcp-passthrough DNS must use address=/ to backend IP 192.168.8.240:\n%s", dnsmasqCfg)
}
if strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.151") {
t.Errorf("tcp-passthrough DNS must NOT point to Central IP:\n%s", dnsmasqCfg)
if strings.Contains(dnsmasqCfg, "host-record=cloud.payne.io,192.168.8.151") {
t.Errorf("wildcard tcp-passthrough DNS must NOT use host-record= to Central IP:\n%s", dnsmasqCfg)
}
}
@@ -437,20 +449,21 @@ func TestReconciliation_DNSOnlyNoGateway(t *testing.T) {
dnsIP = extractHost(dom.Backend.Address)
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
Domain: dom.DomainName,
IP: dnsIP,
Wildcard: dom.Subdomains,
})
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
// dns-only domain should resolve to its backend IP (192.168.8.222)
if !strings.Contains(dnsmasqCfg, "address=/dev.payne.io/192.168.8.222") {
t.Errorf("dns-only domain must point DNS to backend IP:\n%s", dnsmasqCfg)
// dns-only domain (public, no subdomains) → host-record= with backend IP
if !strings.Contains(dnsmasqCfg, "host-record=dev.payne.io,192.168.8.222") {
t.Errorf("dns-only domain must use host-record= with backend IP:\n%s", dnsmasqCfg)
}
// HTTP domain should resolve to Central IP
if !strings.Contains(dnsmasqCfg, "address=/app.payne.io/192.168.8.151") {
t.Errorf("http domain must point DNS to Central IP:\n%s", dnsmasqCfg)
// HTTP domain (no subdomains) → host-record= with Central IP
if !strings.Contains(dnsmasqCfg, "host-record=app.payne.io,192.168.8.151") {
t.Errorf("http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
}
}

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/storage"
)
// loadState loads state from disk, returning an empty state if the file doesn't exist.
@@ -17,9 +18,21 @@ func (api *API) loadState() *config.State {
return state
}
// saveState writes state to disk and returns an error suitable for HTTP responses.
func (api *API) saveState(state *config.State) error {
return config.SaveState(state, api.statePath())
// modifyState atomically reads state, applies fn, and writes it back under a
// file lock. This prevents concurrent handlers from silently dropping each
// other's changes via overlapping read-modify-write cycles.
func (api *API) modifyState(fn func(state *config.State) error) error {
lockPath := api.statePath() + ".lock"
return storage.WithLock(lockPath, func() error {
state, err := config.LoadState(api.statePath())
if err != nil {
state = &config.State{}
}
if err := fn(state); err != nil {
return err
}
return config.SaveState(state, api.statePath())
})
}
// --- Operator ---
@@ -37,15 +50,18 @@ func (api *API) UpdateOperator(w http.ResponseWriter, r *http.Request) {
return
}
state := api.loadState()
state.Operator.Email = updates.Email
if err := api.saveState(state); err != nil {
var result any
if err := api.modifyState(func(state *config.State) error {
state.Operator.Email = updates.Email
result = state.Operator
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
slog.Info("operator updated", "email", updates.Email)
respondJSON(w, http.StatusOK, state.Operator)
respondJSON(w, http.StatusOK, result)
}
// --- Central Domain ---
@@ -65,9 +81,10 @@ func (api *API) UpdateCentralDomain(w http.ResponseWriter, r *http.Request) {
return
}
state := api.loadState()
state.Cloud.Central.Domain = updates.Domain
if err := api.saveState(state); err != nil {
if err := api.modifyState(func(state *config.State) error {
state.Cloud.Central.Domain = updates.Domain
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
@@ -95,25 +112,28 @@ func (api *API) UpdateDDNSConfig(w http.ResponseWriter, r *http.Request) {
return
}
state := api.loadState()
if updates.Enabled != nil {
state.Cloud.DDNS.Enabled = *updates.Enabled
}
if updates.Provider != "" {
state.Cloud.DDNS.Provider = updates.Provider
}
if updates.IntervalMinutes != nil {
state.Cloud.DDNS.IntervalMinutes = *updates.IntervalMinutes
}
if err := api.saveState(state); err != nil {
var result any
if err := api.modifyState(func(state *config.State) error {
if updates.Enabled != nil {
state.Cloud.DDNS.Enabled = *updates.Enabled
}
if updates.Provider != "" {
state.Cloud.DDNS.Provider = updates.Provider
}
if updates.IntervalMinutes != nil {
state.Cloud.DDNS.IntervalMinutes = *updates.IntervalMinutes
}
result = state.Cloud.DDNS
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
go api.reloadDDNSIfEnabled()
slog.Info("DDNS config updated", "enabled", state.Cloud.DDNS.Enabled)
respondJSON(w, http.StatusOK, state.Cloud.DDNS)
slog.Info("DDNS config updated")
respondJSON(w, http.StatusOK, result)
}
// --- DNS Settings (dnsmasq IP/interface) ---
@@ -136,23 +156,26 @@ func (api *API) UpdateDNSSettings(w http.ResponseWriter, r *http.Request) {
return
}
state := api.loadState()
if updates.IP != "" {
state.Cloud.Dnsmasq.IP = updates.IP
}
if updates.Interface != "" {
state.Cloud.Dnsmasq.Interface = updates.Interface
}
if err := api.saveState(state); err != nil {
var result map[string]string
if err := api.modifyState(func(state *config.State) error {
if updates.IP != "" {
state.Cloud.Dnsmasq.IP = updates.IP
}
if updates.Interface != "" {
state.Cloud.Dnsmasq.Interface = updates.Interface
}
result = map[string]string{
"ip": state.Cloud.Dnsmasq.IP,
"interface": state.Cloud.Dnsmasq.Interface,
}
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
slog.Info("DNS settings updated", "ip", state.Cloud.Dnsmasq.IP)
respondJSON(w, http.StatusOK, map[string]string{
"ip": state.Cloud.Dnsmasq.IP,
"interface": state.Cloud.Dnsmasq.Interface,
})
slog.Info("DNS settings updated", "ip", result["ip"])
respondJSON(w, http.StatusOK, result)
}
// --- DHCP Config ---
@@ -174,30 +197,33 @@ func (api *API) UpdateDHCPConfig(w http.ResponseWriter, r *http.Request) {
return
}
state := api.loadState()
dhcp := &state.Cloud.Dnsmasq.DHCP
if updates.Enabled != nil {
dhcp.Enabled = *updates.Enabled
}
if updates.RangeStart != "" {
dhcp.RangeStart = updates.RangeStart
}
if updates.RangeEnd != "" {
dhcp.RangeEnd = updates.RangeEnd
}
if updates.LeaseTime != "" {
dhcp.LeaseTime = updates.LeaseTime
}
if updates.Gateway != "" {
dhcp.Gateway = updates.Gateway
}
if err := api.saveState(state); err != nil {
var result any
if err := api.modifyState(func(state *config.State) error {
dhcp := &state.Cloud.Dnsmasq.DHCP
if updates.Enabled != nil {
dhcp.Enabled = *updates.Enabled
}
if updates.RangeStart != "" {
dhcp.RangeStart = updates.RangeStart
}
if updates.RangeEnd != "" {
dhcp.RangeEnd = updates.RangeEnd
}
if updates.LeaseTime != "" {
dhcp.LeaseTime = updates.LeaseTime
}
if updates.Gateway != "" {
dhcp.Gateway = updates.Gateway
}
result = state.Cloud.Dnsmasq.DHCP
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
slog.Info("DHCP config updated", "enabled", dhcp.Enabled)
respondJSON(w, http.StatusOK, state.Cloud.Dnsmasq.DHCP)
slog.Info("DHCP config updated")
respondJSON(w, http.StatusOK, result)
}
// --- nftables Config ---
@@ -217,23 +243,26 @@ func (api *API) UpdateNftablesConfig(w http.ResponseWriter, r *http.Request) {
return
}
state := api.loadState()
if updates.Enabled != nil {
state.Cloud.Nftables.Enabled = updates.Enabled
}
if updates.WANInterface != "" {
state.Cloud.Nftables.WANInterface = updates.WANInterface
}
if updates.ExtraPorts != nil {
state.Cloud.Nftables.ExtraPorts = updates.ExtraPorts
}
if err := api.saveState(state); err != nil {
var result any
if err := api.modifyState(func(state *config.State) error {
if updates.Enabled != nil {
state.Cloud.Nftables.Enabled = updates.Enabled
}
if updates.WANInterface != "" {
state.Cloud.Nftables.WANInterface = updates.WANInterface
}
if updates.ExtraPorts != nil {
state.Cloud.Nftables.ExtraPorts = updates.ExtraPorts
}
result = state.Cloud.Nftables
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
slog.Info("nftables config updated")
respondJSON(w, http.StatusOK, state.Cloud.Nftables)
respondJSON(w, http.StatusOK, result)
}
// --- HAProxy Custom Routes ---
@@ -253,15 +282,18 @@ func (api *API) UpdateHAProxyRoutes(w http.ResponseWriter, r *http.Request) {
return
}
state := api.loadState()
state.Cloud.HAProxy.CustomRoutes = updates.CustomRoutes
if err := api.saveState(state); err != nil {
var result any
if err := api.modifyState(func(state *config.State) error {
state.Cloud.HAProxy.CustomRoutes = updates.CustomRoutes
result = map[string]any{
"customRoutes": state.Cloud.HAProxy.CustomRoutes,
}
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
slog.Info("HAProxy custom routes updated", "count", len(updates.CustomRoutes))
respondJSON(w, http.StatusOK, map[string]any{
"customRoutes": state.Cloud.HAProxy.CustomRoutes,
})
respondJSON(w, http.StatusOK, result)
}

View File

@@ -11,9 +11,30 @@ import (
"github.com/wild-cloud/wild-central/internal/wireguard"
)
// VpnStatus returns the current WireGuard interface status.
func (api *API) VpnStatus(w http.ResponseWriter, r *http.Request) {
status, err := api.vpn.GetStatus()
// VPNManager is the interface for VPN operations used by these handlers.
type VPNManager interface {
GetStatus() (*wireguard.Status, error)
GetConfig() (*wireguard.Config, error)
SaveConfig(cfg *wireguard.Config) error
GetPublicKey() string
GenerateKeypair() error
Apply() error
ListPeers() ([]*wireguard.Peer, error)
AddPeer(name string) (*wireguard.Peer, error)
GetPeer(id string) (*wireguard.Peer, error)
DeletePeer(id string) error
GeneratePeerConfigText(peerID string) (string, error)
}
// vpnHandlers groups VPN HTTP handlers with their dependencies.
type vpnHandlers struct {
vpn VPNManager
statePath string
syncNftables func(globalCfg *config.State) // callback to resync firewall after config changes
}
func (h *vpnHandlers) Status(w http.ResponseWriter, r *http.Request) {
status, err := h.vpn.GetStatus()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get VPN status: %v", err))
return
@@ -21,9 +42,8 @@ func (api *API) VpnStatus(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, status)
}
// VpnGetConfig returns the server interface configuration and public key.
func (api *API) VpnGetConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := api.vpn.GetConfig()
func (h *vpnHandlers) GetConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := h.vpn.GetConfig()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get VPN config: %v", err))
return
@@ -35,12 +55,11 @@ func (api *API) VpnGetConfig(w http.ResponseWriter, r *http.Request) {
"endpoint": cfg.Endpoint,
"dns": cfg.DNS,
"lanCIDR": cfg.LanCIDR,
"publicKey": api.vpn.GetPublicKey(),
"publicKey": h.vpn.GetPublicKey(),
})
}
// VpnUpdateConfig updates the server interface configuration.
func (api *API) VpnUpdateConfig(w http.ResponseWriter, r *http.Request) {
func (h *vpnHandlers) UpdateConfig(w http.ResponseWriter, r *http.Request) {
var req wireguard.Config
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
@@ -49,41 +68,38 @@ func (api *API) VpnUpdateConfig(w http.ResponseWriter, r *http.Request) {
if req.ListenPort == 0 {
req.ListenPort = 51820
}
if err := api.vpn.SaveConfig(&req); err != nil {
if err := h.vpn.SaveConfig(&req); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to save VPN config: %v", err))
return
}
// Resync nftables so the VPN listen port is automatically allowed/removed
if globalCfg, err := config.LoadState(api.statePath()); err == nil {
go api.syncNftablesOnly(globalCfg)
if globalCfg, err := config.LoadState(h.statePath); err == nil {
go h.syncNftables(globalCfg)
}
respondJSON(w, http.StatusOK, map[string]string{"message": "VPN configuration saved"})
}
// VpnGenerateKeypair generates a new server keypair.
func (api *API) VpnGenerateKeypair(w http.ResponseWriter, r *http.Request) {
if err := api.vpn.GenerateKeypair(); err != nil {
func (h *vpnHandlers) GenerateKeypair(w http.ResponseWriter, r *http.Request) {
if err := h.vpn.GenerateKeypair(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate keypair: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{
"message": "Server keypair generated",
"publicKey": api.vpn.GetPublicKey(),
"publicKey": h.vpn.GetPublicKey(),
})
}
// VpnApply writes the wg0.conf and brings the WireGuard interface up.
func (api *API) VpnApply(w http.ResponseWriter, r *http.Request) {
if err := api.vpn.Apply(); err != nil {
func (h *vpnHandlers) Apply(w http.ResponseWriter, r *http.Request) {
if err := h.vpn.Apply(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to apply VPN config: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "WireGuard interface applied successfully"})
}
// VpnListPeers returns all configured peers (without private keys).
func (api *API) VpnListPeers(w http.ResponseWriter, r *http.Request) {
peers, err := api.vpn.ListPeers()
func (h *vpnHandlers) ListPeers(w http.ResponseWriter, r *http.Request) {
peers, err := h.vpn.ListPeers()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list peers: %v", err))
return
@@ -91,8 +107,7 @@ func (api *API) VpnListPeers(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"peers": redactPeers(peers)})
}
// VpnAddPeer adds a new peer, auto-generating a keypair and assigning an IP.
func (api *API) VpnAddPeer(w http.ResponseWriter, r *http.Request) {
func (h *vpnHandlers) AddPeer(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
@@ -100,7 +115,7 @@ func (api *API) VpnAddPeer(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "name is required")
return
}
peer, err := api.vpn.AddPeer(req.Name)
peer, err := h.vpn.AddPeer(req.Name)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add peer: %v", err))
return
@@ -108,15 +123,14 @@ func (api *API) VpnAddPeer(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusCreated, redactPeer(peer))
}
// VpnGetPeerConfig returns the wg-quick config text for a peer (used by clients and QR generation).
func (api *API) VpnGetPeerConfig(w http.ResponseWriter, r *http.Request) {
func (h *vpnHandlers) GetPeerConfig(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
text, err := api.vpn.GeneratePeerConfigText(id)
text, err := h.vpn.GeneratePeerConfigText(id)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate peer config: %v", err))
return
}
peer, err := api.vpn.GetPeer(id)
peer, err := h.vpn.GetPeer(id)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get peer: %v", err))
return
@@ -127,10 +141,9 @@ func (api *API) VpnGetPeerConfig(w http.ResponseWriter, r *http.Request) {
})
}
// VpnDeletePeer removes a peer by ID.
func (api *API) VpnDeletePeer(w http.ResponseWriter, r *http.Request) {
func (h *vpnHandlers) DeletePeer(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
if err := api.vpn.DeletePeer(id); err != nil {
if err := h.vpn.DeletePeer(id); err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to delete peer: %v", err))
return
}

View File

@@ -2,18 +2,9 @@ package v1
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/wild-cloud/wild-central/internal/certbot"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/dnsmasq"
"github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/haproxy"
"github.com/wild-cloud/wild-central/internal/network"
"github.com/wild-cloud/wild-central/internal/sse"
)
@@ -60,258 +51,7 @@ func (api *API) EnsureCentralDomain() {
// Reconcile runs networking reconciliation immediately. Called on startup
// and automatically whenever a domain is registered/updated/deregistered.
func (api *API) Reconcile() {
api.reconcileNetworking()
}
// reconcileNetworking reads all registered domains and regenerates
// dnsmasq DNS entries and HAProxy routes to match.
func (api *API) reconcileNetworking() {
doms, err := api.domains.List()
if err != nil {
slog.Error("reconcile: failed to list domains", "error", err)
return
}
globalCfg, err := config.LoadState(api.statePath())
if err != nil {
slog.Warn("reconcile: failed to load state, using empty", "error", err)
globalCfg = &config.State{}
}
// Build HAProxy routes from registered domains
var l4Routes []haproxy.L4Route
var httpRoutes []haproxy.HTTPRoute
for _, dom := range doms {
switch dom.EffectiveBackendType() {
case domains.BackendTCPPassthrough:
l4Routes = append(l4Routes, haproxy.L4Route{
Name: dom.DomainName,
Domain: dom.DomainName,
BackendIP: extractHost(dom.EffectiveBackendAddress()),
Subdomains: dom.Subdomains,
})
case domains.BackendDNSOnly:
// dns-only: no gateway routing, just DNS resolution
case domains.BackendHTTP:
var routeBackends []haproxy.HTTPRouteBackend
for _, r := range dom.EffectiveRoutes() {
routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{
Paths: r.Paths,
Backend: r.Backend.Address,
HealthPath: r.Backend.Health,
Headers: r.Headers,
IPAllow: r.IPAllow,
})
}
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
Subdomains: dom.Subdomains,
Routes: routeBackends,
})
}
}
// Clean up any 0-byte cert files that would poison HAProxy validation.
// A 0-byte .pem in the certs directory causes the global `bind ssl crt`
// to fail, taking down ALL L7 routes — not just the broken domain.
certsDir := "/etc/haproxy/certs/"
if entries, err := os.ReadDir(certsDir); err == nil {
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".pem") {
if info, err := e.Info(); err == nil && info.Size() == 0 {
slog.Warn("reconcile: removing 0-byte cert file", "file", e.Name())
_ = os.Remove(filepath.Join(certsDir, e.Name()))
}
}
}
}
// Only include L7 HTTP routes for domains that have a valid cert.
var activeHTTPRoutes []haproxy.HTTPRoute
for _, route := range httpRoutes {
if hasCertForDomain(certsDir, route.Domain) {
activeHTTPRoutes = append(activeHTTPRoutes, route)
} else {
slog.Warn("reconcile: skipping L7 route (no cert)", "domain", route.Domain)
}
}
// Generate and write HAProxy config
genOpts := haproxy.GenerateOpts{
HTTPRoutes: activeHTTPRoutes,
CertsDir: certsDir,
}
haproxyCfg := api.haproxy.GenerateWithOpts(l4Routes, nil, genOpts)
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
// Validation failed — identify and exclude broken domains, then retry
brokenDomains := haproxy.FindBrokenServices(haproxyCfg, haproxy.ParseValidationErrors(err.Error()))
if len(brokenDomains) > 0 {
slog.Error("reconcile: excluding broken domains and retrying",
"broken", brokenDomains, "error", err)
exclude := map[string]bool{}
for _, d := range brokenDomains {
exclude[d] = true
}
var filteredInstances []haproxy.L4Route
for _, r := range l4Routes {
if !exclude[r.Domain] {
filteredInstances = append(filteredInstances, r)
}
}
var filteredHTTP []haproxy.HTTPRoute
for _, r := range activeHTTPRoutes {
if !exclude[r.Domain] {
filteredHTTP = append(filteredHTTP, r)
}
}
genOpts.HTTPRoutes = filteredHTTP
haproxyCfg = api.haproxy.GenerateWithOpts(filteredInstances, nil, genOpts)
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
slog.Error("reconcile: retry also failed", "error", err)
} else {
if err := api.haproxy.ReloadService(); err != nil {
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
} else {
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated (excluded broken domains)")
}
}
} else {
slog.Error("reconcile: failed to write HAProxy config (no broken domains identified)", "error", err)
}
} else {
if err := api.haproxy.ReloadService(); err != nil {
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
} else {
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated from registered domains")
}
}
// Build dnsmasq DNS entries from registered domains.
// DNS target IP depends on backend type:
// tcp-passthrough/dns-only → backend IP (LAN traffic goes direct)
// http → Central IP (HAProxy terminates TLS)
// Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected
// (auto-detect can pick wlan0 if that's the default route).
centralIP := globalCfg.Cloud.Dnsmasq.IP
if centralIP == "" {
centralIP, _ = network.GetWildCentralIP()
}
if centralIP == "" {
centralIP = "127.0.0.1"
}
var dnsEntries []dnsmasq.DNSEntry
for _, dom := range doms {
if dom.DomainName == "" {
continue
}
dnsIP := centralIP
bt := dom.EffectiveBackendType()
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
dnsIP = extractHost(dom.EffectiveBackendAddress())
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
})
}
if err := api.dnsmasq.UpdateConfig(globalCfg, dnsEntries, true); err != nil {
slog.Error("reconcile: failed to update dnsmasq", "error", err)
} else {
api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered domains")
}
// Ensure TLS certs exist for HTTP domains (where Central terminates TLS).
// For domains under the gateway domain, a wildcard cert covers them all.
// For others, provision individual certs.
if len(httpRoutes) > 0 {
api.ensureTLSCerts(globalCfg, doms)
}
// Nudge DDNS to pick up any domain changes (public toggle, new domains).
// The runner reads fresh params on each check via its callback.
api.ddns.Trigger()
slog.Info("reconcile: networking updated",
"domains", len(doms),
"l4Routes", len(l4Routes),
"l7Routes", len(httpRoutes),
)
}
// ensureTLSCerts logs which certificates are missing for registered domains.
// Does NOT auto-provision — cert provisioning is user-initiated via the
// Certificates page. This just logs warnings so the operator knows what's needed.
func (api *API) ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) {
gatewayDomain := ""
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
gatewayDomain = parts[1]
}
for _, dom := range doms {
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
continue
}
// Check if covered by wildcard
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
wildcardCert := certbot.HAProxyCertPath(gatewayDomain)
if _, err := os.Stat(wildcardCert); err != nil {
slog.Warn("reconcile/tls: no wildcard cert for gateway domain — provision via Certificates page",
"domain", dom.DomainName, "needed", "*."+gatewayDomain)
}
continue
}
// Check individual cert
if _, err := os.Stat(certbot.HAProxyCertPath(dom.DomainName)); err != nil {
slog.Warn("reconcile/tls: no cert for domain — provision via Certificates page",
"domain", dom.DomainName)
}
}
}
// 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 {
// Check individual cert
if isValidCertFile(certbot.HAProxyCertPath(domain)) {
return true
}
// Check wildcard certs — a *.example.com cert covers foo.example.com
parts := strings.SplitN(domain, ".", 2)
if len(parts) == 2 {
wildcardBase := parts[1]
if isValidCertFile(certbot.HAProxyCertPath(wildcardBase)) {
return true
}
}
return false
}
// isValidCertFile checks that a cert file exists and is non-empty.
func isValidCertFile(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Size() > 0
}
// extractHost gets the host part from a host:port string
func extractHost(addr string) string {
for i := len(addr) - 1; i >= 0; i-- {
if addr[i] == ':' {
return addr[:i]
}
}
return addr
api.reconciler.Reconcile()
}
// broadcastDnsmasqEvent broadcasts SSE events for dnsmasq status changes
@@ -359,6 +99,25 @@ func (api *API) broadcastHaproxyEvent(eventType string, message string) {
api.sseManager.Broadcast(event)
}
// broadcastAutheliaEvent broadcasts SSE events for Authelia status changes
func (api *API) broadcastAutheliaEvent(eventType string, message string) {
if api.sseManager == nil {
return
}
event := &sse.Event{
ID: fmt.Sprintf("authelia-%d", time.Now().UnixNano()),
Type: eventType,
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"message": message,
},
}
api.sseManager.Broadcast(event)
}
// broadcastCentralStatusEvent broadcasts SSE events for central status changes
func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
if api.sseManager == nil {
@@ -382,3 +141,22 @@ func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
api.sseManager.Broadcast(event)
}
// broadcastDNSFilterEvent broadcasts SSE events for DNS filter changes
func (api *API) broadcastDNSFilterEvent(eventType string, message string) {
if api.sseManager == nil {
return
}
event := &sse.Event{
ID: fmt.Sprintf("dns-filter-%d", time.Now().UnixNano()),
Type: eventType,
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"message": message,
},
}
api.sseManager.Broadcast(event)
}

View File

@@ -1,58 +0,0 @@
package v1
import (
"os"
"path/filepath"
"testing"
)
func TestIsValidCertFile_Valid(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "test.pem")
if err := os.WriteFile(path, []byte("--- cert content ---"), 0600); err != nil {
t.Fatal(err)
}
if !isValidCertFile(path) {
t.Error("expected valid cert file to return true")
}
}
func TestIsValidCertFile_Empty(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "empty.pem")
if err := os.WriteFile(path, nil, 0600); err != nil {
t.Fatal(err)
}
if isValidCertFile(path) {
t.Error("expected 0-byte cert file to return false")
}
}
func TestIsValidCertFile_Missing(t *testing.T) {
if isValidCertFile("/nonexistent/path.pem") {
t.Error("expected missing file to return false")
}
}
func TestHasCertForDomain_DirectMatch(t *testing.T) {
tmp := t.TempDir()
certsDir := filepath.Join(tmp, "certs")
os.MkdirAll(certsDir, 0700)
// hasCertForDomain checks certbot.HAProxyCertPath which is hardcoded to /etc/haproxy/certs/.
// We test isValidCertFile directly instead since hasCertForDomain uses absolute paths.
path := filepath.Join(certsDir, "example.com.pem")
os.WriteFile(path, []byte("cert data"), 0600)
if !isValidCertFile(path) {
t.Error("expected direct cert match to be valid")
}
}
func TestHasCertForDomain_ZeroByteShouldFail(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "bad.pem")
os.WriteFile(path, nil, 0600)
if isValidCertFile(path) {
t.Error("0-byte cert should not be considered valid")
}
}

View File

@@ -37,6 +37,43 @@ func (w *statusResponseWriter) Flush() {
}
}
// BearerAuthMiddleware returns middleware that requires a valid Bearer token
// on all API endpoints except health checks and the SSE event stream.
func BearerAuthMiddleware(token string) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Public endpoints — no auth required
if path == "/api/v1/health" || path == "/api/v1/health/reconcile" ||
strings.HasSuffix(path, "/events") {
next.ServeHTTP(w, r)
return
}
// Non-API paths (frontend static files) — no auth required
if !strings.HasPrefix(path, "/api/") {
next.ServeHTTP(w, r)
return
}
auth := r.Header.Get("Authorization")
if auth == "" {
http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized)
return
}
const prefix = "Bearer "
if !strings.HasPrefix(auth, prefix) || auth[len(prefix):] != token {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
// RequestLoggingMiddleware logs method, path, status, and duration for each request.
// Long-lived connections (SSE, WebSocket) are excluded.
func RequestLoggingMiddleware(next http.Handler) http.Handler {
@@ -49,6 +86,11 @@ func RequestLoggingMiddleware(next http.Handler) http.Handler {
return
}
// Security response headers
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Cache-Control", "no-store")
start := time.Now()
sw := &statusResponseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
@@ -73,5 +115,3 @@ func RequestLoggingMiddleware(next http.Handler) http.Handler {
}
})
}

View File

@@ -10,9 +10,9 @@ import (
// Error responses use the Error field.
// Message-only responses use the Message field.
type APIResponse struct {
Data any `json:"data,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
Data any `json:"data,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
// respondJSON writes a JSON response with the given status code.
@@ -33,4 +33,3 @@ func respondMessage(w http.ResponseWriter, status int, message string) {
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, APIResponse{Error: message})
}

86
internal/authelia/TODO.md Normal file
View File

@@ -0,0 +1,86 @@
# Authelia Packaging TODO
These steps must be added to the postinst script (`wild-cloud/dist/debian/DEBIAN/postinst`)
when packaging Wild Central for distribution.
## Install Authelia
```bash
if ! command -v authelia &>/dev/null; then
curl -fsSL https://apt.authelia.com/organization/signing.asc | \
gpg --dearmor -o /usr/share/keyrings/authelia-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/authelia-archive-keyring.gpg] \
https://apt.authelia.com/integration/apt/repo/stable/debian debian main" \
> /etc/apt/sources.list.d/authelia.list
apt-get update -qq
apt-get install -y authelia
fi
```
## Install HAProxy Lua scripts
```bash
mkdir -p /etc/haproxy/lua
curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua \
https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua
curl -fsSL -o /etc/haproxy/lua/haproxy-lua-http.lua \
https://raw.githubusercontent.com/haproxytech/haproxy-lua-http/master/http.lua
apt-get install -y lua-json
```
## Systemd service override
```bash
mkdir -p /etc/systemd/system/authelia.service.d
cat > /etc/systemd/system/authelia.service.d/wild-central.conf << EOF
[Service]
ExecStart=
ExecStart=/usr/bin/authelia --config /var/lib/wild-central/authelia/configuration.yml
EOF
systemctl daemon-reload
```
## Data directory
```bash
mkdir -p /var/lib/wild-central/authelia
chown wildcloud:wildcloud /var/lib/wild-central/authelia
chmod 700 /var/lib/wild-central/authelia
```
## Polkit rule (manages all Wild Central services, not just Authelia)
```bash
cat > /etc/polkit-1/rules.d/50-wild-central.rules << 'EOF'
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
subject.isInGroup("wildcloud")) {
return polkit.Result.YES;
}
});
EOF
```
---
# Certificate Issues TODO
Problems discovered during Authelia integration that need fixing in the cert management system.
## `hasCertForDomain` doesn't verify cert actually covers the domain
`hasCertForDomain()` in `helpers.go` checks if a PEM file exists at the expected path (e.g. `payne.io.pem` for wildcard coverage of `auth.payne.io`), but never verifies the cert's Subject/SAN actually matches. We had a `*.payne.io.pem` file that was actually a cert for `central.payne.io` — it passed the existence check but served the wrong cert at runtime, causing TLS errors.
**Fix:** Parse the cert and verify the SAN covers the requested domain, or at minimum check for `CN=*.domain` when relying on a wildcard.
## Cert provisioning deploy hook can produce misnamed files
The certbot deploy hook builds an HAProxy PEM from `/etc/letsencrypt/live/{cert-name}/`. The cert name doesn't always match the domain (e.g. certbot may name a `*.payne.io` wildcard cert as `payne.io`). If the PEM filename doesn't match what `HAProxyCertPath()` expects, the cert won't be found or will be confused with a different cert.
**Fix:** The deploy hook or `BuildHAProxyCert` should name the PEM based on the cert's actual SAN, not just the certbot cert name.
## UI has no "re-provision" option when cert exists but is wrong
The Certificates UI considers a cert valid if the PEM file exists and is non-empty. There's no way to re-provision if the cert covers the wrong domain or is otherwise invalid. The user had to manually delete the bad PEM to trigger re-provisioning.
**Fix:** Add a "Re-provision" or "Renew" action per domain in the Certificates UI, and show what domain(s) the cert actually covers (parsed from SAN).

277
internal/authelia/config.go Normal file
View File

@@ -0,0 +1,277 @@
package authelia
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"log/slog"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// ConfigOpts holds parameters for generating Authelia's configuration.yml
type ConfigOpts struct {
Domain string // auth portal domain (e.g. auth.payne.io)
JWTSecret string // identity validation JWT secret
SessionSecret string // session cookie secret
StorageEncKey string // storage encryption key (>=20 chars)
OIDCHMACSecret string // OIDC HMAC secret
DefaultPolicy string // "one_factor" or "two_factor"
SessionDomain string // cookie domain for SSO (e.g. payne.io) — forward-auth only works for subdomains of this
DataDir string // authelia data directory path
ListenAddr string // listen address (default: 127.0.0.1:9091)
OIDCClients []OIDCClient // registered OIDC clients
SMTPHost string // SMTP server host (empty = use filesystem notifier)
SMTPPort int // SMTP server port (default: 587)
SMTPUsername string // SMTP login username (defaults to sender if empty)
SMTPSender string // From address
SMTPPassword string // SMTP password (from secrets)
}
// ValidateConfigOpts checks that required fields are present and valid.
func ValidateConfigOpts(opts ConfigOpts) error {
if opts.Domain == "" {
return fmt.Errorf("auth portal domain is required")
}
if opts.JWTSecret == "" {
return fmt.Errorf("JWT secret is required")
}
if opts.SessionSecret == "" {
return fmt.Errorf("session secret is required")
}
if len(opts.StorageEncKey) < 20 {
return fmt.Errorf("storage encryption key must be at least 20 characters, got %d", len(opts.StorageEncKey))
}
return nil
}
// GenerateConfig validates options, builds Authelia's configuration.yml, and writes it atomically.
func (m *Manager) GenerateConfig(opts ConfigOpts) error {
if err := ValidateConfigOpts(opts); err != nil {
return fmt.Errorf("config validation: %w", err)
}
if err := m.EnsureDataDir(); err != nil {
return err
}
if opts.ListenAddr == "" {
opts.ListenAddr = "127.0.0.1:9091"
}
if opts.DefaultPolicy == "" {
opts.DefaultPolicy = "one_factor"
}
if opts.DataDir == "" {
opts.DataDir = m.dataDir
}
cfg := m.buildConfig(opts)
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling authelia config: %w", err)
}
header := "# Authelia Configuration\n# Managed by Wild Central — do not edit manually\n---\n"
content := header + string(data)
tmpPath := m.configPath() + ".tmp"
if err := os.WriteFile(tmpPath, []byte(content), 0600); err != nil {
return fmt.Errorf("writing temp config: %w", err)
}
if err := os.Rename(tmpPath, m.configPath()); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("installing config: %w", err)
}
slog.Info("authelia config written", "component", "authelia", "path", m.configPath())
return nil
}
// EnsureJWKS generates an RSA key pair for OIDC token signing if one doesn't exist.
func (m *Manager) EnsureJWKS() error {
if _, err := os.Stat(m.jwksPath()); err == nil {
return nil // already exists
}
key, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return fmt.Errorf("generating RSA key: %w", err)
}
keyBytes := x509.MarshalPKCS1PrivateKey(key)
pemBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyBytes}
pemData := pem.EncodeToMemory(pemBlock)
if err := os.WriteFile(m.jwksPath(), pemData, 0600); err != nil {
return fmt.Errorf("writing JWKS key: %w", err)
}
slog.Info("authelia JWKS key pair generated", "component", "authelia", "path", m.jwksPath())
return nil
}
// buildConfig constructs the Authelia config as a nested map for YAML marshaling.
func (m *Manager) buildConfig(opts ConfigOpts) map[string]any {
cfg := map[string]any{
"theme": "auto",
"server": map[string]any{
"address": "tcp://" + opts.ListenAddr + "/",
},
"log": map[string]any{
"level": "info",
},
"totp": map[string]any{
"issuer": "Wild Cloud",
},
"authentication_backend": map[string]any{
"file": map[string]any{
"path": m.usersDBPath(),
"password": map[string]any{
"algorithm": "argon2id",
"iterations": 3,
"memory": 65536,
"parallelism": 2,
"key_length": 32,
"salt_length": 16,
},
},
},
"access_control": map[string]any{
"default_policy": opts.DefaultPolicy,
},
"session": map[string]any{
"name": "wild_central_session",
"secret": opts.SessionSecret,
"cookies": []map[string]any{
{
"domain": opts.SessionDomain,
"authelia_url": "https://" + opts.Domain,
},
},
},
"storage": map[string]any{
"encryption_key": opts.StorageEncKey,
"local": map[string]any{
"path": m.sqlitePath(),
},
},
"notifier": buildNotifier(opts, m.notificationPath()),
"identity_validation": map[string]any{
"reset_password": map[string]any{
"jwt_secret": opts.JWTSecret,
},
},
}
// OIDC provider configuration — only emitted when clients exist.
// Authelia requires at least one client when the OIDC section is present.
if opts.OIDCHMACSecret != "" && len(opts.OIDCClients) > 0 {
oidcCfg := map[string]any{
"hmac_secret": opts.OIDCHMACSecret,
}
// Inline the JWKS private key directly (avoids needing template filters)
if jwksData, err := os.ReadFile(m.jwksPath()); err == nil {
oidcCfg["jwks"] = []map[string]any{
{
"key_id": "wild-central",
"algorithm": "RS256",
"use": "sig",
"key": string(jwksData),
},
}
}
{
var clients []map[string]any
for _, c := range opts.OIDCClients {
client := map[string]any{
"client_id": c.ID,
"client_name": c.Name,
"client_secret": c.Secret, // already hashed
"redirect_uris": c.RedirectURIs,
"scopes": c.Scopes,
"authorization_policy": c.Policy,
}
if c.ConsentMode != "" {
client["consent_mode"] = c.ConsentMode
}
clients = append(clients, client)
}
oidcCfg["clients"] = clients
}
cfg["identity_providers"] = map[string]any{
"oidc": oidcCfg,
}
}
return cfg
}
// buildNotifier returns the notifier config — SMTP if configured, filesystem otherwise.
func buildNotifier(opts ConfigOpts, filesystemPath string) map[string]any {
if opts.SMTPHost != "" && opts.SMTPSender != "" {
username := opts.SMTPUsername
if username == "" {
username = opts.SMTPSender
}
port := opts.SMTPPort
if port == 0 {
port = 587
}
// Use the modern address format: submission:// for STARTTLS on 587, smtps:// for implicit TLS on 465
scheme := "submission"
if port == 465 {
scheme = "smtps"
}
smtp := map[string]any{
"address": fmt.Sprintf("%s://%s:%d", scheme, opts.SMTPHost, port),
"sender": opts.SMTPSender,
"username": username,
"password": opts.SMTPPassword,
"tls": map[string]any{
"server_name": opts.SMTPHost,
},
}
return map[string]any{"smtp": smtp}
}
return map[string]any{
"filesystem": map[string]any{
"filename": filesystemPath,
},
}
}
// GenerateSecret creates a cryptographically random hex string of the given byte length.
func GenerateSecret(byteLen int) (string, error) {
b := make([]byte, byteLen)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generating random bytes: %w", err)
}
return fmt.Sprintf("%x", b), nil
}
// SessionDomainFromAuthDomain extracts the parent domain for session cookie scoping.
// "auth.payne.io" -> "payne.io", "auth.sub.example.com" -> "sub.example.com"
func SessionDomainFromAuthDomain(authDomain string) string {
parts := strings.SplitN(authDomain, ".", 2)
if len(parts) == 2 {
return parts[1]
}
return authDomain
}

View File

@@ -0,0 +1,205 @@
// Package authelia manages the Authelia authentication service.
// Authelia provides centralized authentication for network services via
// HAProxy forward-auth and acts as an OIDC provider for apps with native support.
package authelia
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// Status represents the current state of the Authelia service
type Status struct {
Active bool `json:"active"`
Version string `json:"version,omitempty"`
PID int `json:"pid,omitempty"`
LastRestart time.Time `json:"lastRestart,omitempty"`
}
// Manager handles Authelia configuration and service lifecycle
type Manager struct {
dataDir string // e.g. /var/lib/wild-central/authelia
}
// NewManager creates a new Authelia manager
func NewManager(dataDir string) *Manager {
return &Manager{dataDir: dataDir}
}
// EnsureDataDir creates the Authelia data directory if it doesn't exist
func (m *Manager) EnsureDataDir() error {
if err := os.MkdirAll(m.dataDir, 0700); err != nil {
return fmt.Errorf("creating authelia data dir: %w", err)
}
return nil
}
// IsInstalled checks if the authelia binary exists on PATH
func (m *Manager) IsInstalled() bool {
_, err := exec.LookPath("authelia")
return err == nil
}
const luaScriptPath = "/etc/haproxy/lua/haproxy-auth-request.lua"
// HasLuaScript checks if the HAProxy auth-request Lua script is installed
func (m *Manager) HasLuaScript() bool {
info, err := os.Stat(luaScriptPath)
return err == nil && info.Size() > 0
}
// GetStatus returns the current Authelia service status
func (m *Manager) GetStatus() (*Status, error) {
status := &Status{}
cmd := exec.Command("systemctl", "is-active", "--quiet", "authelia")
status.Active = cmd.Run() == nil
if status.Active {
if out, err := exec.Command("systemctl", "show", "authelia.service", "--property=MainPID").Output(); err == nil {
parts := strings.Split(strings.TrimSpace(string(out)), "=")
if len(parts) == 2 {
if pid, err := strconv.Atoi(parts[1]); err == nil {
status.PID = pid
}
}
}
if out, err := exec.Command("systemctl", "show", "authelia.service", "--property=ActiveEnterTimestamp").Output(); err == nil {
parts := strings.Split(strings.TrimSpace(string(out)), "=")
if len(parts) == 2 {
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", parts[1]); err == nil {
status.LastRestart = t
}
}
}
}
if out, err := exec.Command("authelia", "--version").Output(); err == nil {
s := string(out)
if i := strings.Index(s, "version "); i >= 0 {
v := s[i+8:]
if j := strings.IndexByte(v, ' '); j > 0 {
v = v[:j]
}
status.Version = strings.TrimSpace(v)
}
}
return status, nil
}
// RestartService restarts the Authelia systemd service and verifies it stays running.
func (m *Manager) RestartService() error {
cmd := exec.Command("systemctl", "restart", "authelia.service")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to restart authelia: %w (output: %s)", err, string(output))
}
// Give it a moment to start (or crash)
time.Sleep(2 * time.Second)
// Check if it's actually running
if exec.Command("systemctl", "is-active", "--quiet", "authelia.service").Run() != nil {
// It crashed — grab the error from the journal
reason := m.lastError()
if reason != "" {
return fmt.Errorf("authelia started but crashed: %s", reason)
}
return fmt.Errorf("authelia started but crashed (check journalctl -u authelia.service)")
}
slog.Info("authelia service restarted", "component", "authelia")
return nil
}
// lastError extracts the most recent error-level message from the Authelia journal.
// Authelia logs look like: time="..." level=error msg="..." error="..." provider=...
func (m *Manager) lastError() string {
cmd := exec.Command("journalctl", "-u", "authelia.service", "-n", "20", "--no-pager", "-o", "cat")
output, err := cmd.CombinedOutput()
if err != nil {
return ""
}
// Find the last line containing level=error
var lastErr string
for _, line := range strings.Split(string(output), "\n") {
if strings.Contains(line, "level=error") {
lastErr = line
}
}
if lastErr == "" {
return ""
}
// Extract error="..." field (has the actual detail)
if i := strings.Index(lastErr, "error=\""); i >= 0 {
detail := lastErr[i+7:]
if j := strings.Index(detail, "\""); j >= 0 {
return detail[:j]
}
}
// Fall back to msg="..." field
if i := strings.Index(lastErr, "msg=\""); i >= 0 {
detail := lastErr[i+5:]
if j := strings.Index(detail, "\""); j >= 0 {
return detail[:j]
}
}
return lastErr
}
// StopService stops the Authelia systemd service
func (m *Manager) StopService() error {
cmd := exec.Command("systemctl", "stop", "authelia.service")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to stop authelia: %w (output: %s)", err, string(output))
}
slog.Info("authelia service stopped", "component", "authelia")
return nil
}
// ReadConfig reads the current Authelia configuration file
func (m *Manager) ReadConfig() (string, error) {
data, err := os.ReadFile(m.configPath())
if err != nil {
return "", fmt.Errorf("reading authelia config: %w", err)
}
return string(data), nil
}
// DataDir returns the Authelia data directory path
func (m *Manager) DataDir() string {
return m.dataDir
}
func (m *Manager) configPath() string {
return filepath.Join(m.dataDir, "configuration.yml")
}
func (m *Manager) usersDBPath() string {
return filepath.Join(m.dataDir, "users_database.yml")
}
func (m *Manager) sqlitePath() string {
return filepath.Join(m.dataDir, "db.sqlite3")
}
func (m *Manager) jwksPath() string {
return filepath.Join(m.dataDir, "jwks.pem")
}
func (m *Manager) notificationPath() string {
return filepath.Join(m.dataDir, "notification.txt")
}
func (m *Manager) oidcClientsPath() string {
return filepath.Join(m.dataDir, "oidc_clients.yml")
}

View File

@@ -0,0 +1,352 @@
package authelia
import (
"os"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestGenerateConfig(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
opts := ConfigOpts{
Domain: "auth.payne.io",
JWTSecret: "test-jwt-secret",
SessionSecret: "test-session-secret",
StorageEncKey: "test-storage-encryption-key-min-20",
OIDCHMACSecret: "test-oidc-hmac-secret",
DefaultPolicy: "one_factor",
SessionDomain: "payne.io",
DataDir: dir,
ListenAddr: "127.0.0.1:9091",
}
if err := m.GenerateConfig(opts); err != nil {
t.Fatalf("GenerateConfig: %v", err)
}
// Verify file exists
configPath := filepath.Join(dir, "configuration.yml")
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("reading config: %v", err)
}
content := string(data)
// Check key values are present
checks := []string{
"tcp://127.0.0.1:9091/",
"one_factor",
"payne.io",
"auth.payne.io",
"Wild Cloud",
"argon2id",
"test-session-secret",
"test-storage-encryption-key-min-20",
}
for _, check := range checks {
if !strings.Contains(content, check) {
t.Errorf("config missing expected value: %q", check)
}
}
// Verify it's valid YAML
var parsed map[string]any
if err := yaml.Unmarshal(data, &parsed); err != nil {
t.Errorf("config is not valid YAML: %v", err)
}
// Check file permissions
info, _ := os.Stat(configPath)
if info.Mode().Perm() != 0600 {
t.Errorf("config permissions = %o, want 0600", info.Mode().Perm())
}
}
func TestGenerateConfigWithOIDCClients(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
opts := ConfigOpts{
Domain: "auth.payne.io",
JWTSecret: "jwt",
SessionSecret: "session",
StorageEncKey: "storage-enc-key-20chars",
OIDCHMACSecret: "oidc-hmac",
SessionDomain: "payne.io",
OIDCClients: []OIDCClient{
{
ID: "gitea",
Name: "Gitea",
Secret: "$pbkdf2-sha512$hashed",
RedirectURIs: []string{"https://git.payne.io/user/oauth2/authelia/callback"},
Scopes: []string{"openid", "profile", "email"},
Policy: "one_factor",
},
},
}
if err := m.GenerateConfig(opts); err != nil {
t.Fatalf("GenerateConfig: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "configuration.yml"))
content := string(data)
if !strings.Contains(content, "gitea") {
t.Error("config should contain OIDC client 'gitea'")
}
if !strings.Contains(content, "git.payne.io") {
t.Error("config should contain redirect URI")
}
}
func TestGenerateConfigDefaults(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
// Minimal opts — should use defaults
opts := ConfigOpts{
Domain: "auth.example.com",
JWTSecret: "jwt",
SessionSecret: "session",
StorageEncKey: "enc-key-twenty-chars",
SessionDomain: "example.com",
}
if err := m.GenerateConfig(opts); err != nil {
t.Fatalf("GenerateConfig: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "configuration.yml"))
content := string(data)
// Should default to one_factor
if !strings.Contains(content, "one_factor") {
t.Error("should default to one_factor policy")
}
// Should default to 127.0.0.1:9091
if !strings.Contains(content, "127.0.0.1:9091") {
t.Error("should default to 127.0.0.1:9091")
}
// Without OIDCHMACSecret, should not have identity_providers
if strings.Contains(content, "identity_providers") {
t.Error("should not include OIDC section without HMAC secret")
}
}
func TestUserCRUD(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
// List empty
users, err := m.ListUsers()
if err != nil {
t.Fatalf("ListUsers empty: %v", err)
}
if len(users) != 0 {
t.Errorf("expected 0 users, got %d", len(users))
}
// Create
err = m.CreateUser(User{
Username: "admin",
Displayname: "Admin User",
Password: "supersecret",
Email: "admin@example.com",
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
// List after create
users, err = m.ListUsers()
if err != nil {
t.Fatalf("ListUsers: %v", err)
}
if len(users) != 1 {
t.Fatalf("expected 1 user, got %d", len(users))
}
if users[0].Username != "admin" {
t.Errorf("username = %q, want %q", users[0].Username, "admin")
}
if users[0].Displayname != "Admin User" {
t.Errorf("displayname = %q, want %q", users[0].Displayname, "Admin User")
}
if users[0].Email != "admin@example.com" {
t.Errorf("email = %q, want %q", users[0].Email, "admin@example.com")
}
// Password should be omitted in listing
if users[0].Password != "" {
t.Error("password should be empty in listing")
}
// Duplicate create should fail
err = m.CreateUser(User{Username: "admin", Password: "x", Displayname: "dup"})
if err == nil {
t.Error("duplicate create should fail")
}
// Update
newName := "Super Admin"
err = m.UpdateUser("admin", UserUpdate{Displayname: &newName})
if err != nil {
t.Fatalf("UpdateUser: %v", err)
}
users, _ = m.ListUsers()
if users[0].Displayname != "Super Admin" {
t.Errorf("after update, displayname = %q, want %q", users[0].Displayname, "Super Admin")
}
// Update nonexistent user
err = m.UpdateUser("nobody", UserUpdate{Displayname: &newName})
if err == nil {
t.Error("update nonexistent should fail")
}
// Delete
err = m.DeleteUser("admin")
if err != nil {
t.Fatalf("DeleteUser: %v", err)
}
users, _ = m.ListUsers()
if len(users) != 0 {
t.Errorf("after delete, expected 0 users, got %d", len(users))
}
// Delete nonexistent
err = m.DeleteUser("admin")
if err == nil {
t.Error("delete nonexistent should fail")
}
}
func TestCreateUserValidation(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
if err := m.CreateUser(User{Password: "x", Displayname: "y"}); err == nil {
t.Error("empty username should fail")
}
if err := m.CreateUser(User{Username: "x", Displayname: "y"}); err == nil {
t.Error("empty password should fail")
}
}
func TestHashPassword(t *testing.T) {
hash, err := hashPassword("testpassword")
if err != nil {
t.Fatalf("hashPassword: %v", err)
}
if !strings.HasPrefix(hash, "$argon2id$v=19$") {
t.Errorf("hash should start with $argon2id$v=19$, got %q", hash[:30])
}
// Two hashes of the same password should differ (different salt)
hash2, _ := hashPassword("testpassword")
if hash == hash2 {
t.Error("two hashes of the same password should differ (random salt)")
}
}
func TestUserCount(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
if m.UserCount() != 0 {
t.Error("empty db should have 0 users")
}
m.CreateUser(User{Username: "a", Password: "p", Displayname: "A"})
m.CreateUser(User{Username: "b", Password: "p", Displayname: "B"})
if m.UserCount() != 2 {
t.Errorf("expected 2 users, got %d", m.UserCount())
}
}
func TestEnsureUsersDB(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
if err := m.EnsureUsersDB(); err != nil {
t.Fatalf("EnsureUsersDB: %v", err)
}
// File should exist
if _, err := os.Stat(m.usersDBPath()); err != nil {
t.Errorf("users db file should exist: %v", err)
}
// Should be idempotent
if err := m.EnsureUsersDB(); err != nil {
t.Fatalf("EnsureUsersDB (second call): %v", err)
}
}
func TestEnsureJWKS(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
if err := m.EnsureJWKS(); err != nil {
t.Fatalf("EnsureJWKS: %v", err)
}
// File should exist
data, err := os.ReadFile(m.jwksPath())
if err != nil {
t.Fatalf("reading JWKS: %v", err)
}
if !strings.Contains(string(data), "RSA PRIVATE KEY") {
t.Error("JWKS file should contain RSA private key")
}
// Should be idempotent (doesn't regenerate)
if err := m.EnsureJWKS(); err != nil {
t.Fatalf("EnsureJWKS (second call): %v", err)
}
}
func TestGenerateSecret(t *testing.T) {
s1, err := GenerateSecret(32)
if err != nil {
t.Fatalf("GenerateSecret: %v", err)
}
if len(s1) != 64 { // 32 bytes = 64 hex chars
t.Errorf("secret length = %d, want 64", len(s1))
}
s2, _ := GenerateSecret(32)
if s1 == s2 {
t.Error("two generated secrets should differ")
}
}
func TestSessionDomainFromAuthDomain(t *testing.T) {
tests := []struct {
input string
want string
}{
{"auth.payne.io", "payne.io"},
{"auth.sub.example.com", "sub.example.com"},
{"localhost", "localhost"},
}
for _, tt := range tests {
got := SessionDomainFromAuthDomain(tt.input)
if got != tt.want {
t.Errorf("SessionDomainFromAuthDomain(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestIsInstalled(t *testing.T) {
m := NewManager(t.TempDir())
// Just verify it doesn't panic — the result depends on whether authelia is installed
_ = m.IsInstalled()
}

219
internal/authelia/oidc.go Normal file
View File

@@ -0,0 +1,219 @@
package authelia
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-central/internal/storage"
)
// OIDCClient represents a registered OIDC client application
type OIDCClient struct {
ID string `yaml:"client_id" json:"clientId"`
Name string `yaml:"client_name" json:"clientName"`
Secret string `yaml:"client_secret" json:"clientSecret,omitempty"` // pbkdf2 hash in config, cleartext on create response
RedirectURIs []string `yaml:"redirect_uris" json:"redirectUris"`
Scopes []string `yaml:"scopes" json:"scopes"`
Policy string `yaml:"authorization_policy" json:"authorizationPolicy"`
ConsentMode string `yaml:"consent_mode,omitempty" json:"consentMode,omitempty"`
}
// OIDCClientUpdate holds partial updates for an OIDC client
type OIDCClientUpdate struct {
Name *string `json:"clientName,omitempty"`
RedirectURIs []string `json:"redirectUris,omitempty"`
Scopes []string `json:"scopes,omitempty"`
Policy *string `json:"authorizationPolicy,omitempty"`
ConsentMode *string `json:"consentMode,omitempty"`
}
// oidcClientsFile is the top-level structure of the OIDC clients file
type oidcClientsFile struct {
Clients []OIDCClient `yaml:"clients"`
}
// ListOIDCClients returns all registered OIDC clients (secrets redacted)
func (m *Manager) ListOIDCClients() ([]OIDCClient, error) {
clients, err := m.readOIDCClients()
if err != nil {
return nil, err
}
// Redact secrets for listing
for i := range clients {
clients[i].Secret = ""
}
return clients, nil
}
// ReadOIDCClients returns all clients with secrets (for config generation)
func (m *Manager) ReadOIDCClients() ([]OIDCClient, error) {
return m.readOIDCClients()
}
// AddOIDCClient adds a new OIDC client. Returns the cleartext secret (shown once).
func (m *Manager) AddOIDCClient(client OIDCClient) (cleartextSecret string, err error) {
if client.ID == "" {
return "", fmt.Errorf("client_id is required")
}
if client.Name == "" {
return "", fmt.Errorf("client_name is required")
}
if len(client.RedirectURIs) == 0 {
return "", fmt.Errorf("at least one redirect_uri is required")
}
lockPath := m.configPath() + ".lock"
err = storage.WithLock(lockPath, func() error {
clients, readErr := m.readOIDCClients()
if readErr != nil {
return readErr
}
for _, c := range clients {
if c.ID == client.ID {
return fmt.Errorf("client %q already exists", client.ID)
}
}
// Generate client secret
secret, genErr := GenerateSecret(32)
if genErr != nil {
return fmt.Errorf("generating client secret: %w", genErr)
}
cleartextSecret = secret
// Hash for storage
client.Secret = hashClientSecret(secret)
// Default scopes
if len(client.Scopes) == 0 {
client.Scopes = []string{"openid", "profile", "email", "groups"}
}
if client.Policy == "" {
client.Policy = "one_factor"
}
return m.writeOIDCClients(append(clients, client))
})
return
}
// UpdateOIDCClient applies partial updates to an OIDC client
func (m *Manager) UpdateOIDCClient(clientID string, updates OIDCClientUpdate) error {
lockPath := m.configPath() + ".lock"
return storage.WithLock(lockPath, func() error {
clients, err := m.readOIDCClients()
if err != nil {
return err
}
found := false
for i, c := range clients {
if c.ID == clientID {
if updates.Name != nil {
clients[i].Name = *updates.Name
}
if updates.RedirectURIs != nil {
clients[i].RedirectURIs = updates.RedirectURIs
}
if updates.Scopes != nil {
clients[i].Scopes = updates.Scopes
}
if updates.Policy != nil {
clients[i].Policy = *updates.Policy
}
if updates.ConsentMode != nil {
clients[i].ConsentMode = *updates.ConsentMode
}
found = true
break
}
}
if !found {
return fmt.Errorf("client %q not found", clientID)
}
return m.writeOIDCClients(clients)
})
}
// DeleteOIDCClient removes an OIDC client by ID
func (m *Manager) DeleteOIDCClient(clientID string) error {
lockPath := m.configPath() + ".lock"
return storage.WithLock(lockPath, func() error {
clients, err := m.readOIDCClients()
if err != nil {
return err
}
var filtered []OIDCClient
found := false
for _, c := range clients {
if c.ID == clientID {
found = true
continue
}
filtered = append(filtered, c)
}
if !found {
return fmt.Errorf("client %q not found", clientID)
}
return m.writeOIDCClients(filtered)
})
}
// OIDCClientCount returns the number of registered OIDC clients
func (m *Manager) OIDCClientCount() int {
clients, err := m.readOIDCClients()
if err != nil {
return 0
}
return len(clients)
}
// readOIDCClients reads OIDC clients from the dedicated clients file
func (m *Manager) readOIDCClients() ([]OIDCClient, error) {
data, err := os.ReadFile(m.oidcClientsPath())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading oidc clients: %w", err)
}
var f oidcClientsFile
if err := yaml.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parsing oidc clients: %w", err)
}
return f.Clients, nil
}
// writeOIDCClients writes the OIDC clients to the dedicated clients file
func (m *Manager) writeOIDCClients(clients []OIDCClient) error {
f := oidcClientsFile{Clients: clients}
data, err := yaml.Marshal(f)
if err != nil {
return fmt.Errorf("marshaling oidc clients: %w", err)
}
tmpPath := m.oidcClientsPath() + ".tmp"
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
return fmt.Errorf("writing temp oidc clients: %w", err)
}
if err := os.Rename(tmpPath, m.oidcClientsPath()); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("installing oidc clients: %w", err)
}
return nil
}
// hashClientSecret wraps the secret with $plaintext$ prefix.
// Authelia accepts this format and handles hashing internally.
func hashClientSecret(secret string) string {
return "$plaintext$" + secret
}

242
internal/authelia/users.go Normal file
View File

@@ -0,0 +1,242 @@
package authelia
import (
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"sort"
"golang.org/x/crypto/argon2"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-central/internal/storage"
)
// User represents an Authelia user account
type User struct {
Username string `json:"username" yaml:"-"`
Displayname string `json:"displayname" yaml:"displayname"`
Password string `json:"password,omitempty" yaml:"password"` // argon2id hash (cleartext on create, never returned)
Email string `json:"email,omitempty" yaml:"email,omitempty"`
Groups []string `json:"groups,omitempty" yaml:"groups,omitempty"`
Disabled bool `json:"disabled,omitempty" yaml:"disabled,omitempty"`
}
// UserUpdate holds partial updates for a user
type UserUpdate struct {
Displayname *string `json:"displayname,omitempty"`
Password *string `json:"password,omitempty"` // cleartext, will be hashed
Email *string `json:"email,omitempty"`
Groups []string `json:"groups,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
}
// usersDB is the top-level structure of Authelia's users_database.yml
type usersDB struct {
Users map[string]*userEntry `yaml:"users"`
}
type userEntry struct {
Displayname string `yaml:"displayname"`
Password string `yaml:"password"`
Email string `yaml:"email,omitempty"`
Groups []string `yaml:"groups,omitempty"`
Disabled bool `yaml:"disabled,omitempty"`
}
// ListUsers returns all users from the database (passwords omitted)
func (m *Manager) ListUsers() ([]User, error) {
db, err := m.readUsersDB()
if err != nil {
return nil, err
}
var users []User
for name, entry := range db.Users {
users = append(users, User{
Username: name,
Displayname: entry.Displayname,
Email: entry.Email,
Groups: entry.Groups,
Disabled: entry.Disabled,
})
}
sort.Slice(users, func(i, j int) bool {
return users[i].Username < users[j].Username
})
return users, nil
}
// CreateUser adds a new user with an argon2id-hashed password
func (m *Manager) CreateUser(user User) error {
if user.Username == "" {
return fmt.Errorf("username is required")
}
if user.Password == "" {
return fmt.Errorf("password is required")
}
lockPath := m.usersDBPath() + ".lock"
return storage.WithLock(lockPath, func() error {
db, err := m.readUsersDB()
if err != nil {
return err
}
if _, exists := db.Users[user.Username]; exists {
return fmt.Errorf("user %q already exists", user.Username)
}
hash, err := hashPassword(user.Password)
if err != nil {
return fmt.Errorf("hashing password: %w", err)
}
db.Users[user.Username] = &userEntry{
Displayname: user.Displayname,
Password: hash,
Email: user.Email,
Groups: user.Groups,
Disabled: user.Disabled,
}
return m.writeUsersDB(db)
})
}
// UpdateUser applies partial updates to an existing user
func (m *Manager) UpdateUser(username string, updates UserUpdate) error {
lockPath := m.usersDBPath() + ".lock"
return storage.WithLock(lockPath, func() error {
db, err := m.readUsersDB()
if err != nil {
return err
}
entry, exists := db.Users[username]
if !exists {
return fmt.Errorf("user %q not found", username)
}
if updates.Displayname != nil {
entry.Displayname = *updates.Displayname
}
if updates.Email != nil {
entry.Email = *updates.Email
}
if updates.Disabled != nil {
entry.Disabled = *updates.Disabled
}
if updates.Groups != nil {
entry.Groups = updates.Groups
}
if updates.Password != nil {
hash, err := hashPassword(*updates.Password)
if err != nil {
return fmt.Errorf("hashing password: %w", err)
}
entry.Password = hash
}
return m.writeUsersDB(db)
})
}
// DeleteUser removes a user from the database
func (m *Manager) DeleteUser(username string) error {
lockPath := m.usersDBPath() + ".lock"
return storage.WithLock(lockPath, func() error {
db, err := m.readUsersDB()
if err != nil {
return err
}
if _, exists := db.Users[username]; !exists {
return fmt.Errorf("user %q not found", username)
}
delete(db.Users, username)
return m.writeUsersDB(db)
})
}
// UserCount returns the number of users in the database
func (m *Manager) UserCount() int {
db, err := m.readUsersDB()
if err != nil {
return 0
}
return len(db.Users)
}
// EnsureUsersDB creates an empty users database file if one doesn't exist
func (m *Manager) EnsureUsersDB() error {
if _, err := os.Stat(m.usersDBPath()); err == nil {
return nil
}
db := &usersDB{Users: map[string]*userEntry{}}
return m.writeUsersDB(db)
}
func (m *Manager) readUsersDB() (*usersDB, error) {
data, err := os.ReadFile(m.usersDBPath())
if err != nil {
if os.IsNotExist(err) {
return &usersDB{Users: map[string]*userEntry{}}, nil
}
return nil, fmt.Errorf("reading users db: %w", err)
}
db := &usersDB{Users: map[string]*userEntry{}}
if err := yaml.Unmarshal(data, db); err != nil {
return nil, fmt.Errorf("parsing users db: %w", err)
}
if db.Users == nil {
db.Users = map[string]*userEntry{}
}
return db, nil
}
func (m *Manager) writeUsersDB(db *usersDB) error {
data, err := yaml.Marshal(db)
if err != nil {
return fmt.Errorf("marshaling users db: %w", err)
}
tmpPath := m.usersDBPath() + ".tmp"
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
return fmt.Errorf("writing temp users db: %w", err)
}
if err := os.Rename(tmpPath, m.usersDBPath()); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("installing users db: %w", err)
}
return nil
}
// hashPassword creates an argon2id hash in Authelia's expected format.
// Parameters tuned for Raspberry Pi: memory=64MB, iterations=3, parallelism=2.
func hashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generating salt: %w", err)
}
const (
memory = 64 * 1024 // 64MB
iterations = 3
parallel = 2
keyLen = 32
)
hash := argon2.IDKey([]byte(password), salt, iterations, memory, uint8(parallel), keyLen)
// Authelia expects: $argon2id$v=19$m=65536,t=3,p=2$<base64-salt>$<base64-hash>
saltB64 := base64.RawStdEncoding.EncodeToString(salt)
hashB64 := base64.RawStdEncoding.EncodeToString(hash)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
memory, iterations, parallel, saltB64, hashB64), nil
}

View File

@@ -11,14 +11,14 @@ import (
// CertStatus represents the current state of a TLS certificate.
type CertStatus struct {
Exists bool `json:"exists"`
Domain string `json:"domain"`
Wildcard bool `json:"wildcard,omitempty"`
Expiry time.Time `json:"expiry,omitempty"`
CertPath string `json:"certPath,omitempty"`
KeyPath string `json:"keyPath,omitempty"`
DaysLeft int `json:"daysLeft,omitempty"`
IssuerCN string `json:"issuerCN,omitempty"`
Exists bool `json:"exists"`
Domain string `json:"domain"`
Wildcard bool `json:"wildcard,omitempty"`
Expiry time.Time `json:"expiry,omitempty"`
CertPath string `json:"certPath,omitempty"`
KeyPath string `json:"keyPath,omitempty"`
DaysLeft int `json:"daysLeft,omitempty"`
IssuerCN string `json:"issuerCN,omitempty"`
}
// Manager handles TLS certificate provisioning via certbot.
@@ -61,6 +61,13 @@ func (m *Manager) Provision(domain, email string) error {
if email == "" {
return fmt.Errorf("email is required")
}
// Defense-in-depth: reject domains with shell metacharacters.
// Domain validation at the API boundary should already prevent this.
for _, c := range domain {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '*') {
return fmt.Errorf("invalid domain for cert provisioning: %q", domain)
}
}
if _, err := os.Stat(m.credsPath); err != nil {
return fmt.Errorf("cloudflare credentials not found at %s; configure the Cloudflare API token first", m.credsPath)
}

View File

@@ -3,11 +3,14 @@ package config
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-central/internal/storage"
)
// ExtraPort is a TCP or UDP port that the firewall should allow on the WAN interface.
@@ -114,27 +117,82 @@ type State struct {
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"`
} `yaml:"ddns,omitempty" json:"ddns,omitempty"`
Authelia struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // login portal domain (e.g. auth.payne.io)
DefaultPolicy string `yaml:"defaultPolicy,omitempty" json:"defaultPolicy,omitempty"` // one_factor or two_factor
SMTP struct {
Host string `yaml:"host,omitempty" json:"host,omitempty"` // e.g. smtp.gmail.com
Port int `yaml:"port,omitempty" json:"port,omitempty"` // e.g. 587
Username string `yaml:"username,omitempty" json:"username,omitempty"` // SMTP login username
Sender string `yaml:"sender,omitempty" json:"sender,omitempty"` // From address (e.g. auth@payne.io)
} `yaml:"smtp,omitempty" json:"smtp,omitempty"`
} `yaml:"authelia,omitempty" json:"authelia,omitempty"`
DNSFilter struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
IntervalHours int `yaml:"intervalHours,omitempty" json:"intervalHours,omitempty"` // update interval, default 24
} `yaml:"dnsFilter,omitempty" json:"dnsFilter,omitempty"`
} `yaml:"cloud,omitempty" json:"cloud,omitempty"`
}
// LoadState loads state from the specified path
// AddDHCPStaticLease adds or replaces a static lease entry (matched by MAC).
func (s *State) AddDHCPStaticLease(lease DHCPStaticLease) {
for i, l := range s.Cloud.Dnsmasq.DHCP.StaticLeases {
if l.MAC == lease.MAC {
s.Cloud.Dnsmasq.DHCP.StaticLeases[i] = lease
return
}
}
s.Cloud.Dnsmasq.DHCP.StaticLeases = append(s.Cloud.Dnsmasq.DHCP.StaticLeases, lease)
}
// RemoveDHCPStaticLease removes a static lease entry by MAC. Returns true if found.
func (s *State) RemoveDHCPStaticLease(mac string) bool {
leases := s.Cloud.Dnsmasq.DHCP.StaticLeases
for i, l := range leases {
if l.MAC == mac {
s.Cloud.Dnsmasq.DHCP.StaticLeases = append(leases[:i], leases[i+1:]...)
return true
}
}
return false
}
// LoadState loads state from the specified path. If the primary file is
// corrupted, falls back to the .bak backup if one exists.
func LoadState(configPath string) (*State, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
state, err := loadStateFrom(configPath)
if err == nil {
return state, nil
}
// Primary failed — try backup
bakPath := configPath + ".bak"
bakState, bakErr := loadStateFrom(bakPath)
if bakErr == nil {
slog.Warn("loaded state from backup (primary corrupted)", "component", "config",
"path", configPath, "error", err)
return bakState, nil
}
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
}
func loadStateFrom(path string) (*State, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
config := &State{}
if err := yaml.Unmarshal(data, config); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err)
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
return config, nil
}
// SaveState saves the state to the specified path
// SaveState saves the state to the specified path. Backs up the current
// file to .bak before writing so LoadState can recover from corruption.
func SaveState(config *State, configPath string) error {
// Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
@@ -144,7 +202,10 @@ func SaveState(config *State, configPath string) error {
return fmt.Errorf("marshaling config: %w", err)
}
return os.WriteFile(configPath, data, 0644)
// Backup current state before overwriting
if storage.FileExists(configPath) {
_ = storage.CopyFile(configPath, configPath+".bak")
}
return storage.WriteFileAtomic(configPath, data, 0644)
}

View File

@@ -115,7 +115,7 @@ func TestLoadState_Errors(t *testing.T) {
}
return statePath
},
errContains: "parsing config file",
errContains: "yaml:",
},
}
@@ -266,3 +266,53 @@ func TestState_RoundTrip(t *testing.T) {
}
}
func TestAddDHCPStaticLease_New(t *testing.T) {
s := &State{}
s.AddDHCPStaticLease(DHCPStaticLease{MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.100", Hostname: "host1"})
if len(s.Cloud.Dnsmasq.DHCP.StaticLeases) != 1 {
t.Fatalf("expected 1 lease, got %d", len(s.Cloud.Dnsmasq.DHCP.StaticLeases))
}
l := s.Cloud.Dnsmasq.DHCP.StaticLeases[0]
if l.MAC != "aa:bb:cc:dd:ee:ff" || l.IP != "192.168.1.100" || l.Hostname != "host1" {
t.Errorf("lease mismatch: %+v", l)
}
}
func TestAddDHCPStaticLease_ReplaceByMAC(t *testing.T) {
s := &State{}
s.AddDHCPStaticLease(DHCPStaticLease{MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.100"})
s.AddDHCPStaticLease(DHCPStaticLease{MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.200"})
if len(s.Cloud.Dnsmasq.DHCP.StaticLeases) != 1 {
t.Fatalf("expected 1 lease after replace, got %d", len(s.Cloud.Dnsmasq.DHCP.StaticLeases))
}
if s.Cloud.Dnsmasq.DHCP.StaticLeases[0].IP != "192.168.1.200" {
t.Errorf("expected IP to be updated to 192.168.1.200, got %s", s.Cloud.Dnsmasq.DHCP.StaticLeases[0].IP)
}
}
func TestRemoveDHCPStaticLease_Exists(t *testing.T) {
s := &State{}
s.AddDHCPStaticLease(DHCPStaticLease{MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.100"})
s.AddDHCPStaticLease(DHCPStaticLease{MAC: "11:22:33:44:55:66", IP: "192.168.1.101"})
found := s.RemoveDHCPStaticLease("aa:bb:cc:dd:ee:ff")
if !found {
t.Error("expected RemoveDHCPStaticLease to return true")
}
if len(s.Cloud.Dnsmasq.DHCP.StaticLeases) != 1 {
t.Fatalf("expected 1 lease remaining, got %d", len(s.Cloud.Dnsmasq.DHCP.StaticLeases))
}
if s.Cloud.Dnsmasq.DHCP.StaticLeases[0].MAC != "11:22:33:44:55:66" {
t.Error("wrong lease was removed")
}
}
func TestRemoveDHCPStaticLease_NotFound(t *testing.T) {
s := &State{}
found := s.RemoveDHCPStaticLease("nonexistent")
if found {
t.Error("expected RemoveDHCPStaticLease to return false for missing MAC")
}
}

View File

@@ -100,7 +100,7 @@ func TestExtraPortsYAML_StructuredRoundTrip(t *testing.T) {
func TestSplitExtraPorts(t *testing.T) {
ports := []ExtraPort{
{Port: 22, Protocol: "tcp"},
{Port: 80}, // default = tcp
{Port: 80}, // default = tcp
{Port: 5353, Protocol: "udp"},
{Port: 123, Protocol: "UDP"}, // case-insensitive
}

View File

@@ -12,8 +12,8 @@ import (
type Machine struct {
MachineID string `json:"machineId"`
IsValidated bool `json:"isValidated"`
LastPush string `json:"last_push,omitempty"`
LastHeartbeat string `json:"last_heartbeat,omitempty"`
LastPush string `json:"lastPush,omitempty"`
LastHeartbeat string `json:"lastHeartbeat,omitempty"`
IPAddress string `json:"ipAddress,omitempty"`
Version string `json:"version,omitempty"`
}
@@ -104,10 +104,29 @@ func (m *Manager) GetMachines() ([]Machine, error) {
if out == "" || out == "null" {
return []Machine{}, nil
}
var machines []Machine
if err := json.Unmarshal([]byte(out), &machines); err != nil {
// cscli outputs snake_case JSON — map to our camelCase API types
var raw []struct {
MachineID string `json:"machineId"`
IsValidated bool `json:"isValidated"`
LastPush string `json:"last_push"`
LastHeartbeat string `json:"last_heartbeat"`
IPAddress string `json:"ipAddress"`
Version string `json:"version"`
}
if err := json.Unmarshal([]byte(out), &raw); err != nil {
return nil, fmt.Errorf("parsing machines: %w", err)
}
machines := make([]Machine, 0, len(raw))
for _, r := range raw {
machines = append(machines, Machine{
MachineID: r.MachineID,
IsValidated: r.IsValidated,
LastPush: r.LastPush,
LastHeartbeat: r.LastHeartbeat,
IPAddress: r.IPAddress,
Version: r.Version,
})
}
return machines, nil
}

View File

@@ -146,7 +146,7 @@ func (rn *Runner) run(ctx context.Context) {
}
// checkAndUpdate fetches fresh params, the current public IP, and syncs all records.
// The updateCloudflareRecord function short-circuits when the A record already matches,
// The updateRecord method short-circuits when the A record already matches,
// so there's no wasted API calls when nothing has changed.
func (rn *Runner) checkAndUpdate() {
rn.mu.Lock()
@@ -155,7 +155,7 @@ func (rn *Runner) checkAndUpdate() {
p := rn.paramsFn()
if p.APIToken == "" || len(p.Records) == 0 {
slog.Debug("DDNS: no token or records, skipping", "component", "ddns")
slog.Debug("no token or records, skipping", "component", "ddns")
return
}
@@ -164,18 +164,20 @@ func (rn *Runner) checkAndUpdate() {
rn.mu.Lock()
rn.status.LastError = fmt.Sprintf("getting public IP: %v", err)
rn.mu.Unlock()
slog.Error("DDNS: failed to get public IP", "component", "ddns", "error", err)
slog.Error("failed to get public IP", "component", "ddns", "error", err)
return
}
slog.Debug("DDNS: syncing records", "component", "ddns", "ip", ip, "records", p.Records)
slog.Debug("syncing records", "component", "ddns", "ip", ip, "records", p.Records)
cf := &cfClient{apiToken: p.APIToken}
var lastErr string
records := make([]RecordStatus, 0, len(p.Records))
for _, record := range p.Records {
if err := updateCloudflareRecord(p.APIToken, record, ip); err != nil {
if err := cf.updateRecord(record, ip); err != nil {
lastErr = fmt.Sprintf("updating %s: %v", record, err)
slog.Error("DDNS: failed to update record", "component", "ddns", "record", record, "error", err)
slog.Error("failed to update record", "component", "ddns", "record", record, "error", err)
records = append(records, RecordStatus{Name: record, OK: false, Error: err.Error()})
} else {
records = append(records, RecordStatus{Name: record, IP: ip, OK: true})
@@ -238,6 +240,13 @@ func ipFromURL(url string) (string, error) {
return ip, nil
}
// --- Cloudflare API client ---
// cfClient wraps the Cloudflare API token and provides DNS record operations.
type cfClient struct {
apiToken string
}
// cloudflareRecord is used to find the record ID for a given hostname
type cloudflareRecord struct {
ID string `json:"id"`
@@ -246,104 +255,67 @@ type cloudflareRecord struct {
Content string `json:"content"`
}
// updateCloudflareRecord updates a single A record via the Cloudflare API.
// The record name must be a fully qualified domain name (e.g. "cloud.payne.io").
// If a CNAME already exists at that name (e.g. from a router DDNS service), it is
// deleted first so the A record can be created without conflict.
func updateCloudflareRecord(apiToken, recordName, ip string) error {
// Extract zone name: last two parts of the FQDN (e.g. "payne.io" from "cloud.payne.io")
// do executes an authenticated Cloudflare API request and checks the status.
func (c *cfClient) do(method, url string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
}
return resp, nil
}
// updateRecord updates a single A record via the Cloudflare API.
// If a CNAME already exists at that name, it is deleted first.
func (c *cfClient) updateRecord(recordName, ip string) error {
parts := strings.Split(recordName, ".")
if len(parts) < 2 {
return fmt.Errorf("invalid record name: %s", recordName)
}
zoneName := strings.Join(parts[len(parts)-2:], ".")
// Get zone ID
zoneID, err := getCloudflareZoneID(apiToken, zoneName)
zoneID, err := c.getZoneID(zoneName)
if err != nil {
return fmt.Errorf("getting zone ID for %s: %w", zoneName, err)
}
// Check for existing A record — patch it if found
recordID, currentIP, err := getCloudflareRecordID(apiToken, zoneID, recordName, "A")
recordID, currentIP, err := c.getRecordID(zoneID, recordName, "A")
if err == nil {
if currentIP == ip {
return nil // already up to date
}
return patchCloudflareRecord(apiToken, zoneID, recordID, recordName, ip)
return c.patchRecord(zoneID, recordID, recordName, ip)
}
// No A record. Remove any CNAME that would conflict with A record creation
// (common when migrating from a router-based DDNS service like GL.iNet).
if cnameID, _, cnameErr := getCloudflareRecordID(apiToken, zoneID, recordName, "CNAME"); cnameErr == nil {
slog.Info("DDNS: removing CNAME before creating A record", "component", "ddns", "record", recordName)
if err := deleteCloudflareRecord(apiToken, zoneID, cnameID); err != nil {
// No A record. Remove any CNAME that would conflict.
if cnameID, _, cnameErr := c.getRecordID(zoneID, recordName, "CNAME"); cnameErr == nil {
slog.Info("removing CNAME before creating A record", "component", "ddns", "record", recordName)
if err := c.deleteRecord(zoneID, cnameID); err != nil {
return fmt.Errorf("removing CNAME for %s: %w", recordName, err)
}
}
slog.Info("DDNS: creating A record", "component", "ddns", "record", recordName)
return createCloudflareRecord(apiToken, zoneID, recordName, ip)
slog.Info("creating A record", "component", "ddns", "record", recordName)
return c.createRecord(zoneID, recordName, ip)
}
// createCloudflareRecord creates a new A record via the Cloudflare API
func createCloudflareRecord(apiToken, zoneID, recordName, ip string) error {
body, _ := json.Marshal(map[string]string{
"type": "A",
"name": recordName,
"content": ip,
})
req, _ := http.NewRequest(http.MethodPost,
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID),
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("creating record: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
}
return nil
}
// patchCloudflareRecord updates an existing A record via the Cloudflare API
func patchCloudflareRecord(apiToken, zoneID, recordID, recordName, ip string) error {
body, _ := json.Marshal(map[string]string{
"type": "A",
"name": recordName,
"content": ip,
})
req, _ := http.NewRequest(http.MethodPatch,
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID),
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("updating record: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
}
return nil
}
func getCloudflareZoneID(apiToken, zoneName string) (string, error) {
req, _ := http.NewRequest(http.MethodGet,
"https://api.cloudflare.com/client/v4/zones?name="+zoneName, nil)
req.Header.Set("Authorization", "Bearer "+apiToken)
resp, err := http.DefaultClient.Do(req)
func (c *cfClient) getZoneID(zoneName string) (string, error) {
resp, err := c.do(http.MethodGet, "https://api.cloudflare.com/client/v4/zones?name="+zoneName, nil)
if err != nil {
return "", err
}
@@ -363,12 +335,9 @@ func getCloudflareZoneID(apiToken, zoneName string) (string, error) {
return result.Result[0].ID, nil
}
func getCloudflareRecordID(apiToken, zoneID, recordName, recordType string) (id, currentContent string, err error) {
req, _ := http.NewRequest(http.MethodGet,
func (c *cfClient) getRecordID(zoneID, recordName, recordType string) (id, currentContent string, err error) {
resp, err := c.do(http.MethodGet,
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, recordName), nil)
req.Header.Set("Authorization", "Bearer "+apiToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", err
}
@@ -386,21 +355,36 @@ func getCloudflareRecordID(apiToken, zoneID, recordName, recordType string) (id,
return result.Result[0].ID, result.Result[0].Content, nil
}
func deleteCloudflareRecord(apiToken, zoneID, recordID string) error {
req, _ := http.NewRequest(http.MethodDelete,
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID),
nil)
req.Header.Set("Authorization", "Bearer "+apiToken)
func (c *cfClient) createRecord(zoneID, recordName, ip string) error {
body, _ := json.Marshal(map[string]string{"type": "A", "name": recordName, "content": ip})
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 record: %w", err)
}
resp.Body.Close()
return nil
}
resp, err := http.DefaultClient.Do(req)
func (c *cfClient) patchRecord(zoneID, recordID, recordName, ip string) error {
body, _ := json.Marshal(map[string]string{"type": "A", "name": recordName, "content": ip})
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 record: %w", err)
}
resp.Body.Close()
return nil
}
func (c *cfClient) deleteRecord(zoneID, recordID string) error {
resp, err := c.do(http.MethodDelete,
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID), nil)
if err != nil {
return fmt.Errorf("deleting record: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
}
resp.Body.Close()
return nil
}

View File

@@ -0,0 +1,550 @@
package dnsfilter
import (
"bufio"
"context"
"crypto/sha256"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/wild-cloud/wild-central/internal/storage"
"gopkg.in/yaml.v3"
)
// Blocklist represents a subscribed blocklist source.
type Blocklist struct {
ID string `yaml:"id" json:"id"`
URL string `yaml:"url,omitempty" json:"url,omitempty"`
Name string `yaml:"name" json:"name"`
Enabled bool `yaml:"enabled" json:"enabled"`
EntryCount int `yaml:"entryCount,omitempty" json:"entryCount,omitempty"`
LastUpdated time.Time `yaml:"lastUpdated,omitempty" json:"lastUpdated,omitzero"`
LastError string `yaml:"lastError,omitempty" json:"lastError,omitempty"`
}
// CustomEntry represents a user-managed domain override.
type CustomEntry struct {
Domain string `yaml:"domain" json:"domain"`
Type string `yaml:"type" json:"type"` // "allow" or "block"
}
// FilterData holds all DNS filter configuration persisted in lists.yaml.
type FilterData struct {
Lists []Blocklist `yaml:"lists" json:"lists"`
CustomEntries []CustomEntry `yaml:"customEntries" json:"customEntries"`
}
// FilterStats holds current filtering statistics.
type FilterStats struct {
Enabled bool `json:"enabled"`
TotalBlocked int `json:"totalBlocked"`
ListCount int `json:"listCount"`
EnabledLists int `json:"enabledLists"`
LastUpdated time.Time `json:"lastUpdated"`
QueryStats *QueryStats `json:"queryStats,omitempty"`
}
// QueryStats holds DNS query statistics from dnsmasq logs.
type QueryStats struct {
TotalQueries int `json:"totalQueries"`
BlockedQueries int `json:"blockedQueries"`
BlockedPercent float64 `json:"blockedPercent"`
Period string `json:"period"` // e.g. "24h"
}
// Manager handles DNS filter list management and compilation.
type Manager struct {
dataDir string // e.g. /var/lib/wild-central/dns-filter
hostsFilePath string // compiled output path (must be readable by dnsmasq)
}
// NewManager creates a new DNS filter manager.
func NewManager(dataDir string) *Manager {
return &Manager{
dataDir: dataDir,
hostsFilePath: filepath.Join(dataDir, "blocked.conf"), // default, override with SetHostsFilePath
}
}
// SetHostsFilePath sets the output path for the compiled hosts file.
// Must be readable by the dnsmasq process (e.g. /etc/dnsmasq.d/).
func (m *Manager) SetHostsFilePath(path string) {
m.hostsFilePath = path
}
func (m *Manager) listsPath() string { return filepath.Join(m.dataDir, "lists.yaml") }
func (m *Manager) cachePath() string { return filepath.Join(m.dataDir, "cache") }
func (m *Manager) lockPath() string { return filepath.Join(m.dataDir, "lists.yaml.lock") }
// HostsFilePath returns the path to the compiled blocked hosts file.
func (m *Manager) HostsFilePath() string { return m.hostsFilePath }
// EnsureDataDir creates the dns-filter directory structure.
func (m *Manager) EnsureDataDir() error {
if err := storage.EnsureDir(m.dataDir, 0755); err != nil {
return err
}
// Also ensure the hosts file output directory exists (may differ from dataDir)
hostsDir := filepath.Dir(m.hostsFilePath)
if hostsDir != m.dataDir {
if err := storage.EnsureDir(hostsDir, 0755); err != nil {
return err
}
}
return storage.EnsureDir(m.cachePath(), 0755)
}
// LoadFilterData reads lists.yaml. Returns empty data if file doesn't exist.
func (m *Manager) LoadFilterData() (*FilterData, error) {
data, err := os.ReadFile(m.listsPath())
if err != nil {
if os.IsNotExist(err) {
return &FilterData{}, nil
}
return nil, fmt.Errorf("reading filter data: %w", err)
}
var fd FilterData
if err := yaml.Unmarshal(data, &fd); err != nil {
return nil, fmt.Errorf("parsing filter data: %w", err)
}
return &fd, nil
}
// SaveFilterData writes lists.yaml atomically with file locking.
func (m *Manager) SaveFilterData(fd *FilterData) error {
return storage.WithLock(m.lockPath(), func() error {
data, err := yaml.Marshal(fd)
if err != nil {
return fmt.Errorf("marshaling filter data: %w", err)
}
return os.WriteFile(m.listsPath(), data, 0644)
})
}
// listID returns a stable ID for a URL-based list.
func listID(url string) string {
h := sha256.Sum256([]byte(url))
return fmt.Sprintf("%x", h[:8])
}
// cacheFile returns the cache file path for a list by ID.
func (m *Manager) cacheFile(id string) string {
return filepath.Join(m.cachePath(), id+".txt")
}
// AddList adds a URL-based blocklist subscription.
func (m *Manager) AddList(bl Blocklist) error {
fd, err := m.LoadFilterData()
if err != nil {
return err
}
if bl.URL != "" {
bl.ID = listID(bl.URL)
}
if bl.ID == "" {
return fmt.Errorf("list must have a URL or ID")
}
// Deduplicate
for _, existing := range fd.Lists {
if existing.ID == bl.ID {
return fmt.Errorf("list already exists: %s", bl.Name)
}
}
bl.Enabled = true
fd.Lists = append(fd.Lists, bl)
return m.SaveFilterData(fd)
}
// AddUploadedList saves an uploaded blocklist file and adds it as a list entry.
func (m *Manager) AddUploadedList(name string, content []byte) (string, error) {
if err := m.EnsureDataDir(); err != nil {
return "", err
}
h := sha256.Sum256(content)
id := fmt.Sprintf("upload-%x", h[:8])
// Write content to cache
if err := os.WriteFile(m.cacheFile(id), content, 0644); err != nil {
return "", fmt.Errorf("writing uploaded list: %w", err)
}
// Count entries
domains, _ := ParseFile(m.cacheFile(id))
bl := Blocklist{
ID: id,
Name: name,
Enabled: true,
EntryCount: len(domains),
LastUpdated: time.Now(),
}
fd, err := m.LoadFilterData()
if err != nil {
return "", err
}
// Deduplicate
for _, existing := range fd.Lists {
if existing.ID == id {
return id, fmt.Errorf("identical list already uploaded")
}
}
fd.Lists = append(fd.Lists, bl)
return id, m.SaveFilterData(fd)
}
// RemoveList removes a list by ID and its cached file.
func (m *Manager) RemoveList(id string) error {
fd, err := m.LoadFilterData()
if err != nil {
return err
}
found := false
filtered := fd.Lists[:0]
for _, bl := range fd.Lists {
if bl.ID == id {
found = true
_ = os.Remove(m.cacheFile(id))
continue
}
filtered = append(filtered, bl)
}
if !found {
return fmt.Errorf("list not found: %s", id)
}
fd.Lists = filtered
return m.SaveFilterData(fd)
}
// ToggleList enables or disables a list by ID.
func (m *Manager) ToggleList(id string, enabled bool) error {
fd, err := m.LoadFilterData()
if err != nil {
return err
}
for i := range fd.Lists {
if fd.Lists[i].ID == id {
fd.Lists[i].Enabled = enabled
return m.SaveFilterData(fd)
}
}
return fmt.Errorf("list not found: %s", id)
}
// SetCustomEntry adds or replaces a custom allow/block entry.
func (m *Manager) SetCustomEntry(entry CustomEntry) error {
if entry.Type != "allow" && entry.Type != "block" {
return fmt.Errorf("type must be 'allow' or 'block'")
}
fd, err := m.LoadFilterData()
if err != nil {
return err
}
// Replace if exists
for i := range fd.CustomEntries {
if fd.CustomEntries[i].Domain == entry.Domain {
fd.CustomEntries[i].Type = entry.Type
return m.SaveFilterData(fd)
}
}
fd.CustomEntries = append(fd.CustomEntries, entry)
return m.SaveFilterData(fd)
}
// RemoveCustomEntry removes a custom entry by domain.
func (m *Manager) RemoveCustomEntry(domain string) error {
fd, err := m.LoadFilterData()
if err != nil {
return err
}
found := false
filtered := fd.CustomEntries[:0]
for _, e := range fd.CustomEntries {
if e.Domain == domain {
found = true
continue
}
filtered = append(filtered, e)
}
if !found {
return fmt.Errorf("custom entry not found: %s", domain)
}
fd.CustomEntries = filtered
return m.SaveFilterData(fd)
}
// UpdateLists downloads all enabled URL-based lists to cache.
func (m *Manager) UpdateLists(ctx context.Context) error {
if err := m.EnsureDataDir(); err != nil {
return err
}
fd, err := m.LoadFilterData()
if err != nil {
return err
}
client := &http.Client{Timeout: 60 * time.Second}
changed := false
for i := range fd.Lists {
bl := &fd.Lists[i]
if !bl.Enabled || bl.URL == "" {
continue
}
slog.Info("downloading list", "component", "dns-filter", "name", bl.Name, "url", bl.URL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, bl.URL, nil)
if err != nil {
bl.LastError = err.Error()
changed = true
continue
}
req.Header.Set("User-Agent", "WildCentral/1.0")
resp, err := client.Do(req)
if err != nil {
bl.LastError = fmt.Sprintf("download failed: %v", err)
changed = true
slog.Warn("download failed", "component", "dns-filter", "name", bl.Name, "error", err)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
resp.Body.Close()
bl.LastError = fmt.Sprintf("HTTP %d", resp.StatusCode)
changed = true
slog.Warn("download failed", "component", "dns-filter", "name", bl.Name, "status", resp.StatusCode)
continue
}
// Limit download to 50MB
limited := io.LimitReader(resp.Body, 50*1024*1024)
data, err := io.ReadAll(limited)
resp.Body.Close()
if err != nil {
bl.LastError = fmt.Sprintf("read failed: %v", err)
changed = true
continue
}
if err := os.WriteFile(m.cacheFile(bl.ID), data, 0644); err != nil {
bl.LastError = fmt.Sprintf("write failed: %v", err)
changed = true
continue
}
bl.LastUpdated = time.Now()
bl.LastError = ""
changed = true
}
if changed {
_ = m.SaveFilterData(fd)
}
return nil
}
// Compile merges all enabled cached lists, applies custom entries,
// and writes the compiled hosts.blocked file. Returns domain count.
func (m *Manager) Compile() (int, error) {
fd, err := m.LoadFilterData()
if err != nil {
return 0, err
}
blocked := make(map[string]struct{})
// Parse all enabled lists
for i := range fd.Lists {
bl := &fd.Lists[i]
if !bl.Enabled {
continue
}
cachePath := m.cacheFile(bl.ID)
if !storage.FileExists(cachePath) {
continue
}
domains, err := ParseFile(cachePath)
if err != nil {
slog.Warn("failed to parse list", "component", "dns-filter", "name", bl.Name, "error", err)
continue
}
for d := range domains {
blocked[d] = struct{}{}
}
bl.EntryCount = len(domains)
}
// Apply custom entries
for _, ce := range fd.CustomEntries {
switch ce.Type {
case "allow":
delete(blocked, ce.Domain)
case "block":
blocked[ce.Domain] = struct{}{}
}
}
// Write hosts.blocked atomically
tmpPath := m.HostsFilePath() + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return 0, fmt.Errorf("creating hosts file: %w", err)
}
// Sort for deterministic output
sorted := make([]string, 0, len(blocked))
for d := range blocked {
sorted = append(sorted, d)
}
sort.Strings(sorted)
w := bufio.NewWriter(f)
fmt.Fprintln(w, "# Wild Central DNS Filter — auto-generated, do not edit")
for _, d := range sorted {
fmt.Fprintf(w, "address=/%s/\n", d)
}
if err := w.Flush(); err != nil {
f.Close()
os.Remove(tmpPath)
return 0, fmt.Errorf("writing hosts file: %w", err)
}
f.Close()
if err := os.Rename(tmpPath, m.HostsFilePath()); err != nil {
return 0, fmt.Errorf("renaming hosts file: %w", err)
}
// Update entry counts and save
_ = m.SaveFilterData(fd)
slog.Info("compiled blocklist", "component", "dns-filter", "domains", len(sorted))
return len(sorted), nil
}
// GetStats returns current filtering statistics.
func (m *Manager) GetStats() FilterStats {
fd, err := m.LoadFilterData()
if err != nil {
return FilterStats{}
}
var enabled int
var lastUpdated time.Time
for _, bl := range fd.Lists {
if bl.Enabled {
enabled++
}
if bl.LastUpdated.After(lastUpdated) {
lastUpdated = bl.LastUpdated
}
}
// Count lines in hosts.blocked for total
total := 0
if f, err := os.Open(m.HostsFilePath()); err == nil {
scanner := bufio.NewScanner(f)
for scanner.Scan() {
total++
}
_ = scanner.Err()
f.Close()
}
return FilterStats{
TotalBlocked: total,
ListCount: len(fd.Lists),
EnabledLists: enabled,
LastUpdated: lastUpdated,
}
}
// GetQueryStats parses dnsmasq journal logs to count total and blocked queries.
// With address=/ directives, blocked queries log as "config <domain> is NXDOMAIN"
// (for address=/domain/) or "config <domain> is 0.0.0.0".
func (m *Manager) GetQueryStats() *QueryStats {
// Use journalctl to read dnsmasq logs from the last 24 hours.
// Run via sudo since the wildcloud user may not be in the systemd-journal group.
cmd := exec.Command("sudo", "journalctl", "-u", "dnsmasq", "--since", "24 hours ago", "--no-pager", "-o", "cat")
output, err := cmd.Output()
if err != nil {
slog.Debug("failed to read dnsmasq journal", "component", "dns-filter", "error", err)
return nil
}
var total, blocked int
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "query[") {
total++
}
// dnsmasq logs address=/ blocks as: "config <domain> is NXDOMAIN"
// and addn-hosts blocks as: "/path/file <domain> is 0.0.0.0"
if strings.HasPrefix(line, "config ") && strings.Contains(line, " is NXDOMAIN") {
blocked++
}
}
var pct float64
if total > 0 {
pct = float64(blocked) / float64(total) * 100
}
return &QueryStats{
TotalQueries: total,
BlockedQueries: blocked,
BlockedPercent: pct,
Period: "24h",
}
}
// SuggestedLists returns well-known blocklists for easy adding.
// Prefers dnsmasq-format lists which support wildcard subdomain blocking.
func SuggestedLists() []Blocklist {
type entry struct {
url string
name string
}
lists := []entry{
{"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", "Steven Black - Unified Hosts"},
{"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/dnsmasq/light.txt", "Hagezi - Light"},
{"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/dnsmasq/pro.txt", "Hagezi - Pro"},
{"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/dnsmasq/ultimate.txt", "Hagezi - Ultimate"},
{"https://big.oisd.nl/dnsmasq2", "OISD - Big"},
{"https://small.oisd.nl/dnsmasq2", "OISD - Small"},
}
result := make([]Blocklist, len(lists))
for i, l := range lists {
result[i] = Blocklist{ID: listID(l.url), URL: l.url, Name: l.name}
}
return result
}

View File

@@ -0,0 +1,304 @@
package dnsfilter
import (
"os"
"path/filepath"
"strings"
"testing"
)
func setupTestManager(t *testing.T) *Manager {
t.Helper()
tmp := t.TempDir()
m := NewManager(tmp)
m.SetHostsFilePath(filepath.Join(tmp, "blocked.conf"))
if err := m.EnsureDataDir(); err != nil {
t.Fatal(err)
}
return m
}
func TestFilterData_Roundtrip(t *testing.T) {
m := setupTestManager(t)
fd := &FilterData{
Lists: []Blocklist{
{ID: "abc123", Name: "Test List", Enabled: true, EntryCount: 42},
},
CustomEntries: []CustomEntry{
{Domain: "example.com", Type: "allow"},
{Domain: "ads.example.com", Type: "block"},
},
}
if err := m.SaveFilterData(fd); err != nil {
t.Fatalf("save: %v", err)
}
loaded, err := m.LoadFilterData()
if err != nil {
t.Fatalf("load: %v", err)
}
if len(loaded.Lists) != 1 || loaded.Lists[0].ID != "abc123" {
t.Errorf("lists roundtrip failed: %+v", loaded.Lists)
}
if len(loaded.CustomEntries) != 2 {
t.Errorf("custom entries roundtrip failed: %+v", loaded.CustomEntries)
}
}
func TestLoadFilterData_EmptyOnMissing(t *testing.T) {
m := setupTestManager(t)
fd, err := m.LoadFilterData()
if err != nil {
t.Fatal(err)
}
if fd == nil || len(fd.Lists) != 0 {
t.Error("expected empty filter data for missing file")
}
}
func TestAddList_DeduplicatesURL(t *testing.T) {
m := setupTestManager(t)
bl := Blocklist{URL: "https://example.com/list.txt", Name: "Test"}
if err := m.AddList(bl); err != nil {
t.Fatal(err)
}
err := m.AddList(bl)
if err == nil {
t.Error("expected error on duplicate URL")
}
}
func TestAddList_RequiresURLOrID(t *testing.T) {
m := setupTestManager(t)
err := m.AddList(Blocklist{Name: "No URL"})
if err == nil {
t.Error("expected error for list without URL or ID")
}
}
func TestToggleList(t *testing.T) {
m := setupTestManager(t)
bl := Blocklist{URL: "https://example.com/list.txt", Name: "Test"}
if err := m.AddList(bl); err != nil {
t.Fatal(err)
}
fd, _ := m.LoadFilterData()
id := fd.Lists[0].ID
if !fd.Lists[0].Enabled {
t.Error("expected list to be enabled by default")
}
if err := m.ToggleList(id, false); err != nil {
t.Fatal(err)
}
fd, _ = m.LoadFilterData()
if fd.Lists[0].Enabled {
t.Error("expected list to be disabled after toggle")
}
}
func TestToggleList_NotFound(t *testing.T) {
m := setupTestManager(t)
err := m.ToggleList("nonexistent", true)
if err == nil {
t.Error("expected error for nonexistent list")
}
}
func TestRemoveList(t *testing.T) {
m := setupTestManager(t)
bl := Blocklist{URL: "https://example.com/list.txt", Name: "Test"}
if err := m.AddList(bl); err != nil {
t.Fatal(err)
}
fd, _ := m.LoadFilterData()
id := fd.Lists[0].ID
if err := m.RemoveList(id); err != nil {
t.Fatal(err)
}
fd, _ = m.LoadFilterData()
if len(fd.Lists) != 0 {
t.Error("expected list to be removed")
}
}
func TestSetCustomEntry(t *testing.T) {
m := setupTestManager(t)
if err := m.SetCustomEntry(CustomEntry{Domain: "ads.example.com", Type: "block"}); err != nil {
t.Fatal(err)
}
fd, _ := m.LoadFilterData()
if len(fd.CustomEntries) != 1 || fd.CustomEntries[0].Type != "block" {
t.Errorf("custom entry not saved: %+v", fd.CustomEntries)
}
// Update same domain to allow
if err := m.SetCustomEntry(CustomEntry{Domain: "ads.example.com", Type: "allow"}); err != nil {
t.Fatal(err)
}
fd, _ = m.LoadFilterData()
if len(fd.CustomEntries) != 1 || fd.CustomEntries[0].Type != "allow" {
t.Errorf("custom entry not updated: %+v", fd.CustomEntries)
}
}
func TestSetCustomEntry_InvalidType(t *testing.T) {
m := setupTestManager(t)
err := m.SetCustomEntry(CustomEntry{Domain: "example.com", Type: "invalid"})
if err == nil {
t.Error("expected error for invalid type")
}
}
func TestRemoveCustomEntry(t *testing.T) {
m := setupTestManager(t)
_ = m.SetCustomEntry(CustomEntry{Domain: "ads.example.com", Type: "block"})
if err := m.RemoveCustomEntry("ads.example.com"); err != nil {
t.Fatal(err)
}
fd, _ := m.LoadFilterData()
if len(fd.CustomEntries) != 0 {
t.Error("expected custom entry to be removed")
}
}
func TestCompile_MergesBlocklists(t *testing.T) {
m := setupTestManager(t)
// Create two cached list files
_ = os.WriteFile(m.cacheFile("list1"), []byte("ads.example.com\ntracker.example.com\n"), 0644)
_ = os.WriteFile(m.cacheFile("list2"), []byte("malware.example.com\nads.example.com\n"), 0644)
fd := &FilterData{
Lists: []Blocklist{
{ID: "list1", Name: "List 1", Enabled: true},
{ID: "list2", Name: "List 2", Enabled: true},
},
}
_ = m.SaveFilterData(fd)
count, err := m.Compile()
if err != nil {
t.Fatal(err)
}
// 3 unique domains (ads.example.com deduplicated)
if count != 3 {
t.Errorf("expected 3 domains, got %d", count)
}
content, _ := os.ReadFile(m.HostsFilePath())
output := string(content)
if !strings.Contains(output, "address=/ads.example.com/") {
t.Error("missing ads.example.com in output")
}
if !strings.Contains(output, "address=/tracker.example.com/") {
t.Error("missing tracker.example.com in output")
}
if !strings.Contains(output, "address=/malware.example.com/") {
t.Error("missing malware.example.com in output")
}
}
func TestCompile_DisabledListsSkipped(t *testing.T) {
m := setupTestManager(t)
_ = os.WriteFile(m.cacheFile("enabled"), []byte("good.example.com\n"), 0644)
_ = os.WriteFile(m.cacheFile("disabled"), []byte("should-not-appear.example.com\n"), 0644)
fd := &FilterData{
Lists: []Blocklist{
{ID: "enabled", Name: "Enabled", Enabled: true},
{ID: "disabled", Name: "Disabled", Enabled: false},
},
}
_ = m.SaveFilterData(fd)
count, err := m.Compile()
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("expected 1 domain (disabled list skipped), got %d", count)
}
content, _ := os.ReadFile(m.HostsFilePath())
if strings.Contains(string(content), "should-not-appear") {
t.Error("disabled list domains should not appear in output")
}
}
func TestCompile_CustomAllowOverridesBlock(t *testing.T) {
m := setupTestManager(t)
_ = os.WriteFile(m.cacheFile("list1"), []byte("blocked.example.com\nallowed.example.com\n"), 0644)
fd := &FilterData{
Lists: []Blocklist{{ID: "list1", Name: "List 1", Enabled: true}},
CustomEntries: []CustomEntry{{Domain: "allowed.example.com", Type: "allow"}},
}
_ = m.SaveFilterData(fd)
count, err := m.Compile()
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("expected 1 domain (allow override), got %d", count)
}
content, _ := os.ReadFile(m.HostsFilePath())
if strings.Contains(string(content), "allowed.example.com") {
t.Error("allowed domain should not be in blocklist")
}
}
func TestCompile_CustomBlockAdds(t *testing.T) {
m := setupTestManager(t)
fd := &FilterData{
CustomEntries: []CustomEntry{{Domain: "custom-blocked.example.com", Type: "block"}},
}
_ = m.SaveFilterData(fd)
count, err := m.Compile()
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("expected 1 domain from custom block, got %d", count)
}
content, _ := os.ReadFile(m.HostsFilePath())
if !strings.Contains(string(content), "address=/custom-blocked.example.com/") {
t.Error("custom blocked domain should appear in output")
}
}
func TestCompile_EmptyLists(t *testing.T) {
m := setupTestManager(t)
count, err := m.Compile()
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Errorf("expected 0 domains for empty lists, got %d", count)
}
}

View File

@@ -0,0 +1,89 @@
package dnsfilter
import (
"bufio"
"net"
"os"
"strings"
)
// ParseLine extracts a domain from a single blocklist line.
// Handles hosts format (0.0.0.0 domain), dnsmasq format (address=/domain/...),
// and plain domain lists. Returns empty string and false for unparseable lines.
func ParseLine(line string) (string, bool) {
line = strings.TrimSpace(line)
if line == "" || line[0] == '#' || line[0] == '!' {
return "", false
}
// dnsmasq format: address=/domain/0.0.0.0 or server=/domain/
if strings.HasPrefix(line, "address=/") || strings.HasPrefix(line, "server=/") {
parts := strings.SplitN(line, "/", 4)
if len(parts) >= 3 && parts[1] != "" {
return validateDomain(parts[1])
}
return "", false
}
// Hosts format: 0.0.0.0 domain or 127.0.0.1 domain
fields := strings.Fields(line)
if len(fields) >= 2 {
ip := fields[0]
if ip == "0.0.0.0" || ip == "127.0.0.1" || ip == "::1" || ip == "::0" {
domain := fields[1]
// Strip inline comments
if i := strings.IndexByte(domain, '#'); i >= 0 {
domain = domain[:i]
}
return validateDomain(domain)
}
}
// Plain domain format: one domain per line
if len(fields) == 1 {
return validateDomain(fields[0])
}
return "", false
}
// validateDomain checks that a string looks like a valid domain for blocking.
func validateDomain(d string) (string, bool) {
d = strings.ToLower(strings.TrimSpace(d))
if d == "" || !strings.Contains(d, ".") {
return "", false
}
// Reject known non-domains
switch d {
case "localhost", "localhost.localdomain", "local", "broadcasthost",
"ip6-localhost", "ip6-loopback", "ip6-localnet",
"ip6-mcastprefix", "ip6-allnodes", "ip6-allrouters":
return "", false
}
// Reject IP addresses
if net.ParseIP(d) != nil {
return "", false
}
return d, true
}
// ParseFile reads a cached blocklist file and returns all unique domains.
func ParseFile(path string) (map[string]struct{}, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
domains := make(map[string]struct{})
scanner := bufio.NewScanner(f)
for scanner.Scan() {
if domain, ok := ParseLine(scanner.Text()); ok {
domains[domain] = struct{}{}
}
}
return domains, scanner.Err()
}

View File

@@ -0,0 +1,141 @@
package dnsfilter
import (
"os"
"path/filepath"
"testing"
)
func TestParseLine_HostsFormat(t *testing.T) {
tests := []struct {
line string
want string
wantOK bool
}{
{"0.0.0.0 ads.example.com", "ads.example.com", true},
{"127.0.0.1 tracker.example.com", "tracker.example.com", true},
{"0.0.0.0 ads.example.com # comment", "ads.example.com", true},
{"::1 ads.example.com", "ads.example.com", true},
{"0.0.0.0 localhost", "", false},
{"0.0.0.0 broadcasthost", "", false},
{"0.0.0.0 localhost.localdomain", "", false},
{"127.0.0.1 local", "", false},
}
for _, tt := range tests {
got, ok := ParseLine(tt.line)
if ok != tt.wantOK || got != tt.want {
t.Errorf("ParseLine(%q) = (%q, %v), want (%q, %v)", tt.line, got, ok, tt.want, tt.wantOK)
}
}
}
func TestParseLine_DnsmasqFormat(t *testing.T) {
tests := []struct {
line string
want string
wantOK bool
}{
{"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},
{"address=//", "", false},
}
for _, tt := range tests {
got, ok := ParseLine(tt.line)
if ok != tt.wantOK || got != tt.want {
t.Errorf("ParseLine(%q) = (%q, %v), want (%q, %v)", tt.line, got, ok, tt.want, tt.wantOK)
}
}
}
func TestParseLine_PlainDomain(t *testing.T) {
tests := []struct {
line string
want string
wantOK bool
}{
{"ads.example.com", "ads.example.com", true},
{"TRACKER.Example.COM", "tracker.example.com", true},
{"nodot", "", false},
{"# comment", "", false},
{"", "", false},
{" ", "", false},
{"192.168.1.1", "", false},
}
for _, tt := range tests {
got, ok := ParseLine(tt.line)
if ok != tt.wantOK || got != tt.want {
t.Errorf("ParseLine(%q) = (%q, %v), want (%q, %v)", tt.line, got, ok, tt.want, tt.wantOK)
}
}
}
func TestParseFile(t *testing.T) {
content := `# Steven Black hosts file
0.0.0.0 localhost
0.0.0.0 ads.example.com
0.0.0.0 tracker.example.com
127.0.0.1 more-ads.example.com
# duplicate
0.0.0.0 ads.example.com
`
dir := t.TempDir()
path := filepath.Join(dir, "test.hosts")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
domains, err := ParseFile(path)
if err != nil {
t.Fatal(err)
}
// Should have 3 unique domains (localhost excluded)
if len(domains) != 3 {
t.Errorf("got %d domains, want 3", len(domains))
}
for _, expected := range []string{"ads.example.com", "tracker.example.com", "more-ads.example.com"} {
if _, ok := domains[expected]; !ok {
t.Errorf("missing expected domain: %s", expected)
}
}
}
func TestParseFile_MixedFormats(t *testing.T) {
content := `# Mixed format file
0.0.0.0 hosts-format.example.com
address=/dnsmasq-format.example.com/0.0.0.0
plain-domain.example.com
! adblock comment
127.0.0.1 another.example.com
`
dir := t.TempDir()
path := filepath.Join(dir, "mixed.txt")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
domains, err := ParseFile(path)
if err != nil {
t.Fatal(err)
}
if len(domains) != 4 {
t.Errorf("got %d domains, want 4", len(domains))
}
for _, expected := range []string{
"hosts-format.example.com",
"dnsmasq-format.example.com",
"plain-domain.example.com",
"another.example.com",
} {
if _, ok := domains[expected]; !ok {
t.Errorf("missing expected domain: %s", expected)
}
}
}

View File

@@ -0,0 +1,115 @@
package dnsfilter
import (
"context"
"log/slog"
"sync"
"time"
)
// Runner manages the background blocklist update goroutine.
type Runner struct {
mu sync.RWMutex
manager *Manager
cancel context.CancelFunc
trigger chan struct{}
running bool
onUpdate func() // called after lists are downloaded and compiled
}
// NewRunner creates a new DNS filter update runner.
func NewRunner(manager *Manager, onUpdate func()) *Runner {
return &Runner{
manager: manager,
trigger: make(chan struct{}, 1),
onUpdate: onUpdate,
}
}
// Start launches the background update goroutine.
// Safe to call multiple times — cancels the previous run first.
func (r *Runner) Start(parentCtx context.Context, intervalHours int) {
r.mu.Lock()
if r.cancel != nil {
r.cancel()
}
ctx, cancel := context.WithCancel(parentCtx)
r.cancel = cancel
r.running = true
r.mu.Unlock()
if intervalHours <= 0 {
intervalHours = 24
}
go r.run(ctx, intervalHours)
}
// Stop cancels the running goroutine.
func (r *Runner) Stop() {
r.mu.Lock()
defer r.mu.Unlock()
if r.cancel != nil {
r.cancel()
r.cancel = nil
}
r.running = false
}
// IsRunning returns whether the runner is active.
func (r *Runner) IsRunning() bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.running
}
// Trigger requests an immediate update (non-blocking).
func (r *Runner) Trigger() {
select {
case r.trigger <- struct{}{}:
default:
}
}
func (r *Runner) run(ctx context.Context, intervalHours int) {
interval := time.Duration(intervalHours) * time.Hour
slog.Info("runner started", "component", "dns-filter", "interval", interval)
// Immediate update on start
r.updateAndCompile(ctx)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
r.mu.Lock()
r.running = false
r.mu.Unlock()
slog.Info("runner stopped", "component", "dns-filter")
return
case <-ticker.C:
r.updateAndCompile(ctx)
case <-r.trigger:
r.updateAndCompile(ctx)
}
}
}
func (r *Runner) updateAndCompile(ctx context.Context) {
if err := r.manager.UpdateLists(ctx); err != nil {
slog.Error("update failed", "component", "dns-filter", "error", err)
}
if _, err := r.manager.Compile(); err != nil {
slog.Error("compile failed", "component", "dns-filter", "error", err)
return
}
if r.onUpdate != nil {
r.onUpdate()
}
}

View File

@@ -11,42 +11,50 @@ import (
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/network"
"github.com/wild-cloud/wild-central/internal/storage"
)
// DNSEntry represents a single domain-to-IP DNS mapping for dnsmasq.
// Each entry produces both local=/ and address=/ directives.
// local=/ makes dnsmasq authoritative for the domain, which prevents
// AAAA queries from leaking to upstream DNS. Without it, upstream could
// return public IPv6 records and browsers using Happy Eyeballs (RFC 8305)
// would try the unreachable IPv6 path first, adding latency on LAN.
// Exact-match domains use host-record= (SOA/NS queries forwarded upstream,
// enabling ACME DNS-01 validation). Wildcard domains use address=/ to resolve
// all subdomains. Happy Eyeballs (AAAA leaking) is handled globally by
// filter-AAAA in the config template.
type DNSEntry struct {
Domain string // FQDN to resolve (e.g. "cloud.payne.io")
IP string // target IPv4 address
Domain string // FQDN to resolve (e.g. "cloud.payne.io")
IP string // target IPv4 address
Wildcard bool // true: address=/ (all subdomains); false: host-record= (exact match)
}
// ConfigGenerator handles dnsmasq configuration generation
type ConfigGenerator struct {
configPath string
// Manager handles dnsmasq configuration generation
type Manager struct {
configPath string
filterConfPath string // path to addn-hosts file for DNS filtering
}
// NewConfigGenerator creates a new dnsmasq config generator
func NewConfigGenerator(configPath string) *ConfigGenerator {
// NewManager creates a new dnsmasq config generator
func NewManager(configPath string) *Manager {
if configPath == "" {
configPath = "/etc/dnsmasq.d/wild-cloud.conf"
configPath = "/etc/dnsmasq.d/wild-central.conf"
}
return &ConfigGenerator{
return &Manager{
configPath: configPath,
}
}
// GetConfigPath returns the dnsmasq config file path
func (g *ConfigGenerator) GetConfigPath() string {
func (g *Manager) GetConfigPath() string {
return g.configPath
}
// SetFilterConfPath sets the path to an additional hosts file for DNS filtering.
// When set, the generated config includes an addn-hosts= directive.
func (g *Manager) SetFilterConfPath(path string) {
g.filterConfPath = path
}
// Generate creates a dnsmasq configuration from registered domain entries.
// It auto-detects the Wild Central server's IP address for the listen directive.
func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string {
func (g *Manager) Generate(cfg *config.State, entries []DNSEntry) string {
// Use configured dnsmasq IP (bound to eth0), falling back to auto-detect.
// Auto-detect can pick wlan0 if that's the default route, which is wrong
// when dnsmasq is only listening on eth0.
@@ -68,12 +76,17 @@ func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string
if entry.Domain == "" || entry.IP == "" {
continue
}
// local=/ makes dnsmasq authoritative for the domain so AAAA queries
// return empty instead of leaking to upstream DNS. Without this,
// upstream could return public IPv6 records and browsers using Happy
// Eyeballs (RFC 8305) would try the unreachable IPv6 path first,
// adding 250ms-2s latency on LAN.
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", entry.Domain, entry.Domain, entry.IP)
if entry.Wildcard {
// Wildcard: resolves domain + all subdomains to this IP.
// Note: address=/ blocks forwarding for the entire subtree,
// so SOA walks stop here. For non-apex domains this is fine —
// the walk continues up to the parent (which uses host-record=).
resolution_section += fmt.Sprintf("address=/%s/%s\n", entry.Domain, entry.IP)
} else {
// Exact match: only this specific name. SOA/NS/TXT queries
// are forwarded upstream, enabling ACME DNS-01 validation.
resolution_section += fmt.Sprintf("host-record=%s,%s\n", entry.Domain, entry.IP)
}
}
template := `# Configuration file for dnsmasq.
@@ -84,8 +97,9 @@ bind-interfaces
domain-needed
bogus-priv
no-resolv
filter-AAAA
# DNS Local Resolution - Central server handles these domains authoritatively
# DNS Local Resolution - registered domain A records
%s
server=1.1.1.1
server=8.8.8.8
@@ -94,16 +108,24 @@ log-queries
log-dhcp
`
return fmt.Sprintf(template,
result := fmt.Sprintf(template,
dnsIP,
resolution_section,
)
if g.filterConfPath != "" {
result += fmt.Sprintf("\n# DNS Filtering\nconf-file=%s\n", g.filterConfPath)
}
return result
}
// RestartService reloads the dnsmasq service (SIGHUP — no downtime).
// Falls back to restart if reload fails.
func (g *ConfigGenerator) RestartService() error {
cmd := exec.Command("systemctl", "reload-or-restart", "dnsmasq.service")
// RestartService fully restarts the dnsmasq service.
// A full restart is required because SIGHUP (reload) does not re-read
// host-record= directives — only address=/ and a few other directives
// are reloaded on SIGHUP.
func (g *Manager) RestartService() error {
cmd := exec.Command("systemctl", "restart", "dnsmasq.service")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to reload dnsmasq: %w (output: %s)", err, string(output))
@@ -112,18 +134,18 @@ func (g *ConfigGenerator) RestartService() error {
return nil
}
// ServiceStatus represents the status of the dnsmasq service
type ServiceStatus struct {
Status string `json:"status"`
PID int `json:"pid"`
IP string `json:"ip"`
ConfigFile string `json:"config_file"`
DomainsConfigured int `json:"domains_configured"`
LastRestart time.Time `json:"last_restart"`
// Status represents the status of the dnsmasq service
type Status struct {
Status string `json:"status"`
PID int `json:"pid"`
IP string `json:"ip"`
ConfigFile string `json:"configFile"`
DomainsConfigured int `json:"domainsConfigured"`
LastRestart time.Time `json:"lastRestart"`
}
// GetStatus checks the status of the dnsmasq service
func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) {
func (g *Manager) GetStatus() (*Status, error) {
// Get the Wild Central IP address
dnsIP, err := network.GetWildCentralIP()
if err != nil {
@@ -131,7 +153,7 @@ func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) {
dnsIP = ""
}
status := &ServiceStatus{
status := &Status{
ConfigFile: g.configPath,
IP: dnsIP,
}
@@ -174,16 +196,17 @@ func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) {
}
}
// Count domains in config by counting local=/ directives
// Count domains in config by counting host-record= and address=/ directives
if data, err := os.ReadFile(g.configPath); err == nil {
status.DomainsConfigured = strings.Count(string(data), "\nlocal=/")
config := string(data)
status.DomainsConfigured = strings.Count(config, "host-record=") + strings.Count(config, "\naddress=/")
}
return status, nil
}
// ReadConfig reads the current dnsmasq configuration
func (g *ConfigGenerator) ReadConfig() (string, error) {
func (g *Manager) ReadConfig() (string, error) {
data, err := os.ReadFile(g.configPath)
if err != nil {
return "", fmt.Errorf("reading dnsmasq config: %w", err)
@@ -192,13 +215,13 @@ func (g *ConfigGenerator) ReadConfig() (string, error) {
}
// UpdateConfig regenerates and writes the dnsmasq configuration and optionally restarts.
func (g *ConfigGenerator) UpdateConfig(cfg *config.State, entries []DNSEntry, restart bool) error {
func (g *Manager) UpdateConfig(cfg *config.State, entries []DNSEntry, restart bool) error {
// Generate fresh config from scratch
configContent := g.Generate(cfg, entries)
// Write config
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", g.configPath)
if err := os.WriteFile(g.configPath, []byte(configContent), 0644); err != nil {
if err := storage.WriteFileAtomic(g.configPath, []byte(configContent), 0644); err != nil {
return fmt.Errorf("writing dnsmasq config: %w", err)
}
@@ -213,7 +236,7 @@ func (g *ConfigGenerator) UpdateConfig(cfg *config.State, entries []DNSEntry, re
// GenerateMainConfig creates the main dnsmasq configuration with global settings.
// Used by the DnsmasqGenerate handler to preview/apply the base config independently
// of domain-specific DNS entries (which are handled by Generate + UpdateConfig).
func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string {
func (g *Manager) GenerateMainConfig(cfg *config.State) string {
dnsIP, err := network.GetWildCentralIP()
if err != nil {
slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "error", err)
@@ -270,11 +293,71 @@ log-dhcp
}
}
if g.filterConfPath != "" {
fmt.Fprintf(&sb, "\n# DNS Filtering\nconf-file=%s\n", g.filterConfPath)
}
return sb.String()
}
// 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"
if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
return fmt.Errorf("writing validation file: %w", err)
}
defer os.Remove(tmpFile)
if err := g.ValidateConfig(tmpFile); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Backup current config
if storage.FileExists(g.configPath) {
_ = storage.CopyFile(g.configPath, g.configPath+".bak")
}
// Write atomically
if err := storage.WriteFileAtomic(g.configPath, []byte(content), 0644); err != nil {
return fmt.Errorf("write failed: %w", err)
}
// Restart daemon
if err := g.RestartService(); err != nil {
g.rollback()
return fmt.Errorf("restart failed (rolled back): %w", err)
}
// Verify daemon is running
if err := g.verify(); err != nil {
g.rollback()
return fmt.Errorf("health check failed (rolled back): %w", err)
}
return nil
}
func (g *Manager) rollback() {
bakPath := g.configPath + ".bak"
if storage.FileExists(bakPath) {
_ = os.Rename(bakPath, g.configPath)
_ = g.RestartService()
slog.Warn("rolled back to previous config", "component", "dnsmasq", "path", g.configPath)
}
}
func (g *Manager) verify() error {
cmd := exec.Command("systemctl", "is-active", "--quiet", "dnsmasq.service")
if err := cmd.Run(); err != nil {
return fmt.Errorf("dnsmasq not active after restart")
}
return nil
}
// ValidateConfig tests a dnsmasq configuration file for syntax errors
func (g *ConfigGenerator) ValidateConfig(configPath string) error {
func (g *Manager) ValidateConfig(configPath string) error {
cmd := exec.Command("dnsmasq", "--test", "-C", configPath)
output, err := cmd.CombinedOutput()
if err != nil {
@@ -286,22 +369,15 @@ func (g *ConfigGenerator) ValidateConfig(configPath string) error {
return nil
}
// ReloadService sends a SIGHUP to dnsmasq to reload configuration.
// Lighter weight than a full restart. Falls back to RestartService on failure.
func (g *ConfigGenerator) ReloadService() error {
cmd := exec.Command("systemctl", "reload", "dnsmasq.service")
_, err := cmd.CombinedOutput()
if err != nil {
slog.Error("reload failed, attempting restart", "component", "dnsmasq", "error", err)
return g.RestartService()
}
slog.Info("dnsmasq service reloaded", "component", "dnsmasq")
return nil
// ReloadService restarts dnsmasq to apply configuration changes.
// Uses a full restart because SIGHUP does not re-read host-record= directives.
func (g *Manager) ReloadService() error {
return g.RestartService()
}
// ConfigureSystemDNS configures systemd-resolved to use the local dnsmasq server
// This should only be called on first start of dnsmasq
func (g *ConfigGenerator) ConfigureSystemDNS() error {
func (g *Manager) ConfigureSystemDNS() error {
// Auto-detect network info to get the DNS IP
netInfo, err := network.DetectNetworkInfo()
if err != nil {
@@ -312,7 +388,7 @@ func (g *ConfigGenerator) ConfigureSystemDNS() error {
// Write systemd-resolved configuration to file owned by wildcloud user
// (created during package installation in postinst)
resolvedConfPath := "/etc/systemd/resolved.conf.d/wild-cloud.conf"
resolvedConfPath := "/etc/systemd/resolved.conf.d/wild-central.conf"
resolvedConf := fmt.Sprintf("[Resolve]\nDNS=%s\nDomains=~.\n", dnsIP)
if err := os.WriteFile(resolvedConfPath, []byte(resolvedConf), 0644); err != nil {

View File

@@ -12,9 +12,13 @@ func entry(domain, ip string) DNSEntry {
return DNSEntry{Domain: domain, IP: ip}
}
// Test: Generate with entries produces expected DNS entries
func wildcardEntry(domain, ip string) DNSEntry {
return DNSEntry{Domain: domain, IP: ip, Wildcard: true}
}
// Test: Generate with exact-match entries produces host-record= directives
func TestGenerate_WithEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
entries := []DNSEntry{
entry("cloud.example.com", "192.168.1.10"),
entry("internal.example.com", "192.168.1.10"),
@@ -22,17 +26,11 @@ func TestGenerate_WithEntries(t *testing.T) {
out := g.Generate(nil, entries)
if !strings.Contains(out, "local=/cloud.example.com/") {
t.Errorf("expected local=/ for domain, got:\n%s", out)
if !strings.Contains(out, "host-record=cloud.example.com,192.168.1.10") {
t.Errorf("expected host-record for domain, got:\n%s", out)
}
if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") {
t.Errorf("expected address entry for domain, got:\n%s", out)
}
if !strings.Contains(out, "local=/internal.example.com/") {
t.Errorf("expected local=/ for internal domain, got:\n%s", out)
}
if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") {
t.Errorf("expected address entry for internal domain, got:\n%s", out)
if !strings.Contains(out, "host-record=internal.example.com,192.168.1.10") {
t.Errorf("expected host-record for internal domain, got:\n%s", out)
}
if !strings.Contains(out, "server=1.1.1.1") {
t.Errorf("expected upstream DNS server, got:\n%s", out)
@@ -41,7 +39,7 @@ func TestGenerate_WithEntries(t *testing.T) {
// Test: Generate with no entries still produces valid config skeleton
func TestGenerate_NoEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
out := g.Generate(nil, []DNSEntry{})
@@ -58,18 +56,18 @@ func TestGenerate_NoEntries(t *testing.T) {
// Test: Generate skips entries with empty IP
func TestGenerate_EntryWithoutIP(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
out := g.Generate(nil, []DNSEntry{entry("cloud.example.com", "")})
if strings.Contains(out, "address=/") {
t.Errorf("expected no address= entries for empty IP, got:\n%s", out)
if strings.Contains(out, "host-record=") {
t.Errorf("expected no host-record entries for empty IP, got:\n%s", out)
}
}
// Test: GenerateMainConfig produces valid base config
func TestGenerateMainConfig(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
out := g.GenerateMainConfig(nil)
@@ -83,7 +81,7 @@ func TestGenerateMainConfig(t *testing.T) {
// Test: GenerateMainConfig with DHCP disabled does not include dhcp-range
func TestGenerateMainConfig_DHCPDisabled(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = false
@@ -96,7 +94,7 @@ func TestGenerateMainConfig_DHCPDisabled(t *testing.T) {
// Test: GenerateMainConfig with DHCP enabled includes dhcp-range
func TestGenerateMainConfig_DHCPEnabled(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
@@ -116,7 +114,7 @@ func TestGenerateMainConfig_DHCPEnabled(t *testing.T) {
// Test: DHCP without gateway omits dhcp-option=3
func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
@@ -131,7 +129,7 @@ func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) {
// Test: DHCP explicit gateway is used
func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
@@ -147,7 +145,7 @@ func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
// Test: DHCP static leases appear in output
func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
@@ -169,7 +167,7 @@ func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) {
// Test: DHCP lease time defaults to 24h when not specified
func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
@@ -182,96 +180,116 @@ func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) {
}
}
// Test: NewConfigGenerator uses provided path
func TestNewConfigGenerator_CustomPath(t *testing.T) {
g := NewConfigGenerator("/custom/path/dnsmasq.conf")
// Test: NewManager uses provided path
func TestNewManager_CustomPath(t *testing.T) {
g := NewManager("/custom/path/dnsmasq.conf")
if g.GetConfigPath() != "/custom/path/dnsmasq.conf" {
t.Errorf("got %q, want /custom/path/dnsmasq.conf", g.GetConfigPath())
}
}
// Test: NewConfigGenerator uses default path when empty
func TestNewConfigGenerator_DefaultPath(t *testing.T) {
g := NewConfigGenerator("")
if g.GetConfigPath() != "/etc/dnsmasq.d/wild-cloud.conf" {
t.Errorf("got %q, want /etc/dnsmasq.d/wild-cloud.conf", g.GetConfigPath())
// Test: NewManager uses default path when empty
func TestNewManager_DefaultPath(t *testing.T) {
g := NewManager("")
if g.GetConfigPath() != "/etc/dnsmasq.d/wild-central.conf" {
t.Errorf("got %q, want /etc/dnsmasq.d/wild-central.conf", g.GetConfigPath())
}
}
// Test: Domain-only entry (no internal domain pair)
func TestGenerate_DomainOnly(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
// Test: Exact-match domain produces host-record=
func TestGenerate_ExactMatchDomain(t *testing.T) {
g := NewManager("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
out := g.Generate(globalCfg, []DNSEntry{entry("my-api.payne.io", "192.168.8.151")})
if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") {
t.Errorf("expected address entry for domain, got:\n%s", out)
if !strings.Contains(out, "host-record=my-api.payne.io,192.168.8.151") {
t.Errorf("expected host-record entry for domain, got:\n%s", out)
}
if !strings.Contains(out, "local=/my-api.payne.io/") {
t.Errorf("expected local=/ entry for domain, got:\n%s", out)
if strings.Contains(out, "address=/") {
t.Errorf("exact-match domain must not produce address=/ directive:\n%s", out)
}
// Must NOT have empty entries
if strings.Contains(out, "local=//") {
t.Errorf("unexpected empty local=// in output:\n%s", out)
}
if strings.Contains(out, "address=//") {
t.Errorf("unexpected empty address=// in output:\n%s", out)
if strings.Contains(out, "local=/") {
t.Errorf("must not produce local=/ directive:\n%s", out)
}
}
// Test: All domains get local=/ to prevent AAAA leaking upstream
func TestGenerate_AllDomainsGetLocal(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
// Test: Wildcard domain produces address=/ without local=/
func TestGenerate_WildcardDomain(t *testing.T) {
g := NewManager("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
out := g.Generate(globalCfg, []DNSEntry{wildcardEntry("cloud.payne.io", "192.168.8.240")})
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("expected address=/ entry for wildcard domain, got:\n%s", out)
}
if strings.Contains(out, "host-record=") {
t.Errorf("wildcard domain must not produce host-record= directive:\n%s", out)
}
if strings.Contains(out, "local=/") {
t.Errorf("must not produce local=/ directive:\n%s", out)
}
}
// Test: No local=/ directives are ever generated
func TestGenerate_NoLocalDirectives(t *testing.T) {
g := NewManager("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
// Both "internal" and "public" domains should get local=/ —
// prevents AAAA queries from leaking to upstream DNS (Happy Eyeballs).
entries := []DNSEntry{
entry("internal-api.payne.io", "192.168.8.151"),
entry("cloud.payne.io", "192.168.8.240"),
wildcardEntry("cloud.payne.io", "192.168.8.240"),
}
out := g.Generate(globalCfg, entries)
if !strings.Contains(out, "local=/internal-api.payne.io/") {
t.Errorf("expected local=/ for internal domain, got:\n%s", out)
}
if !strings.Contains(out, "local=/cloud.payne.io/") {
t.Errorf("expected local=/ for public domain (prevents AAAA leaking), got:\n%s", out)
if strings.Contains(out, "local=/") {
t.Errorf("must never produce local=/ directives:\n%s", out)
}
}
// Test: Mix of domains each get their own entries
// Test: filter-AAAA appears in generated config
func TestGenerate_FilterAAAA(t *testing.T) {
g := NewManager("/tmp/test-dnsmasq.conf")
out := g.Generate(nil, nil)
if !strings.Contains(out, "filter-AAAA") {
t.Errorf("expected filter-AAAA in config, got:\n%s", out)
}
}
// Test: Mix of exact and wildcard domains
func TestGenerate_MixedDomains(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
entries := []DNSEntry{
entry("cloud.payne.io", "192.168.8.240"), // k8s passthrough
entry("central.payne.io", "192.168.8.151"), // Central HTTP
entry("wild-cloud.payne.io", "192.168.8.151"), // service HTTP
wildcardEntry("cloud.payne.io", "192.168.8.240"), // wildcard
entry("central.payne.io", "192.168.8.151"), // exact
entry("wild-cloud.payne.io", "192.168.8.151"), // exact
}
out := g.Generate(globalCfg, entries)
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("expected address for cloud domain, got:\n%s", out)
t.Errorf("expected address=/ for wildcard domain, got:\n%s", out)
}
if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") {
t.Errorf("expected address for central, got:\n%s", out)
if !strings.Contains(out, "host-record=central.payne.io,192.168.8.151") {
t.Errorf("expected host-record for exact domain, got:\n%s", out)
}
if !strings.Contains(out, "address=/wild-cloud.payne.io/192.168.8.151") {
t.Errorf("expected address for wild-cloud, got:\n%s", out)
if !strings.Contains(out, "host-record=wild-cloud.payne.io,192.168.8.151") {
t.Errorf("expected host-record for exact domain, got:\n%s", out)
}
if strings.Contains(out, "local=//") {
t.Errorf("unexpected empty local=// in output:\n%s", out)
if strings.Contains(out, "local=/") {
t.Errorf("must not produce local=/ directives:\n%s", out)
}
}
// Test: empty entries list produces base config with no address= entries
// Test: empty entries list produces base config with no DNS entries
func TestGenerate_EmptyEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
out := g.Generate(globalCfg, []DNSEntry{})
@@ -282,17 +300,17 @@ func TestGenerate_EmptyEntries(t *testing.T) {
if !strings.Contains(out, "server=1.1.1.1") {
t.Errorf("expected upstream DNS server, got:\n%s", out)
}
if strings.Contains(out, "address=/") {
t.Errorf("expected no address= entries with empty entries, got:\n%s", out)
if strings.Contains(out, "host-record=") {
t.Errorf("expected no host-record entries with empty entries, got:\n%s", out)
}
if strings.Contains(out, "local=/") {
t.Errorf("expected no local=/ entries with empty entries, got:\n%s", out)
if strings.Contains(out, "address=/") {
t.Errorf("expected no address=/ entries with empty entries, got:\n%s", out)
}
}
// Test: multiple entries each get their own DNS records
// Test: multiple exact-match entries each get their own DNS records
func TestGenerate_MultipleEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
entries := []DNSEntry{
@@ -304,18 +322,52 @@ func TestGenerate_MultipleEntries(t *testing.T) {
out := g.Generate(globalCfg, entries)
for _, e := range entries {
if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", e.Domain, e.IP)) {
t.Errorf("expected address entry for %s, got:\n%s", e.Domain, out)
}
if !strings.Contains(out, fmt.Sprintf("local=/%s/", e.Domain)) {
t.Errorf("expected local=/ entry for %s, got:\n%s", e.Domain, out)
if !strings.Contains(out, fmt.Sprintf("host-record=%s,%s", e.Domain, e.IP)) {
t.Errorf("expected host-record entry for %s, got:\n%s", e.Domain, out)
}
}
}
func TestGenerate_ConfFile(t *testing.T) {
g := NewManager("/tmp/test-dnsmasq.conf")
g.SetFilterConfPath("/var/lib/wild-central/dns-filter/hosts.blocked")
out := g.Generate(nil, nil)
if !strings.Contains(out, "conf-file=/var/lib/wild-central/dns-filter/hosts.blocked") {
t.Errorf("expected addn-hosts directive, got:\n%s", out)
}
if !strings.Contains(out, "# DNS Filtering") {
t.Errorf("expected DNS Filtering comment, got:\n%s", out)
}
}
func TestGenerate_NoConfFileWhenEmpty(t *testing.T) {
g := NewManager("/tmp/test-dnsmasq.conf")
// addnHostsPath is empty by default
out := g.Generate(nil, nil)
if strings.Contains(out, "addn-hosts") {
t.Errorf("should not contain addn-hosts when path is empty, got:\n%s", out)
}
}
func TestGenerateMainConfig_ConfFile(t *testing.T) {
g := NewManager("/tmp/test-dnsmasq.conf")
g.SetFilterConfPath("/tmp/hosts.blocked")
cfg := &config.State{}
out := g.GenerateMainConfig(cfg)
if !strings.Contains(out, "conf-file=/tmp/hosts.blocked") {
t.Errorf("expected addn-hosts in main config, got:\n%s", out)
}
}
// Test: nil config doesn't panic, uses auto-detect for IP
func TestGenerate_NilConfig(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
out := g.Generate(nil, []DNSEntry{})
@@ -329,7 +381,7 @@ func TestGenerate_NilConfig(t *testing.T) {
// Test: when cfg.Cloud.Dnsmasq.IP is set, uses that instead of auto-detect
func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cfg.Cloud.Dnsmasq.IP = "192.168.8.100"
@@ -340,9 +392,9 @@ func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
}
}
// Test: verify no address=// or local=// entries appear
// Test: verify no leaked empty entries appear
func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g := NewManager("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cases := []struct {
@@ -350,7 +402,8 @@ func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
entries []DNSEntry
}{
{"empty", nil},
{"domain_only", []DNSEntry{entry("example.com", "10.0.0.1")}},
{"exact_domain", []DNSEntry{entry("example.com", "10.0.0.1")}},
{"wildcard_domain", []DNSEntry{wildcardEntry("example.com", "10.0.0.1")}},
{"no_ip", []DNSEntry{entry("example.com", "")}},
{"no_domain", []DNSEntry{entry("", "10.0.0.1")}},
}
@@ -358,11 +411,14 @@ func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := g.Generate(cfg, tc.entries)
if strings.Contains(out, "host-record=,") {
t.Errorf("leaked empty host-record in output:\n%s", out)
}
if strings.Contains(out, "address=//") {
t.Errorf("leaked empty address=// in output:\n%s", out)
}
if strings.Contains(out, "local=//") {
t.Errorf("leaked empty local=// in output:\n%s", out)
if strings.Contains(out, "local=/") {
t.Errorf("must not produce local=/ directives:\n%s", out)
}
})
}

View File

@@ -9,12 +9,16 @@ package domains
import (
"fmt"
"log/slog"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-central/internal/storage"
)
// BackendType describes how the gateway handles traffic for this domain.
@@ -58,17 +62,25 @@ type Route struct {
IPAllow []string `yaml:"ipAllow,omitempty" json:"ipAllow,omitempty"` // per-route CIDR whitelist
}
// AuthConfig describes forward-auth protection for an L7 HTTP domain.
// Only applicable to domains with backend type "http" (L7 TLS termination).
type AuthConfig struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
Policy string `yaml:"policy,omitempty" json:"policy,omitempty"` // one_factor, two_factor, bypass
}
// Domain represents a registered domain. The Domain field is the unique key.
//
// Simple domains use Backend directly. Multi-backend domains use Routes
// for path-based splitting (Backend is ignored when Routes is present).
type Domain struct {
DomainName string `yaml:"domain" json:"domain"` // FQDN — unique key
Source string `yaml:"source,omitempty" json:"source,omitempty"` // who registered: wild-cloud, wild-works, manual
Backend Backend `yaml:"backend,omitempty" json:"backend,omitempty"` // single backend (simple case)
Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain
Public bool `yaml:"public,omitempty" json:"public,omitempty"` // true = internet-visible (DDNS + external), false = LAN only
TLS TLSMode `yaml:"tls,omitempty" json:"tls,omitempty"` // passthrough or terminate
DomainName string `yaml:"domain" json:"domain"` // FQDN — unique key
Source string `yaml:"source,omitempty" json:"source,omitempty"` // who registered: wild-cloud, wild-works, manual
Backend Backend `yaml:"backend,omitempty" json:"backend,omitempty"` // single backend (simple case)
Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain
Public bool `yaml:"public,omitempty" json:"public,omitempty"` // true = internet-visible (DDNS + external), false = LAN only
TLS TLSMode `yaml:"tls,omitempty" json:"tls,omitempty"` // passthrough or terminate
Auth *AuthConfig `yaml:"auth,omitempty" json:"auth,omitempty"` // forward-auth protection (L7 HTTP only)
// Multi-backend path routing. When present, Backend is ignored.
// Each route maps path prefixes to a specific backend with its own L7 options.
@@ -151,6 +163,86 @@ func (m *Manager) domainPath(domain string) string {
return filepath.Join(m.domainsDir(), domainToFilename(domain))
}
// isValidDomain checks that a string is a valid FQDN (RFC 1123) or wildcard domain.
// Prevents config injection via newlines, spaces, or shell metacharacters.
func isValidDomain(s string) bool {
if len(s) == 0 || len(s) > 253 {
return false
}
for _, label := range strings.Split(s, ".") {
if len(label) == 0 || len(label) > 63 {
return false
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for _, c := range label {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '*') {
return false
}
}
}
return true
}
// isValidBackendAddress checks that an address is a valid host:port.
func isValidBackendAddress(addr string) bool {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return false
}
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
return false
}
if net.ParseIP(host) != nil {
return true
}
return isValidDomain(host)
}
// isValidHeaderName checks that a header name contains only HTTP token characters.
func isValidHeaderName(s string) bool {
if len(s) == 0 {
return false
}
for _, c := range s {
if c <= ' ' || c == ':' || c == '"' || c >= 0x7f {
return false
}
}
return true
}
// isValidHeaderValue checks that a header value contains no newlines or NULs.
func isValidHeaderValue(s string) bool {
return !strings.ContainsAny(s, "\r\n\x00")
}
// validateHeaders checks all header keys and values in a HeaderConfig.
func validateHeaders(h *HeaderConfig) error {
if h == nil {
return nil
}
for k, v := range h.Request {
if !isValidHeaderName(k) {
return fmt.Errorf("invalid request header name: %q", k)
}
if !isValidHeaderValue(v) {
return fmt.Errorf("invalid request header value for %q", k)
}
}
for k, v := range h.Response {
if !isValidHeaderName(k) {
return fmt.Errorf("invalid response header name: %q", k)
}
if !isValidHeaderValue(v) {
return fmt.Errorf("invalid response header value for %q", k)
}
}
return nil
}
// Register creates or updates a domain registration.
func (m *Manager) Register(dom Domain) error {
m.mu.Lock()
@@ -159,6 +251,9 @@ func (m *Manager) Register(dom Domain) error {
if dom.DomainName == "" {
return fmt.Errorf("domain is required")
}
if !isValidDomain(dom.DomainName) {
return fmt.Errorf("invalid domain name: %q", dom.DomainName)
}
hasBackend := dom.Backend.Address != ""
hasRoutes := len(dom.Routes) > 0
@@ -175,14 +270,28 @@ func (m *Manager) Register(dom Domain) error {
if r.Backend.Address == "" {
return fmt.Errorf("route %d: backend address is required", i)
}
if !isValidBackendAddress(r.Backend.Address) {
return fmt.Errorf("route %d: invalid backend address: %q", i, r.Backend.Address)
}
if r.Backend.Type == "" {
return fmt.Errorf("route %d: backend type is required", i)
}
if err := validateHeaders(r.Headers); err != nil {
return fmt.Errorf("route %d: %w", i, err)
}
}
} else {
if dom.Backend.Type == "" {
return fmt.Errorf("backend type is required")
}
// dns-only backends may omit port (just an IP for resolution)
if dom.Backend.Type == BackendDNSOnly {
if net.ParseIP(dom.Backend.Address) == nil && !isValidBackendAddress(dom.Backend.Address) {
return fmt.Errorf("invalid backend address: %q", dom.Backend.Address)
}
} else if !isValidBackendAddress(dom.Backend.Address) {
return fmt.Errorf("invalid backend address: %q", dom.Backend.Address)
}
}
// Default TLS mode based on backend type
@@ -207,7 +316,7 @@ func (m *Manager) Register(dom Domain) error {
return fmt.Errorf("marshaling domain: %w", err)
}
if err := os.WriteFile(m.domainPath(dom.DomainName), data, 0644); err != nil {
if err := storage.WriteFileAtomic(m.domainPath(dom.DomainName), data, 0644); err != nil {
return fmt.Errorf("writing domain file: %w", err)
}
@@ -354,6 +463,21 @@ func (m *Manager) Update(domain string, updates map[string]any) error {
if tls, ok := updates["tls"].(string); ok {
dom.TLS = TLSMode(tls)
}
if auth, ok := updates["auth"].(map[string]any); ok {
if dom.Auth == nil {
dom.Auth = &AuthConfig{}
}
if enabled, ok := auth["enabled"].(bool); ok {
dom.Auth.Enabled = enabled
}
if policy, ok := auth["policy"].(string); ok {
dom.Auth.Policy = policy
}
// Clear auth if disabled and no policy
if !dom.Auth.Enabled && dom.Auth.Policy == "" {
dom.Auth = nil
}
}
return m.Register(*dom)
}

View File

@@ -13,6 +13,7 @@ import (
"time"
"github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/storage"
)
// L4Route represents an L4 TCP passthrough route.
@@ -34,11 +35,13 @@ type CustomRoute struct {
// HTTPRouteBackend maps path prefixes to a specific backend with its own L7 options.
type HTTPRouteBackend struct {
Paths []string // path prefixes (empty = catch-all)
Backend string // host:port
HealthPath string // optional health check path
Headers *domains.HeaderConfig // per-route headers
IPAllow []string // per-route CIDR whitelist
Paths []string // path prefixes (empty = catch-all)
Backend string // host:port
HealthPath string // optional health check path
Headers *domains.HeaderConfig // per-route headers
IPAllow []string // per-route CIDR whitelist
AuthEnabled bool // forward-auth protection via Authelia
AuthPolicy string // one_factor, two_factor (for access control)
}
// HTTPRoute represents an L7 HTTP reverse proxy route.
@@ -80,14 +83,43 @@ func (m *Manager) GetConfigPath() string {
// GenerateOpts holds optional parameters for HAProxy config generation.
type GenerateOpts struct {
HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central)
CertsDir string // directory containing per-domain PEM files for L7 TLS
HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central)
CertsDir string // directory containing per-domain PEM files for L7 TLS
AuthEnabled bool // global Authelia forward-auth toggle
AuthBackend string // Authelia backend address (e.g. "127.0.0.1:9091")
AuthDomain string // Authelia login portal domain (excluded from protection)
AuthSessionDomain string // session cookie scope (e.g. "payne.io") — only subdomains are eligible for forward-auth
}
// GenerateWithOpts creates a complete HAProxy configuration with full options.
func (m *Manager) GenerateWithOpts(instances []L4Route, custom []CustomRoute, opts GenerateOpts) string {
var sb strings.Builder
writeGlobalDefaults(&sb, opts.AuthEnabled)
if len(instances) > 0 || len(opts.HTTPRoutes) > 0 {
writeHTTPSFrontend(&sb, instances, opts)
writeL4Backends(&sb, instances)
if len(opts.HTTPRoutes) > 0 {
writeL7Stack(&sb, opts)
}
}
if opts.AuthEnabled && opts.AuthBackend != "" {
sb.WriteString("# Authelia forward-auth backend\n")
sb.WriteString("backend be_authelia\n")
sb.WriteString(" mode http\n")
fmt.Fprintf(&sb, " server s0 %s\n", opts.AuthBackend)
sb.WriteString("\n")
}
writeCustomRoutes(&sb, custom)
return sb.String()
}
// writeGlobalDefaults writes the HAProxy global, defaults, stats, and HTTP redirect sections.
func writeGlobalDefaults(sb *strings.Builder, authEnabled bool) {
sb.WriteString(`# Wild Cloud HAProxy Configuration
# Managed by Wild Cloud Central API — do not edit manually
@@ -97,7 +129,13 @@ global
stats timeout 30s
maxconn 50000
daemon
`)
if authEnabled {
sb.WriteString(" lua-prepend-path /etc/haproxy/lua/?.lua\n")
sb.WriteString(" lua-load /etc/haproxy/lua/haproxy-auth-request.lua\n")
}
sb.WriteString(`
defaults
log global
mode tcp
@@ -122,231 +160,278 @@ frontend http_in
redirect scheme https code 301
`)
}
hasL4Routes := len(instances) > 0 || len(opts.HTTPRoutes) > 0
// writeHTTPSFrontend writes the SNI-based HTTPS frontend with ACL routing.
// ACL ordering is critical:
// 1. L7 HTTP exact matches → L7 termination
// 2. L4 exact matches → instance backends
// 3. L4 wildcard matches → instance backends
// 4. Default → L7 termination (if any HTTP routes exist)
func writeHTTPSFrontend(sb *strings.Builder, instances []L4Route, opts GenerateOpts) {
sb.WriteString("# HTTPS: SNI-based L4 routing (TLS passes through to k8s traefik)\n")
sb.WriteString("frontend https_in\n")
sb.WriteString(" bind *:443\n")
sb.WriteString(" mode tcp\n")
sb.WriteString(" tcp-request inspect-delay 5s\n")
sb.WriteString(" tcp-request content accept if { req_ssl_hello_type 1 }\n")
sb.WriteString("\n")
if hasL4Routes {
sb.WriteString("# HTTPS: SNI-based L4 routing (TLS passes through to k8s traefik)\n")
sb.WriteString("frontend https_in\n")
sb.WriteString(" bind *:443\n")
// 1. L7 HTTP services — route to L7 TLS termination frontend
if len(opts.HTTPRoutes) > 0 {
sb.WriteString(" # L7 HTTP services (→ TLS termination)\n")
for _, route := range opts.HTTPRoutes {
if route.Subdomains {
continue
}
aclName := "is_l7_" + sanitizeName(route.Name)
fmt.Fprintf(sb, " # service: %s\n", route.Domain)
fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain)
fmt.Fprintf(sb, " use_backend be_l7_termination if %s\n", aclName)
}
for _, route := range opts.HTTPRoutes {
if !route.Subdomains {
continue
}
aclName := "is_l7_" + sanitizeName(route.Name)
fmt.Fprintf(sb, " # service: %s\n", route.Domain)
fmt.Fprintf(sb, " acl %s req_ssl_sni -m end .%s\n", aclName, route.Domain)
fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain)
fmt.Fprintf(sb, " use_backend be_l7_termination if %s\n", aclName)
}
sb.WriteString("\n")
}
// 2. L4 exact matches
hasExact := false
for _, inst := range instances {
if inst.Subdomains {
continue
}
hasExact = true
aclName := "is_" + sanitizeName(inst.Name)
fmt.Fprintf(sb, " # service: %s\n", inst.Domain)
fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain)
fmt.Fprintf(sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
}
if hasExact {
sb.WriteString("\n")
}
// 3. L4 wildcard matches
sb.WriteString(" # L4 instance routes (wildcard subdomain matching)\n")
for _, inst := range instances {
if !inst.Subdomains {
continue
}
aclName := "is_" + sanitizeName(inst.Name)
fmt.Fprintf(sb, " # service: %s\n", inst.Domain)
fmt.Fprintf(sb, " acl %s req_ssl_sni -m end .%s\n", aclName, inst.Domain)
fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain)
fmt.Fprintf(sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
}
sb.WriteString("\n")
// 4. Default fallback
if len(opts.HTTPRoutes) > 0 {
sb.WriteString(" default_backend be_l7_termination\n")
}
sb.WriteString("\n")
}
// writeL4Backends writes L4 TCP passthrough backend definitions.
func writeL4Backends(sb *strings.Builder, instances []L4Route) {
for _, inst := range instances {
fmt.Fprintf(sb, "# service: %s\n", inst.Domain)
fmt.Fprintf(sb, "backend be_https_%s\n", sanitizeName(inst.Name))
sb.WriteString(" mode tcp\n")
sb.WriteString(" tcp-request inspect-delay 5s\n")
sb.WriteString(" tcp-request content accept if { req_ssl_hello_type 1 }\n")
sb.WriteString(" option tcp-check\n")
fmt.Fprintf(sb, " server s0 %s:443 check\n", inst.BackendIP)
sb.WriteString("\n")
}
}
// ACL ordering is critical. Process in this order:
// 1. L7 HTTP exact matches (central, wild-cloud app) → L7 termination
// 2. L4 exact matches (payne.io, civilsociety.dev) → instance backends
// 3. L4 wildcard matches (*.cloud.payne.io) → instance backends
// 4. Default → L7 termination (if any HTTP routes exist)
// writeL7Stack writes the L7 TLS termination backend, frontend, host ACLs,
// per-route rules (IP whitelisting, headers, auth), routing, and backends.
func writeL7Stack(sb *strings.Builder, opts GenerateOpts) {
certPath := opts.CertsDir
if certPath == "" {
certPath = "/etc/haproxy/certs/"
}
// 1. L7 HTTP services — route to L7 TLS termination frontend
if len(opts.HTTPRoutes) > 0 {
sb.WriteString(" # L7 HTTP services (→ TLS termination)\n")
// Exact matches first
for _, route := range opts.HTTPRoutes {
if route.Subdomains {
continue // handled below
}
aclName := "is_l7_" + sanitizeName(route.Name)
fmt.Fprintf(&sb, " # service: %s\n", route.Domain)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain)
fmt.Fprintf(&sb, " use_backend be_l7_termination if %s\n", aclName)
}
// Wildcard matches after exact
for _, route := range opts.HTTPRoutes {
if !route.Subdomains {
continue
}
aclName := "is_l7_" + sanitizeName(route.Name)
fmt.Fprintf(&sb, " # service: %s\n", route.Domain)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m end .%s\n", aclName, route.Domain)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain)
fmt.Fprintf(&sb, " use_backend be_l7_termination if %s\n", aclName)
}
sb.WriteString("\n")
// TLS termination backend + frontend binding
sb.WriteString("backend be_l7_termination\n")
sb.WriteString(" mode tcp\n")
sb.WriteString(" server s0 127.0.0.1:8443\n")
sb.WriteString("\n")
sb.WriteString("# L7 HTTP frontend: TLS termination + Host-based routing\n")
sb.WriteString("frontend l7_https\n")
fmt.Fprintf(sb, " bind 127.0.0.1:8443 ssl crt %s\n", certPath)
sb.WriteString(" mode http\n")
sb.WriteString("\n")
// Host ACLs
for _, httpRoute := range opts.HTTPRoutes {
hostACL := "host_" + sanitizeName(httpRoute.Name)
fmt.Fprintf(sb, " # service: %s\n", httpRoute.Domain)
if httpRoute.Subdomains {
fmt.Fprintf(sb, " acl %s hdr(host) -i -m end .%s\n", hostACL, httpRoute.Domain)
fmt.Fprintf(sb, " acl %s hdr(host) -i -m str %s\n", hostACL, httpRoute.Domain)
} else {
fmt.Fprintf(sb, " acl %s hdr(host) -i %s\n", hostACL, httpRoute.Domain)
}
}
sb.WriteString("\n")
// 2. L4 exact matches — instances with subdomains:false
hasExact := false
for _, inst := range instances {
if inst.Subdomains {
continue // handled in step 3
}
hasExact = true
aclName := "is_" + sanitizeName(inst.Name)
fmt.Fprintf(&sb, " # service: %s\n", inst.Domain)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain)
fmt.Fprintf(&sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
}
if hasExact {
sb.WriteString("\n")
}
// Per-route ACLs: IP whitelisting and headers
writeL7RouteACLs(sb, opts.HTTPRoutes)
// 3. L4 wildcard matches — instances with subdomains:true
sb.WriteString(" # L4 instance routes (wildcard subdomain matching)\n")
for _, inst := range instances {
if !inst.Subdomains {
continue // already handled in step 2
}
aclName := "is_" + sanitizeName(inst.Name)
fmt.Fprintf(&sb, " # service: %s\n", inst.Domain)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m end .%s\n", aclName, inst.Domain)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain)
fmt.Fprintf(&sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
}
sb.WriteString("\n")
// Authelia forward-auth directives
if opts.AuthEnabled && opts.AuthBackend != "" {
writeL7AuthDirectives(sb, opts)
}
// 4. Default → L7 termination fallback
if len(opts.HTTPRoutes) > 0 {
sb.WriteString(" default_backend be_l7_termination\n")
}
// Routing rules: path-specific first, then catch-all
writeL7RoutingRules(sb, opts.HTTPRoutes)
sb.WriteString("\n")
// L7 backends — one per route
writeL7Backends(sb, opts.HTTPRoutes)
}
// Instance L4 backends
for _, inst := range instances {
fmt.Fprintf(&sb, "# service: %s\n", inst.Domain)
fmt.Fprintf(&sb, "backend be_https_%s\n", sanitizeName(inst.Name))
sb.WriteString(" mode tcp\n")
sb.WriteString(" option tcp-check\n")
fmt.Fprintf(&sb, " server s0 %s:443 check\n", inst.BackendIP)
sb.WriteString("\n")
}
// writeL7RouteACLs writes per-route IP whitelisting and header directives.
func writeL7RouteACLs(sb *strings.Builder, httpRoutes []HTTPRoute) {
for _, httpRoute := range httpRoutes {
hostACL := "host_" + sanitizeName(httpRoute.Name)
routeName := sanitizeName(httpRoute.Name)
// L7 TLS termination backend + frontend (for HTTP routes)
if len(opts.HTTPRoutes) > 0 {
// Load all PEM files from the certs directory — HAProxy serves
// the right cert per-SNI. Each service gets its own <domain>.pem.
certPath := opts.CertsDir
if certPath == "" {
certPath = "/etc/haproxy/certs/"
}
for i, rb := range httpRoute.Routes {
aclSuffix := fmt.Sprintf("%s_%d", routeName, i)
sb.WriteString("backend be_l7_termination\n")
sb.WriteString(" mode tcp\n")
sb.WriteString(" server s0 127.0.0.1:8443\n")
sb.WriteString("\n")
sb.WriteString("# L7 HTTP frontend: TLS termination + Host-based routing\n")
sb.WriteString("frontend l7_https\n")
fmt.Fprintf(&sb, " bind 127.0.0.1:8443 ssl crt %s\n", certPath)
sb.WriteString(" mode http\n")
sb.WriteString("\n")
// Host ACLs for all services
for _, httpRoute := range opts.HTTPRoutes {
hostACL := "host_" + sanitizeName(httpRoute.Name)
fmt.Fprintf(&sb, " # service: %s\n", httpRoute.Domain)
if httpRoute.Subdomains {
fmt.Fprintf(&sb, " acl %s hdr(host) -i -m end .%s\n", hostACL, httpRoute.Domain)
fmt.Fprintf(&sb, " acl %s hdr(host) -i -m str %s\n", hostACL, httpRoute.Domain)
if len(rb.IPAllow) > 0 {
ipACL := "ip_" + aclSuffix
fmt.Fprintf(sb, " acl %s src %s\n", ipACL, strings.Join(rb.IPAllow, " "))
if len(rb.Paths) > 0 {
pathACL := "path_" + aclSuffix
fmt.Fprintf(sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " "))
fmt.Fprintf(sb, " http-request deny if %s %s !%s\n", hostACL, pathACL, ipACL)
} else {
fmt.Fprintf(&sb, " acl %s hdr(host) -i %s\n", hostACL, httpRoute.Domain)
}
}
sb.WriteString("\n")
// Per-route ACLs (IP whitelisting, headers) and routing rules
for _, httpRoute := range opts.HTTPRoutes {
hostACL := "host_" + sanitizeName(httpRoute.Name)
routeName := sanitizeName(httpRoute.Name)
for i, rb := range httpRoute.Routes {
aclSuffix := fmt.Sprintf("%s_%d", routeName, i)
// IP whitelisting for this route
if len(rb.IPAllow) > 0 {
ipACL := "ip_" + aclSuffix
fmt.Fprintf(&sb, " acl %s src %s\n", ipACL, strings.Join(rb.IPAllow, " "))
if len(rb.Paths) > 0 {
pathACL := "path_" + aclSuffix
fmt.Fprintf(&sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " "))
fmt.Fprintf(&sb, " http-request deny if %s %s !%s\n", hostACL, pathACL, ipACL)
} else {
fmt.Fprintf(&sb, " http-request deny if %s !%s\n", hostACL, ipACL)
}
}
// Request headers
if rb.Headers != nil {
for _, k := range sortedKeys(rb.Headers.Request) {
fmt.Fprintf(&sb, " http-request set-header %s %q if %s\n", k, rb.Headers.Request[k], hostACL)
}
}
// Response headers
if rb.Headers != nil {
for _, k := range sortedKeys(rb.Headers.Response) {
fmt.Fprintf(&sb, " http-response set-header %s %q if %s\n", k, rb.Headers.Response[k], hostACL)
}
}
fmt.Fprintf(sb, " http-request deny if %s !%s\n", hostACL, ipACL)
}
}
sb.WriteString("\n")
// Routing rules: path-specific first, then catch-all
for _, httpRoute := range opts.HTTPRoutes {
hostACL := "host_" + sanitizeName(httpRoute.Name)
routeName := sanitizeName(httpRoute.Name)
// Path-specific routes first (most specific wins)
for i, rb := range httpRoute.Routes {
if len(rb.Paths) == 0 {
continue
}
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
pathACL := fmt.Sprintf("path_%s_%d", routeName, i)
fmt.Fprintf(&sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " "))
fmt.Fprintf(&sb, " use_backend %s if %s %s\n", backendName, hostACL, pathACL)
if rb.Headers != nil {
for _, k := range sortedKeys(rb.Headers.Request) {
fmt.Fprintf(sb, " http-request set-header %s %q if %s\n", k, rb.Headers.Request[k], hostACL)
}
// Catch-all route last
for i, rb := range httpRoute.Routes {
if len(rb.Paths) > 0 {
continue
}
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
fmt.Fprintf(&sb, " use_backend %s if %s\n", backendName, hostACL)
}
}
sb.WriteString("\n")
// L7 backends — one per route
for _, httpRoute := range opts.HTTPRoutes {
routeName := sanitizeName(httpRoute.Name)
for i, rb := range httpRoute.Routes {
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
fmt.Fprintf(&sb, "# service: %s\n", httpRoute.Domain)
fmt.Fprintf(&sb, "backend %s\n", backendName)
sb.WriteString(" mode http\n")
if rb.HealthPath != "" {
fmt.Fprintf(&sb, " option httpchk GET %s\n", rb.HealthPath)
fmt.Fprintf(&sb, " server s0 %s check\n", rb.Backend)
} else {
fmt.Fprintf(&sb, " server s0 %s\n", rb.Backend)
}
sb.WriteString("\n")
for _, k := range sortedKeys(rb.Headers.Response) {
fmt.Fprintf(sb, " http-response set-header %s %q if %s\n", k, rb.Headers.Response[k], hostACL)
}
}
}
}
}
for _, route := range custom {
fmt.Fprintf(&sb, "# Custom route: %s\n", route.Name)
fmt.Fprintf(&sb, "frontend fe_%s\n", sanitizeName(route.Name))
fmt.Fprintf(&sb, " bind *:%d\n", route.Port)
sb.WriteString(" mode tcp\n")
fmt.Fprintf(&sb, " default_backend be_%s\n", sanitizeName(route.Name))
sb.WriteString("\n")
fmt.Fprintf(&sb, "backend be_%s\n", sanitizeName(route.Name))
sb.WriteString(" mode tcp\n")
fmt.Fprintf(&sb, " server s0 %s\n", route.Backend)
sb.WriteString("\n")
// writeL7AuthDirectives writes Authelia forward-auth directives for eligible routes.
func writeL7AuthDirectives(sb *strings.Builder, opts GenerateOpts) {
authDomainACL := ""
if opts.AuthDomain != "" {
authDomainACL = "host_" + sanitizeName(opts.AuthDomain)
}
return sb.String()
for _, httpRoute := range opts.HTTPRoutes {
hostACL := "host_" + sanitizeName(httpRoute.Name)
// Skip auth portal domain (would cause infinite redirect)
if hostACL == authDomainACL {
continue
}
// Skip domains outside the auth session scope
if opts.AuthSessionDomain != "" &&
httpRoute.Domain != opts.AuthSessionDomain &&
!strings.HasSuffix(httpRoute.Domain, "."+opts.AuthSessionDomain) {
continue
}
hasAuth := false
for _, rb := range httpRoute.Routes {
if rb.AuthEnabled {
hasAuth = true
break
}
}
if !hasAuth {
continue
}
fmt.Fprintf(sb, " # auth: %s\n", httpRoute.Domain)
fmt.Fprintf(sb, " http-request lua.auth-request be_authelia /api/authz/forward-auth if %s\n", hostACL)
fmt.Fprintf(sb, " http-request redirect location https://%s/?rd=https://%%[hdr(host)]%%[path] if %s !{ var(txn.auth_response_successful) -m bool }\n", opts.AuthDomain, hostACL)
}
sb.WriteString("\n")
}
// writeL7RoutingRules writes use_backend rules: path-specific first, then catch-all.
func writeL7RoutingRules(sb *strings.Builder, httpRoutes []HTTPRoute) {
for _, httpRoute := range httpRoutes {
hostACL := "host_" + sanitizeName(httpRoute.Name)
routeName := sanitizeName(httpRoute.Name)
for i, rb := range httpRoute.Routes {
if len(rb.Paths) == 0 {
continue
}
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
pathACL := fmt.Sprintf("path_%s_%d", routeName, i)
fmt.Fprintf(sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " "))
fmt.Fprintf(sb, " use_backend %s if %s %s\n", backendName, hostACL, pathACL)
}
for i, rb := range httpRoute.Routes {
if len(rb.Paths) > 0 {
continue
}
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
fmt.Fprintf(sb, " use_backend %s if %s\n", backendName, hostACL)
}
}
sb.WriteString("\n")
}
// writeL7Backends writes L7 HTTP backend definitions — one per route.
func writeL7Backends(sb *strings.Builder, httpRoutes []HTTPRoute) {
for _, httpRoute := range httpRoutes {
routeName := sanitizeName(httpRoute.Name)
for i, rb := range httpRoute.Routes {
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
fmt.Fprintf(sb, "# service: %s\n", httpRoute.Domain)
fmt.Fprintf(sb, "backend %s\n", backendName)
sb.WriteString(" mode http\n")
if rb.HealthPath != "" {
fmt.Fprintf(sb, " option httpchk GET %s\n", rb.HealthPath)
fmt.Fprintf(sb, " server s0 %s check\n", rb.Backend)
} else {
fmt.Fprintf(sb, " server s0 %s\n", rb.Backend)
}
sb.WriteString("\n")
}
}
}
// writeCustomRoutes writes custom TCP proxy frontend/backend pairs.
func writeCustomRoutes(sb *strings.Builder, custom []CustomRoute) {
for _, route := range custom {
fmt.Fprintf(sb, "# Custom route: %s\n", route.Name)
fmt.Fprintf(sb, "frontend fe_%s\n", sanitizeName(route.Name))
fmt.Fprintf(sb, " bind *:%d\n", route.Port)
sb.WriteString(" mode tcp\n")
fmt.Fprintf(sb, " default_backend be_%s\n", sanitizeName(route.Name))
sb.WriteString("\n")
fmt.Fprintf(sb, "backend be_%s\n", sanitizeName(route.Name))
sb.WriteString(" mode tcp\n")
fmt.Fprintf(sb, " server s0 %s\n", route.Backend)
sb.WriteString("\n")
}
}
// sortedKeys returns the keys of a map in sorted order for deterministic output.
@@ -466,6 +551,51 @@ func (m *Manager) WriteConfig(content string) error {
return nil
}
// SafeApply validates, backs up, writes, reloads, and verifies the HAProxy config.
// On reload or verification failure, rolls back to the previous config.
func (m *Manager) SafeApply(content string) error {
// Backup current config
if storage.FileExists(m.configPath) {
_ = storage.CopyFile(m.configPath, m.configPath+".bak")
}
// Validate + atomic write
if err := m.WriteConfig(content); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Reload daemon
if err := m.ReloadService(); err != nil {
m.rollback()
return fmt.Errorf("reload failed (rolled back): %w", err)
}
// Verify daemon is running
if err := m.verify(); err != nil {
m.rollback()
return fmt.Errorf("health check failed (rolled back): %w", err)
}
return nil
}
func (m *Manager) rollback() {
bakPath := m.configPath + ".bak"
if storage.FileExists(bakPath) {
_ = os.Rename(bakPath, m.configPath)
_ = m.ReloadService()
slog.Warn("rolled back to previous config", "component", "haproxy", "path", m.configPath)
}
}
func (m *Manager) verify() error {
cmd := exec.Command("systemctl", "is-active", "--quiet", "haproxy.service")
if err := cmd.Run(); err != nil {
return fmt.Errorf("haproxy not active after reload")
}
return nil
}
// ReloadService gracefully reloads HAProxy (starts it if not running)
func (m *Manager) ReloadService() error {
cmd := exec.Command("systemctl", "reload-or-restart", "haproxy.service")
@@ -488,8 +618,8 @@ func (m *Manager) RestartService() error {
return nil
}
// ServiceStatus represents the status of the HAProxy service
type ServiceStatus struct {
// Status represents the status of the HAProxy service
type Status struct {
Status string `json:"status"`
PID int `json:"pid"`
ConfigFile string `json:"configFile"`
@@ -497,8 +627,8 @@ type ServiceStatus struct {
}
// GetStatus returns the current HAProxy service status
func (m *Manager) GetStatus() (*ServiceStatus, error) {
status := &ServiceStatus{
func (m *Manager) GetStatus() (*Status, error) {
status := &Status{
ConfigFile: m.configPath,
}

View File

@@ -833,3 +833,213 @@ line3
t.Errorf("expected 0 broken services when no markers, got %d", len(broken))
}
}
// --- Authelia forward-auth tests ---
func TestGenerateWithOpts_AuthLuaLoad(t *testing.T) {
m := NewManager("")
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
AuthEnabled: true,
AuthBackend: "127.0.0.1:9091",
AuthDomain: "auth.payne.io",
})
if !strings.Contains(cfg, "lua-load /etc/haproxy/lua/haproxy-auth-request.lua") {
t.Error("config should contain lua-load directive when auth is enabled")
}
}
func TestGenerateWithOpts_NoAuthLuaLoadWhenDisabled(t *testing.T) {
m := NewManager("")
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
AuthEnabled: false,
})
if strings.Contains(cfg, "lua-load") {
t.Error("config should NOT contain lua-load when auth is disabled")
}
}
func TestGenerateWithOpts_AuthBackend(t *testing.T) {
m := NewManager("")
httpRoutes := []HTTPRoute{
{
Name: "app.payne.io",
Domain: "app.payne.io",
Routes: []HTTPRouteBackend{
{Backend: "127.0.0.1:8080", AuthEnabled: true},
},
},
}
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
HTTPRoutes: httpRoutes,
CertsDir: "/tmp/certs/",
AuthEnabled: true,
AuthBackend: "127.0.0.1:9091",
AuthDomain: "auth.payne.io",
})
if !strings.Contains(cfg, "backend be_authelia") {
t.Error("config should contain Authelia backend")
}
if !strings.Contains(cfg, "server s0 127.0.0.1:9091") {
t.Error("config should contain Authelia server address")
}
}
func TestGenerateWithOpts_AuthInterceptDirective(t *testing.T) {
m := NewManager("")
httpRoutes := []HTTPRoute{
{
Name: "app.payne.io",
Domain: "app.payne.io",
Routes: []HTTPRouteBackend{
{Backend: "127.0.0.1:8080", AuthEnabled: true},
},
},
}
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
HTTPRoutes: httpRoutes,
CertsDir: "/tmp/certs/",
AuthEnabled: true,
AuthBackend: "127.0.0.1:9091",
AuthDomain: "auth.payne.io",
})
if !strings.Contains(cfg, "lua.auth-request be_authelia") {
t.Error("config should contain auth-intercept directive for protected route")
}
if !strings.Contains(cfg, "auth.payne.io/?rd=") {
t.Error("config should contain redirect to auth portal")
}
}
func TestGenerateWithOpts_AuthDomainExempt(t *testing.T) {
m := NewManager("")
httpRoutes := []HTTPRoute{
{
Name: "auth.payne.io",
Domain: "auth.payne.io",
Routes: []HTTPRouteBackend{
{Backend: "127.0.0.1:9091", AuthEnabled: true},
},
},
{
Name: "app.payne.io",
Domain: "app.payne.io",
Routes: []HTTPRouteBackend{
{Backend: "127.0.0.1:8080", AuthEnabled: true},
},
},
}
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
HTTPRoutes: httpRoutes,
CertsDir: "/tmp/certs/",
AuthEnabled: true,
AuthBackend: "127.0.0.1:9091",
AuthDomain: "auth.payne.io",
})
// Count auth-intercept directives — should be 1 (for app), not 2 (auth domain exempt)
count := strings.Count(cfg, "lua.auth-request")
if count != 1 {
t.Errorf("expected 1 auth-intercept directive (auth domain exempt), got %d", count)
}
// The auth comment should only appear for app.payne.io
if !strings.Contains(cfg, "# auth: app.payne.io") {
t.Error("should have auth comment for app.payne.io")
}
if strings.Contains(cfg, "# auth: auth.payne.io") {
t.Error("should NOT have auth comment for auth.payne.io (exempt)")
}
}
func TestGenerateWithOpts_NoAuthForUnprotectedRoutes(t *testing.T) {
m := NewManager("")
httpRoutes := []HTTPRoute{
{
Name: "public.payne.io",
Domain: "public.payne.io",
Routes: []HTTPRouteBackend{
{Backend: "127.0.0.1:8080", AuthEnabled: false},
},
},
}
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
HTTPRoutes: httpRoutes,
CertsDir: "/tmp/certs/",
AuthEnabled: true,
AuthBackend: "127.0.0.1:9091",
AuthDomain: "auth.payne.io",
})
if strings.Contains(cfg, "lua.auth-request") {
t.Error("should NOT contain auth-intercept for unprotected routes")
}
}
func TestGenerateWithOpts_AuthDisabledNoDirectives(t *testing.T) {
m := NewManager("")
httpRoutes := []HTTPRoute{
{
Name: "app.payne.io",
Domain: "app.payne.io",
Routes: []HTTPRouteBackend{
{Backend: "127.0.0.1:8080", AuthEnabled: true},
},
},
}
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
HTTPRoutes: httpRoutes,
CertsDir: "/tmp/certs/",
AuthEnabled: false,
})
if strings.Contains(cfg, "lua.auth-request") {
t.Error("should NOT contain auth directives when auth is globally disabled")
}
if strings.Contains(cfg, "be_authelia") {
t.Error("should NOT contain authelia backend when auth is globally disabled")
}
}
func TestGenerateWithOpts_AuthWithHeaders(t *testing.T) {
m := NewManager("")
httpRoutes := []HTTPRoute{
{
Name: "app.payne.io",
Domain: "app.payne.io",
Routes: []HTTPRouteBackend{
{
Backend: "127.0.0.1:8080",
AuthEnabled: true,
Headers: &domains.HeaderConfig{
Request: map[string]string{"X-Custom": "value"},
},
},
},
},
}
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
HTTPRoutes: httpRoutes,
CertsDir: "/tmp/certs/",
AuthEnabled: true,
AuthBackend: "127.0.0.1:9091",
AuthDomain: "auth.payne.io",
})
// Should have both auth and headers
if !strings.Contains(cfg, "lua.auth-request") {
t.Error("should contain auth-intercept")
}
if !strings.Contains(cfg, "X-Custom") {
t.Error("should still contain custom headers alongside auth")
}
}

View File

@@ -17,7 +17,7 @@ import (
const (
// KV bucket names
BucketDomains = "wild-domains" // domain registrations
BucketDomains = "wild-domains" // domain registrations
BucketPresence = "wild-presence" // node liveness (TTL keys)
// Stream names
@@ -37,8 +37,9 @@ type Server struct {
// Config for the embedded NATS server.
type Config struct {
Port int // listen port (default 4222)
DataDir string // JetStream storage directory
Port int // listen port (default 4222)
DataDir string // JetStream storage directory
AuthToken string // require this token for client connections (empty = no auth)
}
// Start launches the embedded NATS server with JetStream and creates
@@ -52,9 +53,11 @@ func Start(cfg Config) (*Server, error) {
Port: cfg.Port,
JetStream: true,
StoreDir: cfg.DataDir,
// Quiet logging — NATS logs at its own level
NoLog: true,
NoSigs: true,
NoLog: true,
NoSigs: true,
}
if cfg.AuthToken != "" {
opts.Authorization = cfg.AuthToken
}
ns, err := natsserver.NewServer(opts)
@@ -87,7 +90,11 @@ func Start(cfg Config) (*Server, error) {
slog.Info("NATS JetStream server started", "port", cfg.Port, "dataDir", cfg.DataDir)
// Connect as internal client
nc, err := nats.Connect(ns.ClientURL())
connectOpts := []nats.Option{}
if cfg.AuthToken != "" {
connectOpts = append(connectOpts, nats.Token(cfg.AuthToken))
}
nc, err := nats.Connect(ns.ClientURL(), connectOpts...)
if err != nil {
ns.Shutdown()
return nil, fmt.Errorf("connecting to embedded NATS: %w", err)

View File

@@ -9,8 +9,8 @@ import (
// NetworkInfo holds detected network configuration
type NetworkInfo struct {
PrimaryIP string `json:"primary_ip"`
PrimaryInterface string `json:"primary_interface"`
PrimaryIP string `json:"primaryIP"`
PrimaryInterface string `json:"primaryInterface"`
Gateway string `json:"gateway"`
}

View File

@@ -3,12 +3,15 @@ package nftables
import (
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"strings"
"github.com/wild-cloud/wild-central/internal/storage"
)
const defaultRulesPath = "/etc/nftables.d/wild-cloud.nft"
const defaultRulesPath = "/etc/nftables.d/wild-central.nft"
// Manager handles nftables rule generation and application
type Manager struct {
@@ -65,15 +68,15 @@ func (m *Manager) Generate(haproxyPorts []int, extraTCPPorts []int, extraUDPPort
var sb strings.Builder
sb.WriteString("# Wild Cloud nftables rules\n")
sb.WriteString("# Managed by Wild Cloud Central API — do not edit manually\n\n")
sb.WriteString("# Wild Central nftables rules\n")
sb.WriteString("# Managed by Wild Central — do not edit manually\n\n")
// Delete and recreate the table so re-applying never accumulates stale set elements.
// The first line ensures the table exists (so delete never errors on first run).
sb.WriteString("table inet wild-cloud {}\n")
sb.WriteString("delete table inet wild-cloud\n\n")
sb.WriteString("table inet wild-central {}\n")
sb.WriteString("delete table inet wild-central\n\n")
sb.WriteString("table inet wild-cloud {\n")
sb.WriteString("table inet wild-central {\n")
sb.WriteString(" # TCP ports allowed through the firewall (HAProxy + extra TCP)\n")
sb.WriteString(" set allowed_tcp_ports {\n")
sb.WriteString(" type inet_service\n")
@@ -129,11 +132,79 @@ func (m *Manager) ValidateRules(rulesPath string) error {
return nil
}
// ValidateWANInterface checks that the specified WAN interface exists on the system.
// Returns nil if empty (no WAN filtering) or if the interface exists.
func ValidateWANInterface(name string) error {
if name == "" {
return nil
}
if _, err := net.InterfaceByName(name); err != nil {
return fmt.Errorf("WAN interface %q not found: %w", name, err)
}
return nil
}
// SafeApply validates, backs up, writes, and applies nftables rules.
// On apply failure, rolls back to the previous rules.
func (m *Manager) SafeApply(content string) error {
// Validate syntax
tmpFile := "/tmp/wild-central-nft-safeapply.tmp"
if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
return fmt.Errorf("writing validation file: %w", err)
}
defer os.Remove(tmpFile)
if err := m.ValidateRules(tmpFile); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Backup current rules
if storage.FileExists(m.rulesPath) {
_ = storage.CopyFile(m.rulesPath, m.rulesPath+".bak")
}
// Write atomically
if err := storage.WriteFileAtomic(m.rulesPath, []byte(content), 0644); err != nil {
return fmt.Errorf("write failed: %w", err)
}
// Apply to kernel
if err := m.ApplyRules(); err != nil {
m.rollback()
return fmt.Errorf("apply failed (rolled back): %w", err)
}
// Verify rules loaded
if err := m.verify(); err != nil {
m.rollback()
return fmt.Errorf("verification failed (rolled back): %w", err)
}
return nil
}
func (m *Manager) rollback() {
bakPath := m.rulesPath + ".bak"
if storage.FileExists(bakPath) {
_ = os.Rename(bakPath, m.rulesPath)
_ = m.ApplyRules()
slog.Warn("rolled back to previous rules", "component", "nftables", "path", m.rulesPath)
}
}
func (m *Manager) verify() error {
status, err := m.GetStatus()
if err != nil || status == "" {
return fmt.Errorf("nftables table not loaded after apply")
}
return nil
}
// WriteRules validates the content then writes the nftables rules file.
// Validation uses a temp file in /tmp (world-writable) to avoid needing
// write permission on the /etc/nftables.d/ directory itself.
func (m *Manager) WriteRules(content string) error {
tempFile := "/tmp/wild-cloud-nft-validate.tmp"
tempFile := "/tmp/wild-central-nft-validate.tmp"
if err := os.WriteFile(tempFile, []byte(content), 0644); err != nil {
return fmt.Errorf("writing temp rules: %w", err)
@@ -144,7 +215,7 @@ func (m *Manager) WriteRules(content string) error {
return err
}
if err := os.WriteFile(m.rulesPath, []byte(content), 0644); err != nil {
if err := storage.WriteFileAtomic(m.rulesPath, []byte(content), 0644); err != nil {
return fmt.Errorf("writing rules file: %w", err)
}
@@ -153,9 +224,9 @@ func (m *Manager) WriteRules(content string) error {
}
// ApplyRules loads the rules file into the kernel via a systemd oneshot service.
// The wildcloud user has polkit permission to start wild-cloud-nftables-reload.service.
// The wildcloud user has polkit permission to start wild-central-nftables-reload.service.
func (m *Manager) ApplyRules() error {
cmd := exec.Command("systemctl", "start", "wild-cloud-nftables-reload.service")
cmd := exec.Command("systemctl", "start", "wild-central-nftables-reload.service")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("applying nftables rules: %w (output: %s)", err, string(output))
@@ -164,24 +235,24 @@ func (m *Manager) ApplyRules() error {
return nil
}
// WriteDisabledRules writes a rules file that flushes the wild-cloud table,
// removing all Wild Cloud firewall rules from the kernel when applied.
// WriteDisabledRules writes a rules file that flushes the wild-central table,
// removing all Wild Central firewall rules from the kernel when applied.
func (m *Manager) WriteDisabledRules() error {
content := "# Wild Cloud nftables rules — firewall disabled\n" +
"# Managed by Wild Cloud Central API — do not edit manually\n\n" +
"table inet wild-cloud {}\n" +
"delete table inet wild-cloud\n"
if err := os.WriteFile(m.rulesPath, []byte(content), 0644); err != nil {
content := "# Wild Central nftables rules — firewall disabled\n" +
"# Managed by Wild Central — do not edit manually\n\n" +
"table inet wild-central {}\n" +
"delete table inet wild-central\n"
if err := storage.WriteFileAtomic(m.rulesPath, []byte(content), 0644); err != nil {
return fmt.Errorf("writing disabled rules file: %w", err)
}
slog.Info("nftables rules disabled", "component", "nftables", "path", m.rulesPath)
return nil
}
// GetStatus returns the current wild-cloud nftables table as a string.
// GetStatus returns the current wild-central nftables table as a string.
// Uses sudo to allow the wildcloud user to read kernel state.
func (m *Manager) GetStatus() (string, error) {
cmd := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud")
cmd := exec.Command("sudo", "nft", "list", "table", "inet", "wild-central")
output, err := cmd.CombinedOutput()
if err != nil {
// Table may not exist yet — not an error

View File

@@ -13,9 +13,9 @@ func TestNewManager_DefaultPath(t *testing.T) {
}
func TestNewManager_CustomPath(t *testing.T) {
m := NewManager("/tmp/wild-cloud.nft")
if m.GetRulesPath() != "/tmp/wild-cloud.nft" {
t.Errorf("got %q, want /tmp/wild-cloud.nft", m.GetRulesPath())
m := NewManager("/tmp/wild-central.nft")
if m.GetRulesPath() != "/tmp/wild-central.nft" {
t.Errorf("got %q, want /tmp/wild-central.nft", m.GetRulesPath())
}
}
@@ -40,7 +40,7 @@ func TestGenerate_AlwaysIncludesStructure(t *testing.T) {
out := m.Generate([]int{80, 443}, nil, nil, "")
for _, want := range []string{
"table inet wild-cloud",
"table inet wild-central",
"set allowed_tcp_ports",
"chain input",
"type filter hook input priority filter; policy accept;",

View File

@@ -0,0 +1,533 @@
// Package reconcile orchestrates the domain→config→daemon pipeline.
// When domains change, Reconcile regenerates HAProxy routes, dnsmasq DNS
// entries, and related subsystem configs, then reloads the affected daemons.
package reconcile
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"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/dnsmasq"
"github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/haproxy"
"github.com/wild-cloud/wild-central/internal/network"
"github.com/wild-cloud/wild-central/internal/sse"
"github.com/wild-cloud/wild-central/internal/storage"
)
// HAProxyManager is the subset of haproxy.Manager used by reconciliation.
type HAProxyManager interface {
GenerateWithOpts(instances []haproxy.L4Route, custom []haproxy.CustomRoute, opts haproxy.GenerateOpts) string
SafeApply(content string) error
WriteConfig(content string) error
ReloadService() error
}
// DNSManager is the subset of dnsmasq.Manager used by reconciliation.
type DNSManager interface {
Generate(cfg *config.State, entries []dnsmasq.DNSEntry) string
SafeApply(content string) error
SetFilterConfPath(path string)
GetStatus() (*dnsmasq.Status, error)
}
// DomainManager is the subset of domains.Manager used by reconciliation.
type DomainManager interface {
List() ([]domains.Domain, error)
}
// AuthManager is the subset of authelia.Manager used by reconciliation.
type AuthManager interface {
UserCount() int
RestartService() error
}
// CertManager is the subset of certbot.Manager used by reconciliation.
type CertManager interface {
EnsureCredentials(apiToken string) error
Provision(domain, email string) error
BuildHAProxyCert(domain string) error
}
// DDNSRunner is the subset of ddns.Runner used by reconciliation.
type DDNSRunner interface {
Trigger()
}
// DNSFilterManager is the subset of dnsfilter.Manager used by reconciliation.
type DNSFilterManager interface {
HostsFilePath() string
}
// EventBroadcaster is the subset of sse.Manager used by reconciliation.
type EventBroadcaster interface {
Broadcast(event *sse.Event)
}
// GenerateAutheliaConfigFn generates the Authelia config from current state.
// This is a callback because the logic depends on secrets and OIDC clients
// that the reconciler doesn't own.
type GenerateAutheliaConfigFn func(state *config.State) error
// SubsystemHealth records the outcome of the last reconciliation for one subsystem.
type SubsystemHealth struct {
Status string `json:"status"` // "ok", "degraded", "error"
Message string `json:"message,omitempty"`
}
// Health records the outcome of the last reconciliation across all subsystems.
type Health struct {
HAProxy SubsystemHealth `json:"haproxy"`
DNS SubsystemHealth `json:"dns"`
TLS SubsystemHealth `json:"tls"`
UpdatedAt time.Time `json:"updatedAt"`
ExcludedDomains []string `json:"excludedDomains,omitempty"`
MissingCerts []string `json:"missingCerts,omitempty"`
}
// Reconciler orchestrates config regeneration when domains change.
type Reconciler struct {
mu sync.Mutex // serializes concurrent Reconcile() calls
health Health
Domains DomainManager
HAProxy HAProxyManager
DNS DNSManager
Auth AuthManager
Certs CertManager
DDNS DDNSRunner
DNSFilter DNSFilterManager
SSE EventBroadcaster
StatePath string
GenerateAutheliaConfig GenerateAutheliaConfigFn
GetCloudflareToken func() string // callback to read CF token from secrets
}
// GetHealth returns a snapshot of the last reconciliation health state.
func (r *Reconciler) GetHealth() Health {
r.mu.Lock()
defer r.mu.Unlock()
return r.health
}
// Reconcile reads all registered domains and regenerates dnsmasq DNS entries
// and HAProxy routes to match. Serialized by mutex — concurrent calls wait
// rather than racing on config files.
func (r *Reconciler) Reconcile() {
r.mu.Lock()
defer r.mu.Unlock()
doms, err := r.Domains.List()
if err != nil {
slog.Error("failed to list domains", "component", "reconcile", "error", err)
return
}
globalCfg, err := config.LoadState(r.StatePath)
if err != nil {
if os.IsNotExist(err) {
globalCfg = &config.State{} // first run, no state yet
} else {
slog.Error("failed to load state (refusing to reconcile with empty config)", "component", "reconcile", "error", err)
return
}
}
l4Routes, httpRoutes := buildRoutes(doms)
cleanZeroByteCerts("/etc/haproxy/certs/")
// Only include L7 HTTP routes for domains that have a valid cert.
var activeHTTPRoutes []haproxy.HTTPRoute
for _, route := range httpRoutes {
if hasCertForDomain(route.Domain) {
activeHTTPRoutes = append(activeHTTPRoutes, route)
} else {
slog.Warn("skipping L7 route (no cert)", "component", "reconcile", "domain", route.Domain)
}
}
previousHealth := r.health
r.writeHAProxyConfig(l4Routes, activeHTTPRoutes, globalCfg)
// Build and apply DNS config
centralIP := globalCfg.Cloud.Dnsmasq.IP
if centralIP == "" {
centralIP, _ = network.GetWildCentralIP()
}
if centralIP == "" {
centralIP = "127.0.0.1"
}
dnsEntries := buildDNSEntries(doms, centralIP)
if globalCfg.Cloud.DNSFilter.Enabled {
hostsPath := r.DNSFilter.HostsFilePath()
if storage.FileExists(hostsPath) {
r.DNS.SetFilterConfPath(hostsPath)
}
} else {
r.DNS.SetFilterConfPath("")
}
dnsConfig := r.DNS.Generate(globalCfg, dnsEntries)
if err := r.DNS.SafeApply(dnsConfig); err != nil {
slog.Error("failed to update dnsmasq", "component", "reconcile", "error", err)
r.health.DNS = SubsystemHealth{Status: "error", Message: err.Error()}
r.broadcastEvent("dnsmasq:error", err.Error())
} else {
r.health.DNS = SubsystemHealth{Status: "ok"}
r.broadcastDNSEvent("dnsmasq:config", "DNS config regenerated from registered domains")
}
if len(httpRoutes) > 0 {
missingCerts := r.ensureTLSCerts(globalCfg, doms)
r.health.MissingCerts = missingCerts
if len(missingCerts) > 0 {
r.health.TLS = SubsystemHealth{
Status: "degraded",
Message: fmt.Sprintf("%d certs missing: %v", len(missingCerts), missingCerts),
}
} else {
r.health.TLS = SubsystemHealth{Status: "ok"}
}
} else {
r.health.TLS = SubsystemHealth{Status: "ok"}
r.health.MissingCerts = nil
}
r.DDNS.Trigger()
r.health.UpdatedAt = time.Now()
// Detect recoveries
if previousHealth.HAProxy.Status == "error" && r.health.HAProxy.Status == "ok" {
r.broadcastEvent("haproxy:recovered", "HAProxy recovered")
}
if previousHealth.DNS.Status == "error" && r.health.DNS.Status == "ok" {
r.broadcastEvent("dnsmasq:recovered", "DNS recovered")
}
if previousHealth.TLS.Status != "ok" && r.health.TLS.Status == "ok" {
r.broadcastEvent("tls:recovered", "All TLS certificates provisioned")
}
slog.Info("networking updated", "component", "reconcile",
"domains", len(doms),
"l4Routes", len(l4Routes),
"l7Routes", len(httpRoutes),
"haproxy", r.health.HAProxy.Status,
"dns", r.health.DNS.Status,
"tls", r.health.TLS.Status,
)
}
// buildRoutes converts registered domains into HAProxy L4 and L7 route lists.
func buildRoutes(doms []domains.Domain) ([]haproxy.L4Route, []haproxy.HTTPRoute) {
var l4Routes []haproxy.L4Route
var httpRoutes []haproxy.HTTPRoute
for _, dom := range doms {
switch dom.EffectiveBackendType() {
case domains.BackendTCPPassthrough:
l4Routes = append(l4Routes, haproxy.L4Route{
Name: dom.DomainName,
Domain: dom.DomainName,
BackendIP: extractHost(dom.EffectiveBackendAddress()),
Subdomains: dom.Subdomains,
})
case domains.BackendDNSOnly:
// dns-only: no gateway routing, just DNS resolution
case domains.BackendHTTP:
var routeBackends []haproxy.HTTPRouteBackend
for _, route := range dom.EffectiveRoutes() {
rb := haproxy.HTTPRouteBackend{
Paths: route.Paths,
Backend: route.Backend.Address,
HealthPath: route.Backend.Health,
Headers: route.Headers,
IPAllow: route.IPAllow,
}
if dom.Auth != nil && dom.Auth.Enabled {
rb.AuthEnabled = true
rb.AuthPolicy = dom.Auth.Policy
}
routeBackends = append(routeBackends, rb)
}
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
Subdomains: dom.Subdomains,
Routes: routeBackends,
})
}
}
return l4Routes, httpRoutes
}
// buildDNSEntries converts domains to dnsmasq entries with appropriate IPs.
// HTTP domains point to centralIP (HAProxy terminates TLS).
// TCP passthrough and DNS-only domains point to their backend IP directly.
func buildDNSEntries(doms []domains.Domain, centralIP string) []dnsmasq.DNSEntry {
var entries []dnsmasq.DNSEntry
for _, dom := range doms {
if dom.DomainName == "" {
continue
}
dnsIP := centralIP
bt := dom.EffectiveBackendType()
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
dnsIP = extractHost(dom.EffectiveBackendAddress())
}
entries = append(entries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
Wildcard: dom.Subdomains,
})
}
return entries
}
// writeHAProxyConfig generates and applies the HAProxy config via SafeApply.
// On validation failure, identifies broken domains and retries without them.
func (r *Reconciler) writeHAProxyConfig(l4Routes []haproxy.L4Route, httpRoutes []haproxy.HTTPRoute, globalCfg *config.State) {
genOpts := haproxy.GenerateOpts{
HTTPRoutes: httpRoutes,
CertsDir: "/etc/haproxy/certs/",
}
if globalCfg.Cloud.Authelia.Enabled && globalCfg.Cloud.Authelia.Domain != "" {
genOpts.AuthEnabled = true
genOpts.AuthBackend = "127.0.0.1:9091"
genOpts.AuthDomain = globalCfg.Cloud.Authelia.Domain
genOpts.AuthSessionDomain = authelia.SessionDomainFromAuthDomain(globalCfg.Cloud.Authelia.Domain)
if r.GenerateAutheliaConfig != nil {
if err := r.GenerateAutheliaConfig(globalCfg); err != nil {
slog.Warn("failed to regenerate authelia config", "component", "reconcile", "error", err)
} else if r.Auth.UserCount() > 0 {
_ = r.Auth.RestartService()
}
}
}
cfg := r.HAProxy.GenerateWithOpts(l4Routes, nil, genOpts)
if err := r.HAProxy.SafeApply(cfg); err != nil {
// SafeApply handles rollback internally. Try to identify broken domains and retry.
brokenDomains := haproxy.FindBrokenServices(cfg, haproxy.ParseValidationErrors(err.Error()))
if len(brokenDomains) > 0 {
slog.Error("excluding broken domains and retrying",
"component", "reconcile", "broken", brokenDomains, "error", err)
exclude := map[string]bool{}
for _, d := range brokenDomains {
exclude[d] = true
}
var filteredL4 []haproxy.L4Route
for _, route := range l4Routes {
if !exclude[route.Domain] {
filteredL4 = append(filteredL4, route)
}
}
var filteredHTTP []haproxy.HTTPRoute
for _, route := range httpRoutes {
if !exclude[route.Domain] {
filteredHTTP = append(filteredHTTP, route)
}
}
genOpts.HTTPRoutes = filteredHTTP
cfg = r.HAProxy.GenerateWithOpts(filteredL4, nil, genOpts)
if err := r.HAProxy.SafeApply(cfg); err != nil {
slog.Error("retry also failed", "component", "reconcile", "error", err)
r.health.HAProxy = SubsystemHealth{Status: "error", Message: err.Error()}
r.broadcastEvent("haproxy:error", err.Error())
} else {
r.health.HAProxy = SubsystemHealth{Status: "degraded", Message: fmt.Sprintf("excluded domains: %v", brokenDomains)}
r.health.ExcludedDomains = brokenDomains
r.broadcastEvent("haproxy:config", "HAProxy config regenerated (excluded broken domains)")
}
} else {
slog.Error("failed to apply HAProxy config (no broken domains identified)", "component", "reconcile", "error", err)
r.health.HAProxy = SubsystemHealth{Status: "error", Message: err.Error()}
r.broadcastEvent("haproxy:error", err.Error())
}
} else {
r.health.HAProxy = SubsystemHealth{Status: "ok"}
r.health.ExcludedDomains = nil
r.broadcastEvent("haproxy:config", "HAProxy config regenerated from registered domains")
}
}
// cleanZeroByteCerts removes 0-byte cert files that would poison HAProxy validation.
func cleanZeroByteCerts(certsDir string) {
entries, err := os.ReadDir(certsDir)
if err != nil {
return
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".pem") {
if info, err := e.Info(); err == nil && info.Size() == 0 {
slog.Warn("removing 0-byte cert file", "component", "reconcile", "file", e.Name())
_ = os.Remove(filepath.Join(certsDir, e.Name()))
}
}
}
}
// broadcastEvent sends a simple SSE event.
func (r *Reconciler) broadcastEvent(eventType, message string) {
if r.SSE == nil {
return
}
r.SSE.Broadcast(&sse.Event{
ID: fmt.Sprintf("%s-%d", strings.SplitN(eventType, ":", 2)[0], time.Now().UnixNano()),
Type: eventType,
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{"message": message},
})
}
// broadcastDNSEvent sends a dnsmasq SSE event including current status.
func (r *Reconciler) broadcastDNSEvent(eventType, message string) {
if r.SSE == nil {
return
}
status, err := r.DNS.GetStatus()
if err != nil {
status = nil
}
r.SSE.Broadcast(&sse.Event{
ID: fmt.Sprintf("dnsmasq-%d", time.Now().UnixNano()),
Type: eventType,
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"message": message,
"status": status,
},
})
}
// ensureTLSCerts checks for missing certificates and auto-provisions them
// when Cloudflare credentials and operator email are available.
// Returns the list of domains still missing certs after provisioning attempts.
func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) []string {
gatewayDomain := ""
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
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{}
for _, dom := range doms {
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
continue
}
if hasCertForDomain(dom.DomainName) {
continue
}
// Determine what cert to provision: wildcard for gateway subdomains, individual otherwise
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
missing["*."+gatewayDomain] = true
} else {
missing[dom.DomainName] = true
}
}
if len(missing) == 0 {
return nil
}
// Can we auto-provision?
email := globalCfg.Operator.Email
cfToken := ""
if r.GetCloudflareToken != nil {
cfToken = r.GetCloudflareToken()
}
if cfToken == "" || email == "" || r.Certs == nil {
// Can't auto-provision — report what's missing
var names []string
for domain := range missing {
names = append(names, domain)
slog.Warn("missing cert (auto-provision unavailable — configure Cloudflare token and operator email)",
"component", "reconcile", "domain", domain)
}
return names
}
// Auto-provision missing 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 {
names = append(names, domain)
}
return names
}
var stillMissing []string
for domain := range missing {
provisionDomain := domain
if strings.HasPrefix(domain, "*.") {
provisionDomain = domain // certbot handles wildcard with -d *.example.com
}
slog.Info("auto-provisioning certificate", "component", "reconcile", "domain", provisionDomain)
if err := r.Certs.Provision(provisionDomain, email); err != nil {
slog.Error("cert auto-provision failed", "component", "reconcile", "domain", provisionDomain, "error", err)
stillMissing = append(stillMissing, domain)
} else {
slog.Info("certificate provisioned", "component", "reconcile", "domain", provisionDomain)
// Build HAProxy PEM for non-wildcard certs
baseDomain := strings.TrimPrefix(domain, "*.")
_ = r.Certs.BuildHAProxyCert(baseDomain)
}
}
return stillMissing
}
// 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 {
if isValidCertFile(certbot.HAProxyCertPath(domain)) {
return true
}
parts := strings.SplitN(domain, ".", 2)
if len(parts) == 2 {
if isValidCertFile(certbot.HAProxyCertPath(parts[1])) {
return true
}
}
return false
}
func isValidCertFile(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Size() > 0
}
func extractHost(addr string) string {
for i := len(addr) - 1; i >= 0; i-- {
if addr[i] == ':' {
return addr[:i]
}
}
return addr
}

View File

@@ -0,0 +1,290 @@
package reconcile
import (
"os"
"path/filepath"
"testing"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/dnsmasq"
"github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/haproxy"
"github.com/wild-cloud/wild-central/internal/sse"
)
// --- Stubs ---
type stubDomainManager struct {
domains []domains.Domain
err error
}
func (s *stubDomainManager) List() ([]domains.Domain, error) {
return s.domains, s.err
}
type stubHAProxy struct {
generateCalls int
lastL4Routes []haproxy.L4Route
writtenConfig string
writeErr error
reloadCalled bool
}
func (s *stubHAProxy) GenerateWithOpts(l4 []haproxy.L4Route, _ []haproxy.CustomRoute, _ haproxy.GenerateOpts) string {
s.generateCalls++
s.lastL4Routes = l4
return "generated-config"
}
func (s *stubHAProxy) SafeApply(c string) error { s.writtenConfig = c; return s.writeErr }
func (s *stubHAProxy) WriteConfig(c string) error { s.writtenConfig = c; return s.writeErr }
func (s *stubHAProxy) ReloadService() error { s.reloadCalled = true; return nil }
type stubDNS struct {
entries []dnsmasq.DNSEntry
filterPath string
applyCalled bool
}
func (s *stubDNS) Generate(_ *config.State, entries []dnsmasq.DNSEntry) string {
s.entries = entries
return "generated-dns-config"
}
func (s *stubDNS) SafeApply(_ string) error {
s.applyCalled = true
return nil
}
func (s *stubDNS) SetFilterConfPath(p string) { s.filterPath = p }
func (s *stubDNS) GetStatus() (*dnsmasq.Status, error) { return nil, nil }
type stubAuth struct {
userCount int
restartCalled bool
}
func (s *stubAuth) UserCount() int { return s.userCount }
func (s *stubAuth) RestartService() error { s.restartCalled = true; return nil }
type stubDDNS struct{ triggerCalled bool }
func (s *stubDDNS) Trigger() { s.triggerCalled = true }
type stubDNSFilter struct{ hostsPath string }
func (s *stubDNSFilter) HostsFilePath() string { return s.hostsPath }
type stubSSE struct{ events []*sse.Event }
func (s *stubSSE) Broadcast(e *sse.Event) { s.events = append(s.events, e) }
func newTestReconciler(t *testing.T, doms []domains.Domain) (*Reconciler, *stubHAProxy, *stubDNS, *stubDDNS) {
t.Helper()
tmpDir := t.TempDir()
statePath := filepath.Join(tmpDir, "state.yaml")
// Write minimal state
_ = config.SaveState(&config.State{}, statePath)
hp := &stubHAProxy{}
dns := &stubDNS{}
ddns := &stubDDNS{}
r := &Reconciler{
Domains: &stubDomainManager{domains: doms},
HAProxy: hp,
DNS: dns,
Auth: &stubAuth{},
DDNS: ddns,
DNSFilter: &stubDNSFilter{},
SSE: &stubSSE{},
StatePath: statePath,
}
return r, hp, dns, ddns
}
// --- Pure function tests ---
func TestBuildRoutes_MixedBackendTypes(t *testing.T) {
doms := []domains.Domain{
{DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}, Subdomains: true},
{DomainName: "api.example.com", Backend: domains.Backend{Address: "192.168.1.20:8080", Type: domains.BackendHTTP}},
{DomainName: "ssh.example.com", Backend: domains.Backend{Address: "192.168.1.30:22", Type: domains.BackendDNSOnly}},
}
l4, http := buildRoutes(doms)
if len(l4) != 1 {
t.Fatalf("expected 1 L4 route, got %d", len(l4))
}
if l4[0].Domain != "cloud.example.com" || l4[0].BackendIP != "192.168.1.10" || !l4[0].Subdomains {
t.Errorf("L4 route mismatch: %+v", l4[0])
}
if len(http) != 1 {
t.Fatalf("expected 1 HTTP route, got %d", len(http))
}
if http[0].Domain != "api.example.com" {
t.Errorf("HTTP route domain = %q, want api.example.com", http[0].Domain)
}
if len(http[0].Routes) != 1 || http[0].Routes[0].Backend != "192.168.1.20:8080" {
t.Errorf("HTTP route backend mismatch: %+v", http[0].Routes)
}
}
func TestBuildRoutes_EmptyDomains(t *testing.T) {
l4, http := buildRoutes(nil)
if l4 != nil || http != nil {
t.Error("expected nil routes for nil domains")
}
}
func TestBuildDNSEntries_IPAssignment(t *testing.T) {
centralIP := "10.0.0.1"
doms := []domains.Domain{
{DomainName: "central.example.com", Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP}},
{DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}},
{DomainName: "ssh.example.com", Backend: domains.Backend{Address: "192.168.1.30:22", Type: domains.BackendDNSOnly}},
}
entries := buildDNSEntries(doms, centralIP)
if len(entries) != 3 {
t.Fatalf("expected 3 entries, got %d", len(entries))
}
// HTTP → centralIP
if entries[0].IP != centralIP {
t.Errorf("HTTP domain IP = %q, want %q", entries[0].IP, centralIP)
}
// TCP passthrough → backend IP
if entries[1].IP != "192.168.1.10" {
t.Errorf("TCP domain IP = %q, want 192.168.1.10", entries[1].IP)
}
// DNS-only → backend IP
if entries[2].IP != "192.168.1.30" {
t.Errorf("DNS-only domain IP = %q, want 192.168.1.30", entries[2].IP)
}
}
func TestBuildDNSEntries_SkipsEmptyDomainName(t *testing.T) {
entries := buildDNSEntries([]domains.Domain{{DomainName: ""}}, "10.0.0.1")
if len(entries) != 0 {
t.Error("expected empty domain to be skipped")
}
}
func TestBuildDNSEntries_WildcardFlag(t *testing.T) {
doms := []domains.Domain{
{DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}, Subdomains: true},
{DomainName: "exact.example.com", Backend: domains.Backend{Address: "192.168.1.20:443", Type: domains.BackendTCPPassthrough}, Subdomains: false},
}
entries := buildDNSEntries(doms, "10.0.0.1")
if !entries[0].Wildcard {
t.Error("expected first entry to be wildcard")
}
if entries[1].Wildcard {
t.Error("expected second entry to not be wildcard")
}
}
// --- Integration tests with stubs ---
func TestReconcile_EmptyDomains(t *testing.T) {
r, hp, dns, ddns := newTestReconciler(t, nil)
r.Reconcile()
if !dns.applyCalled {
t.Error("expected dnsmasq update")
}
if len(dns.entries) != 0 {
t.Errorf("expected 0 DNS entries, got %d", len(dns.entries))
}
if !hp.reloadCalled {
// No routes but config is still generated and written
}
if !ddns.triggerCalled {
t.Error("expected DDNS trigger")
}
// With no routes, HAProxy should still generate (empty config is valid)
if hp.generateCalls != 1 {
t.Errorf("expected 1 HAProxy generate call, got %d", hp.generateCalls)
}
}
func TestReconcile_DDNSTriggered(t *testing.T) {
doms := []domains.Domain{
{DomainName: "test.example.com", Backend: domains.Backend{Address: "127.0.0.1:80", Type: domains.BackendHTTP}},
}
r, _, _, ddns := newTestReconciler(t, doms)
r.Reconcile()
if !ddns.triggerCalled {
t.Error("expected DDNS.Trigger() to be called")
}
}
func TestReconcile_DNSEntriesBuiltFromDomains(t *testing.T) {
doms := []domains.Domain{
{DomainName: "app.example.com", Backend: domains.Backend{Address: "127.0.0.1:8080", Type: domains.BackendHTTP}},
{DomainName: "k8s.example.com", Backend: domains.Backend{Address: "192.168.1.50:443", Type: domains.BackendTCPPassthrough}},
}
r, _, dns, _ := newTestReconciler(t, doms)
r.Reconcile()
if len(dns.entries) != 2 {
t.Fatalf("expected 2 DNS entries, got %d", len(dns.entries))
}
// HTTP domain should use centralIP (127.0.0.1 fallback since state has no IP)
if dns.entries[0].Domain != "app.example.com" {
t.Errorf("first DNS entry domain = %q", dns.entries[0].Domain)
}
// TCP passthrough should use backend IP directly
if dns.entries[1].IP != "192.168.1.50" {
t.Errorf("TCP DNS entry IP = %q, want 192.168.1.50", dns.entries[1].IP)
}
}
// --- Helper tests ---
func TestIsValidCertFile_Valid(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "test.pem")
if err := os.WriteFile(path, []byte("--- cert content ---"), 0600); err != nil {
t.Fatal(err)
}
if !isValidCertFile(path) {
t.Error("expected valid cert file to return true")
}
}
func TestIsValidCertFile_Empty(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "empty.pem")
if err := os.WriteFile(path, nil, 0600); err != nil {
t.Fatal(err)
}
if isValidCertFile(path) {
t.Error("expected 0-byte cert file to return false")
}
}
func TestIsValidCertFile_Missing(t *testing.T) {
if isValidCertFile("/nonexistent/path.pem") {
t.Error("expected missing file to return false")
}
}
func TestExtractHost(t *testing.T) {
tests := []struct {
input, want string
}{
{"192.168.1.1:8080", "192.168.1.1"},
{"example.com:443", "example.com"},
{"localhost", "localhost"},
{"127.0.0.1", "127.0.0.1"},
}
for _, tt := range tests {
if got := extractHost(tt.input); got != tt.want {
t.Errorf("extractHost(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}

View File

@@ -14,10 +14,10 @@ import (
// Event represents an SSE event
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
InstanceName string `json:"instanceName"`
Timestamp time.Time `json:"timestamp"`
ID string `json:"id"`
Type string `json:"type"`
InstanceName string `json:"instanceName"`
Timestamp time.Time `json:"timestamp"`
Data any `json:"data"`
Metadata map[string]any `json:"metadata,omitempty"`
}

View File

@@ -101,6 +101,50 @@ func WithLock(lockPath string, fn func() error) error {
return fn()
}
// WriteFileAtomic writes content to a file atomically via temp + rename.
// If the process crashes mid-write, the original file is untouched.
func WriteFileAtomic(path string, content []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := EnsureDir(dir, 0755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp.*")
if err != nil {
return fmt.Errorf("creating temp file for %s: %w", path, err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(content); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("writing temp file %s: %w", tmpPath, err)
}
if err := tmp.Chmod(perm); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("setting permissions on %s: %w", tmpPath, err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("closing temp file %s: %w", tmpPath, err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("installing %s → %s: %w", tmpPath, path, err)
}
return nil
}
// CopyFile copies a file atomically. Used for creating .bak backups.
func CopyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("reading %s: %w", src, err)
}
return WriteFileAtomic(dst, data, 0644)
}
// EnsureFilePermissions ensures a file has the correct permissions
func EnsureFilePermissions(path string, perm os.FileMode) error {
if err := os.Chmod(path, perm); err != nil {
@@ -108,4 +152,3 @@ func EnsureFilePermissions(path string, perm os.FileMode) error {
}
return nil
}

View File

@@ -19,14 +19,16 @@ import (
"strings"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-central/internal/storage"
)
// Config holds tunnel configuration.
type Config struct {
Enabled bool `yaml:"enabled" json:"enabled"`
TunnelID string `yaml:"tunnelId" json:"tunnelId"`
PublicDomain string `yaml:"publicDomain" json:"publicDomain"` // e.g. "pub.payne.io"
GatewayDomain string `yaml:"gatewayDomain" json:"gatewayDomain"` // e.g. "payne.io" (internal)
Enabled bool `yaml:"enabled" json:"enabled"`
TunnelID string `yaml:"tunnelId" json:"tunnelId"`
PublicDomain string `yaml:"publicDomain" json:"publicDomain"` // e.g. "pub.payne.io"
GatewayDomain string `yaml:"gatewayDomain" json:"gatewayDomain"` // e.g. "payne.io" (internal)
CredentialsDir string `yaml:"credentialsDir" json:"credentialsDir"`
}
@@ -130,8 +132,32 @@ func (m *Manager) GenerateConfig(cfg Config, services []PublicDomain) string {
return string(data)
}
// WriteConfig generates and writes the cloudflared config to disk.
// ValidateConfig checks tunnel config for common errors before generating.
func ValidateConfig(cfg Config) error {
if !cfg.Enabled {
return nil // disabled is valid
}
if cfg.TunnelID == "" {
return fmt.Errorf("tunnel ID is required")
}
if cfg.PublicDomain == "" {
return fmt.Errorf("public domain is required")
}
if cfg.GatewayDomain == "" {
return fmt.Errorf("gateway domain is required")
}
credsFile := credentialsPath(cfg.CredentialsDir, cfg.TunnelID)
if _, err := os.Stat(credsFile); err != nil {
return fmt.Errorf("credentials file not found: %s", credsFile)
}
return nil
}
// WriteConfig validates, generates, and writes the cloudflared config to disk.
func (m *Manager) WriteConfig(cfg Config, services []PublicDomain) error {
if err := ValidateConfig(cfg); err != nil {
return fmt.Errorf("config validation: %w", err)
}
content := m.GenerateConfig(cfg, services)
if content == "" {
// Remove config if tunnel is disabled or no public services
@@ -143,12 +169,7 @@ func (m *Manager) WriteConfig(cfg Config, services []PublicDomain) error {
return nil
}
dir := filepath.Dir(m.configPath())
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("creating tunnel config dir: %w", err)
}
if err := os.WriteFile(m.configPath(), []byte(content), 0644); err != nil {
if err := storage.WriteFileAtomic(m.configPath(), []byte(content), 0644); err != nil {
return fmt.Errorf("writing tunnel config: %w", err)
}

View File

@@ -7,13 +7,17 @@ import (
"testing"
)
func testConfig() Config {
func testConfig(t *testing.T) Config {
t.Helper()
credsDir := filepath.Join(t.TempDir(), "creds")
os.MkdirAll(credsDir, 0755)
os.WriteFile(filepath.Join(credsDir, "abc-123.json"), []byte(`{"AccountTag":"test"}`), 0600)
return Config{
Enabled: true,
TunnelID: "abc-123",
PublicDomain: "pub.payne.io",
GatewayDomain: "payne.io",
CredentialsDir: "/tmp/creds",
Enabled: true,
TunnelID: "abc-123",
PublicDomain: "pub.payne.io",
GatewayDomain: "payne.io",
CredentialsDir: credsDir,
}
}
@@ -25,7 +29,8 @@ func TestGenerateConfig_Basic(t *testing.T) {
{Name: "dashboard"},
}
out := m.GenerateConfig(testConfig(), services)
cfg := testConfig(t)
out := m.GenerateConfig(cfg, services)
if out == "" {
t.Fatal("expected non-empty config")
@@ -37,7 +42,7 @@ func TestGenerateConfig_Basic(t *testing.T) {
}
// Check credentials file
if !strings.Contains(out, "credentials-file: /tmp/creds/abc-123.json") {
if !strings.Contains(out, "abc-123.json") {
t.Errorf("expected credentials file, got:\n%s", out)
}
@@ -75,7 +80,7 @@ func TestGenerateConfig_SubdomainOverride(t *testing.T) {
{Name: "internal-name", Subdomain: "public-name"},
}
out := m.GenerateConfig(testConfig(), services)
out := m.GenerateConfig(testConfig(t), services)
if !strings.Contains(out, "hostname: public-name.pub.payne.io") {
t.Errorf("expected subdomain override, got:\n%s", out)
@@ -85,7 +90,7 @@ func TestGenerateConfig_SubdomainOverride(t *testing.T) {
func TestGenerateConfig_Disabled(t *testing.T) {
m := NewManager(t.TempDir())
cfg := testConfig()
cfg := testConfig(t)
cfg.Enabled = false
out := m.GenerateConfig(cfg, []PublicDomain{{Name: "my-api"}})
@@ -96,7 +101,7 @@ func TestGenerateConfig_Disabled(t *testing.T) {
func TestGenerateConfig_NoServices(t *testing.T) {
m := NewManager(t.TempDir())
out := m.GenerateConfig(testConfig(), nil)
out := m.GenerateConfig(testConfig(t), nil)
if out != "" {
t.Errorf("expected empty config with no services, got:\n%s", out)
}
@@ -104,7 +109,7 @@ func TestGenerateConfig_NoServices(t *testing.T) {
func TestGenerateConfig_MissingTunnelID(t *testing.T) {
m := NewManager(t.TempDir())
cfg := testConfig()
cfg := testConfig(t)
cfg.TunnelID = ""
out := m.GenerateConfig(cfg, []PublicDomain{{Name: "my-api"}})
@@ -118,7 +123,7 @@ func TestWriteConfig(t *testing.T) {
m := NewManager(tmpDir)
services := []PublicDomain{{Name: "my-api"}}
if err := m.WriteConfig(testConfig(), services); err != nil {
if err := m.WriteConfig(testConfig(t), services); err != nil {
t.Fatalf("WriteConfig failed: %v", err)
}
@@ -139,12 +144,12 @@ func TestWriteConfig_RemovesWhenDisabled(t *testing.T) {
m := NewManager(tmpDir)
// Write config first
if err := m.WriteConfig(testConfig(), []PublicDomain{{Name: "my-api"}}); err != nil {
if err := m.WriteConfig(testConfig(t), []PublicDomain{{Name: "my-api"}}); err != nil {
t.Fatalf("WriteConfig failed: %v", err)
}
// Now disable and write again — should remove the file
cfg := testConfig()
cfg := testConfig(t)
cfg.Enabled = false
if err := m.WriteConfig(cfg, []PublicDomain{{Name: "my-api"}}); err != nil {
t.Fatalf("WriteConfig (disable) failed: %v", err)

View File

@@ -11,6 +11,8 @@ import (
"github.com/google/uuid"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-central/internal/storage"
)
// Config holds the WireGuard server interface configuration.
@@ -41,12 +43,12 @@ type secrets struct {
// Status represents the current runtime state of the WireGuard interface.
type Status struct {
Running bool `json:"running"`
Interface string `json:"interface"`
PublicKey string `json:"publicKey"`
ListenPort int `json:"listenPort"`
PeerCount int `json:"peerCount"`
RawOutput string `json:"rawOutput"`
Running bool `json:"running"`
Interface string `json:"interface"`
PublicKey string `json:"publicKey"`
ListenPort int `json:"listenPort"`
PeerCount int `json:"peerCount"`
RawOutput string `json:"rawOutput"`
}
// Manager handles WireGuard configuration and service management.
@@ -101,8 +103,29 @@ func (m *Manager) GetConfig() (*Config, error) {
return &cfg, nil
}
// SaveConfig writes the server interface config.
// ValidateConfig checks a WireGuard config for common errors before saving.
func ValidateConfig(cfg *Config) error {
if cfg.ListenPort < 1 || cfg.ListenPort > 65535 {
return fmt.Errorf("listenPort must be 1-65535, got %d", cfg.ListenPort)
}
if cfg.Address != "" {
if _, _, err := net.ParseCIDR(cfg.Address); err != nil {
return fmt.Errorf("invalid server address CIDR %q: %w", cfg.Address, err)
}
}
if cfg.LanCIDR != "" {
if _, _, err := net.ParseCIDR(cfg.LanCIDR); err != nil {
return fmt.Errorf("invalid LAN CIDR %q: %w", cfg.LanCIDR, err)
}
}
return nil
}
// SaveConfig validates and writes the server interface config.
func (m *Manager) SaveConfig(cfg *Config) error {
if err := ValidateConfig(cfg); err != nil {
return err
}
if err := os.MkdirAll(m.vpnDir(), 0755); err != nil {
return fmt.Errorf("create vpn dir: %w", err)
}
@@ -110,7 +133,7 @@ func (m *Manager) SaveConfig(cfg *Config) error {
if err != nil {
return fmt.Errorf("marshal vpn config: %w", err)
}
return os.WriteFile(m.configFilePath(), data, 0644)
return storage.WriteFileAtomic(m.configFilePath(), data, 0644)
}
// --- Keys ---
@@ -133,7 +156,7 @@ func (m *Manager) GenerateKeypair() error {
if err != nil {
return err
}
return os.WriteFile(m.secretsFilePath(), data, 0600)
return storage.WriteFileAtomic(m.secretsFilePath(), data, 0600)
}
// GetPublicKey returns the server public key, or empty string if not yet generated.
@@ -206,6 +229,13 @@ func (m *Manager) readPeerFile(path string) (*Peer, error) {
// AddPeer generates a new peer keypair, assigns the next available IP in the VPN
// subnet, saves the peer, and returns it.
func (m *Manager) AddPeer(name string) (*Peer, error) {
// Validate peer name — prevents config injection via wg0.conf comments
for _, c := range name {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == ' ') {
return nil, fmt.Errorf("invalid peer name: only letters, digits, hyphens, underscores, and spaces allowed")
}
}
cfg, err := m.GetConfig()
if err != nil {
return nil, err
@@ -261,7 +291,7 @@ func (m *Manager) savePeer(p *Peer) error {
if err != nil {
return err
}
return os.WriteFile(filepath.Join(m.peersDir(), p.ID+".yaml"), data, 0600)
return storage.WriteFileAtomic(filepath.Join(m.peersDir(), p.ID+".yaml"), data, 0600)
}
// nextAvailableIP finds the next unused host IP in the given CIDR (skipping the network
@@ -409,7 +439,7 @@ func (m *Manager) Apply() error {
if err := os.MkdirAll(filepath.Dir(m.configPath), 0755); err != nil {
return fmt.Errorf("create wireguard config dir: %w", err)
}
if err := os.WriteFile(m.configPath, []byte(content), 0600); err != nil {
if err := storage.WriteFileAtomic(m.configPath, []byte(content), 0600); err != nil {
return fmt.Errorf("write wireguard config: %w", err)
}
upCmd := exec.Command("sudo", "wg-quick", "up", "wg0")

36
main.go
View File

@@ -19,6 +19,7 @@ import (
"github.com/wild-cloud/wild-central/internal/frontend"
"github.com/wild-cloud/wild-central/internal/logging"
"github.com/wild-cloud/wild-central/internal/natsbus"
"github.com/wild-cloud/wild-central/internal/secrets"
)
var startTime time.Time
@@ -99,15 +100,38 @@ func main() {
fmt.Sscanf(v, "%d", &natsPort)
}
// Load or generate NATS auth token from secrets
secretsMgr := secrets.NewManager(filepath.Join(dataDir, "secrets.yaml"))
natsToken, _ := secretsMgr.GetSecret("nats.authToken")
if natsToken == "" {
natsToken, _ = secrets.GenerateSecret(32)
_ = secretsMgr.SetSecret("nats.authToken", natsToken)
slog.Info("generated NATS auth token", "component", "startup")
}
natsSrv, err := natsbus.Start(natsbus.Config{
Port: natsPort,
DataDir: natsDataDir,
Port: natsPort,
DataDir: natsDataDir,
AuthToken: natsToken,
})
if err != nil {
slog.Error("failed to start NATS server", "error", err)
os.Exit(1)
}
// Load or generate API bearer token
apiToken, _ := secretsMgr.GetSecret("api.bearerToken")
if apiToken == "" {
apiToken, _ = secrets.GenerateSecret(32)
_ = secretsMgr.SetSecret("api.bearerToken", apiToken)
slog.Info("generated API bearer token", "component", "startup")
}
isDev := os.Getenv("WILD_CENTRAL_ENV") == "development"
if isDev {
slog.Info("development mode: API authentication disabled", "component", "startup")
}
allowedOrigins := buildAllowedOrigins(dataDir)
api, err := v1.NewAPI(dataDir, Version, allowedOrigins)
@@ -123,9 +147,10 @@ func main() {
slog.Info("central status broadcaster started")
api.StartDDNS(ctx)
api.StartDNSFilter(ctx)
router := mux.NewRouter()
api.RegisterRoutes(router)
api.RegisterRoutes(router, apiToken, isDev)
router.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -159,9 +184,14 @@ func main() {
// Tell the API what port it's running on, register Central as a domain
// (if a domain is configured), and reconcile all networking.
api.SetPort(port)
api.CheckPrerequisites()
api.EnsureCentralDomain()
api.Reconcile()
// Start periodic convergence loop — continuously drives system toward
// desired state, recovering from daemon crashes and config drift.
api.StartConvergenceLoop(ctx, 5*time.Minute)
addr := fmt.Sprintf("%s:%d", host, port)
slog.Info("wild-central started", "addr", addr, "version", Version)

View File

@@ -1,6 +1,6 @@
# Building Wild App
# Building Wild Central Web App
This document describes the architecture and tooling used to build the Wild App, the web-based interface for managing Wild Cloud instances, hosted on Wild Central.
This document describes the architecture and tooling used to build the Wild Central web app, the browser-based interface for managing Wild Central and its connected services.
## Principles

View File

@@ -1,13 +1,13 @@
# Wild Cloud Web App
# Wild Central Web App
The Wild Cloud Web App is a web-based interface for managing Wild Cloud instances. It allows users to view and control their Wild Cloud environments, including deploying applications, monitoring resources, and configuring settings.
The Wild Central Web App is a web-based interface for managing Wild Central and its connected services. It allows users to configure networking (DNS, gateway, VPN, firewall, TLS), manage Wild Cloud instances, deploy applications, and monitor resources.
## Prerequisites
Before starting the web app, ensure the Wild Central API is running:
```bash
cd ../api
cd ..
make dev
```
@@ -57,11 +57,9 @@ pnpm run check # Run lint, type-check, and tests
### `VITE_API_BASE_URL`
The base URL of the Wild Central API server.
The base URL of the Wild Central API.
- **Default:** `http://localhost:5055`
- **Example:** `http://192.168.1.100:5055`
- **Usage:** Set in `.env` file (see `.env.example` for template)
This variable is used by the API client to connect to the Wild Central API. If not set, it defaults to `http://localhost:5055`.

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0ea5e9" />
<meta name="theme-color" content="#2563eb" />
<meta
name="description"
content="Wild Central — Network management for your LAN"

View File

@@ -1,7 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0ea5e9"/>
<g transform="translate(4, 4)" stroke="white" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 16.326A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 .5 8.973"/>
<path d="M13 11l-3 5h4l-3 5"/>
</g>
</svg>
<rect width="32" height="32" rx="7" fill="#2563eb"/>
<!-- Central node -->
<circle cx="16" cy="16" r="4" fill="white"/>
<!-- Radiating connections -->
<line x1="16" y1="6" x2="16" y2="11" stroke="white" stroke-width="2" stroke-linecap="round"/>
<line x1="16" y1="21" x2="16" y2="26" stroke="white" stroke-width="2" stroke-linecap="round"/>
<line x1="6" y1="16" x2="11" y2="16" stroke="white" stroke-width="2" stroke-linecap="round"/>
<line x1="21" y1="16" x2="26" y2="16" stroke="white" stroke-width="2" stroke-linecap="round"/>
<line x1="8.9" y1="8.9" x2="12.5" y2="12.5" stroke="white" stroke-width="2" stroke-linecap="round"/>
<line x1="19.5" y1="19.5" x2="23.1" y2="23.1" stroke="white" stroke-width="2" stroke-linecap="round"/>
<line x1="23.1" y1="8.9" x2="19.5" y2="12.5" stroke="white" stroke-width="2" stroke-linecap="round"/>
<line x1="12.5" y1="19.5" x2="8.9" y2="23.1" stroke="white" stroke-width="2" stroke-linecap="round"/>
<!-- Outer ring nodes -->
<circle cx="16" cy="5.5" r="1.5" fill="white" opacity="0.7"/>
<circle cx="16" cy="26.5" r="1.5" fill="white" opacity="0.7"/>
<circle cx="5.5" cy="16" r="1.5" fill="white" opacity="0.7"/>
<circle cx="26.5" cy="16" r="1.5" fill="white" opacity="0.7"/>
<circle cx="8.6" cy="8.6" r="1.5" fill="white" opacity="0.5"/>
<circle cx="23.4" cy="23.4" r="1.5" fill="white" opacity="0.5"/>
<circle cx="23.4" cy="8.6" r="1.5" fill="white" opacity="0.5"/>
<circle cx="8.6" cy="23.4" r="1.5" fill="white" opacity="0.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 361 B

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,6 +1,6 @@
{
"short_name": "Wild Cloud",
"name": "Wild Cloud Central",
"short_name": "Wild Central",
"name": "Wild Central",
"icons": [
{
"src": "favicon.svg",
@@ -10,6 +10,6 @@
],
"start_url": ".",
"display": "standalone",
"theme_color": "#0ea5e9",
"theme_color": "#2563eb",
"background_color": "#ffffff"
}

View File

@@ -1,5 +1,7 @@
import { useState, useEffect } from 'react';
import { NavLink } from 'react-router';
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, CloudLightning, Network } from 'lucide-react';
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, ShieldCheck, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import {
Sidebar,
SidebarContent,
@@ -17,10 +19,37 @@ import {
import { useTheme } from '../contexts/ThemeContext';
import { useCentralStatus } from '../hooks/useCentralStatus';
function WildCentralLogo({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className={className} fill="none">
<circle cx="12" cy="12" r="3" fill="currentColor"/>
<line x1="12" y1="4" x2="12" y2="8.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="12" y1="15.5" x2="12" y2="20" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="4" y1="12" x2="8.5" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="15.5" y1="12" x2="20" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="6.3" y1="6.3" x2="9.4" y2="9.4" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="14.6" y1="14.6" x2="17.7" y2="17.7" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="17.7" y1="6.3" x2="14.6" y2="9.4" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="9.4" y1="14.6" x2="6.3" y2="17.7" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<circle cx="12" cy="3.5" r="1.2" fill="currentColor" opacity="0.6"/>
<circle cx="12" cy="20.5" r="1.2" fill="currentColor" opacity="0.6"/>
<circle cx="3.5" cy="12" r="1.2" fill="currentColor" opacity="0.6"/>
<circle cx="20.5" cy="12" r="1.2" fill="currentColor" opacity="0.6"/>
</svg>
);
}
const ADVANCED_OPEN_KEY = 'wild-central:advanced-open';
export function AppSidebar() {
const { theme, setTheme } = useTheme();
const { state } = useSidebar();
const { data: centralStatus } = useCentralStatus();
const [advancedOpen, setAdvancedOpen] = useState(() => localStorage.getItem(ADVANCED_OPEN_KEY) === 'true');
useEffect(() => {
localStorage.setItem(ADVANCED_OPEN_KEY, String(advancedOpen));
}, [advancedOpen]);
const cycleTheme = () => {
if (theme === 'light') {
@@ -55,23 +84,28 @@ export function AppSidebar() {
};
const domainsItems = [
{ to: '/central', icon: LayoutDashboard, label: 'Dashboard', end: true },
{ to: '/central/domains', icon: Globe, label: 'Domains' },
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
{ to: '/domains', icon: Globe, label: 'Domains' },
];
const centralItems = [
{ to: '/central/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const },
{ to: '/central/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const },
{ to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
{ to: '/central/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const },
{ to: '/auth', icon: KeyRound, label: 'Authentication', daemon: 'authelia' as const },
{ to: '/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const },
{ to: '/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const },
{ 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 },
];
const advancedItems = [
{ to: '/central/advanced/haproxy', icon: Network, label: 'HAProxy', daemon: 'haproxy' as const },
{ to: '/central/advanced/dnsmasq', icon: Wifi, label: 'dnsmasq', daemon: 'dnsmasq' as const },
{ to: '/central/advanced/nftables', icon: Shield, label: 'nftables', daemon: 'nftables' as const },
{ to: '/central/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const },
{ to: '/central/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
{ to: '/advanced/haproxy', icon: Network, label: 'HAProxy', daemon: 'haproxy' as const },
{ to: '/advanced/dnsmasq', icon: Wifi, label: 'dnsmasq', daemon: 'dnsmasq' as const },
{ to: '/advanced/nftables', icon: Shield, label: 'nftables', daemon: 'nftables' as const },
{ to: '/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const },
{ to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
{ to: '/advanced/authelia', icon: KeyRound, label: 'Authelia', daemon: 'authelia' as const },
{ to: '/advanced/ddns', icon: Globe, label: 'DDNS', daemon: undefined },
{ to: '/advanced/certificates', icon: ShieldCheck, label: 'Certificates', daemon: 'certbot' as const },
];
return (
@@ -79,7 +113,7 @@ export function AppSidebar() {
<SidebarHeader>
<div className="flex items-center justify-center pb-2">
<div className="p-1 bg-primary/10 rounded-lg">
<CloudLightning className="h-6 w-6 text-primary" />
<WildCentralLogo className="h-6 w-6" />
</div>
<div className="overflow-hidden transition-all duration-200 ease-linear ml-2 max-w-[200px] opacity-100 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:max-w-0 group-data-[collapsible=icon]:opacity-0">
<h2 className="text-lg font-bold text-foreground whitespace-nowrap">Wild Central</h2>
@@ -158,31 +192,40 @@ export function AppSidebar() {
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Advanced</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{advancedItems.map(({ to, icon: Icon, label, daemon }) => {
const active = centralStatus?.daemons?.[daemon]?.active;
return (
<SidebarMenuItem key={to}>
<NavLink to={to}>
{({ isActive }) => (
<SidebarMenuButton isActive={isActive}>
<Icon className="h-4 w-4" />
<span className="truncate">{label}</span>
{active !== undefined && (
<span className={`ml-auto h-1.5 w-1.5 rounded-full ${active ? 'bg-green-500' : 'bg-red-500'}`} />
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<SidebarGroup>
<CollapsibleTrigger asChild>
<SidebarGroupLabel className="cursor-pointer select-none">
Advanced
<ChevronRight className={`ml-auto h-3.5 w-3.5 transition-transform duration-200 ${advancedOpen ? 'rotate-90' : ''}`} />
</SidebarGroupLabel>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{advancedItems.map(({ to, icon: Icon, label, daemon }) => {
const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
return (
<SidebarMenuItem key={to}>
<NavLink to={to}>
{({ isActive }) => (
<SidebarMenuButton isActive={isActive}>
<Icon className="h-4 w-4" />
<span className="truncate">{label}</span>
{active !== undefined && (
<span className={`ml-auto h-1.5 w-1.5 rounded-full ${active ? 'bg-green-500' : 'bg-red-500'}`} />
)}
</SidebarMenuButton>
)}
</SidebarMenuButton>
)}
</NavLink>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</NavLink>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
</>
)}
</SidebarContent>

View File

@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react';
import { Card, CardContent } from './ui/card';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Alert, AlertDescription } from './ui/alert';
import { ShieldCheck, Loader2, AlertCircle, LogIn } from 'lucide-react';
import { apiClient } from '../services/api/client';
interface AuthGateProps {
children: React.ReactNode;
}
export function AuthGate({ children }: AuthGateProps) {
const [state, setState] = useState<'checking' | 'authenticated' | 'login'>('checking');
const [token, setToken] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
// On mount, check if saved token is still valid
useEffect(() => {
checkAuth();
}, []);
async function checkAuth() {
// If no token saved, might be dev mode (no auth required)
try {
const resp = await fetch(`${apiClient.getBaseURL()}/api/v1/health`);
if (!resp.ok) {
setState('login');
return;
}
// Health is public, but try a protected endpoint to see if auth is needed
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (apiClient.hasToken()) {
headers['Authorization'] = `Bearer ${localStorage.getItem('wild-central:token')}`;
}
const statusResp = await fetch(`${apiClient.getBaseURL()}/api/v1/operator`, { headers });
if (statusResp.ok) {
setState('authenticated');
} else if (statusResp.status === 401) {
// Auth required but token is missing or invalid
if (apiClient.hasToken()) {
apiClient.clearToken();
}
setState('login');
} else {
// Other error — maybe server is starting up, treat as authenticated
setState('authenticated');
}
} catch {
// Can't reach server — show app anyway, it'll show connection errors
setState('authenticated');
}
}
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
setError('');
setSubmitting(true);
try {
// Test the token against a protected endpoint
const resp = await fetch(`${apiClient.getBaseURL()}/api/v1/operator`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
if (resp.ok) {
apiClient.setToken(token);
setState('authenticated');
} else if (resp.status === 401) {
setError('Invalid token');
} else {
setError(`Server error: ${resp.status}`);
}
} catch {
setError('Cannot reach Wild Central');
} finally {
setSubmitting(false);
}
}
if (state === 'checking') {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (state === 'authenticated') {
return <>{children}</>;
}
// Login screen
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<Card className="w-full max-w-sm mx-4">
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-primary/10 rounded-lg">
<ShieldCheck className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold">Wild Central</h1>
<p className="text-sm text-muted-foreground">Enter your API token to continue</p>
</div>
</div>
{error && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<form onSubmit={handleLogin} className="space-y-3">
<div>
<Label htmlFor="token">API Token</Label>
<Input
id="token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Paste your bearer token"
className="mt-1 font-mono"
autoFocus
/>
</div>
<Button type="submit" className="w-full gap-2" disabled={submitting || !token}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogIn className="h-4 w-4" />}
Sign In
</Button>
</form>
<p className="text-xs text-muted-foreground text-center">
Find your token in <code className="bg-muted px-1 rounded">secrets.yaml</code> under <code className="bg-muted px-1 rounded">api.bearerToken</code>
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,671 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { KeyRound, BookOpen, CheckCircle, AlertCircle, Plus, Trash2, Loader2, RotateCw, ExternalLink, Copy, X, UserPlus, Globe, Pencil } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Badge } from './ui/badge';
import { Alert, AlertDescription } from './ui/alert';
import { Switch } from './ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { useAuthelia } from '../hooks/useAuthelia';
import { domainsApi } from '../services/api';
import type { AutheliaStatus } from '../services/api/authelia';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function AutheliaComponent() {
const {
status, isLoadingStatus,
users, isLoadingUsers,
oidcClients, isLoadingClients,
updateConfig, restart,
createUser, deleteUser,
createOIDCClient, updateOIDCClient, deleteOIDCClient,
} = useAuthelia();
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [showAddUser, setShowAddUser] = useState(false);
const [showAddClient, setShowAddClient] = useState(false);
const [newClientSecret, setNewClientSecret] = useState<string | null>(null);
const [editingClientId, setEditingClientId] = useState<string | null>(null);
const showSuccess = (msg: string) => {
setSuccessMsg(msg);
setErrorMsg(null);
setTimeout(() => setSuccessMsg(null), 5000);
};
const showError = (msg: string) => {
setErrorMsg(msg);
setSuccessMsg(null);
};
if (isLoadingStatus) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<KeyRound className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Authentication</h2>
<p className="text-muted-foreground">Centralized authentication for network services via Authelia</p>
</div>
</div>
{/* Alerts */}
{successMsg && (
<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">{successMsg}</AlertDescription>
<Button variant="ghost" size="sm" className="absolute right-2 top-2 h-6 w-6 p-0" onClick={() => setSuccessMsg(null)}>
<X className="h-3 w-3" />
</Button>
</Alert>
)}
{errorMsg && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{errorMsg}</AlertDescription>
<Button variant="ghost" size="sm" className="absolute right-2 top-2 h-6 w-6 p-0" onClick={() => setErrorMsg(null)}>
<X className="h-3 w-3" />
</Button>
</Alert>
)}
{/* Educational Card */}
<Card className="bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-900/20 dark:to-blue-900/20 border-cyan-200 dark:border-cyan-800">
<CardContent className="p-4 flex items-start gap-3">
<BookOpen className="h-5 w-5 text-cyan-600 dark:text-cyan-400 mt-0.5 shrink-0" />
<div className="text-sm text-cyan-800 dark:text-cyan-200">
<p className="font-medium mb-1">Centralized Authentication for Your Network</p>
<p>Authelia provides two ways to protect services: <strong>Forward-auth</strong> intercepts requests at HAProxy for apps without native login. <strong>OIDC</strong> lets apps like Gitea and Grafana use Authelia as their identity provider. Both share one user database and login portal.</p>
</div>
</CardContent>
</Card>
{/* Configuration */}
<ConfigCard
status={status}
onUpdate={updateConfig}
showSuccess={showSuccess}
showError={showError}
/>
{/* Service Status (only if enabled) */}
{status?.enabled && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Service Status</CardTitle>
<div className="flex items-center gap-2">
<Badge variant={status.active ? 'success' : 'destructive'} className="gap-1">
{status.active ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
{status.active ? 'Running' : 'Stopped'}
</Badge>
<Button variant="outline" size="sm" className="gap-1" onClick={() => restart.mutate(undefined, {
onSuccess: () => showSuccess('Authelia restarted'),
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to restart'),
})} disabled={restart.isPending}>
{restart.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
Restart
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
{status.version && <div><span className="text-muted-foreground">Version</span><div className="font-mono mt-0.5">{status.version}</div></div>}
<div><span className="text-muted-foreground">Users</span><div className="font-mono mt-0.5">{status.userCount}</div></div>
<div><span className="text-muted-foreground">OIDC Clients</span><div className="font-mono mt-0.5">{status.clientCount}</div></div>
{status.domain && (
<div>
<span className="text-muted-foreground">Login Portal</span>
<div className="font-mono mt-0.5">
<a href={`https://${status.domain}`} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline inline-flex items-center gap-1">
{status.domain}<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* User Management */}
{status?.enabled && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Users</CardTitle>
<Button size="sm" className="gap-1" onClick={() => setShowAddUser(!showAddUser)}>
{showAddUser ? <X className="h-3 w-3" /> : <UserPlus className="h-3 w-3" />}
{showAddUser ? 'Cancel' : 'Add User'}
</Button>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-3">
{showAddUser && (
<AddUserForm
onSubmit={(data) => {
createUser.mutate(data, {
onSuccess: () => { showSuccess(`User "${data.username}" created`); setShowAddUser(false); },
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to create user'),
});
}}
isSubmitting={createUser.isPending}
/>
)}
{isLoadingUsers ? (
<div className="text-center py-4"><Loader2 className="h-5 w-5 animate-spin mx-auto text-muted-foreground" /></div>
) : users.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No users yet. Add a user to get started.</p>
) : (
<div className="space-y-2">
{users.map((user) => (
<div key={user.username} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
<div className="flex items-center gap-3">
<div>
<span className="font-medium text-sm">{user.displayname || user.username}</span>
<span className="text-muted-foreground text-sm ml-2">({user.username})</span>
{user.email && <span className="text-muted-foreground text-xs ml-2">{user.email}</span>}
</div>
{user.disabled && <Badge variant="secondary">Disabled</Badge>}
</div>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive h-7 w-7 p-0"
onClick={() => {
if (confirm(`Delete user "${user.username}"?`)) {
deleteUser.mutate(user.username, {
onSuccess: () => showSuccess(`User "${user.username}" deleted`),
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to delete user'),
});
}
}}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* OIDC Clients */}
{status?.enabled && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">OIDC Clients</CardTitle>
<Button size="sm" className="gap-1" onClick={() => { setShowAddClient(!showAddClient); setNewClientSecret(null); }}>
{showAddClient ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
{showAddClient ? 'Cancel' : 'Add Client'}
</Button>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-3">
{newClientSecret && (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
<AlertDescription className="text-amber-800 dark:text-amber-200">
<p className="font-medium mb-1">Client secret save this now, it won't be shown again:</p>
<div className="flex items-center gap-2">
<code className="bg-muted px-2 py-1 rounded text-xs font-mono break-all">{newClientSecret}</code>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" onClick={() => navigator.clipboard.writeText(newClientSecret)}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</AlertDescription>
</Alert>
)}
{showAddClient && (
<AddOIDCClientForm
onSubmit={(data) => {
createOIDCClient.mutate(data, {
onSuccess: (result) => {
setNewClientSecret(result.clientSecret);
setShowAddClient(false);
showSuccess(`OIDC client "${data.clientId}" created`);
},
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to create client'),
});
}}
isSubmitting={createOIDCClient.isPending}
/>
)}
{isLoadingClients ? (
<div className="text-center py-4"><Loader2 className="h-5 w-5 animate-spin mx-auto text-muted-foreground" /></div>
) : oidcClients.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No OIDC clients. Apps with native SSO support (Gitea, Grafana, etc.) need a client registration here.</p>
) : (
<div className="space-y-2">
{oidcClients.map((client) => (
editingClientId === client.clientId ? (
<EditOIDCClientForm
key={client.clientId}
client={client}
onSubmit={(data) => {
updateOIDCClient.mutate({ clientId: client.clientId, ...data }, {
onSuccess: () => { showSuccess(`Client "${client.clientName}" updated`); setEditingClientId(null); },
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to update client'),
});
}}
onCancel={() => setEditingClientId(null)}
isSubmitting={updateOIDCClient.isPending}
/>
) : (
<div key={client.clientId} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
<div>
<span className="font-medium text-sm">{client.clientName}</span>
<span className="text-muted-foreground text-xs ml-2 font-mono">{client.clientId}</span>
<div className="text-xs text-muted-foreground mt-0.5 font-mono">
{client.redirectUris.join(', ')}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">{client.authorizationPolicy}</Badge>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setEditingClientId(client.clientId)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive h-7 w-7 p-0"
onClick={() => {
if (confirm(`Delete OIDC client "${client.clientName}"?`)) {
deleteOIDCClient.mutate(client.clientId, {
onSuccess: () => showSuccess(`Client "${client.clientName}" deleted`),
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to delete client'),
});
}
}}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
))}
</div>
)}
</CardContent>
</Card>
)}
{/* Protected Domains */}
{status?.enabled && <ProtectedDomainsCard authDomain={status.domain} />}
</div>
);
}
// --- Sub-components ---
function ConfigCard({ status, onUpdate, showSuccess, showError }: {
status: AutheliaStatus | undefined;
onUpdate: ReturnType<typeof useAuthelia>['updateConfig'];
showSuccess: (msg: string) => void;
showError: (msg: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [initialized, setInitialized] = useState(false);
const { register, handleSubmit, watch, setValue, reset } = useForm({
defaultValues: {
domain: '',
defaultPolicy: 'one_factor',
smtpHost: '',
smtpPort: 587,
smtpUsername: '',
smtpSender: '',
smtpPassword: '',
},
});
// Sync form with status data once on load, not continuously
if (status && !initialized) {
reset({
domain: status.domain ?? '',
defaultPolicy: status.defaultPolicy || 'one_factor',
smtpHost: status.smtp?.host ?? '',
smtpPort: status.smtp?.port || 587,
smtpUsername: status.smtp?.username ?? '',
smtpSender: status.smtp?.sender ?? '',
smtpPassword: '',
});
setInitialized(true);
}
const enabled = status?.enabled ?? false;
const isUpdating = onUpdate.isPending;
const doUpdate = (data: { enabled?: boolean; domain?: string; defaultPolicy?: string; smtpHost?: string; smtpPort?: number; smtpUsername?: string; smtpSender?: string; smtpPassword?: string }) => {
onUpdate.mutate(data, {
onSuccess: () => {
setEditing(false);
showSuccess('Configuration updated');
},
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to update config'),
});
};
return (
<Card className="border-l-4 border-l-blue-500">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Configuration</CardTitle>
<div className="flex items-center gap-3">
<Label htmlFor="authelia-enabled" className="text-sm">Enabled</Label>
<Switch
id="authelia-enabled"
checked={enabled}
onCheckedChange={(checked) => {
if (!checked || status?.domain) {
doUpdate({ enabled: checked });
} else {
setEditing(true);
}
}}
disabled={isUpdating || (!status?.installed && !enabled)}
/>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{!status?.installed && !enabled && (
<p className="text-sm text-muted-foreground">Authelia is not installed. Install it first: <code className="bg-muted px-1 rounded">apt install authelia</code></p>
)}
{(enabled || editing) && (
<form onSubmit={handleSubmit((data) => {
const update: any = { ...data, enabled: true };
if (!update.smtpPassword) delete update.smtpPassword; // don't send empty password
doUpdate(update);
})} className="space-y-3">
<div>
<Label htmlFor="auth-domain">Auth Portal Domain</Label>
<Input {...register('domain', { required: 'Required' })} id="auth-domain" placeholder="auth.example.com" className="mt-1 font-mono" />
</div>
<div>
<Label htmlFor="default-policy">Default Policy</Label>
<Select value={watch('defaultPolicy')} onValueChange={(v) => setValue('defaultPolicy', v)}>
<SelectTrigger className="mt-1" id="default-policy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="one_factor">One Factor (password only)</SelectItem>
<SelectItem value="two_factor">Two Factor (password + TOTP)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="pt-2 border-t">
<p className="text-sm font-medium mb-2">Email Notifications (SMTP)</p>
<p className="text-xs text-muted-foreground mb-3">Required for password resets and 2FA enrollment. Without SMTP, notifications are written to a local file.</p>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Label htmlFor="smtp-host">SMTP Host</Label>
<Input {...register('smtpHost')} id="smtp-host" placeholder="smtp.gmail.com" className="mt-1 font-mono" />
</div>
<div>
<Label htmlFor="smtp-port">Port</Label>
<Input {...register('smtpPort', { valueAsNumber: true })} id="smtp-port" type="number" placeholder="587" className="mt-1 font-mono" />
</div>
</div>
<div className="grid grid-cols-3 gap-3 mt-3">
<div>
<Label htmlFor="smtp-sender">Sender Email</Label>
<Input {...register('smtpSender')} id="smtp-sender" placeholder="auth@example.com" className="mt-1 font-mono" />
</div>
<div>
<Label htmlFor="smtp-username">Username</Label>
<Input {...register('smtpUsername')} id="smtp-username" placeholder="(defaults to sender)" className="mt-1 font-mono" />
</div>
<div>
<Label htmlFor="smtp-password">Password</Label>
<Input {...register('smtpPassword')} id="smtp-password" type="password" placeholder={status?.smtp?.host ? '••••••••' : ''} className="mt-1" />
</div>
</div>
</div>
{editing && (
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={isUpdating}>
{isUpdating && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Enable & Save
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setEditing(false)} disabled={isUpdating}>Cancel</Button>
</div>
)}
{enabled && !editing && (
<Button type="submit" size="sm" disabled={isUpdating}>
{isUpdating && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Save Changes
</Button>
)}
</form>
)}
</CardContent>
</Card>
);
}
function AddUserForm({ onSubmit, isSubmitting }: {
onSubmit: (data: { username: string; displayname: string; password: string; email?: string }) => void;
isSubmitting: boolean;
}) {
const { register, handleSubmit, formState: { errors } } = useForm({
defaultValues: { username: '', displayname: '', password: '', email: '' },
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="p-3 bg-muted/30 rounded-md space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="new-username">Username</Label>
<Input {...register('username', { required: 'Required' })} id="new-username" className="mt-1" />
{errors.username && <p className="text-sm text-red-600 mt-1">{errors.username.message}</p>}
</div>
<div>
<Label htmlFor="new-displayname">Display Name</Label>
<Input {...register('displayname', { required: 'Required' })} id="new-displayname" className="mt-1" />
{errors.displayname && <p className="text-sm text-red-600 mt-1">{errors.displayname.message}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="new-email">Email</Label>
<Input {...register('email')} id="new-email" type="email" className="mt-1" />
</div>
<div>
<Label htmlFor="new-password">Password</Label>
<Input {...register('password', { required: 'Required', minLength: { value: 8, message: 'Min 8 characters' } })} id="new-password" type="password" className="mt-1" />
{errors.password && <p className="text-sm text-red-600 mt-1">{errors.password.message}</p>}
</div>
</div>
<Button type="submit" size="sm" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Create User
</Button>
</form>
);
}
function AddOIDCClientForm({ onSubmit, isSubmitting }: {
onSubmit: (data: { clientId: string; clientName: string; redirectUris: string[]; authorizationPolicy?: string }) => void;
isSubmitting: boolean;
}) {
const { register, handleSubmit, formState: { errors }, watch, setValue } = useForm({
defaultValues: { clientId: '', clientName: '', redirectUri: '', authorizationPolicy: 'one_factor' },
});
return (
<form onSubmit={handleSubmit((data) => {
onSubmit({
clientId: data.clientId,
clientName: data.clientName,
redirectUris: [data.redirectUri],
authorizationPolicy: data.authorizationPolicy,
});
})} className="p-3 bg-muted/30 rounded-md space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="client-id">Client ID</Label>
<Input {...register('clientId', { required: 'Required' })} id="client-id" placeholder="gitea" className="mt-1 font-mono" />
{errors.clientId && <p className="text-sm text-red-600 mt-1">{errors.clientId.message}</p>}
</div>
<div>
<Label htmlFor="client-name">Client Name</Label>
<Input {...register('clientName', { required: 'Required' })} id="client-name" placeholder="Gitea" className="mt-1" />
{errors.clientName && <p className="text-sm text-red-600 mt-1">{errors.clientName.message}</p>}
</div>
</div>
<div>
<Label htmlFor="redirect-uri">Redirect URI</Label>
<Input {...register('redirectUri', { required: 'Required' })} id="redirect-uri" placeholder="https://git.example.com/user/oauth2/authelia/callback" className="mt-1 font-mono text-sm" />
{errors.redirectUri && <p className="text-sm text-red-600 mt-1">{errors.redirectUri.message}</p>}
</div>
<div>
<Label htmlFor="client-policy">Authorization Policy</Label>
<Select value={watch('authorizationPolicy')} onValueChange={(v) => setValue('authorizationPolicy', v)}>
<SelectTrigger className="mt-1" id="client-policy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="one_factor">One Factor</SelectItem>
<SelectItem value="two_factor">Two Factor</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Create Client
</Button>
</form>
);
}
function EditOIDCClientForm({ client, onSubmit, onCancel, isSubmitting }: {
client: { clientId: string; clientName: string; redirectUris: string[]; authorizationPolicy: string };
onSubmit: (data: { clientName?: string; redirectUris?: string[]; authorizationPolicy?: string }) => void;
onCancel: () => void;
isSubmitting: boolean;
}) {
const { register, handleSubmit, watch, setValue } = useForm({
defaultValues: {
clientName: client.clientName,
redirectUri: client.redirectUris[0] ?? '',
authorizationPolicy: client.authorizationPolicy || 'one_factor',
},
});
return (
<form onSubmit={handleSubmit((data) => {
onSubmit({
clientName: data.clientName,
redirectUris: [data.redirectUri],
authorizationPolicy: data.authorizationPolicy,
});
})} className="p-3 bg-muted/30 rounded-md space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Client ID</Label>
<Input value={client.clientId} disabled className="mt-1 font-mono" />
</div>
<div>
<Label htmlFor="edit-client-name">Client Name</Label>
<Input {...register('clientName')} id="edit-client-name" className="mt-1" />
</div>
</div>
<div>
<Label htmlFor="edit-redirect-uri">Redirect URI</Label>
<Input {...register('redirectUri')} id="edit-redirect-uri" className="mt-1 font-mono text-sm" />
</div>
<div>
<Label htmlFor="edit-client-policy">Authorization Policy</Label>
<Select value={watch('authorizationPolicy')} onValueChange={(v) => setValue('authorizationPolicy', v)}>
<SelectTrigger className="mt-1" id="edit-client-policy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="one_factor">One Factor</SelectItem>
<SelectItem value="two_factor">Two Factor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Save
</Button>
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={isSubmitting}>Cancel</Button>
</div>
</form>
);
}
function ProtectedDomainsCard({ authDomain }: { authDomain: string }) {
const queryClient = useQueryClient();
const domainsQuery = useQuery({
queryKey: ['domains'],
queryFn: () => domainsApi.list(),
});
const toggleAuthMutation = useMutation({
mutationFn: async ({ domain, enabled }: { domain: string; enabled: boolean }) => {
return apiClient.patch(`/api/v1/domains/${domain}`, { auth: { enabled, policy: 'one_factor' } });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['domains'] });
},
});
const httpDomains = (domainsQuery.data?.domains ?? []).filter(
(d) => d.backend.type === 'http' && d.source !== 'authelia' && d.source !== 'central' && d.domain !== authDomain
);
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<CardTitle className="text-base">Protected Domains</CardTitle>
<Badge variant="secondary" className="text-xs">Forward-Auth</Badge>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-3">
<p className="text-sm text-muted-foreground">
Toggle forward-auth protection for L7 HTTP services. Apps with native OIDC support (Gitea, Grafana, etc.) should use an OIDC client above instead.
</p>
{httpDomains.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4 flex items-center justify-center gap-2">
<Globe className="h-4 w-4" />
No L7 HTTP domains registered yet.
</p>
) : (
<div className="space-y-2">
{httpDomains.map((domain) => {
const authEnabled = (domain as any).auth?.enabled ?? false;
return (
<div key={domain.domain} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
<div>
<span className="font-mono text-sm">{domain.domain}</span>
<span className="text-muted-foreground text-xs ml-2">({domain.source})</span>
</div>
<Switch
checked={authEnabled}
onCheckedChange={(checked) => toggleAuthMutation.mutate({ domain: domain.domain, enabled: checked })}
disabled={toggleAuthMutation.isPending}
/>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
// Import apiClient for the PATCH call in ProtectedDomainsCard
import { apiClient } from '../services/api/client';

View File

@@ -82,8 +82,12 @@ function titleCaseScenario(s: string): string {
).join(' ');
}
function friendlyReason(reason: string): string {
if (!reason) return 'Community blocklist';
function friendlyScenario(scenario: string, origin?: string): string {
if (!scenario) {
if (origin === 'capi') return 'Community blocklist';
if (origin === 'manual' || origin === 'cscli') return 'Manual';
return origin || 'Unknown';
}
const map: Record<string, string> = {
'crowdsecurity/http-scan-classb': 'HTTP Scanner',
'crowdsecurity/ssh-slow-bruteforce': 'SSH Brute Force (slow)',
@@ -96,8 +100,8 @@ function friendlyReason(reason: string): string {
'crowdsecurity/http-bad-user-agent': 'Bad User Agent',
'crowdsecurity/iptables-scan-multi_ports': 'Port Scanner',
};
if (map[reason]) return map[reason];
const short = reason.includes('/') ? reason.split('/').slice(1).join(' ') : reason;
if (map[scenario]) return map[scenario];
const short = scenario.includes('/') ? scenario.split('/').slice(1).join(' ') : scenario;
return titleCaseScenario(short);
}
@@ -294,10 +298,10 @@ export function CrowdSecComponent() {
/>
{scenarioHubUrl(reason) ? (
<a href={scenarioHubUrl(reason)!} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground hover:underline">
{friendlyReason(reason)}
{friendlyScenario(reason)}
</a>
) : (
<span className="text-muted-foreground">{friendlyReason(reason)}</span>
<span className="text-muted-foreground">{friendlyScenario(reason)}</span>
)}
</div>
<span className="font-mono text-xs tabular-nums">{count.toLocaleString()}</span>
@@ -411,10 +415,10 @@ export function CrowdSecComponent() {
</div>
{scenarioHubUrl(alert.scenario) ? (
<a href={scenarioHubUrl(alert.scenario)!} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground whitespace-nowrap hover:text-foreground hover:underline">
{friendlyReason(alert.scenario)}
{friendlyScenario(alert.scenario)}
</a>
) : (
<span className="text-xs text-muted-foreground whitespace-nowrap">{friendlyReason(alert.scenario)}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">{friendlyScenario(alert.scenario)}</span>
)}
<span className="text-xs font-mono text-muted-foreground whitespace-nowrap">{alert.machineId?.replace(/^wc-/, '') ?? '—'}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">{formatRelativeTime(alert.createdAt)}</span>
@@ -528,7 +532,7 @@ export function CrowdSecComponent() {
}
<span className="font-mono truncate">{d.value}</span>
<Badge variant="outline" className="text-xs h-4 shrink-0">{d.type}</Badge>
<span className="text-muted-foreground truncate">{friendlyReason(d.reason)}</span>
<span className="text-muted-foreground truncate">{friendlyScenario(d.scenario, d.origin)}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{d.duration && (

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card } from './ui/card';
import { Button } from './ui/button';
@@ -7,20 +7,59 @@ import { Alert, AlertDescription } from './ui/alert';
import { Badge } from './ui/badge';
import {
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
RefreshCw, BookOpen, Globe, Router, Server, RotateCw, Key,
BookOpen, Globe, Router, RotateCw, Key, KeyRound,
Shield, ShieldCheck, Eye, ArrowLeftRight, Lock,
} from 'lucide-react';
import { useCloudflare } from '../hooks/useCloudflare';
import { useDdns } from '../hooks/useDdns';
import { useCentralStatus } from '../hooks/useCentralStatus';
import { useVpn } from '../hooks/useVpn';
import { secretsApi, dnsSettingsApi, nftablesConfigApi } from '../services/api';
import { usePageHelp } from '../hooks/usePageHelp';
import type { DdnsRecordStatus } from '../services/api/networking';
const SERVICES = [
{ key: 'dnsmasq', label: 'DNS / DHCP', icon: Globe },
{ key: 'haproxy', label: 'HAProxy', icon: ArrowLeftRight },
{ key: 'nftables', label: 'Firewall', icon: Shield },
{ key: 'wireguard', label: 'VPN', icon: Lock },
{ key: 'crowdsec', label: 'CrowdSec', icon: Eye },
{ key: 'authelia', label: 'Auth', icon: KeyRound },
{ key: 'certbot', label: 'Certificates', icon: ShieldCheck },
];
function formatUptime(totalSeconds: number): string {
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
function useLiveUptime(serverSeconds: number | undefined): string {
const [elapsed, setElapsed] = useState(0);
const baseRef = useRef(serverSeconds ?? 0);
useEffect(() => {
if (serverSeconds === undefined) return;
baseRef.current = serverSeconds;
setElapsed(0);
}, [serverSeconds]);
useEffect(() => {
if (serverSeconds === undefined) return;
const id = setInterval(() => setElapsed(e => e + 1), 1000);
return () => clearInterval(id);
}, [serverSeconds]);
return formatUptime(baseRef.current + elapsed);
}
export function DashboardComponent() {
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
const { verification, isLoading: cfLoading } = useCloudflare();
const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns();
const { config: vpnConfig } = useVpn();
const { data: dnsSettings } = useQuery({ queryKey: ['dns', 'settings'], queryFn: () => dnsSettingsApi.get() });
const { data: nftConfig } = useQuery({ queryKey: ['nftables', 'config'], queryFn: () => nftablesConfigApi.get() });
@@ -47,11 +86,21 @@ export function DashboardComponent() {
},
});
// Warning conditions
const cfTokenMissing = !cfLoading && !verification?.tokenConfigured;
const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid;
const ddnsFailed = ddnsStatus?.enabled && ddnsStatus?.lastError;
const hasWarnings = cfTokenMissing || cfTokenInvalid || ddnsFailed;
const hasWarnings = cfTokenMissing || cfTokenInvalid;
const daemons = (centralStatus?.daemons ?? {}) as Record<string, { active?: boolean; version?: string }>;
const uptime = useLiveUptime(centralStatus?.uptimeSeconds);
const ports = [
{ port: 443, proto: 'TCP', label: 'HTTPS' },
{ port: 80, proto: 'TCP', label: 'HTTP' },
...(vpnConfig?.enabled ? [{ port: vpnConfig.listenPort || 51820, proto: 'UDP', label: 'VPN' }] : []),
...((nftConfig?.extraPorts ?? []) as { port: number; protocol?: string; label?: string }[])
.map(ep => ({ port: ep.port, proto: (ep.protocol || 'tcp').toUpperCase(), label: ep.label || '' })),
];
usePageHelp({
title: 'What is the Dashboard?',
@@ -73,15 +122,36 @@ export function DashboardComponent() {
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<LayoutDashboard className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Dashboard</h2>
<p className="text-muted-foreground">Infrastructure health and service prerequisites</p>
{/* Page Header with system metadata */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<LayoutDashboard className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Dashboard</h2>
<p className="text-muted-foreground">System health overview</p>
</div>
</div>
{!statusLoading && centralStatus ? (
<div className="flex items-center gap-3 text-sm text-muted-foreground sm:mt-1">
<span className="font-mono text-xs">{centralStatus.version}</span>
<span className="text-border">|</span>
<span>up <span className="font-mono">{uptime}</span></span>
<Button
variant="ghost"
size="sm"
onClick={restartDaemon}
disabled={isRestarting}
className="gap-1 h-7 text-xs"
>
{isRestarting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
{isRestarting ? 'Restarting...' : 'Restart'}
</Button>
</div>
) : !statusLoading ? (
<Badge variant="destructive">Offline</Badge>
) : null}
</div>
{/* Warnings */}
@@ -99,322 +169,276 @@ export function DashboardComponent() {
<AlertDescription>Cloudflare API token is invalid. Check your token in Cloudflare settings.</AlertDescription>
</Alert>
)}
{ddnsFailed && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>DDNS sync error: {ddnsStatus.lastError}</AlertDescription>
</Alert>
)}
</div>
)}
{/* 1. Cloudflare */}
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Cloud className="h-5 w-5 text-blue-500" />
<div className="font-medium">Cloudflare</div>
</div>
{cfLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : verification?.tokenValid ? (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
Connected
</Badge>
) : verification?.tokenConfigured ? (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Invalid
</Badge>
) : (
<Badge variant="secondary">Not configured</Badge>
)}
{/* Services */}
<div>
<div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-3">Services</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{SERVICES.map((svc) => (
<ServiceCard
key={svc.key}
label={svc.label}
daemon={svc.key}
icon={svc.icon}
active={daemons[svc.key]?.active}
version={daemons[svc.key]?.version}
loading={statusLoading}
/>
))}
</div>
</div>
{cfLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
<Loader2 className="h-4 w-4 animate-spin" />
Checking token...
</div>
) : verification?.tokenValid ? (
<div className="space-y-2 ml-7">
{verification.zones && verification.zones.length > 0 && (
<div className="flex items-start gap-2 text-sm">
<Globe className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<span className="text-muted-foreground">Zones: </span>
<span className="inline-flex flex-wrap gap-1">
{verification.zones.map((zone) => (
<span key={zone.id} className="font-mono bg-muted px-2 py-0.5 rounded text-xs">
{zone.name}
</span>
))}
</span>
{/* Configuration */}
<div>
<div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-3">Configuration</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Cloudflare */}
<Card className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-orange-500/10 rounded-xl">
<Cloud className="h-5 w-5 text-orange-500" />
</div>
<div className="font-semibold">Cloudflare</div>
</div>
)}
{!editingToken ? (
<Button variant="ghost" size="sm" onClick={() => setEditingToken(true)} className="gap-1 text-muted-foreground">
<Key className="h-3 w-3" />
Change Token
</Button>
) : (
<div className="space-y-2">
<Input
type="password"
placeholder="New Cloudflare API token..."
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
{cfLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : verification?.tokenValid ? (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
Connected
</Badge>
) : verification?.tokenConfigured ? (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Invalid
</Badge>
) : (
<Badge variant="secondary">Not configured</Badge>
)}
</div>
{cfLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Verifying...
</div>
) : verification?.tokenValid ? (
<div className="space-y-3">
<div className="space-y-1">
<PermissionRow label="Zone:Read" description="List DNS zones" ok={verification.permissions?.zone} />
<PermissionRow label="DNS:Edit" description="Manage DNS records" ok={verification.permissions?.dns} />
</div>
<ZoneCoverage
available={verification.zones}
required={verification.requiredZones}
/>
<div className="flex gap-2">
<Button size="sm" onClick={() => updateSecretsMutation.mutate({ cloudflare: { apiToken: tokenValue } })} disabled={!tokenValue}>
Save
{!editingToken ? (
<Button variant="ghost" size="sm" onClick={() => setEditingToken(true)} className="gap-1 h-7 text-xs text-muted-foreground">
<Key className="h-3 w-3" />
Change Token
</Button>
<Button size="sm" variant="ghost" onClick={() => { setEditingToken(false); setTokenValue(''); }}>
Cancel
</Button>
</div>
</div>
)}
</div>
) : (
<div className="ml-7">
{!editingToken ? (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{verification?.tokenConfigured
? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}`
: 'An API token is required for DNS and TLS features'}
</p>
<Button variant="outline" size="sm" onClick={() => setEditingToken(true)} className="gap-1">
<Key className="h-3 w-3" />
{cfTokenIsSet ? 'Update Token' : 'Set Token'}
</Button>
) : (
<div className="space-y-2">
<Input
type="password"
placeholder="New Cloudflare API token..."
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
/>
<div className="flex gap-2">
<Button size="sm" onClick={() => updateSecretsMutation.mutate({ cloudflare: { apiToken: tokenValue } })} disabled={!tokenValue}>
Save
</Button>
<Button size="sm" variant="ghost" onClick={() => { setEditingToken(false); setTokenValue(''); }}>
Cancel
</Button>
</div>
</div>
)}
</div>
) : (
<div className="space-y-2">
<Input
type="password"
placeholder="Cloudflare API token..."
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => updateSecretsMutation.mutate({ cloudflare: { apiToken: tokenValue } })}
disabled={updateSecretsMutation.isPending || !tokenValue}
>
{updateSecretsMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
Save
</Button>
<Button variant="outline" size="sm" onClick={() => { setEditingToken(false); setTokenValue(''); }}>
Cancel
</Button>
</div>
{updateSecretsMutation.isError && (
<p className="text-xs text-red-500">{String(updateSecretsMutation.error)}</p>
<div>
{!editingToken ? (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{verification?.tokenConfigured
? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}`
: 'Required for DNS and TLS'}
</p>
<Button variant="outline" size="sm" onClick={() => setEditingToken(true)} className="gap-1">
<Key className="h-3 w-3" />
{cfTokenIsSet ? 'Update Token' : 'Set Token'}
</Button>
</div>
) : (
<div className="space-y-2">
<Input
type="password"
placeholder="Cloudflare API token..."
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => updateSecretsMutation.mutate({ cloudflare: { apiToken: tokenValue } })}
disabled={updateSecretsMutation.isPending || !tokenValue}
>
{updateSecretsMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
Save
</Button>
<Button variant="outline" size="sm" onClick={() => { setEditingToken(false); setTokenValue(''); }}>
Cancel
</Button>
</div>
{updateSecretsMutation.isError && (
<p className="text-xs text-red-500">{String(updateSecretsMutation.error)}</p>
)}
</div>
)}
</div>
)}
</div>
)}
</Card>
</Card>
{/* 2. Dynamic DNS */}
<Card className="p-4 border-l-4 border-l-green-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-green-500" />
<div className="font-medium">Dynamic DNS</div>
</div>
<div className="flex items-center gap-2">
{ddnsLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : ddnsStatus?.enabled ? (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
Active
</Badge>
) : (
<Badge variant="secondary">Disabled</Badge>
)}
</div>
</div>
{ddnsStatus?.enabled ? (
<div className="space-y-3 ml-7">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Public IP</span>
<div className="font-mono font-medium mt-0.5">{ddnsStatus.currentIP || '--'}</div>
{/* Router */}
<Card className="p-5">
<div className="flex items-center gap-3 mb-4">
<div className="p-2.5 bg-amber-500/10 rounded-xl">
<Router className="h-5 w-5 text-amber-500" />
</div>
<div>
<span className="text-muted-foreground">Last checked</span>
<div className="font-mono mt-0.5">
{ddnsStatus.lastChecked ? new Date(ddnsStatus.lastChecked).toLocaleTimeString() : '--'}
</div>
<div className="font-semibold">Router</div>
<div className="text-xs text-muted-foreground">LAN setup checklist</div>
</div>
</div>
{/* Record list (read-only — derived from public domains) */}
{ddnsStatus.records?.length ? (
<div className="space-y-1">
{ddnsStatus.records.map((rs: DdnsRecordStatus) => (
<div key={rs.name} className="flex items-center gap-2 text-xs">
{rs.ok ? (
<CheckCircle className="h-3.5 w-3.5 text-green-500 shrink-0" />
) : (
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
)}
<span className="font-mono bg-muted px-2 py-0.5 rounded">{rs.name}</span>
{rs.ok && rs.ip && (
<span className="text-muted-foreground font-mono">{rs.ip}</span>
)}
{!rs.ok && rs.error && (
<span className="text-red-500 truncate" title={rs.error}>{rs.error}</span>
)}
<div className="text-sm text-muted-foreground space-y-2">
<p>Configure your LAN router so Wild Central can serve traffic:</p>
<ol className="list-decimal list-inside space-y-1.5 text-xs">
<li>
Set the router's DNS server to Wild Central
{dnsSettings?.ip && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">{dnsSettings.ip}</span>
)}
</li>
<li>
Forward these ports to Wild Central:
<div className="flex flex-wrap gap-1 mt-1 ml-4">
{ports.map((p, i) => (
<span key={i} className="font-mono bg-muted px-2 py-0.5 rounded">
{p.port}/{p.proto}
{p.label && <span className="text-muted-foreground/60"> ({p.label})</span>}
</span>
))}
</div>
))}
<p className="text-xs text-muted-foreground mt-1">
Records are derived from public domains. Toggle Public on the Domains page to manage.
</p>
</div>
) : (
<p className="text-xs text-muted-foreground">
No public domains registered. Set a domain to Public on the Domains page to enable DDNS.
</p>
)}
<Button
variant="outline"
size="sm"
onClick={() => triggerDdns()}
disabled={isTriggering}
className="gap-1"
>
{isTriggering ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
Check Now
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground ml-7">
DDNS is not enabled. Enable it in the global config to keep public domain DNS records in sync with your public IP.
</p>
)}
</Card>
{/* 3. Daemon Status */}
<Card className="p-4 border-l-4 border-l-purple-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-purple-500" />
<div className="font-medium">Daemon Status</div>
</div>
{statusLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : centralStatus ? (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
Running
</Badge>
) : (
<Badge variant="destructive">Offline</Badge>
)}
</div>
{statusLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
<Loader2 className="h-4 w-4 animate-spin" />
Loading status...
</div>
) : centralStatus ? (
<div className="space-y-3 ml-7">
{/* Service badges */}
<div className="flex flex-wrap gap-2">
<ServiceBadge name="dnsmasq" active={centralStatus?.daemons?.dnsmasq?.active} />
<ServiceBadge name="haproxy" active={centralStatus?.daemons?.haproxy?.active} />
<ServiceBadge name="nftables" active={centralStatus?.daemons?.nftables?.active} />
<ServiceBadge name="wireguard" active={centralStatus?.daemons?.wireguard?.active} />
<ServiceBadge name="crowdsec" active={centralStatus?.daemons?.crowdsec?.active} />
</li>
</ol>
</div>
{/* Version and uptime */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Version</span>
<div className="font-mono font-medium mt-0.5">{centralStatus.version || 'Unknown'}</div>
</div>
<div>
<span className="text-muted-foreground">Uptime</span>
<div className="font-mono mt-0.5">{centralStatus.uptime || '--'}</div>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={restartDaemon}
disabled={isRestarting}
className="gap-1"
>
{isRestarting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
{isRestarting ? 'Restarting...' : 'Restart Daemon'}
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground ml-7">Cannot reach the Wild Central daemon.</p>
)}
</Card>
{/* 4. Gateway Router */}
<Card className="p-4 border-l-4 border-l-amber-500 bg-gradient-to-r from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10">
<div className="flex items-center gap-2 mb-3">
<Router className="h-5 w-5 text-amber-500" />
<div className="font-medium">Gateway Router</div>
</Card>
</div>
<div className="space-y-2 ml-7 text-sm text-muted-foreground">
<p>Your LAN router should be configured to work with Wild Central:</p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li>
Set the router's DNS server to Wild Central
{dnsSettings?.ip && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">
{dnsSettings.ip}
</span>
)}
</li>
<li>
Port-forward to Wild Central:{' '}
{[
{ port: 443, proto: 'TCP', label: 'HTTPS' },
{ port: 80, proto: 'TCP', label: 'HTTP' },
...(vpnConfig?.enabled ? [{ port: vpnConfig.listenPort || 51820, proto: 'UDP', label: 'VPN' }] : []),
...((nftConfig?.extraPorts ?? []) as { port: number; protocol?: string; label?: string }[])
.map(ep => ({ port: ep.port, proto: (ep.protocol || 'tcp').toUpperCase(), label: ep.label || '' })),
].map((p, i) => (
<span key={i}>
{i > 0 && ', '}
<span className="font-mono">{p.port}</span>/{p.proto}
{p.label && <span className="text-muted-foreground/60"> ({p.label})</span>}
</span>
))}
</li>
</ul>
</div>
</Card>
</div>
</div>
);
}
/** Inline badge showing a service name with a colored dot reflecting actual daemon status. */
function ServiceBadge({ name, active }: { name: string; active?: boolean }) {
const dotColor = active === undefined ? 'bg-gray-400' : active ? 'bg-green-500' : 'bg-red-500';
function ZoneCoverage({ available, required }: {
available: { id: string; name: string }[] | null;
required: string[] | null;
}) {
const availableNames = new Set((available ?? []).map(z => z.name));
const requiredNames = required ?? [];
// Merge: all required zones (checked/unchecked) + any extra available zones not in required
const allZones: { name: string; covered: boolean; needed: boolean }[] = [];
for (const name of requiredNames) {
allZones.push({ name, covered: availableNames.has(name), needed: true });
}
for (const z of available ?? []) {
if (!requiredNames.includes(z.name)) {
allZones.push({ name: z.name, covered: true, needed: false });
}
}
if (allZones.length === 0) return null;
return (
<span className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-medium">
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
{name}
</span>
<div>
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Zones</div>
<div className="space-y-1">
{allZones.map((z) => (
<div key={z.name} className="flex items-center gap-2 text-xs">
{z.needed ? (
z.covered ? (
<CheckCircle className="h-3 w-3 text-green-500 shrink-0" />
) : (
<XCircle className="h-3 w-3 text-red-500 shrink-0" />
)
) : (
<CheckCircle className="h-3 w-3 text-muted-foreground/40 shrink-0" />
)}
<span className="font-mono">{z.name}</span>
{!z.covered && z.needed && (
<span className="text-red-500">not covered</span>
)}
{!z.needed && (
<span className="text-muted-foreground">unused</span>
)}
</div>
))}
</div>
</div>
);
}
function PermissionRow({ label, description, ok }: { label: string; description: string; ok?: boolean }) {
return (
<div className="flex items-center gap-2 text-xs">
{ok ? (
<CheckCircle className="h-3 w-3 text-green-500 shrink-0" />
) : (
<XCircle className="h-3 w-3 text-red-500 shrink-0" />
)}
<span className="font-mono font-medium">{label}</span>
<span className="text-muted-foreground">{description}</span>
</div>
);
}
function ServiceCard({ label, daemon, icon: Icon, active, version, loading }: {
label: string;
daemon: string;
icon: typeof Globe;
active?: boolean;
version?: string;
loading: boolean;
}) {
return (
<Card className={`p-4 transition-colors ${active === false ? 'border-red-500/40' : ''}`}>
<div className="flex flex-col items-center gap-2 text-center">
<div className={`p-2.5 rounded-xl ${
loading ? 'bg-muted' :
active ? 'bg-green-500/10' :
active === false ? 'bg-red-500/10' :
'bg-muted'
}`}>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
) : (
<Icon className={`h-5 w-5 ${
active ? 'text-green-600 dark:text-green-400' :
active === false ? 'text-red-500' :
'text-muted-foreground'
}`} />
)}
</div>
<div>
<div className="text-sm font-medium">{label}</div>
<div className="text-[10px] text-muted-foreground font-mono">{daemon}</div>
{version && <div className="text-[10px] text-muted-foreground font-mono">{version}</div>}
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,501 @@
import { useState, useRef } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
import { Button } from './ui/button';
import { Input, Label } from './ui';
import { Alert, AlertDescription } from './ui/alert';
import { Switch } from './ui/switch';
import { Badge } from './ui/badge';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from './ui/select';
import {
ShieldBan, Loader2, Plus, Trash2, RotateCw, Upload,
CheckCircle, AlertCircle, X, ExternalLink, List,
} from 'lucide-react';
import { useDnsFilter } from '../hooks/useDnsFilter';
export function DnsFilterComponent() {
const {
status, isLoadingStatus, config,
lists, customEntries, suggestedLists,
updateConfig, isUpdatingConfig,
addList, isAddingList,
removeList, toggleList,
addCustomEntry, removeCustomEntry,
triggerUpdate, isUpdating,
uploadList, isUploading,
} = useDnsFilter();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Add list form
const [newListUrl, setNewListUrl] = useState('');
const [newListName, setNewListName] = useState('');
const [showAddList, setShowAddList] = useState(false);
// Upload form
const [uploadName, setUploadName] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// Custom entry form
const [showAddCustom, setShowAddCustom] = useState(false);
const [newCustomDomain, setNewCustomDomain] = useState('');
const [newCustomType, setNewCustomType] = useState<'allow' | 'block'>('block');
const showSuccess = (msg: string) => {
setSuccessMessage(msg);
setErrorMessage(null);
setTimeout(() => setSuccessMessage(null), 5000);
};
const showError = (msg: string) => {
setErrorMessage(msg);
setSuccessMessage(null);
setTimeout(() => setErrorMessage(null), 8000);
};
const handleToggleEnabled = async () => {
try {
await updateConfig({ enabled: !status?.enabled });
showSuccess(status?.enabled ? 'DNS filtering disabled' : 'DNS filtering enabled');
} catch (e) {
showError(`Failed to update config: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleAddList = async () => {
if (!newListUrl || !newListName) return;
try {
await addList({ url: newListUrl, name: newListName });
setNewListUrl('');
setNewListName('');
setShowAddList(false);
showSuccess(`Added list: ${newListName}`);
} catch (e) {
showError(`Failed to add list: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleAddSuggested = async (url: string, name: string) => {
try {
await addList({ url, name });
showSuccess(`Added list: ${name}`);
} catch (e) {
showError(`Failed to add list: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleUpload = async () => {
const file = fileInputRef.current?.files?.[0];
if (!file || !uploadName) return;
try {
await uploadList({ name: uploadName, file });
setUploadName('');
if (fileInputRef.current) fileInputRef.current.value = '';
showSuccess(`Uploaded list: ${uploadName}`);
} catch (e) {
showError(`Failed to upload: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleRemoveList = async (id: string) => {
try {
await removeList(id);
showSuccess('List removed');
} catch (e) {
showError(`Failed to remove list: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleToggleList = async (id: string, enabled: boolean) => {
try {
await toggleList({ id, enabled });
} catch (e) {
showError(`Failed to toggle list: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleAddCustom = async () => {
if (!newCustomDomain) return;
try {
await addCustomEntry({ domain: newCustomDomain, type: newCustomType });
setNewCustomDomain('');
setShowAddCustom(false);
showSuccess(`Added ${newCustomType} entry for ${newCustomDomain}`);
} catch (e) {
showError(`Failed to add entry: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleRemoveCustom = async (domain: string) => {
try {
await removeCustomEntry(domain);
showSuccess('Custom entry removed');
} catch (e) {
showError(`Failed to remove entry: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleTriggerUpdate = async () => {
try {
await triggerUpdate();
showSuccess('Update triggered — lists will refresh in the background');
} catch (e) {
showError(`Failed to trigger update: ${e instanceof Error ? e.message : String(e)}`);
}
};
// Filter suggested lists to exclude already-added ones
const addedIds = new Set(lists.map(l => l.id));
const availableSuggested = suggestedLists.filter(s => !addedIds.has(s.id));
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<ShieldBan className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">DNS Filter</h2>
<p className="text-muted-foreground">Block ads, trackers, and malware at the DNS level</p>
</div>
</div>
{/* Alerts */}
{successMessage && (
<Alert className="border-green-500 bg-green-50 dark:bg-green-900/20">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="flex items-center justify-between">
<span className="text-green-800 dark:text-green-200">{successMessage}</span>
<Button variant="ghost" size="sm" onClick={() => setSuccessMessage(null)}><X className="h-3 w-3" /></Button>
</AlertDescription>
</Alert>
)}
{errorMessage && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between">
<span>{errorMessage}</span>
<Button variant="ghost" size="sm" onClick={() => setErrorMessage(null)}><X className="h-3 w-3" /></Button>
</AlertDescription>
</Alert>
)}
{/* Status Card */}
<Card className="border-l-4 border-l-blue-500">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Status</CardTitle>
<div className="flex items-center gap-3">
{status?.enabled && (
<Button
variant="outline"
size="sm"
onClick={handleTriggerUpdate}
disabled={isUpdating}
>
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <RotateCw className="h-4 w-4 mr-1" />}
Update Now
</Button>
)}
<div className="flex items-center gap-2">
<Label htmlFor="dns-filter-toggle" className="text-sm">
{status?.enabled ? 'Enabled' : 'Disabled'}
</Label>
<Switch
id="dns-filter-toggle"
checked={status?.enabled ?? false}
onCheckedChange={handleToggleEnabled}
disabled={isUpdatingConfig || isLoadingStatus}
/>
</div>
</div>
</div>
</CardHeader>
{status?.enabled && (
<CardContent className="space-y-4">
{/* Query stats */}
{status.queryStats && (
<div className="grid grid-cols-3 gap-4">
<div className="bg-muted rounded-md p-3 text-center">
<p className="text-2xl font-bold font-mono">{status.queryStats.totalQueries.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Queries ({status.queryStats.period})</p>
</div>
<div className="bg-muted rounded-md p-3 text-center">
<p className="text-2xl font-bold font-mono text-red-500">{status.queryStats.blockedQueries.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Blocked</p>
</div>
<div className="bg-muted rounded-md p-3 text-center">
<p className="text-2xl font-bold font-mono text-red-500">{status.queryStats.blockedPercent.toFixed(1)}%</p>
<p className="text-xs text-muted-foreground">Block rate</p>
</div>
</div>
)}
{/* List stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-muted rounded-md p-3 text-center">
<p className="text-2xl font-bold font-mono">{status.totalBlocked.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Domains in blocklist</p>
</div>
<div className="bg-muted rounded-md p-3 text-center">
<p className="text-2xl font-bold font-mono">{status.enabledLists}</p>
<p className="text-xs text-muted-foreground">Active lists</p>
</div>
<div className="bg-muted rounded-md p-3 text-center">
<p className="text-2xl font-bold font-mono">{status.listCount}</p>
<p className="text-xs text-muted-foreground">Total lists</p>
</div>
<div className="bg-muted rounded-md p-3 text-center">
<p className="text-sm font-mono">
{status.lastUpdated ? new Date(status.lastUpdated).toLocaleString() : 'Never'}
</p>
<p className="text-xs text-muted-foreground">Last updated</p>
</div>
</div>
{config && (
<p className="text-sm text-muted-foreground">
Lists update every {config.intervalHours || 24} hours
</p>
)}
</CardContent>
)}
</Card>
{/* Blocklists Card */}
<Card className="border-l-4 border-l-green-500">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<List className="h-5 w-5" />
Blocklists
</CardTitle>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setShowAddList(!showAddList)}>
<Plus className="h-4 w-4 mr-1" />
Add List
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Add list form */}
{showAddList && (
<Card className="p-4 bg-muted/50">
<div className="space-y-3">
<h4 className="font-medium text-sm">Add blocklist by URL</h4>
<div>
<Label htmlFor="list-name">Name</Label>
<Input
id="list-name"
placeholder="e.g. My Custom Blocklist"
value={newListName}
onChange={e => setNewListName(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="list-url">URL</Label>
<Input
id="list-url"
placeholder="https://example.com/blocklist.txt"
value={newListUrl}
onChange={e => setNewListUrl(e.target.value)}
className="mt-1"
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleAddList} disabled={isAddingList || !newListUrl || !newListName}>
{isAddingList ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plus className="h-4 w-4 mr-1" />}
Add
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowAddList(false)}>Cancel</Button>
</div>
{/* Upload */}
<div className="border-t pt-3 mt-3">
<h4 className="font-medium text-sm mb-2">Or upload a file</h4>
<div className="flex gap-2 items-end">
<div className="flex-1">
<Label htmlFor="upload-name">Name</Label>
<Input
id="upload-name"
placeholder="My uploaded list"
value={uploadName}
onChange={e => setUploadName(e.target.value)}
className="mt-1"
/>
</div>
<div className="flex-1">
<Label htmlFor="upload-file">File</Label>
<Input id="upload-file" type="file" ref={fileInputRef} accept=".txt,.hosts,.conf" className="mt-1" />
</div>
<Button size="sm" onClick={handleUpload} disabled={isUploading || !uploadName}>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Upload className="h-4 w-4 mr-1" />}
Upload
</Button>
</div>
</div>
</div>
</Card>
)}
{/* Current lists */}
{lists.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<ShieldBan className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="font-medium">No blocklists added</p>
<p className="text-sm mt-1">Add a blocklist URL or choose from the suggestions below</p>
</div>
) : (
<div className="space-y-2">
{lists.map(list => (
<div key={list.id} className="flex items-center gap-3 p-3 bg-muted/50 rounded-md">
<Switch
checked={list.enabled}
onCheckedChange={(checked) => handleToggleList(list.id, checked)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{list.name}</span>
{list.entryCount > 0 && (
<Badge variant="secondary" className="text-xs">
{list.entryCount.toLocaleString()} domains
</Badge>
)}
{list.lastError && (
<Badge variant="destructive" className="text-xs">Error</Badge>
)}
</div>
{list.url && (
<p className="text-xs text-muted-foreground font-mono truncate mt-0.5">{list.url}</p>
)}
{!list.url && (
<p className="text-xs text-muted-foreground mt-0.5">Uploaded file</p>
)}
{list.lastError && (
<p className="text-xs text-red-500 mt-0.5">{list.lastError}</p>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => handleRemoveList(list.id)}>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
))}
</div>
)}
{/* Suggested lists */}
{availableSuggested.length > 0 && (
<div className="border-t pt-4 mt-4">
<h4 className="text-sm font-medium mb-3 text-muted-foreground">Suggested lists</h4>
<div className="space-y-2">
{availableSuggested.map(suggested => (
<div key={suggested.id} className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50 transition-colors">
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{suggested.name}</span>
{suggested.url && (
<p className="text-xs text-muted-foreground font-mono truncate">{suggested.url}</p>
)}
</div>
<div className="flex gap-1">
{suggested.url && (
<Button variant="ghost" size="sm" asChild>
<a href={suggested.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleAddSuggested(suggested.url!, suggested.name)}
disabled={isAddingList}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add
</Button>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Custom Entries Card */}
<Card className="border-l-4 border-l-blue-500">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Custom Entries</CardTitle>
<Button variant="outline" size="sm" onClick={() => setShowAddCustom(!showAddCustom)}>
<Plus className="h-4 w-4 mr-1" />
Add Entry
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{showAddCustom && (
<Card className="p-4 bg-muted/50">
<div className="space-y-3">
<div className="flex gap-3 items-end">
<div className="flex-1">
<Label htmlFor="custom-domain">Domain</Label>
<Input
id="custom-domain"
placeholder="ads.example.com"
value={newCustomDomain}
onChange={e => setNewCustomDomain(e.target.value)}
className="mt-1 font-mono"
/>
</div>
<div className="w-32">
<Label htmlFor="custom-type">Type</Label>
<Select value={newCustomType} onValueChange={(v) => setNewCustomType(v as 'allow' | 'block')}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="block">Block</SelectItem>
<SelectItem value="allow">Allow</SelectItem>
</SelectContent>
</Select>
</div>
<Button size="sm" onClick={handleAddCustom} disabled={!newCustomDomain}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowAddCustom(false)}>Cancel</Button>
</div>
<p className="text-xs text-muted-foreground">
<strong>Block</strong> adds a domain to the blocklist. <strong>Allow</strong> overrides blocklists to permit a specific domain.
</p>
</div>
</Card>
)}
{customEntries.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No custom entries. Use allow entries to override blocklists for specific domains.
</p>
) : (
<div className="space-y-1">
{customEntries.map(entry => (
<div key={entry.domain} className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50">
<Badge variant={entry.type === 'block' ? 'destructive' : 'default'} className="text-xs w-14 justify-center">
{entry.type}
</Badge>
<span className="flex-1 font-mono text-sm">{entry.domain}</span>
<Button variant="ghost" size="sm" onClick={() => handleRemoveCustom(entry.domain)}>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -53,10 +53,11 @@ export function DomainsComponent() {
const [search, setSearch] = useState(() => {
try { return localStorage.getItem('domains:search') ?? ''; } catch { return ''; }
});
const [filter, setFilter] = useState<'all' | 'public' | 'private' | 'direct' | 'l4' | 'l7'>(() => {
type FilterType = 'all' | 'public' | 'private' | 'direct' | 'l4' | 'l7';
const [filter, setFilter] = useState<FilterType>(() => {
try {
const v = localStorage.getItem('domains:filter');
return (v as typeof filter) || 'all';
return (v as FilterType) || 'all';
} catch { return 'all'; }
});

View File

@@ -386,6 +386,16 @@ export function FirewallComponent() {
permissive (safe default while you're getting started).
</p>
{currentWan && !currentExtraPorts.some((p) => p.port === 22) && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
Strict mode is active but SSH (port 22) is not in your allowed ports.
You may lose remote access if you don't have another way in.
</AlertDescription>
</Alert>
)}
{!editingWan ? (
<div className="flex items-center gap-3">
{currentWan ? (

View File

@@ -0,0 +1,41 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { KeyRound } from 'lucide-react';
import { autheliaApi } from '../../services/api/authelia';
import { SubsystemPage } from '../SubsystemPage';
export function AutheliaSubsystem() {
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const configQuery = useQuery({
queryKey: ['authelia', 'config', 'raw'],
queryFn: () => autheliaApi.getConfig(),
});
const restartMutation = useMutation({
mutationFn: () => autheliaApi.restart(),
onSuccess: () => {
setSuccessMsg('Authelia restarted');
setErrorMsg(null);
setTimeout(() => setSuccessMsg(null), 5000);
},
onError: (e: Error) => setErrorMsg(e.message),
});
return (
<SubsystemPage
title="Authelia"
description="Authentication and identity provider"
icon={KeyRound}
daemonKey="authelia"
configContent={configQuery.data?.content}
isLoadingConfig={configQuery.isLoading}
onRestart={() => restartMutation.mutate()}
isRestarting={restartMutation.isPending}
restartLabel="Restart Authelia"
successMessage={successMsg}
errorMessage={errorMsg}
/>
);
}

View File

@@ -0,0 +1,227 @@
import { useState } from 'react';
import { Card, CardContent } from '../ui/card';
import { Button } from '../ui/button';
import { Alert, AlertDescription } from '../ui/alert';
import { Badge } from '../ui/badge';
import { Loader2, CheckCircle, AlertCircle, RefreshCw, ShieldCheck, Clock, AlertTriangle } from 'lucide-react';
import { useCert } from '../../hooks/useCert';
function formatDate(iso?: string): string {
if (!iso) return '--';
const d = new Date(iso);
if (isNaN(d.getTime())) return '--';
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function daysLeftBadge(daysLeft?: number) {
if (daysLeft === undefined) return null;
if (daysLeft <= 7) {
return <Badge variant="destructive" className="gap-1 text-xs"><AlertTriangle className="h-3 w-3" />Expires in {daysLeft}d</Badge>;
}
if (daysLeft <= 30) {
return <Badge variant="secondary" className="gap-1 text-xs"><Clock className="h-3 w-3" />{daysLeft}d left</Badge>;
}
return <Badge variant="success" className="gap-1 text-xs"><CheckCircle className="h-3 w-3" />{daysLeft}d left</Badge>;
}
export function CertificatesSubsystem() {
const { status, isLoading, provision, isProvisioning, renew, isRenewing } = useCert();
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const certs = status?.certs ?? [];
const hasCerts = certs.some(c => c.cert.exists);
async function handleProvision(domain?: string) {
setMessage(null);
try {
const result = await provision(domain);
setMessage({ type: 'success', text: result.message });
setTimeout(() => setMessage(null), 5000);
} catch (err) {
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Provisioning failed' });
setTimeout(() => setMessage(null), 8000);
}
}
async function handleRenew() {
setMessage(null);
try {
const result = await renew();
setMessage({ type: 'success', text: result.message });
setTimeout(() => setMessage(null), 5000);
} catch (err) {
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Renewal failed' });
setTimeout(() => setMessage(null), 8000);
}
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<ShieldCheck className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">TLS Certificates</h2>
<p className="text-muted-foreground">Manage TLS certificates for domain routing</p>
</div>
</div>
{status && (
<Badge variant={status.canProvision ? 'success' : 'secondary'} className="gap-1">
{status.canProvision ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
{status.canProvision ? 'Auto-provision ready' : 'Manual only'}
</Badge>
)}
</div>
{/* Alerts */}
{message && (
<Alert variant={message.type === 'success' ? 'default' : 'error'}>
{message.type === 'success' ? <CheckCircle className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
<AlertDescription>{message.text}</AlertDescription>
</Alert>
)}
{!status?.canProvision && status && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{!status.hasToken && !status.hasEmail
? 'Configure a Cloudflare API token and operator email to enable automatic certificate provisioning.'
: !status.hasToken
? 'Configure a Cloudflare API token to enable automatic certificate provisioning.'
: 'Configure an operator email to enable automatic certificate provisioning.'}
</AlertDescription>
</Alert>
)}
{/* Status + Actions */}
<Card>
<CardContent className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm flex-1">
<div>
<span className="text-muted-foreground">Central Domain</span>
<div className="font-mono mt-0.5">{status?.domain ?? '--'}</div>
</div>
<div>
<span className="text-muted-foreground">Certificates</span>
<div className="mt-0.5">{certs.filter(c => c.cert.exists).length} active / {certs.length} domains</div>
</div>
<div>
<span className="text-muted-foreground">Cloudflare Token</span>
<div className="mt-0.5">{status?.hasToken ? 'Configured' : 'Not configured'}</div>
</div>
</div>
<div className="flex gap-2 ml-4 shrink-0">
{status?.canProvision && (
<Button
variant="outline"
size="sm"
className="gap-1"
onClick={() => handleProvision()}
disabled={isProvisioning || !status?.domain}
>
{isProvisioning ? <Loader2 className="h-3 w-3 animate-spin" /> : <ShieldCheck className="h-3 w-3" />}
Provision
</Button>
)}
{hasCerts && (
<Button
variant="outline"
size="sm"
className="gap-1"
onClick={handleRenew}
disabled={isRenewing}
>
{isRenewing ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
Renew All
</Button>
)}
</div>
</div>
</CardContent>
</Card>
{/* Certificates table */}
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-3">Domain Certificates</h3>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 text-primary animate-spin" />
</div>
) : certs.length === 0 ? (
<div className="text-center py-8">
<ShieldCheck className="h-10 w-10 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
No domains require TLS certificates. Register an HTTP domain to get started.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="pb-2 font-medium text-muted-foreground">Domain</th>
<th className="pb-2 font-medium text-muted-foreground">Source</th>
<th className="pb-2 font-medium text-muted-foreground">Expires</th>
<th className="pb-2 font-medium text-muted-foreground">Issuer</th>
<th className="pb-2 font-medium text-muted-foreground text-right">Status</th>
</tr>
</thead>
<tbody>
{certs.map((entry) => (
<tr key={entry.domain} className="border-b last:border-0">
<td className="py-2 font-mono text-xs">
{entry.domain}
{entry.cert.wildcard && <span className="text-muted-foreground ml-1">(wildcard)</span>}
</td>
<td className="py-2 text-xs text-muted-foreground">{entry.source}</td>
<td className="py-2 text-xs">
{entry.cert.exists ? formatDate(entry.cert.expiry) : '--'}
</td>
<td className="py-2 text-xs text-muted-foreground truncate max-w-[200px]" title={entry.cert.issuerCN}>
{entry.cert.issuerCN ?? '--'}
</td>
<td className="py-2 text-right">
{entry.cert.exists ? (
daysLeftBadge(entry.cert.daysLeft)
) : entry.coveredBy ? (
<Badge variant="secondary" className="gap-1 text-xs">
<CheckCircle className="h-3 w-3" />
{entry.coveredBy}
</Badge>
) : (
<span className="inline-flex items-center gap-2">
<Badge variant="destructive" className="gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
Missing
</Badge>
{status?.canProvision && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleProvision(entry.domain)}
disabled={isProvisioning}
>
{isProvisioning ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Provision'}
</Button>
)}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import { Card, CardContent } from '../ui/card';
import { Button } from '../ui/button';
import { Alert, AlertDescription } from '../ui/alert';
import { Badge } from '../ui/badge';
import { Loader2, CheckCircle, AlertCircle, RefreshCw, Globe } from 'lucide-react';
import { useDdns } from '../../hooks/useDdns';
import { useQuery } from '@tanstack/react-query';
import { ddnsConfigApi } from '../../services/api/settings';
function formatTime(iso?: string): string {
if (!iso) return '--';
const d = new Date(iso);
if (isNaN(d.getTime())) return '--';
return d.toLocaleString();
}
export function DdnsSubsystem() {
const { status, isLoadingStatus, trigger, isTriggering } = useDdns();
const configQuery = useQuery({
queryKey: ['ddns', 'config'],
queryFn: () => ddnsConfigApi.get(),
});
const config = configQuery.data;
const records = status?.records ?? [];
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Globe className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Dynamic DNS</h2>
<p className="text-muted-foreground">Cloudflare DNS records managed by Wild Central</p>
</div>
</div>
{status && (
<Badge variant={status.enabled ? 'success' : 'secondary'} className="gap-1">
{status.enabled ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
{status.enabled ? 'Enabled' : 'Disabled'}
</Badge>
)}
</div>
{/* Global error */}
{status?.lastError && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{status.lastError}</AlertDescription>
</Alert>
)}
{/* Status + Actions */}
<Card>
<CardContent className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm flex-1">
<div>
<span className="text-muted-foreground">Public IP</span>
<div className="font-mono mt-0.5">{status?.currentIP ?? '--'}</div>
</div>
<div>
<span className="text-muted-foreground">Provider</span>
<div className="font-mono mt-0.5">{config?.provider ?? '--'}</div>
</div>
<div>
<span className="text-muted-foreground">Last Checked</span>
<div className="font-mono mt-0.5 text-xs">{formatTime(status?.lastChecked)}</div>
</div>
<div>
<span className="text-muted-foreground">Last Updated</span>
<div className="font-mono mt-0.5 text-xs">{formatTime(status?.lastUpdated)}</div>
</div>
</div>
<Button
variant="outline"
size="sm"
className="gap-1 ml-4 shrink-0"
onClick={() => trigger()}
disabled={isTriggering || !status?.enabled}
>
{isTriggering ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
Sync Now
</Button>
</div>
{config?.intervalMinutes && (
<p className="text-xs text-muted-foreground">
Checks every {config.intervalMinutes} minute{config.intervalMinutes !== 1 ? 's' : ''}
</p>
)}
</CardContent>
</Card>
{/* Records table */}
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-3">Managed Records</h3>
{isLoadingStatus ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 text-primary animate-spin" />
</div>
) : records.length === 0 ? (
<div className="text-center py-8">
<Globe className="h-10 w-10 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{status?.enabled
? 'No public domains registered. Mark a domain as public to create a DDNS record.'
: 'DDNS is disabled. Enable it in the Domains settings to manage DNS records.'}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="pb-2 font-medium text-muted-foreground">Record</th>
<th className="pb-2 font-medium text-muted-foreground">IP</th>
<th className="pb-2 font-medium text-muted-foreground text-right">Status</th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.name} className="border-b last:border-0">
<td className="py-2 font-mono text-xs">{r.name}</td>
<td className="py-2 font-mono text-xs">{r.ip ?? '--'}</td>
<td className="py-2 text-right">
{r.ok ? (
<Badge variant="success" className="gap-1 text-xs">
<CheckCircle className="h-3 w-3" />
Synced
</Badge>
) : (
<span className="inline-flex items-center gap-1">
<Badge variant="destructive" className="gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
Error
</Badge>
{r.error && (
<span className="text-xs text-muted-foreground max-w-[200px] truncate" title={r.error}>
{r.error}
</span>
)}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -3,3 +3,6 @@ export { DnsmasqSubsystem } from './DnsmasqSubsystem';
export { NftablesSubsystem } from './NftablesSubsystem';
export { WireguardSubsystem } from './WireguardSubsystem';
export { CrowdsecSubsystem } from './CrowdsecSubsystem';
export { AutheliaSubsystem } from './AutheliaSubsystem';
export { DdnsSubsystem } from './DdnsSubsystem';
export { CertificatesSubsystem } from './CertificatesSubsystem';

View File

@@ -5,7 +5,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Trash2, CheckCircle, AlertCircle, Loader2, Plus, Undo2, Save } from 'lucide-react';
import { Trash2, CheckCircle, AlertCircle, Loader2, Plus, Undo2, Save, RotateCw, Pencil } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-mobile';
import { DomainTopology } from './DomainTopology';
@@ -107,28 +107,60 @@ function describeChanges(server: DomainDraft, draft: DomainDraft): string[] {
return changes;
}
/** Compact controls for mobile */
/** Compact controls for mobile — vertical pipeline: Source → Routing → Destination */
function MobileControls({
draft, onUpdateDraft, cert, onProvisionCert, isProvisioningCert,
draft, onUpdateDraft, domain, cert, ddnsRecord,
onProvisionCert, onRenewCert, isProvisioningCert, isRenewingCert,
}: {
draft: DomainDraft;
onUpdateDraft: (patch: Partial<DomainDraft>) => void;
domain: RegisteredDomain;
cert: CertEntry | undefined;
ddnsRecord: DdnsRecordStatus | undefined;
onProvisionCert: (d: string) => void;
onRenewCert: () => void;
isProvisioningCert: boolean;
isRenewingCert: boolean;
}) {
const isL7 = draft.mode === 'l7';
const isDnsOnly = draft.mode === 'dns-only';
const hasCert = !!cert?.cert.exists;
const daysLeft = cert?.cert.daysLeft ?? 0;
const hasRoutes = !!domain.routes?.length;
const [editingBackend, setEditingBackend] = useState(false);
const backendInputRef = useRef<HTMLInputElement>(null);
const handleBackendSave = () => {
const val = backendInputRef.current?.value.trim();
if (val) onUpdateDraft({ backendAddress: val });
setEditingBackend(false);
};
return (
<div className="space-y-3 py-2">
<div className="flex items-center justify-between">
<Label className="text-xs">Public (internet-accessible)</Label>
<Switch checked={draft.public} onCheckedChange={v => onUpdateDraft({ public: v })} />
<div className="py-2 space-y-3">
{/* === Section 1: Source === */}
<div className="space-y-2">
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">Source</div>
<div className="flex items-center justify-between">
<Label className="text-xs">Internet-accessible</Label>
<Switch checked={draft.public} onCheckedChange={v => onUpdateDraft({ public: v })} />
</div>
{draft.public && ddnsRecord && !ddnsRecord.ok && (
<div className="flex items-center gap-2 text-[10px] text-red-600 dark:text-red-400 bg-red-50/50 dark:bg-red-950/20 rounded-md px-3 py-1.5">
<AlertCircle className="h-3 w-3 shrink-0" />
DDNS sync failed{ddnsRecord.error ? `: ${ddnsRecord.error}` : ''}
</div>
)}
<div className="flex items-center justify-between">
<Label className="text-xs">Include subdomains</Label>
<Switch checked={draft.subdomains} onCheckedChange={v => onUpdateDraft({ subdomains: v })} />
</div>
</div>
<div>
<Label className="text-xs mb-1.5 block">Routing mode</Label>
{/* === Section 2: Routing === */}
<div className="border-t border-border/50 pt-3 space-y-2">
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">Routing</div>
<div className="flex gap-1 bg-muted rounded-md p-1">
{([
{ value: 'dns-only' as const, label: 'Direct' },
@@ -146,39 +178,111 @@ function MobileControls({
</button>
))}
</div>
{isDnsOnly && (
<div className="flex items-center gap-2 text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50/50 dark:bg-amber-950/20 rounded-md px-3 py-1.5">
<AlertCircle className="h-3 w-3 shrink-0" />
No firewall, proxy, or TLS protection traffic goes directly to your server
</div>
)}
{isL7 && (
<>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs">
{hasCert ? (
<>
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
<span>TLS: {(cert!.cert.wildcard || cert!.coveredBy) ? `*.${cert!.domain}` : cert!.domain}</span>
{cert!.cert.daysLeft != null && <span className="text-muted-foreground">({cert!.cert.daysLeft}d)</span>}
</>
) : (
<>
<AlertCircle className="h-3.5 w-3.5 text-red-500" />
<span>No TLS certificate</span>
</>
)}
</div>
{!hasCert && (
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
disabled={isProvisioningCert}
onClick={() => onProvisionCert(draft.subdomains ? `*.` : '')}
>
{isProvisioningCert ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
Provision
</Button>
)}
{hasCert && daysLeft < 30 && (
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
disabled={isRenewingCert}
onClick={() => onRenewCert()}
>
{isRenewingCert ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
Renew
</Button>
)}
</div>
{!hasCert && (
<div className="flex items-center gap-2 text-[10px] text-red-600 dark:text-red-400 bg-red-50/50 dark:bg-red-950/20 rounded-md px-3 py-1.5">
<AlertCircle className="h-3 w-3 shrink-0" />
L7 routing is inactive until a TLS certificate is provisioned
</div>
)}
</>
)}
</div>
{isL7 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs">
{hasCert ? (
<>
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
<span>TLS: {(cert!.cert.wildcard || cert!.coveredBy) ? `*.${cert!.domain}` : cert!.domain}</span>
{cert!.cert.daysLeft != null && <span className="text-muted-foreground">({cert!.cert.daysLeft}d)</span>}
</>
) : (
<>
<AlertCircle className="h-3.5 w-3.5 text-red-500" />
<span>No TLS certificate</span>
</>
{/* === Section 3: Destination === */}
<div className="border-t border-border/50 pt-3 space-y-2">
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">Destination</div>
{editingBackend ? (
<div className="space-y-1.5">
<input
ref={backendInputRef}
defaultValue={draft.backendAddress}
className="font-mono bg-muted rounded px-2 py-1.5 text-xs w-full border border-border focus:outline-none focus:ring-1 focus:ring-ring"
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') handleBackendSave();
if (e.key === 'Escape') setEditingBackend(false);
}}
/>
<div className="flex gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs flex-1" onClick={handleBackendSave}>Save</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs flex-1" onClick={() => setEditingBackend(false)}>Cancel</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<span className="font-mono text-xs bg-muted rounded px-2 py-1">
{hasRoutes ? `${domain.routes!.length} backends` : draft.backendAddress}
</span>
{!hasRoutes && (
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => setEditingBackend(true)}
>
<Pencil className="h-3 w-3" />
</Button>
)}
</div>
{!hasCert && (
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
disabled={isProvisioningCert}
onClick={() => onProvisionCert(draft.subdomains ? `*.` : '')}
>
{isProvisioningCert ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
Provision
</Button>
)}
</div>
)}
<div className="text-xs">
<span className="text-muted-foreground">Backend: </span>
<span className="font-mono">{draft.backendAddress}</span>
)}
{isL7 && domain.routes && domain.routes.length > 0 && (
<div className="bg-muted/30 rounded-md p-2 space-y-1">
<div className="text-[10px] text-muted-foreground font-medium mb-1">Route table</div>
{domain.routes.map((route, i) => (
<div key={i} className="flex items-center gap-2 text-[10px] flex-wrap">
<span className="font-mono text-muted-foreground truncate">
{route.paths?.length ? route.paths.join(', ') : '/*'}
</span>
<span className="text-muted-foreground">{'->'}</span>
<span className="font-mono">{route.backend.address}</span>
{route.ipAllow && route.ipAllow.length > 0 && (
<span className="text-muted-foreground flex items-center gap-0.5">
{route.ipAllow.length} IP rules
</span>
)}
</div>
))}
</div>
)}
</div>
</div>
);
@@ -362,9 +466,13 @@ export function DomainCard({
<MobileControls
draft={draft}
onUpdateDraft={updateDraft}
domain={domain}
cert={cert}
ddnsRecord={ddnsRecord}
onProvisionCert={d => onProvisionCert(draft.subdomains ? `*.${domain.domain}` : d || domain.domain)}
onRenewCert={onRenewCert}
isProvisioningCert={isProvisioningCert}
isRenewingCert={isRenewingCert}
/>
) : (
<DomainTopology
@@ -412,16 +520,18 @@ export function DomainCard({
</div>
)}
{/* === Footer: subdomains + source + deregister === */}
{/* === Footer: subdomains (desktop only) + source + deregister === */}
<div className="flex items-center justify-between pt-2 border-t border-border/50">
<div className="flex items-center gap-4">
<button
type="button"
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => updateDraft({ subdomains: !draft.subdomains })}
>
{draft.subdomains ? '*.wildcard on' : 'wildcard off'}
</button>
{!isMobile && (
<button
type="button"
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => updateDraft({ subdomains: !draft.subdomains })}
>
{draft.subdomains ? '*.wildcard on' : 'wildcard off'}
</button>
)}
<span className="text-[10px] text-muted-foreground">
Source: <span className="font-mono">{domain.source}</span>

View File

@@ -1,5 +1,4 @@
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
import { Link } from 'react-router';
import {
ReactFlow, Handle, Position, applyNodeChanges,
type Node, type Edge, type NodeProps, type NodeChange, type ReactFlowInstance,
@@ -169,23 +168,17 @@ function FlowNode({ data }: NodeProps) {
<>
{leftHandle}
<div className="flex flex-col gap-1">
<Link to="/central/firewall" className="group nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
<TopologyNode
label="Firewall"
status={fwOk ? 'ok' : 'na'}
icon={<Shield className="h-3 w-3" />}
className="group-hover:border-primary/40"
/>
</Link>
<Link to="/central/crowdsec" className="group nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
<TopologyNode
label="CrowdSec"
sublabel={isL4 ? 'IP only' : 'HTTP + IP'}
status={csOk ? 'ok' : 'na'}
icon={<ShieldAlert className="h-3 w-3" />}
className="group-hover:border-primary/40"
/>
</Link>
<TopologyNode
label="Firewall"
status={fwOk ? 'ok' : 'na'}
icon={<Shield className="h-3 w-3" />}
/>
<TopologyNode
label="CrowdSec"
sublabel={isL4 ? 'IP only' : 'HTTP + IP'}
status={csOk ? 'ok' : 'na'}
icon={<ShieldAlert className="h-3 w-3" />}
/>
</div>
{rightHandle}
</>
@@ -234,7 +227,6 @@ function FlowNode({ data }: NodeProps) {
if (v === 'tls') {
const status = data.status as NodeStatus;
const certDomain = data.certDomain as string | undefined;
const isWildcard = data.isWildcardCert as boolean;
const daysLeft = data.daysLeft as number;
const hasCert = data.hasCert as boolean;
const provisionDomain = data.provisionDomain as string;
@@ -317,15 +309,12 @@ function FlowNode({ data }: NodeProps) {
return (
<>
{leftHandle}
<Link to="/central/vpn" className="nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
<TopologyNode
label="VPN"
sublabel={running ? `${peerCount} peer${peerCount !== 1 ? 's' : ''}` : 'Not running'}
status={running ? 'ok' : 'na'}
icon={<Lock className="h-3.5 w-3.5" />}
className="hover:border-primary/40"
/>
</Link>
<TopologyNode
label="VPN"
sublabel={running ? `${peerCount} peer${peerCount !== 1 ? 's' : ''}` : 'Not running'}
status={running ? 'ok' : 'na'}
icon={<Lock className="h-3.5 w-3.5" />}
/>
{rightHandle}
</>
);
@@ -336,16 +325,13 @@ function FlowNode({ data }: NodeProps) {
return (
<>
{leftHandle}
<Link to="/central" className="nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
<TopologyNode
label="Router"
sublabel={ports}
status="na"
icon={<Router className="h-3 w-3" />}
className="hover:border-primary/40"
tooltip="Your LAN router port-forwards these ports to Wild Central. See Dashboard for setup."
/>
</Link>
<TopologyNode
label="Router"
sublabel={ports}
status="na"
icon={<Router className="h-3 w-3" />}
tooltip="Your LAN router port-forwards these ports to Wild Central"
/>
{rightHandle}
</>
);

View File

@@ -0,0 +1,90 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { autheliaApi } from '../services/api/authelia';
import type { CreateUserRequest, UpdateUserRequest, CreateOIDCClientRequest, UpdateOIDCClientRequest } from '../services/api/authelia';
export function useAuthelia() {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ['authelia', 'status'],
queryFn: () => autheliaApi.getStatus(),
staleTime: 10000,
});
const usersQuery = useQuery({
queryKey: ['authelia', 'users'],
queryFn: () => autheliaApi.listUsers(),
enabled: !!statusQuery.data?.enabled,
});
const oidcClientsQuery = useQuery({
queryKey: ['authelia', 'oidc', 'clients'],
queryFn: () => autheliaApi.listOIDCClients(),
enabled: !!statusQuery.data?.active,
});
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['authelia'] });
queryClient.invalidateQueries({ queryKey: ['central', 'status'] });
};
const updateConfigMutation = useMutation({
mutationFn: (data: { enabled?: boolean; domain?: string; defaultPolicy?: string; smtpHost?: string; smtpPort?: number; smtpUsername?: string; smtpSender?: string; smtpPassword?: string }) =>
autheliaApi.updateConfig(data),
onSuccess: invalidateAll,
});
const restartMutation = useMutation({
mutationFn: () => autheliaApi.restart(),
onSuccess: invalidateAll,
});
const createUserMutation = useMutation({
mutationFn: (req: CreateUserRequest) => autheliaApi.createUser(req),
onSuccess: invalidateAll,
});
const updateUserMutation = useMutation({
mutationFn: ({ username, ...req }: UpdateUserRequest & { username: string }) =>
autheliaApi.updateUser(username, req),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'users'] }),
});
const deleteUserMutation = useMutation({
mutationFn: (username: string) => autheliaApi.deleteUser(username),
onSuccess: invalidateAll,
});
const createOIDCClientMutation = useMutation({
mutationFn: (req: CreateOIDCClientRequest) => autheliaApi.createOIDCClient(req),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
});
const updateOIDCClientMutation = useMutation({
mutationFn: ({ clientId, ...req }: UpdateOIDCClientRequest & { clientId: string }) =>
autheliaApi.updateOIDCClient(clientId, req),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
});
const deleteOIDCClientMutation = useMutation({
mutationFn: (clientId: string) => autheliaApi.deleteOIDCClient(clientId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
});
return {
status: statusQuery.data,
isLoadingStatus: statusQuery.isLoading,
users: usersQuery.data?.users ?? [],
isLoadingUsers: usersQuery.isLoading,
oidcClients: oidcClientsQuery.data?.clients ?? [],
isLoadingClients: oidcClientsQuery.isLoading,
updateConfig: updateConfigMutation,
restart: restartMutation,
createUser: createUserMutation,
updateUser: updateUserMutation,
deleteUser: deleteUserMutation,
createOIDCClient: createOIDCClientMutation,
updateOIDCClient: updateOIDCClientMutation,
deleteOIDCClient: deleteOIDCClientMutation,
};
}

View File

@@ -3,6 +3,7 @@ import { apiClient } from '../services/api/client';
interface DaemonStatus {
active: boolean;
version?: string;
}
interface CentralStatus {

View File

@@ -0,0 +1,101 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { dnsFilterApi, type CustomEntry } from '../services/api/dnsfilter';
export function useDnsFilter() {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ['dns-filter', 'status'],
queryFn: () => dnsFilterApi.getStatus(),
});
const configQuery = useQuery({
queryKey: ['dns-filter', 'config'],
queryFn: () => dnsFilterApi.getConfig(),
});
const listsQuery = useQuery({
queryKey: ['dns-filter', 'lists'],
queryFn: () => dnsFilterApi.getLists(),
});
const customQuery = useQuery({
queryKey: ['dns-filter', 'custom'],
queryFn: () => dnsFilterApi.getCustomEntries(),
});
const suggestedQuery = useQuery({
queryKey: ['dns-filter', 'suggested'],
queryFn: () => dnsFilterApi.getSuggested(),
staleTime: Infinity,
});
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['dns-filter'] });
};
const updateConfigMutation = useMutation({
mutationFn: dnsFilterApi.updateConfig,
onSuccess: invalidateAll,
});
const addListMutation = useMutation({
mutationFn: (data: { url: string; name: string }) => dnsFilterApi.addList(data),
onSuccess: invalidateAll,
});
const removeListMutation = useMutation({
mutationFn: (id: string) => dnsFilterApi.removeList(id),
onSuccess: invalidateAll,
});
const toggleListMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => dnsFilterApi.toggleList(id, enabled),
onSuccess: invalidateAll,
});
const addCustomMutation = useMutation({
mutationFn: (entry: CustomEntry) => dnsFilterApi.setCustomEntry(entry),
onSuccess: invalidateAll,
});
const removeCustomMutation = useMutation({
mutationFn: (domain: string) => dnsFilterApi.removeCustomEntry(domain),
onSuccess: invalidateAll,
});
const triggerUpdateMutation = useMutation({
mutationFn: () => dnsFilterApi.triggerUpdate(),
onSuccess: () => {
// Delay invalidation to give the runner time to download + compile
setTimeout(invalidateAll, 3000);
},
});
const uploadListMutation = useMutation({
mutationFn: ({ name, file }: { name: string; file: File }) => dnsFilterApi.uploadList(name, file),
onSuccess: invalidateAll,
});
return {
status: statusQuery.data,
isLoadingStatus: statusQuery.isLoading,
config: configQuery.data,
lists: listsQuery.data?.lists ?? [],
customEntries: customQuery.data?.customEntries ?? [],
suggestedLists: suggestedQuery.data?.lists ?? [],
updateConfig: updateConfigMutation.mutateAsync,
isUpdatingConfig: updateConfigMutation.isPending,
addList: addListMutation.mutateAsync,
isAddingList: addListMutation.isPending,
removeList: removeListMutation.mutateAsync,
toggleList: toggleListMutation.mutateAsync,
addCustomEntry: addCustomMutation.mutateAsync,
removeCustomEntry: removeCustomMutation.mutateAsync,
triggerUpdate: triggerUpdateMutation.mutateAsync,
isUpdating: triggerUpdateMutation.isPending,
uploadList: uploadListMutation.mutateAsync,
isUploading: uploadListMutation.isPending,
refetch: invalidateAll,
};
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Outlet } from 'react-router';
import { AppSidebar } from '../components/AppSidebar';
import { AuthGate } from '../components/AuthGate';
import { SidebarProvider, SidebarInset, SidebarTrigger } from '../components/ui/sidebar';
import { HelpProvider, useHelp } from '../contexts/HelpContext';
import { HelpPanel } from '../components/HelpPanel';
@@ -46,16 +47,18 @@ export function CentralLayout() {
);
return (
<HelpProvider>
<SidebarProvider
open={sidebarOpen}
onOpenChange={(open) => {
setSidebarOpen(open);
localStorage.setItem('sidebar_state', String(open));
}}
>
<CentralLayoutContent />
</SidebarProvider>
</HelpProvider>
<AuthGate>
<HelpProvider>
<SidebarProvider
open={sidebarOpen}
onOpenChange={(open) => {
setSidebarOpen(open);
localStorage.setItem('sidebar_state', String(open));
}}
>
<CentralLayoutContent />
</SidebarProvider>
</HelpProvider>
</AuthGate>
);
}

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More