Compare commits

..

10 Commits

Author SHA1 Message Date
Paul Payne
1d1d6c605c Adds dist. 2026-07-12 21:40:15 +00:00
Paul Payne
518cdbbce5 Add DNS filtering with dnsmasq address=/ directives for wildcard blocking
Adds Pi-hole-style DNS filtering using dnsmasq's native address=/ directives,
which block domains and all subdomains. Users subscribe to blocklists by URL
or upload files, with suggested lists from Hagezi, Steven Black, and OISD.
Background runner refreshes lists on a configurable interval (default 24h).
2026-07-12 12:44:19 +00:00
Paul Payne
134c01fe5e Add SMTP support for Authelia notifications and fix config persistence
SMTP: Add host, port, username, sender, and password fields to the
Authelia configuration. Uses modern address format (submission:// for
STARTTLS on 587, smtps:// for implicit TLS on 465) with explicit TLS
server name. Falls back to filesystem notifier when SMTP is not
configured.

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

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

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

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

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

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

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.1.0

30
dist/.gitignore vendored Normal file
View File

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

164
dist/Makefile vendored Normal file
View File

@@ -0,0 +1,164 @@
# Default target
.DEFAULT_GOAL := help
# Build configuration
VERSION := $(shell cat ../VERSION 2>/dev/null || echo "0.0.0-dev")
BUILD_DIR := build
DIST_DIR := dist
DEB_DIR := debian-package
# Git information
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
EDGE_VERSION := 0.0.0~edge.$(shell date -u '+%Y%m%d%H%M%S').$(GIT_COMMIT)
# Source paths
API_SOURCE := ..
WEB_SOURCE := ../web
# Output files
ARM64_BINARY := $(BUILD_DIR)/wild-central-arm64
AMD64_BINARY := $(BUILD_DIR)/wild-central-amd64
WEB_DIST_MARKER := $(BUILD_DIR)/.web-built
ARM64_DEB := $(DIST_DIR)/packages/wild-central_$(VERSION)_arm64.deb
AMD64_DEB := $(DIST_DIR)/packages/wild-central_$(VERSION)_amd64.deb
# Gitea release configuration
GITEA_URL := https://git.civilsociety.dev
GITEA_OWNER := wild-cloud
GITEA_REPO := wild-central
RELEASE_TAG := v$(VERSION)
.PHONY: help clean version build build-all package package-all repo deploy-repo release release-edge
help:
@echo "Wild Central Package Builder"
@echo ""
@echo "This directory builds .deb packages for Wild Central."
@echo "For development, use the parent directory:"
@echo " - make dev (Go daemon)"
@echo " - cd web && pnpm dev (React frontend)"
@echo ""
@echo "Build targets:"
@echo " build - Build for current architecture (amd64)"
@echo " build-all - Build all architectures"
@echo ""
@echo "Package targets:"
@echo " package - Create .deb package (amd64)"
@echo " package-all - Create all .deb packages"
@echo ""
@echo "Repository targets:"
@echo " deploy-repo - Deploy repository to server"
@echo ""
@echo "Release targets:"
@echo " release - Tag HEAD as v$$(cat ../VERSION), publish stable Gitea release + APT"
@echo " release-edge - Publish rolling edge Gitea release + APT (no version bump needed)"
@echo ""
@echo "Utilities:"
@echo " clean - Remove all build artifacts"
@echo " version - Show build information"
@echo ""
@echo " Current stable version: $(VERSION) (from ../VERSION)"
@echo " Current edge version: $(EDGE_VERSION)"
@echo ""
@echo "Environment variables:"
@echo " GITEA_TOKEN - Gitea API token (required for release)"
@echo " APTLY_URL - Aptly instance URL (required for APT deployment)"
@echo " APTLY_PASS - Aptly API password"
@echo " Run: source .envrc"
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 " Run: source .envrc"; \
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 " Or run: source .envrc"; \
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 " Or run: source .envrc"; \
exit 1; \
fi
@echo "Force-pushing edge tag to HEAD..."
@git tag -f edge
@git push origin edge --force
@./scripts/create-gitea-release.sh "$(GITEA_URL)" "$(GITEA_OWNER)" "$(GITEA_REPO)" "edge" "$(EDGE_VERSION)" "$(GITEA_TOKEN)" edge
@if [ -n "$(APTLY_URL)" ] && [ -n "$(APTLY_PASS)" ]; then \
DIST=edge ./scripts/deploy-apt-repository.sh; \
else \
echo "Skipping APT deployment (APTLY_URL/APTLY_PASS not set)"; \
fi

76
dist/README.md vendored Normal file
View File

@@ -0,0 +1,76 @@
# Packaging Wild Central
## Installation
### APT Repository (recommended)
```bash
# Import the signing key
curl -fsSL https://aptly.cloud2.payne.io/wild-central.gpg \
| sudo tee /usr/share/keyrings/wild-central.gpg > /dev/null
# Add the repository
sudo tee /etc/apt/sources.list.d/wild-central.sources <<EOF
Types: deb
URIs: https://aptly.cloud2.payne.io
Suites: stable
Components: main
Signed-By: /usr/share/keyrings/wild-central.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 (see `.envrc`):
- `GITEA_TOKEN` — required for `make release` / `make release-edge`
- `APTLY_URL` / `APTLY_PASS` — required for APT deployment

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

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

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

@@ -0,0 +1,262 @@
#!/bin/bash
set -e
case "$1" in
configure)
echo "Configuring wild-central..."
# Create wildcloud user if it doesn't exist
if ! id wildcloud >/dev/null 2>&1; then
useradd --system --home-dir /var/lib/wild-central --create-home --shell /bin/false wildcloud
fi
# Create required directories
mkdir -p /var/lib/wild-central
mkdir -p /var/log/wild-central
mkdir -p /etc/wild-central
# Set ownership
chown -R wildcloud:wildcloud /var/lib/wild-central
chown wildcloud:wildcloud /var/log/wild-central
# Set up dnsmasq directories and permissions
mkdir -p /etc/dnsmasq.d
mkdir -p /etc/dnsmasq.d/wild-cloud-instances
# Give wildcloud group write access to dnsmasq.d directory
chgrp wildcloud /etc/dnsmasq.d
chmod 775 /etc/dnsmasq.d
# Create or fix ownership of wild-cloud.conf
if [ ! -f /etc/dnsmasq.d/wild-cloud.conf ]; then
touch /etc/dnsmasq.d/wild-cloud.conf
echo "Created /etc/dnsmasq.d/wild-cloud.conf"
else
echo "Found existing /etc/dnsmasq.d/wild-cloud.conf - updating ownership"
fi
chown wildcloud:wildcloud /etc/dnsmasq.d/wild-cloud.conf
chmod 644 /etc/dnsmasq.d/wild-cloud.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-cloud.conf ]; then
touch /etc/systemd/resolved.conf.d/wild-cloud.conf
echo "Created /etc/systemd/resolved.conf.d/wild-cloud.conf"
else
echo "Found existing /etc/systemd/resolved.conf.d/wild-cloud.conf"
fi
chown wildcloud:wildcloud /etc/systemd/resolved.conf.d/wild-cloud.conf
chmod 644 /etc/systemd/resolved.conf.d/wild-cloud.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-cloud.nft ]; then
touch /etc/nftables.d/wild-cloud.nft
echo "Created /etc/nftables.d/wild-cloud.nft"
fi
chown wildcloud:wildcloud /etc/nftables.d/wild-cloud.nft
chmod 644 /etc/nftables.d/wild-cloud.nft
# Ensure nftables.conf includes wild-cloud rules
if [ -f /etc/nftables.conf ] && ! grep -q 'wild-cloud.nft' /etc/nftables.conf; then
echo 'include "/etc/nftables.d/wild-cloud.nft"' >> /etc/nftables.conf
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-cloud, /usr/bin/cscli bouncers *, /usr/bin/cscli machines *, /usr/bin/cscli decisions *, /usr/bin/cscli alerts *, /usr/bin/cscli version, /usr/bin/wg-quick up wg0, /usr/bin/wg-quick down wg0, /usr/bin/wg show wg0, /usr/bin/certbot *, /usr/sbin/nginx -t, /usr/bin/systemctl reload nginx
SUDOERS_EOF
chmod 440 /etc/sudoers.d/wild-central
echo "Installed sudoers rules for Wild Central"
# Install polkit rules for service management
mkdir -p /etc/polkit-1/rules.d
cat > /etc/polkit-1/rules.d/50-wild-central.rules << 'POLKIT_EOF'
/* Allow wildcloud user to manage Wild Central services */
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
(action.lookup("unit") == "dnsmasq.service" ||
action.lookup("unit") == "systemd-resolved.service" ||
action.lookup("unit") == "haproxy.service" ||
action.lookup("unit") == "wild-central-nftables-reload.service" ||
action.lookup("unit") == "crowdsec.service" ||
action.lookup("unit") == "crowdsec-firewall-bouncer.service" ||
action.lookup("unit") == "wg-quick@wg0.service" ||
action.lookup("unit") == "cloudflared.service" ||
action.lookup("unit") == "authelia.service" ||
action.lookup("unit") == "wild-central.service") &&
subject.user == "wildcloud") {
return polkit.Result.YES;
}
});
POLKIT_EOF
chmod 644 /etc/polkit-1/rules.d/50-wild-central.rules
echo "Installed polkit rules for service management"
# Enable nginx site (disable default site, enable wild-central)
if [ -f /etc/nginx/sites-enabled/default ]; then
rm -f /etc/nginx/sites-enabled/default
echo "Disabled default nginx site"
fi
if [ ! -L /etc/nginx/sites-enabled/wild-central ]; then
ln -sf /etc/nginx/sites-available/wild-central /etc/nginx/sites-enabled/wild-central
echo "Enabled wild-central nginx site"
fi
# Reload nginx to pick up the new site
if systemctl is-active --quiet nginx; then
nginx -t && systemctl reload nginx
echo "Reloaded nginx configuration"
fi
# Enable and start the service
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
systemctl enable wild-central.service
echo "wild-central configured successfully"
echo "Start the service with: sudo systemctl start wild-central"
echo "View logs with: sudo journalctl -u wild-central -f"
else
echo "Note: systemd not detected (container environment?), skipping service enable"
echo "wild-central installed successfully"
fi
;;
abort-upgrade|abort-remove|abort-deconfigure)
;;
*)
echo "postinst called with unknown argument \`$1'" >&2
exit 1
;;
esac
exit 0

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

@@ -0,0 +1,138 @@
#!/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-central"
DIST="${DIST:-stable}"
COMPONENT="main"
PACKAGE_DIR="dist/packages"
SNAP_NAME="${REPO_NAME}-${VERSION}"
AUTH="${APTLY_USER}:${APTLY_PASS}"
echo "Deploying Wild Central ${VERSION} to APT repository..."
echo " URL: ${APTLY_URL}"
# Signing config
if [ -n "$APTLY_GPG_KEY" ]; then
SIGNING="{\"GpgKey\":\"${APTLY_GPG_KEY}\"}"
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}"/wild-central_"${VERSION}"_*.deb; 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}/wild-central.gpg \\"
echo " | sudo tee /usr/share/keyrings/wild-central.gpg > /dev/null"
echo ""
echo " sudo tee /etc/apt/sources.list.d/wild-central.sources <<EOF"
echo " Types: deb"
echo " URIs: ${APTLY_URL}"
echo " Suites: ${DIST}"
echo " Components: ${COMPONENT}"
echo " Signed-By: /usr/share/keyrings/wild-central.gpg"
echo " EOF"
echo ""
echo " sudo apt update && sudo apt install wild-central"

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

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

2
go.mod
View File

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

View File

@@ -8,6 +8,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"syscall" "syscall"
"time" "time"
@@ -16,10 +17,12 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"gopkg.in/yaml.v3" "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/certbot"
"github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/crowdsec" "github.com/wild-cloud/wild-central/internal/crowdsec"
"github.com/wild-cloud/wild-central/internal/ddns" "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/dnsmasq"
"github.com/wild-cloud/wild-central/internal/haproxy" "github.com/wild-cloud/wild-central/internal/haproxy"
"github.com/wild-cloud/wild-central/internal/network" "github.com/wild-cloud/wild-central/internal/network"
@@ -44,9 +47,12 @@ type API struct {
crowdsec *crowdsec.Manager crowdsec *crowdsec.Manager
vpn *wireguard.Manager vpn *wireguard.Manager
certbot *certbot.Manager certbot *certbot.Manager
domains *domains.Manager // Domain registration manager authelia *authelia.Manager // Authelia authentication service manager
sseManager *sse.Manager // SSE manager for real-time events dnsFilter *dnsfilter.Manager // DNS filtering manager
port int // Running API port (for config-driven routes) 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 // NewAPI creates a new Central API handler with all dependencies
@@ -71,10 +77,18 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
crowdsec: crowdsec.NewManager(), crowdsec: crowdsec.NewManager(),
vpn: wireguard.NewManager(dataDir, vpnConfigPath), vpn: wireguard.NewManager(dataDir, vpnConfigPath),
certbot: certbot.NewManager(""), certbot: certbot.NewManager(""),
authelia: authelia.NewManager(filepath.Join(dataDir, "authelia")),
dnsFilter: dnsfilter.NewManager(filepath.Join(dataDir, "dns-filter")),
domains: domains.NewManager(dataDir), domains: domains.NewManager(dataDir),
sseManager: sseManager, 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"))
// Wire up domain registration reconciliation: when domains change, // Wire up domain registration reconciliation: when domains change,
// regenerate dnsmasq DNS entries and HAProxy routes. // regenerate dnsmasq DNS entries and HAProxy routes.
api.domains.SetReconcileFn(api.reconcileNetworking) api.domains.SetReconcileFn(api.reconcileNetworking)
@@ -109,6 +123,30 @@ func (api *API) StartDDNS(ctx gocontext.Context) {
api.ddns.Start(ctx, api.ddnsParams) 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("dns-filter: failed to reload dnsmasq after update", "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. // ddnsParams returns the current DDNS parameters derived from runtime state.
// Called by the DDNS runner on each tick — always returns fresh values. // Called by the DDNS runner on each tick — always returns fresh values.
func (api *API) ddnsParams() ddns.Params { func (api *API) ddnsParams() ddns.Params {
@@ -256,6 +294,35 @@ func (api *API) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareGetZoneID).Methods("GET") r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareGetZoneID).Methods("GET")
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareSetZoneID).Methods("POST") 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 // Domain registration — domain is the unique key
r.HandleFunc("/api/v1/domains", api.DomainsListAll).Methods("GET") r.HandleFunc("/api/v1/domains", api.DomainsListAll).Methods("GET")
r.HandleFunc("/api/v1/domains", api.DomainsRegister).Methods("POST") r.HandleFunc("/api/v1/domains", api.DomainsRegister).Methods("POST")
@@ -329,24 +396,98 @@ func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime
}) })
} }
// getDaemonStatus checks whether each managed daemon is active. // getDaemonStatus checks whether each managed daemon is active and gets its version.
func getDaemonStatus() map[string]map[string]bool { func getDaemonStatus() map[string]map[string]any {
daemons := map[string]string{ type daemonInfo struct {
"dnsmasq": "dnsmasq.service", unit string
"haproxy": "haproxy.service", verCmd []string // command to get version
"wireguard": "wg-quick@wg0.service", verParse func(string) string // extract version from output
"crowdsec": "crowdsec.service",
} }
result := make(map[string]map[string]bool, len(daemons)+1) firstWord := func(s string) string {
for name, unit := range daemons { if i := strings.IndexByte(s, ' '); i > 0 {
err := exec.Command("systemctl", "is-active", "--quiet", unit).Run() return s[:i]
result[name] = map[string]bool{"active": err == nil} }
return strings.TrimSpace(s)
}
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-cloud table exists // nftables has no persistent service — check if the wild-cloud table exists
err := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run() nftErr := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run()
result["nftables"] = map[string]bool{"active": err == nil} 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
return result return result
} }

View File

@@ -0,0 +1,492 @@
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)
}
// Enabling Authelia
if state.Cloud.Authelia.Enabled {
if !api.authelia.IsInstalled() {
respondError(w, http.StatusBadRequest, "Authelia is not installed. Install it first (apt install authelia).")
return
}
if state.Cloud.Authelia.Domain == "" {
respondError(w, http.StatusBadRequest, "Auth portal domain is required when enabling Authelia")
return
}
if !api.authelia.HasLuaScript() {
respondError(w, http.StatusBadRequest,
"HAProxy auth-request Lua script not found at /etc/haproxy/lua/haproxy-auth-request.lua. "+
"Install it: sudo mkdir -p /etc/haproxy/lua && sudo curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua "+
"https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua")
return
}
// Ensure data directory exists
if err := api.authelia.EnsureDataDir(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create data directory: %v", err))
return
}
// Auto-generate secrets if missing
if err := api.ensureAutheliaSecrets(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate secrets: %v", err))
return
}
// Generate JWKS key pair for OIDC
if err := api.authelia.EnsureJWKS(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate JWKS: %v", err))
return
}
// Ensure user database exists
if err := api.authelia.EnsureUsersDB(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to initialize user database: %v", err))
return
}
// Generate config
if err := api.generateAutheliaConfig(state); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate config: %v", err))
return
}
// Register auth portal domain
api.ensureAuthDomain(state.Cloud.Authelia.Domain)
// Start service — requires at least one user
startErr := error(nil)
if api.authelia.UserCount() > 0 {
startErr = api.authelia.RestartService()
}
// Save state and reconcile regardless of whether the service started
if err := config.SaveState(state, api.statePath()); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
go api.reconcileNetworking()
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
if startErr != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Configuration saved but Authelia failed to start: %v", startErr))
return
}
} else {
// Disabling — stop service and deregister domain
_ = api.authelia.StopService()
api.deregisterAuthDomain()
if err := config.SaveState(state, api.statePath()); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
go api.reconcileNetworking()
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
}
respondMessage(w, http.StatusOK, "Authelia configuration updated")
}
// AutheliaRestart restarts the Authelia service
func (api *API) AutheliaRestart(w http.ResponseWriter, r *http.Request) {
if err := api.authelia.RestartService(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to restart authelia: %v", err))
return
}
api.broadcastAutheliaEvent("authelia:restart", "Authelia service restarted")
respondMessage(w, http.StatusOK, "Authelia restarted")
}
// --- User management ---
// AutheliaListUsers returns all Authelia users (passwords omitted)
func (api *API) AutheliaListUsers(w http.ResponseWriter, r *http.Request) {
users, err := api.authelia.ListUsers()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list users: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"users": users})
}
// AutheliaCreateUser creates a new Authelia user
func (api *API) AutheliaCreateUser(w http.ResponseWriter, r *http.Request) {
var user authelia.User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := api.authelia.CreateUser(user); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to create user: %v", err))
return
}
slog.Info("authelia user created", "username", user.Username)
// If Authelia is enabled but not running (was waiting for first user), start it
state, _ := config.LoadState(api.statePath())
if state != nil && state.Cloud.Authelia.Enabled {
status, _ := api.authelia.GetStatus()
if status != nil && !status.Active {
if err := api.authelia.RestartService(); err != nil {
slog.Warn("failed to start authelia after first user", "error", err)
}
}
}
api.broadcastAutheliaEvent("authelia:user", fmt.Sprintf("User %q created", user.Username))
respondMessage(w, http.StatusCreated, fmt.Sprintf("User %q created", user.Username))
}
// AutheliaUpdateUser updates an existing Authelia user
func (api *API) AutheliaUpdateUser(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
var updates authelia.UserUpdate
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := api.authelia.UpdateUser(username, updates); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to update user: %v", err))
return
}
slog.Info("authelia user updated", "username", username)
respondMessage(w, http.StatusOK, fmt.Sprintf("User %q updated", username))
}
// AutheliaDeleteUser deletes an Authelia user
func (api *API) AutheliaDeleteUser(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
if err := api.authelia.DeleteUser(username); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to delete user: %v", err))
return
}
slog.Info("authelia user deleted", "username", username)
api.broadcastAutheliaEvent("authelia:user", fmt.Sprintf("User %q deleted", username))
respondMessage(w, http.StatusOK, fmt.Sprintf("User %q deleted", username))
}
// --- OIDC client management ---
// AutheliaListOIDCClients returns all registered OIDC clients (secrets redacted)
func (api *API) AutheliaListOIDCClients(w http.ResponseWriter, r *http.Request) {
clients, err := api.authelia.ListOIDCClients()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list OIDC clients: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"clients": clients})
}
// AutheliaCreateOIDCClient creates a new OIDC client and returns the secret once
func (api *API) AutheliaCreateOIDCClient(w http.ResponseWriter, r *http.Request) {
var client authelia.OIDCClient
if err := json.NewDecoder(r.Body).Decode(&client); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
secret, err := api.authelia.AddOIDCClient(client)
if err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to create OIDC client: %v", err))
return
}
// Regenerate full config (adds OIDC section) and restart
if err := api.regenAndRestartAuthelia(); err != nil {
slog.Warn("failed to restart authelia after OIDC client create", "error", err)
}
slog.Info("authelia OIDC client created", "clientId", client.ID)
api.broadcastAutheliaEvent("authelia:oidc", fmt.Sprintf("OIDC client %q created", client.ID))
respondJSON(w, http.StatusCreated, map[string]any{
"clientId": client.ID,
"clientSecret": secret,
"message": "OIDC client created. Save the client secret — it will not be shown again.",
})
}
// AutheliaUpdateOIDCClient updates an OIDC client
func (api *API) AutheliaUpdateOIDCClient(w http.ResponseWriter, r *http.Request) {
clientID := mux.Vars(r)["clientId"]
var updates authelia.OIDCClientUpdate
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := api.authelia.UpdateOIDCClient(clientID, updates); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to update OIDC client: %v", err))
return
}
if err := api.regenAndRestartAuthelia(); err != nil {
slog.Warn("failed to restart authelia after OIDC client update", "error", err)
}
slog.Info("authelia OIDC client updated", "clientId", clientID)
respondMessage(w, http.StatusOK, fmt.Sprintf("OIDC client %q updated", clientID))
}
// AutheliaDeleteOIDCClient deletes an OIDC client
func (api *API) AutheliaDeleteOIDCClient(w http.ResponseWriter, r *http.Request) {
clientID := mux.Vars(r)["clientId"]
if err := api.authelia.DeleteOIDCClient(clientID); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to delete OIDC client: %v", err))
return
}
if err := api.regenAndRestartAuthelia(); err != nil {
slog.Warn("failed to restart authelia after OIDC client delete", "error", err)
}
slog.Info("authelia OIDC client deleted", "clientId", clientID)
api.broadcastAutheliaEvent("authelia:oidc", fmt.Sprintf("OIDC client %q deleted", clientID))
respondMessage(w, http.StatusOK, fmt.Sprintf("OIDC client %q deleted", clientID))
}
// --- Internal helpers ---
// regenAndRestartAuthelia regenerates the full Authelia config and restarts the service.
// Used after OIDC client changes to ensure the config includes the OIDC section.
func (api *API) regenAndRestartAuthelia() error {
state, err := config.LoadState(api.statePath())
if err != nil || !state.Cloud.Authelia.Enabled {
return nil
}
if err := api.generateAutheliaConfig(state); err != nil {
return fmt.Errorf("regenerating config: %w", err)
}
if api.authelia.UserCount() > 0 {
return api.authelia.RestartService()
}
return nil
}
// ensureAutheliaSecrets generates Authelia secrets if they don't already exist
func (api *API) ensureAutheliaSecrets() error {
keys := []string{
"authelia.jwtSecret",
"authelia.sessionSecret",
"authelia.storageEncryptionKey",
"authelia.oidcHmacSecret",
}
for _, key := range keys {
existing, _ := api.secrets.GetSecret(key)
if existing != "" {
continue
}
secret, err := authelia.GenerateSecret(32)
if err != nil {
return fmt.Errorf("generating %s: %w", key, err)
}
if err := api.secrets.SetSecret(key, secret); err != nil {
return fmt.Errorf("saving %s: %w", key, err)
}
}
return nil
}
// generateAutheliaConfig reads secrets and state, then generates the Authelia config
func (api *API) generateAutheliaConfig(state *config.State) error {
jwtSecret, _ := api.secrets.GetSecret("authelia.jwtSecret")
sessionSecret, _ := api.secrets.GetSecret("authelia.sessionSecret")
storageEncKey, _ := api.secrets.GetSecret("authelia.storageEncryptionKey")
oidcHmac, _ := api.secrets.GetSecret("authelia.oidcHmacSecret")
// Read OIDC clients from the clients file (with secrets for config generation)
clients, _ := api.authelia.ReadOIDCClients()
defaultPolicy := state.Cloud.Authelia.DefaultPolicy
if defaultPolicy == "" {
defaultPolicy = "one_factor"
}
sessionDomain := authelia.SessionDomainFromAuthDomain(state.Cloud.Authelia.Domain)
smtpPassword, _ := api.secrets.GetSecret("authelia.smtpPassword")
opts := authelia.ConfigOpts{
Domain: state.Cloud.Authelia.Domain,
JWTSecret: jwtSecret,
SessionSecret: sessionSecret,
StorageEncKey: storageEncKey,
OIDCHMACSecret: oidcHmac,
DefaultPolicy: defaultPolicy,
SessionDomain: sessionDomain,
OIDCClients: clients,
SMTPHost: state.Cloud.Authelia.SMTP.Host,
SMTPPort: state.Cloud.Authelia.SMTP.Port,
SMTPUsername: state.Cloud.Authelia.SMTP.Username,
SMTPSender: state.Cloud.Authelia.SMTP.Sender,
SMTPPassword: smtpPassword,
}
return api.authelia.GenerateConfig(opts)
}
// ensureAuthDomain registers the Authelia login portal as a domain
func (api *API) ensureAuthDomain(authDomain string) {
if authDomain == "" {
return
}
// Clean up stale authelia registrations
doms, _ := api.domains.List()
for _, dom := range doms {
if dom.Source == "authelia" && dom.DomainName != authDomain {
_ = api.domains.Deregister(dom.DomainName)
}
}
_ = api.domains.Register(domains.Domain{
DomainName: authDomain,
Source: "authelia",
Routes: []domains.Route{
{
Backend: domains.Backend{
Address: "127.0.0.1:9091",
Type: domains.BackendHTTP,
},
Headers: &domains.HeaderConfig{
Request: map[string]string{
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": authDomain,
},
},
},
},
TLS: domains.TLSTerminate,
})
}
// deregisterAuthDomain removes all authelia-sourced domain registrations
func (api *API) deregisterAuthDomain() {
doms, _ := api.domains.List()
for _, dom := range doms {
if dom.Source == "authelia" {
_ = api.domains.Deregister(dom.DomainName)
}
}
}

View File

@@ -2,7 +2,9 @@ package v1
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings"
) )
type cloudflareZone struct { type cloudflareZone struct {
@@ -10,12 +12,19 @@ type cloudflareZone struct {
Name string `json:"name"` 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 { type cloudflareVerifyResponse struct {
TokenConfigured bool `json:"tokenConfigured"` TokenConfigured bool `json:"tokenConfigured"`
TokenValid bool `json:"tokenValid"` TokenValid bool `json:"tokenValid"`
TokenStatus string `json:"tokenStatus"` TokenStatus string `json:"tokenStatus"`
Zones []cloudflareZone `json:"zones"` Zones []cloudflareZone `json:"zones"`
Error string `json:"error,omitempty"` RequiredZones []string `json:"requiredZones"`
Permissions cloudflarePermissions `json:"permissions"`
Error string `json:"error,omitempty"`
} }
// CloudflareVerify checks whether the stored Cloudflare API token is valid // 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 return
} }
resp.Zones = zones 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) 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") 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) { func verifyCloudflareToken(apiToken string) (string, error) {
req, err := http.NewRequest(http.MethodGet, req, err := http.NewRequest(http.MethodGet,
"https://api.cloudflare.com/client/v4/user/tokens/verify", nil) "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 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) { func listCloudflareZones(apiToken string) ([]cloudflareZone, error) {
req, err := http.NewRequest(http.MethodGet, req, err := http.NewRequest(http.MethodGet,
"https://api.cloudflare.com/client/v4/zones", nil) "https://api.cloudflare.com/client/v4/zones", nil)

View File

@@ -0,0 +1,351 @@
package v1
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/dnsfilter"
)
// DNSFilterStatus returns the current DNS filter status and stats.
func (api *API) DNSFilterStatus(w http.ResponseWriter, r *http.Request) {
state, err := config.LoadState(api.statePath())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load state")
return
}
stats := api.dnsFilter.GetStats()
stats.Enabled = state.Cloud.DNSFilter.Enabled
if stats.Enabled {
stats.QueryStats = api.dnsFilter.GetQueryStats()
}
respondJSON(w, http.StatusOK, stats)
}
// DNSFilterGetConfig returns the DNS filter configuration from state.yaml.
func (api *API) DNSFilterGetConfig(w http.ResponseWriter, r *http.Request) {
state, err := config.LoadState(api.statePath())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load state")
return
}
respondJSON(w, http.StatusOK, state.Cloud.DNSFilter)
}
// DNSFilterUpdateConfig enables/disables DNS filtering and updates settings.
func (api *API) DNSFilterUpdateConfig(w http.ResponseWriter, r *http.Request) {
var req struct {
Enabled *bool `json:"enabled"`
IntervalHours *int `json:"intervalHours"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
state, err := config.LoadState(api.statePath())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load state")
return
}
if req.Enabled != nil {
state.Cloud.DNSFilter.Enabled = *req.Enabled
}
if req.IntervalHours != nil {
state.Cloud.DNSFilter.IntervalHours = *req.IntervalHours
}
if err := config.SaveState(state, api.statePath()); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
if state.Cloud.DNSFilter.Enabled {
if err := api.dnsFilter.EnsureDataDir(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create data dir: %v", err))
return
}
// Start or restart the runner
interval := state.Cloud.DNSFilter.IntervalHours
if interval <= 0 {
interval = 24
}
api.dnsFilterRunner.Start(api.ctx, interval)
slog.Info("dns-filter: enabled", "interval", interval)
} else {
// Stop the runner and clear addn-hosts
api.dnsFilterRunner.Stop()
api.dnsmasq.SetFilterConfPath("")
slog.Info("dns-filter: disabled")
}
// Reconcile to add/remove addn-hosts from dnsmasq config
go api.reconcileNetworking()
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("dns-filter: recompile after removal failed", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("dns-filter: reload after removal failed", "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("dns-filter: recompile after toggle failed", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("dns-filter: reload after toggle failed", "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("dns-filter: recompile after custom entry failed", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("dns-filter: reload after custom entry failed", "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("dns-filter: recompile after custom removal failed", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("dns-filter: reload after custom removal failed", "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("dns-filter: recompile after upload failed", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("dns-filter: reload after upload failed", "error", err)
}
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter list uploaded")
}()
respondJSON(w, http.StatusCreated, map[string]string{
"id": id,
"message": "List uploaded",
})
}
// DNSFilterGetSuggested returns well-known blocklists for easy adding.
func (api *API) DNSFilterGetSuggested(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{
"lists": dnsfilter.SuggestedLists(),
})
}

View File

@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/wild-cloud/wild-central/internal/authelia"
"github.com/wild-cloud/wild-central/internal/certbot" "github.com/wild-cloud/wild-central/internal/certbot"
"github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/dnsmasq" "github.com/wild-cloud/wild-central/internal/dnsmasq"
@@ -15,6 +16,7 @@ import (
"github.com/wild-cloud/wild-central/internal/haproxy" "github.com/wild-cloud/wild-central/internal/haproxy"
"github.com/wild-cloud/wild-central/internal/network" "github.com/wild-cloud/wild-central/internal/network"
"github.com/wild-cloud/wild-central/internal/sse" "github.com/wild-cloud/wild-central/internal/sse"
"github.com/wild-cloud/wild-central/internal/storage"
) )
// EnsureCentralDomain registers (or updates) Central's own domain as a domain // EnsureCentralDomain registers (or updates) Central's own domain as a domain
@@ -96,13 +98,18 @@ func (api *API) reconcileNetworking() {
case domains.BackendHTTP: case domains.BackendHTTP:
var routeBackends []haproxy.HTTPRouteBackend var routeBackends []haproxy.HTTPRouteBackend
for _, r := range dom.EffectiveRoutes() { for _, r := range dom.EffectiveRoutes() {
routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{ rb := haproxy.HTTPRouteBackend{
Paths: r.Paths, Paths: r.Paths,
Backend: r.Backend.Address, Backend: r.Backend.Address,
HealthPath: r.Backend.Health, HealthPath: r.Backend.Health,
Headers: r.Headers, Headers: r.Headers,
IPAllow: r.IPAllow, IPAllow: r.IPAllow,
}) }
if dom.Auth != nil && dom.Auth.Enabled {
rb.AuthEnabled = true
rb.AuthPolicy = dom.Auth.Policy
}
routeBackends = append(routeBackends, rb)
} }
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName, Name: dom.DomainName,
@@ -143,6 +150,22 @@ func (api *API) reconcileNetworking() {
HTTPRoutes: activeHTTPRoutes, HTTPRoutes: activeHTTPRoutes,
CertsDir: certsDir, CertsDir: certsDir,
} }
// Propagate Authelia forward-auth settings if enabled
if globalCfg.Cloud.Authelia.Enabled && globalCfg.Cloud.Authelia.Domain != "" {
genOpts.AuthEnabled = true
genOpts.AuthBackend = "127.0.0.1:9091"
genOpts.AuthDomain = globalCfg.Cloud.Authelia.Domain
genOpts.AuthSessionDomain = authelia.SessionDomainFromAuthDomain(globalCfg.Cloud.Authelia.Domain)
// Regenerate Authelia config so session cookie domains stay in sync
// with the set of auth-protected domains
if err := api.generateAutheliaConfig(globalCfg); err != nil {
slog.Warn("reconcile: failed to regenerate authelia config", "error", err)
} else if api.authelia.UserCount() > 0 {
_ = api.authelia.RestartService()
}
}
haproxyCfg := api.haproxy.GenerateWithOpts(l4Routes, nil, genOpts) haproxyCfg := api.haproxy.GenerateWithOpts(l4Routes, nil, genOpts)
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil { if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
@@ -224,6 +247,16 @@ func (api *API) reconcileNetworking() {
}) })
} }
// Set addn-hosts path for DNS filtering
if globalCfg.Cloud.DNSFilter.Enabled {
hostsPath := api.dnsFilter.HostsFilePath()
if storage.FileExists(hostsPath) {
api.dnsmasq.SetFilterConfPath(hostsPath)
}
} else {
api.dnsmasq.SetFilterConfPath("")
}
if err := api.dnsmasq.UpdateConfig(globalCfg, dnsEntries, true); err != nil { if err := api.dnsmasq.UpdateConfig(globalCfg, dnsEntries, true); err != nil {
slog.Error("reconcile: failed to update dnsmasq", "error", err) slog.Error("reconcile: failed to update dnsmasq", "error", err)
} else { } else {
@@ -359,6 +392,25 @@ func (api *API) broadcastHaproxyEvent(eventType string, message string) {
api.sseManager.Broadcast(event) 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 // broadcastCentralStatusEvent broadcasts SSE events for central status changes
func (api *API) broadcastCentralStatusEvent(startTime time.Time) { func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
if api.sseManager == nil { if api.sseManager == nil {
@@ -382,3 +434,22 @@ func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
api.sseManager.Broadcast(event) 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)
}

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

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

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

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

View File

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

View File

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

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

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

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

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

View File

@@ -114,6 +114,21 @@ type State struct {
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"` IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"`
} `yaml:"ddns,omitempty" json:"ddns,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"` } `yaml:"cloud,omitempty" json:"cloud,omitempty"`
} }

View File

@@ -0,0 +1,550 @@
package dnsfilter
import (
"bufio"
"context"
"crypto/sha256"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/wild-cloud/wild-central/internal/storage"
"gopkg.in/yaml.v3"
)
// Blocklist represents a subscribed blocklist source.
type Blocklist struct {
ID string `yaml:"id" json:"id"`
URL string `yaml:"url,omitempty" json:"url,omitempty"`
Name string `yaml:"name" json:"name"`
Enabled bool `yaml:"enabled" json:"enabled"`
EntryCount int `yaml:"entryCount,omitempty" json:"entryCount,omitempty"`
LastUpdated time.Time `yaml:"lastUpdated,omitempty" json:"lastUpdated,omitzero"`
LastError string `yaml:"lastError,omitempty" json:"lastError,omitempty"`
}
// CustomEntry represents a user-managed domain override.
type CustomEntry struct {
Domain string `yaml:"domain" json:"domain"`
Type string `yaml:"type" json:"type"` // "allow" or "block"
}
// FilterData holds all DNS filter configuration persisted in lists.yaml.
type FilterData struct {
Lists []Blocklist `yaml:"lists" json:"lists"`
CustomEntries []CustomEntry `yaml:"customEntries" json:"customEntries"`
}
// FilterStats holds current filtering statistics.
type FilterStats struct {
Enabled bool `json:"enabled"`
TotalBlocked int `json:"totalBlocked"`
ListCount int `json:"listCount"`
EnabledLists int `json:"enabledLists"`
LastUpdated time.Time `json:"lastUpdated"`
QueryStats *QueryStats `json:"queryStats,omitempty"`
}
// QueryStats holds DNS query statistics from dnsmasq logs.
type QueryStats struct {
TotalQueries int `json:"totalQueries"`
BlockedQueries int `json:"blockedQueries"`
BlockedPercent float64 `json:"blockedPercent"`
Period string `json:"period"` // e.g. "24h"
}
// Manager handles DNS filter list management and compilation.
type Manager struct {
dataDir string // e.g. /var/lib/wild-central/dns-filter
hostsFilePath string // compiled output path (must be readable by dnsmasq)
}
// NewManager creates a new DNS filter manager.
func NewManager(dataDir string) *Manager {
return &Manager{
dataDir: dataDir,
hostsFilePath: filepath.Join(dataDir, "blocked.conf"), // default, override with SetHostsFilePath
}
}
// SetHostsFilePath sets the output path for the compiled hosts file.
// Must be readable by the dnsmasq process (e.g. /etc/dnsmasq.d/).
func (m *Manager) SetHostsFilePath(path string) {
m.hostsFilePath = path
}
func (m *Manager) listsPath() string { return filepath.Join(m.dataDir, "lists.yaml") }
func (m *Manager) cachePath() string { return filepath.Join(m.dataDir, "cache") }
func (m *Manager) lockPath() string { return filepath.Join(m.dataDir, "lists.yaml.lock") }
// HostsFilePath returns the path to the compiled blocked hosts file.
func (m *Manager) HostsFilePath() string { return m.hostsFilePath }
// EnsureDataDir creates the dns-filter directory structure.
func (m *Manager) EnsureDataDir() error {
if err := storage.EnsureDir(m.dataDir, 0755); err != nil {
return err
}
// Also ensure the hosts file output directory exists (may differ from dataDir)
hostsDir := filepath.Dir(m.hostsFilePath)
if hostsDir != m.dataDir {
if err := storage.EnsureDir(hostsDir, 0755); err != nil {
return err
}
}
return storage.EnsureDir(m.cachePath(), 0755)
}
// LoadFilterData reads lists.yaml. Returns empty data if file doesn't exist.
func (m *Manager) LoadFilterData() (*FilterData, error) {
data, err := os.ReadFile(m.listsPath())
if err != nil {
if os.IsNotExist(err) {
return &FilterData{}, nil
}
return nil, fmt.Errorf("reading filter data: %w", err)
}
var fd FilterData
if err := yaml.Unmarshal(data, &fd); err != nil {
return nil, fmt.Errorf("parsing filter data: %w", err)
}
return &fd, nil
}
// SaveFilterData writes lists.yaml atomically with file locking.
func (m *Manager) SaveFilterData(fd *FilterData) error {
return storage.WithLock(m.lockPath(), func() error {
data, err := yaml.Marshal(fd)
if err != nil {
return fmt.Errorf("marshaling filter data: %w", err)
}
return os.WriteFile(m.listsPath(), data, 0644)
})
}
// listID returns a stable ID for a URL-based list.
func listID(url string) string {
h := sha256.Sum256([]byte(url))
return fmt.Sprintf("%x", h[:8])
}
// cacheFile returns the cache file path for a list by ID.
func (m *Manager) cacheFile(id string) string {
return filepath.Join(m.cachePath(), id+".txt")
}
// AddList adds a URL-based blocklist subscription.
func (m *Manager) AddList(bl Blocklist) error {
fd, err := m.LoadFilterData()
if err != nil {
return err
}
if bl.URL != "" {
bl.ID = listID(bl.URL)
}
if bl.ID == "" {
return fmt.Errorf("list must have a URL or ID")
}
// Deduplicate
for _, existing := range fd.Lists {
if existing.ID == bl.ID {
return fmt.Errorf("list already exists: %s", bl.Name)
}
}
bl.Enabled = true
fd.Lists = append(fd.Lists, bl)
return m.SaveFilterData(fd)
}
// AddUploadedList saves an uploaded blocklist file and adds it as a list entry.
func (m *Manager) AddUploadedList(name string, content []byte) (string, error) {
if err := m.EnsureDataDir(); err != nil {
return "", err
}
h := sha256.Sum256(content)
id := fmt.Sprintf("upload-%x", h[:8])
// Write content to cache
if err := os.WriteFile(m.cacheFile(id), content, 0644); err != nil {
return "", fmt.Errorf("writing uploaded list: %w", err)
}
// Count entries
domains, _ := ParseFile(m.cacheFile(id))
bl := Blocklist{
ID: id,
Name: name,
Enabled: true,
EntryCount: len(domains),
LastUpdated: time.Now(),
}
fd, err := m.LoadFilterData()
if err != nil {
return "", err
}
// Deduplicate
for _, existing := range fd.Lists {
if existing.ID == id {
return id, fmt.Errorf("identical list already uploaded")
}
}
fd.Lists = append(fd.Lists, bl)
return id, m.SaveFilterData(fd)
}
// RemoveList removes a list by ID and its cached file.
func (m *Manager) RemoveList(id string) error {
fd, err := m.LoadFilterData()
if err != nil {
return err
}
found := false
filtered := fd.Lists[:0]
for _, bl := range fd.Lists {
if bl.ID == id {
found = true
_ = os.Remove(m.cacheFile(id))
continue
}
filtered = append(filtered, bl)
}
if !found {
return fmt.Errorf("list not found: %s", id)
}
fd.Lists = filtered
return m.SaveFilterData(fd)
}
// ToggleList enables or disables a list by ID.
func (m *Manager) ToggleList(id string, enabled bool) error {
fd, err := m.LoadFilterData()
if err != nil {
return err
}
for i := range fd.Lists {
if fd.Lists[i].ID == id {
fd.Lists[i].Enabled = enabled
return m.SaveFilterData(fd)
}
}
return fmt.Errorf("list not found: %s", id)
}
// SetCustomEntry adds or replaces a custom allow/block entry.
func (m *Manager) SetCustomEntry(entry CustomEntry) error {
if entry.Type != "allow" && entry.Type != "block" {
return fmt.Errorf("type must be 'allow' or 'block'")
}
fd, err := m.LoadFilterData()
if err != nil {
return err
}
// Replace if exists
for i := range fd.CustomEntries {
if fd.CustomEntries[i].Domain == entry.Domain {
fd.CustomEntries[i].Type = entry.Type
return m.SaveFilterData(fd)
}
}
fd.CustomEntries = append(fd.CustomEntries, entry)
return m.SaveFilterData(fd)
}
// RemoveCustomEntry removes a custom entry by domain.
func (m *Manager) RemoveCustomEntry(domain string) error {
fd, err := m.LoadFilterData()
if err != nil {
return err
}
found := false
filtered := fd.CustomEntries[:0]
for _, e := range fd.CustomEntries {
if e.Domain == domain {
found = true
continue
}
filtered = append(filtered, e)
}
if !found {
return fmt.Errorf("custom entry not found: %s", domain)
}
fd.CustomEntries = filtered
return m.SaveFilterData(fd)
}
// UpdateLists downloads all enabled URL-based lists to cache.
func (m *Manager) UpdateLists(ctx context.Context) error {
if err := m.EnsureDataDir(); err != nil {
return err
}
fd, err := m.LoadFilterData()
if err != nil {
return err
}
client := &http.Client{Timeout: 60 * time.Second}
changed := false
for i := range fd.Lists {
bl := &fd.Lists[i]
if !bl.Enabled || bl.URL == "" {
continue
}
slog.Info("dns-filter: downloading list", "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("dns-filter: download failed", "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("dns-filter: download failed", "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("dns-filter: failed to parse list", "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("dns-filter: compiled blocklist", "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("dns-filter: failed to read dnsmasq journal", "error", err)
return nil
}
var total, blocked int
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "query[") {
total++
}
// dnsmasq logs address=/ blocks as: "config <domain> is NXDOMAIN"
// and addn-hosts blocks as: "/path/file <domain> is 0.0.0.0"
if strings.HasPrefix(line, "config ") && strings.Contains(line, " is NXDOMAIN") {
blocked++
}
}
var pct float64
if total > 0 {
pct = float64(blocked) / float64(total) * 100
}
return &QueryStats{
TotalQueries: total,
BlockedQueries: blocked,
BlockedPercent: pct,
Period: "24h",
}
}
// SuggestedLists returns well-known blocklists for easy adding.
// Prefers dnsmasq-format lists which support wildcard subdomain blocking.
func SuggestedLists() []Blocklist {
type entry struct {
url string
name string
}
lists := []entry{
{"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", "Steven Black - Unified Hosts"},
{"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/dnsmasq/light.txt", "Hagezi - Light"},
{"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/dnsmasq/pro.txt", "Hagezi - Pro"},
{"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/dnsmasq/ultimate.txt", "Hagezi - Ultimate"},
{"https://big.oisd.nl/dnsmasq2", "OISD - Big"},
{"https://small.oisd.nl/dnsmasq2", "OISD - Small"},
}
result := make([]Blocklist, len(lists))
for i, l := range lists {
result[i] = Blocklist{ID: listID(l.url), URL: l.url, Name: l.name}
}
return result
}

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,8 @@ type DNSEntry struct {
// ConfigGenerator handles dnsmasq configuration generation // ConfigGenerator handles dnsmasq configuration generation
type ConfigGenerator struct { type ConfigGenerator struct {
configPath string configPath string
filterConfPath string // path to addn-hosts file for DNS filtering
} }
// NewConfigGenerator creates a new dnsmasq config generator // NewConfigGenerator creates a new dnsmasq config generator
@@ -44,6 +45,12 @@ func (g *ConfigGenerator) GetConfigPath() string {
return g.configPath return g.configPath
} }
// SetFilterConfPath sets the path to an additional hosts file for DNS filtering.
// When set, the generated config includes an addn-hosts= directive.
func (g *ConfigGenerator) SetFilterConfPath(path string) {
g.filterConfPath = path
}
// Generate creates a dnsmasq configuration from registered domain entries. // Generate creates a dnsmasq configuration from registered domain entries.
// It auto-detects the Wild Central server's IP address for the listen directive. // 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 *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string {
@@ -94,10 +101,16 @@ log-queries
log-dhcp log-dhcp
` `
return fmt.Sprintf(template, result := fmt.Sprintf(template,
dnsIP, dnsIP,
resolution_section, 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). // RestartService reloads the dnsmasq service (SIGHUP — no downtime).
@@ -270,6 +283,10 @@ log-dhcp
} }
} }
if g.filterConfPath != "" {
fmt.Fprintf(&sb, "\n# DNS Filtering\nconf-file=%s\n", g.filterConfPath)
}
return sb.String() return sb.String()
} }

View File

@@ -313,6 +313,43 @@ func TestGenerate_MultipleEntries(t *testing.T) {
} }
} }
func TestGenerate_ConfFile(t *testing.T) {
g := NewConfigGenerator("/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 := NewConfigGenerator("/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 := NewConfigGenerator("/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 // Test: nil config doesn't panic, uses auto-detect for IP
func TestGenerate_NilConfig(t *testing.T) { func TestGenerate_NilConfig(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")

View File

@@ -58,6 +58,13 @@ type Route struct {
IPAllow []string `yaml:"ipAllow,omitempty" json:"ipAllow,omitempty"` // per-route CIDR whitelist 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. // Domain represents a registered domain. The Domain field is the unique key.
// //
// Simple domains use Backend directly. Multi-backend domains use Routes // Simple domains use Backend directly. Multi-backend domains use Routes
@@ -69,6 +76,7 @@ type Domain struct {
Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain 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 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 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. // Multi-backend path routing. When present, Backend is ignored.
// Each route maps path prefixes to a specific backend with its own L7 options. // Each route maps path prefixes to a specific backend with its own L7 options.
@@ -354,6 +362,21 @@ func (m *Manager) Update(domain string, updates map[string]any) error {
if tls, ok := updates["tls"].(string); ok { if tls, ok := updates["tls"].(string); ok {
dom.TLS = TLSMode(tls) 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) return m.Register(*dom)
} }

View File

@@ -39,6 +39,8 @@ type HTTPRouteBackend struct {
HealthPath string // optional health check path HealthPath string // optional health check path
Headers *domains.HeaderConfig // per-route headers Headers *domains.HeaderConfig // per-route headers
IPAllow []string // per-route CIDR whitelist 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. // HTTPRoute represents an L7 HTTP reverse proxy route.
@@ -80,8 +82,12 @@ func (m *Manager) GetConfigPath() string {
// GenerateOpts holds optional parameters for HAProxy config generation. // GenerateOpts holds optional parameters for HAProxy config generation.
type GenerateOpts struct { type GenerateOpts struct {
HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central) HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central)
CertsDir string // directory containing per-domain PEM files for L7 TLS 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. // GenerateWithOpts creates a complete HAProxy configuration with full options.
@@ -97,7 +103,13 @@ global
stats timeout 30s stats timeout 30s
maxconn 50000 maxconn 50000
daemon daemon
`)
if opts.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 defaults
log global log global
mode tcp mode tcp
@@ -284,7 +296,48 @@ frontend http_in
} }
} }
sb.WriteString("\n") // Authelia forward-auth directives for protected routes
if opts.AuthEnabled && opts.AuthBackend != "" {
authDomainACL := ""
if opts.AuthDomain != "" {
authDomainACL = "host_" + sanitizeName(opts.AuthDomain)
}
for _, httpRoute := range opts.HTTPRoutes {
hostACL := "host_" + sanitizeName(httpRoute.Name)
// Skip auth portal domain (would cause infinite redirect)
if hostACL == authDomainACL {
continue
}
// Skip domains outside the auth session scope — forward-auth
// requires shared cookies, which only work within the same
// parent domain. Other domains should use OIDC instead.
if opts.AuthSessionDomain != "" &&
httpRoute.Domain != opts.AuthSessionDomain &&
!strings.HasSuffix(httpRoute.Domain, "."+opts.AuthSessionDomain) {
continue
}
// Check if any route on this domain has auth enabled
hasAuth := false
for _, rb := range httpRoute.Routes {
if rb.AuthEnabled {
hasAuth = true
break
}
}
if !hasAuth {
continue
}
fmt.Fprintf(&sb, " # auth: %s\n", httpRoute.Domain)
fmt.Fprintf(&sb, " http-request lua.auth-request be_authelia /api/authz/forward-auth if %s\n", hostACL)
fmt.Fprintf(&sb, " http-request redirect location https://%s/?rd=https://%%[hdr(host)]%%[path] if %s !{ var(txn.auth_response_successful) -m bool }\n", opts.AuthDomain, hostACL)
}
sb.WriteString("\n")
}
// Routing rules: path-specific first, then catch-all // Routing rules: path-specific first, then catch-all
for _, httpRoute := range opts.HTTPRoutes { for _, httpRoute := range opts.HTTPRoutes {
@@ -333,6 +386,15 @@ frontend http_in
} }
} }
// Authelia backend for forward-auth
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")
}
for _, route := range custom { for _, route := range custom {
fmt.Fprintf(&sb, "# Custom route: %s\n", route.Name) fmt.Fprintf(&sb, "# Custom route: %s\n", route.Name)
fmt.Fprintf(&sb, "frontend fe_%s\n", sanitizeName(route.Name)) fmt.Fprintf(&sb, "frontend fe_%s\n", sanitizeName(route.Name))

View File

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

View File

@@ -123,6 +123,7 @@ func main() {
slog.Info("central status broadcaster started") slog.Info("central status broadcaster started")
api.StartDDNS(ctx) api.StartDDNS(ctx)
api.StartDNSFilter(ctx)
router := mux.NewRouter() router := mux.NewRouter()
api.RegisterRoutes(router) api.RegisterRoutes(router)

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 361 B

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

@@ -1,5 +1,7 @@
import { useState, useEffect } from 'react';
import { NavLink } from 'react-router'; 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, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -17,10 +19,37 @@ import {
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useCentralStatus } from '../hooks/useCentralStatus'; 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() { export function AppSidebar() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { state } = useSidebar(); const { state } = useSidebar();
const { data: centralStatus } = useCentralStatus(); 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 = () => { const cycleTheme = () => {
if (theme === 'light') { if (theme === 'light') {
@@ -55,23 +84,26 @@ export function AppSidebar() {
}; };
const domainsItems = [ const domainsItems = [
{ to: '/central', icon: LayoutDashboard, label: 'Dashboard', end: true }, { to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
{ to: '/central/domains', icon: Globe, label: 'Domains' }, { to: '/domains', icon: Globe, label: 'Domains' },
]; ];
const centralItems = [ const centralItems = [
{ to: '/central/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const }, { to: '/auth', icon: KeyRound, label: 'Authentication', daemon: 'authelia' as const },
{ to: '/central/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const }, { to: '/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const },
{ to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const }, { to: '/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const },
{ to: '/central/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' 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 = [ const advancedItems = [
{ to: '/central/advanced/haproxy', icon: Network, label: 'HAProxy', daemon: 'haproxy' as const }, { to: '/advanced/haproxy', icon: Network, label: 'HAProxy', daemon: 'haproxy' as const },
{ to: '/central/advanced/dnsmasq', icon: Wifi, label: 'dnsmasq', daemon: 'dnsmasq' as const }, { to: '/advanced/dnsmasq', icon: Wifi, label: 'dnsmasq', daemon: 'dnsmasq' as const },
{ to: '/central/advanced/nftables', icon: Shield, label: 'nftables', daemon: 'nftables' as const }, { to: '/advanced/nftables', icon: Shield, label: 'nftables', daemon: 'nftables' as const },
{ to: '/central/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const }, { to: '/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const },
{ to: '/central/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const }, { to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
{ to: '/advanced/authelia', icon: KeyRound, label: 'Authelia', daemon: 'authelia' as const },
]; ];
return ( return (
@@ -79,7 +111,7 @@ export function AppSidebar() {
<SidebarHeader> <SidebarHeader>
<div className="flex items-center justify-center pb-2"> <div className="flex items-center justify-center pb-2">
<div className="p-1 bg-primary/10 rounded-lg"> <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>
<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"> <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> <h2 className="text-lg font-bold text-foreground whitespace-nowrap">Wild Central</h2>
@@ -158,31 +190,40 @@ export function AppSidebar() {
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<SidebarGroup> <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<SidebarGroupLabel>Advanced</SidebarGroupLabel> <SidebarGroup>
<SidebarGroupContent> <CollapsibleTrigger asChild>
<SidebarMenu> <SidebarGroupLabel className="cursor-pointer select-none">
{advancedItems.map(({ to, icon: Icon, label, daemon }) => { Advanced
const active = centralStatus?.daemons?.[daemon]?.active; <ChevronRight className={`ml-auto h-3.5 w-3.5 transition-transform duration-200 ${advancedOpen ? 'rotate-90' : ''}`} />
return ( </SidebarGroupLabel>
<SidebarMenuItem key={to}> </CollapsibleTrigger>
<NavLink to={to}> <CollapsibleContent>
{({ isActive }) => ( <SidebarGroupContent>
<SidebarMenuButton isActive={isActive}> <SidebarMenu>
<Icon className="h-4 w-4" /> {advancedItems.map(({ to, icon: Icon, label, daemon }) => {
<span className="truncate">{label}</span> const active = centralStatus?.daemons?.[daemon]?.active;
{active !== undefined && ( return (
<span className={`ml-auto h-1.5 w-1.5 rounded-full ${active ? 'bg-green-500' : 'bg-red-500'}`} /> <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>
</NavLink> );
</SidebarMenuItem> })}
); </SidebarMenu>
})} </SidebarGroupContent>
</SidebarMenu> </CollapsibleContent>
</SidebarGroupContent> </SidebarGroup>
</SidebarGroup> </Collapsible>
</> </>
)} )}
</SidebarContent> </SidebarContent>

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card } from './ui/card'; import { Card } from './ui/card';
import { Button } from './ui/button'; import { Button } from './ui/button';
@@ -7,20 +7,58 @@ import { Alert, AlertDescription } from './ui/alert';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { import {
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2, LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
RefreshCw, BookOpen, Globe, Router, Server, RotateCw, Key, BookOpen, Globe, Router, RotateCw, Key, KeyRound,
Shield, Eye, ArrowLeftRight, Lock,
} from 'lucide-react'; } from 'lucide-react';
import { useCloudflare } from '../hooks/useCloudflare'; import { useCloudflare } from '../hooks/useCloudflare';
import { useDdns } from '../hooks/useDdns';
import { useCentralStatus } from '../hooks/useCentralStatus'; import { useCentralStatus } from '../hooks/useCentralStatus';
import { useVpn } from '../hooks/useVpn'; import { useVpn } from '../hooks/useVpn';
import { secretsApi, dnsSettingsApi, nftablesConfigApi } from '../services/api'; import { secretsApi, dnsSettingsApi, nftablesConfigApi } from '../services/api';
import { usePageHelp } from '../hooks/usePageHelp'; 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 },
];
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() { export function DashboardComponent() {
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus(); const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
const { verification, isLoading: cfLoading } = useCloudflare(); const { verification, isLoading: cfLoading } = useCloudflare();
const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns();
const { config: vpnConfig } = useVpn(); const { config: vpnConfig } = useVpn();
const { data: dnsSettings } = useQuery({ queryKey: ['dns', 'settings'], queryFn: () => dnsSettingsApi.get() }); const { data: dnsSettings } = useQuery({ queryKey: ['dns', 'settings'], queryFn: () => dnsSettingsApi.get() });
const { data: nftConfig } = useQuery({ queryKey: ['nftables', 'config'], queryFn: () => nftablesConfigApi.get() }); const { data: nftConfig } = useQuery({ queryKey: ['nftables', 'config'], queryFn: () => nftablesConfigApi.get() });
@@ -47,11 +85,21 @@ export function DashboardComponent() {
}, },
}); });
// Warning conditions
const cfTokenMissing = !cfLoading && !verification?.tokenConfigured; const cfTokenMissing = !cfLoading && !verification?.tokenConfigured;
const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid; const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid;
const ddnsFailed = ddnsStatus?.enabled && ddnsStatus?.lastError; const hasWarnings = cfTokenMissing || cfTokenInvalid;
const hasWarnings = cfTokenMissing || cfTokenInvalid || ddnsFailed;
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({ usePageHelp({
title: 'What is the Dashboard?', title: 'What is the Dashboard?',
@@ -73,15 +121,36 @@ export function DashboardComponent() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Page Header */} {/* Page Header with system metadata */}
<div className="flex items-center gap-4 mb-6"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="p-2 bg-primary/10 rounded-lg"> <div className="flex items-center gap-4">
<LayoutDashboard className="h-6 w-6 text-primary" /> <div className="p-2 bg-primary/10 rounded-lg">
</div> <LayoutDashboard className="h-6 w-6 text-primary" />
<div> </div>
<h2 className="text-2xl font-semibold">Dashboard</h2> <div>
<p className="text-muted-foreground">Infrastructure health and service prerequisites</p> <h2 className="text-2xl font-semibold">Dashboard</h2>
<p className="text-muted-foreground">System health overview</p>
</div>
</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> </div>
{/* Warnings */} {/* Warnings */}
@@ -99,322 +168,276 @@ export function DashboardComponent() {
<AlertDescription>Cloudflare API token is invalid. Check your token in Cloudflare settings.</AlertDescription> <AlertDescription>Cloudflare API token is invalid. Check your token in Cloudflare settings.</AlertDescription>
</Alert> </Alert>
)} )}
{ddnsFailed && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>DDNS sync error: {ddnsStatus.lastError}</AlertDescription>
</Alert>
)}
</div> </div>
)} )}
{/* 1. Cloudflare */} {/* Services */}
<Card className="p-4 border-l-4 border-l-blue-500"> <div>
<div className="flex items-center justify-between mb-3"> <div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-3">Services</div>
<div className="flex items-center gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<Cloud className="h-5 w-5 text-blue-500" /> {SERVICES.map((svc) => (
<div className="font-medium">Cloudflare</div> <ServiceCard
</div> key={svc.key}
{cfLoading ? ( label={svc.label}
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> daemon={svc.key}
) : verification?.tokenValid ? ( icon={svc.icon}
<Badge variant="success" className="gap-1"> active={daemons[svc.key]?.active}
<CheckCircle className="h-3 w-3" /> version={daemons[svc.key]?.version}
Connected loading={statusLoading}
</Badge> />
) : verification?.tokenConfigured ? ( ))}
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Invalid
</Badge>
) : (
<Badge variant="secondary">Not configured</Badge>
)}
</div> </div>
</div>
{cfLoading ? ( {/* Configuration */}
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7"> <div>
<Loader2 className="h-4 w-4 animate-spin" /> <div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-3">Configuration</div>
Checking token... <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
</div> {/* Cloudflare */}
) : verification?.tokenValid ? ( <Card className="p-5">
<div className="space-y-2 ml-7"> <div className="flex items-center justify-between mb-4">
{verification.zones && verification.zones.length > 0 && ( <div className="flex items-center gap-3">
<div className="flex items-start gap-2 text-sm"> <div className="p-2.5 bg-orange-500/10 rounded-xl">
<Globe className="h-4 w-4 text-muted-foreground mt-0.5" /> <Cloud className="h-5 w-5 text-orange-500" />
<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>
</div> </div>
<div className="font-semibold">Cloudflare</div>
</div> </div>
)} {cfLoading ? (
{!editingToken ? ( <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<Button variant="ghost" size="sm" onClick={() => setEditingToken(true)} className="gap-1 text-muted-foreground"> ) : verification?.tokenValid ? (
<Key className="h-3 w-3" /> <Badge variant="success" className="gap-1">
Change Token <CheckCircle className="h-3 w-3" />
</Button> Connected
) : ( </Badge>
<div className="space-y-2"> ) : verification?.tokenConfigured ? (
<Input <Badge variant="destructive" className="gap-1">
type="password" <XCircle className="h-3 w-3" />
placeholder="New Cloudflare API token..." Invalid
value={tokenValue} </Badge>
onChange={(e) => setTokenValue(e.target.value)} ) : (
<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"> {!editingToken ? (
<Button size="sm" onClick={() => updateSecretsMutation.mutate({ cloudflare: { apiToken: tokenValue } })} disabled={!tokenValue}> <Button variant="ghost" size="sm" onClick={() => setEditingToken(true)} className="gap-1 h-7 text-xs text-muted-foreground">
Save <Key className="h-3 w-3" />
Change Token
</Button> </Button>
<Button size="sm" variant="ghost" onClick={() => { setEditingToken(false); setTokenValue(''); }}> ) : (
Cancel <div className="space-y-2">
</Button> <Input
</div> type="password"
</div> placeholder="New Cloudflare API token..."
)} value={tokenValue}
</div> onChange={(e) => setTokenValue(e.target.value)}
) : ( />
<div className="ml-7"> <div className="flex gap-2">
{!editingToken ? ( <Button size="sm" onClick={() => updateSecretsMutation.mutate({ cloudflare: { apiToken: tokenValue } })} disabled={!tokenValue}>
<div className="space-y-2"> Save
<p className="text-sm text-muted-foreground"> </Button>
{verification?.tokenConfigured <Button size="sm" variant="ghost" onClick={() => { setEditingToken(false); setTokenValue(''); }}>
? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}` Cancel
: 'An API token is required for DNS and TLS features'} </Button>
</p> </div>
<Button variant="outline" size="sm" onClick={() => setEditingToken(true)} className="gap-1"> </div>
<Key className="h-3 w-3" /> )}
{cfTokenIsSet ? 'Update Token' : 'Set Token'}
</Button>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div>
<Input {!editingToken ? (
type="password" <div className="space-y-2">
placeholder="Cloudflare API token..." <p className="text-sm text-muted-foreground">
value={tokenValue} {verification?.tokenConfigured
onChange={(e) => setTokenValue(e.target.value)} ? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}`
/> : 'Required for DNS and TLS'}
<div className="flex gap-2"> </p>
<Button <Button variant="outline" size="sm" onClick={() => setEditingToken(true)} className="gap-1">
size="sm" <Key className="h-3 w-3" />
onClick={() => updateSecretsMutation.mutate({ cloudflare: { apiToken: tokenValue } })} {cfTokenIsSet ? 'Update Token' : 'Set Token'}
disabled={updateSecretsMutation.isPending || !tokenValue} </Button>
> </div>
{updateSecretsMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null} ) : (
Save <div className="space-y-2">
</Button> <Input
<Button variant="outline" size="sm" onClick={() => { setEditingToken(false); setTokenValue(''); }}> type="password"
Cancel placeholder="Cloudflare API token..."
</Button> value={tokenValue}
</div> onChange={(e) => setTokenValue(e.target.value)}
{updateSecretsMutation.isError && ( />
<p className="text-xs text-red-500">{String(updateSecretsMutation.error)}</p> <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>
)} )}
</div> </Card>
)}
</Card>
{/* 2. Dynamic DNS */} {/* Router */}
<Card className="p-4 border-l-4 border-l-green-500"> <Card className="p-5">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center gap-3 mb-4">
<div className="flex items-center gap-2"> <div className="p-2.5 bg-amber-500/10 rounded-xl">
<Globe className="h-5 w-5 text-green-500" /> <Router className="h-5 w-5 text-amber-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>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Last checked</span> <div className="font-semibold">Router</div>
<div className="font-mono mt-0.5"> <div className="text-xs text-muted-foreground">LAN setup checklist</div>
{ddnsStatus.lastChecked ? new Date(ddnsStatus.lastChecked).toLocaleTimeString() : '--'}
</div>
</div> </div>
</div> </div>
<div className="text-sm text-muted-foreground space-y-2">
{/* Record list (read-only — derived from public domains) */} <p>Configure your LAN router so Wild Central can serve traffic:</p>
{ddnsStatus.records?.length ? ( <ol className="list-decimal list-inside space-y-1.5 text-xs">
<div className="space-y-1"> <li>
{ddnsStatus.records.map((rs: DdnsRecordStatus) => ( Set the router's DNS server to Wild Central
<div key={rs.name} className="flex items-center gap-2 text-xs"> {dnsSettings?.ip && (
{rs.ok ? ( <span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">{dnsSettings.ip}</span>
<CheckCircle className="h-3.5 w-3.5 text-green-500 shrink-0" /> )}
) : ( </li>
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" /> <li>
)} Forward these ports to Wild Central:
<span className="font-mono bg-muted px-2 py-0.5 rounded">{rs.name}</span> <div className="flex flex-wrap gap-1 mt-1 ml-4">
{rs.ok && rs.ip && ( {ports.map((p, i) => (
<span className="text-muted-foreground font-mono">{rs.ip}</span> <span key={i} className="font-mono bg-muted px-2 py-0.5 rounded">
)} {p.port}/{p.proto}
{!rs.ok && rs.error && ( {p.label && <span className="text-muted-foreground/60"> ({p.label})</span>}
<span className="text-red-500 truncate" title={rs.error}>{rs.error}</span> </span>
)} ))}
</div> </div>
))} </li>
<p className="text-xs text-muted-foreground mt-1"> </ol>
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} />
</div> </div>
</Card>
{/* 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>
</div> </div>
<div className="space-y-2 ml-7 text-sm text-muted-foreground"> </div>
<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 ZoneCoverage({ available, required }: {
function ServiceBadge({ name, active }: { name: string; active?: boolean }) { available: { id: string; name: string }[] | null;
const dotColor = active === undefined ? 'bg-gray-400' : active ? 'bg-green-500' : 'bg-red-500'; 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 ( return (
<span className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-medium"> <div>
<span className={`h-2 w-2 rounded-full ${dotColor}`} /> <div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Zones</div>
{name} <div className="space-y-1">
</span> {allZones.map((z) => (
<div key={z.name} className="flex items-center gap-2 text-xs">
{z.needed ? (
z.covered ? (
<CheckCircle className="h-3 w-3 text-green-500 shrink-0" />
) : (
<XCircle className="h-3 w-3 text-red-500 shrink-0" />
)
) : (
<CheckCircle className="h-3 w-3 text-muted-foreground/40 shrink-0" />
)}
<span className="font-mono">{z.name}</span>
{!z.covered && z.needed && (
<span className="text-red-500">not covered</span>
)}
{!z.needed && (
<span className="text-muted-foreground">unused</span>
)}
</div>
))}
</div>
</div>
);
}
function PermissionRow({ label, description, ok }: { label: string; description: string; ok?: boolean }) {
return (
<div className="flex items-center gap-2 text-xs">
{ok ? (
<CheckCircle className="h-3 w-3 text-green-500 shrink-0" />
) : (
<XCircle className="h-3 w-3 text-red-500 shrink-0" />
)}
<span className="font-mono font-medium">{label}</span>
<span className="text-muted-foreground">{description}</span>
</div>
);
}
function ServiceCard({ label, daemon, icon: Icon, active, version, loading }: {
label: string;
daemon: string;
icon: typeof Globe;
active?: boolean;
version?: string;
loading: boolean;
}) {
return (
<Card className={`p-4 transition-colors ${active === false ? 'border-red-500/40' : ''}`}>
<div className="flex flex-col items-center gap-2 text-center">
<div className={`p-2.5 rounded-xl ${
loading ? 'bg-muted' :
active ? 'bg-green-500/10' :
active === false ? 'bg-red-500/10' :
'bg-muted'
}`}>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
) : (
<Icon className={`h-5 w-5 ${
active ? 'text-green-600 dark:text-green-400' :
active === false ? 'text-red-500' :
'text-muted-foreground'
}`} />
)}
</div>
<div>
<div className="text-sm font-medium">{label}</div>
<div className="text-[10px] text-muted-foreground font-mono">{daemon}</div>
{version && <div className="text-[10px] text-muted-foreground font-mono">{version}</div>}
</div>
</div>
</Card>
); );
} }

View File

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

View File

@@ -386,6 +386,16 @@ export function FirewallComponent() {
permissive (safe default while you're getting started). permissive (safe default while you're getting started).
</p> </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 ? ( {!editingWan ? (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{currentWan ? ( {currentWan ? (

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; 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 { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { DomainTopology } from './DomainTopology'; import { DomainTopology } from './DomainTopology';
@@ -107,28 +107,60 @@ function describeChanges(server: DomainDraft, draft: DomainDraft): string[] {
return changes; return changes;
} }
/** Compact controls for mobile */ /** Compact controls for mobile — vertical pipeline: Source → Routing → Destination */
function MobileControls({ function MobileControls({
draft, onUpdateDraft, cert, onProvisionCert, isProvisioningCert, draft, onUpdateDraft, domain, cert, ddnsRecord,
onProvisionCert, onRenewCert, isProvisioningCert, isRenewingCert,
}: { }: {
draft: DomainDraft; draft: DomainDraft;
onUpdateDraft: (patch: Partial<DomainDraft>) => void; onUpdateDraft: (patch: Partial<DomainDraft>) => void;
domain: RegisteredDomain;
cert: CertEntry | undefined; cert: CertEntry | undefined;
ddnsRecord: DdnsRecordStatus | undefined;
onProvisionCert: (d: string) => void; onProvisionCert: (d: string) => void;
onRenewCert: () => void;
isProvisioningCert: boolean; isProvisioningCert: boolean;
isRenewingCert: boolean;
}) { }) {
const isL7 = draft.mode === 'l7'; const isL7 = draft.mode === 'l7';
const isDnsOnly = draft.mode === 'dns-only';
const hasCert = !!cert?.cert.exists; 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 ( return (
<div className="space-y-3 py-2"> <div className="py-2 space-y-3">
<div className="flex items-center justify-between"> {/* === Section 1: Source === */}
<Label className="text-xs">Public (internet-accessible)</Label> <div className="space-y-2">
<Switch checked={draft.public} onCheckedChange={v => onUpdateDraft({ public: v })} /> <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>
<div> {/* === Section 2: Routing === */}
<Label className="text-xs mb-1.5 block">Routing mode</Label> <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"> <div className="flex gap-1 bg-muted rounded-md p-1">
{([ {([
{ value: 'dns-only' as const, label: 'Direct' }, { value: 'dns-only' as const, label: 'Direct' },
@@ -146,39 +178,111 @@ function MobileControls({
</button> </button>
))} ))}
</div> </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> </div>
{isL7 && ( {/* === Section 3: Destination === */}
<div className="flex items-center justify-between"> <div className="border-t border-border/50 pt-3 space-y-2">
<div className="flex items-center gap-2 text-xs"> <div className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">Destination</div>
{hasCert ? ( {editingBackend ? (
<> <div className="space-y-1.5">
<CheckCircle className="h-3.5 w-3.5 text-green-500" /> <input
<span>TLS: {(cert!.cert.wildcard || cert!.coveredBy) ? `*.${cert!.domain}` : cert!.domain}</span> ref={backendInputRef}
{cert!.cert.daysLeft != null && <span className="text-muted-foreground">({cert!.cert.daysLeft}d)</span>} 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 => {
<AlertCircle className="h-3.5 w-3.5 text-red-500" /> if (e.key === 'Enter') handleBackendSave();
<span>No TLS certificate</span> 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> </div>
{!hasCert && ( )}
<Button variant="outline" size="sm" className="h-7 text-xs gap-1" {isL7 && domain.routes && domain.routes.length > 0 && (
disabled={isProvisioningCert} <div className="bg-muted/30 rounded-md p-2 space-y-1">
onClick={() => onProvisionCert(draft.subdomains ? `*.` : '')} <div className="text-[10px] text-muted-foreground font-medium mb-1">Route table</div>
> {domain.routes.map((route, i) => (
{isProvisioningCert ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />} <div key={i} className="flex items-center gap-2 text-[10px] flex-wrap">
Provision <span className="font-mono text-muted-foreground truncate">
</Button> {route.paths?.length ? route.paths.join(', ') : '/*'}
)} </span>
</div> <span className="text-muted-foreground">{'->'}</span>
)} <span className="font-mono">{route.backend.address}</span>
{route.ipAllow && route.ipAllow.length > 0 && (
<div className="text-xs"> <span className="text-muted-foreground flex items-center gap-0.5">
<span className="text-muted-foreground">Backend: </span> {route.ipAllow.length} IP rules
<span className="font-mono">{draft.backendAddress}</span> </span>
)}
</div>
))}
</div>
)}
</div> </div>
</div> </div>
); );
@@ -362,9 +466,13 @@ export function DomainCard({
<MobileControls <MobileControls
draft={draft} draft={draft}
onUpdateDraft={updateDraft} onUpdateDraft={updateDraft}
domain={domain}
cert={cert} cert={cert}
ddnsRecord={ddnsRecord}
onProvisionCert={d => onProvisionCert(draft.subdomains ? `*.${domain.domain}` : d || domain.domain)} onProvisionCert={d => onProvisionCert(draft.subdomains ? `*.${domain.domain}` : d || domain.domain)}
onRenewCert={onRenewCert}
isProvisioningCert={isProvisioningCert} isProvisioningCert={isProvisioningCert}
isRenewingCert={isRenewingCert}
/> />
) : ( ) : (
<DomainTopology <DomainTopology
@@ -412,16 +520,18 @@ export function DomainCard({
</div> </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 justify-between pt-2 border-t border-border/50">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button {!isMobile && (
type="button" <button
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors" type="button"
onClick={() => updateDraft({ subdomains: !draft.subdomains })} className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
> onClick={() => updateDraft({ subdomains: !draft.subdomains })}
{draft.subdomains ? '*.wildcard on' : 'wildcard off'} >
</button> {draft.subdomains ? '*.wildcard on' : 'wildcard off'}
</button>
)}
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
Source: <span className="font-mono">{domain.source}</span> Source: <span className="font-mono">{domain.source}</span>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,3 +3,4 @@ export { DnsmasqPage } from './DnsmasqPage';
export { NftablesPage } from './NftablesPage'; export { NftablesPage } from './NftablesPage';
export { WireguardPage } from './WireguardPage'; export { WireguardPage } from './WireguardPage';
export { CrowdsecPage } from './CrowdsecPage'; export { CrowdsecPage } from './CrowdsecPage';
export { AutheliaAdvancedPage } from './AutheliaPage';

View File

@@ -1,4 +1,3 @@
import { Navigate } from 'react-router';
import type { RouteObject } from 'react-router'; import type { RouteObject } from 'react-router';
import { CentralLayout } from './CentralLayout'; import { CentralLayout } from './CentralLayout';
import { NotFoundPage } from './pages/NotFoundPage'; import { NotFoundPage } from './pages/NotFoundPage';
@@ -8,18 +7,17 @@ import { FirewallPage } from './pages/FirewallPage';
import { VpnPage } from './pages/VpnPage'; import { VpnPage } from './pages/VpnPage';
import { CrowdSecPage } from './pages/CrowdSecPage'; import { CrowdSecPage } from './pages/CrowdSecPage';
import { DhcpPage } from './pages/DhcpPage'; import { DhcpPage } from './pages/DhcpPage';
import { DnsFilterPage } from './pages/DnsFilterPage';
import { AutheliaPage } from './pages/AutheliaPage';
import { import {
HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage, HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage,
CrowdsecPage as CrowdsecAdvancedPage, CrowdsecPage as CrowdsecAdvancedPage,
AutheliaAdvancedPage,
} from './pages/advanced'; } from './pages/advanced';
export const routes: RouteObject[] = [ export const routes: RouteObject[] = [
{ {
path: '/', path: '/',
element: <Navigate to="/central" replace />,
},
{
path: '/central',
element: <CentralLayout />, element: <CentralLayout />,
children: [ children: [
{ index: true, element: <DashboardPage /> }, { index: true, element: <DashboardPage /> },
@@ -27,12 +25,15 @@ export const routes: RouteObject[] = [
{ path: 'firewall', element: <FirewallPage /> }, { path: 'firewall', element: <FirewallPage /> },
{ path: 'vpn', element: <VpnPage /> }, { path: 'vpn', element: <VpnPage /> },
{ path: 'crowdsec', element: <CrowdSecPage /> }, { path: 'crowdsec', element: <CrowdSecPage /> },
{ path: 'auth', element: <AutheliaPage /> },
{ path: 'dhcp', element: <DhcpPage /> }, { path: 'dhcp', element: <DhcpPage /> },
{ path: 'dns-filter', element: <DnsFilterPage /> },
{ path: 'advanced/haproxy', element: <HaproxyPage /> }, { path: 'advanced/haproxy', element: <HaproxyPage /> },
{ path: 'advanced/dnsmasq', element: <DnsmasqPage /> }, { path: 'advanced/dnsmasq', element: <DnsmasqPage /> },
{ path: 'advanced/nftables', element: <NftablesPage /> }, { path: 'advanced/nftables', element: <NftablesPage /> },
{ path: 'advanced/wireguard', element: <WireguardPage /> }, { path: 'advanced/wireguard', element: <WireguardPage /> },
{ path: 'advanced/crowdsec', element: <CrowdsecAdvancedPage /> }, { path: 'advanced/crowdsec', element: <CrowdsecAdvancedPage /> },
{ path: 'advanced/authelia', element: <AutheliaAdvancedPage /> },
], ],
}, },
{ {

View File

@@ -0,0 +1,117 @@
import { apiClient } from './client';
// Types
export interface AutheliaStatus {
active: boolean;
version?: string;
enabled: boolean;
domain: string;
defaultPolicy: string;
userCount: number;
clientCount: number;
installed: boolean;
smtp: {
host: string;
port: number;
username: string;
sender: string;
};
}
export interface AutheliaUser {
username: string;
displayname: string;
email?: string;
groups?: string[];
disabled?: boolean;
}
export interface CreateUserRequest {
username: string;
displayname: string;
password: string;
email?: string;
groups?: string[];
}
export interface UpdateUserRequest {
displayname?: string;
password?: string;
email?: string;
groups?: string[];
disabled?: boolean;
}
export interface OIDCClient {
clientId: string;
clientName: string;
clientSecret?: string;
redirectUris: string[];
scopes: string[];
authorizationPolicy: string;
consentMode?: string;
}
export interface CreateOIDCClientRequest {
clientId: string;
clientName: string;
redirectUris: string[];
scopes?: string[];
authorizationPolicy?: string;
consentMode?: string;
}
export interface UpdateOIDCClientRequest {
clientName?: string;
redirectUris?: string[];
scopes?: string[];
authorizationPolicy?: string;
consentMode?: string;
}
// API
export const autheliaApi = {
// Status & Config
async getStatus(): Promise<AutheliaStatus> {
return apiClient.get('/api/v1/authelia/status');
},
async getConfig(): Promise<{ content: string }> {
return apiClient.get('/api/v1/authelia/config');
},
async updateConfig(data: { enabled?: boolean; domain?: string; defaultPolicy?: string; smtpHost?: string; smtpPort?: number; smtpUsername?: string; smtpSender?: string; smtpPassword?: string }): Promise<{ message: string }> {
return apiClient.put('/api/v1/authelia/config', data);
},
async restart(): Promise<{ message: string }> {
return apiClient.post('/api/v1/authelia/restart');
},
// Users
async listUsers(): Promise<{ users: AutheliaUser[] }> {
return apiClient.get('/api/v1/authelia/users');
},
async createUser(req: CreateUserRequest): Promise<{ message: string }> {
return apiClient.post('/api/v1/authelia/users', req);
},
async updateUser(username: string, req: UpdateUserRequest): Promise<{ message: string }> {
return apiClient.put(`/api/v1/authelia/users/${username}`, req);
},
async deleteUser(username: string): Promise<{ message: string }> {
return apiClient.delete(`/api/v1/authelia/users/${username}`);
},
// OIDC Clients
async listOIDCClients(): Promise<{ clients: OIDCClient[] }> {
return apiClient.get('/api/v1/authelia/oidc/clients');
},
async createOIDCClient(req: CreateOIDCClientRequest): Promise<{ clientId: string; clientSecret: string; message: string }> {
return apiClient.post('/api/v1/authelia/oidc/clients', req);
},
async updateOIDCClient(clientId: string, req: UpdateOIDCClientRequest): Promise<{ message: string }> {
return apiClient.put(`/api/v1/authelia/oidc/clients/${clientId}`, req);
},
async deleteOIDCClient(clientId: string): Promise<{ message: string }> {
return apiClient.delete(`/api/v1/authelia/oidc/clients/${clientId}`);
},
};

View File

@@ -5,11 +5,18 @@ export interface CloudflareZone {
name: string; name: string;
} }
export interface CloudflarePermissions {
zone: boolean;
dns: boolean;
}
export interface CloudflareVerifyResponse { export interface CloudflareVerifyResponse {
tokenConfigured: boolean; tokenConfigured: boolean;
tokenValid: boolean; tokenValid: boolean;
tokenStatus: string; tokenStatus: string;
zones: CloudflareZone[] | null; zones: CloudflareZone[] | null;
requiredZones: string[] | null;
permissions: CloudflarePermissions;
error?: string; error?: string;
} }

View File

@@ -0,0 +1,94 @@
import { apiClient } from './client';
import { getApiBaseUrl } from './config';
export interface QueryStats {
totalQueries: number;
blockedQueries: number;
blockedPercent: number;
period: string;
}
export interface DNSFilterStats {
enabled: boolean;
totalBlocked: number;
listCount: number;
enabledLists: number;
lastUpdated: string;
queryStats?: QueryStats;
}
export interface DNSFilterConfig {
enabled: boolean;
intervalHours: number;
}
export interface Blocklist {
id: string;
url?: string;
name: string;
enabled: boolean;
entryCount: number;
lastUpdated: string;
lastError?: string;
}
export interface CustomEntry {
domain: string;
type: 'allow' | 'block';
}
export const dnsFilterApi = {
getStatus: () =>
apiClient.get<DNSFilterStats>('/api/v1/dns-filter/status'),
getConfig: () =>
apiClient.get<DNSFilterConfig>('/api/v1/dns-filter/config'),
updateConfig: (data: Partial<DNSFilterConfig>) =>
apiClient.put<{ message: string }>('/api/v1/dns-filter/config', data),
getLists: () =>
apiClient.get<{ lists: Blocklist[] }>('/api/v1/dns-filter/lists'),
addList: (data: { url: string; name: string }) =>
apiClient.post<{ message: string }>('/api/v1/dns-filter/lists', data),
removeList: (id: string) =>
apiClient.delete<{ message: string }>(`/api/v1/dns-filter/lists/${id}`),
toggleList: (id: string, enabled: boolean) =>
apiClient.put<{ message: string }>(`/api/v1/dns-filter/lists/${id}/toggle`, { enabled }),
getCustomEntries: () =>
apiClient.get<{ customEntries: CustomEntry[] }>('/api/v1/dns-filter/custom'),
setCustomEntry: (entry: CustomEntry) =>
apiClient.post<{ message: string }>('/api/v1/dns-filter/custom', entry),
removeCustomEntry: (domain: string) =>
apiClient.delete<{ message: string }>(`/api/v1/dns-filter/custom/${domain}`),
triggerUpdate: () =>
apiClient.post<{ message: string }>('/api/v1/dns-filter/update'),
getSuggested: () =>
apiClient.get<{ lists: Blocklist[] }>('/api/v1/dns-filter/lists/suggested'),
uploadList: async (name: string, file: File): Promise<{ id: string; message: string }> => {
const formData = new FormData();
formData.append('name', name);
formData.append('file', file);
const response = await fetch(`${getApiBaseUrl()}/api/v1/dns-filter/lists/upload`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return response.json();
},
};

View File

@@ -11,3 +11,7 @@ export { cloudflareApi } from './cloudflare';
export type { CloudflareVerifyResponse, CloudflareZone } from './cloudflare'; export type { CloudflareVerifyResponse, CloudflareZone } from './cloudflare';
export { dnsSettingsApi, ddnsConfigApi, dhcpConfigApi, nftablesConfigApi, haproxyRoutesApi } from './settings'; export { dnsSettingsApi, ddnsConfigApi, dhcpConfigApi, nftablesConfigApi, haproxyRoutesApi } from './settings';
export type { OperatorSettings, CentralDomainSettings, DNSSettings, DDNSConfig, DHCPConfig, NftablesConfig, HAProxyCustomRoute, HAProxyRoutes } from './settings'; export type { OperatorSettings, CentralDomainSettings, DNSSettings, DDNSConfig, DHCPConfig, NftablesConfig, HAProxyCustomRoute, HAProxyRoutes } from './settings';
export { autheliaApi } from './authelia';
export type { AutheliaStatus, AutheliaUser, OIDCClient } from './authelia';
export { dnsFilterApi } from './dnsfilter';
export type { DNSFilterStats, DNSFilterConfig, Blocklist, CustomEntry } from './dnsfilter';

View File

@@ -30,6 +30,10 @@ export interface RegisteredDomain {
public: boolean; public: boolean;
tls?: string; tls?: string;
routes?: Route[]; routes?: Route[];
auth?: {
enabled: boolean;
policy?: string;
};
} }
export const domainsApi = { export const domainsApi = {
@@ -203,7 +207,7 @@ export interface CrowdSecDecision {
value: string; value: string;
type: string; type: string;
scope: string; scope: string;
reason: string; scenario: string;
origin: string; origin: string;
duration?: string; duration?: string;
} }