Compare commits
33 Commits
ff6d6bbd0b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c85d27a5d1 | ||
|
|
a98557cb55 | ||
|
|
0437494715 | ||
|
|
e8b6449df3 | ||
|
|
245d260a2b | ||
|
|
282d255c0a | ||
|
|
519df610e8 | ||
|
|
93700d7956 | ||
|
|
6f85d25362 | ||
|
|
f5a030fd44 | ||
|
|
60f3ca4a3a | ||
|
|
4500a1a45e | ||
|
|
8dd9e117bc | ||
|
|
947991da7f | ||
|
|
a85bfbfe9e | ||
|
|
2bb162a794 | ||
|
|
a0ab8faa1c | ||
|
|
88acd437a1 | ||
|
|
3172e56288 | ||
|
|
428d47f876 | ||
|
|
1e7d93256e | ||
|
|
65c7e56b0a | ||
|
|
d0b645b9b5 | ||
|
|
1d1d6c605c | ||
|
|
518cdbbce5 | ||
|
|
134c01fe5e | ||
|
|
d796a79f79 | ||
|
|
24bb976652 | ||
|
|
43d407bf2e | ||
|
|
994c9fbfdf | ||
|
|
a48d955dc0 | ||
|
|
6490fef5d5 | ||
|
|
9e8f23aab7 |
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
|
||||
|
||||
4
TODO.md
4
TODO.md
@@ -16,3 +16,7 @@ Affects: keila (CORS headers), plausible (Private-Network-Access header).
|
||||
## Input validation for header values
|
||||
|
||||
Header values with special characters (newlines, NULs) should be rejected at registration time. Currently values are Go-`%q`-quoted in the HAProxy output which handles spaces/commas but may produce backslash escapes that HAProxy doesn't understand for edge cases.
|
||||
|
||||
## APT package: Authelia bare-metal installation
|
||||
|
||||
When building the wild-central apt package in `dist/`, include Authelia as a dependency or document its installation. Bare-metal installation instructions: https://www.authelia.com/integration/deployment/bare-metal/
|
||||
|
||||
30
dist/.gitignore
vendored
Normal file
30
dist/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Local dev secrets
|
||||
.envrc
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
*.deb
|
||||
__debug*
|
||||
|
||||
# APT repository build artifacts
|
||||
.aptly/
|
||||
.aptly.conf
|
||||
|
||||
# Go build cache
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
# Coverage
|
||||
*.out
|
||||
*.cover
|
||||
*.coverage
|
||||
|
||||
# Go workspace
|
||||
go.work
|
||||
go.work.sum
|
||||
gpg/wild-central.gpg
|
||||
164
dist/Makefile
vendored
Normal file
164
dist/Makefile
vendored
Normal file
@@ -0,0 +1,164 @@
|
||||
# Default target
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# Build configuration
|
||||
VERSION := $(shell cat ../VERSION 2>/dev/null || echo "0.0.0-dev")
|
||||
BUILD_DIR := build
|
||||
DIST_DIR := dist
|
||||
DEB_DIR := debian-package
|
||||
|
||||
# Git information
|
||||
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
||||
EDGE_VERSION := 0.0.0~edge.$(shell date -u '+%Y%m%d%H%M%S').$(GIT_COMMIT)
|
||||
|
||||
# Source paths
|
||||
API_SOURCE := ..
|
||||
WEB_SOURCE := ../web
|
||||
|
||||
# Output files
|
||||
ARM64_BINARY := $(BUILD_DIR)/wild-central-arm64
|
||||
AMD64_BINARY := $(BUILD_DIR)/wild-central-amd64
|
||||
WEB_DIST_MARKER := $(BUILD_DIR)/.web-built
|
||||
ARM64_DEB := $(DIST_DIR)/packages/wild-central_$(VERSION)_arm64.deb
|
||||
AMD64_DEB := $(DIST_DIR)/packages/wild-central_$(VERSION)_amd64.deb
|
||||
|
||||
# Gitea release configuration
|
||||
GITEA_URL := https://git.civilsociety.dev
|
||||
GITEA_OWNER := wild-cloud
|
||||
GITEA_REPO := wild-central
|
||||
RELEASE_TAG := v$(VERSION)
|
||||
|
||||
.PHONY: help clean version build build-all package package-all repo deploy-repo release release-edge
|
||||
|
||||
help:
|
||||
@echo "Wild Central Package Builder"
|
||||
@echo ""
|
||||
@echo "This directory builds .deb packages for Wild Central."
|
||||
@echo "For development, use the parent directory:"
|
||||
@echo " - make dev (Go daemon)"
|
||||
@echo " - cd web && pnpm dev (React frontend)"
|
||||
@echo ""
|
||||
@echo "Build targets:"
|
||||
@echo " build - Build for current architecture (amd64)"
|
||||
@echo " build-all - Build all architectures"
|
||||
@echo ""
|
||||
@echo "Package targets:"
|
||||
@echo " package - Create .deb package (amd64)"
|
||||
@echo " package-all - Create all .deb packages"
|
||||
@echo ""
|
||||
@echo "Repository targets:"
|
||||
@echo " deploy-repo - Deploy repository to server"
|
||||
@echo ""
|
||||
@echo "Release targets:"
|
||||
@echo " release - Tag HEAD as v$$(cat ../VERSION), publish stable Gitea release + APT"
|
||||
@echo " release-edge - Publish rolling edge Gitea release + APT (no version bump needed)"
|
||||
@echo ""
|
||||
@echo "Utilities:"
|
||||
@echo " clean - Remove all build artifacts"
|
||||
@echo " version - Show build information"
|
||||
@echo ""
|
||||
@echo " Current stable version: $(VERSION) (from ../VERSION)"
|
||||
@echo " Current edge version: $(EDGE_VERSION)"
|
||||
@echo ""
|
||||
@echo "Environment variables:"
|
||||
@echo " GITEA_TOKEN - Gitea API token (required for release)"
|
||||
@echo " APTLY_URL - Aptly instance URL (required for APT deployment)"
|
||||
@echo " APTLY_PASS - Aptly API password"
|
||||
@echo " These must be set in your environment"
|
||||
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
@rm -rf $(BUILD_DIR) $(DIST_DIR) $(DEB_DIR)-* $(DEB_DIR)
|
||||
|
||||
version:
|
||||
@echo "Version: $(VERSION)"
|
||||
@echo "Git Commit: $(GIT_COMMIT)"
|
||||
@echo "Build Time: $(BUILD_TIME)"
|
||||
|
||||
# Web app build - track with marker file
|
||||
$(WEB_DIST_MARKER): $(shell find $(WEB_SOURCE)/src -type f 2>/dev/null | head -100) $(WEB_SOURCE)/package.json
|
||||
@echo "Building web application..."
|
||||
@ARCH=amd64 VERSION=$(VERSION) ./scripts/build-package.sh
|
||||
@touch $(WEB_DIST_MARKER)
|
||||
|
||||
# ARM64 binary
|
||||
$(ARM64_BINARY): $(shell find $(API_SOURCE) -name '*.go' -not -path '*/dist/*' -not -path '*/web/*' 2>/dev/null | head -100)
|
||||
@echo "Building arm64 binary..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@cd $(API_SOURCE) && GOOS=linux GOARCH=arm64 go build -ldflags="-X main.Version=$(VERSION)" -o dist/$(ARM64_BINARY) .
|
||||
|
||||
# AMD64 binary
|
||||
$(AMD64_BINARY): $(shell find $(API_SOURCE) -name '*.go' -not -path '*/dist/*' -not -path '*/web/*' 2>/dev/null | head -100)
|
||||
@echo "Building amd64 binary..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@cd $(API_SOURCE) && GOOS=linux GOARCH=amd64 go build -ldflags="-X main.Version=$(VERSION)" -o dist/$(AMD64_BINARY) .
|
||||
|
||||
# ARM64 package
|
||||
$(ARM64_DEB): $(ARM64_BINARY) $(WEB_DIST_MARKER)
|
||||
@echo "Creating arm64 .deb package..."
|
||||
@rm -f $(DIST_DIR)/packages/wild-central_*_arm64.deb
|
||||
@./scripts/package-deb.sh arm64 $(ARM64_BINARY) $(VERSION)
|
||||
|
||||
# AMD64 package
|
||||
$(AMD64_DEB): $(AMD64_BINARY) $(WEB_DIST_MARKER)
|
||||
@echo "Creating amd64 .deb package..."
|
||||
@rm -f $(DIST_DIR)/packages/wild-central_*_amd64.deb
|
||||
@./scripts/package-deb.sh amd64 $(AMD64_BINARY) $(VERSION)
|
||||
|
||||
# Convenience targets
|
||||
build: $(AMD64_BINARY) $(WEB_DIST_MARKER)
|
||||
|
||||
build-all: $(ARM64_BINARY) $(AMD64_BINARY) $(WEB_DIST_MARKER)
|
||||
|
||||
package: $(AMD64_DEB)
|
||||
|
||||
package-all: $(ARM64_DEB) $(AMD64_DEB)
|
||||
|
||||
deploy-repo: $(ARM64_DEB) $(AMD64_DEB)
|
||||
@echo "Deploying APT repository..."
|
||||
@if [ -z "$(APTLY_URL)" ] || [ -z "$(APTLY_PASS)" ]; then \
|
||||
echo "Error: APTLY_URL and APTLY_PASS must be set"; \
|
||||
echo " These must be set in your environment"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@./scripts/deploy-apt-repository.sh
|
||||
|
||||
release: $(ARM64_DEB) $(AMD64_DEB)
|
||||
@echo "Creating stable release $(RELEASE_TAG)..."
|
||||
@if [ -z "$(GITEA_TOKEN)" ]; then \
|
||||
echo "Error: GITEA_TOKEN environment variable not set"; \
|
||||
echo " Get a token from $(GITEA_URL)/user/settings/applications"; \
|
||||
echo " These must be set in your environment"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if git tag | grep -q "^$(RELEASE_TAG)$$"; then \
|
||||
echo "Tag $(RELEASE_TAG) already exists — updating release assets only"; \
|
||||
else \
|
||||
echo "Tagging HEAD as $(RELEASE_TAG)..."; \
|
||||
git tag -a $(RELEASE_TAG) -m "Wild Central $(VERSION)"; \
|
||||
git push origin $(RELEASE_TAG); \
|
||||
fi
|
||||
@./scripts/create-gitea-release.sh "$(GITEA_URL)" "$(GITEA_OWNER)" "$(GITEA_REPO)" "$(RELEASE_TAG)" "$(VERSION)" "$(GITEA_TOKEN)" stable
|
||||
@if [ -n "$(APTLY_URL)" ] && [ -n "$(APTLY_PASS)" ]; then \
|
||||
./scripts/deploy-apt-repository.sh; \
|
||||
else \
|
||||
echo "Skipping APT deployment (APTLY_URL/APTLY_PASS not set)"; \
|
||||
fi
|
||||
|
||||
release-edge: $(ARM64_DEB) $(AMD64_DEB)
|
||||
@echo "Creating edge release ($(EDGE_VERSION))..."
|
||||
@if [ -z "$(GITEA_TOKEN)" ]; then \
|
||||
echo "Error: GITEA_TOKEN environment variable not set"; \
|
||||
echo " These must be set in your environment"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Force-pushing edge tag to HEAD..."
|
||||
@git tag -f edge
|
||||
@git push origin edge --force
|
||||
@./scripts/create-gitea-release.sh "$(GITEA_URL)" "$(GITEA_OWNER)" "$(GITEA_REPO)" "edge" "$(EDGE_VERSION)" "$(GITEA_TOKEN)" edge
|
||||
@if [ -n "$(APTLY_URL)" ] && [ -n "$(APTLY_PASS)" ]; then \
|
||||
DIST=edge ./scripts/deploy-apt-repository.sh; \
|
||||
else \
|
||||
echo "Skipping APT deployment (APTLY_URL/APTLY_PASS not set)"; \
|
||||
fi
|
||||
76
dist/README.md
vendored
Normal file
76
dist/README.md
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
# Packaging Wild Central
|
||||
|
||||
## Installation
|
||||
|
||||
### APT Repository (recommended)
|
||||
|
||||
```bash
|
||||
# Import the signing key
|
||||
curl -fsSL https://aptly.cloud.payne.io/public.key \
|
||||
| gpg --dearmor | sudo tee /usr/share/keyrings/wild.gpg > /dev/null
|
||||
|
||||
# Add the repository
|
||||
sudo tee /etc/apt/sources.list.d/wild.sources <<EOF
|
||||
Types: deb
|
||||
URIs: https://aptly.cloud.payne.io
|
||||
Suites: stable
|
||||
Components: main
|
||||
Signed-By: /usr/share/keyrings/wild.gpg
|
||||
EOF
|
||||
|
||||
sudo apt update && sudo apt install wild-central
|
||||
```
|
||||
|
||||
Use `Suites: edge` for rolling builds from HEAD.
|
||||
|
||||
### Manual .deb install
|
||||
|
||||
```bash
|
||||
sudo dpkg -i wild-central_*.deb
|
||||
sudo apt-get install -f
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Start the service**:
|
||||
```bash
|
||||
sudo systemctl enable --now wild-central
|
||||
```
|
||||
|
||||
2. **Access the web interface**: open `http://your-server-ip` in your browser
|
||||
|
||||
3. **Configure** (optional):
|
||||
```bash
|
||||
sudo cp /etc/wild-central/config.yaml.example /etc/wild-central/config.yaml
|
||||
sudo nano /etc/wild-central/config.yaml
|
||||
sudo systemctl restart wild-central
|
||||
```
|
||||
|
||||
## Release Artifacts
|
||||
|
||||
Each release includes:
|
||||
- `wild-central_{VERSION}_{arch}.deb` — full install (daemon + web UI)
|
||||
- `wild-central-{arch}` — standalone daemon binary
|
||||
- `SHA256SUMS` — checksums for all artifacts
|
||||
|
||||
## Developer Tooling
|
||||
|
||||
```bash
|
||||
make help # Show all targets and current version info
|
||||
make package-all # Build all .deb packages
|
||||
make release # Stable release: tag + Gitea + APT stable
|
||||
make release-edge # Edge release: force-tag + Gitea + APT edge
|
||||
make deploy-repo # Deploy packages to APT repository only
|
||||
```
|
||||
|
||||
Directory structure:
|
||||
|
||||
```
|
||||
build/ Intermediate build artifacts
|
||||
dist/bin/ Standalone binaries
|
||||
dist/packages/ .deb packages
|
||||
```
|
||||
|
||||
Environment variables (must be set in your environment):
|
||||
- `GITEA_TOKEN` — required for `make release` / `make release-edge`
|
||||
- `APTLY_URL` / `APTLY_PASS` — required for APT deployment
|
||||
12
dist/debian/DEBIAN/control
vendored
Normal file
12
dist/debian/DEBIAN/control
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
Package: wild-central
|
||||
Version: VERSION_PLACEHOLDER
|
||||
Section: net
|
||||
Priority: optional
|
||||
Architecture: ARCH_PLACEHOLDER
|
||||
Homepage: https://mywildcloud.org
|
||||
Pre-Depends: dnsmasq, haproxy, nftables, wireguard-tools, certbot, python3-certbot-dns-cloudflare
|
||||
Depends: adduser, nginx
|
||||
Maintainer: Wild Cloud Team <paul@payne.io>
|
||||
Description: Wild Central - Networking and Coordination Platform
|
||||
Manages DNS, gateway routing, firewall, VPN, TLS certificates, and tunnels
|
||||
for Wild Cloud and other self-hosted services on the local network.
|
||||
262
dist/debian/DEBIAN/postinst
vendored
Normal file
262
dist/debian/DEBIAN/postinst
vendored
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
echo "Configuring wild-central..."
|
||||
|
||||
# Create wildcloud user if it doesn't exist
|
||||
if ! id wildcloud >/dev/null 2>&1; then
|
||||
useradd --system --home-dir /var/lib/wild-central --create-home --shell /bin/false wildcloud
|
||||
fi
|
||||
|
||||
# Create required directories
|
||||
mkdir -p /var/lib/wild-central
|
||||
mkdir -p /var/log/wild-central
|
||||
mkdir -p /etc/wild-central
|
||||
|
||||
# Set ownership
|
||||
chown -R wildcloud:wildcloud /var/lib/wild-central
|
||||
chown wildcloud:wildcloud /var/log/wild-central
|
||||
|
||||
# Set up dnsmasq directories and permissions
|
||||
mkdir -p /etc/dnsmasq.d
|
||||
mkdir -p /etc/dnsmasq.d/wild-cloud-instances
|
||||
|
||||
# Give wildcloud group write access to dnsmasq.d directory
|
||||
chgrp wildcloud /etc/dnsmasq.d
|
||||
chmod 775 /etc/dnsmasq.d
|
||||
|
||||
# Create or fix ownership of wild-central.conf
|
||||
if [ ! -f /etc/dnsmasq.d/wild-central.conf ]; then
|
||||
touch /etc/dnsmasq.d/wild-central.conf
|
||||
echo "Created /etc/dnsmasq.d/wild-central.conf"
|
||||
else
|
||||
echo "Found existing /etc/dnsmasq.d/wild-central.conf - updating ownership"
|
||||
fi
|
||||
|
||||
chown wildcloud:wildcloud /etc/dnsmasq.d/wild-central.conf
|
||||
chmod 644 /etc/dnsmasq.d/wild-central.conf
|
||||
|
||||
# Set ownership and permissions for instance configs directory
|
||||
chown wildcloud:wildcloud /etc/dnsmasq.d/wild-cloud-instances
|
||||
chmod 755 /etc/dnsmasq.d/wild-cloud-instances
|
||||
echo "Created /etc/dnsmasq.d/wild-cloud-instances/ for per-instance DNS configs"
|
||||
|
||||
# Set up systemd-resolved configuration directory and file
|
||||
mkdir -p /etc/systemd/resolved.conf.d
|
||||
|
||||
if [ ! -f /etc/systemd/resolved.conf.d/wild-central.conf ]; then
|
||||
touch /etc/systemd/resolved.conf.d/wild-central.conf
|
||||
echo "Created /etc/systemd/resolved.conf.d/wild-central.conf"
|
||||
else
|
||||
echo "Found existing /etc/systemd/resolved.conf.d/wild-central.conf"
|
||||
fi
|
||||
chown wildcloud:wildcloud /etc/systemd/resolved.conf.d/wild-central.conf
|
||||
chmod 644 /etc/systemd/resolved.conf.d/wild-central.conf
|
||||
|
||||
# Ensure /etc/resolv.conf is a symlink to systemd-resolved stub
|
||||
# Skip in Docker/container environments where resolv.conf might be bind-mounted
|
||||
if [ ! -L /etc/resolv.conf ] || [ "$(readlink /etc/resolv.conf)" != "/run/systemd/resolve/stub-resolv.conf" ]; then
|
||||
echo "Configuring /etc/resolv.conf to use systemd-resolved stub mode"
|
||||
if [ -f /etc/resolv.conf ] && [ ! -L /etc/resolv.conf ]; then
|
||||
cp /etc/resolv.conf /etc/resolv.conf.wild-backup 2>/dev/null || echo "Note: Could not backup /etc/resolv.conf (may be read-only)"
|
||||
fi
|
||||
if rm -f /etc/resolv.conf 2>/dev/null; then
|
||||
ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
|
||||
echo "Set /etc/resolv.conf to use systemd-resolved stub mode"
|
||||
else
|
||||
echo "Note: /etc/resolv.conf is read-only or busy (container environment?), skipping symlink setup"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set up HAProxy configuration directory
|
||||
mkdir -p /etc/haproxy
|
||||
if [ ! -f /etc/haproxy/haproxy.cfg ]; then
|
||||
touch /etc/haproxy/haproxy.cfg
|
||||
echo "Created /etc/haproxy/haproxy.cfg"
|
||||
fi
|
||||
chown wildcloud:wildcloud /etc/haproxy
|
||||
chown wildcloud:wildcloud /etc/haproxy/haproxy.cfg
|
||||
chmod 644 /etc/haproxy/haproxy.cfg
|
||||
|
||||
# Set up HAProxy TLS certs directory
|
||||
mkdir -p /etc/haproxy/certs
|
||||
chown wildcloud:wildcloud /etc/haproxy/certs
|
||||
chmod 700 /etc/haproxy/certs
|
||||
echo "Configured HAProxy for Wild Central management"
|
||||
|
||||
# Set up WireGuard configuration directory
|
||||
mkdir -p /etc/wireguard
|
||||
chown wildcloud:wildcloud /etc/wireguard
|
||||
chmod 700 /etc/wireguard
|
||||
if [ ! -f /etc/wireguard/wg0.conf ]; then
|
||||
touch /etc/wireguard/wg0.conf
|
||||
echo "Created /etc/wireguard/wg0.conf"
|
||||
fi
|
||||
chown wildcloud:wildcloud /etc/wireguard/wg0.conf
|
||||
chmod 600 /etc/wireguard/wg0.conf
|
||||
echo "Configured WireGuard for Wild Central management"
|
||||
|
||||
# Set up nftables configuration directory
|
||||
mkdir -p /etc/nftables.d
|
||||
if [ ! -f /etc/nftables.d/wild-central.nft ]; then
|
||||
touch /etc/nftables.d/wild-central.nft
|
||||
echo "Created /etc/nftables.d/wild-central.nft"
|
||||
fi
|
||||
chown wildcloud:wildcloud /etc/nftables.d/wild-central.nft
|
||||
chmod 644 /etc/nftables.d/wild-central.nft
|
||||
|
||||
# Ensure nftables.conf includes wild-central rules
|
||||
if [ -f /etc/nftables.conf ] && ! grep -q 'wild-central.nft' /etc/nftables.conf; then
|
||||
echo 'include "/etc/nftables.d/wild-central.nft"' >> /etc/nftables.conf
|
||||
echo "Added Wild Central nftables include to /etc/nftables.conf"
|
||||
fi
|
||||
echo "Configured nftables for Wild Central management"
|
||||
|
||||
# Install CrowdSec if not already installed
|
||||
if ! command -v cscli >/dev/null 2>&1; then
|
||||
echo "Installing CrowdSec..."
|
||||
curl -s https://packagecloud.io/install/repositories/crowdsec/crowdsec/script.deb.sh | os=ubuntu dist=noble bash
|
||||
apt-get install -y crowdsec
|
||||
echo "Installed CrowdSec"
|
||||
else
|
||||
echo "CrowdSec already installed: $(cscli version 2>/dev/null | head -1 || echo 'unknown')"
|
||||
fi
|
||||
|
||||
# Configure CrowdSec LAPI to listen on all interfaces
|
||||
CROWDSEC_CONFIG=/etc/crowdsec/config.yaml
|
||||
if [ -f "$CROWDSEC_CONFIG" ] && command -v yq >/dev/null 2>&1; then
|
||||
yq e -i '.api.server.listen_uri = "0.0.0.0:8080"' "$CROWDSEC_CONFIG"
|
||||
echo "Configured CrowdSec LAPI to listen on 0.0.0.0:8080"
|
||||
fi
|
||||
|
||||
# Ensure CrowdSec is running so the firewall bouncer can register with LAPI
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl start crowdsec 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Install CrowdSec firewall bouncer
|
||||
if ! command -v crowdsec-firewall-bouncer >/dev/null 2>&1; then
|
||||
echo "Installing CrowdSec firewall bouncer..."
|
||||
apt-get install -y crowdsec-firewall-bouncer
|
||||
echo "Installed CrowdSec firewall bouncer"
|
||||
else
|
||||
echo "CrowdSec firewall bouncer already installed"
|
||||
fi
|
||||
|
||||
# Install Authelia if not already installed
|
||||
if ! command -v authelia >/dev/null 2>&1; then
|
||||
echo "Installing Authelia..."
|
||||
curl -fsSL https://apt.authelia.com/organization/signing.asc | \
|
||||
gpg --dearmor -o /usr/share/keyrings/authelia-archive-keyring.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/authelia-archive-keyring.gpg] \
|
||||
https://apt.authelia.com/integration/apt/repo/stable/debian debian main" \
|
||||
> /etc/apt/sources.list.d/authelia.list
|
||||
apt-get update -qq
|
||||
apt-get install -y authelia
|
||||
echo "Installed Authelia"
|
||||
else
|
||||
echo "Authelia already installed"
|
||||
fi
|
||||
|
||||
# Install HAProxy Lua scripts for Authelia auth-request forwarding
|
||||
mkdir -p /etc/haproxy/lua
|
||||
if [ ! -f /etc/haproxy/lua/haproxy-auth-request.lua ]; then
|
||||
curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua \
|
||||
https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua
|
||||
echo "Installed haproxy-auth-request.lua"
|
||||
fi
|
||||
if [ ! -f /etc/haproxy/lua/haproxy-lua-http.lua ]; then
|
||||
curl -fsSL -o /etc/haproxy/lua/haproxy-lua-http.lua \
|
||||
https://raw.githubusercontent.com/haproxytech/haproxy-lua-http/master/http.lua
|
||||
echo "Installed haproxy-lua-http.lua"
|
||||
fi
|
||||
apt-get install -y lua-json 2>/dev/null || true
|
||||
|
||||
# Configure Authelia systemd override to use Wild Central config path
|
||||
mkdir -p /etc/systemd/system/authelia.service.d
|
||||
cat > /etc/systemd/system/authelia.service.d/wild-central.conf << 'AUTHELIA_EOF'
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/authelia --config /var/lib/wild-central/authelia/configuration.yml
|
||||
AUTHELIA_EOF
|
||||
echo "Configured Authelia systemd override"
|
||||
|
||||
# Create Authelia data directory
|
||||
mkdir -p /var/lib/wild-central/authelia
|
||||
chown wildcloud:wildcloud /var/lib/wild-central/authelia
|
||||
chmod 700 /var/lib/wild-central/authelia
|
||||
|
||||
# Install sudoers rules for privileged operations
|
||||
cat > /etc/sudoers.d/wild-central << 'SUDOERS_EOF'
|
||||
wildcloud ALL=(ALL) NOPASSWD: /usr/sbin/nft -c -f *, /usr/sbin/nft list table inet wild-central, /usr/bin/cscli bouncers *, /usr/bin/cscli machines *, /usr/bin/cscli decisions *, /usr/bin/cscli alerts *, /usr/bin/cscli version, /usr/bin/wg-quick up wg0, /usr/bin/wg-quick down wg0, /usr/bin/wg show wg0, /usr/bin/certbot *, /usr/sbin/nginx -t, /usr/bin/systemctl reload nginx
|
||||
SUDOERS_EOF
|
||||
chmod 440 /etc/sudoers.d/wild-central
|
||||
echo "Installed sudoers rules for Wild Central"
|
||||
|
||||
# Install polkit rules for service management
|
||||
mkdir -p /etc/polkit-1/rules.d
|
||||
cat > /etc/polkit-1/rules.d/50-wild-central.rules << 'POLKIT_EOF'
|
||||
/* Allow wildcloud user to manage Wild Central services */
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
(action.lookup("unit") == "dnsmasq.service" ||
|
||||
action.lookup("unit") == "systemd-resolved.service" ||
|
||||
action.lookup("unit") == "haproxy.service" ||
|
||||
action.lookup("unit") == "wild-central-nftables-reload.service" ||
|
||||
action.lookup("unit") == "crowdsec.service" ||
|
||||
action.lookup("unit") == "crowdsec-firewall-bouncer.service" ||
|
||||
action.lookup("unit") == "wg-quick@wg0.service" ||
|
||||
action.lookup("unit") == "cloudflared.service" ||
|
||||
action.lookup("unit") == "authelia.service" ||
|
||||
action.lookup("unit") == "wild-central.service") &&
|
||||
subject.user == "wildcloud") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
POLKIT_EOF
|
||||
chmod 644 /etc/polkit-1/rules.d/50-wild-central.rules
|
||||
echo "Installed polkit rules for service management"
|
||||
|
||||
# Enable nginx site (disable default site, enable wild-central)
|
||||
if [ -f /etc/nginx/sites-enabled/default ]; then
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
echo "Disabled default nginx site"
|
||||
fi
|
||||
if [ ! -L /etc/nginx/sites-enabled/wild-central ]; then
|
||||
ln -sf /etc/nginx/sites-available/wild-central /etc/nginx/sites-enabled/wild-central
|
||||
echo "Enabled wild-central nginx site"
|
||||
fi
|
||||
|
||||
# Reload nginx to pick up the new site
|
||||
if systemctl is-active --quiet nginx; then
|
||||
nginx -t && systemctl reload nginx
|
||||
echo "Reloaded nginx configuration"
|
||||
fi
|
||||
|
||||
# Enable and start the service
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload
|
||||
systemctl enable wild-central.service
|
||||
echo "wild-central configured successfully"
|
||||
echo "Start the service with: sudo systemctl start wild-central"
|
||||
echo "View logs with: sudo journalctl -u wild-central -f"
|
||||
else
|
||||
echo "Note: systemd not detected (container environment?), skipping service enable"
|
||||
echo "wild-central installed successfully"
|
||||
fi
|
||||
;;
|
||||
|
||||
abort-upgrade|abort-remove|abort-deconfigure)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postinst called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
80
dist/debian/DEBIAN/postrm
vendored
Normal file
80
dist/debian/DEBIAN/postrm
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
purge)
|
||||
echo "Purging wild-central configuration..."
|
||||
|
||||
# Remove polkit rules
|
||||
if [ -f /etc/polkit-1/rules.d/50-wild-central.rules ]; then
|
||||
rm -f /etc/polkit-1/rules.d/50-wild-central.rules
|
||||
echo "Removed polkit rules"
|
||||
fi
|
||||
|
||||
# Remove sudoers file
|
||||
if [ -f /etc/sudoers.d/wild-central ]; then
|
||||
rm -f /etc/sudoers.d/wild-central
|
||||
fi
|
||||
|
||||
# Remove dnsmasq configuration
|
||||
if [ -f /etc/dnsmasq.d/wild-central.conf ]; then
|
||||
rm -f /etc/dnsmasq.d/wild-central.conf
|
||||
echo "Removed dnsmasq configuration"
|
||||
fi
|
||||
|
||||
# Remove systemd-resolved configuration
|
||||
if [ -f /etc/systemd/resolved.conf.d/wild-central.conf ]; then
|
||||
rm -f /etc/systemd/resolved.conf.d/wild-central.conf
|
||||
echo "Removed systemd-resolved configuration"
|
||||
systemctl restart systemd-resolved 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Restore /etc/resolv.conf if we have a backup
|
||||
if [ -f /etc/resolv.conf.wild-backup ]; then
|
||||
echo "Restoring original /etc/resolv.conf from backup"
|
||||
rm -f /etc/resolv.conf
|
||||
mv /etc/resolv.conf.wild-backup /etc/resolv.conf
|
||||
fi
|
||||
|
||||
# Remove configuration directory
|
||||
if [ -d /etc/wild-central ]; then
|
||||
rm -rf /etc/wild-central
|
||||
fi
|
||||
|
||||
# Remove log directory
|
||||
if [ -d /var/log/wild-central ]; then
|
||||
rm -rf /var/log/wild-central
|
||||
fi
|
||||
|
||||
# Remove lib directory
|
||||
if [ -d /var/lib/wild-central ]; then
|
||||
rm -rf /var/lib/wild-central
|
||||
fi
|
||||
|
||||
# Remove nginx site
|
||||
if [ -L /etc/nginx/sites-enabled/wild-central ]; then
|
||||
rm -f /etc/nginx/sites-enabled/wild-central
|
||||
echo "Disabled wild-central nginx site"
|
||||
fi
|
||||
|
||||
# Only remove wildcloud user if wild-cloud package is not installed
|
||||
if ! dpkg -s wild-cloud >/dev/null 2>&1; then
|
||||
if id wildcloud >/dev/null 2>&1; then
|
||||
userdel wildcloud || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "wild-central purged successfully"
|
||||
;;
|
||||
|
||||
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postrm called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
28
dist/debian/DEBIAN/prerm
vendored
Normal file
28
dist/debian/DEBIAN/prerm
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
remove|upgrade|deconfigure)
|
||||
echo "Stopping wild-central service..."
|
||||
|
||||
# Stop and disable the service
|
||||
if systemctl is-active --quiet wild-central; then
|
||||
systemctl stop wild-central
|
||||
fi
|
||||
|
||||
if systemctl is-enabled --quiet wild-central; then
|
||||
systemctl disable wild-central
|
||||
fi
|
||||
;;
|
||||
|
||||
failed-upgrade)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "prerm called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
34
dist/debian/etc/nginx/sites-available/wild-central
vendored
Normal file
34
dist/debian/etc/nginx/sites-available/wild-central
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Wild Central Management Interface
|
||||
root /var/www/html/wild-central;
|
||||
index index.html;
|
||||
|
||||
# API proxy to wild-central service
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:5055;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SSE events proxy (long-lived connections)
|
||||
location /api/v1/events {
|
||||
proxy_pass http://localhost:5055;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
# Static files - SPA routing support
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
9
dist/debian/etc/systemd/system/wild-central-nftables-reload.service
vendored
Normal file
9
dist/debian/etc/systemd/system/wild-central-nftables-reload.service
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Apply Wild Central nftables rules
|
||||
Documentation=https://mywildcloud.org
|
||||
After=network.target nftables.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/sbin/nft -f /etc/nftables.d/wild-central.nft
|
||||
RemainAfterExit=no
|
||||
28
dist/debian/etc/systemd/system/wild-central.service
vendored
Normal file
28
dist/debian/etc/systemd/system/wild-central.service
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
[Unit]
|
||||
Description=Wild Central Service
|
||||
Documentation=https://mywildcloud.org
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=wildcloud
|
||||
Group=wildcloud
|
||||
ExecStart=/usr/bin/wild-central
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=WILD_CENTRAL_DATA_DIR=/var/lib/wild-central
|
||||
Environment=WILD_CENTRAL_STATIC_DIR=/var/www/html/wild-central
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=wild-central
|
||||
|
||||
# Security settings
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=full
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/etc/wild-central /var/lib/wild-central /var/log/wild-central /etc/dnsmasq.d /etc/systemd/resolved.conf.d /etc/haproxy /etc/nftables.d /etc/wireguard
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
42
dist/debian/etc/wild-central/config.yaml.example
vendored
Normal file
42
dist/debian/etc/wild-central/config.yaml.example
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
cloud:
|
||||
router:
|
||||
ip: 192.168.8.1
|
||||
dnsmasq:
|
||||
ip: 192.168.8.50
|
||||
interface: eth0
|
||||
# DHCP server (disabled by default — enable to take over DHCP from your router)
|
||||
# dhcp:
|
||||
# enabled: false
|
||||
# rangeStart: "192.168.8.100"
|
||||
# rangeEnd: "192.168.8.200"
|
||||
# leaseTime: "24h"
|
||||
# gateway: "" # defaults to cloud.router.ip
|
||||
# staticLeases: # managed via POST /api/v1/dnsmasq/dhcp/static
|
||||
# - mac: "aa:bb:cc:dd:ee:ff"
|
||||
# ip: "192.168.8.10"
|
||||
# hostname: "node1"
|
||||
|
||||
# HAProxy ingress proxy — routes external traffic by SNI.
|
||||
# Use POST /api/v1/haproxy/generate to apply after changing this config.
|
||||
haproxy:
|
||||
# Optional custom TCP routes for non-Wild Cloud services on your network.
|
||||
# customRoutes:
|
||||
# - name: civil_ssh
|
||||
# port: 2222 # external listen port
|
||||
# backend: 192.168.8.10:22
|
||||
|
||||
# nftables host firewall — tracks ports HAProxy is serving.
|
||||
# Set wanInterface to enable strict WAN filtering (drops all unrecognised inbound traffic).
|
||||
# Leave unset for default accept-all policy (safe during initial setup).
|
||||
# nftables:
|
||||
# wanInterface: eth0
|
||||
|
||||
# Dynamic DNS — updates DNS A records when your public IP changes.
|
||||
# API token goes in secrets.yaml under cloudflare.apiToken.
|
||||
# ddns:
|
||||
# enabled: false
|
||||
# provider: cloudflare
|
||||
# records:
|
||||
# - cloud.example.com
|
||||
# - cloud2.example.com
|
||||
# intervalMinutes: 5
|
||||
71
dist/scripts/build-package.sh
vendored
Executable file
71
dist/scripts/build-package.sh
vendored
Executable file
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Build configuration
|
||||
VERSION="${VERSION:-0.1.0}"
|
||||
ARCH="${ARCH:-amd64}"
|
||||
BUILD_DIR="build"
|
||||
SRC_DIR="$BUILD_DIR/src"
|
||||
BINARY_NAME="wild-central"
|
||||
|
||||
# Source paths (relative to dist directory)
|
||||
API_SOURCE=".."
|
||||
WEBAPP_SOURCE="../web"
|
||||
|
||||
echo "Building Wild Central v${VERSION} for ${ARCH}"
|
||||
echo "================================================"
|
||||
|
||||
# Clean and create build directories
|
||||
echo "Preparing build directories..."
|
||||
rm -rf "$SRC_DIR"
|
||||
mkdir -p "$SRC_DIR"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
# Copy API source
|
||||
echo ""
|
||||
echo "Copying API source..."
|
||||
rsync -a --exclude='dist/' --exclude='web/' "$API_SOURCE/" "$SRC_DIR/api/"
|
||||
cd "$SRC_DIR/api"
|
||||
|
||||
# Build the daemon
|
||||
echo "Building daemon binary..."
|
||||
if [ "$ARCH" = "arm64" ]; then
|
||||
GOOS=linux GOARCH=arm64 go build -ldflags="-X main.Version=$VERSION" -o "../../$BINARY_NAME" .
|
||||
elif [ "$ARCH" = "amd64" ]; then
|
||||
GOOS=linux GOARCH=amd64 go build -ldflags="-X main.Version=$VERSION" -o "../../$BINARY_NAME" .
|
||||
else
|
||||
echo "Unsupported architecture: $ARCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd - > /dev/null
|
||||
echo "Daemon binary built: $BUILD_DIR/$BINARY_NAME"
|
||||
|
||||
# Copy web app source
|
||||
echo ""
|
||||
echo "Copying web app source..."
|
||||
cp -r "$WEBAPP_SOURCE" "$SRC_DIR/web"
|
||||
cd "$SRC_DIR/web"
|
||||
|
||||
# Remove any local env overrides
|
||||
rm -f .env.local .env.*.local
|
||||
|
||||
# Build the web app
|
||||
echo "Building web application..."
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# For production builds, use empty base URL so the app uses relative paths
|
||||
echo "VITE_API_BASE_URL=" > .env.production.local
|
||||
|
||||
pnpm run build --mode production || {
|
||||
echo "Build with type checking failed, trying without type check..."
|
||||
pnpm exec vite build
|
||||
}
|
||||
|
||||
cd - > /dev/null
|
||||
echo "Web app built: $SRC_DIR/web/dist/"
|
||||
|
||||
echo ""
|
||||
echo "Build complete!"
|
||||
echo " Binary: $BUILD_DIR/$BINARY_NAME"
|
||||
echo " Web App: $SRC_DIR/web/dist/"
|
||||
189
dist/scripts/create-gitea-release.sh
vendored
Executable file
189
dist/scripts/create-gitea-release.sh
vendored
Executable file
@@ -0,0 +1,189 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_URL="$1"
|
||||
OWNER="$2"
|
||||
REPO="$3"
|
||||
TAG="$4"
|
||||
VERSION="$5"
|
||||
TOKEN="$6"
|
||||
CHANNEL="${7:-stable}" # "stable" or "edge"
|
||||
|
||||
API_URL="${GITEA_URL}/api/v1"
|
||||
PACKAGES_DIR="dist/packages"
|
||||
BINARIES_DIR="dist/bin"
|
||||
SKIP_RELEASE_CREATION=false
|
||||
|
||||
echo "Release configuration:"
|
||||
echo " Repository: ${OWNER}/${REPO}"
|
||||
echo " Tag: ${TAG}"
|
||||
echo " Version: ${VERSION}"
|
||||
echo ""
|
||||
|
||||
# Check if release already exists
|
||||
echo "Checking if release ${TAG} exists..."
|
||||
RELEASE_ID=$(curl -s -H "Authorization: token ${TOKEN}" \
|
||||
"${API_URL}/repos/${OWNER}/${REPO}/releases/tags/${TAG}" \
|
||||
| jq -r '.id // empty')
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
echo "Release ${TAG} exists (ID: ${RELEASE_ID})"
|
||||
echo "Updating existing release assets..."
|
||||
|
||||
# Get existing assets
|
||||
EXISTING_ASSETS=$(curl -s -H "Authorization: token ${TOKEN}" \
|
||||
"${API_URL}/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||
| jq -r '.[].id')
|
||||
|
||||
# Delete old assets
|
||||
for ASSET_ID in $EXISTING_ASSETS; do
|
||||
ASSET_NAME=$(curl -s -H "Authorization: token ${TOKEN}" \
|
||||
"${API_URL}/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}" \
|
||||
| jq -r '.name')
|
||||
|
||||
if [[ "$ASSET_NAME" == *.deb ]] || [[ "$ASSET_NAME" == wild-central-* ]] || [[ "$ASSET_NAME" == "SHA256SUMS" ]]; then
|
||||
echo " Deleting old asset: ${ASSET_NAME}"
|
||||
curl -s -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_URL}/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Cleaned up old package assets"
|
||||
SKIP_RELEASE_CREATION=true
|
||||
fi
|
||||
|
||||
# Extract changelog section for this version
|
||||
CHANGELOG_SECTION=""
|
||||
if [ -f "../CHANGELOG.md" ]; then
|
||||
CHANGELOG_SECTION=$(awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" ../CHANGELOG.md | sed '/^$/d')
|
||||
fi
|
||||
|
||||
INSTALL_DOCS="### Installation
|
||||
|
||||
#### Full Installation (.deb package)
|
||||
|
||||
Download the appropriate .deb package for your architecture:
|
||||
|
||||
- **arm64**: \`wild-central_${VERSION}_arm64.deb\` — Raspberry Pi 4/5, ARM servers
|
||||
- **amd64**: \`wild-central_${VERSION}_amd64.deb\` — x86_64 systems
|
||||
|
||||
\`\`\`bash
|
||||
wget ${GITEA_URL}/${OWNER}/${REPO}/releases/download/v${VERSION}/wild-central_${VERSION}_amd64.deb
|
||||
sudo dpkg -i wild-central_${VERSION}_amd64.deb
|
||||
sudo apt-get install -f
|
||||
sudo systemctl enable --now wild-central
|
||||
\`\`\`
|
||||
|
||||
#### Standalone Binary
|
||||
|
||||
- \`wild-central-{arch}\` — Daemon binary only (Docker/K8s)
|
||||
|
||||
#### Verification
|
||||
|
||||
\`\`\`bash
|
||||
wget ${GITEA_URL}/${OWNER}/${REPO}/releases/download/v${VERSION}/SHA256SUMS
|
||||
sha256sum -c SHA256SUMS
|
||||
\`\`\`"
|
||||
|
||||
if [ -n "$CHANGELOG_SECTION" ]; then
|
||||
BODY="${CHANGELOG_SECTION}
|
||||
|
||||
---
|
||||
|
||||
${INSTALL_DOCS}"
|
||||
else
|
||||
BODY="${INSTALL_DOCS}"
|
||||
fi
|
||||
|
||||
# Set release name and prerelease flag based on channel
|
||||
if [ "$CHANNEL" = "edge" ]; then
|
||||
RELEASE_NAME="Wild Central (edge) — ${VERSION}"
|
||||
IS_PRERELEASE=true
|
||||
else
|
||||
RELEASE_NAME="Wild Central ${VERSION}"
|
||||
IS_PRERELEASE=false
|
||||
fi
|
||||
|
||||
# Create new release if it doesn't exist
|
||||
if [ "$SKIP_RELEASE_CREATION" != "true" ]; then
|
||||
echo "Creating new release ${TAG}..."
|
||||
RELEASE_DATA=$(jq -n \
|
||||
--arg tag "${TAG}" \
|
||||
--arg name "${RELEASE_NAME}" \
|
||||
--arg body "${BODY}" \
|
||||
--argjson prerelease "${IS_PRERELEASE}" \
|
||||
'{"tag_name": $tag, "name": $name, "body": $body, "draft": false, "prerelease": $prerelease}')
|
||||
|
||||
RELEASE_RESPONSE=$(curl -s -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${RELEASE_DATA}" \
|
||||
"${API_URL}/repos/${OWNER}/${REPO}/releases")
|
||||
|
||||
RELEASE_ID=$(echo "${RELEASE_RESPONSE}" | jq -r '.id')
|
||||
|
||||
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
|
||||
echo "Failed to create release"
|
||||
echo "${RELEASE_RESPONSE}" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Created release ${TAG} (ID: ${RELEASE_ID})"
|
||||
fi
|
||||
|
||||
# Generate checksums
|
||||
echo ""
|
||||
echo "Generating checksums..."
|
||||
cd dist
|
||||
sha256sum packages/*.deb bin/wild-central-* > SHA256SUMS
|
||||
echo "Created SHA256SUMS"
|
||||
cd ..
|
||||
|
||||
# Upload artifacts
|
||||
echo ""
|
||||
echo "Uploading release artifacts..."
|
||||
|
||||
upload_file() {
|
||||
local FILE="$1"
|
||||
local FILENAME=$(basename "$FILE")
|
||||
|
||||
echo " Uploading ${FILENAME}..."
|
||||
|
||||
UPLOAD_RESPONSE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-F "attachment=@${FILE}" \
|
||||
"${API_URL}/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}")
|
||||
|
||||
ASSET_ID=$(echo "${UPLOAD_RESPONSE}" | jq -r '.id')
|
||||
|
||||
if [ -z "$ASSET_ID" ] || [ "$ASSET_ID" = "null" ]; then
|
||||
echo " Failed to upload ${FILENAME}"
|
||||
echo "${UPLOAD_RESPONSE}" | jq .
|
||||
return 1
|
||||
else
|
||||
echo " Uploaded ${FILENAME}"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Upload .deb packages
|
||||
for DEB in ${PACKAGES_DIR}/wild-central_${VERSION}_*.deb; do
|
||||
if [ ! -f "$DEB" ]; then
|
||||
echo "No .deb files found for version ${VERSION} in ${PACKAGES_DIR}"
|
||||
exit 1
|
||||
fi
|
||||
upload_file "$DEB"
|
||||
done
|
||||
|
||||
# Upload standalone daemon binaries
|
||||
for BINARY in ${BINARIES_DIR}/wild-central-*; do
|
||||
if [ -f "$BINARY" ]; then
|
||||
upload_file "$BINARY"
|
||||
fi
|
||||
done
|
||||
|
||||
# Upload checksums
|
||||
upload_file "dist/SHA256SUMS"
|
||||
|
||||
echo ""
|
||||
echo "Release complete!"
|
||||
echo "View at: ${GITEA_URL}/${OWNER}/${REPO}/releases/tag/${TAG}"
|
||||
141
dist/scripts/deploy-apt-repository.sh
vendored
Executable file
141
dist/scripts/deploy-apt-repository.sh
vendored
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Required environment variables:
|
||||
# APTLY_URL - Aptly server URL (e.g. https://aptly.cloud2.payne.io)
|
||||
# APTLY_PASS - Aptly API password
|
||||
# Optional:
|
||||
# APTLY_USER - API username (default: aptly)
|
||||
# APTLY_GPG_KEY - GPG key fingerprint for signing; if unset, signing is skipped
|
||||
|
||||
APTLY_URL="${APTLY_URL:?APTLY_URL is required}"
|
||||
APTLY_PASS="${APTLY_PASS:?APTLY_PASS is required}"
|
||||
APTLY_USER="${APTLY_USER:-aptly}"
|
||||
APTLY_GPG_KEY="${APTLY_GPG_KEY:-}"
|
||||
|
||||
VERSION=$(cat ../VERSION 2>/dev/null || echo "0.0.0-dev")
|
||||
REPO_NAME="wild"
|
||||
DIST="${DIST:-stable}"
|
||||
COMPONENT="main"
|
||||
PACKAGE_DIR="dist/packages"
|
||||
SNAP_NAME="wild-central-${VERSION}"
|
||||
AUTH="${APTLY_USER}:${APTLY_PASS}"
|
||||
PACKAGE_GLOB="wild-central_${VERSION}_*.deb"
|
||||
|
||||
echo "Deploying Wild Central ${VERSION} to APT repository..."
|
||||
echo " URL: ${APTLY_URL}"
|
||||
|
||||
# Signing config — use server-side GPG key
|
||||
# The key must exist in Aptly's GPG keyring (auto-generated by the init-gpg container)
|
||||
# Get the fingerprint: curl -sf ${APTLY_URL}/public.key | gpg --with-colons --import-options show-only --import | grep fpr | cut -d: -f10
|
||||
if [ -n "$APTLY_GPG_KEY" ]; then
|
||||
SIGNING="{\"GpgKey\":\"${APTLY_GPG_KEY}\",\"Batch\":true}"
|
||||
else
|
||||
echo "APTLY_GPG_KEY not set — publishing without GPG signature"
|
||||
SIGNING="{\"Skip\":true}"
|
||||
fi
|
||||
|
||||
api() {
|
||||
local METHOD="$1"
|
||||
local ENDPOINT="$2"
|
||||
local DATA="${3:-}"
|
||||
if [ -n "$DATA" ]; then
|
||||
curl -sf --http1.1 -u "$AUTH" -X "$METHOD" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$DATA" \
|
||||
"${APTLY_URL}/api${ENDPOINT}"
|
||||
else
|
||||
curl -sf --http1.1 -u "$AUTH" -X "$METHOD" "${APTLY_URL}/api${ENDPOINT}"
|
||||
fi
|
||||
}
|
||||
|
||||
# 1. Ensure local repo exists
|
||||
echo ""
|
||||
echo "Ensuring repository '${REPO_NAME}' exists..."
|
||||
if ! api GET "/repos/${REPO_NAME}" > /dev/null 2>&1; then
|
||||
api POST "/repos" \
|
||||
"{\"Name\":\"${REPO_NAME}\",\"DefaultDistribution\":\"${DIST}\",\"DefaultComponent\":\"${COMPONENT}\"}" \
|
||||
> /dev/null
|
||||
echo " Created '${REPO_NAME}'"
|
||||
else
|
||||
echo " '${REPO_NAME}' already exists"
|
||||
fi
|
||||
|
||||
# 2. Upload .deb files
|
||||
echo ""
|
||||
echo "Uploading packages..."
|
||||
UPLOAD_DIR="${REPO_NAME}-${VERSION}"
|
||||
FOUND=0
|
||||
for DEB in "${PACKAGE_DIR}"/${PACKAGE_GLOB}; do
|
||||
[ -f "$DEB" ] || continue
|
||||
FOUND=1
|
||||
FILENAME=$(basename "$DEB")
|
||||
echo " ${FILENAME}..."
|
||||
curl -sf --http1.1 -u "$AUTH" -X POST \
|
||||
-F "file=@${DEB}" \
|
||||
"${APTLY_URL}/api/files/${UPLOAD_DIR}" > /dev/null
|
||||
done
|
||||
if [ "$FOUND" -eq 0 ]; then
|
||||
echo "No .deb files found for version ${VERSION} in ${PACKAGE_DIR}"
|
||||
exit 1
|
||||
fi
|
||||
echo " Uploaded"
|
||||
|
||||
# 3. Add packages to repo
|
||||
echo ""
|
||||
echo "Adding packages to '${REPO_NAME}'..."
|
||||
api POST "/repos/${REPO_NAME}/file/${UPLOAD_DIR}" > /dev/null
|
||||
echo " Added"
|
||||
|
||||
# 4. Clean up uploaded files from aptly's staging area
|
||||
api DELETE "/files/${UPLOAD_DIR}" > /dev/null 2>&1 || true
|
||||
|
||||
# 5. Create snapshot for this version
|
||||
echo ""
|
||||
echo "Snapshotting as '${SNAP_NAME}'..."
|
||||
if api GET "/snapshots/${SNAP_NAME}" > /dev/null 2>&1; then
|
||||
echo " Snapshot '${SNAP_NAME}' already exists, using as-is"
|
||||
else
|
||||
api POST "/repos/${REPO_NAME}/snapshots" \
|
||||
"{\"Name\":\"${SNAP_NAME}\",\"Description\":\"Wild Central ${VERSION}\"}" \
|
||||
> /dev/null
|
||||
echo " Snapshot '${SNAP_NAME}' created"
|
||||
fi
|
||||
|
||||
# 6. Publish or update
|
||||
echo ""
|
||||
PUBLISH_PREFIX=$(api GET "/publish" \
|
||||
| jq -r ".[] | select(.Distribution == \"${DIST}\") | .Prefix" 2>/dev/null \
|
||||
| head -1)
|
||||
|
||||
UPDATE_BODY="{\"Snapshots\":[{\"Component\":\"${COMPONENT}\",\"Name\":\"${SNAP_NAME}\"}],\"Signing\":${SIGNING},\"ForceOverwrite\":true}"
|
||||
|
||||
if [ -z "$PUBLISH_PREFIX" ]; then
|
||||
echo "Publishing '${DIST}' for the first time..."
|
||||
api POST "/publish" \
|
||||
"{\"SourceKind\":\"snapshot\",\"Sources\":[{\"Component\":\"${COMPONENT}\",\"Name\":\"${SNAP_NAME}\"}],\"Distribution\":\"${DIST}\",\"Architectures\":[\"amd64\",\"arm64\"],\"Signing\":${SIGNING}}" \
|
||||
> /dev/null
|
||||
else
|
||||
echo "Updating published '${DIST}'..."
|
||||
URL_PREFIX=$(echo "$PUBLISH_PREFIX" | sed 's/^\.$/_/')
|
||||
api PUT "/publish/${URL_PREFIX}/${DIST}" "$UPDATE_BODY" > /dev/null
|
||||
fi
|
||||
echo " Published"
|
||||
|
||||
echo ""
|
||||
echo "Wild Central ${VERSION} is live."
|
||||
echo ""
|
||||
echo "To install on a new device:"
|
||||
echo ""
|
||||
echo " curl -fsSL ${APTLY_URL}/public.key \\"
|
||||
echo " | gpg --dearmor | sudo tee /usr/share/keyrings/wild.gpg > /dev/null"
|
||||
echo ""
|
||||
echo " sudo tee /etc/apt/sources.list.d/wild.sources <<EOF"
|
||||
echo " Types: deb"
|
||||
echo " URIs: ${APTLY_URL}"
|
||||
echo " Suites: ${DIST}"
|
||||
echo " Components: ${COMPONENT}"
|
||||
echo " Signed-By: /usr/share/keyrings/wild.gpg"
|
||||
echo " EOF"
|
||||
echo ""
|
||||
echo " sudo apt update && sudo apt install wild-central"
|
||||
49
dist/scripts/package-deb.sh
vendored
Executable file
49
dist/scripts/package-deb.sh
vendored
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ARCH="$1"
|
||||
BINARY_PATH="$2"
|
||||
VERSION="$3"
|
||||
|
||||
BUILD_DIR="build"
|
||||
DIST_DIR="dist"
|
||||
DEB_DIR="debian-package"
|
||||
WEB_DIST="${BUILD_DIR}/src/web/dist"
|
||||
|
||||
echo "Creating .deb package for ${ARCH}..."
|
||||
|
||||
# Create directories
|
||||
mkdir -p "${DIST_DIR}/bin" "${DIST_DIR}/packages"
|
||||
|
||||
# Copy debian package structure
|
||||
cp -r debian/ "${BUILD_DIR}/${DEB_DIR}-${ARCH}/"
|
||||
|
||||
# Copy binary to correct location
|
||||
mkdir -p "${BUILD_DIR}/${DEB_DIR}-${ARCH}/usr/bin"
|
||||
cp "${BINARY_PATH}" "${BUILD_DIR}/${DEB_DIR}-${ARCH}/usr/bin/wild-central"
|
||||
|
||||
# Copy static web files from built web app
|
||||
mkdir -p "${BUILD_DIR}/${DEB_DIR}-${ARCH}/var/www/html/wild-central"
|
||||
if [ -d "${WEB_DIST}" ]; then
|
||||
cp -r "${WEB_DIST}"/* "${BUILD_DIR}/${DEB_DIR}-${ARCH}/var/www/html/wild-central/"
|
||||
echo "Copied web app files"
|
||||
else
|
||||
echo "Warning: Web app dist not found at ${WEB_DIST}"
|
||||
fi
|
||||
|
||||
# Set script permissions
|
||||
chmod 755 "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/postinst"
|
||||
chmod 755 "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/prerm"
|
||||
chmod 755 "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/postrm"
|
||||
|
||||
# Substitute placeholders in control file
|
||||
sed -i "s/VERSION_PLACEHOLDER/${VERSION}/g" "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/control"
|
||||
sed -i "s/ARCH_PLACEHOLDER/${ARCH}/g" "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/control"
|
||||
|
||||
# Build package and copy to dist directories
|
||||
dpkg-deb --root-owner-group --build "${BUILD_DIR}/${DEB_DIR}-${ARCH}" "${BUILD_DIR}/wild-central_${VERSION}_${ARCH}.deb"
|
||||
cp "${BINARY_PATH}" "${DIST_DIR}/bin/wild-central-${ARCH}"
|
||||
cp "${BUILD_DIR}/wild-central_${VERSION}_${ARCH}.deb" "${DIST_DIR}/packages/"
|
||||
|
||||
echo "Package created: ${DIST_DIR}/packages/wild-central_${VERSION}_${ARCH}.deb"
|
||||
echo "Daemon binary copied: ${DIST_DIR}/bin/wild-central-${ARCH}"
|
||||
@@ -113,8 +113,8 @@ When a service is registered, Central automatically:
|
||||
|
||||
| Effect | Passthrough | Terminate |
|
||||
|--------|-------------|-----------|
|
||||
| **LAN DNS** | `address=/<domain>/<backend-IP>` — direct to backend | `address=/<domain>/<central-IP>` — through Central |
|
||||
| **LAN DNS (private)** | Also `local=/<domain>/` — prevents upstream forwarding | Same |
|
||||
| **LAN DNS** | `host-record=<domain>,<backend-IP>` (exact) or `address=/<domain>/<backend-IP>` (wildcard if subdomains:true) | `host-record=<domain>,<central-IP>` (exact) or `address=/<domain>/<central-IP>` (wildcard if subdomains:true) |
|
||||
| **AAAA filtering** | `filter-AAAA` globally strips IPv6 from upstream responses (prevents Happy Eyeballs on IPv4-only LAN) | Same |
|
||||
| **Public DNS** | DDNS A record if public | Same |
|
||||
| **Proxy** | L4 SNI passthrough. Subdomains adds `*.domain` matching. | L7 HTTP reverse proxy by Host header. Routes add path-based ACLs. |
|
||||
| **TLS** | Backend handles — Central passes through | Central provisions cert via Let's Encrypt |
|
||||
@@ -147,7 +147,7 @@ Central creates: LAN DNS → Central IP, public DNS A record, HAProxy L7 TLS ter
|
||||
}
|
||||
```
|
||||
|
||||
Central creates: LAN DNS → Central IP (with `local=/`), HAProxy L7 reverse proxy, TLS cert via certbot. No public DNS.
|
||||
Central creates: LAN DNS → Central IP (host-record=, exact match), HAProxy L7 reverse proxy, TLS cert via certbot. No public DNS.
|
||||
|
||||
### Service with custom response headers
|
||||
|
||||
|
||||
2
go.mod
2
go.mod
@@ -7,6 +7,7 @@ require (
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/nats-io/nats-server/v2 v2.14.3
|
||||
github.com/nats-io/nats.go v1.52.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -19,6 +20,5 @@ require (
|
||||
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -16,45 +17,52 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/authelia"
|
||||
"github.com/wild-cloud/wild-central/internal/certbot"
|
||||
"github.com/wild-cloud/wild-central/internal/config"
|
||||
"github.com/wild-cloud/wild-central/internal/crowdsec"
|
||||
"github.com/wild-cloud/wild-central/internal/ddns"
|
||||
"github.com/wild-cloud/wild-central/internal/dnsfilter"
|
||||
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
||||
"github.com/wild-cloud/wild-central/internal/domains"
|
||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
||||
"github.com/wild-cloud/wild-central/internal/network"
|
||||
"github.com/wild-cloud/wild-central/internal/nftables"
|
||||
"github.com/wild-cloud/wild-central/internal/reconcile"
|
||||
"github.com/wild-cloud/wild-central/internal/secrets"
|
||||
"github.com/wild-cloud/wild-central/internal/domains"
|
||||
"github.com/wild-cloud/wild-central/internal/sse"
|
||||
"github.com/wild-cloud/wild-central/internal/wireguard"
|
||||
)
|
||||
|
||||
// API holds all dependencies for Central API handlers
|
||||
type API struct {
|
||||
dataDir string
|
||||
version string
|
||||
allowedOrigins []string
|
||||
ctx gocontext.Context // parent context for restartable goroutines
|
||||
secrets *secrets.Manager
|
||||
dnsmasq *dnsmasq.ConfigGenerator
|
||||
haproxy *haproxy.Manager
|
||||
nftables *nftables.Manager
|
||||
ddns *ddns.Runner
|
||||
crowdsec *crowdsec.Manager
|
||||
vpn *wireguard.Manager
|
||||
certbot *certbot.Manager
|
||||
domains *domains.Manager // Domain registration manager
|
||||
sseManager *sse.Manager // SSE manager for real-time events
|
||||
port int // Running API port (for config-driven routes)
|
||||
dataDir string
|
||||
version string
|
||||
allowedOrigins []string
|
||||
ctx gocontext.Context // parent context for restartable goroutines
|
||||
reconciler *reconcile.Reconciler
|
||||
secrets *secrets.Manager
|
||||
dnsmasq *dnsmasq.Manager
|
||||
haproxy *haproxy.Manager
|
||||
nftables *nftables.Manager
|
||||
ddns *ddns.Runner
|
||||
crowdsec *crowdsec.Manager
|
||||
vpn *wireguard.Manager
|
||||
certbot *certbot.Manager
|
||||
authelia *authelia.Manager // Authelia authentication service manager
|
||||
dnsFilter *dnsfilter.Manager // DNS filtering manager
|
||||
dnsFilterRunner *dnsfilter.Runner // DNS filter background update runner
|
||||
domains *domains.Manager // Domain registration manager
|
||||
sseManager *sse.Manager // SSE manager for real-time events
|
||||
port int // Running API port (for config-driven routes)
|
||||
}
|
||||
|
||||
// NewAPI creates a new Central API handler with all dependencies
|
||||
func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
|
||||
// Determine config paths from env or defaults
|
||||
dnsmasqConfigPath := envOrDefault("WILD_CENTRAL_DNSMASQ_CONFIG_PATH", "/etc/dnsmasq.d/wild-cloud.conf")
|
||||
dnsmasqConfigPath := envOrDefault("WILD_CENTRAL_DNSMASQ_CONFIG_PATH", "/etc/dnsmasq.d/wild-central.conf")
|
||||
haproxyConfigPath := envOrDefault("WILD_CENTRAL_HAPROXY_CONFIG_PATH", "/etc/haproxy/haproxy.cfg")
|
||||
nftablesRulesPath := envOrDefault("WILD_CENTRAL_NFTABLES_RULES_PATH", "/etc/nftables.d/wild-cloud.nft")
|
||||
nftablesRulesPath := envOrDefault("WILD_CENTRAL_NFTABLES_RULES_PATH", "/etc/nftables.d/wild-central.nft")
|
||||
vpnConfigPath := envOrDefault("WILD_CENTRAL_VPN_CONFIG_PATH", "/etc/wireguard/wg0.conf")
|
||||
|
||||
sseManager := sse.NewManager()
|
||||
@@ -64,20 +72,43 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
|
||||
version: version,
|
||||
allowedOrigins: allowedOrigins,
|
||||
secrets: secrets.NewManager(filepath.Join(dataDir, "secrets.yaml")),
|
||||
dnsmasq: dnsmasq.NewConfigGenerator(dnsmasqConfigPath),
|
||||
dnsmasq: dnsmasq.NewManager(dnsmasqConfigPath),
|
||||
haproxy: haproxy.NewManager(haproxyConfigPath),
|
||||
nftables: nftables.NewManager(nftablesRulesPath),
|
||||
ddns: ddns.NewRunner(),
|
||||
crowdsec: crowdsec.NewManager(),
|
||||
vpn: wireguard.NewManager(dataDir, vpnConfigPath),
|
||||
certbot: certbot.NewManager(""),
|
||||
authelia: authelia.NewManager(filepath.Join(dataDir, "authelia")),
|
||||
dnsFilter: dnsfilter.NewManager(filepath.Join(dataDir, "dns-filter")),
|
||||
domains: domains.NewManager(dataDir),
|
||||
sseManager: sseManager,
|
||||
}
|
||||
|
||||
// The compiled blocklist must be readable by dnsmasq which runs as its
|
||||
// own user. Use /var/lib/wild-central/dns-filter/ (world-readable) rather
|
||||
// than the data dir which may be under a user home with 0750 permissions.
|
||||
filterDir := envOrDefault("WILD_CENTRAL_FILTER_DIR", "/var/lib/wild-central/dns-filter")
|
||||
api.dnsFilter.SetHostsFilePath(filepath.Join(filterDir, "blocked.conf"))
|
||||
|
||||
// Build the reconciler — the core domain→config→daemon pipeline
|
||||
api.reconciler = &reconcile.Reconciler{
|
||||
Domains: api.domains,
|
||||
HAProxy: api.haproxy,
|
||||
DNS: api.dnsmasq,
|
||||
Auth: api.authelia,
|
||||
DDNS: api.ddns,
|
||||
DNSFilter: api.dnsFilter,
|
||||
SSE: sseManager,
|
||||
StatePath: filepath.Join(dataDir, "state.yaml"),
|
||||
GenerateAutheliaConfig: api.generateAutheliaConfig,
|
||||
Certs: api.certbot,
|
||||
GetCloudflareToken: api.getCloudflareToken,
|
||||
}
|
||||
|
||||
// Wire up domain registration reconciliation: when domains change,
|
||||
// regenerate dnsmasq DNS entries and HAProxy routes.
|
||||
api.domains.SetReconcileFn(api.reconcileNetworking)
|
||||
api.domains.SetReconcileFn(api.reconciler.Reconcile)
|
||||
|
||||
return api, nil
|
||||
}
|
||||
@@ -109,6 +140,30 @@ func (api *API) StartDDNS(ctx gocontext.Context) {
|
||||
api.ddns.Start(ctx, api.ddnsParams)
|
||||
}
|
||||
|
||||
// StartDNSFilter launches the DNS filter background goroutine if enabled.
|
||||
func (api *API) StartDNSFilter(ctx gocontext.Context) {
|
||||
api.dnsFilterRunner = dnsfilter.NewRunner(api.dnsFilter, func() {
|
||||
if err := api.dnsmasq.ReloadService(); err != nil {
|
||||
slog.Warn("failed to reload dnsmasq after update", "component", "dns-filter", "error", err)
|
||||
}
|
||||
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter lists updated")
|
||||
})
|
||||
|
||||
state, err := config.LoadState(api.statePath())
|
||||
if err != nil || !state.Cloud.DNSFilter.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
_ = api.dnsFilter.EnsureDataDir()
|
||||
|
||||
interval := state.Cloud.DNSFilter.IntervalHours
|
||||
if interval <= 0 {
|
||||
interval = 24
|
||||
}
|
||||
|
||||
api.dnsFilterRunner.Start(ctx, interval)
|
||||
}
|
||||
|
||||
// ddnsParams returns the current DDNS parameters derived from runtime state.
|
||||
// Called by the DDNS runner on each tick — always returns fresh values.
|
||||
func (api *API) ddnsParams() ddns.Params {
|
||||
@@ -162,9 +217,57 @@ func (api *API) StartCentralStatusBroadcaster(startTime time.Time) {
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all Central API routes
|
||||
func (api *API) RegisterRoutes(r *mux.Router) {
|
||||
// ReconcileHealth returns the health state from the last reconciliation.
|
||||
func (api *API) ReconcileHealth(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, api.reconciler.GetHealth())
|
||||
}
|
||||
|
||||
// CheckPrerequisites verifies that required daemons are available on the system.
|
||||
// Non-fatal — logs errors for required and info for optional missing binaries.
|
||||
func (api *API) CheckPrerequisites() {
|
||||
for _, bin := range []string{"dnsmasq", "haproxy"} {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
slog.Error("required daemon not found", "component", "startup", "binary", bin)
|
||||
}
|
||||
}
|
||||
for _, bin := range []string{"wg", "authelia", "cscli", "cloudflared", "nft"} {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
slog.Info("optional daemon not found (some features unavailable)", "component", "startup", "binary", bin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartConvergenceLoop starts a periodic reconciliation loop that continuously
|
||||
// drives the system toward desired state. Catches config drift, daemon crashes,
|
||||
// and transient failures.
|
||||
func (api *API) StartConvergenceLoop(ctx gocontext.Context, interval time.Duration) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
api.reconciler.Reconcile()
|
||||
}
|
||||
}
|
||||
}()
|
||||
slog.Info("convergence loop started", "component", "reconcile", "interval", interval)
|
||||
}
|
||||
|
||||
func (api *API) RegisterRoutes(r *mux.Router, bearerToken string, devMode bool) {
|
||||
r.Use(RequestLoggingMiddleware)
|
||||
|
||||
// Bearer token authentication — protects all /api/ routes.
|
||||
// Dev mode skips auth for ease of development.
|
||||
if !devMode && bearerToken != "" {
|
||||
r.Use(BearerAuthMiddleware(bearerToken))
|
||||
}
|
||||
|
||||
// Health (accessible even with auth — middleware exempts /health)
|
||||
r.HandleFunc("/api/v1/health/reconcile", api.ReconcileHealth).Methods("GET")
|
||||
|
||||
// Resource-oriented settings (persisted in state.yaml)
|
||||
r.HandleFunc("/api/v1/operator", api.GetOperator).Methods("GET")
|
||||
r.HandleFunc("/api/v1/operator", api.UpdateOperator).Methods("PUT")
|
||||
@@ -221,15 +324,16 @@ func (api *API) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/v1/ddns/trigger", api.DDNSTrigger).Methods("POST")
|
||||
|
||||
// WireGuard VPN management
|
||||
r.HandleFunc("/api/v1/vpn/status", api.VpnStatus).Methods("GET")
|
||||
r.HandleFunc("/api/v1/vpn/config", api.VpnGetConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/vpn/config", api.VpnUpdateConfig).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/vpn/keygen", api.VpnGenerateKeypair).Methods("POST")
|
||||
r.HandleFunc("/api/v1/vpn/apply", api.VpnApply).Methods("POST")
|
||||
r.HandleFunc("/api/v1/vpn/peers", api.VpnListPeers).Methods("GET")
|
||||
r.HandleFunc("/api/v1/vpn/peers", api.VpnAddPeer).Methods("POST")
|
||||
r.HandleFunc("/api/v1/vpn/peers/{id}/config", api.VpnGetPeerConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/vpn/peers/{id}", api.VpnDeletePeer).Methods("DELETE")
|
||||
vpn := &vpnHandlers{vpn: api.vpn, statePath: api.statePath(), syncNftables: api.syncNftablesOnly}
|
||||
r.HandleFunc("/api/v1/vpn/status", vpn.Status).Methods("GET")
|
||||
r.HandleFunc("/api/v1/vpn/config", vpn.GetConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/vpn/config", vpn.UpdateConfig).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/vpn/keygen", vpn.GenerateKeypair).Methods("POST")
|
||||
r.HandleFunc("/api/v1/vpn/apply", vpn.Apply).Methods("POST")
|
||||
r.HandleFunc("/api/v1/vpn/peers", vpn.ListPeers).Methods("GET")
|
||||
r.HandleFunc("/api/v1/vpn/peers", vpn.AddPeer).Methods("POST")
|
||||
r.HandleFunc("/api/v1/vpn/peers/{id}/config", vpn.GetPeerConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/vpn/peers/{id}", vpn.DeletePeer).Methods("DELETE")
|
||||
|
||||
// TLS certificate management
|
||||
r.HandleFunc("/api/v1/cert/status", api.CertStatus).Methods("GET")
|
||||
@@ -237,25 +341,55 @@ func (api *API) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/v1/cert/renew", api.CertRenew).Methods("POST")
|
||||
|
||||
// CrowdSec LAPI management
|
||||
r.HandleFunc("/api/v1/crowdsec/status", api.CrowdSecStatus).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/summary", api.CrowdSecGetSummary).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/alerts", api.CrowdSecGetAlerts).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/decisions", api.CrowdSecGetDecisions).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/decisions", api.CrowdSecAddDecision).Methods("POST")
|
||||
r.HandleFunc("/api/v1/crowdsec/decisions/{id}", api.CrowdSecDeleteDecision).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/crowdsec/decisions/ip/{ip}", api.CrowdSecDeleteDecisionByIP).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/crowdsec/machines", api.CrowdSecGetMachines).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/machines", api.CrowdSecAddMachine).Methods("POST")
|
||||
r.HandleFunc("/api/v1/crowdsec/machines/{name}", api.CrowdSecDeleteMachine).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecGetBouncers).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecAddBouncer).Methods("POST")
|
||||
r.HandleFunc("/api/v1/crowdsec/bouncers/{name}", api.CrowdSecDeleteBouncer).Methods("DELETE")
|
||||
csec := &crowdsecHandlers{crowdsec: api.crowdsec}
|
||||
r.HandleFunc("/api/v1/crowdsec/status", csec.Status).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/summary", csec.GetSummary).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/alerts", csec.GetAlerts).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/decisions", csec.GetDecisions).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/decisions", csec.AddDecision).Methods("POST")
|
||||
r.HandleFunc("/api/v1/crowdsec/decisions/{id}", csec.DeleteDecision).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/crowdsec/decisions/ip/{ip}", csec.DeleteDecisionByIP).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/crowdsec/machines", csec.GetMachines).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/machines", csec.AddMachine).Methods("POST")
|
||||
r.HandleFunc("/api/v1/crowdsec/machines/{name}", csec.DeleteMachine).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/crowdsec/bouncers", csec.GetBouncers).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/bouncers", csec.AddBouncer).Methods("POST")
|
||||
r.HandleFunc("/api/v1/crowdsec/bouncers/{name}", csec.DeleteBouncer).Methods("DELETE")
|
||||
|
||||
// Cloudflare management
|
||||
r.HandleFunc("/api/v1/cloudflare/verify", api.CloudflareVerify).Methods("GET")
|
||||
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareGetZoneID).Methods("GET")
|
||||
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareSetZoneID).Methods("POST")
|
||||
|
||||
// Authelia authentication management
|
||||
r.HandleFunc("/api/v1/authelia/status", api.AutheliaStatus).Methods("GET")
|
||||
r.HandleFunc("/api/v1/authelia/config", api.AutheliaGetConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/authelia/config", api.AutheliaUpdateConfig).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/authelia/restart", api.AutheliaRestart).Methods("POST")
|
||||
r.HandleFunc("/api/v1/authelia/users", api.AutheliaListUsers).Methods("GET")
|
||||
r.HandleFunc("/api/v1/authelia/users", api.AutheliaCreateUser).Methods("POST")
|
||||
r.HandleFunc("/api/v1/authelia/users/{username}", api.AutheliaUpdateUser).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/authelia/users/{username}", api.AutheliaDeleteUser).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/authelia/oidc/clients", api.AutheliaListOIDCClients).Methods("GET")
|
||||
r.HandleFunc("/api/v1/authelia/oidc/clients", api.AutheliaCreateOIDCClient).Methods("POST")
|
||||
r.HandleFunc("/api/v1/authelia/oidc/clients/{clientId}", api.AutheliaUpdateOIDCClient).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/authelia/oidc/clients/{clientId}", api.AutheliaDeleteOIDCClient).Methods("DELETE")
|
||||
|
||||
// DNS Filtering
|
||||
r.HandleFunc("/api/v1/dns-filter/status", api.DNSFilterStatus).Methods("GET")
|
||||
r.HandleFunc("/api/v1/dns-filter/config", api.DNSFilterGetConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/dns-filter/config", api.DNSFilterUpdateConfig).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/dns-filter/lists", api.DNSFilterGetLists).Methods("GET")
|
||||
r.HandleFunc("/api/v1/dns-filter/lists", api.DNSFilterAddList).Methods("POST")
|
||||
r.HandleFunc("/api/v1/dns-filter/lists/upload", api.DNSFilterUploadList).Methods("POST")
|
||||
r.HandleFunc("/api/v1/dns-filter/lists/suggested", api.DNSFilterGetSuggested).Methods("GET")
|
||||
r.HandleFunc("/api/v1/dns-filter/lists/{id}", api.DNSFilterRemoveList).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/dns-filter/lists/{id}/toggle", api.DNSFilterToggleList).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/dns-filter/custom", api.DNSFilterGetCustomEntries).Methods("GET")
|
||||
r.HandleFunc("/api/v1/dns-filter/custom", api.DNSFilterSetCustomEntry).Methods("POST")
|
||||
r.HandleFunc("/api/v1/dns-filter/custom/{domain:.+}", api.DNSFilterRemoveCustomEntry).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/dns-filter/update", api.DNSFilterTriggerUpdate).Methods("POST")
|
||||
|
||||
// Domain registration — domain is the unique key
|
||||
r.HandleFunc("/api/v1/domains", api.DomainsListAll).Methods("GET")
|
||||
r.HandleFunc("/api/v1/domains", api.DomainsRegister).Methods("POST")
|
||||
@@ -278,11 +412,7 @@ func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
showRaw := r.URL.Query().Get("raw") == "true"
|
||||
if !showRaw {
|
||||
redactSecrets(secretsMap)
|
||||
}
|
||||
|
||||
redactSecrets(secretsMap)
|
||||
respondJSON(w, http.StatusOK, secretsMap)
|
||||
}
|
||||
|
||||
@@ -329,24 +459,112 @@ func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime
|
||||
})
|
||||
}
|
||||
|
||||
// getDaemonStatus checks whether each managed daemon is active.
|
||||
func getDaemonStatus() map[string]map[string]bool {
|
||||
daemons := map[string]string{
|
||||
"dnsmasq": "dnsmasq.service",
|
||||
"haproxy": "haproxy.service",
|
||||
"wireguard": "wg-quick@wg0.service",
|
||||
"crowdsec": "crowdsec.service",
|
||||
// getDaemonStatus checks whether each managed daemon is active and gets its version.
|
||||
func getDaemonStatus() map[string]map[string]any {
|
||||
type daemonInfo struct {
|
||||
unit string
|
||||
verCmd []string // command to get version
|
||||
verParse func(string) string // extract version from output
|
||||
}
|
||||
|
||||
result := make(map[string]map[string]bool, len(daemons)+1)
|
||||
for name, unit := range daemons {
|
||||
err := exec.Command("systemctl", "is-active", "--quiet", unit).Run()
|
||||
result[name] = map[string]bool{"active": err == nil}
|
||||
firstWord := func(s string) string {
|
||||
if i := strings.IndexByte(s, ' '); i > 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// nftables has no persistent service — check if the wild-cloud table exists
|
||||
err := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run()
|
||||
result["nftables"] = map[string]bool{"active": err == nil}
|
||||
daemons := map[string]daemonInfo{
|
||||
"dnsmasq": {
|
||||
unit: "dnsmasq.service",
|
||||
verCmd: []string{"dnsmasq", "--version"},
|
||||
verParse: func(out string) string {
|
||||
// "Dnsmasq version 2.90 ..."
|
||||
if i := strings.Index(out, "version "); i >= 0 {
|
||||
return firstWord(out[i+8:])
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
"haproxy": {
|
||||
unit: "haproxy.service",
|
||||
verCmd: []string{"haproxy", "-v"},
|
||||
verParse: func(out string) string {
|
||||
// "HAProxy version 2.8.5-1 ..."
|
||||
if i := strings.Index(out, "version "); i >= 0 {
|
||||
return firstWord(out[i+8:])
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
"wireguard": {
|
||||
unit: "wg-quick@wg0.service",
|
||||
verCmd: []string{"wg", "--version"},
|
||||
verParse: func(out string) string {
|
||||
// "wireguard-tools v1.0.20210914 - ..."
|
||||
if i := strings.Index(out, " v"); i >= 0 {
|
||||
return firstWord(out[i+1:])
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
"crowdsec": {
|
||||
unit: "crowdsec.service",
|
||||
verCmd: []string{"cscli", "version", "--raw"},
|
||||
verParse: func(out string) string {
|
||||
return firstWord(out)
|
||||
},
|
||||
},
|
||||
"authelia": {
|
||||
unit: "authelia.service",
|
||||
verCmd: []string{"authelia", "--version"},
|
||||
verParse: func(out string) string {
|
||||
// "authelia version 4.x.x" or just "4.x.x"
|
||||
if after, found := strings.CutPrefix(out, "authelia version "); found {
|
||||
return firstWord(after)
|
||||
}
|
||||
return firstWord(out)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := make(map[string]map[string]any, len(daemons)+1)
|
||||
for name, info := range daemons {
|
||||
err := exec.Command("systemctl", "is-active", "--quiet", info.unit).Run()
|
||||
entry := map[string]any{"active": err == nil}
|
||||
if out, verErr := exec.Command(info.verCmd[0], info.verCmd[1:]...).Output(); verErr == nil {
|
||||
if v := info.verParse(string(out)); v != "" {
|
||||
entry["version"] = v
|
||||
}
|
||||
}
|
||||
result[name] = entry
|
||||
}
|
||||
|
||||
// nftables has no persistent service — check if the wild-central table exists
|
||||
nftErr := exec.Command("sudo", "nft", "list", "table", "inet", "wild-central").Run()
|
||||
nftEntry := map[string]any{"active": nftErr == nil}
|
||||
if out, verErr := exec.Command("nft", "--version").Output(); verErr == nil {
|
||||
// "nftables v1.0.9 (Old Doc Yak #3)"
|
||||
s := string(out)
|
||||
if i := strings.Index(s, " v"); i >= 0 {
|
||||
nftEntry["version"] = firstWord(s[i+1:])
|
||||
}
|
||||
}
|
||||
result["nftables"] = nftEntry
|
||||
|
||||
// certbot is a CLI tool, not a daemon — check if installed
|
||||
certbotEntry := map[string]any{"active": false}
|
||||
if _, err := exec.LookPath("certbot"); err == nil {
|
||||
certbotEntry["active"] = true
|
||||
if out, verErr := exec.Command("certbot", "--version").Output(); verErr == nil {
|
||||
// "certbot 2.11.0"
|
||||
s := strings.TrimSpace(string(out))
|
||||
if _, after, found := strings.Cut(s, " "); found {
|
||||
certbotEntry["version"] = after
|
||||
}
|
||||
}
|
||||
}
|
||||
result["certbot"] = certbotEntry
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
474
internal/api/v1/handlers_authelia.go
Normal file
474
internal/api/v1/handlers_authelia.go
Normal file
@@ -0,0 +1,474 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/authelia"
|
||||
"github.com/wild-cloud/wild-central/internal/config"
|
||||
"github.com/wild-cloud/wild-central/internal/domains"
|
||||
)
|
||||
|
||||
// AutheliaStatus returns the current Authelia service status
|
||||
func (api *API) AutheliaStatus(w http.ResponseWriter, r *http.Request) {
|
||||
status, err := api.authelia.GetStatus()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get authelia status: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
state, _ := config.LoadState(api.statePath())
|
||||
enabled := false
|
||||
domain := ""
|
||||
defaultPolicy := ""
|
||||
if state != nil {
|
||||
enabled = state.Cloud.Authelia.Enabled
|
||||
domain = state.Cloud.Authelia.Domain
|
||||
defaultPolicy = state.Cloud.Authelia.DefaultPolicy
|
||||
}
|
||||
|
||||
smtp := struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Sender string `json:"sender"`
|
||||
}{}
|
||||
if state != nil {
|
||||
smtp.Host = state.Cloud.Authelia.SMTP.Host
|
||||
smtp.Port = state.Cloud.Authelia.SMTP.Port
|
||||
smtp.Username = state.Cloud.Authelia.SMTP.Username
|
||||
smtp.Sender = state.Cloud.Authelia.SMTP.Sender
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"active": status.Active,
|
||||
"version": status.Version,
|
||||
"enabled": enabled,
|
||||
"domain": domain,
|
||||
"defaultPolicy": defaultPolicy,
|
||||
"userCount": api.authelia.UserCount(),
|
||||
"clientCount": api.authelia.OIDCClientCount(),
|
||||
"installed": api.authelia.IsInstalled(),
|
||||
"smtp": smtp,
|
||||
})
|
||||
}
|
||||
|
||||
// AutheliaGetConfig returns the raw Authelia configuration for the advanced view
|
||||
func (api *API) AutheliaGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
content, err := api.authelia.ReadConfig()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read authelia config: %v", err))
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"content": content})
|
||||
}
|
||||
|
||||
// AutheliaUpdateConfig enables/disables Authelia and updates its configuration
|
||||
func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Domain *string `json:"domain"`
|
||||
DefaultPolicy *string `json:"defaultPolicy"`
|
||||
SMTPHost *string `json:"smtpHost"`
|
||||
SMTPPort *int `json:"smtpPort"`
|
||||
SMTPUsername *string `json:"smtpUsername"`
|
||||
SMTPSender *string `json:"smtpSender"`
|
||||
SMTPPassword *string `json:"smtpPassword"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
state, err := config.LoadState(api.statePath())
|
||||
if err != nil {
|
||||
state = &config.State{}
|
||||
}
|
||||
|
||||
if req.Domain != nil {
|
||||
state.Cloud.Authelia.Domain = *req.Domain
|
||||
}
|
||||
if req.DefaultPolicy != nil {
|
||||
state.Cloud.Authelia.DefaultPolicy = *req.DefaultPolicy
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
state.Cloud.Authelia.Enabled = *req.Enabled
|
||||
}
|
||||
if req.SMTPHost != nil {
|
||||
state.Cloud.Authelia.SMTP.Host = *req.SMTPHost
|
||||
}
|
||||
if req.SMTPPort != nil {
|
||||
state.Cloud.Authelia.SMTP.Port = *req.SMTPPort
|
||||
}
|
||||
if req.SMTPUsername != nil {
|
||||
state.Cloud.Authelia.SMTP.Username = *req.SMTPUsername
|
||||
}
|
||||
if req.SMTPSender != nil {
|
||||
state.Cloud.Authelia.SMTP.Sender = *req.SMTPSender
|
||||
}
|
||||
if req.SMTPPassword != nil && *req.SMTPPassword != "" {
|
||||
_ = api.secrets.SetSecret("authelia.smtpPassword", *req.SMTPPassword)
|
||||
}
|
||||
|
||||
if state.Cloud.Authelia.Enabled {
|
||||
if err := api.enableAuthelia(state); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
api.disableAuthelia(state)
|
||||
}
|
||||
|
||||
if err := config.SaveState(state, api.statePath()); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
||||
return
|
||||
}
|
||||
go api.reconciler.Reconcile()
|
||||
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
|
||||
|
||||
respondMessage(w, http.StatusOK, "Authelia configuration updated")
|
||||
}
|
||||
|
||||
// enableAuthelia validates prerequisites, sets up services, generates config,
|
||||
// and starts Authelia. Returns a user-facing error message or nil.
|
||||
func (api *API) enableAuthelia(state *config.State) error {
|
||||
if !api.authelia.IsInstalled() {
|
||||
return fmt.Errorf("Authelia is not installed. Install it first (apt install authelia).")
|
||||
}
|
||||
if state.Cloud.Authelia.Domain == "" {
|
||||
return fmt.Errorf("Auth portal domain is required when enabling Authelia")
|
||||
}
|
||||
if !api.authelia.HasLuaScript() {
|
||||
return fmt.Errorf(
|
||||
"HAProxy auth-request Lua script not found at /etc/haproxy/lua/haproxy-auth-request.lua. " +
|
||||
"Install it: sudo mkdir -p /etc/haproxy/lua && sudo curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua " +
|
||||
"https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua")
|
||||
}
|
||||
|
||||
if err := api.authelia.EnsureDataDir(); err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
if err := api.ensureAutheliaSecrets(); err != nil {
|
||||
return fmt.Errorf("failed to generate secrets: %w", err)
|
||||
}
|
||||
if err := api.authelia.EnsureJWKS(); err != nil {
|
||||
return fmt.Errorf("failed to generate JWKS: %w", err)
|
||||
}
|
||||
if err := api.authelia.EnsureUsersDB(); err != nil {
|
||||
return fmt.Errorf("failed to initialize user database: %w", err)
|
||||
}
|
||||
if err := api.generateAutheliaConfig(state); err != nil {
|
||||
return fmt.Errorf("failed to generate config: %w", err)
|
||||
}
|
||||
|
||||
api.ensureAuthDomain(state.Cloud.Authelia.Domain)
|
||||
|
||||
if api.authelia.UserCount() > 0 {
|
||||
if err := api.authelia.RestartService(); err != nil {
|
||||
return fmt.Errorf("authelia failed to start: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// disableAuthelia stops the service and removes auth domain registrations.
|
||||
func (api *API) disableAuthelia(_ *config.State) {
|
||||
_ = api.authelia.StopService()
|
||||
api.deregisterAuthDomain()
|
||||
}
|
||||
|
||||
// AutheliaRestart restarts the Authelia service
|
||||
func (api *API) AutheliaRestart(w http.ResponseWriter, r *http.Request) {
|
||||
if err := api.authelia.RestartService(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to restart authelia: %v", err))
|
||||
return
|
||||
}
|
||||
api.broadcastAutheliaEvent("authelia:restart", "Authelia service restarted")
|
||||
respondMessage(w, http.StatusOK, "Authelia restarted")
|
||||
}
|
||||
|
||||
// --- User management ---
|
||||
|
||||
// AutheliaListUsers returns all Authelia users (passwords omitted)
|
||||
func (api *API) AutheliaListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := api.authelia.ListUsers()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list users: %v", err))
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"users": users})
|
||||
}
|
||||
|
||||
// AutheliaCreateUser creates a new Authelia user
|
||||
func (api *API) AutheliaCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var user authelia.User
|
||||
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.authelia.CreateUser(user); err != nil {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to create user: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("authelia user created", "username", user.Username)
|
||||
|
||||
// If Authelia is enabled but not running (was waiting for first user), start it
|
||||
state, _ := config.LoadState(api.statePath())
|
||||
if state != nil && state.Cloud.Authelia.Enabled {
|
||||
status, _ := api.authelia.GetStatus()
|
||||
if status != nil && !status.Active {
|
||||
if err := api.authelia.RestartService(); err != nil {
|
||||
slog.Warn("failed to start authelia after first user", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
api.broadcastAutheliaEvent("authelia:user", fmt.Sprintf("User %q created", user.Username))
|
||||
respondMessage(w, http.StatusCreated, fmt.Sprintf("User %q created", user.Username))
|
||||
}
|
||||
|
||||
// AutheliaUpdateUser updates an existing Authelia user
|
||||
func (api *API) AutheliaUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
username := mux.Vars(r)["username"]
|
||||
|
||||
var updates authelia.UserUpdate
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.authelia.UpdateUser(username, updates); err != nil {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to update user: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("authelia user updated", "username", username)
|
||||
respondMessage(w, http.StatusOK, fmt.Sprintf("User %q updated", username))
|
||||
}
|
||||
|
||||
// AutheliaDeleteUser deletes an Authelia user
|
||||
func (api *API) AutheliaDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
username := mux.Vars(r)["username"]
|
||||
|
||||
if err := api.authelia.DeleteUser(username); err != nil {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to delete user: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("authelia user deleted", "username", username)
|
||||
api.broadcastAutheliaEvent("authelia:user", fmt.Sprintf("User %q deleted", username))
|
||||
respondMessage(w, http.StatusOK, fmt.Sprintf("User %q deleted", username))
|
||||
}
|
||||
|
||||
// --- OIDC client management ---
|
||||
|
||||
// AutheliaListOIDCClients returns all registered OIDC clients (secrets redacted)
|
||||
func (api *API) AutheliaListOIDCClients(w http.ResponseWriter, r *http.Request) {
|
||||
clients, err := api.authelia.ListOIDCClients()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list OIDC clients: %v", err))
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"clients": clients})
|
||||
}
|
||||
|
||||
// AutheliaCreateOIDCClient creates a new OIDC client and returns the secret once
|
||||
func (api *API) AutheliaCreateOIDCClient(w http.ResponseWriter, r *http.Request) {
|
||||
var client authelia.OIDCClient
|
||||
if err := json.NewDecoder(r.Body).Decode(&client); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
secret, err := api.authelia.AddOIDCClient(client)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to create OIDC client: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Regenerate full config (adds OIDC section) and restart
|
||||
if err := api.regenAndRestartAuthelia(); err != nil {
|
||||
slog.Warn("failed to restart authelia after OIDC client create", "error", err)
|
||||
}
|
||||
|
||||
slog.Info("authelia OIDC client created", "clientId", client.ID)
|
||||
api.broadcastAutheliaEvent("authelia:oidc", fmt.Sprintf("OIDC client %q created", client.ID))
|
||||
|
||||
respondJSON(w, http.StatusCreated, map[string]any{
|
||||
"clientId": client.ID,
|
||||
"clientSecret": secret,
|
||||
"message": "OIDC client created. Save the client secret — it will not be shown again.",
|
||||
})
|
||||
}
|
||||
|
||||
// AutheliaUpdateOIDCClient updates an OIDC client
|
||||
func (api *API) AutheliaUpdateOIDCClient(w http.ResponseWriter, r *http.Request) {
|
||||
clientID := mux.Vars(r)["clientId"]
|
||||
|
||||
var updates authelia.OIDCClientUpdate
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.authelia.UpdateOIDCClient(clientID, updates); err != nil {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to update OIDC client: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.regenAndRestartAuthelia(); err != nil {
|
||||
slog.Warn("failed to restart authelia after OIDC client update", "error", err)
|
||||
}
|
||||
|
||||
slog.Info("authelia OIDC client updated", "clientId", clientID)
|
||||
respondMessage(w, http.StatusOK, fmt.Sprintf("OIDC client %q updated", clientID))
|
||||
}
|
||||
|
||||
// AutheliaDeleteOIDCClient deletes an OIDC client
|
||||
func (api *API) AutheliaDeleteOIDCClient(w http.ResponseWriter, r *http.Request) {
|
||||
clientID := mux.Vars(r)["clientId"]
|
||||
|
||||
if err := api.authelia.DeleteOIDCClient(clientID); err != nil {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to delete OIDC client: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.regenAndRestartAuthelia(); err != nil {
|
||||
slog.Warn("failed to restart authelia after OIDC client delete", "error", err)
|
||||
}
|
||||
|
||||
slog.Info("authelia OIDC client deleted", "clientId", clientID)
|
||||
api.broadcastAutheliaEvent("authelia:oidc", fmt.Sprintf("OIDC client %q deleted", clientID))
|
||||
respondMessage(w, http.StatusOK, fmt.Sprintf("OIDC client %q deleted", clientID))
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
// regenAndRestartAuthelia regenerates the full Authelia config and restarts the service.
|
||||
// Used after OIDC client changes to ensure the config includes the OIDC section.
|
||||
func (api *API) regenAndRestartAuthelia() error {
|
||||
state, err := config.LoadState(api.statePath())
|
||||
if err != nil || !state.Cloud.Authelia.Enabled {
|
||||
return nil
|
||||
}
|
||||
if err := api.generateAutheliaConfig(state); err != nil {
|
||||
return fmt.Errorf("regenerating config: %w", err)
|
||||
}
|
||||
if api.authelia.UserCount() > 0 {
|
||||
return api.authelia.RestartService()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureAutheliaSecrets generates Authelia secrets if they don't already exist
|
||||
func (api *API) ensureAutheliaSecrets() error {
|
||||
keys := []string{
|
||||
"authelia.jwtSecret",
|
||||
"authelia.sessionSecret",
|
||||
"authelia.storageEncryptionKey",
|
||||
"authelia.oidcHmacSecret",
|
||||
}
|
||||
for _, key := range keys {
|
||||
existing, _ := api.secrets.GetSecret(key)
|
||||
if existing != "" {
|
||||
continue
|
||||
}
|
||||
secret, err := authelia.GenerateSecret(32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating %s: %w", key, err)
|
||||
}
|
||||
if err := api.secrets.SetSecret(key, secret); err != nil {
|
||||
return fmt.Errorf("saving %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateAutheliaConfig reads secrets and state, then generates the Authelia config
|
||||
func (api *API) generateAutheliaConfig(state *config.State) error {
|
||||
jwtSecret, _ := api.secrets.GetSecret("authelia.jwtSecret")
|
||||
sessionSecret, _ := api.secrets.GetSecret("authelia.sessionSecret")
|
||||
storageEncKey, _ := api.secrets.GetSecret("authelia.storageEncryptionKey")
|
||||
oidcHmac, _ := api.secrets.GetSecret("authelia.oidcHmacSecret")
|
||||
|
||||
// Read OIDC clients from the clients file (with secrets for config generation)
|
||||
clients, _ := api.authelia.ReadOIDCClients()
|
||||
|
||||
defaultPolicy := state.Cloud.Authelia.DefaultPolicy
|
||||
if defaultPolicy == "" {
|
||||
defaultPolicy = "one_factor"
|
||||
}
|
||||
|
||||
sessionDomain := authelia.SessionDomainFromAuthDomain(state.Cloud.Authelia.Domain)
|
||||
|
||||
smtpPassword, _ := api.secrets.GetSecret("authelia.smtpPassword")
|
||||
|
||||
opts := authelia.ConfigOpts{
|
||||
Domain: state.Cloud.Authelia.Domain,
|
||||
JWTSecret: jwtSecret,
|
||||
SessionSecret: sessionSecret,
|
||||
StorageEncKey: storageEncKey,
|
||||
OIDCHMACSecret: oidcHmac,
|
||||
DefaultPolicy: defaultPolicy,
|
||||
SessionDomain: sessionDomain,
|
||||
OIDCClients: clients,
|
||||
SMTPHost: state.Cloud.Authelia.SMTP.Host,
|
||||
SMTPPort: state.Cloud.Authelia.SMTP.Port,
|
||||
SMTPUsername: state.Cloud.Authelia.SMTP.Username,
|
||||
SMTPSender: state.Cloud.Authelia.SMTP.Sender,
|
||||
SMTPPassword: smtpPassword,
|
||||
}
|
||||
|
||||
return api.authelia.GenerateConfig(opts)
|
||||
}
|
||||
|
||||
// ensureAuthDomain registers the Authelia login portal as a domain
|
||||
func (api *API) ensureAuthDomain(authDomain string) {
|
||||
if authDomain == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up stale authelia registrations
|
||||
doms, _ := api.domains.List()
|
||||
for _, dom := range doms {
|
||||
if dom.Source == "authelia" && dom.DomainName != authDomain {
|
||||
_ = api.domains.Deregister(dom.DomainName)
|
||||
}
|
||||
}
|
||||
|
||||
_ = api.domains.Register(domains.Domain{
|
||||
DomainName: authDomain,
|
||||
Source: "authelia",
|
||||
Routes: []domains.Route{
|
||||
{
|
||||
Backend: domains.Backend{
|
||||
Address: "127.0.0.1:9091",
|
||||
Type: domains.BackendHTTP,
|
||||
},
|
||||
Headers: &domains.HeaderConfig{
|
||||
Request: map[string]string{
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-Host": authDomain,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
TLS: domains.TLSTerminate,
|
||||
})
|
||||
}
|
||||
|
||||
// deregisterAuthDomain removes all authelia-sourced domain registrations
|
||||
func (api *API) deregisterAuthDomain() {
|
||||
doms, _ := api.domains.List()
|
||||
for _, dom := range doms {
|
||||
if dom.Source == "authelia" {
|
||||
_ = api.domains.Deregister(dom.DomainName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type cloudflareZone struct {
|
||||
@@ -10,12 +12,19 @@ type cloudflareZone struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type cloudflarePermissions struct {
|
||||
Zone bool `json:"zone"` // can list zones
|
||||
DNS bool `json:"dns"` // can read/edit DNS records
|
||||
}
|
||||
|
||||
type cloudflareVerifyResponse struct {
|
||||
TokenConfigured bool `json:"tokenConfigured"`
|
||||
TokenValid bool `json:"tokenValid"`
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
Zones []cloudflareZone `json:"zones"`
|
||||
Error string `json:"error,omitempty"`
|
||||
TokenConfigured bool `json:"tokenConfigured"`
|
||||
TokenValid bool `json:"tokenValid"`
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
Zones []cloudflareZone `json:"zones"`
|
||||
RequiredZones []string `json:"requiredZones"`
|
||||
Permissions cloudflarePermissions `json:"permissions"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CloudflareVerify checks whether the stored Cloudflare API token is valid
|
||||
@@ -47,6 +56,15 @@ func (api *API) CloudflareVerify(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
resp.Zones = zones
|
||||
resp.Permissions.Zone = true
|
||||
|
||||
// Test DNS access on the first available zone
|
||||
if len(zones) > 0 {
|
||||
resp.Permissions.DNS = testCloudflareDNSAccess(token, zones[0].ID)
|
||||
}
|
||||
|
||||
// Derive required zones from registered domains
|
||||
resp.RequiredZones = api.getRequiredZones()
|
||||
|
||||
respondJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -78,6 +96,36 @@ func (api *API) CloudflareSetZoneID(w http.ResponseWriter, r *http.Request) {
|
||||
respondMessage(w, http.StatusOK, "Zone ID saved")
|
||||
}
|
||||
|
||||
// getRequiredZones extracts unique root zones from all registered domains.
|
||||
// e.g., "app.cloud.payne.io" → "payne.io"
|
||||
func (api *API) getRequiredZones() []string {
|
||||
doms, err := api.domains.List()
|
||||
if err != nil || len(doms) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var zones []string
|
||||
for _, dom := range doms {
|
||||
zone := rootZone(dom.DomainName)
|
||||
if zone != "" && !seen[zone] {
|
||||
seen[zone] = true
|
||||
zones = append(zones, zone)
|
||||
}
|
||||
}
|
||||
return zones
|
||||
}
|
||||
|
||||
// rootZone extracts the registrable zone from a FQDN.
|
||||
// "app.cloud.payne.io" → "payne.io", "foo.example.com" → "example.com"
|
||||
func rootZone(domain string) string {
|
||||
parts := strings.Split(domain, ".")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts[len(parts)-2:], ".")
|
||||
}
|
||||
|
||||
func verifyCloudflareToken(apiToken string) (string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet,
|
||||
"https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
|
||||
@@ -103,6 +151,24 @@ func verifyCloudflareToken(apiToken string) (string, error) {
|
||||
return result.Result.Status, nil
|
||||
}
|
||||
|
||||
// testCloudflareDNSAccess checks if the token can list DNS records for a zone.
|
||||
func testCloudflareDNSAccess(apiToken, zoneID string) bool {
|
||||
req, err := http.NewRequest(http.MethodGet,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?per_page=1", zoneID), nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
func listCloudflareZones(apiToken string) ([]cloudflareZone, error) {
|
||||
req, err := http.NewRequest(http.MethodGet,
|
||||
"https://api.cloudflare.com/client/v4/zones", nil)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
351
internal/api/v1/handlers_dnsfilter.go
Normal file
351
internal/api/v1/handlers_dnsfilter.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/wild-cloud/wild-central/internal/config"
|
||||
"github.com/wild-cloud/wild-central/internal/dnsfilter"
|
||||
)
|
||||
|
||||
// DNSFilterStatus returns the current DNS filter status and stats.
|
||||
func (api *API) DNSFilterStatus(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := config.LoadState(api.statePath())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to load state")
|
||||
return
|
||||
}
|
||||
|
||||
stats := api.dnsFilter.GetStats()
|
||||
stats.Enabled = state.Cloud.DNSFilter.Enabled
|
||||
|
||||
if stats.Enabled {
|
||||
stats.QueryStats = api.dnsFilter.GetQueryStats()
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// DNSFilterGetConfig returns the DNS filter configuration from state.yaml.
|
||||
func (api *API) DNSFilterGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := config.LoadState(api.statePath())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to load state")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, state.Cloud.DNSFilter)
|
||||
}
|
||||
|
||||
// DNSFilterUpdateConfig enables/disables DNS filtering and updates settings.
|
||||
func (api *API) DNSFilterUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
IntervalHours *int `json:"intervalHours"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
state, err := config.LoadState(api.statePath())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to load state")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
state.Cloud.DNSFilter.Enabled = *req.Enabled
|
||||
}
|
||||
if req.IntervalHours != nil {
|
||||
state.Cloud.DNSFilter.IntervalHours = *req.IntervalHours
|
||||
}
|
||||
|
||||
if err := config.SaveState(state, api.statePath()); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
||||
return
|
||||
}
|
||||
|
||||
if state.Cloud.DNSFilter.Enabled {
|
||||
if err := api.dnsFilter.EnsureDataDir(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create data dir: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Start or restart the runner
|
||||
interval := state.Cloud.DNSFilter.IntervalHours
|
||||
if interval <= 0 {
|
||||
interval = 24
|
||||
}
|
||||
api.dnsFilterRunner.Start(api.ctx, interval)
|
||||
|
||||
slog.Info("enabled", "component", "dns-filter", "interval", interval)
|
||||
} else {
|
||||
// Stop the runner and clear addn-hosts
|
||||
api.dnsFilterRunner.Stop()
|
||||
api.dnsmasq.SetFilterConfPath("")
|
||||
|
||||
slog.Info("disabled", "component", "dns-filter")
|
||||
}
|
||||
|
||||
// Reconcile to add/remove addn-hosts from dnsmasq config
|
||||
go api.reconciler.Reconcile()
|
||||
|
||||
api.broadcastDNSFilterEvent("dns-filter:config", "DNS filter configuration updated")
|
||||
respondMessage(w, http.StatusOK, "DNS filter configuration updated")
|
||||
}
|
||||
|
||||
// DNSFilterGetLists returns all subscribed blocklists.
|
||||
func (api *API) DNSFilterGetLists(w http.ResponseWriter, r *http.Request) {
|
||||
fd, err := api.dnsFilter.LoadFilterData()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to load filter data")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"lists": fd.Lists,
|
||||
})
|
||||
}
|
||||
|
||||
// DNSFilterAddList adds a URL-based blocklist subscription.
|
||||
func (api *API) DNSFilterAddList(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
URL string `json:"url"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.URL == "" || req.Name == "" {
|
||||
respondError(w, http.StatusBadRequest, "URL and name are required")
|
||||
return
|
||||
}
|
||||
|
||||
bl := dnsfilter.Blocklist{
|
||||
URL: req.URL,
|
||||
Name: req.Name,
|
||||
}
|
||||
if err := api.dnsFilter.AddList(bl); err != nil {
|
||||
respondError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger update if runner is active
|
||||
if api.dnsFilterRunner.IsRunning() {
|
||||
api.dnsFilterRunner.Trigger()
|
||||
}
|
||||
|
||||
api.broadcastDNSFilterEvent("dns-filter:list-added", fmt.Sprintf("Added list: %s", req.Name))
|
||||
respondMessage(w, http.StatusCreated, "List added")
|
||||
}
|
||||
|
||||
// DNSFilterRemoveList removes a blocklist by ID.
|
||||
func (api *API) DNSFilterRemoveList(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
respondError(w, http.StatusBadRequest, "List ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.dnsFilter.RemoveList(id); err != nil {
|
||||
respondError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Recompile without the removed list
|
||||
go func() {
|
||||
if _, err := api.dnsFilter.Compile(); err != nil {
|
||||
slog.Warn("recompile after removal failed", "component", "dns-filter", "error", err)
|
||||
return
|
||||
}
|
||||
if err := api.dnsmasq.ReloadService(); err != nil {
|
||||
slog.Warn("reload after removal failed", "component", "dns-filter", "error", err)
|
||||
}
|
||||
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter lists updated")
|
||||
}()
|
||||
|
||||
respondMessage(w, http.StatusOK, "List removed")
|
||||
}
|
||||
|
||||
// DNSFilterToggleList enables or disables a list.
|
||||
func (api *API) DNSFilterToggleList(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.dnsFilter.ToggleList(id, req.Enabled); err != nil {
|
||||
respondError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Recompile with updated list status
|
||||
go func() {
|
||||
if _, err := api.dnsFilter.Compile(); err != nil {
|
||||
slog.Warn("recompile after toggle failed", "component", "dns-filter", "error", err)
|
||||
return
|
||||
}
|
||||
if err := api.dnsmasq.ReloadService(); err != nil {
|
||||
slog.Warn("reload after toggle failed", "component", "dns-filter", "error", err)
|
||||
}
|
||||
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter lists updated")
|
||||
}()
|
||||
|
||||
respondMessage(w, http.StatusOK, "List toggled")
|
||||
}
|
||||
|
||||
// DNSFilterGetCustomEntries returns all custom allow/block entries.
|
||||
func (api *API) DNSFilterGetCustomEntries(w http.ResponseWriter, r *http.Request) {
|
||||
fd, err := api.dnsFilter.LoadFilterData()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to load filter data")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"customEntries": fd.CustomEntries,
|
||||
})
|
||||
}
|
||||
|
||||
// DNSFilterSetCustomEntry adds or updates a custom allow/block entry.
|
||||
func (api *API) DNSFilterSetCustomEntry(w http.ResponseWriter, r *http.Request) {
|
||||
var req dnsfilter.CustomEntry
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Domain == "" {
|
||||
respondError(w, http.StatusBadRequest, "Domain is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.dnsFilter.SetCustomEntry(req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Recompile with updated custom entries
|
||||
go func() {
|
||||
if _, err := api.dnsFilter.Compile(); err != nil {
|
||||
slog.Warn("recompile after custom entry failed", "component", "dns-filter", "error", err)
|
||||
return
|
||||
}
|
||||
if err := api.dnsmasq.ReloadService(); err != nil {
|
||||
slog.Warn("reload after custom entry failed", "component", "dns-filter", "error", err)
|
||||
}
|
||||
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter custom entry updated")
|
||||
}()
|
||||
|
||||
respondMessage(w, http.StatusOK, "Custom entry saved")
|
||||
}
|
||||
|
||||
// DNSFilterRemoveCustomEntry removes a custom entry by domain.
|
||||
func (api *API) DNSFilterRemoveCustomEntry(w http.ResponseWriter, r *http.Request) {
|
||||
domain := mux.Vars(r)["domain"]
|
||||
if domain == "" {
|
||||
respondError(w, http.StatusBadRequest, "Domain is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.dnsFilter.RemoveCustomEntry(domain); err != nil {
|
||||
respondError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Recompile
|
||||
go func() {
|
||||
if _, err := api.dnsFilter.Compile(); err != nil {
|
||||
slog.Warn("recompile after custom removal failed", "component", "dns-filter", "error", err)
|
||||
return
|
||||
}
|
||||
if err := api.dnsmasq.ReloadService(); err != nil {
|
||||
slog.Warn("reload after custom removal failed", "component", "dns-filter", "error", err)
|
||||
}
|
||||
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter custom entry removed")
|
||||
}()
|
||||
|
||||
respondMessage(w, http.StatusOK, "Custom entry removed")
|
||||
}
|
||||
|
||||
// DNSFilterTriggerUpdate triggers an immediate list update.
|
||||
func (api *API) DNSFilterTriggerUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if !api.dnsFilterRunner.IsRunning() {
|
||||
respondError(w, http.StatusBadRequest, "DNS filter is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
api.dnsFilterRunner.Trigger()
|
||||
respondJSON(w, http.StatusAccepted, map[string]string{
|
||||
"message": "Update triggered",
|
||||
})
|
||||
}
|
||||
|
||||
// DNSFilterUploadList handles file upload for a blocklist.
|
||||
func (api *API) DNSFilterUploadList(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(50 << 20); err != nil { // 50MB limit
|
||||
respondError(w, http.StatusBadRequest, "Failed to parse upload")
|
||||
return
|
||||
}
|
||||
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
respondError(w, http.StatusBadRequest, "Name is required")
|
||||
return
|
||||
}
|
||||
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "File is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
content, err := io.ReadAll(io.LimitReader(file, 50<<20))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to read file")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := api.dnsFilter.AddUploadedList(name, content)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Recompile with new list
|
||||
go func() {
|
||||
if _, err := api.dnsFilter.Compile(); err != nil {
|
||||
slog.Warn("recompile after upload failed", "component", "dns-filter", "error", err)
|
||||
return
|
||||
}
|
||||
if err := api.dnsmasq.ReloadService(); err != nil {
|
||||
slog.Warn("reload after upload failed", "component", "dns-filter", "error", err)
|
||||
}
|
||||
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter list uploaded")
|
||||
}()
|
||||
|
||||
respondJSON(w, http.StatusCreated, map[string]string{
|
||||
"id": id,
|
||||
"message": "List uploaded",
|
||||
})
|
||||
}
|
||||
|
||||
// DNSFilterGetSuggested returns well-known blocklists for easy adding.
|
||||
func (api *API) DNSFilterGetSuggested(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"lists": dnsfilter.SuggestedLists(),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -168,17 +178,18 @@ func TestReconciliation_HAProxyConfigFromDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestReconciliation_DnsmasqConfigFromDomains verifies that reconciliation
|
||||
// correctly generates dnsmasq config from registered domains, including
|
||||
// the critical reach-based local=/ directives.
|
||||
// correctly generates dnsmasq config from registered domains. Wildcard
|
||||
// domains (subdomains:true) get address=/, exact-match domains get
|
||||
// host-record=. No local=/ directives — AAAA is handled by filter-AAAA.
|
||||
func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
|
||||
api, _ := setupTestAPI(t)
|
||||
|
||||
centralIP := "192.168.8.151"
|
||||
|
||||
// Register domains with different reach and backend types
|
||||
// Register domains with different backend types and subdomain settings
|
||||
testDomains := []domains.Domain{
|
||||
{
|
||||
// tcp-passthrough, public → DNS points to k8s LB IP, no local=/
|
||||
// tcp-passthrough, wildcard → address=/ with backend IP
|
||||
DomainName: "cloud.payne.io",
|
||||
Source: "wild-cloud",
|
||||
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
|
||||
@@ -186,13 +197,13 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
// http, internal → DNS points to Central IP, has local=/
|
||||
// http, exact → host-record= with Central IP
|
||||
DomainName: "my-api.payne.io",
|
||||
Source: "wild-works",
|
||||
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
|
||||
},
|
||||
{
|
||||
// http, public → DNS points to Central IP, NO local=/
|
||||
// http, exact → host-record= with Central IP
|
||||
DomainName: "public-app.payne.io",
|
||||
Source: "wild-works",
|
||||
Backend: domains.Backend{Address: "192.168.8.60:8080", Type: domains.BackendHTTP},
|
||||
@@ -226,35 +237,35 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
||||
Domain: dom.DomainName,
|
||||
IP: dnsIP,
|
||||
Domain: dom.DomainName,
|
||||
IP: dnsIP,
|
||||
Wildcard: dom.Subdomains,
|
||||
})
|
||||
}
|
||||
|
||||
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
|
||||
|
||||
// --- TCP passthrough (public) → backend IP ---
|
||||
// --- Wildcard (subdomains:true) → address=/ with backend IP ---
|
||||
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
|
||||
t.Errorf("tcp-passthrough domain must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg)
|
||||
t.Errorf("wildcard domain must use address=/ with backend IP:\n%s", dnsmasqCfg)
|
||||
}
|
||||
|
||||
// --- HTTP domains → Central IP ---
|
||||
if !strings.Contains(dnsmasqCfg, "address=/my-api.payne.io/192.168.8.151") {
|
||||
t.Errorf("http/internal domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
|
||||
// --- Exact-match HTTP domains → host-record= with Central IP ---
|
||||
if !strings.Contains(dnsmasqCfg, "host-record=my-api.payne.io,192.168.8.151") {
|
||||
t.Errorf("exact-match http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
|
||||
}
|
||||
if !strings.Contains(dnsmasqCfg, "address=/public-app.payne.io/192.168.8.151") {
|
||||
t.Errorf("http/public domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
|
||||
if !strings.Contains(dnsmasqCfg, "host-record=public-app.payne.io,192.168.8.151") {
|
||||
t.Errorf("exact-match http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
|
||||
}
|
||||
|
||||
// --- All domains get local=/ to prevent AAAA leaking upstream (Happy Eyeballs) ---
|
||||
if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") {
|
||||
t.Errorf("domain must have local=/ entry:\n%s", dnsmasqCfg)
|
||||
// --- No local=/ directives (AAAA handled by filter-AAAA) ---
|
||||
if strings.Contains(dnsmasqCfg, "local=/") {
|
||||
t.Errorf("must not produce local=/ directives:\n%s", dnsmasqCfg)
|
||||
}
|
||||
if !strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") {
|
||||
t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg)
|
||||
}
|
||||
if !strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") {
|
||||
t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg)
|
||||
|
||||
// --- filter-AAAA present ---
|
||||
if !strings.Contains(dnsmasqCfg, "filter-AAAA") {
|
||||
t.Errorf("must include filter-AAAA directive:\n%s", dnsmasqCfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,19 +356,20 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
|
||||
dnsIP = extractHost(dom.Backend.Address)
|
||||
}
|
||||
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
||||
Domain: dom.DomainName,
|
||||
IP: dnsIP,
|
||||
Domain: dom.DomainName,
|
||||
IP: dnsIP,
|
||||
Wildcard: dom.Subdomains,
|
||||
})
|
||||
}
|
||||
|
||||
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
|
||||
|
||||
// TCP passthrough → DNS points to backend (k8s LB), NOT central
|
||||
// TCP passthrough with subdomains → address=/ to backend (k8s LB), NOT central
|
||||
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
|
||||
t.Errorf("tcp-passthrough DNS must point to backend IP 192.168.8.240:\n%s", dnsmasqCfg)
|
||||
t.Errorf("wildcard tcp-passthrough DNS must use address=/ to backend IP 192.168.8.240:\n%s", dnsmasqCfg)
|
||||
}
|
||||
if strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.151") {
|
||||
t.Errorf("tcp-passthrough DNS must NOT point to Central IP:\n%s", dnsmasqCfg)
|
||||
if strings.Contains(dnsmasqCfg, "host-record=cloud.payne.io,192.168.8.151") {
|
||||
t.Errorf("wildcard tcp-passthrough DNS must NOT use host-record= to Central IP:\n%s", dnsmasqCfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,20 +449,21 @@ func TestReconciliation_DNSOnlyNoGateway(t *testing.T) {
|
||||
dnsIP = extractHost(dom.Backend.Address)
|
||||
}
|
||||
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
||||
Domain: dom.DomainName,
|
||||
IP: dnsIP,
|
||||
Domain: dom.DomainName,
|
||||
IP: dnsIP,
|
||||
Wildcard: dom.Subdomains,
|
||||
})
|
||||
}
|
||||
|
||||
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
|
||||
|
||||
// dns-only domain should resolve to its backend IP (192.168.8.222)
|
||||
if !strings.Contains(dnsmasqCfg, "address=/dev.payne.io/192.168.8.222") {
|
||||
t.Errorf("dns-only domain must point DNS to backend IP:\n%s", dnsmasqCfg)
|
||||
// dns-only domain (public, no subdomains) → host-record= with backend IP
|
||||
if !strings.Contains(dnsmasqCfg, "host-record=dev.payne.io,192.168.8.222") {
|
||||
t.Errorf("dns-only domain must use host-record= with backend IP:\n%s", dnsmasqCfg)
|
||||
}
|
||||
// HTTP domain should resolve to Central IP
|
||||
if !strings.Contains(dnsmasqCfg, "address=/app.payne.io/192.168.8.151") {
|
||||
t.Errorf("http domain must point DNS to Central IP:\n%s", dnsmasqCfg)
|
||||
// HTTP domain (no subdomains) → host-record= with Central IP
|
||||
if !strings.Contains(dnsmasqCfg, "host-record=app.payne.io,192.168.8.151") {
|
||||
t.Errorf("http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,18 +2,9 @@ package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/certbot"
|
||||
"github.com/wild-cloud/wild-central/internal/config"
|
||||
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
||||
"github.com/wild-cloud/wild-central/internal/domains"
|
||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
||||
"github.com/wild-cloud/wild-central/internal/network"
|
||||
"github.com/wild-cloud/wild-central/internal/sse"
|
||||
)
|
||||
|
||||
@@ -60,258 +51,7 @@ func (api *API) EnsureCentralDomain() {
|
||||
// Reconcile runs networking reconciliation immediately. Called on startup
|
||||
// and automatically whenever a domain is registered/updated/deregistered.
|
||||
func (api *API) Reconcile() {
|
||||
api.reconcileNetworking()
|
||||
}
|
||||
|
||||
// reconcileNetworking reads all registered domains and regenerates
|
||||
// dnsmasq DNS entries and HAProxy routes to match.
|
||||
func (api *API) reconcileNetworking() {
|
||||
doms, err := api.domains.List()
|
||||
if err != nil {
|
||||
slog.Error("reconcile: failed to list domains", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
globalCfg, err := config.LoadState(api.statePath())
|
||||
if err != nil {
|
||||
slog.Warn("reconcile: failed to load state, using empty", "error", err)
|
||||
globalCfg = &config.State{}
|
||||
}
|
||||
|
||||
// Build HAProxy routes from registered domains
|
||||
var l4Routes []haproxy.L4Route
|
||||
var httpRoutes []haproxy.HTTPRoute
|
||||
|
||||
for _, dom := range doms {
|
||||
switch dom.EffectiveBackendType() {
|
||||
case domains.BackendTCPPassthrough:
|
||||
l4Routes = append(l4Routes, haproxy.L4Route{
|
||||
Name: dom.DomainName,
|
||||
Domain: dom.DomainName,
|
||||
BackendIP: extractHost(dom.EffectiveBackendAddress()),
|
||||
Subdomains: dom.Subdomains,
|
||||
})
|
||||
case domains.BackendDNSOnly:
|
||||
// dns-only: no gateway routing, just DNS resolution
|
||||
case domains.BackendHTTP:
|
||||
var routeBackends []haproxy.HTTPRouteBackend
|
||||
for _, r := range dom.EffectiveRoutes() {
|
||||
routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{
|
||||
Paths: r.Paths,
|
||||
Backend: r.Backend.Address,
|
||||
HealthPath: r.Backend.Health,
|
||||
Headers: r.Headers,
|
||||
IPAllow: r.IPAllow,
|
||||
})
|
||||
}
|
||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||
Name: dom.DomainName,
|
||||
Domain: dom.DomainName,
|
||||
Subdomains: dom.Subdomains,
|
||||
Routes: routeBackends,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any 0-byte cert files that would poison HAProxy validation.
|
||||
// A 0-byte .pem in the certs directory causes the global `bind ssl crt`
|
||||
// to fail, taking down ALL L7 routes — not just the broken domain.
|
||||
certsDir := "/etc/haproxy/certs/"
|
||||
if entries, err := os.ReadDir(certsDir); err == nil {
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".pem") {
|
||||
if info, err := e.Info(); err == nil && info.Size() == 0 {
|
||||
slog.Warn("reconcile: removing 0-byte cert file", "file", e.Name())
|
||||
_ = os.Remove(filepath.Join(certsDir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only include L7 HTTP routes for domains that have a valid cert.
|
||||
var activeHTTPRoutes []haproxy.HTTPRoute
|
||||
for _, route := range httpRoutes {
|
||||
if hasCertForDomain(certsDir, route.Domain) {
|
||||
activeHTTPRoutes = append(activeHTTPRoutes, route)
|
||||
} else {
|
||||
slog.Warn("reconcile: skipping L7 route (no cert)", "domain", route.Domain)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate and write HAProxy config
|
||||
genOpts := haproxy.GenerateOpts{
|
||||
HTTPRoutes: activeHTTPRoutes,
|
||||
CertsDir: certsDir,
|
||||
}
|
||||
haproxyCfg := api.haproxy.GenerateWithOpts(l4Routes, nil, genOpts)
|
||||
|
||||
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
|
||||
// Validation failed — identify and exclude broken domains, then retry
|
||||
brokenDomains := haproxy.FindBrokenServices(haproxyCfg, haproxy.ParseValidationErrors(err.Error()))
|
||||
if len(brokenDomains) > 0 {
|
||||
slog.Error("reconcile: excluding broken domains and retrying",
|
||||
"broken", brokenDomains, "error", err)
|
||||
|
||||
exclude := map[string]bool{}
|
||||
for _, d := range brokenDomains {
|
||||
exclude[d] = true
|
||||
}
|
||||
|
||||
var filteredInstances []haproxy.L4Route
|
||||
for _, r := range l4Routes {
|
||||
if !exclude[r.Domain] {
|
||||
filteredInstances = append(filteredInstances, r)
|
||||
}
|
||||
}
|
||||
var filteredHTTP []haproxy.HTTPRoute
|
||||
for _, r := range activeHTTPRoutes {
|
||||
if !exclude[r.Domain] {
|
||||
filteredHTTP = append(filteredHTTP, r)
|
||||
}
|
||||
}
|
||||
|
||||
genOpts.HTTPRoutes = filteredHTTP
|
||||
haproxyCfg = api.haproxy.GenerateWithOpts(filteredInstances, nil, genOpts)
|
||||
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
|
||||
slog.Error("reconcile: retry also failed", "error", err)
|
||||
} else {
|
||||
if err := api.haproxy.ReloadService(); err != nil {
|
||||
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
|
||||
} else {
|
||||
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated (excluded broken domains)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Error("reconcile: failed to write HAProxy config (no broken domains identified)", "error", err)
|
||||
}
|
||||
} else {
|
||||
if err := api.haproxy.ReloadService(); err != nil {
|
||||
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
|
||||
} else {
|
||||
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated from registered domains")
|
||||
}
|
||||
}
|
||||
|
||||
// Build dnsmasq DNS entries from registered domains.
|
||||
// DNS target IP depends on backend type:
|
||||
// tcp-passthrough/dns-only → backend IP (LAN traffic goes direct)
|
||||
// http → Central IP (HAProxy terminates TLS)
|
||||
// Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected
|
||||
// (auto-detect can pick wlan0 if that's the default route).
|
||||
centralIP := globalCfg.Cloud.Dnsmasq.IP
|
||||
if centralIP == "" {
|
||||
centralIP, _ = network.GetWildCentralIP()
|
||||
}
|
||||
if centralIP == "" {
|
||||
centralIP = "127.0.0.1"
|
||||
}
|
||||
|
||||
var dnsEntries []dnsmasq.DNSEntry
|
||||
for _, dom := range doms {
|
||||
if dom.DomainName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
dnsIP := centralIP
|
||||
bt := dom.EffectiveBackendType()
|
||||
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
|
||||
dnsIP = extractHost(dom.EffectiveBackendAddress())
|
||||
}
|
||||
|
||||
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
||||
Domain: dom.DomainName,
|
||||
IP: dnsIP,
|
||||
})
|
||||
}
|
||||
|
||||
if err := api.dnsmasq.UpdateConfig(globalCfg, dnsEntries, true); err != nil {
|
||||
slog.Error("reconcile: failed to update dnsmasq", "error", err)
|
||||
} else {
|
||||
api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered domains")
|
||||
}
|
||||
|
||||
// Ensure TLS certs exist for HTTP domains (where Central terminates TLS).
|
||||
// For domains under the gateway domain, a wildcard cert covers them all.
|
||||
// For others, provision individual certs.
|
||||
if len(httpRoutes) > 0 {
|
||||
api.ensureTLSCerts(globalCfg, doms)
|
||||
}
|
||||
|
||||
// Nudge DDNS to pick up any domain changes (public toggle, new domains).
|
||||
// The runner reads fresh params on each check via its callback.
|
||||
api.ddns.Trigger()
|
||||
|
||||
slog.Info("reconcile: networking updated",
|
||||
"domains", len(doms),
|
||||
"l4Routes", len(l4Routes),
|
||||
"l7Routes", len(httpRoutes),
|
||||
)
|
||||
}
|
||||
|
||||
// ensureTLSCerts logs which certificates are missing for registered domains.
|
||||
// Does NOT auto-provision — cert provisioning is user-initiated via the
|
||||
// Certificates page. This just logs warnings so the operator knows what's needed.
|
||||
func (api *API) ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) {
|
||||
gatewayDomain := ""
|
||||
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
|
||||
gatewayDomain = parts[1]
|
||||
}
|
||||
|
||||
for _, dom := range doms {
|
||||
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if covered by wildcard
|
||||
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
|
||||
wildcardCert := certbot.HAProxyCertPath(gatewayDomain)
|
||||
if _, err := os.Stat(wildcardCert); err != nil {
|
||||
slog.Warn("reconcile/tls: no wildcard cert for gateway domain — provision via Certificates page",
|
||||
"domain", dom.DomainName, "needed", "*."+gatewayDomain)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check individual cert
|
||||
if _, err := os.Stat(certbot.HAProxyCertPath(dom.DomainName)); err != nil {
|
||||
slog.Warn("reconcile/tls: no cert for domain — provision via Certificates page",
|
||||
"domain", dom.DomainName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasCertForDomain checks if a valid (non-empty) cert exists for a domain —
|
||||
// either an individual cert (<domain>.pem) or a wildcard cert that covers it.
|
||||
func hasCertForDomain(_, domain string) bool {
|
||||
// Check individual cert
|
||||
if isValidCertFile(certbot.HAProxyCertPath(domain)) {
|
||||
return true
|
||||
}
|
||||
// Check wildcard certs — a *.example.com cert covers foo.example.com
|
||||
parts := strings.SplitN(domain, ".", 2)
|
||||
if len(parts) == 2 {
|
||||
wildcardBase := parts[1]
|
||||
if isValidCertFile(certbot.HAProxyCertPath(wildcardBase)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isValidCertFile checks that a cert file exists and is non-empty.
|
||||
func isValidCertFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.Size() > 0
|
||||
}
|
||||
|
||||
// extractHost gets the host part from a host:port string
|
||||
func extractHost(addr string) string {
|
||||
for i := len(addr) - 1; i >= 0; i-- {
|
||||
if addr[i] == ':' {
|
||||
return addr[:i]
|
||||
}
|
||||
}
|
||||
return addr
|
||||
api.reconciler.Reconcile()
|
||||
}
|
||||
|
||||
// broadcastDnsmasqEvent broadcasts SSE events for dnsmasq status changes
|
||||
@@ -359,6 +99,25 @@ func (api *API) broadcastHaproxyEvent(eventType string, message string) {
|
||||
api.sseManager.Broadcast(event)
|
||||
}
|
||||
|
||||
// broadcastAutheliaEvent broadcasts SSE events for Authelia status changes
|
||||
func (api *API) broadcastAutheliaEvent(eventType string, message string) {
|
||||
if api.sseManager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := &sse.Event{
|
||||
ID: fmt.Sprintf("authelia-%d", time.Now().UnixNano()),
|
||||
Type: eventType,
|
||||
InstanceName: "global",
|
||||
Timestamp: time.Now(),
|
||||
Data: map[string]any{
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
|
||||
api.sseManager.Broadcast(event)
|
||||
}
|
||||
|
||||
// broadcastCentralStatusEvent broadcasts SSE events for central status changes
|
||||
func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
|
||||
if api.sseManager == nil {
|
||||
@@ -382,3 +141,22 @@ func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
|
||||
|
||||
api.sseManager.Broadcast(event)
|
||||
}
|
||||
|
||||
// broadcastDNSFilterEvent broadcasts SSE events for DNS filter changes
|
||||
func (api *API) broadcastDNSFilterEvent(eventType string, message string) {
|
||||
if api.sseManager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := &sse.Event{
|
||||
ID: fmt.Sprintf("dns-filter-%d", time.Now().UnixNano()),
|
||||
Type: eventType,
|
||||
InstanceName: "global",
|
||||
Timestamp: time.Now(),
|
||||
Data: map[string]any{
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
|
||||
api.sseManager.Broadcast(event)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
86
internal/authelia/TODO.md
Normal file
86
internal/authelia/TODO.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Authelia Packaging TODO
|
||||
|
||||
These steps must be added to the postinst script (`wild-cloud/dist/debian/DEBIAN/postinst`)
|
||||
when packaging Wild Central for distribution.
|
||||
|
||||
## Install Authelia
|
||||
|
||||
```bash
|
||||
if ! command -v authelia &>/dev/null; then
|
||||
curl -fsSL https://apt.authelia.com/organization/signing.asc | \
|
||||
gpg --dearmor -o /usr/share/keyrings/authelia-archive-keyring.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/authelia-archive-keyring.gpg] \
|
||||
https://apt.authelia.com/integration/apt/repo/stable/debian debian main" \
|
||||
> /etc/apt/sources.list.d/authelia.list
|
||||
apt-get update -qq
|
||||
apt-get install -y authelia
|
||||
fi
|
||||
```
|
||||
|
||||
## Install HAProxy Lua scripts
|
||||
|
||||
```bash
|
||||
mkdir -p /etc/haproxy/lua
|
||||
curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua \
|
||||
https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua
|
||||
curl -fsSL -o /etc/haproxy/lua/haproxy-lua-http.lua \
|
||||
https://raw.githubusercontent.com/haproxytech/haproxy-lua-http/master/http.lua
|
||||
apt-get install -y lua-json
|
||||
```
|
||||
|
||||
## Systemd service override
|
||||
|
||||
```bash
|
||||
mkdir -p /etc/systemd/system/authelia.service.d
|
||||
cat > /etc/systemd/system/authelia.service.d/wild-central.conf << EOF
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/authelia --config /var/lib/wild-central/authelia/configuration.yml
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
```
|
||||
|
||||
## Data directory
|
||||
|
||||
```bash
|
||||
mkdir -p /var/lib/wild-central/authelia
|
||||
chown wildcloud:wildcloud /var/lib/wild-central/authelia
|
||||
chmod 700 /var/lib/wild-central/authelia
|
||||
```
|
||||
|
||||
## Polkit rule (manages all Wild Central services, not just Authelia)
|
||||
|
||||
```bash
|
||||
cat > /etc/polkit-1/rules.d/50-wild-central.rules << 'EOF'
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
subject.isInGroup("wildcloud")) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Certificate Issues TODO
|
||||
|
||||
Problems discovered during Authelia integration that need fixing in the cert management system.
|
||||
|
||||
## `hasCertForDomain` doesn't verify cert actually covers the domain
|
||||
|
||||
`hasCertForDomain()` in `helpers.go` checks if a PEM file exists at the expected path (e.g. `payne.io.pem` for wildcard coverage of `auth.payne.io`), but never verifies the cert's Subject/SAN actually matches. We had a `*.payne.io.pem` file that was actually a cert for `central.payne.io` — it passed the existence check but served the wrong cert at runtime, causing TLS errors.
|
||||
|
||||
**Fix:** Parse the cert and verify the SAN covers the requested domain, or at minimum check for `CN=*.domain` when relying on a wildcard.
|
||||
|
||||
## Cert provisioning deploy hook can produce misnamed files
|
||||
|
||||
The certbot deploy hook builds an HAProxy PEM from `/etc/letsencrypt/live/{cert-name}/`. The cert name doesn't always match the domain (e.g. certbot may name a `*.payne.io` wildcard cert as `payne.io`). If the PEM filename doesn't match what `HAProxyCertPath()` expects, the cert won't be found or will be confused with a different cert.
|
||||
|
||||
**Fix:** The deploy hook or `BuildHAProxyCert` should name the PEM based on the cert's actual SAN, not just the certbot cert name.
|
||||
|
||||
## UI has no "re-provision" option when cert exists but is wrong
|
||||
|
||||
The Certificates UI considers a cert valid if the PEM file exists and is non-empty. There's no way to re-provision if the cert covers the wrong domain or is otherwise invalid. The user had to manually delete the bad PEM to trigger re-provisioning.
|
||||
|
||||
**Fix:** Add a "Re-provision" or "Renew" action per domain in the Certificates UI, and show what domain(s) the cert actually covers (parsed from SAN).
|
||||
277
internal/authelia/config.go
Normal file
277
internal/authelia/config.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package authelia
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ConfigOpts holds parameters for generating Authelia's configuration.yml
|
||||
type ConfigOpts struct {
|
||||
Domain string // auth portal domain (e.g. auth.payne.io)
|
||||
JWTSecret string // identity validation JWT secret
|
||||
SessionSecret string // session cookie secret
|
||||
StorageEncKey string // storage encryption key (>=20 chars)
|
||||
OIDCHMACSecret string // OIDC HMAC secret
|
||||
DefaultPolicy string // "one_factor" or "two_factor"
|
||||
SessionDomain string // cookie domain for SSO (e.g. payne.io) — forward-auth only works for subdomains of this
|
||||
DataDir string // authelia data directory path
|
||||
ListenAddr string // listen address (default: 127.0.0.1:9091)
|
||||
OIDCClients []OIDCClient // registered OIDC clients
|
||||
SMTPHost string // SMTP server host (empty = use filesystem notifier)
|
||||
SMTPPort int // SMTP server port (default: 587)
|
||||
SMTPUsername string // SMTP login username (defaults to sender if empty)
|
||||
SMTPSender string // From address
|
||||
SMTPPassword string // SMTP password (from secrets)
|
||||
}
|
||||
|
||||
// ValidateConfigOpts checks that required fields are present and valid.
|
||||
func ValidateConfigOpts(opts ConfigOpts) error {
|
||||
if opts.Domain == "" {
|
||||
return fmt.Errorf("auth portal domain is required")
|
||||
}
|
||||
if opts.JWTSecret == "" {
|
||||
return fmt.Errorf("JWT secret is required")
|
||||
}
|
||||
if opts.SessionSecret == "" {
|
||||
return fmt.Errorf("session secret is required")
|
||||
}
|
||||
if len(opts.StorageEncKey) < 20 {
|
||||
return fmt.Errorf("storage encryption key must be at least 20 characters, got %d", len(opts.StorageEncKey))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateConfig validates options, builds Authelia's configuration.yml, and writes it atomically.
|
||||
func (m *Manager) GenerateConfig(opts ConfigOpts) error {
|
||||
if err := ValidateConfigOpts(opts); err != nil {
|
||||
return fmt.Errorf("config validation: %w", err)
|
||||
}
|
||||
if err := m.EnsureDataDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.ListenAddr == "" {
|
||||
opts.ListenAddr = "127.0.0.1:9091"
|
||||
}
|
||||
if opts.DefaultPolicy == "" {
|
||||
opts.DefaultPolicy = "one_factor"
|
||||
}
|
||||
if opts.DataDir == "" {
|
||||
opts.DataDir = m.dataDir
|
||||
}
|
||||
|
||||
cfg := m.buildConfig(opts)
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling authelia config: %w", err)
|
||||
}
|
||||
|
||||
header := "# Authelia Configuration\n# Managed by Wild Central — do not edit manually\n---\n"
|
||||
content := header + string(data)
|
||||
|
||||
tmpPath := m.configPath() + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, []byte(content), 0600); err != nil {
|
||||
return fmt.Errorf("writing temp config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, m.configPath()); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("installing config: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("authelia config written", "component", "authelia", "path", m.configPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureJWKS generates an RSA key pair for OIDC token signing if one doesn't exist.
|
||||
func (m *Manager) EnsureJWKS() error {
|
||||
if _, err := os.Stat(m.jwksPath()); err == nil {
|
||||
return nil // already exists
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating RSA key: %w", err)
|
||||
}
|
||||
|
||||
keyBytes := x509.MarshalPKCS1PrivateKey(key)
|
||||
pemBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyBytes}
|
||||
pemData := pem.EncodeToMemory(pemBlock)
|
||||
|
||||
if err := os.WriteFile(m.jwksPath(), pemData, 0600); err != nil {
|
||||
return fmt.Errorf("writing JWKS key: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("authelia JWKS key pair generated", "component", "authelia", "path", m.jwksPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildConfig constructs the Authelia config as a nested map for YAML marshaling.
|
||||
func (m *Manager) buildConfig(opts ConfigOpts) map[string]any {
|
||||
cfg := map[string]any{
|
||||
"theme": "auto",
|
||||
|
||||
"server": map[string]any{
|
||||
"address": "tcp://" + opts.ListenAddr + "/",
|
||||
},
|
||||
|
||||
"log": map[string]any{
|
||||
"level": "info",
|
||||
},
|
||||
|
||||
"totp": map[string]any{
|
||||
"issuer": "Wild Cloud",
|
||||
},
|
||||
|
||||
"authentication_backend": map[string]any{
|
||||
"file": map[string]any{
|
||||
"path": m.usersDBPath(),
|
||||
"password": map[string]any{
|
||||
"algorithm": "argon2id",
|
||||
"iterations": 3,
|
||||
"memory": 65536,
|
||||
"parallelism": 2,
|
||||
"key_length": 32,
|
||||
"salt_length": 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"access_control": map[string]any{
|
||||
"default_policy": opts.DefaultPolicy,
|
||||
},
|
||||
|
||||
"session": map[string]any{
|
||||
"name": "wild_central_session",
|
||||
"secret": opts.SessionSecret,
|
||||
"cookies": []map[string]any{
|
||||
{
|
||||
"domain": opts.SessionDomain,
|
||||
"authelia_url": "https://" + opts.Domain,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"storage": map[string]any{
|
||||
"encryption_key": opts.StorageEncKey,
|
||||
"local": map[string]any{
|
||||
"path": m.sqlitePath(),
|
||||
},
|
||||
},
|
||||
|
||||
"notifier": buildNotifier(opts, m.notificationPath()),
|
||||
|
||||
"identity_validation": map[string]any{
|
||||
"reset_password": map[string]any{
|
||||
"jwt_secret": opts.JWTSecret,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// OIDC provider configuration — only emitted when clients exist.
|
||||
// Authelia requires at least one client when the OIDC section is present.
|
||||
if opts.OIDCHMACSecret != "" && len(opts.OIDCClients) > 0 {
|
||||
oidcCfg := map[string]any{
|
||||
"hmac_secret": opts.OIDCHMACSecret,
|
||||
}
|
||||
|
||||
// Inline the JWKS private key directly (avoids needing template filters)
|
||||
if jwksData, err := os.ReadFile(m.jwksPath()); err == nil {
|
||||
oidcCfg["jwks"] = []map[string]any{
|
||||
{
|
||||
"key_id": "wild-central",
|
||||
"algorithm": "RS256",
|
||||
"use": "sig",
|
||||
"key": string(jwksData),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var clients []map[string]any
|
||||
for _, c := range opts.OIDCClients {
|
||||
client := map[string]any{
|
||||
"client_id": c.ID,
|
||||
"client_name": c.Name,
|
||||
"client_secret": c.Secret, // already hashed
|
||||
"redirect_uris": c.RedirectURIs,
|
||||
"scopes": c.Scopes,
|
||||
"authorization_policy": c.Policy,
|
||||
}
|
||||
if c.ConsentMode != "" {
|
||||
client["consent_mode"] = c.ConsentMode
|
||||
}
|
||||
clients = append(clients, client)
|
||||
}
|
||||
oidcCfg["clients"] = clients
|
||||
}
|
||||
|
||||
cfg["identity_providers"] = map[string]any{
|
||||
"oidc": oidcCfg,
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// buildNotifier returns the notifier config — SMTP if configured, filesystem otherwise.
|
||||
func buildNotifier(opts ConfigOpts, filesystemPath string) map[string]any {
|
||||
if opts.SMTPHost != "" && opts.SMTPSender != "" {
|
||||
username := opts.SMTPUsername
|
||||
if username == "" {
|
||||
username = opts.SMTPSender
|
||||
}
|
||||
port := opts.SMTPPort
|
||||
if port == 0 {
|
||||
port = 587
|
||||
}
|
||||
// Use the modern address format: submission:// for STARTTLS on 587, smtps:// for implicit TLS on 465
|
||||
scheme := "submission"
|
||||
if port == 465 {
|
||||
scheme = "smtps"
|
||||
}
|
||||
smtp := map[string]any{
|
||||
"address": fmt.Sprintf("%s://%s:%d", scheme, opts.SMTPHost, port),
|
||||
"sender": opts.SMTPSender,
|
||||
"username": username,
|
||||
"password": opts.SMTPPassword,
|
||||
"tls": map[string]any{
|
||||
"server_name": opts.SMTPHost,
|
||||
},
|
||||
}
|
||||
return map[string]any{"smtp": smtp}
|
||||
}
|
||||
return map[string]any{
|
||||
"filesystem": map[string]any{
|
||||
"filename": filesystemPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateSecret creates a cryptographically random hex string of the given byte length.
|
||||
func GenerateSecret(byteLen int) (string, error) {
|
||||
b := make([]byte, byteLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generating random bytes: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", b), nil
|
||||
}
|
||||
|
||||
// SessionDomainFromAuthDomain extracts the parent domain for session cookie scoping.
|
||||
// "auth.payne.io" -> "payne.io", "auth.sub.example.com" -> "sub.example.com"
|
||||
func SessionDomainFromAuthDomain(authDomain string) string {
|
||||
parts := strings.SplitN(authDomain, ".", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return authDomain
|
||||
}
|
||||
205
internal/authelia/manager.go
Normal file
205
internal/authelia/manager.go
Normal file
@@ -0,0 +1,205 @@
|
||||
// Package authelia manages the Authelia authentication service.
|
||||
// Authelia provides centralized authentication for network services via
|
||||
// HAProxy forward-auth and acts as an OIDC provider for apps with native support.
|
||||
package authelia
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status represents the current state of the Authelia service
|
||||
type Status struct {
|
||||
Active bool `json:"active"`
|
||||
Version string `json:"version,omitempty"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
LastRestart time.Time `json:"lastRestart,omitempty"`
|
||||
}
|
||||
|
||||
// Manager handles Authelia configuration and service lifecycle
|
||||
type Manager struct {
|
||||
dataDir string // e.g. /var/lib/wild-central/authelia
|
||||
}
|
||||
|
||||
// NewManager creates a new Authelia manager
|
||||
func NewManager(dataDir string) *Manager {
|
||||
return &Manager{dataDir: dataDir}
|
||||
}
|
||||
|
||||
// EnsureDataDir creates the Authelia data directory if it doesn't exist
|
||||
func (m *Manager) EnsureDataDir() error {
|
||||
if err := os.MkdirAll(m.dataDir, 0700); err != nil {
|
||||
return fmt.Errorf("creating authelia data dir: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsInstalled checks if the authelia binary exists on PATH
|
||||
func (m *Manager) IsInstalled() bool {
|
||||
_, err := exec.LookPath("authelia")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
const luaScriptPath = "/etc/haproxy/lua/haproxy-auth-request.lua"
|
||||
|
||||
// HasLuaScript checks if the HAProxy auth-request Lua script is installed
|
||||
func (m *Manager) HasLuaScript() bool {
|
||||
info, err := os.Stat(luaScriptPath)
|
||||
return err == nil && info.Size() > 0
|
||||
}
|
||||
|
||||
// GetStatus returns the current Authelia service status
|
||||
func (m *Manager) GetStatus() (*Status, error) {
|
||||
status := &Status{}
|
||||
|
||||
cmd := exec.Command("systemctl", "is-active", "--quiet", "authelia")
|
||||
status.Active = cmd.Run() == nil
|
||||
|
||||
if status.Active {
|
||||
if out, err := exec.Command("systemctl", "show", "authelia.service", "--property=MainPID").Output(); err == nil {
|
||||
parts := strings.Split(strings.TrimSpace(string(out)), "=")
|
||||
if len(parts) == 2 {
|
||||
if pid, err := strconv.Atoi(parts[1]); err == nil {
|
||||
status.PID = pid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out, err := exec.Command("systemctl", "show", "authelia.service", "--property=ActiveEnterTimestamp").Output(); err == nil {
|
||||
parts := strings.Split(strings.TrimSpace(string(out)), "=")
|
||||
if len(parts) == 2 {
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", parts[1]); err == nil {
|
||||
status.LastRestart = t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out, err := exec.Command("authelia", "--version").Output(); err == nil {
|
||||
s := string(out)
|
||||
if i := strings.Index(s, "version "); i >= 0 {
|
||||
v := s[i+8:]
|
||||
if j := strings.IndexByte(v, ' '); j > 0 {
|
||||
v = v[:j]
|
||||
}
|
||||
status.Version = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// RestartService restarts the Authelia systemd service and verifies it stays running.
|
||||
func (m *Manager) RestartService() error {
|
||||
cmd := exec.Command("systemctl", "restart", "authelia.service")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restart authelia: %w (output: %s)", err, string(output))
|
||||
}
|
||||
|
||||
// Give it a moment to start (or crash)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Check if it's actually running
|
||||
if exec.Command("systemctl", "is-active", "--quiet", "authelia.service").Run() != nil {
|
||||
// It crashed — grab the error from the journal
|
||||
reason := m.lastError()
|
||||
if reason != "" {
|
||||
return fmt.Errorf("authelia started but crashed: %s", reason)
|
||||
}
|
||||
return fmt.Errorf("authelia started but crashed (check journalctl -u authelia.service)")
|
||||
}
|
||||
|
||||
slog.Info("authelia service restarted", "component", "authelia")
|
||||
return nil
|
||||
}
|
||||
|
||||
// lastError extracts the most recent error-level message from the Authelia journal.
|
||||
// Authelia logs look like: time="..." level=error msg="..." error="..." provider=...
|
||||
func (m *Manager) lastError() string {
|
||||
cmd := exec.Command("journalctl", "-u", "authelia.service", "-n", "20", "--no-pager", "-o", "cat")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// Find the last line containing level=error
|
||||
var lastErr string
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
if strings.Contains(line, "level=error") {
|
||||
lastErr = line
|
||||
}
|
||||
}
|
||||
if lastErr == "" {
|
||||
return ""
|
||||
}
|
||||
// Extract error="..." field (has the actual detail)
|
||||
if i := strings.Index(lastErr, "error=\""); i >= 0 {
|
||||
detail := lastErr[i+7:]
|
||||
if j := strings.Index(detail, "\""); j >= 0 {
|
||||
return detail[:j]
|
||||
}
|
||||
}
|
||||
// Fall back to msg="..." field
|
||||
if i := strings.Index(lastErr, "msg=\""); i >= 0 {
|
||||
detail := lastErr[i+5:]
|
||||
if j := strings.Index(detail, "\""); j >= 0 {
|
||||
return detail[:j]
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// StopService stops the Authelia systemd service
|
||||
func (m *Manager) StopService() error {
|
||||
cmd := exec.Command("systemctl", "stop", "authelia.service")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop authelia: %w (output: %s)", err, string(output))
|
||||
}
|
||||
slog.Info("authelia service stopped", "component", "authelia")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadConfig reads the current Authelia configuration file
|
||||
func (m *Manager) ReadConfig() (string, error) {
|
||||
data, err := os.ReadFile(m.configPath())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading authelia config: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// DataDir returns the Authelia data directory path
|
||||
func (m *Manager) DataDir() string {
|
||||
return m.dataDir
|
||||
}
|
||||
|
||||
func (m *Manager) configPath() string {
|
||||
return filepath.Join(m.dataDir, "configuration.yml")
|
||||
}
|
||||
|
||||
func (m *Manager) usersDBPath() string {
|
||||
return filepath.Join(m.dataDir, "users_database.yml")
|
||||
}
|
||||
|
||||
func (m *Manager) sqlitePath() string {
|
||||
return filepath.Join(m.dataDir, "db.sqlite3")
|
||||
}
|
||||
|
||||
func (m *Manager) jwksPath() string {
|
||||
return filepath.Join(m.dataDir, "jwks.pem")
|
||||
}
|
||||
|
||||
func (m *Manager) notificationPath() string {
|
||||
return filepath.Join(m.dataDir, "notification.txt")
|
||||
}
|
||||
|
||||
func (m *Manager) oidcClientsPath() string {
|
||||
return filepath.Join(m.dataDir, "oidc_clients.yml")
|
||||
}
|
||||
352
internal/authelia/manager_test.go
Normal file
352
internal/authelia/manager_test.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package authelia
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestGenerateConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
opts := ConfigOpts{
|
||||
Domain: "auth.payne.io",
|
||||
JWTSecret: "test-jwt-secret",
|
||||
SessionSecret: "test-session-secret",
|
||||
StorageEncKey: "test-storage-encryption-key-min-20",
|
||||
OIDCHMACSecret: "test-oidc-hmac-secret",
|
||||
DefaultPolicy: "one_factor",
|
||||
SessionDomain: "payne.io",
|
||||
DataDir: dir,
|
||||
ListenAddr: "127.0.0.1:9091",
|
||||
}
|
||||
|
||||
if err := m.GenerateConfig(opts); err != nil {
|
||||
t.Fatalf("GenerateConfig: %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
configPath := filepath.Join(dir, "configuration.yml")
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reading config: %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
// Check key values are present
|
||||
checks := []string{
|
||||
"tcp://127.0.0.1:9091/",
|
||||
"one_factor",
|
||||
"payne.io",
|
||||
"auth.payne.io",
|
||||
"Wild Cloud",
|
||||
"argon2id",
|
||||
"test-session-secret",
|
||||
"test-storage-encryption-key-min-20",
|
||||
}
|
||||
for _, check := range checks {
|
||||
if !strings.Contains(content, check) {
|
||||
t.Errorf("config missing expected value: %q", check)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify it's valid YAML
|
||||
var parsed map[string]any
|
||||
if err := yaml.Unmarshal(data, &parsed); err != nil {
|
||||
t.Errorf("config is not valid YAML: %v", err)
|
||||
}
|
||||
|
||||
// Check file permissions
|
||||
info, _ := os.Stat(configPath)
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("config permissions = %o, want 0600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigWithOIDCClients(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
opts := ConfigOpts{
|
||||
Domain: "auth.payne.io",
|
||||
JWTSecret: "jwt",
|
||||
SessionSecret: "session",
|
||||
StorageEncKey: "storage-enc-key-20chars",
|
||||
OIDCHMACSecret: "oidc-hmac",
|
||||
SessionDomain: "payne.io",
|
||||
OIDCClients: []OIDCClient{
|
||||
{
|
||||
ID: "gitea",
|
||||
Name: "Gitea",
|
||||
Secret: "$pbkdf2-sha512$hashed",
|
||||
RedirectURIs: []string{"https://git.payne.io/user/oauth2/authelia/callback"},
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
Policy: "one_factor",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := m.GenerateConfig(opts); err != nil {
|
||||
t.Fatalf("GenerateConfig: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "configuration.yml"))
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "gitea") {
|
||||
t.Error("config should contain OIDC client 'gitea'")
|
||||
}
|
||||
if !strings.Contains(content, "git.payne.io") {
|
||||
t.Error("config should contain redirect URI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
// Minimal opts — should use defaults
|
||||
opts := ConfigOpts{
|
||||
Domain: "auth.example.com",
|
||||
JWTSecret: "jwt",
|
||||
SessionSecret: "session",
|
||||
StorageEncKey: "enc-key-twenty-chars",
|
||||
SessionDomain: "example.com",
|
||||
}
|
||||
|
||||
if err := m.GenerateConfig(opts); err != nil {
|
||||
t.Fatalf("GenerateConfig: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "configuration.yml"))
|
||||
content := string(data)
|
||||
|
||||
// Should default to one_factor
|
||||
if !strings.Contains(content, "one_factor") {
|
||||
t.Error("should default to one_factor policy")
|
||||
}
|
||||
// Should default to 127.0.0.1:9091
|
||||
if !strings.Contains(content, "127.0.0.1:9091") {
|
||||
t.Error("should default to 127.0.0.1:9091")
|
||||
}
|
||||
// Without OIDCHMACSecret, should not have identity_providers
|
||||
if strings.Contains(content, "identity_providers") {
|
||||
t.Error("should not include OIDC section without HMAC secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCRUD(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
// List empty
|
||||
users, err := m.ListUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers empty: %v", err)
|
||||
}
|
||||
if len(users) != 0 {
|
||||
t.Errorf("expected 0 users, got %d", len(users))
|
||||
}
|
||||
|
||||
// Create
|
||||
err = m.CreateUser(User{
|
||||
Username: "admin",
|
||||
Displayname: "Admin User",
|
||||
Password: "supersecret",
|
||||
Email: "admin@example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
// List after create
|
||||
users, err = m.ListUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers: %v", err)
|
||||
}
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("expected 1 user, got %d", len(users))
|
||||
}
|
||||
if users[0].Username != "admin" {
|
||||
t.Errorf("username = %q, want %q", users[0].Username, "admin")
|
||||
}
|
||||
if users[0].Displayname != "Admin User" {
|
||||
t.Errorf("displayname = %q, want %q", users[0].Displayname, "Admin User")
|
||||
}
|
||||
if users[0].Email != "admin@example.com" {
|
||||
t.Errorf("email = %q, want %q", users[0].Email, "admin@example.com")
|
||||
}
|
||||
// Password should be omitted in listing
|
||||
if users[0].Password != "" {
|
||||
t.Error("password should be empty in listing")
|
||||
}
|
||||
|
||||
// Duplicate create should fail
|
||||
err = m.CreateUser(User{Username: "admin", Password: "x", Displayname: "dup"})
|
||||
if err == nil {
|
||||
t.Error("duplicate create should fail")
|
||||
}
|
||||
|
||||
// Update
|
||||
newName := "Super Admin"
|
||||
err = m.UpdateUser("admin", UserUpdate{Displayname: &newName})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUser: %v", err)
|
||||
}
|
||||
users, _ = m.ListUsers()
|
||||
if users[0].Displayname != "Super Admin" {
|
||||
t.Errorf("after update, displayname = %q, want %q", users[0].Displayname, "Super Admin")
|
||||
}
|
||||
|
||||
// Update nonexistent user
|
||||
err = m.UpdateUser("nobody", UserUpdate{Displayname: &newName})
|
||||
if err == nil {
|
||||
t.Error("update nonexistent should fail")
|
||||
}
|
||||
|
||||
// Delete
|
||||
err = m.DeleteUser("admin")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
users, _ = m.ListUsers()
|
||||
if len(users) != 0 {
|
||||
t.Errorf("after delete, expected 0 users, got %d", len(users))
|
||||
}
|
||||
|
||||
// Delete nonexistent
|
||||
err = m.DeleteUser("admin")
|
||||
if err == nil {
|
||||
t.Error("delete nonexistent should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserValidation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
if err := m.CreateUser(User{Password: "x", Displayname: "y"}); err == nil {
|
||||
t.Error("empty username should fail")
|
||||
}
|
||||
if err := m.CreateUser(User{Username: "x", Displayname: "y"}); err == nil {
|
||||
t.Error("empty password should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPassword(t *testing.T) {
|
||||
hash, err := hashPassword("testpassword")
|
||||
if err != nil {
|
||||
t.Fatalf("hashPassword: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(hash, "$argon2id$v=19$") {
|
||||
t.Errorf("hash should start with $argon2id$v=19$, got %q", hash[:30])
|
||||
}
|
||||
|
||||
// Two hashes of the same password should differ (different salt)
|
||||
hash2, _ := hashPassword("testpassword")
|
||||
if hash == hash2 {
|
||||
t.Error("two hashes of the same password should differ (random salt)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
if m.UserCount() != 0 {
|
||||
t.Error("empty db should have 0 users")
|
||||
}
|
||||
|
||||
m.CreateUser(User{Username: "a", Password: "p", Displayname: "A"})
|
||||
m.CreateUser(User{Username: "b", Password: "p", Displayname: "B"})
|
||||
|
||||
if m.UserCount() != 2 {
|
||||
t.Errorf("expected 2 users, got %d", m.UserCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUsersDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
if err := m.EnsureUsersDB(); err != nil {
|
||||
t.Fatalf("EnsureUsersDB: %v", err)
|
||||
}
|
||||
|
||||
// File should exist
|
||||
if _, err := os.Stat(m.usersDBPath()); err != nil {
|
||||
t.Errorf("users db file should exist: %v", err)
|
||||
}
|
||||
|
||||
// Should be idempotent
|
||||
if err := m.EnsureUsersDB(); err != nil {
|
||||
t.Fatalf("EnsureUsersDB (second call): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureJWKS(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
if err := m.EnsureJWKS(); err != nil {
|
||||
t.Fatalf("EnsureJWKS: %v", err)
|
||||
}
|
||||
|
||||
// File should exist
|
||||
data, err := os.ReadFile(m.jwksPath())
|
||||
if err != nil {
|
||||
t.Fatalf("reading JWKS: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "RSA PRIVATE KEY") {
|
||||
t.Error("JWKS file should contain RSA private key")
|
||||
}
|
||||
|
||||
// Should be idempotent (doesn't regenerate)
|
||||
if err := m.EnsureJWKS(); err != nil {
|
||||
t.Fatalf("EnsureJWKS (second call): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSecret(t *testing.T) {
|
||||
s1, err := GenerateSecret(32)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSecret: %v", err)
|
||||
}
|
||||
if len(s1) != 64 { // 32 bytes = 64 hex chars
|
||||
t.Errorf("secret length = %d, want 64", len(s1))
|
||||
}
|
||||
|
||||
s2, _ := GenerateSecret(32)
|
||||
if s1 == s2 {
|
||||
t.Error("two generated secrets should differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionDomainFromAuthDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"auth.payne.io", "payne.io"},
|
||||
{"auth.sub.example.com", "sub.example.com"},
|
||||
{"localhost", "localhost"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := SessionDomainFromAuthDomain(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("SessionDomainFromAuthDomain(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInstalled(t *testing.T) {
|
||||
m := NewManager(t.TempDir())
|
||||
// Just verify it doesn't panic — the result depends on whether authelia is installed
|
||||
_ = m.IsInstalled()
|
||||
}
|
||||
219
internal/authelia/oidc.go
Normal file
219
internal/authelia/oidc.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package authelia
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/storage"
|
||||
)
|
||||
|
||||
// OIDCClient represents a registered OIDC client application
|
||||
type OIDCClient struct {
|
||||
ID string `yaml:"client_id" json:"clientId"`
|
||||
Name string `yaml:"client_name" json:"clientName"`
|
||||
Secret string `yaml:"client_secret" json:"clientSecret,omitempty"` // pbkdf2 hash in config, cleartext on create response
|
||||
RedirectURIs []string `yaml:"redirect_uris" json:"redirectUris"`
|
||||
Scopes []string `yaml:"scopes" json:"scopes"`
|
||||
Policy string `yaml:"authorization_policy" json:"authorizationPolicy"`
|
||||
ConsentMode string `yaml:"consent_mode,omitempty" json:"consentMode,omitempty"`
|
||||
}
|
||||
|
||||
// OIDCClientUpdate holds partial updates for an OIDC client
|
||||
type OIDCClientUpdate struct {
|
||||
Name *string `json:"clientName,omitempty"`
|
||||
RedirectURIs []string `json:"redirectUris,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
Policy *string `json:"authorizationPolicy,omitempty"`
|
||||
ConsentMode *string `json:"consentMode,omitempty"`
|
||||
}
|
||||
|
||||
// oidcClientsFile is the top-level structure of the OIDC clients file
|
||||
type oidcClientsFile struct {
|
||||
Clients []OIDCClient `yaml:"clients"`
|
||||
}
|
||||
|
||||
// ListOIDCClients returns all registered OIDC clients (secrets redacted)
|
||||
func (m *Manager) ListOIDCClients() ([]OIDCClient, error) {
|
||||
clients, err := m.readOIDCClients()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Redact secrets for listing
|
||||
for i := range clients {
|
||||
clients[i].Secret = ""
|
||||
}
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
// ReadOIDCClients returns all clients with secrets (for config generation)
|
||||
func (m *Manager) ReadOIDCClients() ([]OIDCClient, error) {
|
||||
return m.readOIDCClients()
|
||||
}
|
||||
|
||||
// AddOIDCClient adds a new OIDC client. Returns the cleartext secret (shown once).
|
||||
func (m *Manager) AddOIDCClient(client OIDCClient) (cleartextSecret string, err error) {
|
||||
if client.ID == "" {
|
||||
return "", fmt.Errorf("client_id is required")
|
||||
}
|
||||
if client.Name == "" {
|
||||
return "", fmt.Errorf("client_name is required")
|
||||
}
|
||||
if len(client.RedirectURIs) == 0 {
|
||||
return "", fmt.Errorf("at least one redirect_uri is required")
|
||||
}
|
||||
|
||||
lockPath := m.configPath() + ".lock"
|
||||
err = storage.WithLock(lockPath, func() error {
|
||||
clients, readErr := m.readOIDCClients()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
|
||||
for _, c := range clients {
|
||||
if c.ID == client.ID {
|
||||
return fmt.Errorf("client %q already exists", client.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate client secret
|
||||
secret, genErr := GenerateSecret(32)
|
||||
if genErr != nil {
|
||||
return fmt.Errorf("generating client secret: %w", genErr)
|
||||
}
|
||||
cleartextSecret = secret
|
||||
|
||||
// Hash for storage
|
||||
client.Secret = hashClientSecret(secret)
|
||||
|
||||
// Default scopes
|
||||
if len(client.Scopes) == 0 {
|
||||
client.Scopes = []string{"openid", "profile", "email", "groups"}
|
||||
}
|
||||
if client.Policy == "" {
|
||||
client.Policy = "one_factor"
|
||||
}
|
||||
|
||||
return m.writeOIDCClients(append(clients, client))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateOIDCClient applies partial updates to an OIDC client
|
||||
func (m *Manager) UpdateOIDCClient(clientID string, updates OIDCClientUpdate) error {
|
||||
lockPath := m.configPath() + ".lock"
|
||||
return storage.WithLock(lockPath, func() error {
|
||||
clients, err := m.readOIDCClients()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, c := range clients {
|
||||
if c.ID == clientID {
|
||||
if updates.Name != nil {
|
||||
clients[i].Name = *updates.Name
|
||||
}
|
||||
if updates.RedirectURIs != nil {
|
||||
clients[i].RedirectURIs = updates.RedirectURIs
|
||||
}
|
||||
if updates.Scopes != nil {
|
||||
clients[i].Scopes = updates.Scopes
|
||||
}
|
||||
if updates.Policy != nil {
|
||||
clients[i].Policy = *updates.Policy
|
||||
}
|
||||
if updates.ConsentMode != nil {
|
||||
clients[i].ConsentMode = *updates.ConsentMode
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("client %q not found", clientID)
|
||||
}
|
||||
|
||||
return m.writeOIDCClients(clients)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteOIDCClient removes an OIDC client by ID
|
||||
func (m *Manager) DeleteOIDCClient(clientID string) error {
|
||||
lockPath := m.configPath() + ".lock"
|
||||
return storage.WithLock(lockPath, func() error {
|
||||
clients, err := m.readOIDCClients()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var filtered []OIDCClient
|
||||
found := false
|
||||
for _, c := range clients {
|
||||
if c.ID == clientID {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("client %q not found", clientID)
|
||||
}
|
||||
|
||||
return m.writeOIDCClients(filtered)
|
||||
})
|
||||
}
|
||||
|
||||
// OIDCClientCount returns the number of registered OIDC clients
|
||||
func (m *Manager) OIDCClientCount() int {
|
||||
clients, err := m.readOIDCClients()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(clients)
|
||||
}
|
||||
|
||||
// readOIDCClients reads OIDC clients from the dedicated clients file
|
||||
func (m *Manager) readOIDCClients() ([]OIDCClient, error) {
|
||||
data, err := os.ReadFile(m.oidcClientsPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading oidc clients: %w", err)
|
||||
}
|
||||
|
||||
var f oidcClientsFile
|
||||
if err := yaml.Unmarshal(data, &f); err != nil {
|
||||
return nil, fmt.Errorf("parsing oidc clients: %w", err)
|
||||
}
|
||||
|
||||
return f.Clients, nil
|
||||
}
|
||||
|
||||
// writeOIDCClients writes the OIDC clients to the dedicated clients file
|
||||
func (m *Manager) writeOIDCClients(clients []OIDCClient) error {
|
||||
f := oidcClientsFile{Clients: clients}
|
||||
data, err := yaml.Marshal(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling oidc clients: %w", err)
|
||||
}
|
||||
|
||||
tmpPath := m.oidcClientsPath() + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("writing temp oidc clients: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, m.oidcClientsPath()); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("installing oidc clients: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashClientSecret wraps the secret with $plaintext$ prefix.
|
||||
// Authelia accepts this format and handles hashing internally.
|
||||
func hashClientSecret(secret string) string {
|
||||
return "$plaintext$" + secret
|
||||
}
|
||||
242
internal/authelia/users.go
Normal file
242
internal/authelia/users.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package authelia
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/storage"
|
||||
)
|
||||
|
||||
// User represents an Authelia user account
|
||||
type User struct {
|
||||
Username string `json:"username" yaml:"-"`
|
||||
Displayname string `json:"displayname" yaml:"displayname"`
|
||||
Password string `json:"password,omitempty" yaml:"password"` // argon2id hash (cleartext on create, never returned)
|
||||
Email string `json:"email,omitempty" yaml:"email,omitempty"`
|
||||
Groups []string `json:"groups,omitempty" yaml:"groups,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty" yaml:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// UserUpdate holds partial updates for a user
|
||||
type UserUpdate struct {
|
||||
Displayname *string `json:"displayname,omitempty"`
|
||||
Password *string `json:"password,omitempty"` // cleartext, will be hashed
|
||||
Email *string `json:"email,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// usersDB is the top-level structure of Authelia's users_database.yml
|
||||
type usersDB struct {
|
||||
Users map[string]*userEntry `yaml:"users"`
|
||||
}
|
||||
|
||||
type userEntry struct {
|
||||
Displayname string `yaml:"displayname"`
|
||||
Password string `yaml:"password"`
|
||||
Email string `yaml:"email,omitempty"`
|
||||
Groups []string `yaml:"groups,omitempty"`
|
||||
Disabled bool `yaml:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// ListUsers returns all users from the database (passwords omitted)
|
||||
func (m *Manager) ListUsers() ([]User, error) {
|
||||
db, err := m.readUsersDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var users []User
|
||||
for name, entry := range db.Users {
|
||||
users = append(users, User{
|
||||
Username: name,
|
||||
Displayname: entry.Displayname,
|
||||
Email: entry.Email,
|
||||
Groups: entry.Groups,
|
||||
Disabled: entry.Disabled,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(users, func(i, j int) bool {
|
||||
return users[i].Username < users[j].Username
|
||||
})
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// CreateUser adds a new user with an argon2id-hashed password
|
||||
func (m *Manager) CreateUser(user User) error {
|
||||
if user.Username == "" {
|
||||
return fmt.Errorf("username is required")
|
||||
}
|
||||
if user.Password == "" {
|
||||
return fmt.Errorf("password is required")
|
||||
}
|
||||
|
||||
lockPath := m.usersDBPath() + ".lock"
|
||||
return storage.WithLock(lockPath, func() error {
|
||||
db, err := m.readUsersDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, exists := db.Users[user.Username]; exists {
|
||||
return fmt.Errorf("user %q already exists", user.Username)
|
||||
}
|
||||
|
||||
hash, err := hashPassword(user.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hashing password: %w", err)
|
||||
}
|
||||
|
||||
db.Users[user.Username] = &userEntry{
|
||||
Displayname: user.Displayname,
|
||||
Password: hash,
|
||||
Email: user.Email,
|
||||
Groups: user.Groups,
|
||||
Disabled: user.Disabled,
|
||||
}
|
||||
|
||||
return m.writeUsersDB(db)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUser applies partial updates to an existing user
|
||||
func (m *Manager) UpdateUser(username string, updates UserUpdate) error {
|
||||
lockPath := m.usersDBPath() + ".lock"
|
||||
return storage.WithLock(lockPath, func() error {
|
||||
db, err := m.readUsersDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry, exists := db.Users[username]
|
||||
if !exists {
|
||||
return fmt.Errorf("user %q not found", username)
|
||||
}
|
||||
|
||||
if updates.Displayname != nil {
|
||||
entry.Displayname = *updates.Displayname
|
||||
}
|
||||
if updates.Email != nil {
|
||||
entry.Email = *updates.Email
|
||||
}
|
||||
if updates.Disabled != nil {
|
||||
entry.Disabled = *updates.Disabled
|
||||
}
|
||||
if updates.Groups != nil {
|
||||
entry.Groups = updates.Groups
|
||||
}
|
||||
if updates.Password != nil {
|
||||
hash, err := hashPassword(*updates.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hashing password: %w", err)
|
||||
}
|
||||
entry.Password = hash
|
||||
}
|
||||
|
||||
return m.writeUsersDB(db)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteUser removes a user from the database
|
||||
func (m *Manager) DeleteUser(username string) error {
|
||||
lockPath := m.usersDBPath() + ".lock"
|
||||
return storage.WithLock(lockPath, func() error {
|
||||
db, err := m.readUsersDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, exists := db.Users[username]; !exists {
|
||||
return fmt.Errorf("user %q not found", username)
|
||||
}
|
||||
|
||||
delete(db.Users, username)
|
||||
return m.writeUsersDB(db)
|
||||
})
|
||||
}
|
||||
|
||||
// UserCount returns the number of users in the database
|
||||
func (m *Manager) UserCount() int {
|
||||
db, err := m.readUsersDB()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(db.Users)
|
||||
}
|
||||
|
||||
// EnsureUsersDB creates an empty users database file if one doesn't exist
|
||||
func (m *Manager) EnsureUsersDB() error {
|
||||
if _, err := os.Stat(m.usersDBPath()); err == nil {
|
||||
return nil
|
||||
}
|
||||
db := &usersDB{Users: map[string]*userEntry{}}
|
||||
return m.writeUsersDB(db)
|
||||
}
|
||||
|
||||
func (m *Manager) readUsersDB() (*usersDB, error) {
|
||||
data, err := os.ReadFile(m.usersDBPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &usersDB{Users: map[string]*userEntry{}}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading users db: %w", err)
|
||||
}
|
||||
|
||||
db := &usersDB{Users: map[string]*userEntry{}}
|
||||
if err := yaml.Unmarshal(data, db); err != nil {
|
||||
return nil, fmt.Errorf("parsing users db: %w", err)
|
||||
}
|
||||
if db.Users == nil {
|
||||
db.Users = map[string]*userEntry{}
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (m *Manager) writeUsersDB(db *usersDB) error {
|
||||
data, err := yaml.Marshal(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling users db: %w", err)
|
||||
}
|
||||
|
||||
tmpPath := m.usersDBPath() + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("writing temp users db: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, m.usersDBPath()); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("installing users db: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashPassword creates an argon2id hash in Authelia's expected format.
|
||||
// Parameters tuned for Raspberry Pi: memory=64MB, iterations=3, parallelism=2.
|
||||
func hashPassword(password string) (string, error) {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("generating salt: %w", err)
|
||||
}
|
||||
|
||||
const (
|
||||
memory = 64 * 1024 // 64MB
|
||||
iterations = 3
|
||||
parallel = 2
|
||||
keyLen = 32
|
||||
)
|
||||
|
||||
hash := argon2.IDKey([]byte(password), salt, iterations, memory, uint8(parallel), keyLen)
|
||||
|
||||
// Authelia expects: $argon2id$v=19$m=65536,t=3,p=2$<base64-salt>$<base64-hash>
|
||||
saltB64 := base64.RawStdEncoding.EncodeToString(salt)
|
||||
hashB64 := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
memory, iterations, parallel, saltB64, hashB64), nil
|
||||
}
|
||||
@@ -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.
|
||||
@@ -114,27 +117,82 @@ type State struct {
|
||||
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
|
||||
IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"`
|
||||
} `yaml:"ddns,omitempty" json:"ddns,omitempty"`
|
||||
Authelia struct {
|
||||
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
||||
Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // login portal domain (e.g. auth.payne.io)
|
||||
DefaultPolicy string `yaml:"defaultPolicy,omitempty" json:"defaultPolicy,omitempty"` // one_factor or two_factor
|
||||
SMTP struct {
|
||||
Host string `yaml:"host,omitempty" json:"host,omitempty"` // e.g. smtp.gmail.com
|
||||
Port int `yaml:"port,omitempty" json:"port,omitempty"` // e.g. 587
|
||||
Username string `yaml:"username,omitempty" json:"username,omitempty"` // SMTP login username
|
||||
Sender string `yaml:"sender,omitempty" json:"sender,omitempty"` // From address (e.g. auth@payne.io)
|
||||
} `yaml:"smtp,omitempty" json:"smtp,omitempty"`
|
||||
} `yaml:"authelia,omitempty" json:"authelia,omitempty"`
|
||||
DNSFilter struct {
|
||||
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
||||
IntervalHours int `yaml:"intervalHours,omitempty" json:"intervalHours,omitempty"` // update interval, default 24
|
||||
} `yaml:"dnsFilter,omitempty" json:"dnsFilter,omitempty"`
|
||||
} `yaml:"cloud,omitempty" json:"cloud,omitempty"`
|
||||
}
|
||||
|
||||
// LoadState loads state from the specified path
|
||||
// AddDHCPStaticLease adds or replaces a static lease entry (matched by MAC).
|
||||
func (s *State) AddDHCPStaticLease(lease DHCPStaticLease) {
|
||||
for i, l := range s.Cloud.Dnsmasq.DHCP.StaticLeases {
|
||||
if l.MAC == lease.MAC {
|
||||
s.Cloud.Dnsmasq.DHCP.StaticLeases[i] = lease
|
||||
return
|
||||
}
|
||||
}
|
||||
s.Cloud.Dnsmasq.DHCP.StaticLeases = append(s.Cloud.Dnsmasq.DHCP.StaticLeases, lease)
|
||||
}
|
||||
|
||||
// RemoveDHCPStaticLease removes a static lease entry by MAC. Returns true if found.
|
||||
func (s *State) RemoveDHCPStaticLease(mac string) bool {
|
||||
leases := s.Cloud.Dnsmasq.DHCP.StaticLeases
|
||||
for i, l := range leases {
|
||||
if l.MAC == mac {
|
||||
s.Cloud.Dnsmasq.DHCP.StaticLeases = append(leases[:i], leases[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LoadState loads state from the specified path. If the primary file is
|
||||
// corrupted, falls back to the .bak backup if one exists.
|
||||
func LoadState(configPath string) (*State, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
|
||||
state, err := loadStateFrom(configPath)
|
||||
if err == nil {
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// Primary failed — try backup
|
||||
bakPath := configPath + ".bak"
|
||||
bakState, bakErr := loadStateFrom(bakPath)
|
||||
if bakErr == nil {
|
||||
slog.Warn("loaded state from backup (primary corrupted)", "component", "config",
|
||||
"path", configPath, "error", err)
|
||||
return bakState, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
func loadStateFrom(path string) (*State, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config := &State{}
|
||||
if err := yaml.Unmarshal(data, config); err != nil {
|
||||
return nil, fmt.Errorf("parsing config file: %w", err)
|
||||
return nil, fmt.Errorf("parsing %s: %w", path, err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// SaveState saves the state to the specified path
|
||||
// SaveState saves the state to the specified path. Backs up the current
|
||||
// file to .bak before writing so LoadState can recover from corruption.
|
||||
func SaveState(config *State, configPath string) error {
|
||||
// Ensure the directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
||||
return fmt.Errorf("creating config directory: %w", err)
|
||||
}
|
||||
@@ -144,7 +202,10 @@ func SaveState(config *State, configPath string) error {
|
||||
return fmt.Errorf("marshaling config: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(configPath, data, 0644)
|
||||
// Backup current state before overwriting
|
||||
if storage.FileExists(configPath) {
|
||||
_ = storage.CopyFile(configPath, configPath+".bak")
|
||||
}
|
||||
|
||||
return storage.WriteFileAtomic(configPath, data, 0644)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,104 +255,67 @@ type cloudflareRecord struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// updateCloudflareRecord updates a single A record via the Cloudflare API.
|
||||
// The record name must be a fully qualified domain name (e.g. "cloud.payne.io").
|
||||
// If a CNAME already exists at that name (e.g. from a router DDNS service), it is
|
||||
// deleted first so the A record can be created without conflict.
|
||||
func updateCloudflareRecord(apiToken, recordName, ip string) error {
|
||||
// Extract zone name: last two parts of the FQDN (e.g. "payne.io" from "cloud.payne.io")
|
||||
// do executes an authenticated Cloudflare API request and checks the status.
|
||||
func (c *cfClient) do(method, url string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// updateRecord updates a single A record via the Cloudflare API.
|
||||
// If a CNAME already exists at that name, it is deleted first.
|
||||
func (c *cfClient) updateRecord(recordName, ip string) error {
|
||||
parts := strings.Split(recordName, ".")
|
||||
if len(parts) < 2 {
|
||||
return fmt.Errorf("invalid record name: %s", recordName)
|
||||
}
|
||||
zoneName := strings.Join(parts[len(parts)-2:], ".")
|
||||
|
||||
// Get zone ID
|
||||
zoneID, err := getCloudflareZoneID(apiToken, zoneName)
|
||||
zoneID, err := c.getZoneID(zoneName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting zone ID for %s: %w", zoneName, err)
|
||||
}
|
||||
|
||||
// Check for existing A record — patch it if found
|
||||
recordID, currentIP, err := getCloudflareRecordID(apiToken, zoneID, recordName, "A")
|
||||
recordID, currentIP, err := c.getRecordID(zoneID, recordName, "A")
|
||||
if err == nil {
|
||||
if currentIP == ip {
|
||||
return nil // already up to date
|
||||
}
|
||||
return patchCloudflareRecord(apiToken, zoneID, recordID, recordName, ip)
|
||||
return c.patchRecord(zoneID, recordID, recordName, ip)
|
||||
}
|
||||
|
||||
// No A record. Remove any CNAME that would conflict with A record creation
|
||||
// (common when migrating from a router-based DDNS service like GL.iNet).
|
||||
if cnameID, _, cnameErr := getCloudflareRecordID(apiToken, zoneID, recordName, "CNAME"); cnameErr == nil {
|
||||
slog.Info("DDNS: removing CNAME before creating A record", "component", "ddns", "record", recordName)
|
||||
if err := deleteCloudflareRecord(apiToken, zoneID, cnameID); err != nil {
|
||||
// No A record. Remove any CNAME that would conflict.
|
||||
if cnameID, _, cnameErr := c.getRecordID(zoneID, recordName, "CNAME"); cnameErr == nil {
|
||||
slog.Info("removing CNAME before creating A record", "component", "ddns", "record", recordName)
|
||||
if err := c.deleteRecord(zoneID, cnameID); err != nil {
|
||||
return fmt.Errorf("removing CNAME for %s: %w", recordName, err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("DDNS: creating A record", "component", "ddns", "record", recordName)
|
||||
return createCloudflareRecord(apiToken, zoneID, recordName, ip)
|
||||
slog.Info("creating A record", "component", "ddns", "record", recordName)
|
||||
return c.createRecord(zoneID, recordName, ip)
|
||||
}
|
||||
|
||||
// createCloudflareRecord creates a new A record via the Cloudflare API
|
||||
func createCloudflareRecord(apiToken, zoneID, recordName, ip string) error {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"type": "A",
|
||||
"name": recordName,
|
||||
"content": ip,
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodPost,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID),
|
||||
bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating record: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// patchCloudflareRecord updates an existing A record via the Cloudflare API
|
||||
func patchCloudflareRecord(apiToken, zoneID, recordID, recordName, ip string) error {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"type": "A",
|
||||
"name": recordName,
|
||||
"content": ip,
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodPatch,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID),
|
||||
bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating record: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getCloudflareZoneID(apiToken, zoneName string) (string, error) {
|
||||
req, _ := http.NewRequest(http.MethodGet,
|
||||
"https://api.cloudflare.com/client/v4/zones?name="+zoneName, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
func (c *cfClient) getZoneID(zoneName string) (string, error) {
|
||||
resp, err := c.do(http.MethodGet, "https://api.cloudflare.com/client/v4/zones?name="+zoneName, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -363,12 +335,9 @@ func getCloudflareZoneID(apiToken, zoneName string) (string, error) {
|
||||
return result.Result[0].ID, nil
|
||||
}
|
||||
|
||||
func getCloudflareRecordID(apiToken, zoneID, recordName, recordType string) (id, currentContent string, err error) {
|
||||
req, _ := http.NewRequest(http.MethodGet,
|
||||
func (c *cfClient) getRecordID(zoneID, recordName, recordType string) (id, currentContent string, err error) {
|
||||
resp, err := c.do(http.MethodGet,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, recordName), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -386,21 +355,36 @@ func getCloudflareRecordID(apiToken, zoneID, recordName, recordType string) (id,
|
||||
return result.Result[0].ID, result.Result[0].Content, nil
|
||||
}
|
||||
|
||||
func deleteCloudflareRecord(apiToken, zoneID, recordID string) error {
|
||||
req, _ := http.NewRequest(http.MethodDelete,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID),
|
||||
nil)
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
func (c *cfClient) createRecord(zoneID, recordName, ip string) error {
|
||||
body, _ := json.Marshal(map[string]string{"type": "A", "name": recordName, "content": ip})
|
||||
resp, err := c.do(http.MethodPost,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID),
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating record: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
func (c *cfClient) patchRecord(zoneID, recordID, recordName, ip string) error {
|
||||
body, _ := json.Marshal(map[string]string{"type": "A", "name": recordName, "content": ip})
|
||||
resp, err := c.do(http.MethodPatch,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID),
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating record: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cfClient) deleteRecord(zoneID, recordID string) error {
|
||||
resp, err := c.do(http.MethodDelete,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting record: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
550
internal/dnsfilter/manager.go
Normal file
550
internal/dnsfilter/manager.go
Normal file
@@ -0,0 +1,550 @@
|
||||
package dnsfilter
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/storage"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Blocklist represents a subscribed blocklist source.
|
||||
type Blocklist struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
URL string `yaml:"url,omitempty" json:"url,omitempty"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
EntryCount int `yaml:"entryCount,omitempty" json:"entryCount,omitempty"`
|
||||
LastUpdated time.Time `yaml:"lastUpdated,omitempty" json:"lastUpdated,omitzero"`
|
||||
LastError string `yaml:"lastError,omitempty" json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
// CustomEntry represents a user-managed domain override.
|
||||
type CustomEntry struct {
|
||||
Domain string `yaml:"domain" json:"domain"`
|
||||
Type string `yaml:"type" json:"type"` // "allow" or "block"
|
||||
}
|
||||
|
||||
// FilterData holds all DNS filter configuration persisted in lists.yaml.
|
||||
type FilterData struct {
|
||||
Lists []Blocklist `yaml:"lists" json:"lists"`
|
||||
CustomEntries []CustomEntry `yaml:"customEntries" json:"customEntries"`
|
||||
}
|
||||
|
||||
// FilterStats holds current filtering statistics.
|
||||
type FilterStats struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
TotalBlocked int `json:"totalBlocked"`
|
||||
ListCount int `json:"listCount"`
|
||||
EnabledLists int `json:"enabledLists"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
QueryStats *QueryStats `json:"queryStats,omitempty"`
|
||||
}
|
||||
|
||||
// QueryStats holds DNS query statistics from dnsmasq logs.
|
||||
type QueryStats struct {
|
||||
TotalQueries int `json:"totalQueries"`
|
||||
BlockedQueries int `json:"blockedQueries"`
|
||||
BlockedPercent float64 `json:"blockedPercent"`
|
||||
Period string `json:"period"` // e.g. "24h"
|
||||
}
|
||||
|
||||
// Manager handles DNS filter list management and compilation.
|
||||
type Manager struct {
|
||||
dataDir string // e.g. /var/lib/wild-central/dns-filter
|
||||
hostsFilePath string // compiled output path (must be readable by dnsmasq)
|
||||
}
|
||||
|
||||
// NewManager creates a new DNS filter manager.
|
||||
func NewManager(dataDir string) *Manager {
|
||||
return &Manager{
|
||||
dataDir: dataDir,
|
||||
hostsFilePath: filepath.Join(dataDir, "blocked.conf"), // default, override with SetHostsFilePath
|
||||
}
|
||||
}
|
||||
|
||||
// SetHostsFilePath sets the output path for the compiled hosts file.
|
||||
// Must be readable by the dnsmasq process (e.g. /etc/dnsmasq.d/).
|
||||
func (m *Manager) SetHostsFilePath(path string) {
|
||||
m.hostsFilePath = path
|
||||
}
|
||||
|
||||
func (m *Manager) listsPath() string { return filepath.Join(m.dataDir, "lists.yaml") }
|
||||
func (m *Manager) cachePath() string { return filepath.Join(m.dataDir, "cache") }
|
||||
func (m *Manager) lockPath() string { return filepath.Join(m.dataDir, "lists.yaml.lock") }
|
||||
|
||||
// HostsFilePath returns the path to the compiled blocked hosts file.
|
||||
func (m *Manager) HostsFilePath() string { return m.hostsFilePath }
|
||||
|
||||
// EnsureDataDir creates the dns-filter directory structure.
|
||||
func (m *Manager) EnsureDataDir() error {
|
||||
if err := storage.EnsureDir(m.dataDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
// Also ensure the hosts file output directory exists (may differ from dataDir)
|
||||
hostsDir := filepath.Dir(m.hostsFilePath)
|
||||
if hostsDir != m.dataDir {
|
||||
if err := storage.EnsureDir(hostsDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return storage.EnsureDir(m.cachePath(), 0755)
|
||||
}
|
||||
|
||||
// LoadFilterData reads lists.yaml. Returns empty data if file doesn't exist.
|
||||
func (m *Manager) LoadFilterData() (*FilterData, error) {
|
||||
data, err := os.ReadFile(m.listsPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &FilterData{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading filter data: %w", err)
|
||||
}
|
||||
var fd FilterData
|
||||
if err := yaml.Unmarshal(data, &fd); err != nil {
|
||||
return nil, fmt.Errorf("parsing filter data: %w", err)
|
||||
}
|
||||
return &fd, nil
|
||||
}
|
||||
|
||||
// SaveFilterData writes lists.yaml atomically with file locking.
|
||||
func (m *Manager) SaveFilterData(fd *FilterData) error {
|
||||
return storage.WithLock(m.lockPath(), func() error {
|
||||
data, err := yaml.Marshal(fd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling filter data: %w", err)
|
||||
}
|
||||
return os.WriteFile(m.listsPath(), data, 0644)
|
||||
})
|
||||
}
|
||||
|
||||
// listID returns a stable ID for a URL-based list.
|
||||
func listID(url string) string {
|
||||
h := sha256.Sum256([]byte(url))
|
||||
return fmt.Sprintf("%x", h[:8])
|
||||
}
|
||||
|
||||
// cacheFile returns the cache file path for a list by ID.
|
||||
func (m *Manager) cacheFile(id string) string {
|
||||
return filepath.Join(m.cachePath(), id+".txt")
|
||||
}
|
||||
|
||||
// AddList adds a URL-based blocklist subscription.
|
||||
func (m *Manager) AddList(bl Blocklist) error {
|
||||
fd, err := m.LoadFilterData()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if bl.URL != "" {
|
||||
bl.ID = listID(bl.URL)
|
||||
}
|
||||
if bl.ID == "" {
|
||||
return fmt.Errorf("list must have a URL or ID")
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
for _, existing := range fd.Lists {
|
||||
if existing.ID == bl.ID {
|
||||
return fmt.Errorf("list already exists: %s", bl.Name)
|
||||
}
|
||||
}
|
||||
|
||||
bl.Enabled = true
|
||||
fd.Lists = append(fd.Lists, bl)
|
||||
return m.SaveFilterData(fd)
|
||||
}
|
||||
|
||||
// AddUploadedList saves an uploaded blocklist file and adds it as a list entry.
|
||||
func (m *Manager) AddUploadedList(name string, content []byte) (string, error) {
|
||||
if err := m.EnsureDataDir(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
h := sha256.Sum256(content)
|
||||
id := fmt.Sprintf("upload-%x", h[:8])
|
||||
|
||||
// Write content to cache
|
||||
if err := os.WriteFile(m.cacheFile(id), content, 0644); err != nil {
|
||||
return "", fmt.Errorf("writing uploaded list: %w", err)
|
||||
}
|
||||
|
||||
// Count entries
|
||||
domains, _ := ParseFile(m.cacheFile(id))
|
||||
|
||||
bl := Blocklist{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Enabled: true,
|
||||
EntryCount: len(domains),
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
fd, err := m.LoadFilterData()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
for _, existing := range fd.Lists {
|
||||
if existing.ID == id {
|
||||
return id, fmt.Errorf("identical list already uploaded")
|
||||
}
|
||||
}
|
||||
|
||||
fd.Lists = append(fd.Lists, bl)
|
||||
return id, m.SaveFilterData(fd)
|
||||
}
|
||||
|
||||
// RemoveList removes a list by ID and its cached file.
|
||||
func (m *Manager) RemoveList(id string) error {
|
||||
fd, err := m.LoadFilterData()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
filtered := fd.Lists[:0]
|
||||
for _, bl := range fd.Lists {
|
||||
if bl.ID == id {
|
||||
found = true
|
||||
_ = os.Remove(m.cacheFile(id))
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, bl)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("list not found: %s", id)
|
||||
}
|
||||
|
||||
fd.Lists = filtered
|
||||
return m.SaveFilterData(fd)
|
||||
}
|
||||
|
||||
// ToggleList enables or disables a list by ID.
|
||||
func (m *Manager) ToggleList(id string, enabled bool) error {
|
||||
fd, err := m.LoadFilterData()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range fd.Lists {
|
||||
if fd.Lists[i].ID == id {
|
||||
fd.Lists[i].Enabled = enabled
|
||||
return m.SaveFilterData(fd)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("list not found: %s", id)
|
||||
}
|
||||
|
||||
// SetCustomEntry adds or replaces a custom allow/block entry.
|
||||
func (m *Manager) SetCustomEntry(entry CustomEntry) error {
|
||||
if entry.Type != "allow" && entry.Type != "block" {
|
||||
return fmt.Errorf("type must be 'allow' or 'block'")
|
||||
}
|
||||
|
||||
fd, err := m.LoadFilterData()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Replace if exists
|
||||
for i := range fd.CustomEntries {
|
||||
if fd.CustomEntries[i].Domain == entry.Domain {
|
||||
fd.CustomEntries[i].Type = entry.Type
|
||||
return m.SaveFilterData(fd)
|
||||
}
|
||||
}
|
||||
|
||||
fd.CustomEntries = append(fd.CustomEntries, entry)
|
||||
return m.SaveFilterData(fd)
|
||||
}
|
||||
|
||||
// RemoveCustomEntry removes a custom entry by domain.
|
||||
func (m *Manager) RemoveCustomEntry(domain string) error {
|
||||
fd, err := m.LoadFilterData()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
filtered := fd.CustomEntries[:0]
|
||||
for _, e := range fd.CustomEntries {
|
||||
if e.Domain == domain {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("custom entry not found: %s", domain)
|
||||
}
|
||||
|
||||
fd.CustomEntries = filtered
|
||||
return m.SaveFilterData(fd)
|
||||
}
|
||||
|
||||
// UpdateLists downloads all enabled URL-based lists to cache.
|
||||
func (m *Manager) UpdateLists(ctx context.Context) error {
|
||||
if err := m.EnsureDataDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fd, err := m.LoadFilterData()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
changed := false
|
||||
|
||||
for i := range fd.Lists {
|
||||
bl := &fd.Lists[i]
|
||||
if !bl.Enabled || bl.URL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("downloading list", "component", "dns-filter", "name", bl.Name, "url", bl.URL)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, bl.URL, nil)
|
||||
if err != nil {
|
||||
bl.LastError = err.Error()
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
req.Header.Set("User-Agent", "WildCentral/1.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
bl.LastError = fmt.Sprintf("download failed: %v", err)
|
||||
changed = true
|
||||
slog.Warn("download failed", "component", "dns-filter", "name", bl.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
resp.Body.Close()
|
||||
bl.LastError = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
changed = true
|
||||
slog.Warn("download failed", "component", "dns-filter", "name", bl.Name, "status", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
// Limit download to 50MB
|
||||
limited := io.LimitReader(resp.Body, 50*1024*1024)
|
||||
data, err := io.ReadAll(limited)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
bl.LastError = fmt.Sprintf("read failed: %v", err)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.WriteFile(m.cacheFile(bl.ID), data, 0644); err != nil {
|
||||
bl.LastError = fmt.Sprintf("write failed: %v", err)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
|
||||
bl.LastUpdated = time.Now()
|
||||
bl.LastError = ""
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
_ = m.SaveFilterData(fd)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile merges all enabled cached lists, applies custom entries,
|
||||
// and writes the compiled hosts.blocked file. Returns domain count.
|
||||
func (m *Manager) Compile() (int, error) {
|
||||
fd, err := m.LoadFilterData()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
blocked := make(map[string]struct{})
|
||||
|
||||
// Parse all enabled lists
|
||||
for i := range fd.Lists {
|
||||
bl := &fd.Lists[i]
|
||||
if !bl.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
cachePath := m.cacheFile(bl.ID)
|
||||
if !storage.FileExists(cachePath) {
|
||||
continue
|
||||
}
|
||||
|
||||
domains, err := ParseFile(cachePath)
|
||||
if err != nil {
|
||||
slog.Warn("failed to parse list", "component", "dns-filter", "name", bl.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for d := range domains {
|
||||
blocked[d] = struct{}{}
|
||||
}
|
||||
bl.EntryCount = len(domains)
|
||||
}
|
||||
|
||||
// Apply custom entries
|
||||
for _, ce := range fd.CustomEntries {
|
||||
switch ce.Type {
|
||||
case "allow":
|
||||
delete(blocked, ce.Domain)
|
||||
case "block":
|
||||
blocked[ce.Domain] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Write hosts.blocked atomically
|
||||
tmpPath := m.HostsFilePath() + ".tmp"
|
||||
f, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("creating hosts file: %w", err)
|
||||
}
|
||||
|
||||
// Sort for deterministic output
|
||||
sorted := make([]string, 0, len(blocked))
|
||||
for d := range blocked {
|
||||
sorted = append(sorted, d)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
|
||||
w := bufio.NewWriter(f)
|
||||
fmt.Fprintln(w, "# Wild Central DNS Filter — auto-generated, do not edit")
|
||||
for _, d := range sorted {
|
||||
fmt.Fprintf(w, "address=/%s/\n", d)
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmpPath)
|
||||
return 0, fmt.Errorf("writing hosts file: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
if err := os.Rename(tmpPath, m.HostsFilePath()); err != nil {
|
||||
return 0, fmt.Errorf("renaming hosts file: %w", err)
|
||||
}
|
||||
|
||||
// Update entry counts and save
|
||||
_ = m.SaveFilterData(fd)
|
||||
|
||||
slog.Info("compiled blocklist", "component", "dns-filter", "domains", len(sorted))
|
||||
return len(sorted), nil
|
||||
}
|
||||
|
||||
// GetStats returns current filtering statistics.
|
||||
func (m *Manager) GetStats() FilterStats {
|
||||
fd, err := m.LoadFilterData()
|
||||
if err != nil {
|
||||
return FilterStats{}
|
||||
}
|
||||
|
||||
var enabled int
|
||||
var lastUpdated time.Time
|
||||
for _, bl := range fd.Lists {
|
||||
if bl.Enabled {
|
||||
enabled++
|
||||
}
|
||||
if bl.LastUpdated.After(lastUpdated) {
|
||||
lastUpdated = bl.LastUpdated
|
||||
}
|
||||
}
|
||||
|
||||
// Count lines in hosts.blocked for total
|
||||
total := 0
|
||||
if f, err := os.Open(m.HostsFilePath()); err == nil {
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
total++
|
||||
}
|
||||
_ = scanner.Err()
|
||||
f.Close()
|
||||
}
|
||||
|
||||
return FilterStats{
|
||||
TotalBlocked: total,
|
||||
ListCount: len(fd.Lists),
|
||||
EnabledLists: enabled,
|
||||
LastUpdated: lastUpdated,
|
||||
}
|
||||
}
|
||||
|
||||
// GetQueryStats parses dnsmasq journal logs to count total and blocked queries.
|
||||
// With address=/ directives, blocked queries log as "config <domain> is NXDOMAIN"
|
||||
// (for address=/domain/) or "config <domain> is 0.0.0.0".
|
||||
func (m *Manager) GetQueryStats() *QueryStats {
|
||||
// Use journalctl to read dnsmasq logs from the last 24 hours.
|
||||
// Run via sudo since the wildcloud user may not be in the systemd-journal group.
|
||||
cmd := exec.Command("sudo", "journalctl", "-u", "dnsmasq", "--since", "24 hours ago", "--no-pager", "-o", "cat")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
slog.Debug("failed to read dnsmasq journal", "component", "dns-filter", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var total, blocked int
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(output)))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.Contains(line, "query[") {
|
||||
total++
|
||||
}
|
||||
// dnsmasq logs address=/ blocks as: "config <domain> is NXDOMAIN"
|
||||
// and addn-hosts blocks as: "/path/file <domain> is 0.0.0.0"
|
||||
if strings.HasPrefix(line, "config ") && strings.Contains(line, " is NXDOMAIN") {
|
||||
blocked++
|
||||
}
|
||||
}
|
||||
|
||||
var pct float64
|
||||
if total > 0 {
|
||||
pct = float64(blocked) / float64(total) * 100
|
||||
}
|
||||
|
||||
return &QueryStats{
|
||||
TotalQueries: total,
|
||||
BlockedQueries: blocked,
|
||||
BlockedPercent: pct,
|
||||
Period: "24h",
|
||||
}
|
||||
}
|
||||
|
||||
// SuggestedLists returns well-known blocklists for easy adding.
|
||||
// Prefers dnsmasq-format lists which support wildcard subdomain blocking.
|
||||
func SuggestedLists() []Blocklist {
|
||||
type entry struct {
|
||||
url string
|
||||
name string
|
||||
}
|
||||
lists := []entry{
|
||||
{"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", "Steven Black - Unified Hosts"},
|
||||
{"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/dnsmasq/light.txt", "Hagezi - Light"},
|
||||
{"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/dnsmasq/pro.txt", "Hagezi - Pro"},
|
||||
{"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/dnsmasq/ultimate.txt", "Hagezi - Ultimate"},
|
||||
{"https://big.oisd.nl/dnsmasq2", "OISD - Big"},
|
||||
{"https://small.oisd.nl/dnsmasq2", "OISD - Small"},
|
||||
}
|
||||
result := make([]Blocklist, len(lists))
|
||||
for i, l := range lists {
|
||||
result[i] = Blocklist{ID: listID(l.url), URL: l.url, Name: l.name}
|
||||
}
|
||||
return result
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
89
internal/dnsfilter/parser.go
Normal file
89
internal/dnsfilter/parser.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package dnsfilter
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseLine extracts a domain from a single blocklist line.
|
||||
// Handles hosts format (0.0.0.0 domain), dnsmasq format (address=/domain/...),
|
||||
// and plain domain lists. Returns empty string and false for unparseable lines.
|
||||
func ParseLine(line string) (string, bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || line[0] == '#' || line[0] == '!' {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// dnsmasq format: address=/domain/0.0.0.0 or server=/domain/
|
||||
if strings.HasPrefix(line, "address=/") || strings.HasPrefix(line, "server=/") {
|
||||
parts := strings.SplitN(line, "/", 4)
|
||||
if len(parts) >= 3 && parts[1] != "" {
|
||||
return validateDomain(parts[1])
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Hosts format: 0.0.0.0 domain or 127.0.0.1 domain
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
ip := fields[0]
|
||||
if ip == "0.0.0.0" || ip == "127.0.0.1" || ip == "::1" || ip == "::0" {
|
||||
domain := fields[1]
|
||||
// Strip inline comments
|
||||
if i := strings.IndexByte(domain, '#'); i >= 0 {
|
||||
domain = domain[:i]
|
||||
}
|
||||
return validateDomain(domain)
|
||||
}
|
||||
}
|
||||
|
||||
// Plain domain format: one domain per line
|
||||
if len(fields) == 1 {
|
||||
return validateDomain(fields[0])
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// validateDomain checks that a string looks like a valid domain for blocking.
|
||||
func validateDomain(d string) (string, bool) {
|
||||
d = strings.ToLower(strings.TrimSpace(d))
|
||||
if d == "" || !strings.Contains(d, ".") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Reject known non-domains
|
||||
switch d {
|
||||
case "localhost", "localhost.localdomain", "local", "broadcasthost",
|
||||
"ip6-localhost", "ip6-loopback", "ip6-localnet",
|
||||
"ip6-mcastprefix", "ip6-allnodes", "ip6-allrouters":
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Reject IP addresses
|
||||
if net.ParseIP(d) != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return d, true
|
||||
}
|
||||
|
||||
// ParseFile reads a cached blocklist file and returns all unique domains.
|
||||
func ParseFile(path string) (map[string]struct{}, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
domains := make(map[string]struct{})
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
if domain, ok := ParseLine(scanner.Text()); ok {
|
||||
domains[domain] = struct{}{}
|
||||
}
|
||||
}
|
||||
return domains, scanner.Err()
|
||||
}
|
||||
141
internal/dnsfilter/parser_test.go
Normal file
141
internal/dnsfilter/parser_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package dnsfilter
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseLine_HostsFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
line string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"0.0.0.0 ads.example.com", "ads.example.com", true},
|
||||
{"127.0.0.1 tracker.example.com", "tracker.example.com", true},
|
||||
{"0.0.0.0 ads.example.com # comment", "ads.example.com", true},
|
||||
{"::1 ads.example.com", "ads.example.com", true},
|
||||
{"0.0.0.0 localhost", "", false},
|
||||
{"0.0.0.0 broadcasthost", "", false},
|
||||
{"0.0.0.0 localhost.localdomain", "", false},
|
||||
{"127.0.0.1 local", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, ok := ParseLine(tt.line)
|
||||
if ok != tt.wantOK || got != tt.want {
|
||||
t.Errorf("ParseLine(%q) = (%q, %v), want (%q, %v)", tt.line, got, ok, tt.want, tt.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLine_DnsmasqFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
line string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"address=/ads.example.com/0.0.0.0", "ads.example.com", true},
|
||||
{"address=/tracker.example.com/", "tracker.example.com", true},
|
||||
{"server=/blocked.example.com/", "blocked.example.com", true},
|
||||
{"address=//", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, ok := ParseLine(tt.line)
|
||||
if ok != tt.wantOK || got != tt.want {
|
||||
t.Errorf("ParseLine(%q) = (%q, %v), want (%q, %v)", tt.line, got, ok, tt.want, tt.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLine_PlainDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
line string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"ads.example.com", "ads.example.com", true},
|
||||
{"TRACKER.Example.COM", "tracker.example.com", true},
|
||||
{"nodot", "", false},
|
||||
{"# comment", "", false},
|
||||
{"", "", false},
|
||||
{" ", "", false},
|
||||
{"192.168.1.1", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, ok := ParseLine(tt.line)
|
||||
if ok != tt.wantOK || got != tt.want {
|
||||
t.Errorf("ParseLine(%q) = (%q, %v), want (%q, %v)", tt.line, got, ok, tt.want, tt.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile(t *testing.T) {
|
||||
content := `# Steven Black hosts file
|
||||
0.0.0.0 localhost
|
||||
0.0.0.0 ads.example.com
|
||||
0.0.0.0 tracker.example.com
|
||||
127.0.0.1 more-ads.example.com
|
||||
# duplicate
|
||||
0.0.0.0 ads.example.com
|
||||
`
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.hosts")
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
domains, err := ParseFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Should have 3 unique domains (localhost excluded)
|
||||
if len(domains) != 3 {
|
||||
t.Errorf("got %d domains, want 3", len(domains))
|
||||
}
|
||||
|
||||
for _, expected := range []string{"ads.example.com", "tracker.example.com", "more-ads.example.com"} {
|
||||
if _, ok := domains[expected]; !ok {
|
||||
t.Errorf("missing expected domain: %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_MixedFormats(t *testing.T) {
|
||||
content := `# Mixed format file
|
||||
0.0.0.0 hosts-format.example.com
|
||||
address=/dnsmasq-format.example.com/0.0.0.0
|
||||
plain-domain.example.com
|
||||
! adblock comment
|
||||
127.0.0.1 another.example.com
|
||||
`
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "mixed.txt")
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
domains, err := ParseFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(domains) != 4 {
|
||||
t.Errorf("got %d domains, want 4", len(domains))
|
||||
}
|
||||
|
||||
for _, expected := range []string{
|
||||
"hosts-format.example.com",
|
||||
"dnsmasq-format.example.com",
|
||||
"plain-domain.example.com",
|
||||
"another.example.com",
|
||||
} {
|
||||
if _, ok := domains[expected]; !ok {
|
||||
t.Errorf("missing expected domain: %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
115
internal/dnsfilter/runner.go
Normal file
115
internal/dnsfilter/runner.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package dnsfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Runner manages the background blocklist update goroutine.
|
||||
type Runner struct {
|
||||
mu sync.RWMutex
|
||||
manager *Manager
|
||||
cancel context.CancelFunc
|
||||
trigger chan struct{}
|
||||
running bool
|
||||
onUpdate func() // called after lists are downloaded and compiled
|
||||
}
|
||||
|
||||
// NewRunner creates a new DNS filter update runner.
|
||||
func NewRunner(manager *Manager, onUpdate func()) *Runner {
|
||||
return &Runner{
|
||||
manager: manager,
|
||||
trigger: make(chan struct{}, 1),
|
||||
onUpdate: onUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the background update goroutine.
|
||||
// Safe to call multiple times — cancels the previous run first.
|
||||
func (r *Runner) Start(parentCtx context.Context, intervalHours int) {
|
||||
r.mu.Lock()
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(parentCtx)
|
||||
r.cancel = cancel
|
||||
r.running = true
|
||||
r.mu.Unlock()
|
||||
|
||||
if intervalHours <= 0 {
|
||||
intervalHours = 24
|
||||
}
|
||||
|
||||
go r.run(ctx, intervalHours)
|
||||
}
|
||||
|
||||
// Stop cancels the running goroutine.
|
||||
func (r *Runner) Stop() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
r.cancel = nil
|
||||
}
|
||||
r.running = false
|
||||
}
|
||||
|
||||
// IsRunning returns whether the runner is active.
|
||||
func (r *Runner) IsRunning() bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.running
|
||||
}
|
||||
|
||||
// Trigger requests an immediate update (non-blocking).
|
||||
func (r *Runner) Trigger() {
|
||||
select {
|
||||
case r.trigger <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) run(ctx context.Context, intervalHours int) {
|
||||
interval := time.Duration(intervalHours) * time.Hour
|
||||
|
||||
slog.Info("runner started", "component", "dns-filter", "interval", interval)
|
||||
|
||||
// Immediate update on start
|
||||
r.updateAndCompile(ctx)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
r.mu.Lock()
|
||||
r.running = false
|
||||
r.mu.Unlock()
|
||||
slog.Info("runner stopped", "component", "dns-filter")
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.updateAndCompile(ctx)
|
||||
case <-r.trigger:
|
||||
r.updateAndCompile(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) updateAndCompile(ctx context.Context) {
|
||||
if err := r.manager.UpdateLists(ctx); err != nil {
|
||||
slog.Error("update failed", "component", "dns-filter", "error", err)
|
||||
}
|
||||
|
||||
if _, err := r.manager.Compile(); err != nil {
|
||||
slog.Error("compile failed", "component", "dns-filter", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if r.onUpdate != nil {
|
||||
r.onUpdate()
|
||||
}
|
||||
}
|
||||
@@ -11,42 +11,50 @@ import (
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/config"
|
||||
"github.com/wild-cloud/wild-central/internal/network"
|
||||
"github.com/wild-cloud/wild-central/internal/storage"
|
||||
)
|
||||
|
||||
// DNSEntry represents a single domain-to-IP DNS mapping for dnsmasq.
|
||||
// Each entry produces both local=/ and address=/ directives.
|
||||
// local=/ makes dnsmasq authoritative for the domain, which prevents
|
||||
// AAAA queries from leaking to upstream DNS. Without it, upstream could
|
||||
// return public IPv6 records and browsers using Happy Eyeballs (RFC 8305)
|
||||
// would try the unreachable IPv6 path first, adding latency on LAN.
|
||||
// Exact-match domains use host-record= (SOA/NS queries forwarded upstream,
|
||||
// enabling ACME DNS-01 validation). Wildcard domains use address=/ to resolve
|
||||
// all subdomains. Happy Eyeballs (AAAA leaking) is handled globally by
|
||||
// filter-AAAA in the config template.
|
||||
type DNSEntry struct {
|
||||
Domain string // FQDN to resolve (e.g. "cloud.payne.io")
|
||||
IP string // target IPv4 address
|
||||
Domain string // FQDN to resolve (e.g. "cloud.payne.io")
|
||||
IP string // target IPv4 address
|
||||
Wildcard bool // true: address=/ (all subdomains); false: host-record= (exact match)
|
||||
}
|
||||
|
||||
// ConfigGenerator handles dnsmasq configuration generation
|
||||
type ConfigGenerator struct {
|
||||
configPath string
|
||||
// Manager handles dnsmasq configuration generation
|
||||
type Manager struct {
|
||||
configPath string
|
||||
filterConfPath string // path to addn-hosts file for DNS filtering
|
||||
}
|
||||
|
||||
// NewConfigGenerator creates a new dnsmasq config generator
|
||||
func NewConfigGenerator(configPath string) *ConfigGenerator {
|
||||
// NewManager creates a new dnsmasq config generator
|
||||
func NewManager(configPath string) *Manager {
|
||||
if configPath == "" {
|
||||
configPath = "/etc/dnsmasq.d/wild-cloud.conf"
|
||||
configPath = "/etc/dnsmasq.d/wild-central.conf"
|
||||
}
|
||||
return &ConfigGenerator{
|
||||
return &Manager{
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigPath returns the dnsmasq config file path
|
||||
func (g *ConfigGenerator) GetConfigPath() string {
|
||||
func (g *Manager) GetConfigPath() string {
|
||||
return g.configPath
|
||||
}
|
||||
|
||||
// SetFilterConfPath sets the path to an additional hosts file for DNS filtering.
|
||||
// When set, the generated config includes an addn-hosts= directive.
|
||||
func (g *Manager) SetFilterConfPath(path string) {
|
||||
g.filterConfPath = path
|
||||
}
|
||||
|
||||
// Generate creates a dnsmasq configuration from registered domain entries.
|
||||
// It auto-detects the Wild Central server's IP address for the listen directive.
|
||||
func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string {
|
||||
func (g *Manager) Generate(cfg *config.State, entries []DNSEntry) string {
|
||||
// Use configured dnsmasq IP (bound to eth0), falling back to auto-detect.
|
||||
// Auto-detect can pick wlan0 if that's the default route, which is wrong
|
||||
// when dnsmasq is only listening on eth0.
|
||||
@@ -68,12 +76,17 @@ func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string
|
||||
if entry.Domain == "" || entry.IP == "" {
|
||||
continue
|
||||
}
|
||||
// local=/ makes dnsmasq authoritative for the domain so AAAA queries
|
||||
// return empty instead of leaking to upstream DNS. Without this,
|
||||
// upstream could return public IPv6 records and browsers using Happy
|
||||
// Eyeballs (RFC 8305) would try the unreachable IPv6 path first,
|
||||
// adding 250ms-2s latency on LAN.
|
||||
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", entry.Domain, entry.Domain, entry.IP)
|
||||
if entry.Wildcard {
|
||||
// Wildcard: resolves domain + all subdomains to this IP.
|
||||
// Note: address=/ blocks forwarding for the entire subtree,
|
||||
// so SOA walks stop here. For non-apex domains this is fine —
|
||||
// the walk continues up to the parent (which uses host-record=).
|
||||
resolution_section += fmt.Sprintf("address=/%s/%s\n", entry.Domain, entry.IP)
|
||||
} else {
|
||||
// Exact match: only this specific name. SOA/NS/TXT queries
|
||||
// are forwarded upstream, enabling ACME DNS-01 validation.
|
||||
resolution_section += fmt.Sprintf("host-record=%s,%s\n", entry.Domain, entry.IP)
|
||||
}
|
||||
}
|
||||
|
||||
template := `# Configuration file for dnsmasq.
|
||||
@@ -84,8 +97,9 @@ bind-interfaces
|
||||
domain-needed
|
||||
bogus-priv
|
||||
no-resolv
|
||||
filter-AAAA
|
||||
|
||||
# DNS Local Resolution - Central server handles these domains authoritatively
|
||||
# DNS Local Resolution - registered domain A records
|
||||
%s
|
||||
server=1.1.1.1
|
||||
server=8.8.8.8
|
||||
@@ -94,16 +108,24 @@ log-queries
|
||||
log-dhcp
|
||||
`
|
||||
|
||||
return fmt.Sprintf(template,
|
||||
result := fmt.Sprintf(template,
|
||||
dnsIP,
|
||||
resolution_section,
|
||||
)
|
||||
|
||||
if g.filterConfPath != "" {
|
||||
result += fmt.Sprintf("\n# DNS Filtering\nconf-file=%s\n", g.filterConfPath)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// RestartService reloads the dnsmasq service (SIGHUP — no downtime).
|
||||
// Falls back to restart if reload fails.
|
||||
func (g *ConfigGenerator) RestartService() error {
|
||||
cmd := exec.Command("systemctl", "reload-or-restart", "dnsmasq.service")
|
||||
// RestartService fully restarts the dnsmasq service.
|
||||
// A full restart is required because SIGHUP (reload) does not re-read
|
||||
// host-record= directives — only address=/ and a few other directives
|
||||
// are reloaded on SIGHUP.
|
||||
func (g *Manager) RestartService() error {
|
||||
cmd := exec.Command("systemctl", "restart", "dnsmasq.service")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reload dnsmasq: %w (output: %s)", err, string(output))
|
||||
@@ -112,18 +134,18 @@ func (g *ConfigGenerator) RestartService() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServiceStatus represents the status of the dnsmasq service
|
||||
type ServiceStatus struct {
|
||||
Status string `json:"status"`
|
||||
PID int `json:"pid"`
|
||||
IP string `json:"ip"`
|
||||
ConfigFile string `json:"config_file"`
|
||||
DomainsConfigured int `json:"domains_configured"`
|
||||
LastRestart time.Time `json:"last_restart"`
|
||||
// Status represents the status of the dnsmasq service
|
||||
type Status struct {
|
||||
Status string `json:"status"`
|
||||
PID int `json:"pid"`
|
||||
IP string `json:"ip"`
|
||||
ConfigFile string `json:"configFile"`
|
||||
DomainsConfigured int `json:"domainsConfigured"`
|
||||
LastRestart time.Time `json:"lastRestart"`
|
||||
}
|
||||
|
||||
// GetStatus checks the status of the dnsmasq service
|
||||
func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) {
|
||||
func (g *Manager) GetStatus() (*Status, error) {
|
||||
// Get the Wild Central IP address
|
||||
dnsIP, err := network.GetWildCentralIP()
|
||||
if err != nil {
|
||||
@@ -131,7 +153,7 @@ func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) {
|
||||
dnsIP = ""
|
||||
}
|
||||
|
||||
status := &ServiceStatus{
|
||||
status := &Status{
|
||||
ConfigFile: g.configPath,
|
||||
IP: dnsIP,
|
||||
}
|
||||
@@ -174,16 +196,17 @@ func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Count domains in config by counting local=/ directives
|
||||
// Count domains in config by counting host-record= and address=/ directives
|
||||
if data, err := os.ReadFile(g.configPath); err == nil {
|
||||
status.DomainsConfigured = strings.Count(string(data), "\nlocal=/")
|
||||
config := string(data)
|
||||
status.DomainsConfigured = strings.Count(config, "host-record=") + strings.Count(config, "\naddress=/")
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// ReadConfig reads the current dnsmasq configuration
|
||||
func (g *ConfigGenerator) ReadConfig() (string, error) {
|
||||
func (g *Manager) ReadConfig() (string, error) {
|
||||
data, err := os.ReadFile(g.configPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading dnsmasq config: %w", err)
|
||||
@@ -192,13 +215,13 @@ func (g *ConfigGenerator) ReadConfig() (string, error) {
|
||||
}
|
||||
|
||||
// UpdateConfig regenerates and writes the dnsmasq configuration and optionally restarts.
|
||||
func (g *ConfigGenerator) UpdateConfig(cfg *config.State, entries []DNSEntry, restart bool) error {
|
||||
func (g *Manager) UpdateConfig(cfg *config.State, entries []DNSEntry, restart bool) error {
|
||||
// Generate fresh config from scratch
|
||||
configContent := g.Generate(cfg, entries)
|
||||
|
||||
// Write config
|
||||
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", g.configPath)
|
||||
if err := os.WriteFile(g.configPath, []byte(configContent), 0644); err != nil {
|
||||
if err := storage.WriteFileAtomic(g.configPath, []byte(configContent), 0644); err != nil {
|
||||
return fmt.Errorf("writing dnsmasq config: %w", err)
|
||||
}
|
||||
|
||||
@@ -213,7 +236,7 @@ func (g *ConfigGenerator) UpdateConfig(cfg *config.State, entries []DNSEntry, re
|
||||
// GenerateMainConfig creates the main dnsmasq configuration with global settings.
|
||||
// Used by the DnsmasqGenerate handler to preview/apply the base config independently
|
||||
// of domain-specific DNS entries (which are handled by Generate + UpdateConfig).
|
||||
func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string {
|
||||
func (g *Manager) GenerateMainConfig(cfg *config.State) string {
|
||||
dnsIP, err := network.GetWildCentralIP()
|
||||
if err != nil {
|
||||
slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "error", err)
|
||||
@@ -270,11 +293,71 @@ log-dhcp
|
||||
}
|
||||
}
|
||||
|
||||
if g.filterConfPath != "" {
|
||||
fmt.Fprintf(&sb, "\n# DNS Filtering\nconf-file=%s\n", g.filterConfPath)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// SafeApply validates, backs up, writes, restarts, and verifies the dnsmasq config.
|
||||
// On restart or verification failure, rolls back to the previous config.
|
||||
func (g *Manager) SafeApply(content string) error {
|
||||
// Validate via temp file
|
||||
tmpFile := g.configPath + ".validate"
|
||||
if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("writing validation file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
if err := g.ValidateConfig(tmpFile); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Backup current config
|
||||
if storage.FileExists(g.configPath) {
|
||||
_ = storage.CopyFile(g.configPath, g.configPath+".bak")
|
||||
}
|
||||
|
||||
// Write atomically
|
||||
if err := storage.WriteFileAtomic(g.configPath, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("write failed: %w", err)
|
||||
}
|
||||
|
||||
// Restart daemon
|
||||
if err := g.RestartService(); err != nil {
|
||||
g.rollback()
|
||||
return fmt.Errorf("restart failed (rolled back): %w", err)
|
||||
}
|
||||
|
||||
// Verify daemon is running
|
||||
if err := g.verify(); err != nil {
|
||||
g.rollback()
|
||||
return fmt.Errorf("health check failed (rolled back): %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Manager) rollback() {
|
||||
bakPath := g.configPath + ".bak"
|
||||
if storage.FileExists(bakPath) {
|
||||
_ = os.Rename(bakPath, g.configPath)
|
||||
_ = g.RestartService()
|
||||
slog.Warn("rolled back to previous config", "component", "dnsmasq", "path", g.configPath)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Manager) verify() error {
|
||||
cmd := exec.Command("systemctl", "is-active", "--quiet", "dnsmasq.service")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("dnsmasq not active after restart")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateConfig tests a dnsmasq configuration file for syntax errors
|
||||
func (g *ConfigGenerator) ValidateConfig(configPath string) error {
|
||||
func (g *Manager) ValidateConfig(configPath string) error {
|
||||
cmd := exec.Command("dnsmasq", "--test", "-C", configPath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -286,22 +369,15 @@ func (g *ConfigGenerator) ValidateConfig(configPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadService sends a SIGHUP to dnsmasq to reload configuration.
|
||||
// Lighter weight than a full restart. Falls back to RestartService on failure.
|
||||
func (g *ConfigGenerator) ReloadService() error {
|
||||
cmd := exec.Command("systemctl", "reload", "dnsmasq.service")
|
||||
_, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("reload failed, attempting restart", "component", "dnsmasq", "error", err)
|
||||
return g.RestartService()
|
||||
}
|
||||
slog.Info("dnsmasq service reloaded", "component", "dnsmasq")
|
||||
return nil
|
||||
// ReloadService restarts dnsmasq to apply configuration changes.
|
||||
// Uses a full restart because SIGHUP does not re-read host-record= directives.
|
||||
func (g *Manager) ReloadService() error {
|
||||
return g.RestartService()
|
||||
}
|
||||
|
||||
// ConfigureSystemDNS configures systemd-resolved to use the local dnsmasq server
|
||||
// This should only be called on first start of dnsmasq
|
||||
func (g *ConfigGenerator) ConfigureSystemDNS() error {
|
||||
func (g *Manager) ConfigureSystemDNS() error {
|
||||
// Auto-detect network info to get the DNS IP
|
||||
netInfo, err := network.DetectNetworkInfo()
|
||||
if err != nil {
|
||||
@@ -312,7 +388,7 @@ func (g *ConfigGenerator) ConfigureSystemDNS() error {
|
||||
|
||||
// Write systemd-resolved configuration to file owned by wildcloud user
|
||||
// (created during package installation in postinst)
|
||||
resolvedConfPath := "/etc/systemd/resolved.conf.d/wild-cloud.conf"
|
||||
resolvedConfPath := "/etc/systemd/resolved.conf.d/wild-central.conf"
|
||||
resolvedConf := fmt.Sprintf("[Resolve]\nDNS=%s\nDomains=~.\n", dnsIP)
|
||||
|
||||
if err := os.WriteFile(resolvedConfPath, []byte(resolvedConf), 0644); err != nil {
|
||||
|
||||
@@ -12,9 +12,13 @@ func entry(domain, ip string) DNSEntry {
|
||||
return DNSEntry{Domain: domain, IP: ip}
|
||||
}
|
||||
|
||||
// Test: Generate with entries produces expected DNS entries
|
||||
func wildcardEntry(domain, ip string) DNSEntry {
|
||||
return DNSEntry{Domain: domain, IP: ip, Wildcard: true}
|
||||
}
|
||||
|
||||
// Test: Generate with exact-match entries produces host-record= directives
|
||||
func TestGenerate_WithEntries(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
entries := []DNSEntry{
|
||||
entry("cloud.example.com", "192.168.1.10"),
|
||||
entry("internal.example.com", "192.168.1.10"),
|
||||
@@ -22,17 +26,11 @@ func TestGenerate_WithEntries(t *testing.T) {
|
||||
|
||||
out := g.Generate(nil, entries)
|
||||
|
||||
if !strings.Contains(out, "local=/cloud.example.com/") {
|
||||
t.Errorf("expected local=/ for domain, got:\n%s", out)
|
||||
if !strings.Contains(out, "host-record=cloud.example.com,192.168.1.10") {
|
||||
t.Errorf("expected host-record for domain, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") {
|
||||
t.Errorf("expected address entry for domain, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "local=/internal.example.com/") {
|
||||
t.Errorf("expected local=/ for internal domain, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") {
|
||||
t.Errorf("expected address entry for internal domain, got:\n%s", out)
|
||||
if !strings.Contains(out, "host-record=internal.example.com,192.168.1.10") {
|
||||
t.Errorf("expected host-record for internal domain, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "server=1.1.1.1") {
|
||||
t.Errorf("expected upstream DNS server, got:\n%s", out)
|
||||
@@ -41,7 +39,7 @@ func TestGenerate_WithEntries(t *testing.T) {
|
||||
|
||||
// Test: Generate with no entries still produces valid config skeleton
|
||||
func TestGenerate_NoEntries(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
|
||||
out := g.Generate(nil, []DNSEntry{})
|
||||
|
||||
@@ -58,18 +56,18 @@ func TestGenerate_NoEntries(t *testing.T) {
|
||||
|
||||
// Test: Generate skips entries with empty IP
|
||||
func TestGenerate_EntryWithoutIP(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
|
||||
out := g.Generate(nil, []DNSEntry{entry("cloud.example.com", "")})
|
||||
|
||||
if strings.Contains(out, "address=/") {
|
||||
t.Errorf("expected no address= entries for empty IP, got:\n%s", out)
|
||||
if strings.Contains(out, "host-record=") {
|
||||
t.Errorf("expected no host-record entries for empty IP, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: GenerateMainConfig produces valid base config
|
||||
func TestGenerateMainConfig(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
|
||||
out := g.GenerateMainConfig(nil)
|
||||
|
||||
@@ -83,7 +81,7 @@ func TestGenerateMainConfig(t *testing.T) {
|
||||
|
||||
// Test: GenerateMainConfig with DHCP disabled does not include dhcp-range
|
||||
func TestGenerateMainConfig_DHCPDisabled(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
cfg := &config.State{}
|
||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = false
|
||||
|
||||
@@ -96,7 +94,7 @@ func TestGenerateMainConfig_DHCPDisabled(t *testing.T) {
|
||||
|
||||
// Test: GenerateMainConfig with DHCP enabled includes dhcp-range
|
||||
func TestGenerateMainConfig_DHCPEnabled(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
cfg := &config.State{}
|
||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||
@@ -116,7 +114,7 @@ func TestGenerateMainConfig_DHCPEnabled(t *testing.T) {
|
||||
|
||||
// Test: DHCP without gateway omits dhcp-option=3
|
||||
func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
cfg := &config.State{}
|
||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||
@@ -131,7 +129,7 @@ func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) {
|
||||
|
||||
// Test: DHCP explicit gateway is used
|
||||
func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
cfg := &config.State{}
|
||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||
@@ -147,7 +145,7 @@ func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
|
||||
|
||||
// Test: DHCP static leases appear in output
|
||||
func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
cfg := &config.State{}
|
||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||
@@ -169,7 +167,7 @@ func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) {
|
||||
|
||||
// Test: DHCP lease time defaults to 24h when not specified
|
||||
func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
cfg := &config.State{}
|
||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||
@@ -182,96 +180,116 @@ func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test: NewConfigGenerator uses provided path
|
||||
func TestNewConfigGenerator_CustomPath(t *testing.T) {
|
||||
g := NewConfigGenerator("/custom/path/dnsmasq.conf")
|
||||
// Test: NewManager uses provided path
|
||||
func TestNewManager_CustomPath(t *testing.T) {
|
||||
g := NewManager("/custom/path/dnsmasq.conf")
|
||||
if g.GetConfigPath() != "/custom/path/dnsmasq.conf" {
|
||||
t.Errorf("got %q, want /custom/path/dnsmasq.conf", g.GetConfigPath())
|
||||
}
|
||||
}
|
||||
|
||||
// Test: NewConfigGenerator uses default path when empty
|
||||
func TestNewConfigGenerator_DefaultPath(t *testing.T) {
|
||||
g := NewConfigGenerator("")
|
||||
if g.GetConfigPath() != "/etc/dnsmasq.d/wild-cloud.conf" {
|
||||
t.Errorf("got %q, want /etc/dnsmasq.d/wild-cloud.conf", g.GetConfigPath())
|
||||
// Test: NewManager uses default path when empty
|
||||
func TestNewManager_DefaultPath(t *testing.T) {
|
||||
g := NewManager("")
|
||||
if g.GetConfigPath() != "/etc/dnsmasq.d/wild-central.conf" {
|
||||
t.Errorf("got %q, want /etc/dnsmasq.d/wild-central.conf", g.GetConfigPath())
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Domain-only entry (no internal domain pair)
|
||||
func TestGenerate_DomainOnly(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
// Test: Exact-match domain produces host-record=
|
||||
func TestGenerate_ExactMatchDomain(t *testing.T) {
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
globalCfg := &config.State{}
|
||||
|
||||
out := g.Generate(globalCfg, []DNSEntry{entry("my-api.payne.io", "192.168.8.151")})
|
||||
|
||||
if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") {
|
||||
t.Errorf("expected address entry for domain, got:\n%s", out)
|
||||
if !strings.Contains(out, "host-record=my-api.payne.io,192.168.8.151") {
|
||||
t.Errorf("expected host-record entry for domain, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "local=/my-api.payne.io/") {
|
||||
t.Errorf("expected local=/ entry for domain, got:\n%s", out)
|
||||
if strings.Contains(out, "address=/") {
|
||||
t.Errorf("exact-match domain must not produce address=/ directive:\n%s", out)
|
||||
}
|
||||
// Must NOT have empty entries
|
||||
if strings.Contains(out, "local=//") {
|
||||
t.Errorf("unexpected empty local=// in output:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "address=//") {
|
||||
t.Errorf("unexpected empty address=// in output:\n%s", out)
|
||||
if strings.Contains(out, "local=/") {
|
||||
t.Errorf("must not produce local=/ directive:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: All domains get local=/ to prevent AAAA leaking upstream
|
||||
func TestGenerate_AllDomainsGetLocal(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
// Test: Wildcard domain produces address=/ without local=/
|
||||
func TestGenerate_WildcardDomain(t *testing.T) {
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
globalCfg := &config.State{}
|
||||
|
||||
out := g.Generate(globalCfg, []DNSEntry{wildcardEntry("cloud.payne.io", "192.168.8.240")})
|
||||
|
||||
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
|
||||
t.Errorf("expected address=/ entry for wildcard domain, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "host-record=") {
|
||||
t.Errorf("wildcard domain must not produce host-record= directive:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "local=/") {
|
||||
t.Errorf("must not produce local=/ directive:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: No local=/ directives are ever generated
|
||||
func TestGenerate_NoLocalDirectives(t *testing.T) {
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
globalCfg := &config.State{}
|
||||
|
||||
// Both "internal" and "public" domains should get local=/ —
|
||||
// prevents AAAA queries from leaking to upstream DNS (Happy Eyeballs).
|
||||
entries := []DNSEntry{
|
||||
entry("internal-api.payne.io", "192.168.8.151"),
|
||||
entry("cloud.payne.io", "192.168.8.240"),
|
||||
wildcardEntry("cloud.payne.io", "192.168.8.240"),
|
||||
}
|
||||
|
||||
out := g.Generate(globalCfg, entries)
|
||||
|
||||
if !strings.Contains(out, "local=/internal-api.payne.io/") {
|
||||
t.Errorf("expected local=/ for internal domain, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "local=/cloud.payne.io/") {
|
||||
t.Errorf("expected local=/ for public domain (prevents AAAA leaking), got:\n%s", out)
|
||||
if strings.Contains(out, "local=/") {
|
||||
t.Errorf("must never produce local=/ directives:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Mix of domains each get their own entries
|
||||
// Test: filter-AAAA appears in generated config
|
||||
func TestGenerate_FilterAAAA(t *testing.T) {
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
|
||||
out := g.Generate(nil, nil)
|
||||
|
||||
if !strings.Contains(out, "filter-AAAA") {
|
||||
t.Errorf("expected filter-AAAA in config, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Mix of exact and wildcard domains
|
||||
func TestGenerate_MixedDomains(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
globalCfg := &config.State{}
|
||||
|
||||
entries := []DNSEntry{
|
||||
entry("cloud.payne.io", "192.168.8.240"), // k8s passthrough
|
||||
entry("central.payne.io", "192.168.8.151"), // Central HTTP
|
||||
entry("wild-cloud.payne.io", "192.168.8.151"), // service HTTP
|
||||
wildcardEntry("cloud.payne.io", "192.168.8.240"), // wildcard
|
||||
entry("central.payne.io", "192.168.8.151"), // exact
|
||||
entry("wild-cloud.payne.io", "192.168.8.151"), // exact
|
||||
}
|
||||
|
||||
out := g.Generate(globalCfg, entries)
|
||||
|
||||
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
|
||||
t.Errorf("expected address for cloud domain, got:\n%s", out)
|
||||
t.Errorf("expected address=/ for wildcard domain, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") {
|
||||
t.Errorf("expected address for central, got:\n%s", out)
|
||||
if !strings.Contains(out, "host-record=central.payne.io,192.168.8.151") {
|
||||
t.Errorf("expected host-record for exact domain, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "address=/wild-cloud.payne.io/192.168.8.151") {
|
||||
t.Errorf("expected address for wild-cloud, got:\n%s", out)
|
||||
if !strings.Contains(out, "host-record=wild-cloud.payne.io,192.168.8.151") {
|
||||
t.Errorf("expected host-record for exact domain, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "local=//") {
|
||||
t.Errorf("unexpected empty local=// in output:\n%s", out)
|
||||
if strings.Contains(out, "local=/") {
|
||||
t.Errorf("must not produce local=/ directives:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: empty entries list produces base config with no address= entries
|
||||
// Test: empty entries list produces base config with no DNS entries
|
||||
func TestGenerate_EmptyEntries(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
globalCfg := &config.State{}
|
||||
|
||||
out := g.Generate(globalCfg, []DNSEntry{})
|
||||
@@ -282,17 +300,17 @@ func TestGenerate_EmptyEntries(t *testing.T) {
|
||||
if !strings.Contains(out, "server=1.1.1.1") {
|
||||
t.Errorf("expected upstream DNS server, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "address=/") {
|
||||
t.Errorf("expected no address= entries with empty entries, got:\n%s", out)
|
||||
if strings.Contains(out, "host-record=") {
|
||||
t.Errorf("expected no host-record entries with empty entries, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "local=/") {
|
||||
t.Errorf("expected no local=/ entries with empty entries, got:\n%s", out)
|
||||
if strings.Contains(out, "address=/") {
|
||||
t.Errorf("expected no address=/ entries with empty entries, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: multiple entries each get their own DNS records
|
||||
// Test: multiple exact-match entries each get their own DNS records
|
||||
func TestGenerate_MultipleEntries(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
globalCfg := &config.State{}
|
||||
|
||||
entries := []DNSEntry{
|
||||
@@ -304,18 +322,52 @@ func TestGenerate_MultipleEntries(t *testing.T) {
|
||||
out := g.Generate(globalCfg, entries)
|
||||
|
||||
for _, e := range entries {
|
||||
if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", e.Domain, e.IP)) {
|
||||
t.Errorf("expected address entry for %s, got:\n%s", e.Domain, out)
|
||||
}
|
||||
if !strings.Contains(out, fmt.Sprintf("local=/%s/", e.Domain)) {
|
||||
t.Errorf("expected local=/ entry for %s, got:\n%s", e.Domain, out)
|
||||
if !strings.Contains(out, fmt.Sprintf("host-record=%s,%s", e.Domain, e.IP)) {
|
||||
t.Errorf("expected host-record entry for %s, got:\n%s", e.Domain, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_ConfFile(t *testing.T) {
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
g.SetFilterConfPath("/var/lib/wild-central/dns-filter/hosts.blocked")
|
||||
|
||||
out := g.Generate(nil, nil)
|
||||
|
||||
if !strings.Contains(out, "conf-file=/var/lib/wild-central/dns-filter/hosts.blocked") {
|
||||
t.Errorf("expected addn-hosts directive, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "# DNS Filtering") {
|
||||
t.Errorf("expected DNS Filtering comment, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_NoConfFileWhenEmpty(t *testing.T) {
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
// addnHostsPath is empty by default
|
||||
|
||||
out := g.Generate(nil, nil)
|
||||
|
||||
if strings.Contains(out, "addn-hosts") {
|
||||
t.Errorf("should not contain addn-hosts when path is empty, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMainConfig_ConfFile(t *testing.T) {
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
g.SetFilterConfPath("/tmp/hosts.blocked")
|
||||
|
||||
cfg := &config.State{}
|
||||
out := g.GenerateMainConfig(cfg)
|
||||
|
||||
if !strings.Contains(out, "conf-file=/tmp/hosts.blocked") {
|
||||
t.Errorf("expected addn-hosts in main config, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: nil config doesn't panic, uses auto-detect for IP
|
||||
func TestGenerate_NilConfig(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
|
||||
out := g.Generate(nil, []DNSEntry{})
|
||||
|
||||
@@ -329,7 +381,7 @@ func TestGenerate_NilConfig(t *testing.T) {
|
||||
|
||||
// Test: when cfg.Cloud.Dnsmasq.IP is set, uses that instead of auto-detect
|
||||
func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
cfg := &config.State{}
|
||||
cfg.Cloud.Dnsmasq.IP = "192.168.8.100"
|
||||
|
||||
@@ -340,9 +392,9 @@ func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test: verify no address=// or local=// entries appear
|
||||
// Test: verify no leaked empty entries appear
|
||||
func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
g := NewManager("/tmp/test-dnsmasq.conf")
|
||||
cfg := &config.State{}
|
||||
|
||||
cases := []struct {
|
||||
@@ -350,7 +402,8 @@ func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
|
||||
entries []DNSEntry
|
||||
}{
|
||||
{"empty", nil},
|
||||
{"domain_only", []DNSEntry{entry("example.com", "10.0.0.1")}},
|
||||
{"exact_domain", []DNSEntry{entry("example.com", "10.0.0.1")}},
|
||||
{"wildcard_domain", []DNSEntry{wildcardEntry("example.com", "10.0.0.1")}},
|
||||
{"no_ip", []DNSEntry{entry("example.com", "")}},
|
||||
{"no_domain", []DNSEntry{entry("", "10.0.0.1")}},
|
||||
}
|
||||
@@ -358,11 +411,14 @@ func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := g.Generate(cfg, tc.entries)
|
||||
if strings.Contains(out, "host-record=,") {
|
||||
t.Errorf("leaked empty host-record in output:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "address=//") {
|
||||
t.Errorf("leaked empty address=// in output:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "local=//") {
|
||||
t.Errorf("leaked empty local=// in output:\n%s", out)
|
||||
if strings.Contains(out, "local=/") {
|
||||
t.Errorf("must not produce local=/ directives:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,12 +9,16 @@ package domains
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/storage"
|
||||
)
|
||||
|
||||
// BackendType describes how the gateway handles traffic for this domain.
|
||||
@@ -58,17 +62,25 @@ type Route struct {
|
||||
IPAllow []string `yaml:"ipAllow,omitempty" json:"ipAllow,omitempty"` // per-route CIDR whitelist
|
||||
}
|
||||
|
||||
// AuthConfig describes forward-auth protection for an L7 HTTP domain.
|
||||
// Only applicable to domains with backend type "http" (L7 TLS termination).
|
||||
type AuthConfig struct {
|
||||
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
||||
Policy string `yaml:"policy,omitempty" json:"policy,omitempty"` // one_factor, two_factor, bypass
|
||||
}
|
||||
|
||||
// Domain represents a registered domain. The Domain field is the unique key.
|
||||
//
|
||||
// Simple domains use Backend directly. Multi-backend domains use Routes
|
||||
// for path-based splitting (Backend is ignored when Routes is present).
|
||||
type Domain struct {
|
||||
DomainName string `yaml:"domain" json:"domain"` // FQDN — unique key
|
||||
Source string `yaml:"source,omitempty" json:"source,omitempty"` // who registered: wild-cloud, wild-works, manual
|
||||
Backend Backend `yaml:"backend,omitempty" json:"backend,omitempty"` // single backend (simple case)
|
||||
Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain
|
||||
Public bool `yaml:"public,omitempty" json:"public,omitempty"` // true = internet-visible (DDNS + external), false = LAN only
|
||||
TLS TLSMode `yaml:"tls,omitempty" json:"tls,omitempty"` // passthrough or terminate
|
||||
DomainName string `yaml:"domain" json:"domain"` // FQDN — unique key
|
||||
Source string `yaml:"source,omitempty" json:"source,omitempty"` // who registered: wild-cloud, wild-works, manual
|
||||
Backend Backend `yaml:"backend,omitempty" json:"backend,omitempty"` // single backend (simple case)
|
||||
Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain
|
||||
Public bool `yaml:"public,omitempty" json:"public,omitempty"` // true = internet-visible (DDNS + external), false = LAN only
|
||||
TLS TLSMode `yaml:"tls,omitempty" json:"tls,omitempty"` // passthrough or terminate
|
||||
Auth *AuthConfig `yaml:"auth,omitempty" json:"auth,omitempty"` // forward-auth protection (L7 HTTP only)
|
||||
|
||||
// Multi-backend path routing. When present, Backend is ignored.
|
||||
// Each route maps path prefixes to a specific backend with its own L7 options.
|
||||
@@ -151,6 +163,86 @@ func (m *Manager) domainPath(domain string) string {
|
||||
return filepath.Join(m.domainsDir(), domainToFilename(domain))
|
||||
}
|
||||
|
||||
// isValidDomain checks that a string is a valid FQDN (RFC 1123) or wildcard domain.
|
||||
// Prevents config injection via newlines, spaces, or shell metacharacters.
|
||||
func isValidDomain(s string) bool {
|
||||
if len(s) == 0 || len(s) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(s, ".") {
|
||||
if len(label) == 0 || len(label) > 63 {
|
||||
return false
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, c := range label {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '*') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isValidBackendAddress checks that an address is a valid host:port.
|
||||
func isValidBackendAddress(addr string) bool {
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return false
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
return true
|
||||
}
|
||||
return isValidDomain(host)
|
||||
}
|
||||
|
||||
// isValidHeaderName checks that a header name contains only HTTP token characters.
|
||||
func isValidHeaderName(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if c <= ' ' || c == ':' || c == '"' || c >= 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isValidHeaderValue checks that a header value contains no newlines or NULs.
|
||||
func isValidHeaderValue(s string) bool {
|
||||
return !strings.ContainsAny(s, "\r\n\x00")
|
||||
}
|
||||
|
||||
// validateHeaders checks all header keys and values in a HeaderConfig.
|
||||
func validateHeaders(h *HeaderConfig) error {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
for k, v := range h.Request {
|
||||
if !isValidHeaderName(k) {
|
||||
return fmt.Errorf("invalid request header name: %q", k)
|
||||
}
|
||||
if !isValidHeaderValue(v) {
|
||||
return fmt.Errorf("invalid request header value for %q", k)
|
||||
}
|
||||
}
|
||||
for k, v := range h.Response {
|
||||
if !isValidHeaderName(k) {
|
||||
return fmt.Errorf("invalid response header name: %q", k)
|
||||
}
|
||||
if !isValidHeaderValue(v) {
|
||||
return fmt.Errorf("invalid response header value for %q", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register creates or updates a domain registration.
|
||||
func (m *Manager) Register(dom Domain) error {
|
||||
m.mu.Lock()
|
||||
@@ -159,6 +251,9 @@ func (m *Manager) Register(dom Domain) error {
|
||||
if dom.DomainName == "" {
|
||||
return fmt.Errorf("domain is required")
|
||||
}
|
||||
if !isValidDomain(dom.DomainName) {
|
||||
return fmt.Errorf("invalid domain name: %q", dom.DomainName)
|
||||
}
|
||||
|
||||
hasBackend := dom.Backend.Address != ""
|
||||
hasRoutes := len(dom.Routes) > 0
|
||||
@@ -175,14 +270,28 @@ func (m *Manager) Register(dom Domain) error {
|
||||
if r.Backend.Address == "" {
|
||||
return fmt.Errorf("route %d: backend address is required", i)
|
||||
}
|
||||
if !isValidBackendAddress(r.Backend.Address) {
|
||||
return fmt.Errorf("route %d: invalid backend address: %q", i, r.Backend.Address)
|
||||
}
|
||||
if r.Backend.Type == "" {
|
||||
return fmt.Errorf("route %d: backend type is required", i)
|
||||
}
|
||||
if err := validateHeaders(r.Headers); err != nil {
|
||||
return fmt.Errorf("route %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if dom.Backend.Type == "" {
|
||||
return fmt.Errorf("backend type is required")
|
||||
}
|
||||
// dns-only backends may omit port (just an IP for resolution)
|
||||
if dom.Backend.Type == BackendDNSOnly {
|
||||
if net.ParseIP(dom.Backend.Address) == nil && !isValidBackendAddress(dom.Backend.Address) {
|
||||
return fmt.Errorf("invalid backend address: %q", dom.Backend.Address)
|
||||
}
|
||||
} else if !isValidBackendAddress(dom.Backend.Address) {
|
||||
return fmt.Errorf("invalid backend address: %q", dom.Backend.Address)
|
||||
}
|
||||
}
|
||||
|
||||
// Default TLS mode based on backend type
|
||||
@@ -207,7 +316,7 @@ func (m *Manager) Register(dom Domain) error {
|
||||
return fmt.Errorf("marshaling domain: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(m.domainPath(dom.DomainName), data, 0644); err != nil {
|
||||
if err := storage.WriteFileAtomic(m.domainPath(dom.DomainName), data, 0644); err != nil {
|
||||
return fmt.Errorf("writing domain file: %w", err)
|
||||
}
|
||||
|
||||
@@ -354,6 +463,21 @@ func (m *Manager) Update(domain string, updates map[string]any) error {
|
||||
if tls, ok := updates["tls"].(string); ok {
|
||||
dom.TLS = TLSMode(tls)
|
||||
}
|
||||
if auth, ok := updates["auth"].(map[string]any); ok {
|
||||
if dom.Auth == nil {
|
||||
dom.Auth = &AuthConfig{}
|
||||
}
|
||||
if enabled, ok := auth["enabled"].(bool); ok {
|
||||
dom.Auth.Enabled = enabled
|
||||
}
|
||||
if policy, ok := auth["policy"].(string); ok {
|
||||
dom.Auth.Policy = policy
|
||||
}
|
||||
// Clear auth if disabled and no policy
|
||||
if !dom.Auth.Enabled && dom.Auth.Policy == "" {
|
||||
dom.Auth = nil
|
||||
}
|
||||
}
|
||||
|
||||
return m.Register(*dom)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/domains"
|
||||
"github.com/wild-cloud/wild-central/internal/storage"
|
||||
)
|
||||
|
||||
// L4Route represents an L4 TCP passthrough route.
|
||||
@@ -34,11 +35,13 @@ type CustomRoute struct {
|
||||
|
||||
// HTTPRouteBackend maps path prefixes to a specific backend with its own L7 options.
|
||||
type HTTPRouteBackend struct {
|
||||
Paths []string // path prefixes (empty = catch-all)
|
||||
Backend string // host:port
|
||||
HealthPath string // optional health check path
|
||||
Headers *domains.HeaderConfig // per-route headers
|
||||
IPAllow []string // per-route CIDR whitelist
|
||||
Paths []string // path prefixes (empty = catch-all)
|
||||
Backend string // host:port
|
||||
HealthPath string // optional health check path
|
||||
Headers *domains.HeaderConfig // per-route headers
|
||||
IPAllow []string // per-route CIDR whitelist
|
||||
AuthEnabled bool // forward-auth protection via Authelia
|
||||
AuthPolicy string // one_factor, two_factor (for access control)
|
||||
}
|
||||
|
||||
// HTTPRoute represents an L7 HTTP reverse proxy route.
|
||||
@@ -80,14 +83,43 @@ func (m *Manager) GetConfigPath() string {
|
||||
|
||||
// GenerateOpts holds optional parameters for HAProxy config generation.
|
||||
type GenerateOpts struct {
|
||||
HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central)
|
||||
CertsDir string // directory containing per-domain PEM files for L7 TLS
|
||||
HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central)
|
||||
CertsDir string // directory containing per-domain PEM files for L7 TLS
|
||||
AuthEnabled bool // global Authelia forward-auth toggle
|
||||
AuthBackend string // Authelia backend address (e.g. "127.0.0.1:9091")
|
||||
AuthDomain string // Authelia login portal domain (excluded from protection)
|
||||
AuthSessionDomain string // session cookie scope (e.g. "payne.io") — only subdomains are eligible for forward-auth
|
||||
}
|
||||
|
||||
// GenerateWithOpts creates a complete HAProxy configuration with full options.
|
||||
func (m *Manager) GenerateWithOpts(instances []L4Route, custom []CustomRoute, opts GenerateOpts) string {
|
||||
var sb strings.Builder
|
||||
|
||||
writeGlobalDefaults(&sb, opts.AuthEnabled)
|
||||
|
||||
if len(instances) > 0 || len(opts.HTTPRoutes) > 0 {
|
||||
writeHTTPSFrontend(&sb, instances, opts)
|
||||
writeL4Backends(&sb, instances)
|
||||
if len(opts.HTTPRoutes) > 0 {
|
||||
writeL7Stack(&sb, opts)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.AuthEnabled && opts.AuthBackend != "" {
|
||||
sb.WriteString("# Authelia forward-auth backend\n")
|
||||
sb.WriteString("backend be_authelia\n")
|
||||
sb.WriteString(" mode http\n")
|
||||
fmt.Fprintf(&sb, " server s0 %s\n", opts.AuthBackend)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
writeCustomRoutes(&sb, custom)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// writeGlobalDefaults writes the HAProxy global, defaults, stats, and HTTP redirect sections.
|
||||
func writeGlobalDefaults(sb *strings.Builder, authEnabled bool) {
|
||||
sb.WriteString(`# Wild Cloud HAProxy Configuration
|
||||
# Managed by Wild Cloud Central API — do not edit manually
|
||||
|
||||
@@ -97,7 +129,13 @@ global
|
||||
stats timeout 30s
|
||||
maxconn 50000
|
||||
daemon
|
||||
`)
|
||||
if authEnabled {
|
||||
sb.WriteString(" lua-prepend-path /etc/haproxy/lua/?.lua\n")
|
||||
sb.WriteString(" lua-load /etc/haproxy/lua/haproxy-auth-request.lua\n")
|
||||
}
|
||||
|
||||
sb.WriteString(`
|
||||
defaults
|
||||
log global
|
||||
mode tcp
|
||||
@@ -122,231 +160,278 @@ frontend http_in
|
||||
redirect scheme https code 301
|
||||
|
||||
`)
|
||||
}
|
||||
|
||||
hasL4Routes := len(instances) > 0 || len(opts.HTTPRoutes) > 0
|
||||
// writeHTTPSFrontend writes the SNI-based HTTPS frontend with ACL routing.
|
||||
// ACL ordering is critical:
|
||||
// 1. L7 HTTP exact matches → L7 termination
|
||||
// 2. L4 exact matches → instance backends
|
||||
// 3. L4 wildcard matches → instance backends
|
||||
// 4. Default → L7 termination (if any HTTP routes exist)
|
||||
func writeHTTPSFrontend(sb *strings.Builder, instances []L4Route, opts GenerateOpts) {
|
||||
sb.WriteString("# HTTPS: SNI-based L4 routing (TLS passes through to k8s traefik)\n")
|
||||
sb.WriteString("frontend https_in\n")
|
||||
sb.WriteString(" bind *:443\n")
|
||||
sb.WriteString(" mode tcp\n")
|
||||
sb.WriteString(" tcp-request inspect-delay 5s\n")
|
||||
sb.WriteString(" tcp-request content accept if { req_ssl_hello_type 1 }\n")
|
||||
sb.WriteString("\n")
|
||||
|
||||
if hasL4Routes {
|
||||
sb.WriteString("# HTTPS: SNI-based L4 routing (TLS passes through to k8s traefik)\n")
|
||||
sb.WriteString("frontend https_in\n")
|
||||
sb.WriteString(" bind *:443\n")
|
||||
// 1. L7 HTTP services — route to L7 TLS termination frontend
|
||||
if len(opts.HTTPRoutes) > 0 {
|
||||
sb.WriteString(" # L7 HTTP services (→ TLS termination)\n")
|
||||
for _, route := range opts.HTTPRoutes {
|
||||
if route.Subdomains {
|
||||
continue
|
||||
}
|
||||
aclName := "is_l7_" + sanitizeName(route.Name)
|
||||
fmt.Fprintf(sb, " # service: %s\n", route.Domain)
|
||||
fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain)
|
||||
fmt.Fprintf(sb, " use_backend be_l7_termination if %s\n", aclName)
|
||||
}
|
||||
for _, route := range opts.HTTPRoutes {
|
||||
if !route.Subdomains {
|
||||
continue
|
||||
}
|
||||
aclName := "is_l7_" + sanitizeName(route.Name)
|
||||
fmt.Fprintf(sb, " # service: %s\n", route.Domain)
|
||||
fmt.Fprintf(sb, " acl %s req_ssl_sni -m end .%s\n", aclName, route.Domain)
|
||||
fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain)
|
||||
fmt.Fprintf(sb, " use_backend be_l7_termination if %s\n", aclName)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// 2. L4 exact matches
|
||||
hasExact := false
|
||||
for _, inst := range instances {
|
||||
if inst.Subdomains {
|
||||
continue
|
||||
}
|
||||
hasExact = true
|
||||
aclName := "is_" + sanitizeName(inst.Name)
|
||||
fmt.Fprintf(sb, " # service: %s\n", inst.Domain)
|
||||
fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain)
|
||||
fmt.Fprintf(sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
|
||||
}
|
||||
if hasExact {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// 3. L4 wildcard matches
|
||||
sb.WriteString(" # L4 instance routes (wildcard subdomain matching)\n")
|
||||
for _, inst := range instances {
|
||||
if !inst.Subdomains {
|
||||
continue
|
||||
}
|
||||
aclName := "is_" + sanitizeName(inst.Name)
|
||||
fmt.Fprintf(sb, " # service: %s\n", inst.Domain)
|
||||
fmt.Fprintf(sb, " acl %s req_ssl_sni -m end .%s\n", aclName, inst.Domain)
|
||||
fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain)
|
||||
fmt.Fprintf(sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// 4. Default fallback
|
||||
if len(opts.HTTPRoutes) > 0 {
|
||||
sb.WriteString(" default_backend be_l7_termination\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// writeL4Backends writes L4 TCP passthrough backend definitions.
|
||||
func writeL4Backends(sb *strings.Builder, instances []L4Route) {
|
||||
for _, inst := range instances {
|
||||
fmt.Fprintf(sb, "# service: %s\n", inst.Domain)
|
||||
fmt.Fprintf(sb, "backend be_https_%s\n", sanitizeName(inst.Name))
|
||||
sb.WriteString(" mode tcp\n")
|
||||
sb.WriteString(" tcp-request inspect-delay 5s\n")
|
||||
sb.WriteString(" tcp-request content accept if { req_ssl_hello_type 1 }\n")
|
||||
sb.WriteString(" option tcp-check\n")
|
||||
fmt.Fprintf(sb, " server s0 %s:443 check\n", inst.BackendIP)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// ACL ordering is critical. Process in this order:
|
||||
// 1. L7 HTTP exact matches (central, wild-cloud app) → L7 termination
|
||||
// 2. L4 exact matches (payne.io, civilsociety.dev) → instance backends
|
||||
// 3. L4 wildcard matches (*.cloud.payne.io) → instance backends
|
||||
// 4. Default → L7 termination (if any HTTP routes exist)
|
||||
// writeL7Stack writes the L7 TLS termination backend, frontend, host ACLs,
|
||||
// per-route rules (IP whitelisting, headers, auth), routing, and backends.
|
||||
func writeL7Stack(sb *strings.Builder, opts GenerateOpts) {
|
||||
certPath := opts.CertsDir
|
||||
if certPath == "" {
|
||||
certPath = "/etc/haproxy/certs/"
|
||||
}
|
||||
|
||||
// 1. L7 HTTP services — route to L7 TLS termination frontend
|
||||
if len(opts.HTTPRoutes) > 0 {
|
||||
sb.WriteString(" # L7 HTTP services (→ TLS termination)\n")
|
||||
// Exact matches first
|
||||
for _, route := range opts.HTTPRoutes {
|
||||
if route.Subdomains {
|
||||
continue // handled below
|
||||
}
|
||||
aclName := "is_l7_" + sanitizeName(route.Name)
|
||||
fmt.Fprintf(&sb, " # service: %s\n", route.Domain)
|
||||
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain)
|
||||
fmt.Fprintf(&sb, " use_backend be_l7_termination if %s\n", aclName)
|
||||
}
|
||||
// Wildcard matches after exact
|
||||
for _, route := range opts.HTTPRoutes {
|
||||
if !route.Subdomains {
|
||||
continue
|
||||
}
|
||||
aclName := "is_l7_" + sanitizeName(route.Name)
|
||||
fmt.Fprintf(&sb, " # service: %s\n", route.Domain)
|
||||
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m end .%s\n", aclName, route.Domain)
|
||||
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain)
|
||||
fmt.Fprintf(&sb, " use_backend be_l7_termination if %s\n", aclName)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
// TLS termination backend + frontend binding
|
||||
sb.WriteString("backend be_l7_termination\n")
|
||||
sb.WriteString(" mode tcp\n")
|
||||
sb.WriteString(" server s0 127.0.0.1:8443\n")
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("# L7 HTTP frontend: TLS termination + Host-based routing\n")
|
||||
sb.WriteString("frontend l7_https\n")
|
||||
fmt.Fprintf(sb, " bind 127.0.0.1:8443 ssl crt %s\n", certPath)
|
||||
sb.WriteString(" mode http\n")
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Host ACLs
|
||||
for _, httpRoute := range opts.HTTPRoutes {
|
||||
hostACL := "host_" + sanitizeName(httpRoute.Name)
|
||||
fmt.Fprintf(sb, " # service: %s\n", httpRoute.Domain)
|
||||
if httpRoute.Subdomains {
|
||||
fmt.Fprintf(sb, " acl %s hdr(host) -i -m end .%s\n", hostACL, httpRoute.Domain)
|
||||
fmt.Fprintf(sb, " acl %s hdr(host) -i -m str %s\n", hostACL, httpRoute.Domain)
|
||||
} else {
|
||||
fmt.Fprintf(sb, " acl %s hdr(host) -i %s\n", hostACL, httpRoute.Domain)
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// 2. L4 exact matches — instances with subdomains:false
|
||||
hasExact := false
|
||||
for _, inst := range instances {
|
||||
if inst.Subdomains {
|
||||
continue // handled in step 3
|
||||
}
|
||||
hasExact = true
|
||||
aclName := "is_" + sanitizeName(inst.Name)
|
||||
fmt.Fprintf(&sb, " # service: %s\n", inst.Domain)
|
||||
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain)
|
||||
fmt.Fprintf(&sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
|
||||
}
|
||||
if hasExact {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
// Per-route ACLs: IP whitelisting and headers
|
||||
writeL7RouteACLs(sb, opts.HTTPRoutes)
|
||||
|
||||
// 3. L4 wildcard matches — instances with subdomains:true
|
||||
sb.WriteString(" # L4 instance routes (wildcard subdomain matching)\n")
|
||||
for _, inst := range instances {
|
||||
if !inst.Subdomains {
|
||||
continue // already handled in step 2
|
||||
}
|
||||
aclName := "is_" + sanitizeName(inst.Name)
|
||||
fmt.Fprintf(&sb, " # service: %s\n", inst.Domain)
|
||||
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m end .%s\n", aclName, inst.Domain)
|
||||
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain)
|
||||
fmt.Fprintf(&sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
// Authelia forward-auth directives
|
||||
if opts.AuthEnabled && opts.AuthBackend != "" {
|
||||
writeL7AuthDirectives(sb, opts)
|
||||
}
|
||||
|
||||
// 4. Default → L7 termination fallback
|
||||
if len(opts.HTTPRoutes) > 0 {
|
||||
sb.WriteString(" default_backend be_l7_termination\n")
|
||||
}
|
||||
// Routing rules: path-specific first, then catch-all
|
||||
writeL7RoutingRules(sb, opts.HTTPRoutes)
|
||||
|
||||
sb.WriteString("\n")
|
||||
// L7 backends — one per route
|
||||
writeL7Backends(sb, opts.HTTPRoutes)
|
||||
}
|
||||
|
||||
// Instance L4 backends
|
||||
for _, inst := range instances {
|
||||
fmt.Fprintf(&sb, "# service: %s\n", inst.Domain)
|
||||
fmt.Fprintf(&sb, "backend be_https_%s\n", sanitizeName(inst.Name))
|
||||
sb.WriteString(" mode tcp\n")
|
||||
sb.WriteString(" option tcp-check\n")
|
||||
fmt.Fprintf(&sb, " server s0 %s:443 check\n", inst.BackendIP)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
// writeL7RouteACLs writes per-route IP whitelisting and header directives.
|
||||
func writeL7RouteACLs(sb *strings.Builder, httpRoutes []HTTPRoute) {
|
||||
for _, httpRoute := range httpRoutes {
|
||||
hostACL := "host_" + sanitizeName(httpRoute.Name)
|
||||
routeName := sanitizeName(httpRoute.Name)
|
||||
|
||||
// L7 TLS termination backend + frontend (for HTTP routes)
|
||||
if len(opts.HTTPRoutes) > 0 {
|
||||
// Load all PEM files from the certs directory — HAProxy serves
|
||||
// the right cert per-SNI. Each service gets its own <domain>.pem.
|
||||
certPath := opts.CertsDir
|
||||
if certPath == "" {
|
||||
certPath = "/etc/haproxy/certs/"
|
||||
}
|
||||
for i, rb := range httpRoute.Routes {
|
||||
aclSuffix := fmt.Sprintf("%s_%d", routeName, i)
|
||||
|
||||
sb.WriteString("backend be_l7_termination\n")
|
||||
sb.WriteString(" mode tcp\n")
|
||||
sb.WriteString(" server s0 127.0.0.1:8443\n")
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("# L7 HTTP frontend: TLS termination + Host-based routing\n")
|
||||
sb.WriteString("frontend l7_https\n")
|
||||
fmt.Fprintf(&sb, " bind 127.0.0.1:8443 ssl crt %s\n", certPath)
|
||||
sb.WriteString(" mode http\n")
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Host ACLs for all services
|
||||
for _, httpRoute := range opts.HTTPRoutes {
|
||||
hostACL := "host_" + sanitizeName(httpRoute.Name)
|
||||
fmt.Fprintf(&sb, " # service: %s\n", httpRoute.Domain)
|
||||
if httpRoute.Subdomains {
|
||||
fmt.Fprintf(&sb, " acl %s hdr(host) -i -m end .%s\n", hostACL, httpRoute.Domain)
|
||||
fmt.Fprintf(&sb, " acl %s hdr(host) -i -m str %s\n", hostACL, httpRoute.Domain)
|
||||
if len(rb.IPAllow) > 0 {
|
||||
ipACL := "ip_" + aclSuffix
|
||||
fmt.Fprintf(sb, " acl %s src %s\n", ipACL, strings.Join(rb.IPAllow, " "))
|
||||
if len(rb.Paths) > 0 {
|
||||
pathACL := "path_" + aclSuffix
|
||||
fmt.Fprintf(sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " "))
|
||||
fmt.Fprintf(sb, " http-request deny if %s %s !%s\n", hostACL, pathACL, ipACL)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, " acl %s hdr(host) -i %s\n", hostACL, httpRoute.Domain)
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Per-route ACLs (IP whitelisting, headers) and routing rules
|
||||
for _, httpRoute := range opts.HTTPRoutes {
|
||||
hostACL := "host_" + sanitizeName(httpRoute.Name)
|
||||
routeName := sanitizeName(httpRoute.Name)
|
||||
|
||||
for i, rb := range httpRoute.Routes {
|
||||
aclSuffix := fmt.Sprintf("%s_%d", routeName, i)
|
||||
|
||||
// IP whitelisting for this route
|
||||
if len(rb.IPAllow) > 0 {
|
||||
ipACL := "ip_" + aclSuffix
|
||||
fmt.Fprintf(&sb, " acl %s src %s\n", ipACL, strings.Join(rb.IPAllow, " "))
|
||||
if len(rb.Paths) > 0 {
|
||||
pathACL := "path_" + aclSuffix
|
||||
fmt.Fprintf(&sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " "))
|
||||
fmt.Fprintf(&sb, " http-request deny if %s %s !%s\n", hostACL, pathACL, ipACL)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, " http-request deny if %s !%s\n", hostACL, ipACL)
|
||||
}
|
||||
}
|
||||
|
||||
// Request headers
|
||||
if rb.Headers != nil {
|
||||
for _, k := range sortedKeys(rb.Headers.Request) {
|
||||
fmt.Fprintf(&sb, " http-request set-header %s %q if %s\n", k, rb.Headers.Request[k], hostACL)
|
||||
}
|
||||
}
|
||||
|
||||
// Response headers
|
||||
if rb.Headers != nil {
|
||||
for _, k := range sortedKeys(rb.Headers.Response) {
|
||||
fmt.Fprintf(&sb, " http-response set-header %s %q if %s\n", k, rb.Headers.Response[k], hostACL)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(sb, " http-request deny if %s !%s\n", hostACL, ipACL)
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Routing rules: path-specific first, then catch-all
|
||||
for _, httpRoute := range opts.HTTPRoutes {
|
||||
hostACL := "host_" + sanitizeName(httpRoute.Name)
|
||||
routeName := sanitizeName(httpRoute.Name)
|
||||
|
||||
// Path-specific routes first (most specific wins)
|
||||
for i, rb := range httpRoute.Routes {
|
||||
if len(rb.Paths) == 0 {
|
||||
continue
|
||||
}
|
||||
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
|
||||
pathACL := fmt.Sprintf("path_%s_%d", routeName, i)
|
||||
fmt.Fprintf(&sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " "))
|
||||
fmt.Fprintf(&sb, " use_backend %s if %s %s\n", backendName, hostACL, pathACL)
|
||||
if rb.Headers != nil {
|
||||
for _, k := range sortedKeys(rb.Headers.Request) {
|
||||
fmt.Fprintf(sb, " http-request set-header %s %q if %s\n", k, rb.Headers.Request[k], hostACL)
|
||||
}
|
||||
|
||||
// Catch-all route last
|
||||
for i, rb := range httpRoute.Routes {
|
||||
if len(rb.Paths) > 0 {
|
||||
continue
|
||||
}
|
||||
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
|
||||
fmt.Fprintf(&sb, " use_backend %s if %s\n", backendName, hostACL)
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// L7 backends — one per route
|
||||
for _, httpRoute := range opts.HTTPRoutes {
|
||||
routeName := sanitizeName(httpRoute.Name)
|
||||
for i, rb := range httpRoute.Routes {
|
||||
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
|
||||
fmt.Fprintf(&sb, "# service: %s\n", httpRoute.Domain)
|
||||
fmt.Fprintf(&sb, "backend %s\n", backendName)
|
||||
sb.WriteString(" mode http\n")
|
||||
if rb.HealthPath != "" {
|
||||
fmt.Fprintf(&sb, " option httpchk GET %s\n", rb.HealthPath)
|
||||
fmt.Fprintf(&sb, " server s0 %s check\n", rb.Backend)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, " server s0 %s\n", rb.Backend)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
for _, k := range sortedKeys(rb.Headers.Response) {
|
||||
fmt.Fprintf(sb, " http-response set-header %s %q if %s\n", k, rb.Headers.Response[k], hostACL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, route := range custom {
|
||||
fmt.Fprintf(&sb, "# Custom route: %s\n", route.Name)
|
||||
fmt.Fprintf(&sb, "frontend fe_%s\n", sanitizeName(route.Name))
|
||||
fmt.Fprintf(&sb, " bind *:%d\n", route.Port)
|
||||
sb.WriteString(" mode tcp\n")
|
||||
fmt.Fprintf(&sb, " default_backend be_%s\n", sanitizeName(route.Name))
|
||||
sb.WriteString("\n")
|
||||
fmt.Fprintf(&sb, "backend be_%s\n", sanitizeName(route.Name))
|
||||
sb.WriteString(" mode tcp\n")
|
||||
fmt.Fprintf(&sb, " server s0 %s\n", route.Backend)
|
||||
sb.WriteString("\n")
|
||||
// writeL7AuthDirectives writes Authelia forward-auth directives for eligible routes.
|
||||
func writeL7AuthDirectives(sb *strings.Builder, opts GenerateOpts) {
|
||||
authDomainACL := ""
|
||||
if opts.AuthDomain != "" {
|
||||
authDomainACL = "host_" + sanitizeName(opts.AuthDomain)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
for _, httpRoute := range opts.HTTPRoutes {
|
||||
hostACL := "host_" + sanitizeName(httpRoute.Name)
|
||||
|
||||
// Skip auth portal domain (would cause infinite redirect)
|
||||
if hostACL == authDomainACL {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip domains outside the auth session scope
|
||||
if opts.AuthSessionDomain != "" &&
|
||||
httpRoute.Domain != opts.AuthSessionDomain &&
|
||||
!strings.HasSuffix(httpRoute.Domain, "."+opts.AuthSessionDomain) {
|
||||
continue
|
||||
}
|
||||
|
||||
hasAuth := false
|
||||
for _, rb := range httpRoute.Routes {
|
||||
if rb.AuthEnabled {
|
||||
hasAuth = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAuth {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(sb, " # auth: %s\n", httpRoute.Domain)
|
||||
fmt.Fprintf(sb, " http-request lua.auth-request be_authelia /api/authz/forward-auth if %s\n", hostACL)
|
||||
fmt.Fprintf(sb, " http-request redirect location https://%s/?rd=https://%%[hdr(host)]%%[path] if %s !{ var(txn.auth_response_successful) -m bool }\n", opts.AuthDomain, hostACL)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// writeL7RoutingRules writes use_backend rules: path-specific first, then catch-all.
|
||||
func writeL7RoutingRules(sb *strings.Builder, httpRoutes []HTTPRoute) {
|
||||
for _, httpRoute := range httpRoutes {
|
||||
hostACL := "host_" + sanitizeName(httpRoute.Name)
|
||||
routeName := sanitizeName(httpRoute.Name)
|
||||
|
||||
for i, rb := range httpRoute.Routes {
|
||||
if len(rb.Paths) == 0 {
|
||||
continue
|
||||
}
|
||||
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
|
||||
pathACL := fmt.Sprintf("path_%s_%d", routeName, i)
|
||||
fmt.Fprintf(sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " "))
|
||||
fmt.Fprintf(sb, " use_backend %s if %s %s\n", backendName, hostACL, pathACL)
|
||||
}
|
||||
|
||||
for i, rb := range httpRoute.Routes {
|
||||
if len(rb.Paths) > 0 {
|
||||
continue
|
||||
}
|
||||
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
|
||||
fmt.Fprintf(sb, " use_backend %s if %s\n", backendName, hostACL)
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// writeL7Backends writes L7 HTTP backend definitions — one per route.
|
||||
func writeL7Backends(sb *strings.Builder, httpRoutes []HTTPRoute) {
|
||||
for _, httpRoute := range httpRoutes {
|
||||
routeName := sanitizeName(httpRoute.Name)
|
||||
for i, rb := range httpRoute.Routes {
|
||||
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
|
||||
fmt.Fprintf(sb, "# service: %s\n", httpRoute.Domain)
|
||||
fmt.Fprintf(sb, "backend %s\n", backendName)
|
||||
sb.WriteString(" mode http\n")
|
||||
if rb.HealthPath != "" {
|
||||
fmt.Fprintf(sb, " option httpchk GET %s\n", rb.HealthPath)
|
||||
fmt.Fprintf(sb, " server s0 %s check\n", rb.Backend)
|
||||
} else {
|
||||
fmt.Fprintf(sb, " server s0 %s\n", rb.Backend)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeCustomRoutes writes custom TCP proxy frontend/backend pairs.
|
||||
func writeCustomRoutes(sb *strings.Builder, custom []CustomRoute) {
|
||||
for _, route := range custom {
|
||||
fmt.Fprintf(sb, "# Custom route: %s\n", route.Name)
|
||||
fmt.Fprintf(sb, "frontend fe_%s\n", sanitizeName(route.Name))
|
||||
fmt.Fprintf(sb, " bind *:%d\n", route.Port)
|
||||
sb.WriteString(" mode tcp\n")
|
||||
fmt.Fprintf(sb, " default_backend be_%s\n", sanitizeName(route.Name))
|
||||
sb.WriteString("\n")
|
||||
fmt.Fprintf(sb, "backend be_%s\n", sanitizeName(route.Name))
|
||||
sb.WriteString(" mode tcp\n")
|
||||
fmt.Fprintf(sb, " server s0 %s\n", route.Backend)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// sortedKeys returns the keys of a map in sorted order for deterministic output.
|
||||
@@ -466,6 +551,51 @@ func (m *Manager) WriteConfig(content string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SafeApply validates, backs up, writes, reloads, and verifies the HAProxy config.
|
||||
// On reload or verification failure, rolls back to the previous config.
|
||||
func (m *Manager) SafeApply(content string) error {
|
||||
// Backup current config
|
||||
if storage.FileExists(m.configPath) {
|
||||
_ = storage.CopyFile(m.configPath, m.configPath+".bak")
|
||||
}
|
||||
|
||||
// Validate + atomic write
|
||||
if err := m.WriteConfig(content); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Reload daemon
|
||||
if err := m.ReloadService(); err != nil {
|
||||
m.rollback()
|
||||
return fmt.Errorf("reload failed (rolled back): %w", err)
|
||||
}
|
||||
|
||||
// Verify daemon is running
|
||||
if err := m.verify(); err != nil {
|
||||
m.rollback()
|
||||
return fmt.Errorf("health check failed (rolled back): %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) rollback() {
|
||||
bakPath := m.configPath + ".bak"
|
||||
if storage.FileExists(bakPath) {
|
||||
_ = os.Rename(bakPath, m.configPath)
|
||||
_ = m.ReloadService()
|
||||
slog.Warn("rolled back to previous config", "component", "haproxy", "path", m.configPath)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) verify() error {
|
||||
cmd := exec.Command("systemctl", "is-active", "--quiet", "haproxy.service")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("haproxy not active after reload")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadService gracefully reloads HAProxy (starts it if not running)
|
||||
func (m *Manager) ReloadService() error {
|
||||
cmd := exec.Command("systemctl", "reload-or-restart", "haproxy.service")
|
||||
@@ -488,8 +618,8 @@ func (m *Manager) RestartService() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServiceStatus represents the status of the HAProxy service
|
||||
type ServiceStatus struct {
|
||||
// Status represents the status of the HAProxy service
|
||||
type Status struct {
|
||||
Status string `json:"status"`
|
||||
PID int `json:"pid"`
|
||||
ConfigFile string `json:"configFile"`
|
||||
@@ -497,8 +627,8 @@ type ServiceStatus struct {
|
||||
}
|
||||
|
||||
// GetStatus returns the current HAProxy service status
|
||||
func (m *Manager) GetStatus() (*ServiceStatus, error) {
|
||||
status := &ServiceStatus{
|
||||
func (m *Manager) GetStatus() (*Status, error) {
|
||||
status := &Status{
|
||||
ConfigFile: m.configPath,
|
||||
}
|
||||
|
||||
|
||||
@@ -833,3 +833,213 @@ line3
|
||||
t.Errorf("expected 0 broken services when no markers, got %d", len(broken))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Authelia forward-auth tests ---
|
||||
|
||||
func TestGenerateWithOpts_AuthLuaLoad(t *testing.T) {
|
||||
m := NewManager("")
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
AuthEnabled: true,
|
||||
AuthBackend: "127.0.0.1:9091",
|
||||
AuthDomain: "auth.payne.io",
|
||||
})
|
||||
|
||||
if !strings.Contains(cfg, "lua-load /etc/haproxy/lua/haproxy-auth-request.lua") {
|
||||
t.Error("config should contain lua-load directive when auth is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithOpts_NoAuthLuaLoadWhenDisabled(t *testing.T) {
|
||||
m := NewManager("")
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
AuthEnabled: false,
|
||||
})
|
||||
|
||||
if strings.Contains(cfg, "lua-load") {
|
||||
t.Error("config should NOT contain lua-load when auth is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithOpts_AuthBackend(t *testing.T) {
|
||||
m := NewManager("")
|
||||
httpRoutes := []HTTPRoute{
|
||||
{
|
||||
Name: "app.payne.io",
|
||||
Domain: "app.payne.io",
|
||||
Routes: []HTTPRouteBackend{
|
||||
{Backend: "127.0.0.1:8080", AuthEnabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
CertsDir: "/tmp/certs/",
|
||||
AuthEnabled: true,
|
||||
AuthBackend: "127.0.0.1:9091",
|
||||
AuthDomain: "auth.payne.io",
|
||||
})
|
||||
|
||||
if !strings.Contains(cfg, "backend be_authelia") {
|
||||
t.Error("config should contain Authelia backend")
|
||||
}
|
||||
if !strings.Contains(cfg, "server s0 127.0.0.1:9091") {
|
||||
t.Error("config should contain Authelia server address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithOpts_AuthInterceptDirective(t *testing.T) {
|
||||
m := NewManager("")
|
||||
httpRoutes := []HTTPRoute{
|
||||
{
|
||||
Name: "app.payne.io",
|
||||
Domain: "app.payne.io",
|
||||
Routes: []HTTPRouteBackend{
|
||||
{Backend: "127.0.0.1:8080", AuthEnabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
CertsDir: "/tmp/certs/",
|
||||
AuthEnabled: true,
|
||||
AuthBackend: "127.0.0.1:9091",
|
||||
AuthDomain: "auth.payne.io",
|
||||
})
|
||||
|
||||
if !strings.Contains(cfg, "lua.auth-request be_authelia") {
|
||||
t.Error("config should contain auth-intercept directive for protected route")
|
||||
}
|
||||
if !strings.Contains(cfg, "auth.payne.io/?rd=") {
|
||||
t.Error("config should contain redirect to auth portal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithOpts_AuthDomainExempt(t *testing.T) {
|
||||
m := NewManager("")
|
||||
httpRoutes := []HTTPRoute{
|
||||
{
|
||||
Name: "auth.payne.io",
|
||||
Domain: "auth.payne.io",
|
||||
Routes: []HTTPRouteBackend{
|
||||
{Backend: "127.0.0.1:9091", AuthEnabled: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "app.payne.io",
|
||||
Domain: "app.payne.io",
|
||||
Routes: []HTTPRouteBackend{
|
||||
{Backend: "127.0.0.1:8080", AuthEnabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
CertsDir: "/tmp/certs/",
|
||||
AuthEnabled: true,
|
||||
AuthBackend: "127.0.0.1:9091",
|
||||
AuthDomain: "auth.payne.io",
|
||||
})
|
||||
|
||||
// Count auth-intercept directives — should be 1 (for app), not 2 (auth domain exempt)
|
||||
count := strings.Count(cfg, "lua.auth-request")
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 auth-intercept directive (auth domain exempt), got %d", count)
|
||||
}
|
||||
|
||||
// The auth comment should only appear for app.payne.io
|
||||
if !strings.Contains(cfg, "# auth: app.payne.io") {
|
||||
t.Error("should have auth comment for app.payne.io")
|
||||
}
|
||||
if strings.Contains(cfg, "# auth: auth.payne.io") {
|
||||
t.Error("should NOT have auth comment for auth.payne.io (exempt)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithOpts_NoAuthForUnprotectedRoutes(t *testing.T) {
|
||||
m := NewManager("")
|
||||
httpRoutes := []HTTPRoute{
|
||||
{
|
||||
Name: "public.payne.io",
|
||||
Domain: "public.payne.io",
|
||||
Routes: []HTTPRouteBackend{
|
||||
{Backend: "127.0.0.1:8080", AuthEnabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
CertsDir: "/tmp/certs/",
|
||||
AuthEnabled: true,
|
||||
AuthBackend: "127.0.0.1:9091",
|
||||
AuthDomain: "auth.payne.io",
|
||||
})
|
||||
|
||||
if strings.Contains(cfg, "lua.auth-request") {
|
||||
t.Error("should NOT contain auth-intercept for unprotected routes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithOpts_AuthDisabledNoDirectives(t *testing.T) {
|
||||
m := NewManager("")
|
||||
httpRoutes := []HTTPRoute{
|
||||
{
|
||||
Name: "app.payne.io",
|
||||
Domain: "app.payne.io",
|
||||
Routes: []HTTPRouteBackend{
|
||||
{Backend: "127.0.0.1:8080", AuthEnabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
CertsDir: "/tmp/certs/",
|
||||
AuthEnabled: false,
|
||||
})
|
||||
|
||||
if strings.Contains(cfg, "lua.auth-request") {
|
||||
t.Error("should NOT contain auth directives when auth is globally disabled")
|
||||
}
|
||||
if strings.Contains(cfg, "be_authelia") {
|
||||
t.Error("should NOT contain authelia backend when auth is globally disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithOpts_AuthWithHeaders(t *testing.T) {
|
||||
m := NewManager("")
|
||||
httpRoutes := []HTTPRoute{
|
||||
{
|
||||
Name: "app.payne.io",
|
||||
Domain: "app.payne.io",
|
||||
Routes: []HTTPRouteBackend{
|
||||
{
|
||||
Backend: "127.0.0.1:8080",
|
||||
AuthEnabled: true,
|
||||
Headers: &domains.HeaderConfig{
|
||||
Request: map[string]string{"X-Custom": "value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
CertsDir: "/tmp/certs/",
|
||||
AuthEnabled: true,
|
||||
AuthBackend: "127.0.0.1:9091",
|
||||
AuthDomain: "auth.payne.io",
|
||||
})
|
||||
|
||||
// Should have both auth and headers
|
||||
if !strings.Contains(cfg, "lua.auth-request") {
|
||||
t.Error("should contain auth-intercept")
|
||||
}
|
||||
if !strings.Contains(cfg, "X-Custom") {
|
||||
t.Error("should still contain custom headers alongside auth")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
36
main.go
36
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)
|
||||
@@ -123,9 +147,10 @@ func main() {
|
||||
slog.Info("central status broadcaster started")
|
||||
|
||||
api.StartDDNS(ctx)
|
||||
api.StartDNSFilter(ctx)
|
||||
|
||||
router := mux.NewRouter()
|
||||
api.RegisterRoutes(router)
|
||||
api.RegisterRoutes(router, apiToken, isDev)
|
||||
|
||||
router.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -159,9 +184,14 @@ func main() {
|
||||
// Tell the API what port it's running on, register Central as a domain
|
||||
// (if a domain is configured), and reconcile all networking.
|
||||
api.SetPort(port)
|
||||
api.CheckPrerequisites()
|
||||
api.EnsureCentralDomain()
|
||||
api.Reconcile()
|
||||
|
||||
// Start periodic convergence loop — continuously drives system toward
|
||||
// desired state, recovering from daemon crashes and config drift.
|
||||
api.StartConvergenceLoop(ctx, 5*time.Minute)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
slog.Info("wild-central started", "addr", addr, "version", Version)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Building Wild App
|
||||
# Building Wild Central Web App
|
||||
|
||||
This document describes the architecture and tooling used to build the Wild App, the web-based interface for managing Wild Cloud instances, hosted on Wild Central.
|
||||
This document describes the architecture and tooling used to build the Wild Central web app, the browser-based interface for managing Wild Central and its connected services.
|
||||
|
||||
## Principles
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Wild Cloud Web App
|
||||
# Wild Central Web App
|
||||
|
||||
The Wild Cloud Web App is a web-based interface for managing Wild Cloud instances. It allows users to view and control their Wild Cloud environments, including deploying applications, monitoring resources, and configuring settings.
|
||||
The Wild Central Web App is a web-based interface for managing Wild Central and its connected services. It allows users to configure networking (DNS, gateway, VPN, firewall, TLS), manage Wild Cloud instances, deploy applications, and monitor resources.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting the web app, ensure the Wild Central API is running:
|
||||
|
||||
```bash
|
||||
cd ../api
|
||||
cd ..
|
||||
make dev
|
||||
```
|
||||
|
||||
@@ -57,11 +57,9 @@ pnpm run check # Run lint, type-check, and tests
|
||||
|
||||
### `VITE_API_BASE_URL`
|
||||
|
||||
The base URL of the Wild Central API server.
|
||||
The base URL of the Wild Central API.
|
||||
|
||||
- **Default:** `http://localhost:5055`
|
||||
- **Example:** `http://192.168.1.100:5055`
|
||||
- **Usage:** Set in `.env` file (see `.env.example` for template)
|
||||
|
||||
This variable is used by the API client to connect to the Wild Central API. If not set, it defaults to `http://localhost:5055`.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0ea5e9" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Wild Central — Network management for your LAN"
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#0ea5e9"/>
|
||||
<g transform="translate(4, 4)" stroke="white" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 16.326A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 .5 8.973"/>
|
||||
<path d="M13 11l-3 5h4l-3 5"/>
|
||||
</g>
|
||||
</svg>
|
||||
<rect width="32" height="32" rx="7" fill="#2563eb"/>
|
||||
<!-- Central node -->
|
||||
<circle cx="16" cy="16" r="4" fill="white"/>
|
||||
<!-- Radiating connections -->
|
||||
<line x1="16" y1="6" x2="16" y2="11" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="16" y1="21" x2="16" y2="26" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="6" y1="16" x2="11" y2="16" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="21" y1="16" x2="26" y2="16" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="8.9" y1="8.9" x2="12.5" y2="12.5" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="19.5" y1="19.5" x2="23.1" y2="23.1" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="23.1" y1="8.9" x2="19.5" y2="12.5" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="12.5" y1="19.5" x2="8.9" y2="23.1" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- Outer ring nodes -->
|
||||
<circle cx="16" cy="5.5" r="1.5" fill="white" opacity="0.7"/>
|
||||
<circle cx="16" cy="26.5" r="1.5" fill="white" opacity="0.7"/>
|
||||
<circle cx="5.5" cy="16" r="1.5" fill="white" opacity="0.7"/>
|
||||
<circle cx="26.5" cy="16" r="1.5" fill="white" opacity="0.7"/>
|
||||
<circle cx="8.6" cy="8.6" r="1.5" fill="white" opacity="0.5"/>
|
||||
<circle cx="23.4" cy="23.4" r="1.5" fill="white" opacity="0.5"/>
|
||||
<circle cx="23.4" cy="8.6" r="1.5" fill="white" opacity="0.5"/>
|
||||
<circle cx="8.6" cy="23.4" r="1.5" fill="white" opacity="0.5"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 361 B After Width: | Height: | Size: 1.5 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"short_name": "Wild Cloud",
|
||||
"name": "Wild Cloud Central",
|
||||
"short_name": "Wild Central",
|
||||
"name": "Wild Central",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.svg",
|
||||
@@ -10,6 +10,6 @@
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#0ea5e9",
|
||||
"theme_color": "#2563eb",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NavLink } from 'react-router';
|
||||
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, CloudLightning, Network } from 'lucide-react';
|
||||
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, ShieldCheck, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -17,10 +19,37 @@ import {
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useCentralStatus } from '../hooks/useCentralStatus';
|
||||
|
||||
function WildCentralLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className={className} fill="none">
|
||||
<circle cx="12" cy="12" r="3" fill="currentColor"/>
|
||||
<line x1="12" y1="4" x2="12" y2="8.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<line x1="12" y1="15.5" x2="12" y2="20" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<line x1="4" y1="12" x2="8.5" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<line x1="15.5" y1="12" x2="20" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<line x1="6.3" y1="6.3" x2="9.4" y2="9.4" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<line x1="14.6" y1="14.6" x2="17.7" y2="17.7" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<line x1="17.7" y1="6.3" x2="14.6" y2="9.4" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<line x1="9.4" y1="14.6" x2="6.3" y2="17.7" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<circle cx="12" cy="3.5" r="1.2" fill="currentColor" opacity="0.6"/>
|
||||
<circle cx="12" cy="20.5" r="1.2" fill="currentColor" opacity="0.6"/>
|
||||
<circle cx="3.5" cy="12" r="1.2" fill="currentColor" opacity="0.6"/>
|
||||
<circle cx="20.5" cy="12" r="1.2" fill="currentColor" opacity="0.6"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ADVANCED_OPEN_KEY = 'wild-central:advanced-open';
|
||||
|
||||
export function AppSidebar() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { state } = useSidebar();
|
||||
const { data: centralStatus } = useCentralStatus();
|
||||
const [advancedOpen, setAdvancedOpen] = useState(() => localStorage.getItem(ADVANCED_OPEN_KEY) === 'true');
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(ADVANCED_OPEN_KEY, String(advancedOpen));
|
||||
}, [advancedOpen]);
|
||||
|
||||
const cycleTheme = () => {
|
||||
if (theme === 'light') {
|
||||
@@ -55,23 +84,28 @@ export function AppSidebar() {
|
||||
};
|
||||
|
||||
const domainsItems = [
|
||||
{ to: '/central', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
||||
{ to: '/central/domains', icon: Globe, label: 'Domains' },
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
||||
{ to: '/domains', icon: Globe, label: 'Domains' },
|
||||
];
|
||||
|
||||
const centralItems = [
|
||||
{ to: '/central/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const },
|
||||
{ to: '/central/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const },
|
||||
{ to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
||||
{ to: '/central/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const },
|
||||
{ to: '/auth', icon: KeyRound, label: 'Authentication', daemon: 'authelia' as const },
|
||||
{ to: '/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const },
|
||||
{ to: '/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const },
|
||||
{ to: '/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
||||
{ to: '/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const },
|
||||
{ to: '/dns-filter', icon: ShieldBan, label: 'DNS Filter', daemon: 'dnsmasq' as const },
|
||||
];
|
||||
|
||||
const advancedItems = [
|
||||
{ to: '/central/advanced/haproxy', icon: Network, label: 'HAProxy', daemon: 'haproxy' as const },
|
||||
{ to: '/central/advanced/dnsmasq', icon: Wifi, label: 'dnsmasq', daemon: 'dnsmasq' as const },
|
||||
{ to: '/central/advanced/nftables', icon: Shield, label: 'nftables', daemon: 'nftables' as const },
|
||||
{ to: '/central/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const },
|
||||
{ to: '/central/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
||||
{ to: '/advanced/haproxy', icon: Network, label: 'HAProxy', daemon: 'haproxy' as const },
|
||||
{ to: '/advanced/dnsmasq', icon: Wifi, label: 'dnsmasq', daemon: 'dnsmasq' as const },
|
||||
{ to: '/advanced/nftables', icon: Shield, label: 'nftables', daemon: 'nftables' as const },
|
||||
{ to: '/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const },
|
||||
{ to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
||||
{ to: '/advanced/authelia', icon: KeyRound, label: 'Authelia', daemon: 'authelia' as const },
|
||||
{ to: '/advanced/ddns', icon: Globe, label: 'DDNS', daemon: undefined },
|
||||
{ to: '/advanced/certificates', icon: ShieldCheck, label: 'Certificates', daemon: 'certbot' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -79,7 +113,7 @@ export function AppSidebar() {
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center justify-center pb-2">
|
||||
<div className="p-1 bg-primary/10 rounded-lg">
|
||||
<CloudLightning className="h-6 w-6 text-primary" />
|
||||
<WildCentralLogo className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="overflow-hidden transition-all duration-200 ease-linear ml-2 max-w-[200px] opacity-100 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:max-w-0 group-data-[collapsible=icon]:opacity-0">
|
||||
<h2 className="text-lg font-bold text-foreground whitespace-nowrap">Wild Central</h2>
|
||||
@@ -158,31 +192,40 @@ export function AppSidebar() {
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Advanced</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{advancedItems.map(({ to, icon: Icon, label, daemon }) => {
|
||||
const active = centralStatus?.daemons?.[daemon]?.active;
|
||||
return (
|
||||
<SidebarMenuItem key={to}>
|
||||
<NavLink to={to}>
|
||||
{({ isActive }) => (
|
||||
<SidebarMenuButton isActive={isActive}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="truncate">{label}</span>
|
||||
{active !== undefined && (
|
||||
<span className={`ml-auto h-1.5 w-1.5 rounded-full ${active ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<SidebarGroup>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarGroupLabel className="cursor-pointer select-none">
|
||||
Advanced
|
||||
<ChevronRight className={`ml-auto h-3.5 w-3.5 transition-transform duration-200 ${advancedOpen ? 'rotate-90' : ''}`} />
|
||||
</SidebarGroupLabel>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{advancedItems.map(({ to, icon: Icon, label, daemon }) => {
|
||||
const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
|
||||
return (
|
||||
<SidebarMenuItem key={to}>
|
||||
<NavLink to={to}>
|
||||
{({ isActive }) => (
|
||||
<SidebarMenuButton isActive={isActive}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="truncate">{label}</span>
|
||||
{active !== undefined && (
|
||||
<span className={`ml-auto h-1.5 w-1.5 rounded-full ${active ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</NavLink>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</NavLink>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
</SidebarContent>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
671
web/src/components/AutheliaComponent.tsx
Normal file
671
web/src/components/AutheliaComponent.tsx
Normal file
@@ -0,0 +1,671 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { KeyRound, BookOpen, CheckCircle, AlertCircle, Plus, Trash2, Loader2, RotateCw, ExternalLink, Copy, X, UserPlus, Globe, Pencil } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Switch } from './ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { useAuthelia } from '../hooks/useAuthelia';
|
||||
import { domainsApi } from '../services/api';
|
||||
import type { AutheliaStatus } from '../services/api/authelia';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export function AutheliaComponent() {
|
||||
const {
|
||||
status, isLoadingStatus,
|
||||
users, isLoadingUsers,
|
||||
oidcClients, isLoadingClients,
|
||||
updateConfig, restart,
|
||||
createUser, deleteUser,
|
||||
createOIDCClient, updateOIDCClient, deleteOIDCClient,
|
||||
} = useAuthelia();
|
||||
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [showAddUser, setShowAddUser] = useState(false);
|
||||
const [showAddClient, setShowAddClient] = useState(false);
|
||||
const [newClientSecret, setNewClientSecret] = useState<string | null>(null);
|
||||
const [editingClientId, setEditingClientId] = useState<string | null>(null);
|
||||
|
||||
const showSuccess = (msg: string) => {
|
||||
setSuccessMsg(msg);
|
||||
setErrorMsg(null);
|
||||
setTimeout(() => setSuccessMsg(null), 5000);
|
||||
};
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setErrorMsg(msg);
|
||||
setSuccessMsg(null);
|
||||
};
|
||||
|
||||
if (isLoadingStatus) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<KeyRound className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Authentication</h2>
|
||||
<p className="text-muted-foreground">Centralized authentication for network services via Authelia</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{successMsg && (
|
||||
<Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription className="text-green-700 dark:text-green-300">{successMsg}</AlertDescription>
|
||||
<Button variant="ghost" size="sm" className="absolute right-2 top-2 h-6 w-6 p-0" onClick={() => setSuccessMsg(null)}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
{errorMsg && (
|
||||
<Alert variant="error">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{errorMsg}</AlertDescription>
|
||||
<Button variant="ghost" size="sm" className="absolute right-2 top-2 h-6 w-6 p-0" onClick={() => setErrorMsg(null)}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Educational Card */}
|
||||
<Card className="bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-900/20 dark:to-blue-900/20 border-cyan-200 dark:border-cyan-800">
|
||||
<CardContent className="p-4 flex items-start gap-3">
|
||||
<BookOpen className="h-5 w-5 text-cyan-600 dark:text-cyan-400 mt-0.5 shrink-0" />
|
||||
<div className="text-sm text-cyan-800 dark:text-cyan-200">
|
||||
<p className="font-medium mb-1">Centralized Authentication for Your Network</p>
|
||||
<p>Authelia provides two ways to protect services: <strong>Forward-auth</strong> intercepts requests at HAProxy for apps without native login. <strong>OIDC</strong> lets apps like Gitea and Grafana use Authelia as their identity provider. Both share one user database and login portal.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Configuration */}
|
||||
<ConfigCard
|
||||
status={status}
|
||||
onUpdate={updateConfig}
|
||||
showSuccess={showSuccess}
|
||||
showError={showError}
|
||||
/>
|
||||
|
||||
{/* Service Status (only if enabled) */}
|
||||
{status?.enabled && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Service Status</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={status.active ? 'success' : 'destructive'} className="gap-1">
|
||||
{status.active ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
|
||||
{status.active ? 'Running' : 'Stopped'}
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" className="gap-1" onClick={() => restart.mutate(undefined, {
|
||||
onSuccess: () => showSuccess('Authelia restarted'),
|
||||
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to restart'),
|
||||
})} disabled={restart.isPending}>
|
||||
{restart.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
||||
Restart
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
{status.version && <div><span className="text-muted-foreground">Version</span><div className="font-mono mt-0.5">{status.version}</div></div>}
|
||||
<div><span className="text-muted-foreground">Users</span><div className="font-mono mt-0.5">{status.userCount}</div></div>
|
||||
<div><span className="text-muted-foreground">OIDC Clients</span><div className="font-mono mt-0.5">{status.clientCount}</div></div>
|
||||
{status.domain && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Login Portal</span>
|
||||
<div className="font-mono mt-0.5">
|
||||
<a href={`https://${status.domain}`} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline inline-flex items-center gap-1">
|
||||
{status.domain}<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* User Management */}
|
||||
{status?.enabled && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Users</CardTitle>
|
||||
<Button size="sm" className="gap-1" onClick={() => setShowAddUser(!showAddUser)}>
|
||||
{showAddUser ? <X className="h-3 w-3" /> : <UserPlus className="h-3 w-3" />}
|
||||
{showAddUser ? 'Cancel' : 'Add User'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-3">
|
||||
{showAddUser && (
|
||||
<AddUserForm
|
||||
onSubmit={(data) => {
|
||||
createUser.mutate(data, {
|
||||
onSuccess: () => { showSuccess(`User "${data.username}" created`); setShowAddUser(false); },
|
||||
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to create user'),
|
||||
});
|
||||
}}
|
||||
isSubmitting={createUser.isPending}
|
||||
/>
|
||||
)}
|
||||
{isLoadingUsers ? (
|
||||
<div className="text-center py-4"><Loader2 className="h-5 w-5 animate-spin mx-auto text-muted-foreground" /></div>
|
||||
) : users.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No users yet. Add a user to get started.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{users.map((user) => (
|
||||
<div key={user.username} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<span className="font-medium text-sm">{user.displayname || user.username}</span>
|
||||
<span className="text-muted-foreground text-sm ml-2">({user.username})</span>
|
||||
{user.email && <span className="text-muted-foreground text-xs ml-2">{user.email}</span>}
|
||||
</div>
|
||||
{user.disabled && <Badge variant="secondary">Disabled</Badge>}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive h-7 w-7 p-0"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete user "${user.username}"?`)) {
|
||||
deleteUser.mutate(user.username, {
|
||||
onSuccess: () => showSuccess(`User "${user.username}" deleted`),
|
||||
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to delete user'),
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* OIDC Clients */}
|
||||
{status?.enabled && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">OIDC Clients</CardTitle>
|
||||
<Button size="sm" className="gap-1" onClick={() => { setShowAddClient(!showAddClient); setNewClientSecret(null); }}>
|
||||
{showAddClient ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
||||
{showAddClient ? 'Cancel' : 'Add Client'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-3">
|
||||
{newClientSecret && (
|
||||
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
|
||||
<AlertDescription className="text-amber-800 dark:text-amber-200">
|
||||
<p className="font-medium mb-1">Client secret — save this now, it won't be shown again:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted px-2 py-1 rounded text-xs font-mono break-all">{newClientSecret}</code>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" onClick={() => navigator.clipboard.writeText(newClientSecret)}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{showAddClient && (
|
||||
<AddOIDCClientForm
|
||||
onSubmit={(data) => {
|
||||
createOIDCClient.mutate(data, {
|
||||
onSuccess: (result) => {
|
||||
setNewClientSecret(result.clientSecret);
|
||||
setShowAddClient(false);
|
||||
showSuccess(`OIDC client "${data.clientId}" created`);
|
||||
},
|
||||
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to create client'),
|
||||
});
|
||||
}}
|
||||
isSubmitting={createOIDCClient.isPending}
|
||||
/>
|
||||
)}
|
||||
{isLoadingClients ? (
|
||||
<div className="text-center py-4"><Loader2 className="h-5 w-5 animate-spin mx-auto text-muted-foreground" /></div>
|
||||
) : oidcClients.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No OIDC clients. Apps with native SSO support (Gitea, Grafana, etc.) need a client registration here.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{oidcClients.map((client) => (
|
||||
editingClientId === client.clientId ? (
|
||||
<EditOIDCClientForm
|
||||
key={client.clientId}
|
||||
client={client}
|
||||
onSubmit={(data) => {
|
||||
updateOIDCClient.mutate({ clientId: client.clientId, ...data }, {
|
||||
onSuccess: () => { showSuccess(`Client "${client.clientName}" updated`); setEditingClientId(null); },
|
||||
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to update client'),
|
||||
});
|
||||
}}
|
||||
onCancel={() => setEditingClientId(null)}
|
||||
isSubmitting={updateOIDCClient.isPending}
|
||||
/>
|
||||
) : (
|
||||
<div key={client.clientId} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
|
||||
<div>
|
||||
<span className="font-medium text-sm">{client.clientName}</span>
|
||||
<span className="text-muted-foreground text-xs ml-2 font-mono">{client.clientId}</span>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 font-mono">
|
||||
{client.redirectUris.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{client.authorizationPolicy}</Badge>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setEditingClientId(client.clientId)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive h-7 w-7 p-0"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete OIDC client "${client.clientName}"?`)) {
|
||||
deleteOIDCClient.mutate(client.clientId, {
|
||||
onSuccess: () => showSuccess(`Client "${client.clientName}" deleted`),
|
||||
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to delete client'),
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Protected Domains */}
|
||||
{status?.enabled && <ProtectedDomainsCard authDomain={status.domain} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function ConfigCard({ status, onUpdate, showSuccess, showError }: {
|
||||
status: AutheliaStatus | undefined;
|
||||
onUpdate: ReturnType<typeof useAuthelia>['updateConfig'];
|
||||
showSuccess: (msg: string) => void;
|
||||
showError: (msg: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const { register, handleSubmit, watch, setValue, reset } = useForm({
|
||||
defaultValues: {
|
||||
domain: '',
|
||||
defaultPolicy: 'one_factor',
|
||||
smtpHost: '',
|
||||
smtpPort: 587,
|
||||
smtpUsername: '',
|
||||
smtpSender: '',
|
||||
smtpPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Sync form with status data once on load, not continuously
|
||||
if (status && !initialized) {
|
||||
reset({
|
||||
domain: status.domain ?? '',
|
||||
defaultPolicy: status.defaultPolicy || 'one_factor',
|
||||
smtpHost: status.smtp?.host ?? '',
|
||||
smtpPort: status.smtp?.port || 587,
|
||||
smtpUsername: status.smtp?.username ?? '',
|
||||
smtpSender: status.smtp?.sender ?? '',
|
||||
smtpPassword: '',
|
||||
});
|
||||
setInitialized(true);
|
||||
}
|
||||
|
||||
const enabled = status?.enabled ?? false;
|
||||
const isUpdating = onUpdate.isPending;
|
||||
|
||||
const doUpdate = (data: { enabled?: boolean; domain?: string; defaultPolicy?: string; smtpHost?: string; smtpPort?: number; smtpUsername?: string; smtpSender?: string; smtpPassword?: string }) => {
|
||||
onUpdate.mutate(data, {
|
||||
onSuccess: () => {
|
||||
setEditing(false);
|
||||
showSuccess('Configuration updated');
|
||||
},
|
||||
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to update config'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Configuration</CardTitle>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="authelia-enabled" className="text-sm">Enabled</Label>
|
||||
<Switch
|
||||
id="authelia-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!checked || status?.domain) {
|
||||
doUpdate({ enabled: checked });
|
||||
} else {
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
disabled={isUpdating || (!status?.installed && !enabled)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{!status?.installed && !enabled && (
|
||||
<p className="text-sm text-muted-foreground">Authelia is not installed. Install it first: <code className="bg-muted px-1 rounded">apt install authelia</code></p>
|
||||
)}
|
||||
{(enabled || editing) && (
|
||||
<form onSubmit={handleSubmit((data) => {
|
||||
const update: any = { ...data, enabled: true };
|
||||
if (!update.smtpPassword) delete update.smtpPassword; // don't send empty password
|
||||
doUpdate(update);
|
||||
})} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="auth-domain">Auth Portal Domain</Label>
|
||||
<Input {...register('domain', { required: 'Required' })} id="auth-domain" placeholder="auth.example.com" className="mt-1 font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="default-policy">Default Policy</Label>
|
||||
<Select value={watch('defaultPolicy')} onValueChange={(v) => setValue('defaultPolicy', v)}>
|
||||
<SelectTrigger className="mt-1" id="default-policy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="one_factor">One Factor (password only)</SelectItem>
|
||||
<SelectItem value="two_factor">Two Factor (password + TOTP)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-sm font-medium mb-2">Email Notifications (SMTP)</p>
|
||||
<p className="text-xs text-muted-foreground mb-3">Required for password resets and 2FA enrollment. Without SMTP, notifications are written to a local file.</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<Label htmlFor="smtp-host">SMTP Host</Label>
|
||||
<Input {...register('smtpHost')} id="smtp-host" placeholder="smtp.gmail.com" className="mt-1 font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="smtp-port">Port</Label>
|
||||
<Input {...register('smtpPort', { valueAsNumber: true })} id="smtp-port" type="number" placeholder="587" className="mt-1 font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 mt-3">
|
||||
<div>
|
||||
<Label htmlFor="smtp-sender">Sender Email</Label>
|
||||
<Input {...register('smtpSender')} id="smtp-sender" placeholder="auth@example.com" className="mt-1 font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="smtp-username">Username</Label>
|
||||
<Input {...register('smtpUsername')} id="smtp-username" placeholder="(defaults to sender)" className="mt-1 font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="smtp-password">Password</Label>
|
||||
<Input {...register('smtpPassword')} id="smtp-password" type="password" placeholder={status?.smtp?.host ? '••••••••' : ''} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" disabled={isUpdating}>
|
||||
{isUpdating && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||
Enable & Save
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setEditing(false)} disabled={isUpdating}>Cancel</Button>
|
||||
</div>
|
||||
)}
|
||||
{enabled && !editing && (
|
||||
<Button type="submit" size="sm" disabled={isUpdating}>
|
||||
{isUpdating && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AddUserForm({ onSubmit, isSubmitting }: {
|
||||
onSubmit: (data: { username: string; displayname: string; password: string; email?: string }) => void;
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
defaultValues: { username: '', displayname: '', password: '', email: '' },
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-3 bg-muted/30 rounded-md space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="new-username">Username</Label>
|
||||
<Input {...register('username', { required: 'Required' })} id="new-username" className="mt-1" />
|
||||
{errors.username && <p className="text-sm text-red-600 mt-1">{errors.username.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="new-displayname">Display Name</Label>
|
||||
<Input {...register('displayname', { required: 'Required' })} id="new-displayname" className="mt-1" />
|
||||
{errors.displayname && <p className="text-sm text-red-600 mt-1">{errors.displayname.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="new-email">Email</Label>
|
||||
<Input {...register('email')} id="new-email" type="email" className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="new-password">Password</Label>
|
||||
<Input {...register('password', { required: 'Required', minLength: { value: 8, message: 'Min 8 characters' } })} id="new-password" type="password" className="mt-1" />
|
||||
{errors.password && <p className="text-sm text-red-600 mt-1">{errors.password.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||
Create User
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function AddOIDCClientForm({ onSubmit, isSubmitting }: {
|
||||
onSubmit: (data: { clientId: string; clientName: string; redirectUris: string[]; authorizationPolicy?: string }) => void;
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
const { register, handleSubmit, formState: { errors }, watch, setValue } = useForm({
|
||||
defaultValues: { clientId: '', clientName: '', redirectUri: '', authorizationPolicy: 'one_factor' },
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit((data) => {
|
||||
onSubmit({
|
||||
clientId: data.clientId,
|
||||
clientName: data.clientName,
|
||||
redirectUris: [data.redirectUri],
|
||||
authorizationPolicy: data.authorizationPolicy,
|
||||
});
|
||||
})} className="p-3 bg-muted/30 rounded-md space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="client-id">Client ID</Label>
|
||||
<Input {...register('clientId', { required: 'Required' })} id="client-id" placeholder="gitea" className="mt-1 font-mono" />
|
||||
{errors.clientId && <p className="text-sm text-red-600 mt-1">{errors.clientId.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="client-name">Client Name</Label>
|
||||
<Input {...register('clientName', { required: 'Required' })} id="client-name" placeholder="Gitea" className="mt-1" />
|
||||
{errors.clientName && <p className="text-sm text-red-600 mt-1">{errors.clientName.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="redirect-uri">Redirect URI</Label>
|
||||
<Input {...register('redirectUri', { required: 'Required' })} id="redirect-uri" placeholder="https://git.example.com/user/oauth2/authelia/callback" className="mt-1 font-mono text-sm" />
|
||||
{errors.redirectUri && <p className="text-sm text-red-600 mt-1">{errors.redirectUri.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="client-policy">Authorization Policy</Label>
|
||||
<Select value={watch('authorizationPolicy')} onValueChange={(v) => setValue('authorizationPolicy', v)}>
|
||||
<SelectTrigger className="mt-1" id="client-policy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="one_factor">One Factor</SelectItem>
|
||||
<SelectItem value="two_factor">Two Factor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||
Create Client
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EditOIDCClientForm({ client, onSubmit, onCancel, isSubmitting }: {
|
||||
client: { clientId: string; clientName: string; redirectUris: string[]; authorizationPolicy: string };
|
||||
onSubmit: (data: { clientName?: string; redirectUris?: string[]; authorizationPolicy?: string }) => void;
|
||||
onCancel: () => void;
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
const { register, handleSubmit, watch, setValue } = useForm({
|
||||
defaultValues: {
|
||||
clientName: client.clientName,
|
||||
redirectUri: client.redirectUris[0] ?? '',
|
||||
authorizationPolicy: client.authorizationPolicy || 'one_factor',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit((data) => {
|
||||
onSubmit({
|
||||
clientName: data.clientName,
|
||||
redirectUris: [data.redirectUri],
|
||||
authorizationPolicy: data.authorizationPolicy,
|
||||
});
|
||||
})} className="p-3 bg-muted/30 rounded-md space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Client ID</Label>
|
||||
<Input value={client.clientId} disabled className="mt-1 font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-client-name">Client Name</Label>
|
||||
<Input {...register('clientName')} id="edit-client-name" className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-redirect-uri">Redirect URI</Label>
|
||||
<Input {...register('redirectUri')} id="edit-redirect-uri" className="mt-1 font-mono text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-client-policy">Authorization Policy</Label>
|
||||
<Select value={watch('authorizationPolicy')} onValueChange={(v) => setValue('authorizationPolicy', v)}>
|
||||
<SelectTrigger className="mt-1" id="edit-client-policy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="one_factor">One Factor</SelectItem>
|
||||
<SelectItem value="two_factor">Two Factor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||
Save
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={isSubmitting}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtectedDomainsCard({ authDomain }: { authDomain: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const domainsQuery = useQuery({
|
||||
queryKey: ['domains'],
|
||||
queryFn: () => domainsApi.list(),
|
||||
});
|
||||
|
||||
const toggleAuthMutation = useMutation({
|
||||
mutationFn: async ({ domain, enabled }: { domain: string; enabled: boolean }) => {
|
||||
return apiClient.patch(`/api/v1/domains/${domain}`, { auth: { enabled, policy: 'one_factor' } });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['domains'] });
|
||||
},
|
||||
});
|
||||
|
||||
const httpDomains = (domainsQuery.data?.domains ?? []).filter(
|
||||
(d) => d.backend.type === 'http' && d.source !== 'authelia' && d.source !== 'central' && d.domain !== authDomain
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">Protected Domains</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">Forward-Auth</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Toggle forward-auth protection for L7 HTTP services. Apps with native OIDC support (Gitea, Grafana, etc.) should use an OIDC client above instead.
|
||||
</p>
|
||||
{httpDomains.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4 flex items-center justify-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
No L7 HTTP domains registered yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{httpDomains.map((domain) => {
|
||||
const authEnabled = (domain as any).auth?.enabled ?? false;
|
||||
return (
|
||||
<div key={domain.domain} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
|
||||
<div>
|
||||
<span className="font-mono text-sm">{domain.domain}</span>
|
||||
<span className="text-muted-foreground text-xs ml-2">({domain.source})</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={authEnabled}
|
||||
onCheckedChange={(checked) => toggleAuthMutation.mutate({ domain: domain.domain, enabled: checked })}
|
||||
disabled={toggleAuthMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Import apiClient for the PATCH call in ProtectedDomainsCard
|
||||
import { apiClient } from '../services/api/client';
|
||||
@@ -82,8 +82,12 @@ function titleCaseScenario(s: string): string {
|
||||
).join(' ');
|
||||
}
|
||||
|
||||
function friendlyReason(reason: string): string {
|
||||
if (!reason) return 'Community blocklist';
|
||||
function friendlyScenario(scenario: string, origin?: string): string {
|
||||
if (!scenario) {
|
||||
if (origin === 'capi') return 'Community blocklist';
|
||||
if (origin === 'manual' || origin === 'cscli') return 'Manual';
|
||||
return origin || 'Unknown';
|
||||
}
|
||||
const map: Record<string, string> = {
|
||||
'crowdsecurity/http-scan-classb': 'HTTP Scanner',
|
||||
'crowdsecurity/ssh-slow-bruteforce': 'SSH Brute Force (slow)',
|
||||
@@ -96,8 +100,8 @@ function friendlyReason(reason: string): string {
|
||||
'crowdsecurity/http-bad-user-agent': 'Bad User Agent',
|
||||
'crowdsecurity/iptables-scan-multi_ports': 'Port Scanner',
|
||||
};
|
||||
if (map[reason]) return map[reason];
|
||||
const short = reason.includes('/') ? reason.split('/').slice(1).join(' ') : reason;
|
||||
if (map[scenario]) return map[scenario];
|
||||
const short = scenario.includes('/') ? scenario.split('/').slice(1).join(' ') : scenario;
|
||||
return titleCaseScenario(short);
|
||||
}
|
||||
|
||||
@@ -294,10 +298,10 @@ export function CrowdSecComponent() {
|
||||
/>
|
||||
{scenarioHubUrl(reason) ? (
|
||||
<a href={scenarioHubUrl(reason)!} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground hover:underline">
|
||||
{friendlyReason(reason)}
|
||||
{friendlyScenario(reason)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{friendlyReason(reason)}</span>
|
||||
<span className="text-muted-foreground">{friendlyScenario(reason)}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-mono text-xs tabular-nums">{count.toLocaleString()}</span>
|
||||
@@ -411,10 +415,10 @@ export function CrowdSecComponent() {
|
||||
</div>
|
||||
{scenarioHubUrl(alert.scenario) ? (
|
||||
<a href={scenarioHubUrl(alert.scenario)!} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground whitespace-nowrap hover:text-foreground hover:underline">
|
||||
{friendlyReason(alert.scenario)}
|
||||
{friendlyScenario(alert.scenario)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{friendlyReason(alert.scenario)}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{friendlyScenario(alert.scenario)}</span>
|
||||
)}
|
||||
<span className="text-xs font-mono text-muted-foreground whitespace-nowrap">{alert.machineId?.replace(/^wc-/, '') ?? '—'}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{formatRelativeTime(alert.createdAt)}</span>
|
||||
@@ -528,7 +532,7 @@ export function CrowdSecComponent() {
|
||||
}
|
||||
<span className="font-mono truncate">{d.value}</span>
|
||||
<Badge variant="outline" className="text-xs h-4 shrink-0">{d.type}</Badge>
|
||||
<span className="text-muted-foreground truncate">{friendlyReason(d.reason)}</span>
|
||||
<span className="text-muted-foreground truncate">{friendlyScenario(d.scenario, d.origin)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{d.duration && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
@@ -7,20 +7,59 @@ import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Badge } from './ui/badge';
|
||||
import {
|
||||
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
|
||||
RefreshCw, BookOpen, Globe, Router, Server, RotateCw, Key,
|
||||
BookOpen, Globe, Router, RotateCw, Key, KeyRound,
|
||||
Shield, ShieldCheck, Eye, ArrowLeftRight, Lock,
|
||||
} from 'lucide-react';
|
||||
import { useCloudflare } from '../hooks/useCloudflare';
|
||||
import { useDdns } from '../hooks/useDdns';
|
||||
import { useCentralStatus } from '../hooks/useCentralStatus';
|
||||
import { useVpn } from '../hooks/useVpn';
|
||||
import { secretsApi, dnsSettingsApi, nftablesConfigApi } from '../services/api';
|
||||
import { usePageHelp } from '../hooks/usePageHelp';
|
||||
import type { DdnsRecordStatus } from '../services/api/networking';
|
||||
|
||||
const SERVICES = [
|
||||
{ key: 'dnsmasq', label: 'DNS / DHCP', icon: Globe },
|
||||
{ key: 'haproxy', label: 'HAProxy', icon: ArrowLeftRight },
|
||||
{ key: 'nftables', label: 'Firewall', icon: Shield },
|
||||
{ key: 'wireguard', label: 'VPN', icon: Lock },
|
||||
{ key: 'crowdsec', label: 'CrowdSec', icon: Eye },
|
||||
{ key: 'authelia', label: 'Auth', icon: KeyRound },
|
||||
{ key: 'certbot', label: 'Certificates', icon: ShieldCheck },
|
||||
];
|
||||
|
||||
function formatUptime(totalSeconds: number): string {
|
||||
const days = Math.floor(totalSeconds / 86400);
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function useLiveUptime(serverSeconds: number | undefined): string {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const baseRef = useRef(serverSeconds ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverSeconds === undefined) return;
|
||||
baseRef.current = serverSeconds;
|
||||
setElapsed(0);
|
||||
}, [serverSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverSeconds === undefined) return;
|
||||
const id = setInterval(() => setElapsed(e => e + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [serverSeconds]);
|
||||
|
||||
return formatUptime(baseRef.current + elapsed);
|
||||
}
|
||||
|
||||
export function DashboardComponent() {
|
||||
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
|
||||
const { verification, isLoading: cfLoading } = useCloudflare();
|
||||
const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns();
|
||||
const { config: vpnConfig } = useVpn();
|
||||
const { data: dnsSettings } = useQuery({ queryKey: ['dns', 'settings'], queryFn: () => dnsSettingsApi.get() });
|
||||
const { data: nftConfig } = useQuery({ queryKey: ['nftables', 'config'], queryFn: () => nftablesConfigApi.get() });
|
||||
@@ -47,11 +86,21 @@ export function DashboardComponent() {
|
||||
},
|
||||
});
|
||||
|
||||
// Warning conditions
|
||||
const cfTokenMissing = !cfLoading && !verification?.tokenConfigured;
|
||||
const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid;
|
||||
const ddnsFailed = ddnsStatus?.enabled && ddnsStatus?.lastError;
|
||||
const hasWarnings = cfTokenMissing || cfTokenInvalid || ddnsFailed;
|
||||
const hasWarnings = cfTokenMissing || cfTokenInvalid;
|
||||
|
||||
const daemons = (centralStatus?.daemons ?? {}) as Record<string, { active?: boolean; version?: string }>;
|
||||
|
||||
const uptime = useLiveUptime(centralStatus?.uptimeSeconds);
|
||||
|
||||
const ports = [
|
||||
{ port: 443, proto: 'TCP', label: 'HTTPS' },
|
||||
{ port: 80, proto: 'TCP', label: 'HTTP' },
|
||||
...(vpnConfig?.enabled ? [{ port: vpnConfig.listenPort || 51820, proto: 'UDP', label: 'VPN' }] : []),
|
||||
...((nftConfig?.extraPorts ?? []) as { port: number; protocol?: string; label?: string }[])
|
||||
.map(ep => ({ port: ep.port, proto: (ep.protocol || 'tcp').toUpperCase(), label: ep.label || '' })),
|
||||
];
|
||||
|
||||
usePageHelp({
|
||||
title: 'What is the Dashboard?',
|
||||
@@ -73,15 +122,36 @@ export function DashboardComponent() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<LayoutDashboard className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Dashboard</h2>
|
||||
<p className="text-muted-foreground">Infrastructure health and service prerequisites</p>
|
||||
{/* Page Header with system metadata */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<LayoutDashboard className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Dashboard</h2>
|
||||
<p className="text-muted-foreground">System health overview</p>
|
||||
</div>
|
||||
</div>
|
||||
{!statusLoading && centralStatus ? (
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground sm:mt-1">
|
||||
<span className="font-mono text-xs">{centralStatus.version}</span>
|
||||
<span className="text-border">|</span>
|
||||
<span>up <span className="font-mono">{uptime}</span></span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={restartDaemon}
|
||||
disabled={isRestarting}
|
||||
className="gap-1 h-7 text-xs"
|
||||
>
|
||||
{isRestarting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
||||
{isRestarting ? 'Restarting...' : 'Restart'}
|
||||
</Button>
|
||||
</div>
|
||||
) : !statusLoading ? (
|
||||
<Badge variant="destructive">Offline</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
@@ -99,322 +169,276 @@ export function DashboardComponent() {
|
||||
<AlertDescription>Cloudflare API token is invalid. Check your token in Cloudflare settings.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{ddnsFailed && (
|
||||
<Alert variant="error">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>DDNS sync error: {ddnsStatus.lastError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1. Cloudflare */}
|
||||
<Card className="p-4 border-l-4 border-l-blue-500">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="h-5 w-5 text-blue-500" />
|
||||
<div className="font-medium">Cloudflare</div>
|
||||
</div>
|
||||
{cfLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : verification?.tokenValid ? (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Connected
|
||||
</Badge>
|
||||
) : verification?.tokenConfigured ? (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Invalid
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Not configured</Badge>
|
||||
)}
|
||||
{/* Services */}
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-3">Services</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{SERVICES.map((svc) => (
|
||||
<ServiceCard
|
||||
key={svc.key}
|
||||
label={svc.label}
|
||||
daemon={svc.key}
|
||||
icon={svc.icon}
|
||||
active={daemons[svc.key]?.active}
|
||||
version={daemons[svc.key]?.version}
|
||||
loading={statusLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cfLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Checking token...
|
||||
</div>
|
||||
) : verification?.tokenValid ? (
|
||||
<div className="space-y-2 ml-7">
|
||||
{verification.zones && verification.zones.length > 0 && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<Globe className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<span className="text-muted-foreground">Zones: </span>
|
||||
<span className="inline-flex flex-wrap gap-1">
|
||||
{verification.zones.map((zone) => (
|
||||
<span key={zone.id} className="font-mono bg-muted px-2 py-0.5 rounded text-xs">
|
||||
{zone.name}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{/* Configuration */}
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-3">Configuration</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Cloudflare */}
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-orange-500/10 rounded-xl">
|
||||
<Cloud className="h-5 w-5 text-orange-500" />
|
||||
</div>
|
||||
<div className="font-semibold">Cloudflare</div>
|
||||
</div>
|
||||
)}
|
||||
{!editingToken ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditingToken(true)} className="gap-1 text-muted-foreground">
|
||||
<Key className="h-3 w-3" />
|
||||
Change Token
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="New Cloudflare API token..."
|
||||
value={tokenValue}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
{cfLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : verification?.tokenValid ? (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Connected
|
||||
</Badge>
|
||||
) : verification?.tokenConfigured ? (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Invalid
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Not configured</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cfLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Verifying...
|
||||
</div>
|
||||
) : verification?.tokenValid ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<PermissionRow label="Zone:Read" description="List DNS zones" ok={verification.permissions?.zone} />
|
||||
<PermissionRow label="DNS:Edit" description="Manage DNS records" ok={verification.permissions?.dns} />
|
||||
</div>
|
||||
<ZoneCoverage
|
||||
available={verification.zones}
|
||||
required={verification.requiredZones}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => updateSecretsMutation.mutate({ cloudflare: { apiToken: tokenValue } })} disabled={!tokenValue}>
|
||||
Save
|
||||
{!editingToken ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditingToken(true)} className="gap-1 h-7 text-xs text-muted-foreground">
|
||||
<Key className="h-3 w-3" />
|
||||
Change Token
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setEditingToken(false); setTokenValue(''); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-7">
|
||||
{!editingToken ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{verification?.tokenConfigured
|
||||
? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}`
|
||||
: 'An API token is required for DNS and TLS features'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditingToken(true)} className="gap-1">
|
||||
<Key className="h-3 w-3" />
|
||||
{cfTokenIsSet ? 'Update Token' : 'Set Token'}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="New Cloudflare API token..."
|
||||
value={tokenValue}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => updateSecretsMutation.mutate({ cloudflare: { apiToken: tokenValue } })} disabled={!tokenValue}>
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setEditingToken(false); setTokenValue(''); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Cloudflare API token..."
|
||||
value={tokenValue}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => updateSecretsMutation.mutate({ cloudflare: { apiToken: tokenValue } })}
|
||||
disabled={updateSecretsMutation.isPending || !tokenValue}
|
||||
>
|
||||
{updateSecretsMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditingToken(false); setTokenValue(''); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{updateSecretsMutation.isError && (
|
||||
<p className="text-xs text-red-500">{String(updateSecretsMutation.error)}</p>
|
||||
<div>
|
||||
{!editingToken ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{verification?.tokenConfigured
|
||||
? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}`
|
||||
: 'Required for DNS and TLS'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditingToken(true)} className="gap-1">
|
||||
<Key className="h-3 w-3" />
|
||||
{cfTokenIsSet ? 'Update Token' : 'Set Token'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Cloudflare API token..."
|
||||
value={tokenValue}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => updateSecretsMutation.mutate({ cloudflare: { apiToken: tokenValue } })}
|
||||
disabled={updateSecretsMutation.isPending || !tokenValue}
|
||||
>
|
||||
{updateSecretsMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditingToken(false); setTokenValue(''); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{updateSecretsMutation.isError && (
|
||||
<p className="text-xs text-red-500">{String(updateSecretsMutation.error)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
{/* 2. Dynamic DNS */}
|
||||
<Card className="p-4 border-l-4 border-l-green-500">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-green-500" />
|
||||
<div className="font-medium">Dynamic DNS</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{ddnsLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : ddnsStatus?.enabled ? (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Active
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ddnsStatus?.enabled ? (
|
||||
<div className="space-y-3 ml-7">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Public IP</span>
|
||||
<div className="font-mono font-medium mt-0.5">{ddnsStatus.currentIP || '--'}</div>
|
||||
{/* Router */}
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2.5 bg-amber-500/10 rounded-xl">
|
||||
<Router className="h-5 w-5 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Last checked</span>
|
||||
<div className="font-mono mt-0.5">
|
||||
{ddnsStatus.lastChecked ? new Date(ddnsStatus.lastChecked).toLocaleTimeString() : '--'}
|
||||
</div>
|
||||
<div className="font-semibold">Router</div>
|
||||
<div className="text-xs text-muted-foreground">LAN setup checklist</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Record list (read-only — derived from public domains) */}
|
||||
{ddnsStatus.records?.length ? (
|
||||
<div className="space-y-1">
|
||||
{ddnsStatus.records.map((rs: DdnsRecordStatus) => (
|
||||
<div key={rs.name} className="flex items-center gap-2 text-xs">
|
||||
{rs.ok ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
|
||||
)}
|
||||
<span className="font-mono bg-muted px-2 py-0.5 rounded">{rs.name}</span>
|
||||
{rs.ok && rs.ip && (
|
||||
<span className="text-muted-foreground font-mono">{rs.ip}</span>
|
||||
)}
|
||||
{!rs.ok && rs.error && (
|
||||
<span className="text-red-500 truncate" title={rs.error}>{rs.error}</span>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p>Configure your LAN router so Wild Central can serve traffic:</p>
|
||||
<ol className="list-decimal list-inside space-y-1.5 text-xs">
|
||||
<li>
|
||||
Set the router's DNS server to Wild Central
|
||||
{dnsSettings?.ip && (
|
||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">{dnsSettings.ip}</span>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
Forward these ports to Wild Central:
|
||||
<div className="flex flex-wrap gap-1 mt-1 ml-4">
|
||||
{ports.map((p, i) => (
|
||||
<span key={i} className="font-mono bg-muted px-2 py-0.5 rounded">
|
||||
{p.port}/{p.proto}
|
||||
{p.label && <span className="text-muted-foreground/60"> ({p.label})</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Records are derived from public domains. Toggle Public on the Domains page to manage.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No public domains registered. Set a domain to Public on the Domains page to enable DDNS.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => triggerDdns()}
|
||||
disabled={isTriggering}
|
||||
className="gap-1"
|
||||
>
|
||||
{isTriggering ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||
Check Now
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground ml-7">
|
||||
DDNS is not enabled. Enable it in the global config to keep public domain DNS records in sync with your public IP.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 3. Daemon Status */}
|
||||
<Card className="p-4 border-l-4 border-l-purple-500">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-purple-500" />
|
||||
<div className="font-medium">Daemon Status</div>
|
||||
</div>
|
||||
{statusLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : centralStatus ? (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Running
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Offline</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{statusLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading status...
|
||||
</div>
|
||||
) : centralStatus ? (
|
||||
<div className="space-y-3 ml-7">
|
||||
{/* Service badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ServiceBadge name="dnsmasq" active={centralStatus?.daemons?.dnsmasq?.active} />
|
||||
<ServiceBadge name="haproxy" active={centralStatus?.daemons?.haproxy?.active} />
|
||||
<ServiceBadge name="nftables" active={centralStatus?.daemons?.nftables?.active} />
|
||||
<ServiceBadge name="wireguard" active={centralStatus?.daemons?.wireguard?.active} />
|
||||
<ServiceBadge name="crowdsec" active={centralStatus?.daemons?.crowdsec?.active} />
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Version and uptime */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Version</span>
|
||||
<div className="font-mono font-medium mt-0.5">{centralStatus.version || 'Unknown'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Uptime</span>
|
||||
<div className="font-mono mt-0.5">{centralStatus.uptime || '--'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={restartDaemon}
|
||||
disabled={isRestarting}
|
||||
className="gap-1"
|
||||
>
|
||||
{isRestarting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
||||
{isRestarting ? 'Restarting...' : 'Restart Daemon'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground ml-7">Cannot reach the Wild Central daemon.</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 4. Gateway Router */}
|
||||
<Card className="p-4 border-l-4 border-l-amber-500 bg-gradient-to-r from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Router className="h-5 w-5 text-amber-500" />
|
||||
<div className="font-medium">Gateway Router</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-2 ml-7 text-sm text-muted-foreground">
|
||||
<p>Your LAN router should be configured to work with Wild Central:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
<li>
|
||||
Set the router's DNS server to Wild Central
|
||||
{dnsSettings?.ip && (
|
||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">
|
||||
{dnsSettings.ip}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
Port-forward to Wild Central:{' '}
|
||||
{[
|
||||
{ port: 443, proto: 'TCP', label: 'HTTPS' },
|
||||
{ port: 80, proto: 'TCP', label: 'HTTP' },
|
||||
...(vpnConfig?.enabled ? [{ port: vpnConfig.listenPort || 51820, proto: 'UDP', label: 'VPN' }] : []),
|
||||
...((nftConfig?.extraPorts ?? []) as { port: number; protocol?: string; label?: string }[])
|
||||
.map(ep => ({ port: ep.port, proto: (ep.protocol || 'tcp').toUpperCase(), label: ep.label || '' })),
|
||||
].map((p, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ', '}
|
||||
<span className="font-mono">{p.port}</span>/{p.proto}
|
||||
{p.label && <span className="text-muted-foreground/60"> ({p.label})</span>}
|
||||
</span>
|
||||
))}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline badge showing a service name with a colored dot reflecting actual daemon status. */
|
||||
function ServiceBadge({ name, active }: { name: string; active?: boolean }) {
|
||||
const dotColor = active === undefined ? 'bg-gray-400' : active ? 'bg-green-500' : 'bg-red-500';
|
||||
function ZoneCoverage({ available, required }: {
|
||||
available: { id: string; name: string }[] | null;
|
||||
required: string[] | null;
|
||||
}) {
|
||||
const availableNames = new Set((available ?? []).map(z => z.name));
|
||||
const requiredNames = required ?? [];
|
||||
|
||||
// Merge: all required zones (checked/unchecked) + any extra available zones not in required
|
||||
const allZones: { name: string; covered: boolean; needed: boolean }[] = [];
|
||||
for (const name of requiredNames) {
|
||||
allZones.push({ name, covered: availableNames.has(name), needed: true });
|
||||
}
|
||||
for (const z of available ?? []) {
|
||||
if (!requiredNames.includes(z.name)) {
|
||||
allZones.push({ name: z.name, covered: true, needed: false });
|
||||
}
|
||||
}
|
||||
|
||||
if (allZones.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-medium">
|
||||
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
|
||||
{name}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Zones</div>
|
||||
<div className="space-y-1">
|
||||
{allZones.map((z) => (
|
||||
<div key={z.name} className="flex items-center gap-2 text-xs">
|
||||
{z.needed ? (
|
||||
z.covered ? (
|
||||
<CheckCircle className="h-3 w-3 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3 text-red-500 shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<CheckCircle className="h-3 w-3 text-muted-foreground/40 shrink-0" />
|
||||
)}
|
||||
<span className="font-mono">{z.name}</span>
|
||||
{!z.covered && z.needed && (
|
||||
<span className="text-red-500">not covered</span>
|
||||
)}
|
||||
{!z.needed && (
|
||||
<span className="text-muted-foreground">unused</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionRow({ label, description, ok }: { label: string; description: string; ok?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{ok ? (
|
||||
<CheckCircle className="h-3 w-3 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3 text-red-500 shrink-0" />
|
||||
)}
|
||||
<span className="font-mono font-medium">{label}</span>
|
||||
<span className="text-muted-foreground">{description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceCard({ label, daemon, icon: Icon, active, version, loading }: {
|
||||
label: string;
|
||||
daemon: string;
|
||||
icon: typeof Globe;
|
||||
active?: boolean;
|
||||
version?: string;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className={`p-4 transition-colors ${active === false ? 'border-red-500/40' : ''}`}>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className={`p-2.5 rounded-xl ${
|
||||
loading ? 'bg-muted' :
|
||||
active ? 'bg-green-500/10' :
|
||||
active === false ? 'bg-red-500/10' :
|
||||
'bg-muted'
|
||||
}`}>
|
||||
{loading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Icon className={`h-5 w-5 ${
|
||||
active ? 'text-green-600 dark:text-green-400' :
|
||||
active === false ? 'text-red-500' :
|
||||
'text-muted-foreground'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono">{daemon}</div>
|
||||
{version && <div className="text-[10px] text-muted-foreground font-mono">{version}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
501
web/src/components/DnsFilterComponent.tsx
Normal file
501
web/src/components/DnsFilterComponent.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Input, Label } from './ui';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Switch } from './ui/switch';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from './ui/select';
|
||||
import {
|
||||
ShieldBan, Loader2, Plus, Trash2, RotateCw, Upload,
|
||||
CheckCircle, AlertCircle, X, ExternalLink, List,
|
||||
} from 'lucide-react';
|
||||
import { useDnsFilter } from '../hooks/useDnsFilter';
|
||||
|
||||
export function DnsFilterComponent() {
|
||||
const {
|
||||
status, isLoadingStatus, config,
|
||||
lists, customEntries, suggestedLists,
|
||||
updateConfig, isUpdatingConfig,
|
||||
addList, isAddingList,
|
||||
removeList, toggleList,
|
||||
addCustomEntry, removeCustomEntry,
|
||||
triggerUpdate, isUpdating,
|
||||
uploadList, isUploading,
|
||||
} = useDnsFilter();
|
||||
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Add list form
|
||||
const [newListUrl, setNewListUrl] = useState('');
|
||||
const [newListName, setNewListName] = useState('');
|
||||
const [showAddList, setShowAddList] = useState(false);
|
||||
|
||||
// Upload form
|
||||
const [uploadName, setUploadName] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Custom entry form
|
||||
const [showAddCustom, setShowAddCustom] = useState(false);
|
||||
const [newCustomDomain, setNewCustomDomain] = useState('');
|
||||
const [newCustomType, setNewCustomType] = useState<'allow' | 'block'>('block');
|
||||
|
||||
const showSuccess = (msg: string) => {
|
||||
setSuccessMessage(msg);
|
||||
setErrorMessage(null);
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
};
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setErrorMessage(msg);
|
||||
setSuccessMessage(null);
|
||||
setTimeout(() => setErrorMessage(null), 8000);
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async () => {
|
||||
try {
|
||||
await updateConfig({ enabled: !status?.enabled });
|
||||
showSuccess(status?.enabled ? 'DNS filtering disabled' : 'DNS filtering enabled');
|
||||
} catch (e) {
|
||||
showError(`Failed to update config: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddList = async () => {
|
||||
if (!newListUrl || !newListName) return;
|
||||
try {
|
||||
await addList({ url: newListUrl, name: newListName });
|
||||
setNewListUrl('');
|
||||
setNewListName('');
|
||||
setShowAddList(false);
|
||||
showSuccess(`Added list: ${newListName}`);
|
||||
} catch (e) {
|
||||
showError(`Failed to add list: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSuggested = async (url: string, name: string) => {
|
||||
try {
|
||||
await addList({ url, name });
|
||||
showSuccess(`Added list: ${name}`);
|
||||
} catch (e) {
|
||||
showError(`Failed to add list: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
const file = fileInputRef.current?.files?.[0];
|
||||
if (!file || !uploadName) return;
|
||||
try {
|
||||
await uploadList({ name: uploadName, file });
|
||||
setUploadName('');
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
showSuccess(`Uploaded list: ${uploadName}`);
|
||||
} catch (e) {
|
||||
showError(`Failed to upload: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveList = async (id: string) => {
|
||||
try {
|
||||
await removeList(id);
|
||||
showSuccess('List removed');
|
||||
} catch (e) {
|
||||
showError(`Failed to remove list: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleList = async (id: string, enabled: boolean) => {
|
||||
try {
|
||||
await toggleList({ id, enabled });
|
||||
} catch (e) {
|
||||
showError(`Failed to toggle list: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCustom = async () => {
|
||||
if (!newCustomDomain) return;
|
||||
try {
|
||||
await addCustomEntry({ domain: newCustomDomain, type: newCustomType });
|
||||
setNewCustomDomain('');
|
||||
setShowAddCustom(false);
|
||||
showSuccess(`Added ${newCustomType} entry for ${newCustomDomain}`);
|
||||
} catch (e) {
|
||||
showError(`Failed to add entry: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCustom = async (domain: string) => {
|
||||
try {
|
||||
await removeCustomEntry(domain);
|
||||
showSuccess('Custom entry removed');
|
||||
} catch (e) {
|
||||
showError(`Failed to remove entry: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerUpdate = async () => {
|
||||
try {
|
||||
await triggerUpdate();
|
||||
showSuccess('Update triggered — lists will refresh in the background');
|
||||
} catch (e) {
|
||||
showError(`Failed to trigger update: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter suggested lists to exclude already-added ones
|
||||
const addedIds = new Set(lists.map(l => l.id));
|
||||
const availableSuggested = suggestedLists.filter(s => !addedIds.has(s.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<ShieldBan className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">DNS Filter</h2>
|
||||
<p className="text-muted-foreground">Block ads, trackers, and malware at the DNS level</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{successMessage && (
|
||||
<Alert className="border-green-500 bg-green-50 dark:bg-green-900/20">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span className="text-green-800 dark:text-green-200">{successMessage}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSuccessMessage(null)}><X className="h-3 w-3" /></Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<Alert variant="error">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>{errorMessage}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setErrorMessage(null)}><X className="h-3 w-3" /></Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Status Card */}
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Status</CardTitle>
|
||||
<div className="flex items-center gap-3">
|
||||
{status?.enabled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTriggerUpdate}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <RotateCw className="h-4 w-4 mr-1" />}
|
||||
Update Now
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="dns-filter-toggle" className="text-sm">
|
||||
{status?.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Label>
|
||||
<Switch
|
||||
id="dns-filter-toggle"
|
||||
checked={status?.enabled ?? false}
|
||||
onCheckedChange={handleToggleEnabled}
|
||||
disabled={isUpdatingConfig || isLoadingStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{status?.enabled && (
|
||||
<CardContent className="space-y-4">
|
||||
{/* Query stats */}
|
||||
{status.queryStats && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-muted rounded-md p-3 text-center">
|
||||
<p className="text-2xl font-bold font-mono">{status.queryStats.totalQueries.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">Queries ({status.queryStats.period})</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-md p-3 text-center">
|
||||
<p className="text-2xl font-bold font-mono text-red-500">{status.queryStats.blockedQueries.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">Blocked</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-md p-3 text-center">
|
||||
<p className="text-2xl font-bold font-mono text-red-500">{status.queryStats.blockedPercent.toFixed(1)}%</p>
|
||||
<p className="text-xs text-muted-foreground">Block rate</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="bg-muted rounded-md p-3 text-center">
|
||||
<p className="text-2xl font-bold font-mono">{status.totalBlocked.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">Domains in blocklist</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-md p-3 text-center">
|
||||
<p className="text-2xl font-bold font-mono">{status.enabledLists}</p>
|
||||
<p className="text-xs text-muted-foreground">Active lists</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-md p-3 text-center">
|
||||
<p className="text-2xl font-bold font-mono">{status.listCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Total lists</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-md p-3 text-center">
|
||||
<p className="text-sm font-mono">
|
||||
{status.lastUpdated ? new Date(status.lastUpdated).toLocaleString() : 'Never'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Last updated</p>
|
||||
</div>
|
||||
</div>
|
||||
{config && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Lists update every {config.intervalHours || 24} hours
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Blocklists Card */}
|
||||
<Card className="border-l-4 border-l-green-500">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<List className="h-5 w-5" />
|
||||
Blocklists
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAddList(!showAddList)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add List
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Add list form */}
|
||||
{showAddList && (
|
||||
<Card className="p-4 bg-muted/50">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Add blocklist by URL</h4>
|
||||
<div>
|
||||
<Label htmlFor="list-name">Name</Label>
|
||||
<Input
|
||||
id="list-name"
|
||||
placeholder="e.g. My Custom Blocklist"
|
||||
value={newListName}
|
||||
onChange={e => setNewListName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="list-url">URL</Label>
|
||||
<Input
|
||||
id="list-url"
|
||||
placeholder="https://example.com/blocklist.txt"
|
||||
value={newListUrl}
|
||||
onChange={e => setNewListUrl(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleAddList} disabled={isAddingList || !newListUrl || !newListName}>
|
||||
{isAddingList ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plus className="h-4 w-4 mr-1" />}
|
||||
Add
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowAddList(false)}>Cancel</Button>
|
||||
</div>
|
||||
|
||||
{/* Upload */}
|
||||
<div className="border-t pt-3 mt-3">
|
||||
<h4 className="font-medium text-sm mb-2">Or upload a file</h4>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="upload-name">Name</Label>
|
||||
<Input
|
||||
id="upload-name"
|
||||
placeholder="My uploaded list"
|
||||
value={uploadName}
|
||||
onChange={e => setUploadName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="upload-file">File</Label>
|
||||
<Input id="upload-file" type="file" ref={fileInputRef} accept=".txt,.hosts,.conf" className="mt-1" />
|
||||
</div>
|
||||
<Button size="sm" onClick={handleUpload} disabled={isUploading || !uploadName}>
|
||||
{isUploading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Upload className="h-4 w-4 mr-1" />}
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current lists */}
|
||||
{lists.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<ShieldBan className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="font-medium">No blocklists added</p>
|
||||
<p className="text-sm mt-1">Add a blocklist URL or choose from the suggestions below</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{lists.map(list => (
|
||||
<div key={list.id} className="flex items-center gap-3 p-3 bg-muted/50 rounded-md">
|
||||
<Switch
|
||||
checked={list.enabled}
|
||||
onCheckedChange={(checked) => handleToggleList(list.id, checked)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{list.name}</span>
|
||||
{list.entryCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{list.entryCount.toLocaleString()} domains
|
||||
</Badge>
|
||||
)}
|
||||
{list.lastError && (
|
||||
<Badge variant="destructive" className="text-xs">Error</Badge>
|
||||
)}
|
||||
</div>
|
||||
{list.url && (
|
||||
<p className="text-xs text-muted-foreground font-mono truncate mt-0.5">{list.url}</p>
|
||||
)}
|
||||
{!list.url && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Uploaded file</p>
|
||||
)}
|
||||
{list.lastError && (
|
||||
<p className="text-xs text-red-500 mt-0.5">{list.lastError}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRemoveList(list.id)}>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested lists */}
|
||||
{availableSuggested.length > 0 && (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<h4 className="text-sm font-medium mb-3 text-muted-foreground">Suggested lists</h4>
|
||||
<div className="space-y-2">
|
||||
{availableSuggested.map(suggested => (
|
||||
<div key={suggested.id} className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{suggested.name}</span>
|
||||
{suggested.url && (
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{suggested.url}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{suggested.url && (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a href={suggested.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAddSuggested(suggested.url!, suggested.name)}
|
||||
disabled={isAddingList}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Custom Entries Card */}
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Custom Entries</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAddCustom(!showAddCustom)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Entry
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{showAddCustom && (
|
||||
<Card className="p-4 bg-muted/50">
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="custom-domain">Domain</Label>
|
||||
<Input
|
||||
id="custom-domain"
|
||||
placeholder="ads.example.com"
|
||||
value={newCustomDomain}
|
||||
onChange={e => setNewCustomDomain(e.target.value)}
|
||||
className="mt-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Label htmlFor="custom-type">Type</Label>
|
||||
<Select value={newCustomType} onValueChange={(v) => setNewCustomType(v as 'allow' | 'block')}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="block">Block</SelectItem>
|
||||
<SelectItem value="allow">Allow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleAddCustom} disabled={!newCustomDomain}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowAddCustom(false)}>Cancel</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Block</strong> adds a domain to the blocklist. <strong>Allow</strong> overrides blocklists to permit a specific domain.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{customEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No custom entries. Use allow entries to override blocklists for specific domains.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{customEntries.map(entry => (
|
||||
<div key={entry.domain} className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50">
|
||||
<Badge variant={entry.type === 'block' ? 'destructive' : 'default'} className="text-xs w-14 justify-center">
|
||||
{entry.type}
|
||||
</Badge>
|
||||
<span className="flex-1 font-mono text-sm">{entry.domain}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRemoveCustom(entry.domain)}>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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'; }
|
||||
});
|
||||
|
||||
|
||||
@@ -386,6 +386,16 @@ export function FirewallComponent() {
|
||||
permissive (safe default while you're getting started).
|
||||
</p>
|
||||
|
||||
{currentWan && !currentExtraPorts.some((p) => p.port === 22) && (
|
||||
<Alert variant="warning">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
Strict mode is active but SSH (port 22) is not in your allowed ports.
|
||||
You may lose remote access if you don't have another way in.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!editingWan ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{currentWan ? (
|
||||
|
||||
41
web/src/components/advanced/AutheliaSubsystem.tsx
Normal file
41
web/src/components/advanced/AutheliaSubsystem.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import { autheliaApi } from '../../services/api/authelia';
|
||||
import { SubsystemPage } from '../SubsystemPage';
|
||||
|
||||
export function AutheliaSubsystem() {
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const configQuery = useQuery({
|
||||
queryKey: ['authelia', 'config', 'raw'],
|
||||
queryFn: () => autheliaApi.getConfig(),
|
||||
});
|
||||
|
||||
const restartMutation = useMutation({
|
||||
mutationFn: () => autheliaApi.restart(),
|
||||
onSuccess: () => {
|
||||
setSuccessMsg('Authelia restarted');
|
||||
setErrorMsg(null);
|
||||
setTimeout(() => setSuccessMsg(null), 5000);
|
||||
},
|
||||
onError: (e: Error) => setErrorMsg(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<SubsystemPage
|
||||
title="Authelia"
|
||||
description="Authentication and identity provider"
|
||||
icon={KeyRound}
|
||||
daemonKey="authelia"
|
||||
configContent={configQuery.data?.content}
|
||||
isLoadingConfig={configQuery.isLoading}
|
||||
onRestart={() => restartMutation.mutate()}
|
||||
isRestarting={restartMutation.isPending}
|
||||
restartLabel="Restart Authelia"
|
||||
successMessage={successMsg}
|
||||
errorMessage={errorMsg}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
160
web/src/components/advanced/DdnsSubsystem.tsx
Normal file
160
web/src/components/advanced/DdnsSubsystem.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Card, CardContent } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Alert, AlertDescription } from '../ui/alert';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Loader2, CheckCircle, AlertCircle, RefreshCw, Globe } from 'lucide-react';
|
||||
import { useDdns } from '../../hooks/useDdns';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ddnsConfigApi } from '../../services/api/settings';
|
||||
|
||||
function formatTime(iso?: string): string {
|
||||
if (!iso) return '--';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '--';
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
export function DdnsSubsystem() {
|
||||
const { status, isLoadingStatus, trigger, isTriggering } = useDdns();
|
||||
|
||||
const configQuery = useQuery({
|
||||
queryKey: ['ddns', 'config'],
|
||||
queryFn: () => ddnsConfigApi.get(),
|
||||
});
|
||||
|
||||
const config = configQuery.data;
|
||||
const records = status?.records ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Globe className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Dynamic DNS</h2>
|
||||
<p className="text-muted-foreground">Cloudflare DNS records managed by Wild Central</p>
|
||||
</div>
|
||||
</div>
|
||||
{status && (
|
||||
<Badge variant={status.enabled ? 'success' : 'secondary'} className="gap-1">
|
||||
{status.enabled ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
|
||||
{status.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Global error */}
|
||||
{status?.lastError && (
|
||||
<Alert variant="error">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{status.lastError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Status + Actions */}
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm flex-1">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Public IP</span>
|
||||
<div className="font-mono mt-0.5">{status?.currentIP ?? '--'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Provider</span>
|
||||
<div className="font-mono mt-0.5">{config?.provider ?? '--'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Last Checked</span>
|
||||
<div className="font-mono mt-0.5 text-xs">{formatTime(status?.lastChecked)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Last Updated</span>
|
||||
<div className="font-mono mt-0.5 text-xs">{formatTime(status?.lastUpdated)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 ml-4 shrink-0"
|
||||
onClick={() => trigger()}
|
||||
disabled={isTriggering || !status?.enabled}
|
||||
>
|
||||
{isTriggering ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||
Sync Now
|
||||
</Button>
|
||||
</div>
|
||||
{config?.intervalMinutes && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Checks every {config.intervalMinutes} minute{config.intervalMinutes !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Records table */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-medium mb-3">Managed Records</h3>
|
||||
{isLoadingStatus ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 text-primary animate-spin" />
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Globe className="h-10 w-10 text-muted-foreground mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{status?.enabled
|
||||
? 'No public domains registered. Mark a domain as public to create a DDNS record.'
|
||||
: 'DDNS is disabled. Enable it in the Domains settings to manage DNS records.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
<th className="pb-2 font-medium text-muted-foreground">Record</th>
|
||||
<th className="pb-2 font-medium text-muted-foreground">IP</th>
|
||||
<th className="pb-2 font-medium text-muted-foreground text-right">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => (
|
||||
<tr key={r.name} className="border-b last:border-0">
|
||||
<td className="py-2 font-mono text-xs">{r.name}</td>
|
||||
<td className="py-2 font-mono text-xs">{r.ip ?? '--'}</td>
|
||||
<td className="py-2 text-right">
|
||||
{r.ok ? (
|
||||
<Badge variant="success" className="gap-1 text-xs">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Synced
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Badge variant="destructive" className="gap-1 text-xs">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Error
|
||||
</Badge>
|
||||
{r.error && (
|
||||
<span className="text-xs text-muted-foreground max-w-[200px] truncate" title={r.error}>
|
||||
{r.error}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,3 +3,6 @@ export { DnsmasqSubsystem } from './DnsmasqSubsystem';
|
||||
export { NftablesSubsystem } from './NftablesSubsystem';
|
||||
export { WireguardSubsystem } from './WireguardSubsystem';
|
||||
export { CrowdsecSubsystem } from './CrowdsecSubsystem';
|
||||
export { AutheliaSubsystem } from './AutheliaSubsystem';
|
||||
export { DdnsSubsystem } from './DdnsSubsystem';
|
||||
export { CertificatesSubsystem } from './CertificatesSubsystem';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Trash2, CheckCircle, AlertCircle, Loader2, Plus, Undo2, Save } from 'lucide-react';
|
||||
import { Trash2, CheckCircle, AlertCircle, Loader2, Plus, Undo2, Save, RotateCw, Pencil } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { DomainTopology } from './DomainTopology';
|
||||
@@ -107,28 +107,60 @@ function describeChanges(server: DomainDraft, draft: DomainDraft): string[] {
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Compact controls for mobile */
|
||||
/** Compact controls for mobile — vertical pipeline: Source → Routing → Destination */
|
||||
function MobileControls({
|
||||
draft, onUpdateDraft, cert, onProvisionCert, isProvisioningCert,
|
||||
draft, onUpdateDraft, domain, cert, ddnsRecord,
|
||||
onProvisionCert, onRenewCert, isProvisioningCert, isRenewingCert,
|
||||
}: {
|
||||
draft: DomainDraft;
|
||||
onUpdateDraft: (patch: Partial<DomainDraft>) => void;
|
||||
domain: RegisteredDomain;
|
||||
cert: CertEntry | undefined;
|
||||
ddnsRecord: DdnsRecordStatus | undefined;
|
||||
onProvisionCert: (d: string) => void;
|
||||
onRenewCert: () => void;
|
||||
isProvisioningCert: boolean;
|
||||
isRenewingCert: boolean;
|
||||
}) {
|
||||
const isL7 = draft.mode === 'l7';
|
||||
const isDnsOnly = draft.mode === 'dns-only';
|
||||
const hasCert = !!cert?.cert.exists;
|
||||
const daysLeft = cert?.cert.daysLeft ?? 0;
|
||||
const hasRoutes = !!domain.routes?.length;
|
||||
|
||||
const [editingBackend, setEditingBackend] = useState(false);
|
||||
const backendInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleBackendSave = () => {
|
||||
const val = backendInputRef.current?.value.trim();
|
||||
if (val) onUpdateDraft({ backendAddress: val });
|
||||
setEditingBackend(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Public (internet-accessible)</Label>
|
||||
<Switch checked={draft.public} onCheckedChange={v => onUpdateDraft({ public: v })} />
|
||||
<div className="py-2 space-y-3">
|
||||
{/* === Section 1: Source === */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">Source</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Internet-accessible</Label>
|
||||
<Switch checked={draft.public} onCheckedChange={v => onUpdateDraft({ public: v })} />
|
||||
</div>
|
||||
{draft.public && ddnsRecord && !ddnsRecord.ok && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-red-600 dark:text-red-400 bg-red-50/50 dark:bg-red-950/20 rounded-md px-3 py-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
DDNS sync failed{ddnsRecord.error ? `: ${ddnsRecord.error}` : ''}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Include subdomains</Label>
|
||||
<Switch checked={draft.subdomains} onCheckedChange={v => onUpdateDraft({ subdomains: v })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs mb-1.5 block">Routing mode</Label>
|
||||
{/* === Section 2: Routing === */}
|
||||
<div className="border-t border-border/50 pt-3 space-y-2">
|
||||
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">Routing</div>
|
||||
<div className="flex gap-1 bg-muted rounded-md p-1">
|
||||
{([
|
||||
{ value: 'dns-only' as const, label: 'Direct' },
|
||||
@@ -146,39 +178,111 @@ function MobileControls({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isDnsOnly && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50/50 dark:bg-amber-950/20 rounded-md px-3 py-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
No firewall, proxy, or TLS protection — traffic goes directly to your server
|
||||
</div>
|
||||
)}
|
||||
{isL7 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{hasCert ? (
|
||||
<>
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
<span>TLS: {(cert!.cert.wildcard || cert!.coveredBy) ? `*.${cert!.domain}` : cert!.domain}</span>
|
||||
{cert!.cert.daysLeft != null && <span className="text-muted-foreground">({cert!.cert.daysLeft}d)</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
<span>No TLS certificate</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!hasCert && (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
||||
disabled={isProvisioningCert}
|
||||
onClick={() => onProvisionCert(draft.subdomains ? `*.` : '')}
|
||||
>
|
||||
{isProvisioningCert ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
||||
Provision
|
||||
</Button>
|
||||
)}
|
||||
{hasCert && daysLeft < 30 && (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
||||
disabled={isRenewingCert}
|
||||
onClick={() => onRenewCert()}
|
||||
>
|
||||
{isRenewingCert ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
||||
Renew
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!hasCert && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-red-600 dark:text-red-400 bg-red-50/50 dark:bg-red-950/20 rounded-md px-3 py-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
L7 routing is inactive until a TLS certificate is provisioned
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isL7 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{hasCert ? (
|
||||
<>
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
<span>TLS: {(cert!.cert.wildcard || cert!.coveredBy) ? `*.${cert!.domain}` : cert!.domain}</span>
|
||||
{cert!.cert.daysLeft != null && <span className="text-muted-foreground">({cert!.cert.daysLeft}d)</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
<span>No TLS certificate</span>
|
||||
</>
|
||||
{/* === Section 3: Destination === */}
|
||||
<div className="border-t border-border/50 pt-3 space-y-2">
|
||||
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">Destination</div>
|
||||
{editingBackend ? (
|
||||
<div className="space-y-1.5">
|
||||
<input
|
||||
ref={backendInputRef}
|
||||
defaultValue={draft.backendAddress}
|
||||
className="font-mono bg-muted rounded px-2 py-1.5 text-xs w-full border border-border focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleBackendSave();
|
||||
if (e.key === 'Escape') setEditingBackend(false);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs flex-1" onClick={handleBackendSave}>Save</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs flex-1" onClick={() => setEditingBackend(false)}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-xs bg-muted rounded px-2 py-1">
|
||||
{hasRoutes ? `${domain.routes!.length} backends` : draft.backendAddress}
|
||||
</span>
|
||||
{!hasRoutes && (
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
||||
onClick={() => setEditingBackend(true)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!hasCert && (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
||||
disabled={isProvisioningCert}
|
||||
onClick={() => onProvisionCert(draft.subdomains ? `*.` : '')}
|
||||
>
|
||||
{isProvisioningCert ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
||||
Provision
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Backend: </span>
|
||||
<span className="font-mono">{draft.backendAddress}</span>
|
||||
)}
|
||||
{isL7 && domain.routes && domain.routes.length > 0 && (
|
||||
<div className="bg-muted/30 rounded-md p-2 space-y-1">
|
||||
<div className="text-[10px] text-muted-foreground font-medium mb-1">Route table</div>
|
||||
{domain.routes.map((route, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[10px] flex-wrap">
|
||||
<span className="font-mono text-muted-foreground truncate">
|
||||
{route.paths?.length ? route.paths.join(', ') : '/*'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{'->'}</span>
|
||||
<span className="font-mono">{route.backend.address}</span>
|
||||
{route.ipAllow && route.ipAllow.length > 0 && (
|
||||
<span className="text-muted-foreground flex items-center gap-0.5">
|
||||
{route.ipAllow.length} IP rules
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -362,9 +466,13 @@ export function DomainCard({
|
||||
<MobileControls
|
||||
draft={draft}
|
||||
onUpdateDraft={updateDraft}
|
||||
domain={domain}
|
||||
cert={cert}
|
||||
ddnsRecord={ddnsRecord}
|
||||
onProvisionCert={d => onProvisionCert(draft.subdomains ? `*.${domain.domain}` : d || domain.domain)}
|
||||
onRenewCert={onRenewCert}
|
||||
isProvisioningCert={isProvisioningCert}
|
||||
isRenewingCert={isRenewingCert}
|
||||
/>
|
||||
) : (
|
||||
<DomainTopology
|
||||
@@ -412,16 +520,18 @@ export function DomainCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === Footer: subdomains + source + deregister === */}
|
||||
{/* === Footer: subdomains (desktop only) + source + deregister === */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border/50">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => updateDraft({ subdomains: !draft.subdomains })}
|
||||
>
|
||||
{draft.subdomains ? '*.wildcard on' : 'wildcard off'}
|
||||
</button>
|
||||
{!isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => updateDraft({ subdomains: !draft.subdomains })}
|
||||
>
|
||||
{draft.subdomains ? '*.wildcard on' : 'wildcard off'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Source: <span className="font-mono">{domain.source}</span>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import {
|
||||
ReactFlow, Handle, Position, applyNodeChanges,
|
||||
type Node, type Edge, type NodeProps, type NodeChange, type ReactFlowInstance,
|
||||
@@ -169,23 +168,17 @@ function FlowNode({ data }: NodeProps) {
|
||||
<>
|
||||
{leftHandle}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link to="/central/firewall" className="group nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<TopologyNode
|
||||
label="Firewall"
|
||||
status={fwOk ? 'ok' : 'na'}
|
||||
icon={<Shield className="h-3 w-3" />}
|
||||
className="group-hover:border-primary/40"
|
||||
/>
|
||||
</Link>
|
||||
<Link to="/central/crowdsec" className="group nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<TopologyNode
|
||||
label="CrowdSec"
|
||||
sublabel={isL4 ? 'IP only' : 'HTTP + IP'}
|
||||
status={csOk ? 'ok' : 'na'}
|
||||
icon={<ShieldAlert className="h-3 w-3" />}
|
||||
className="group-hover:border-primary/40"
|
||||
/>
|
||||
</Link>
|
||||
<TopologyNode
|
||||
label="Firewall"
|
||||
status={fwOk ? 'ok' : 'na'}
|
||||
icon={<Shield className="h-3 w-3" />}
|
||||
/>
|
||||
<TopologyNode
|
||||
label="CrowdSec"
|
||||
sublabel={isL4 ? 'IP only' : 'HTTP + IP'}
|
||||
status={csOk ? 'ok' : 'na'}
|
||||
icon={<ShieldAlert className="h-3 w-3" />}
|
||||
/>
|
||||
</div>
|
||||
{rightHandle}
|
||||
</>
|
||||
@@ -234,7 +227,6 @@ function FlowNode({ data }: NodeProps) {
|
||||
if (v === 'tls') {
|
||||
const status = data.status as NodeStatus;
|
||||
const certDomain = data.certDomain as string | undefined;
|
||||
const isWildcard = data.isWildcardCert as boolean;
|
||||
const daysLeft = data.daysLeft as number;
|
||||
const hasCert = data.hasCert as boolean;
|
||||
const provisionDomain = data.provisionDomain as string;
|
||||
@@ -317,15 +309,12 @@ function FlowNode({ data }: NodeProps) {
|
||||
return (
|
||||
<>
|
||||
{leftHandle}
|
||||
<Link to="/central/vpn" className="nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<TopologyNode
|
||||
label="VPN"
|
||||
sublabel={running ? `${peerCount} peer${peerCount !== 1 ? 's' : ''}` : 'Not running'}
|
||||
status={running ? 'ok' : 'na'}
|
||||
icon={<Lock className="h-3.5 w-3.5" />}
|
||||
className="hover:border-primary/40"
|
||||
/>
|
||||
</Link>
|
||||
<TopologyNode
|
||||
label="VPN"
|
||||
sublabel={running ? `${peerCount} peer${peerCount !== 1 ? 's' : ''}` : 'Not running'}
|
||||
status={running ? 'ok' : 'na'}
|
||||
icon={<Lock className="h-3.5 w-3.5" />}
|
||||
/>
|
||||
{rightHandle}
|
||||
</>
|
||||
);
|
||||
@@ -336,16 +325,13 @@ function FlowNode({ data }: NodeProps) {
|
||||
return (
|
||||
<>
|
||||
{leftHandle}
|
||||
<Link to="/central" className="nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<TopologyNode
|
||||
label="Router"
|
||||
sublabel={ports}
|
||||
status="na"
|
||||
icon={<Router className="h-3 w-3" />}
|
||||
className="hover:border-primary/40"
|
||||
tooltip="Your LAN router port-forwards these ports to Wild Central. See Dashboard for setup."
|
||||
/>
|
||||
</Link>
|
||||
<TopologyNode
|
||||
label="Router"
|
||||
sublabel={ports}
|
||||
status="na"
|
||||
icon={<Router className="h-3 w-3" />}
|
||||
tooltip="Your LAN router port-forwards these ports to Wild Central"
|
||||
/>
|
||||
{rightHandle}
|
||||
</>
|
||||
);
|
||||
|
||||
90
web/src/hooks/useAuthelia.ts
Normal file
90
web/src/hooks/useAuthelia.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { autheliaApi } from '../services/api/authelia';
|
||||
import type { CreateUserRequest, UpdateUserRequest, CreateOIDCClientRequest, UpdateOIDCClientRequest } from '../services/api/authelia';
|
||||
|
||||
export function useAuthelia() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['authelia', 'status'],
|
||||
queryFn: () => autheliaApi.getStatus(),
|
||||
staleTime: 10000,
|
||||
});
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['authelia', 'users'],
|
||||
queryFn: () => autheliaApi.listUsers(),
|
||||
enabled: !!statusQuery.data?.enabled,
|
||||
});
|
||||
|
||||
const oidcClientsQuery = useQuery({
|
||||
queryKey: ['authelia', 'oidc', 'clients'],
|
||||
queryFn: () => autheliaApi.listOIDCClients(),
|
||||
enabled: !!statusQuery.data?.active,
|
||||
});
|
||||
|
||||
const invalidateAll = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['authelia'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['central', 'status'] });
|
||||
};
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: (data: { enabled?: boolean; domain?: string; defaultPolicy?: string; smtpHost?: string; smtpPort?: number; smtpUsername?: string; smtpSender?: string; smtpPassword?: string }) =>
|
||||
autheliaApi.updateConfig(data),
|
||||
onSuccess: invalidateAll,
|
||||
});
|
||||
|
||||
const restartMutation = useMutation({
|
||||
mutationFn: () => autheliaApi.restart(),
|
||||
onSuccess: invalidateAll,
|
||||
});
|
||||
|
||||
const createUserMutation = useMutation({
|
||||
mutationFn: (req: CreateUserRequest) => autheliaApi.createUser(req),
|
||||
onSuccess: invalidateAll,
|
||||
});
|
||||
|
||||
const updateUserMutation = useMutation({
|
||||
mutationFn: ({ username, ...req }: UpdateUserRequest & { username: string }) =>
|
||||
autheliaApi.updateUser(username, req),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'users'] }),
|
||||
});
|
||||
|
||||
const deleteUserMutation = useMutation({
|
||||
mutationFn: (username: string) => autheliaApi.deleteUser(username),
|
||||
onSuccess: invalidateAll,
|
||||
});
|
||||
|
||||
const createOIDCClientMutation = useMutation({
|
||||
mutationFn: (req: CreateOIDCClientRequest) => autheliaApi.createOIDCClient(req),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
|
||||
});
|
||||
|
||||
const updateOIDCClientMutation = useMutation({
|
||||
mutationFn: ({ clientId, ...req }: UpdateOIDCClientRequest & { clientId: string }) =>
|
||||
autheliaApi.updateOIDCClient(clientId, req),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
|
||||
});
|
||||
|
||||
const deleteOIDCClientMutation = useMutation({
|
||||
mutationFn: (clientId: string) => autheliaApi.deleteOIDCClient(clientId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
|
||||
});
|
||||
|
||||
return {
|
||||
status: statusQuery.data,
|
||||
isLoadingStatus: statusQuery.isLoading,
|
||||
users: usersQuery.data?.users ?? [],
|
||||
isLoadingUsers: usersQuery.isLoading,
|
||||
oidcClients: oidcClientsQuery.data?.clients ?? [],
|
||||
isLoadingClients: oidcClientsQuery.isLoading,
|
||||
updateConfig: updateConfigMutation,
|
||||
restart: restartMutation,
|
||||
createUser: createUserMutation,
|
||||
updateUser: updateUserMutation,
|
||||
deleteUser: deleteUserMutation,
|
||||
createOIDCClient: createOIDCClientMutation,
|
||||
updateOIDCClient: updateOIDCClientMutation,
|
||||
deleteOIDCClient: deleteOIDCClientMutation,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { apiClient } from '../services/api/client';
|
||||
|
||||
interface DaemonStatus {
|
||||
active: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface CentralStatus {
|
||||
|
||||
101
web/src/hooks/useDnsFilter.ts
Normal file
101
web/src/hooks/useDnsFilter.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { dnsFilterApi, type CustomEntry } from '../services/api/dnsfilter';
|
||||
|
||||
export function useDnsFilter() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['dns-filter', 'status'],
|
||||
queryFn: () => dnsFilterApi.getStatus(),
|
||||
});
|
||||
|
||||
const configQuery = useQuery({
|
||||
queryKey: ['dns-filter', 'config'],
|
||||
queryFn: () => dnsFilterApi.getConfig(),
|
||||
});
|
||||
|
||||
const listsQuery = useQuery({
|
||||
queryKey: ['dns-filter', 'lists'],
|
||||
queryFn: () => dnsFilterApi.getLists(),
|
||||
});
|
||||
|
||||
const customQuery = useQuery({
|
||||
queryKey: ['dns-filter', 'custom'],
|
||||
queryFn: () => dnsFilterApi.getCustomEntries(),
|
||||
});
|
||||
|
||||
const suggestedQuery = useQuery({
|
||||
queryKey: ['dns-filter', 'suggested'],
|
||||
queryFn: () => dnsFilterApi.getSuggested(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const invalidateAll = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['dns-filter'] });
|
||||
};
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: dnsFilterApi.updateConfig,
|
||||
onSuccess: invalidateAll,
|
||||
});
|
||||
|
||||
const addListMutation = useMutation({
|
||||
mutationFn: (data: { url: string; name: string }) => dnsFilterApi.addList(data),
|
||||
onSuccess: invalidateAll,
|
||||
});
|
||||
|
||||
const removeListMutation = useMutation({
|
||||
mutationFn: (id: string) => dnsFilterApi.removeList(id),
|
||||
onSuccess: invalidateAll,
|
||||
});
|
||||
|
||||
const toggleListMutation = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => dnsFilterApi.toggleList(id, enabled),
|
||||
onSuccess: invalidateAll,
|
||||
});
|
||||
|
||||
const addCustomMutation = useMutation({
|
||||
mutationFn: (entry: CustomEntry) => dnsFilterApi.setCustomEntry(entry),
|
||||
onSuccess: invalidateAll,
|
||||
});
|
||||
|
||||
const removeCustomMutation = useMutation({
|
||||
mutationFn: (domain: string) => dnsFilterApi.removeCustomEntry(domain),
|
||||
onSuccess: invalidateAll,
|
||||
});
|
||||
|
||||
const triggerUpdateMutation = useMutation({
|
||||
mutationFn: () => dnsFilterApi.triggerUpdate(),
|
||||
onSuccess: () => {
|
||||
// Delay invalidation to give the runner time to download + compile
|
||||
setTimeout(invalidateAll, 3000);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadListMutation = useMutation({
|
||||
mutationFn: ({ name, file }: { name: string; file: File }) => dnsFilterApi.uploadList(name, file),
|
||||
onSuccess: invalidateAll,
|
||||
});
|
||||
|
||||
return {
|
||||
status: statusQuery.data,
|
||||
isLoadingStatus: statusQuery.isLoading,
|
||||
config: configQuery.data,
|
||||
lists: listsQuery.data?.lists ?? [],
|
||||
customEntries: customQuery.data?.customEntries ?? [],
|
||||
suggestedLists: suggestedQuery.data?.lists ?? [],
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
isUpdatingConfig: updateConfigMutation.isPending,
|
||||
addList: addListMutation.mutateAsync,
|
||||
isAddingList: addListMutation.isPending,
|
||||
removeList: removeListMutation.mutateAsync,
|
||||
toggleList: toggleListMutation.mutateAsync,
|
||||
addCustomEntry: addCustomMutation.mutateAsync,
|
||||
removeCustomEntry: removeCustomMutation.mutateAsync,
|
||||
triggerUpdate: triggerUpdateMutation.mutateAsync,
|
||||
isUpdating: triggerUpdateMutation.isPending,
|
||||
uploadList: uploadListMutation.mutateAsync,
|
||||
isUploading: uploadListMutation.isPending,
|
||||
refetch: invalidateAll,
|
||||
};
|
||||
}
|
||||
@@ -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/AutheliaPage.tsx
Normal file
10
web/src/router/pages/AutheliaPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { AutheliaComponent } from '../../components/AutheliaComponent';
|
||||
|
||||
export function AutheliaPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AutheliaComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
10
web/src/router/pages/DnsFilterPage.tsx
Normal file
10
web/src/router/pages/DnsFilterPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { DnsFilterComponent } from '../../components/DnsFilterComponent';
|
||||
|
||||
export function DnsFilterPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<DnsFilterComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
10
web/src/router/pages/advanced/AutheliaPage.tsx
Normal file
10
web/src/router/pages/advanced/AutheliaPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../../components';
|
||||
import { AutheliaSubsystem } from '../../../components/advanced';
|
||||
|
||||
export function AutheliaAdvancedPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AutheliaSubsystem />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user