Compare commits
20 Commits
1e7d93256e
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c85d27a5d1 | ||
|
|
a98557cb55 | ||
|
|
0437494715 | ||
|
|
e8b6449df3 | ||
|
|
245d260a2b | ||
|
|
282d255c0a | ||
|
|
519df610e8 | ||
|
|
93700d7956 | ||
|
|
6f85d25362 | ||
|
|
f5a030fd44 | ||
|
|
60f3ca4a3a | ||
|
|
4500a1a45e | ||
|
|
8dd9e117bc | ||
|
|
947991da7f | ||
|
|
a85bfbfe9e | ||
|
|
2bb162a794 | ||
|
|
a0ab8faa1c | ||
|
|
88acd437a1 | ||
|
|
3172e56288 | ||
|
|
428d47f876 |
7
.envrc
7
.envrc
@@ -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
8
.envrc.example
Normal 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
1
.gitignore
vendored
@@ -4,3 +4,4 @@ coverage.out
|
||||
coverage.html
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.envrc
|
||||
|
||||
8
dist/Makefile
vendored
8
dist/Makefile
vendored
@@ -65,7 +65,7 @@ help:
|
||||
@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 " Run: source .envrc"
|
||||
@echo " These must be set in your environment"
|
||||
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
@@ -119,7 +119,7 @@ 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 " Run: source .envrc"; \
|
||||
echo " These must be set in your environment"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@./scripts/deploy-apt-repository.sh
|
||||
@@ -129,7 +129,7 @@ release: $(ARM64_DEB) $(AMD64_DEB)
|
||||
@if [ -z "$(GITEA_TOKEN)" ]; then \
|
||||
echo "Error: GITEA_TOKEN environment variable not set"; \
|
||||
echo " Get a token from $(GITEA_URL)/user/settings/applications"; \
|
||||
echo " Or run: source .envrc"; \
|
||||
echo " These must be set in your environment"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if git tag | grep -q "^$(RELEASE_TAG)$$"; then \
|
||||
@@ -150,7 +150,7 @@ 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 " Or run: source .envrc"; \
|
||||
echo " These must be set in your environment"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Force-pushing edge tag to HEAD..."
|
||||
|
||||
2
dist/README.md
vendored
2
dist/README.md
vendored
@@ -71,6 +71,6 @@ dist/bin/ Standalone binaries
|
||||
dist/packages/ .deb packages
|
||||
```
|
||||
|
||||
Environment variables (see `.envrc`):
|
||||
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
|
||||
|
||||
44
dist/debian/DEBIAN/postinst
vendored
44
dist/debian/DEBIAN/postinst
vendored
@@ -28,16 +28,16 @@ case "$1" in
|
||||
chgrp wildcloud /etc/dnsmasq.d
|
||||
chmod 775 /etc/dnsmasq.d
|
||||
|
||||
# Create or fix ownership of wild-cloud.conf
|
||||
if [ ! -f /etc/dnsmasq.d/wild-cloud.conf ]; then
|
||||
touch /etc/dnsmasq.d/wild-cloud.conf
|
||||
echo "Created /etc/dnsmasq.d/wild-cloud.conf"
|
||||
# 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-cloud.conf - updating ownership"
|
||||
echo "Found existing /etc/dnsmasq.d/wild-central.conf - updating ownership"
|
||||
fi
|
||||
|
||||
chown wildcloud:wildcloud /etc/dnsmasq.d/wild-cloud.conf
|
||||
chmod 644 /etc/dnsmasq.d/wild-cloud.conf
|
||||
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
|
||||
@@ -47,14 +47,14 @@ case "$1" in
|
||||
# Set up systemd-resolved configuration directory and file
|
||||
mkdir -p /etc/systemd/resolved.conf.d
|
||||
|
||||
if [ ! -f /etc/systemd/resolved.conf.d/wild-cloud.conf ]; then
|
||||
touch /etc/systemd/resolved.conf.d/wild-cloud.conf
|
||||
echo "Created /etc/systemd/resolved.conf.d/wild-cloud.conf"
|
||||
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-cloud.conf"
|
||||
echo "Found existing /etc/systemd/resolved.conf.d/wild-central.conf"
|
||||
fi
|
||||
chown wildcloud:wildcloud /etc/systemd/resolved.conf.d/wild-cloud.conf
|
||||
chmod 644 /etc/systemd/resolved.conf.d/wild-cloud.conf
|
||||
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
|
||||
@@ -101,16 +101,16 @@ case "$1" in
|
||||
|
||||
# Set up nftables configuration directory
|
||||
mkdir -p /etc/nftables.d
|
||||
if [ ! -f /etc/nftables.d/wild-cloud.nft ]; then
|
||||
touch /etc/nftables.d/wild-cloud.nft
|
||||
echo "Created /etc/nftables.d/wild-cloud.nft"
|
||||
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-cloud.nft
|
||||
chmod 644 /etc/nftables.d/wild-cloud.nft
|
||||
chown wildcloud:wildcloud /etc/nftables.d/wild-central.nft
|
||||
chmod 644 /etc/nftables.d/wild-central.nft
|
||||
|
||||
# Ensure nftables.conf includes wild-cloud rules
|
||||
if [ -f /etc/nftables.conf ] && ! grep -q 'wild-cloud.nft' /etc/nftables.conf; then
|
||||
echo 'include "/etc/nftables.d/wild-cloud.nft"' >> /etc/nftables.conf
|
||||
# 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"
|
||||
@@ -192,7 +192,7 @@ AUTHELIA_EOF
|
||||
|
||||
# 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-cloud, /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
|
||||
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"
|
||||
|
||||
8
dist/debian/DEBIAN/postrm
vendored
8
dist/debian/DEBIAN/postrm
vendored
@@ -18,14 +18,14 @@ case "$1" in
|
||||
fi
|
||||
|
||||
# Remove dnsmasq configuration
|
||||
if [ -f /etc/dnsmasq.d/wild-cloud.conf ]; then
|
||||
rm -f /etc/dnsmasq.d/wild-cloud.conf
|
||||
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-cloud.conf ]; then
|
||||
rm -f /etc/systemd/resolved.conf.d/wild-cloud.conf
|
||||
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
|
||||
|
||||
@@ -5,5 +5,5 @@ After=network.target nftables.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/sbin/nft -f /etc/nftables.d/wild-cloud.nft
|
||||
ExecStart=/usr/sbin/nft -f /etc/nftables.d/wild-central.nft
|
||||
RemainAfterExit=no
|
||||
|
||||
@@ -24,43 +24,45 @@ import (
|
||||
"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
|
||||
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)
|
||||
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()
|
||||
@@ -70,15 +72,15 @@ 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")),
|
||||
authelia: authelia.NewManager(filepath.Join(dataDir, "authelia")),
|
||||
dnsFilter: dnsfilter.NewManager(filepath.Join(dataDir, "dns-filter")),
|
||||
domains: domains.NewManager(dataDir),
|
||||
sseManager: sseManager,
|
||||
}
|
||||
@@ -89,9 +91,24 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
|
||||
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
|
||||
}
|
||||
@@ -127,7 +144,7 @@ func (api *API) StartDDNS(ctx gocontext.Context) {
|
||||
func (api *API) StartDNSFilter(ctx gocontext.Context) {
|
||||
api.dnsFilterRunner = dnsfilter.NewRunner(api.dnsFilter, func() {
|
||||
if err := api.dnsmasq.ReloadService(); err != nil {
|
||||
slog.Warn("dns-filter: failed to reload dnsmasq after update", "error", err)
|
||||
slog.Warn("failed to reload dnsmasq after update", "component", "dns-filter", "error", err)
|
||||
}
|
||||
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter lists updated")
|
||||
})
|
||||
@@ -200,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")
|
||||
@@ -259,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")
|
||||
@@ -275,19 +341,20 @@ 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")
|
||||
@@ -345,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)
|
||||
}
|
||||
|
||||
@@ -399,8 +462,8 @@ func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime
|
||||
// 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
|
||||
unit string
|
||||
verCmd []string // command to get version
|
||||
verParse func(string) string // extract version from output
|
||||
}
|
||||
|
||||
@@ -477,8 +540,8 @@ func getDaemonStatus() map[string]map[string]any {
|
||||
result[name] = entry
|
||||
}
|
||||
|
||||
// nftables has no persistent service — check if the wild-cloud table exists
|
||||
nftErr := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run()
|
||||
// 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)"
|
||||
@@ -489,6 +552,20 @@ func getDaemonStatus() map[string]map[string]any {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -114,91 +114,73 @@ func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
_ = api.secrets.SetSecret("authelia.smtpPassword", *req.SMTPPassword)
|
||||
}
|
||||
|
||||
// Enabling Authelia
|
||||
if state.Cloud.Authelia.Enabled {
|
||||
if !api.authelia.IsInstalled() {
|
||||
respondError(w, http.StatusBadRequest, "Authelia is not installed. Install it first (apt install authelia).")
|
||||
return
|
||||
}
|
||||
if state.Cloud.Authelia.Domain == "" {
|
||||
respondError(w, http.StatusBadRequest, "Auth portal domain is required when enabling Authelia")
|
||||
return
|
||||
}
|
||||
if !api.authelia.HasLuaScript() {
|
||||
respondError(w, http.StatusBadRequest,
|
||||
"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")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
if err := api.authelia.EnsureDataDir(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create data directory: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-generate secrets if missing
|
||||
if err := api.ensureAutheliaSecrets(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate secrets: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWKS key pair for OIDC
|
||||
if err := api.authelia.EnsureJWKS(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate JWKS: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure user database exists
|
||||
if err := api.authelia.EnsureUsersDB(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to initialize user database: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Generate config
|
||||
if err := api.generateAutheliaConfig(state); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate config: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Register auth portal domain
|
||||
api.ensureAuthDomain(state.Cloud.Authelia.Domain)
|
||||
|
||||
// Start service — requires at least one user
|
||||
startErr := error(nil)
|
||||
if api.authelia.UserCount() > 0 {
|
||||
startErr = api.authelia.RestartService()
|
||||
}
|
||||
|
||||
// Save state and reconcile regardless of whether the service started
|
||||
if err := config.SaveState(state, api.statePath()); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
||||
return
|
||||
}
|
||||
go api.reconcileNetworking()
|
||||
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
|
||||
|
||||
if startErr != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Configuration saved but Authelia failed to start: %v", startErr))
|
||||
if err := api.enableAuthelia(state); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Disabling — stop service and deregister domain
|
||||
_ = api.authelia.StopService()
|
||||
api.deregisterAuthDomain()
|
||||
|
||||
if err := config.SaveState(state, api.statePath()); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
||||
return
|
||||
}
|
||||
go api.reconcileNetworking()
|
||||
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -83,17 +83,17 @@ func (api *API) DNSFilterUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
api.dnsFilterRunner.Start(api.ctx, interval)
|
||||
|
||||
slog.Info("dns-filter: enabled", "interval", 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("dns-filter: disabled")
|
||||
slog.Info("disabled", "component", "dns-filter")
|
||||
}
|
||||
|
||||
// Reconcile to add/remove addn-hosts from dnsmasq config
|
||||
go api.reconcileNetworking()
|
||||
go api.reconciler.Reconcile()
|
||||
|
||||
api.broadcastDNSFilterEvent("dns-filter:config", "DNS filter configuration updated")
|
||||
respondMessage(w, http.StatusOK, "DNS filter configuration updated")
|
||||
@@ -162,11 +162,11 @@ func (api *API) DNSFilterRemoveList(w http.ResponseWriter, r *http.Request) {
|
||||
// Recompile without the removed list
|
||||
go func() {
|
||||
if _, err := api.dnsFilter.Compile(); err != nil {
|
||||
slog.Warn("dns-filter: recompile after removal failed", "error", err)
|
||||
slog.Warn("recompile after removal failed", "component", "dns-filter", "error", err)
|
||||
return
|
||||
}
|
||||
if err := api.dnsmasq.ReloadService(); err != nil {
|
||||
slog.Warn("dns-filter: reload after removal failed", "error", err)
|
||||
slog.Warn("reload after removal failed", "component", "dns-filter", "error", err)
|
||||
}
|
||||
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter lists updated")
|
||||
}()
|
||||
@@ -194,11 +194,11 @@ func (api *API) DNSFilterToggleList(w http.ResponseWriter, r *http.Request) {
|
||||
// Recompile with updated list status
|
||||
go func() {
|
||||
if _, err := api.dnsFilter.Compile(); err != nil {
|
||||
slog.Warn("dns-filter: recompile after toggle failed", "error", err)
|
||||
slog.Warn("recompile after toggle failed", "component", "dns-filter", "error", err)
|
||||
return
|
||||
}
|
||||
if err := api.dnsmasq.ReloadService(); err != nil {
|
||||
slog.Warn("dns-filter: reload after toggle failed", "error", err)
|
||||
slog.Warn("reload after toggle failed", "component", "dns-filter", "error", err)
|
||||
}
|
||||
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter lists updated")
|
||||
}()
|
||||
@@ -240,11 +240,11 @@ func (api *API) DNSFilterSetCustomEntry(w http.ResponseWriter, r *http.Request)
|
||||
// Recompile with updated custom entries
|
||||
go func() {
|
||||
if _, err := api.dnsFilter.Compile(); err != nil {
|
||||
slog.Warn("dns-filter: recompile after custom entry failed", "error", err)
|
||||
slog.Warn("recompile after custom entry failed", "component", "dns-filter", "error", err)
|
||||
return
|
||||
}
|
||||
if err := api.dnsmasq.ReloadService(); err != nil {
|
||||
slog.Warn("dns-filter: reload after custom entry failed", "error", err)
|
||||
slog.Warn("reload after custom entry failed", "component", "dns-filter", "error", err)
|
||||
}
|
||||
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter custom entry updated")
|
||||
}()
|
||||
@@ -268,11 +268,11 @@ func (api *API) DNSFilterRemoveCustomEntry(w http.ResponseWriter, r *http.Reques
|
||||
// Recompile
|
||||
go func() {
|
||||
if _, err := api.dnsFilter.Compile(); err != nil {
|
||||
slog.Warn("dns-filter: recompile after custom removal failed", "error", err)
|
||||
slog.Warn("recompile after custom removal failed", "component", "dns-filter", "error", err)
|
||||
return
|
||||
}
|
||||
if err := api.dnsmasq.ReloadService(); err != nil {
|
||||
slog.Warn("dns-filter: reload after custom removal failed", "error", err)
|
||||
slog.Warn("reload after custom removal failed", "component", "dns-filter", "error", err)
|
||||
}
|
||||
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter custom entry removed")
|
||||
}()
|
||||
@@ -328,11 +328,11 @@ func (api *API) DNSFilterUploadList(w http.ResponseWriter, r *http.Request) {
|
||||
// Recompile with new list
|
||||
go func() {
|
||||
if _, err := api.dnsFilter.Compile(); err != nil {
|
||||
slog.Warn("dns-filter: recompile after upload failed", "error", err)
|
||||
slog.Warn("recompile after upload failed", "component", "dns-filter", "error", err)
|
||||
return
|
||||
}
|
||||
if err := api.dnsmasq.ReloadService(); err != nil {
|
||||
slog.Warn("dns-filter: reload after upload failed", "error", err)
|
||||
slog.Warn("reload after upload failed", "component", "dns-filter", "error", err)
|
||||
}
|
||||
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter list uploaded")
|
||||
}()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,21 +2,10 @@ package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
// EnsureCentralDomain registers (or updates) Central's own domain as a domain
|
||||
@@ -62,290 +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() {
|
||||
rb := haproxy.HTTPRouteBackend{
|
||||
Paths: r.Paths,
|
||||
Backend: r.Backend.Address,
|
||||
HealthPath: r.Backend.Health,
|
||||
Headers: r.Headers,
|
||||
IPAllow: r.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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
// Propagate Authelia forward-auth settings if enabled
|
||||
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)
|
||||
|
||||
// Regenerate Authelia config so session cookie domains stay in sync
|
||||
// with the set of auth-protected domains
|
||||
if err := api.generateAutheliaConfig(globalCfg); err != nil {
|
||||
slog.Warn("reconcile: failed to regenerate authelia config", "error", err)
|
||||
} else if api.authelia.UserCount() > 0 {
|
||||
_ = api.authelia.RestartService()
|
||||
}
|
||||
}
|
||||
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,
|
||||
Wildcard: dom.Subdomains,
|
||||
})
|
||||
}
|
||||
|
||||
// Set addn-hosts path for DNS filtering
|
||||
if globalCfg.Cloud.DNSFilter.Enabled {
|
||||
hostsPath := api.dnsFilter.HostsFilePath()
|
||||
if storage.FileExists(hostsPath) {
|
||||
api.dnsmasq.SetFilterConfPath(hostsPath)
|
||||
}
|
||||
} else {
|
||||
api.dnsmasq.SetFilterConfPath("")
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ import (
|
||||
|
||||
// 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)
|
||||
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
|
||||
@@ -32,8 +32,28 @@ type ConfigOpts struct {
|
||||
SMTPPassword string // SMTP password (from secrets)
|
||||
}
|
||||
|
||||
// GenerateConfig builds Authelia's configuration.yml and writes it atomically.
|
||||
// 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
|
||||
}
|
||||
@@ -116,11 +136,11 @@ func (m *Manager) buildConfig(opts ConfigOpts) map[string]any {
|
||||
"file": map[string]any{
|
||||
"path": m.usersDBPath(),
|
||||
"password": map[string]any{
|
||||
"algorithm": "argon2id",
|
||||
"iterations": 3,
|
||||
"memory": 65536,
|
||||
"algorithm": "argon2id",
|
||||
"iterations": 3,
|
||||
"memory": 65536,
|
||||
"parallelism": 2,
|
||||
"key_length": 32,
|
||||
"key_length": 32,
|
||||
"salt_length": 16,
|
||||
},
|
||||
},
|
||||
@@ -185,7 +205,7 @@ func (m *Manager) buildConfig(opts ConfigOpts) map[string]any {
|
||||
"client_secret": c.Secret, // already hashed
|
||||
"redirect_uris": c.RedirectURIs,
|
||||
"scopes": c.Scopes,
|
||||
"authorization_policy": c.Policy,
|
||||
"authorization_policy": c.Policy,
|
||||
}
|
||||
if c.ConsentMode != "" {
|
||||
client["consent_mode"] = c.ConsentMode
|
||||
|
||||
@@ -11,13 +11,13 @@ import (
|
||||
|
||||
// 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
|
||||
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"`
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -116,8 +119,8 @@ type State struct {
|
||||
} `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
|
||||
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
|
||||
@@ -132,24 +135,64 @@ type State struct {
|
||||
} `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)
|
||||
}
|
||||
@@ -159,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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,114 +255,72 @@ 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
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Result []struct {
|
||||
ID string `json:"id"`
|
||||
@@ -368,22 +335,14 @@ 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
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Result []cloudflareRecord `json:"result"`
|
||||
}
|
||||
@@ -396,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
|
||||
}
|
||||
|
||||
@@ -44,11 +44,11 @@ type FilterData struct {
|
||||
|
||||
// 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ func (m *Manager) UpdateLists(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("dns-filter: downloading list", "name", bl.Name, "url", bl.URL)
|
||||
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 {
|
||||
@@ -329,7 +329,7 @@ func (m *Manager) UpdateLists(ctx context.Context) error {
|
||||
if err != nil {
|
||||
bl.LastError = fmt.Sprintf("download failed: %v", err)
|
||||
changed = true
|
||||
slog.Warn("dns-filter: download failed", "name", bl.Name, "error", err)
|
||||
slog.Warn("download failed", "component", "dns-filter", "name", bl.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -337,7 +337,7 @@ func (m *Manager) UpdateLists(ctx context.Context) error {
|
||||
resp.Body.Close()
|
||||
bl.LastError = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
changed = true
|
||||
slog.Warn("dns-filter: download failed", "name", bl.Name, "status", resp.StatusCode)
|
||||
slog.Warn("download failed", "component", "dns-filter", "name", bl.Name, "status", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -393,7 +393,7 @@ func (m *Manager) Compile() (int, error) {
|
||||
|
||||
domains, err := ParseFile(cachePath)
|
||||
if err != nil {
|
||||
slog.Warn("dns-filter: failed to parse list", "name", bl.Name, "error", err)
|
||||
slog.Warn("failed to parse list", "component", "dns-filter", "name", bl.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -446,7 +446,7 @@ func (m *Manager) Compile() (int, error) {
|
||||
// Update entry counts and save
|
||||
_ = m.SaveFilterData(fd)
|
||||
|
||||
slog.Info("dns-filter: compiled blocklist", "domains", len(sorted))
|
||||
slog.Info("compiled blocklist", "component", "dns-filter", "domains", len(sorted))
|
||||
return len(sorted), nil
|
||||
}
|
||||
|
||||
@@ -496,7 +496,7 @@ func (m *Manager) GetQueryStats() *QueryStats {
|
||||
cmd := exec.Command("sudo", "journalctl", "-u", "dnsmasq", "--since", "24 hours ago", "--no-pager", "-o", "cat")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
slog.Debug("dns-filter: failed to read dnsmasq journal", "error", err)
|
||||
slog.Debug("failed to read dnsmasq journal", "component", "dns-filter", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
304
internal/dnsfilter/manager_test.go
Normal file
304
internal/dnsfilter/manager_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func (r *Runner) Trigger() {
|
||||
func (r *Runner) run(ctx context.Context, intervalHours int) {
|
||||
interval := time.Duration(intervalHours) * time.Hour
|
||||
|
||||
slog.Info("dns-filter: runner started", "interval", interval)
|
||||
slog.Info("runner started", "component", "dns-filter", "interval", interval)
|
||||
|
||||
// Immediate update on start
|
||||
r.updateAndCompile(ctx)
|
||||
@@ -89,7 +89,7 @@ func (r *Runner) run(ctx context.Context, intervalHours int) {
|
||||
r.mu.Lock()
|
||||
r.running = false
|
||||
r.mu.Unlock()
|
||||
slog.Info("dns-filter: runner stopped")
|
||||
slog.Info("runner stopped", "component", "dns-filter")
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.updateAndCompile(ctx)
|
||||
@@ -101,11 +101,11 @@ func (r *Runner) run(ctx context.Context, intervalHours int) {
|
||||
|
||||
func (r *Runner) updateAndCompile(ctx context.Context) {
|
||||
if err := r.manager.UpdateLists(ctx); err != nil {
|
||||
slog.Error("dns-filter: update failed", "error", err)
|
||||
slog.Error("update failed", "component", "dns-filter", "error", err)
|
||||
}
|
||||
|
||||
if _, err := r.manager.Compile(); err != nil {
|
||||
slog.Error("dns-filter: compile failed", "error", err)
|
||||
slog.Error("compile failed", "component", "dns-filter", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.
|
||||
@@ -24,36 +25,36 @@ type DNSEntry struct {
|
||||
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 *ConfigGenerator) SetFilterConfPath(path string) {
|
||||
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.
|
||||
@@ -123,7 +124,7 @@ log-dhcp
|
||||
// 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 *ConfigGenerator) RestartService() error {
|
||||
func (g *Manager) RestartService() error {
|
||||
cmd := exec.Command("systemctl", "restart", "dnsmasq.service")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -133,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 {
|
||||
@@ -152,7 +153,7 @@ func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) {
|
||||
dnsIP = ""
|
||||
}
|
||||
|
||||
status := &ServiceStatus{
|
||||
status := &Status{
|
||||
ConfigFile: g.configPath,
|
||||
IP: dnsIP,
|
||||
}
|
||||
@@ -205,7 +206,7 @@ func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -214,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)
|
||||
}
|
||||
|
||||
@@ -235,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)
|
||||
@@ -299,8 +300,64 @@ log-dhcp
|
||||
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 {
|
||||
@@ -314,13 +371,13 @@ func (g *ConfigGenerator) ValidateConfig(configPath string) error {
|
||||
|
||||
// ReloadService restarts dnsmasq to apply configuration changes.
|
||||
// Uses a full restart because SIGHUP does not re-read host-record= directives.
|
||||
func (g *ConfigGenerator) ReloadService() error {
|
||||
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 {
|
||||
@@ -331,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 {
|
||||
|
||||
@@ -18,7 +18,7 @@ func wildcardEntry(domain, ip string) DNSEntry {
|
||||
|
||||
// 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"),
|
||||
@@ -39,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{})
|
||||
|
||||
@@ -56,7 +56,7 @@ 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", "")})
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestGenerate_EntryWithoutIP(t *testing.T) {
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -81,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
|
||||
|
||||
@@ -94,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"
|
||||
@@ -114,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"
|
||||
@@ -129,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"
|
||||
@@ -145,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"
|
||||
@@ -167,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"
|
||||
@@ -180,25 +180,25 @@ 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: Exact-match domain produces host-record=
|
||||
func TestGenerate_ExactMatchDomain(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
globalCfg := &config.State{}
|
||||
|
||||
out := g.Generate(globalCfg, []DNSEntry{entry("my-api.payne.io", "192.168.8.151")})
|
||||
@@ -216,7 +216,7 @@ func TestGenerate_ExactMatchDomain(t *testing.T) {
|
||||
|
||||
// Test: Wildcard domain produces address=/ without local=/
|
||||
func TestGenerate_WildcardDomain(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
globalCfg := &config.State{}
|
||||
|
||||
out := g.Generate(globalCfg, []DNSEntry{wildcardEntry("cloud.payne.io", "192.168.8.240")})
|
||||
@@ -234,7 +234,7 @@ func TestGenerate_WildcardDomain(t *testing.T) {
|
||||
|
||||
// Test: No local=/ directives are ever generated
|
||||
func TestGenerate_NoLocalDirectives(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
globalCfg := &config.State{}
|
||||
|
||||
entries := []DNSEntry{
|
||||
@@ -251,7 +251,7 @@ func TestGenerate_NoLocalDirectives(t *testing.T) {
|
||||
|
||||
// Test: filter-AAAA appears in generated config
|
||||
func TestGenerate_FilterAAAA(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
|
||||
out := g.Generate(nil, nil)
|
||||
|
||||
@@ -262,13 +262,13 @@ func TestGenerate_FilterAAAA(t *testing.T) {
|
||||
|
||||
// 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{
|
||||
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
|
||||
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)
|
||||
@@ -289,7 +289,7 @@ func TestGenerate_MixedDomains(t *testing.T) {
|
||||
|
||||
// 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{})
|
||||
@@ -310,7 +310,7 @@ func TestGenerate_EmptyEntries(t *testing.T) {
|
||||
|
||||
// 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{
|
||||
@@ -329,7 +329,7 @@ func TestGenerate_MultipleEntries(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerate_ConfFile(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
g.SetFilterConfPath("/var/lib/wild-central/dns-filter/hosts.blocked")
|
||||
|
||||
out := g.Generate(nil, nil)
|
||||
@@ -343,7 +343,7 @@ func TestGenerate_ConfFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerate_NoConfFileWhenEmpty(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
// addnHostsPath is empty by default
|
||||
|
||||
out := g.Generate(nil, nil)
|
||||
@@ -354,7 +354,7 @@ func TestGenerate_NoConfFileWhenEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerateMainConfig_ConfFile(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
g.SetFilterConfPath("/tmp/hosts.blocked")
|
||||
|
||||
cfg := &config.State{}
|
||||
@@ -367,7 +367,7 @@ func TestGenerateMainConfig_ConfFile(t *testing.T) {
|
||||
|
||||
// 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{})
|
||||
|
||||
@@ -381,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"
|
||||
|
||||
@@ -394,7 +394,7 @@ func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -70,13 +74,13 @@ type AuthConfig struct {
|
||||
// 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
|
||||
Auth *AuthConfig `yaml:"auth,omitempty" json:"auth,omitempty"` // forward-auth protection (L7 HTTP only)
|
||||
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.
|
||||
@@ -159,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()
|
||||
@@ -167,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
|
||||
@@ -183,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
|
||||
@@ -215,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,11 @@ 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)
|
||||
}
|
||||
@@ -82,18 +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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -104,7 +130,7 @@ global
|
||||
maxconn 50000
|
||||
daemon
|
||||
`)
|
||||
if opts.AuthEnabled {
|
||||
if authEnabled {
|
||||
sb.WriteString(" lua-prepend-path /etc/haproxy/lua/?.lua\n")
|
||||
sb.WriteString(" lua-load /etc/haproxy/lua/haproxy-auth-request.lua\n")
|
||||
}
|
||||
@@ -134,281 +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")
|
||||
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")
|
||||
|
||||
// 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)
|
||||
|
||||
// 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)
|
||||
// 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
|
||||
}
|
||||
// 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")
|
||||
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)
|
||||
}
|
||||
|
||||
// 2. L4 exact matches — instances with subdomains:false
|
||||
hasExact := false
|
||||
for _, inst := range instances {
|
||||
if inst.Subdomains {
|
||||
continue // handled in step 3
|
||||
for _, route := range opts.HTTPRoutes {
|
||||
if !route.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 — 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)
|
||||
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")
|
||||
|
||||
// 4. Default → L7 termination fallback
|
||||
if len(opts.HTTPRoutes) > 0 {
|
||||
sb.WriteString(" default_backend be_l7_termination\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// 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/"
|
||||
}
|
||||
|
||||
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)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authelia forward-auth directives for protected routes
|
||||
if opts.AuthEnabled && opts.AuthBackend != "" {
|
||||
authDomainACL := ""
|
||||
if opts.AuthDomain != "" {
|
||||
authDomainACL = "host_" + sanitizeName(opts.AuthDomain)
|
||||
}
|
||||
|
||||
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 — forward-auth
|
||||
// requires shared cookies, which only work within the same
|
||||
// parent domain. Other domains should use OIDC instead.
|
||||
if opts.AuthSessionDomain != "" &&
|
||||
httpRoute.Domain != opts.AuthSessionDomain &&
|
||||
!strings.HasSuffix(httpRoute.Domain, "."+opts.AuthSessionDomain) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if any route on this domain has auth enabled
|
||||
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")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authelia backend for forward-auth
|
||||
// 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(" option tcp-check\n")
|
||||
fmt.Fprintf(sb, " server s0 %s:443 check\n", inst.BackendIP)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 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/"
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
// Per-route ACLs: IP whitelisting and headers
|
||||
writeL7RouteACLs(sb, opts.HTTPRoutes)
|
||||
|
||||
// Authelia forward-auth directives
|
||||
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")
|
||||
writeL7AuthDirectives(sb, opts)
|
||||
}
|
||||
|
||||
// Routing rules: path-specific first, then catch-all
|
||||
writeL7RoutingRules(sb, opts.HTTPRoutes)
|
||||
|
||||
// L7 backends — one per route
|
||||
writeL7Backends(sb, opts.HTTPRoutes)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
for i, rb := range httpRoute.Routes {
|
||||
aclSuffix := fmt.Sprintf("%s_%d", routeName, i)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
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))
|
||||
fmt.Fprintf(sb, " default_backend be_%s\n", sanitizeName(route.Name))
|
||||
sb.WriteString("\n")
|
||||
fmt.Fprintf(&sb, "backend be_%s\n", sanitizeName(route.Name))
|
||||
fmt.Fprintf(sb, "backend be_%s\n", sanitizeName(route.Name))
|
||||
sb.WriteString(" mode tcp\n")
|
||||
fmt.Fprintf(&sb, " server s0 %s\n", route.Backend)
|
||||
fmt.Fprintf(sb, " server s0 %s\n", route.Backend)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// sortedKeys returns the keys of a map in sorted order for deterministic output.
|
||||
@@ -528,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")
|
||||
@@ -550,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"`
|
||||
@@ -559,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;",
|
||||
|
||||
533
internal/reconcile/reconciler.go
Normal file
533
internal/reconcile/reconciler.go
Normal 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
|
||||
}
|
||||
290
internal/reconcile/reconciler_test.go
Normal file
290
internal/reconcile/reconciler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
35
main.go
35
main.go
@@ -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)
|
||||
@@ -126,7 +150,7 @@ func main() {
|
||||
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")
|
||||
@@ -160,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)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NavLink } from 'react-router';
|
||||
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound } 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,
|
||||
@@ -105,6 +105,7 @@ export function AppSidebar() {
|
||||
{ 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 (
|
||||
@@ -203,7 +204,7 @@ export function AppSidebar() {
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{advancedItems.map(({ to, icon: Icon, label, daemon }) => {
|
||||
const active = centralStatus?.daemons?.[daemon]?.active;
|
||||
const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
|
||||
return (
|
||||
<SidebarMenuItem key={to}>
|
||||
<NavLink to={to}>
|
||||
|
||||
148
web/src/components/AuthGate.tsx
Normal file
148
web/src/components/AuthGate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Badge } from './ui/badge';
|
||||
import {
|
||||
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
|
||||
BookOpen, Globe, Router, RotateCw, Key, KeyRound,
|
||||
Shield, Eye, ArrowLeftRight, Lock,
|
||||
Shield, ShieldCheck, Eye, ArrowLeftRight, Lock,
|
||||
} from 'lucide-react';
|
||||
import { useCloudflare } from '../hooks/useCloudflare';
|
||||
import { useCentralStatus } from '../hooks/useCentralStatus';
|
||||
@@ -23,6 +23,7 @@ const SERVICES = [
|
||||
{ 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 {
|
||||
|
||||
@@ -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'; }
|
||||
});
|
||||
|
||||
|
||||
227
web/src/components/advanced/CertificatesSubsystem.tsx
Normal file
227
web/src/components/advanced/CertificatesSubsystem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export { WireguardSubsystem } from './WireguardSubsystem';
|
||||
export { CrowdsecSubsystem } from './CrowdsecSubsystem';
|
||||
export { AutheliaSubsystem } from './AutheliaSubsystem';
|
||||
export { DdnsSubsystem } from './DdnsSubsystem';
|
||||
export { CertificatesSubsystem } from './CertificatesSubsystem';
|
||||
|
||||
@@ -227,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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
10
web/src/router/pages/advanced/CertificatesPage.tsx
Normal file
10
web/src/router/pages/advanced/CertificatesPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../../components';
|
||||
import { CertificatesSubsystem } from '../../../components/advanced';
|
||||
|
||||
export function CertificatesPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<CertificatesSubsystem />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export { WireguardPage } from './WireguardPage';
|
||||
export { CrowdsecPage } from './CrowdsecPage';
|
||||
export { AutheliaAdvancedPage } from './AutheliaPage';
|
||||
export { DdnsPage } from './DdnsPage';
|
||||
export { CertificatesPage } from './CertificatesPage';
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CrowdsecPage as CrowdsecAdvancedPage,
|
||||
AutheliaAdvancedPage,
|
||||
DdnsPage,
|
||||
CertificatesPage,
|
||||
} from './pages/advanced';
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
@@ -36,6 +37,7 @@ export const routes: RouteObject[] = [
|
||||
{ path: 'advanced/crowdsec', element: <CrowdsecAdvancedPage /> },
|
||||
{ path: 'advanced/authelia', element: <AutheliaAdvancedPage /> },
|
||||
{ path: 'advanced/ddns', element: <DdnsPage /> },
|
||||
{ path: 'advanced/certificates', element: <CertificatesPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,17 +21,43 @@ interface ErrorResponseBody {
|
||||
|
||||
import { getApiBaseUrl } from './config';
|
||||
|
||||
const TOKEN_KEY = 'wild-central:token';
|
||||
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
|
||||
constructor(baseUrl: string = getApiBaseUrl()) {
|
||||
this.baseUrl = baseUrl;
|
||||
// Load token from localStorage on init
|
||||
try { this.token = localStorage.getItem(TOKEN_KEY); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
getBaseURL(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
setToken(token: string) {
|
||||
this.token = token;
|
||||
try { localStorage.setItem(TOKEN_KEY, token); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
clearToken() {
|
||||
this.token = null;
|
||||
try { localStorage.removeItem(TOKEN_KEY); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
hasToken(): boolean {
|
||||
return this.token !== null && this.token !== '';
|
||||
}
|
||||
|
||||
private authHeaders(): Record<string, string> {
|
||||
if (this.token) {
|
||||
return { 'Authorization': `Bearer ${this.token}` };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
@@ -43,6 +69,7 @@ export class ApiClient {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.authHeaders(),
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
@@ -104,7 +131,7 @@ export class ApiClient {
|
||||
|
||||
async getText(endpoint: string): Promise<string> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, { headers: this.authHeaders() });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
@@ -122,7 +149,7 @@ export class ApiClient {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
headers: { 'Content-Type': 'text/plain', ...this.authHeaders() },
|
||||
body: text,
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
allowedHosts: true,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user