Compare commits
10 Commits
ff6d6bbd0b
...
1d1d6c605c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d1d6c605c | ||
|
|
518cdbbce5 | ||
|
|
134c01fe5e | ||
|
|
d796a79f79 | ||
|
|
24bb976652 | ||
|
|
43d407bf2e | ||
|
|
994c9fbfdf | ||
|
|
a48d955dc0 | ||
|
|
6490fef5d5 | ||
|
|
9e8f23aab7 |
30
dist/.gitignore
vendored
Normal file
30
dist/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Local dev secrets
|
||||||
|
.envrc
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.deb
|
||||||
|
__debug*
|
||||||
|
|
||||||
|
# APT repository build artifacts
|
||||||
|
.aptly/
|
||||||
|
.aptly.conf
|
||||||
|
|
||||||
|
# Go build cache
|
||||||
|
*.o
|
||||||
|
*.a
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Test binary
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
*.out
|
||||||
|
*.cover
|
||||||
|
*.coverage
|
||||||
|
|
||||||
|
# Go workspace
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
gpg/wild-central.gpg
|
||||||
164
dist/Makefile
vendored
Normal file
164
dist/Makefile
vendored
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Default target
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
# Build configuration
|
||||||
|
VERSION := $(shell cat ../VERSION 2>/dev/null || echo "0.0.0-dev")
|
||||||
|
BUILD_DIR := build
|
||||||
|
DIST_DIR := dist
|
||||||
|
DEB_DIR := debian-package
|
||||||
|
|
||||||
|
# Git information
|
||||||
|
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
|
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
||||||
|
EDGE_VERSION := 0.0.0~edge.$(shell date -u '+%Y%m%d%H%M%S').$(GIT_COMMIT)
|
||||||
|
|
||||||
|
# Source paths
|
||||||
|
API_SOURCE := ..
|
||||||
|
WEB_SOURCE := ../web
|
||||||
|
|
||||||
|
# Output files
|
||||||
|
ARM64_BINARY := $(BUILD_DIR)/wild-central-arm64
|
||||||
|
AMD64_BINARY := $(BUILD_DIR)/wild-central-amd64
|
||||||
|
WEB_DIST_MARKER := $(BUILD_DIR)/.web-built
|
||||||
|
ARM64_DEB := $(DIST_DIR)/packages/wild-central_$(VERSION)_arm64.deb
|
||||||
|
AMD64_DEB := $(DIST_DIR)/packages/wild-central_$(VERSION)_amd64.deb
|
||||||
|
|
||||||
|
# Gitea release configuration
|
||||||
|
GITEA_URL := https://git.civilsociety.dev
|
||||||
|
GITEA_OWNER := wild-cloud
|
||||||
|
GITEA_REPO := wild-central
|
||||||
|
RELEASE_TAG := v$(VERSION)
|
||||||
|
|
||||||
|
.PHONY: help clean version build build-all package package-all repo deploy-repo release release-edge
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Wild Central Package Builder"
|
||||||
|
@echo ""
|
||||||
|
@echo "This directory builds .deb packages for Wild Central."
|
||||||
|
@echo "For development, use the parent directory:"
|
||||||
|
@echo " - make dev (Go daemon)"
|
||||||
|
@echo " - cd web && pnpm dev (React frontend)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Build targets:"
|
||||||
|
@echo " build - Build for current architecture (amd64)"
|
||||||
|
@echo " build-all - Build all architectures"
|
||||||
|
@echo ""
|
||||||
|
@echo "Package targets:"
|
||||||
|
@echo " package - Create .deb package (amd64)"
|
||||||
|
@echo " package-all - Create all .deb packages"
|
||||||
|
@echo ""
|
||||||
|
@echo "Repository targets:"
|
||||||
|
@echo " deploy-repo - Deploy repository to server"
|
||||||
|
@echo ""
|
||||||
|
@echo "Release targets:"
|
||||||
|
@echo " release - Tag HEAD as v$$(cat ../VERSION), publish stable Gitea release + APT"
|
||||||
|
@echo " release-edge - Publish rolling edge Gitea release + APT (no version bump needed)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Utilities:"
|
||||||
|
@echo " clean - Remove all build artifacts"
|
||||||
|
@echo " version - Show build information"
|
||||||
|
@echo ""
|
||||||
|
@echo " Current stable version: $(VERSION) (from ../VERSION)"
|
||||||
|
@echo " Current edge version: $(EDGE_VERSION)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Environment variables:"
|
||||||
|
@echo " GITEA_TOKEN - Gitea API token (required for release)"
|
||||||
|
@echo " APTLY_URL - Aptly instance URL (required for APT deployment)"
|
||||||
|
@echo " APTLY_PASS - Aptly API password"
|
||||||
|
@echo " 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
76
dist/README.md
vendored
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Packaging Wild Central
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### APT Repository (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Import the signing key
|
||||||
|
curl -fsSL https://aptly.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
12
dist/debian/DEBIAN/control
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
Package: wild-central
|
||||||
|
Version: VERSION_PLACEHOLDER
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Architecture: ARCH_PLACEHOLDER
|
||||||
|
Homepage: https://mywildcloud.org
|
||||||
|
Pre-Depends: dnsmasq, haproxy, nftables, wireguard-tools, certbot, python3-certbot-dns-cloudflare
|
||||||
|
Depends: adduser, nginx
|
||||||
|
Maintainer: Wild Cloud Team <paul@payne.io>
|
||||||
|
Description: Wild Central - Networking and Coordination Platform
|
||||||
|
Manages DNS, gateway routing, firewall, VPN, TLS certificates, and tunnels
|
||||||
|
for Wild Cloud and other self-hosted services on the local network.
|
||||||
262
dist/debian/DEBIAN/postinst
vendored
Normal file
262
dist/debian/DEBIAN/postinst
vendored
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
echo "Configuring wild-central..."
|
||||||
|
|
||||||
|
# Create wildcloud user if it doesn't exist
|
||||||
|
if ! id wildcloud >/dev/null 2>&1; then
|
||||||
|
useradd --system --home-dir /var/lib/wild-central --create-home --shell /bin/false wildcloud
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create required directories
|
||||||
|
mkdir -p /var/lib/wild-central
|
||||||
|
mkdir -p /var/log/wild-central
|
||||||
|
mkdir -p /etc/wild-central
|
||||||
|
|
||||||
|
# Set ownership
|
||||||
|
chown -R wildcloud:wildcloud /var/lib/wild-central
|
||||||
|
chown wildcloud:wildcloud /var/log/wild-central
|
||||||
|
|
||||||
|
# Set up dnsmasq directories and permissions
|
||||||
|
mkdir -p /etc/dnsmasq.d
|
||||||
|
mkdir -p /etc/dnsmasq.d/wild-cloud-instances
|
||||||
|
|
||||||
|
# Give wildcloud group write access to dnsmasq.d directory
|
||||||
|
chgrp wildcloud /etc/dnsmasq.d
|
||||||
|
chmod 775 /etc/dnsmasq.d
|
||||||
|
|
||||||
|
# Create or fix ownership of wild-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
80
dist/debian/DEBIAN/postrm
vendored
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
purge)
|
||||||
|
echo "Purging wild-central configuration..."
|
||||||
|
|
||||||
|
# Remove polkit rules
|
||||||
|
if [ -f /etc/polkit-1/rules.d/50-wild-central.rules ]; then
|
||||||
|
rm -f /etc/polkit-1/rules.d/50-wild-central.rules
|
||||||
|
echo "Removed polkit rules"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove sudoers file
|
||||||
|
if [ -f /etc/sudoers.d/wild-central ]; then
|
||||||
|
rm -f /etc/sudoers.d/wild-central
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove dnsmasq configuration
|
||||||
|
if [ -f /etc/dnsmasq.d/wild-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
28
dist/debian/DEBIAN/prerm
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
remove|upgrade|deconfigure)
|
||||||
|
echo "Stopping wild-central service..."
|
||||||
|
|
||||||
|
# Stop and disable the service
|
||||||
|
if systemctl is-active --quiet wild-central; then
|
||||||
|
systemctl stop wild-central
|
||||||
|
fi
|
||||||
|
|
||||||
|
if systemctl is-enabled --quiet wild-central; then
|
||||||
|
systemctl disable wild-central
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
failed-upgrade)
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "prerm called with unknown argument \`$1'" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
34
dist/debian/etc/nginx/sites-available/wild-central
vendored
Normal file
34
dist/debian/etc/nginx/sites-available/wild-central
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Wild Central Management Interface
|
||||||
|
root /var/www/html/wild-central;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# API proxy to wild-central service
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:5055;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSE events proxy (long-lived connections)
|
||||||
|
location /api/v1/events {
|
||||||
|
proxy_pass http://localhost:5055;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static files - SPA routing support
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
dist/debian/etc/systemd/system/wild-central-nftables-reload.service
vendored
Normal file
9
dist/debian/etc/systemd/system/wild-central-nftables-reload.service
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Apply Wild Central nftables rules
|
||||||
|
Documentation=https://mywildcloud.org
|
||||||
|
After=network.target nftables.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/sbin/nft -f /etc/nftables.d/wild-cloud.nft
|
||||||
|
RemainAfterExit=no
|
||||||
28
dist/debian/etc/systemd/system/wild-central.service
vendored
Normal file
28
dist/debian/etc/systemd/system/wild-central.service
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Wild Central Service
|
||||||
|
Documentation=https://mywildcloud.org
|
||||||
|
After=network.target
|
||||||
|
Wants=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=wildcloud
|
||||||
|
Group=wildcloud
|
||||||
|
ExecStart=/usr/bin/wild-central
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=WILD_CENTRAL_DATA_DIR=/var/lib/wild-central
|
||||||
|
Environment=WILD_CENTRAL_STATIC_DIR=/var/www/html/wild-central
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=wild-central
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=yes
|
||||||
|
ReadWritePaths=/etc/wild-central /var/lib/wild-central /var/log/wild-central /etc/dnsmasq.d /etc/systemd/resolved.conf.d /etc/haproxy /etc/nftables.d /etc/wireguard
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
42
dist/debian/etc/wild-central/config.yaml.example
vendored
Normal file
42
dist/debian/etc/wild-central/config.yaml.example
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
cloud:
|
||||||
|
router:
|
||||||
|
ip: 192.168.8.1
|
||||||
|
dnsmasq:
|
||||||
|
ip: 192.168.8.50
|
||||||
|
interface: eth0
|
||||||
|
# DHCP server (disabled by default — enable to take over DHCP from your router)
|
||||||
|
# dhcp:
|
||||||
|
# enabled: false
|
||||||
|
# rangeStart: "192.168.8.100"
|
||||||
|
# rangeEnd: "192.168.8.200"
|
||||||
|
# leaseTime: "24h"
|
||||||
|
# gateway: "" # defaults to cloud.router.ip
|
||||||
|
# staticLeases: # managed via POST /api/v1/dnsmasq/dhcp/static
|
||||||
|
# - mac: "aa:bb:cc:dd:ee:ff"
|
||||||
|
# ip: "192.168.8.10"
|
||||||
|
# hostname: "node1"
|
||||||
|
|
||||||
|
# HAProxy ingress proxy — routes external traffic by SNI.
|
||||||
|
# Use POST /api/v1/haproxy/generate to apply after changing this config.
|
||||||
|
haproxy:
|
||||||
|
# Optional custom TCP routes for non-Wild Cloud services on your network.
|
||||||
|
# customRoutes:
|
||||||
|
# - name: civil_ssh
|
||||||
|
# port: 2222 # external listen port
|
||||||
|
# backend: 192.168.8.10:22
|
||||||
|
|
||||||
|
# nftables host firewall — tracks ports HAProxy is serving.
|
||||||
|
# Set wanInterface to enable strict WAN filtering (drops all unrecognised inbound traffic).
|
||||||
|
# Leave unset for default accept-all policy (safe during initial setup).
|
||||||
|
# nftables:
|
||||||
|
# wanInterface: eth0
|
||||||
|
|
||||||
|
# Dynamic DNS — updates DNS A records when your public IP changes.
|
||||||
|
# API token goes in secrets.yaml under cloudflare.apiToken.
|
||||||
|
# ddns:
|
||||||
|
# enabled: false
|
||||||
|
# provider: cloudflare
|
||||||
|
# records:
|
||||||
|
# - cloud.example.com
|
||||||
|
# - cloud2.example.com
|
||||||
|
# intervalMinutes: 5
|
||||||
71
dist/scripts/build-package.sh
vendored
Executable file
71
dist/scripts/build-package.sh
vendored
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Build configuration
|
||||||
|
VERSION="${VERSION:-0.1.0}"
|
||||||
|
ARCH="${ARCH:-amd64}"
|
||||||
|
BUILD_DIR="build"
|
||||||
|
SRC_DIR="$BUILD_DIR/src"
|
||||||
|
BINARY_NAME="wild-central"
|
||||||
|
|
||||||
|
# Source paths (relative to dist directory)
|
||||||
|
API_SOURCE=".."
|
||||||
|
WEBAPP_SOURCE="../web"
|
||||||
|
|
||||||
|
echo "Building Wild Central v${VERSION} for ${ARCH}"
|
||||||
|
echo "================================================"
|
||||||
|
|
||||||
|
# Clean and create build directories
|
||||||
|
echo "Preparing build directories..."
|
||||||
|
rm -rf "$SRC_DIR"
|
||||||
|
mkdir -p "$SRC_DIR"
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
|
# Copy API source
|
||||||
|
echo ""
|
||||||
|
echo "Copying API source..."
|
||||||
|
rsync -a --exclude='dist/' --exclude='web/' "$API_SOURCE/" "$SRC_DIR/api/"
|
||||||
|
cd "$SRC_DIR/api"
|
||||||
|
|
||||||
|
# Build the daemon
|
||||||
|
echo "Building daemon binary..."
|
||||||
|
if [ "$ARCH" = "arm64" ]; then
|
||||||
|
GOOS=linux GOARCH=arm64 go build -ldflags="-X main.Version=$VERSION" -o "../../$BINARY_NAME" .
|
||||||
|
elif [ "$ARCH" = "amd64" ]; then
|
||||||
|
GOOS=linux GOARCH=amd64 go build -ldflags="-X main.Version=$VERSION" -o "../../$BINARY_NAME" .
|
||||||
|
else
|
||||||
|
echo "Unsupported architecture: $ARCH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd - > /dev/null
|
||||||
|
echo "Daemon binary built: $BUILD_DIR/$BINARY_NAME"
|
||||||
|
|
||||||
|
# Copy web app source
|
||||||
|
echo ""
|
||||||
|
echo "Copying web app source..."
|
||||||
|
cp -r "$WEBAPP_SOURCE" "$SRC_DIR/web"
|
||||||
|
cd "$SRC_DIR/web"
|
||||||
|
|
||||||
|
# Remove any local env overrides
|
||||||
|
rm -f .env.local .env.*.local
|
||||||
|
|
||||||
|
# Build the web app
|
||||||
|
echo "Building web application..."
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# For production builds, use empty base URL so the app uses relative paths
|
||||||
|
echo "VITE_API_BASE_URL=" > .env.production.local
|
||||||
|
|
||||||
|
pnpm run build --mode production || {
|
||||||
|
echo "Build with type checking failed, trying without type check..."
|
||||||
|
pnpm exec vite build
|
||||||
|
}
|
||||||
|
|
||||||
|
cd - > /dev/null
|
||||||
|
echo "Web app built: $SRC_DIR/web/dist/"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Build complete!"
|
||||||
|
echo " Binary: $BUILD_DIR/$BINARY_NAME"
|
||||||
|
echo " Web App: $SRC_DIR/web/dist/"
|
||||||
189
dist/scripts/create-gitea-release.sh
vendored
Executable file
189
dist/scripts/create-gitea-release.sh
vendored
Executable file
@@ -0,0 +1,189 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GITEA_URL="$1"
|
||||||
|
OWNER="$2"
|
||||||
|
REPO="$3"
|
||||||
|
TAG="$4"
|
||||||
|
VERSION="$5"
|
||||||
|
TOKEN="$6"
|
||||||
|
CHANNEL="${7:-stable}" # "stable" or "edge"
|
||||||
|
|
||||||
|
API_URL="${GITEA_URL}/api/v1"
|
||||||
|
PACKAGES_DIR="dist/packages"
|
||||||
|
BINARIES_DIR="dist/bin"
|
||||||
|
SKIP_RELEASE_CREATION=false
|
||||||
|
|
||||||
|
echo "Release configuration:"
|
||||||
|
echo " Repository: ${OWNER}/${REPO}"
|
||||||
|
echo " Tag: ${TAG}"
|
||||||
|
echo " Version: ${VERSION}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if release already exists
|
||||||
|
echo "Checking if release ${TAG} exists..."
|
||||||
|
RELEASE_ID=$(curl -s -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API_URL}/repos/${OWNER}/${REPO}/releases/tags/${TAG}" \
|
||||||
|
| jq -r '.id // empty')
|
||||||
|
|
||||||
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
|
echo "Release ${TAG} exists (ID: ${RELEASE_ID})"
|
||||||
|
echo "Updating existing release assets..."
|
||||||
|
|
||||||
|
# Get existing assets
|
||||||
|
EXISTING_ASSETS=$(curl -s -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API_URL}/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| jq -r '.[].id')
|
||||||
|
|
||||||
|
# Delete old assets
|
||||||
|
for ASSET_ID in $EXISTING_ASSETS; do
|
||||||
|
ASSET_NAME=$(curl -s -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API_URL}/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}" \
|
||||||
|
| jq -r '.name')
|
||||||
|
|
||||||
|
if [[ "$ASSET_NAME" == *.deb ]] || [[ "$ASSET_NAME" == wild-central-* ]] || [[ "$ASSET_NAME" == "SHA256SUMS" ]]; then
|
||||||
|
echo " Deleting old asset: ${ASSET_NAME}"
|
||||||
|
curl -s -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API_URL}/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Cleaned up old package assets"
|
||||||
|
SKIP_RELEASE_CREATION=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract changelog section for this version
|
||||||
|
CHANGELOG_SECTION=""
|
||||||
|
if [ -f "../CHANGELOG.md" ]; then
|
||||||
|
CHANGELOG_SECTION=$(awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" ../CHANGELOG.md | sed '/^$/d')
|
||||||
|
fi
|
||||||
|
|
||||||
|
INSTALL_DOCS="### Installation
|
||||||
|
|
||||||
|
#### Full Installation (.deb package)
|
||||||
|
|
||||||
|
Download the appropriate .deb package for your architecture:
|
||||||
|
|
||||||
|
- **arm64**: \`wild-central_${VERSION}_arm64.deb\` — Raspberry Pi 4/5, ARM servers
|
||||||
|
- **amd64**: \`wild-central_${VERSION}_amd64.deb\` — x86_64 systems
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
wget ${GITEA_URL}/${OWNER}/${REPO}/releases/download/v${VERSION}/wild-central_${VERSION}_amd64.deb
|
||||||
|
sudo dpkg -i wild-central_${VERSION}_amd64.deb
|
||||||
|
sudo apt-get install -f
|
||||||
|
sudo systemctl enable --now wild-central
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
#### Standalone Binary
|
||||||
|
|
||||||
|
- \`wild-central-{arch}\` — Daemon binary only (Docker/K8s)
|
||||||
|
|
||||||
|
#### Verification
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
wget ${GITEA_URL}/${OWNER}/${REPO}/releases/download/v${VERSION}/SHA256SUMS
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
\`\`\`"
|
||||||
|
|
||||||
|
if [ -n "$CHANGELOG_SECTION" ]; then
|
||||||
|
BODY="${CHANGELOG_SECTION}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
${INSTALL_DOCS}"
|
||||||
|
else
|
||||||
|
BODY="${INSTALL_DOCS}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set release name and prerelease flag based on channel
|
||||||
|
if [ "$CHANNEL" = "edge" ]; then
|
||||||
|
RELEASE_NAME="Wild Central (edge) — ${VERSION}"
|
||||||
|
IS_PRERELEASE=true
|
||||||
|
else
|
||||||
|
RELEASE_NAME="Wild Central ${VERSION}"
|
||||||
|
IS_PRERELEASE=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create new release if it doesn't exist
|
||||||
|
if [ "$SKIP_RELEASE_CREATION" != "true" ]; then
|
||||||
|
echo "Creating new release ${TAG}..."
|
||||||
|
RELEASE_DATA=$(jq -n \
|
||||||
|
--arg tag "${TAG}" \
|
||||||
|
--arg name "${RELEASE_NAME}" \
|
||||||
|
--arg body "${BODY}" \
|
||||||
|
--argjson prerelease "${IS_PRERELEASE}" \
|
||||||
|
'{"tag_name": $tag, "name": $name, "body": $body, "draft": false, "prerelease": $prerelease}')
|
||||||
|
|
||||||
|
RELEASE_RESPONSE=$(curl -s -X POST -H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "${RELEASE_DATA}" \
|
||||||
|
"${API_URL}/repos/${OWNER}/${REPO}/releases")
|
||||||
|
|
||||||
|
RELEASE_ID=$(echo "${RELEASE_RESPONSE}" | jq -r '.id')
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
|
||||||
|
echo "Failed to create release"
|
||||||
|
echo "${RELEASE_RESPONSE}" | jq .
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Created release ${TAG} (ID: ${RELEASE_ID})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate checksums
|
||||||
|
echo ""
|
||||||
|
echo "Generating checksums..."
|
||||||
|
cd dist
|
||||||
|
sha256sum packages/*.deb bin/wild-central-* > SHA256SUMS
|
||||||
|
echo "Created SHA256SUMS"
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Upload artifacts
|
||||||
|
echo ""
|
||||||
|
echo "Uploading release artifacts..."
|
||||||
|
|
||||||
|
upload_file() {
|
||||||
|
local FILE="$1"
|
||||||
|
local FILENAME=$(basename "$FILE")
|
||||||
|
|
||||||
|
echo " Uploading ${FILENAME}..."
|
||||||
|
|
||||||
|
UPLOAD_RESPONSE=$(curl -s -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-F "attachment=@${FILE}" \
|
||||||
|
"${API_URL}/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}")
|
||||||
|
|
||||||
|
ASSET_ID=$(echo "${UPLOAD_RESPONSE}" | jq -r '.id')
|
||||||
|
|
||||||
|
if [ -z "$ASSET_ID" ] || [ "$ASSET_ID" = "null" ]; then
|
||||||
|
echo " Failed to upload ${FILENAME}"
|
||||||
|
echo "${UPLOAD_RESPONSE}" | jq .
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
echo " Uploaded ${FILENAME}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upload .deb packages
|
||||||
|
for DEB in ${PACKAGES_DIR}/wild-central_${VERSION}_*.deb; do
|
||||||
|
if [ ! -f "$DEB" ]; then
|
||||||
|
echo "No .deb files found for version ${VERSION} in ${PACKAGES_DIR}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
upload_file "$DEB"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Upload standalone daemon binaries
|
||||||
|
for BINARY in ${BINARIES_DIR}/wild-central-*; do
|
||||||
|
if [ -f "$BINARY" ]; then
|
||||||
|
upload_file "$BINARY"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Upload checksums
|
||||||
|
upload_file "dist/SHA256SUMS"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Release complete!"
|
||||||
|
echo "View at: ${GITEA_URL}/${OWNER}/${REPO}/releases/tag/${TAG}"
|
||||||
138
dist/scripts/deploy-apt-repository.sh
vendored
Executable file
138
dist/scripts/deploy-apt-repository.sh
vendored
Executable 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
49
dist/scripts/package-deb.sh
vendored
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ARCH="$1"
|
||||||
|
BINARY_PATH="$2"
|
||||||
|
VERSION="$3"
|
||||||
|
|
||||||
|
BUILD_DIR="build"
|
||||||
|
DIST_DIR="dist"
|
||||||
|
DEB_DIR="debian-package"
|
||||||
|
WEB_DIST="${BUILD_DIR}/src/web/dist"
|
||||||
|
|
||||||
|
echo "Creating .deb package for ${ARCH}..."
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
mkdir -p "${DIST_DIR}/bin" "${DIST_DIR}/packages"
|
||||||
|
|
||||||
|
# Copy debian package structure
|
||||||
|
cp -r debian/ "${BUILD_DIR}/${DEB_DIR}-${ARCH}/"
|
||||||
|
|
||||||
|
# Copy binary to correct location
|
||||||
|
mkdir -p "${BUILD_DIR}/${DEB_DIR}-${ARCH}/usr/bin"
|
||||||
|
cp "${BINARY_PATH}" "${BUILD_DIR}/${DEB_DIR}-${ARCH}/usr/bin/wild-central"
|
||||||
|
|
||||||
|
# Copy static web files from built web app
|
||||||
|
mkdir -p "${BUILD_DIR}/${DEB_DIR}-${ARCH}/var/www/html/wild-central"
|
||||||
|
if [ -d "${WEB_DIST}" ]; then
|
||||||
|
cp -r "${WEB_DIST}"/* "${BUILD_DIR}/${DEB_DIR}-${ARCH}/var/www/html/wild-central/"
|
||||||
|
echo "Copied web app files"
|
||||||
|
else
|
||||||
|
echo "Warning: Web app dist not found at ${WEB_DIST}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set script permissions
|
||||||
|
chmod 755 "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/postinst"
|
||||||
|
chmod 755 "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/prerm"
|
||||||
|
chmod 755 "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/postrm"
|
||||||
|
|
||||||
|
# Substitute placeholders in control file
|
||||||
|
sed -i "s/VERSION_PLACEHOLDER/${VERSION}/g" "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/control"
|
||||||
|
sed -i "s/ARCH_PLACEHOLDER/${ARCH}/g" "${BUILD_DIR}/${DEB_DIR}-${ARCH}/DEBIAN/control"
|
||||||
|
|
||||||
|
# Build package and copy to dist directories
|
||||||
|
dpkg-deb --root-owner-group --build "${BUILD_DIR}/${DEB_DIR}-${ARCH}" "${BUILD_DIR}/wild-central_${VERSION}_${ARCH}.deb"
|
||||||
|
cp "${BINARY_PATH}" "${DIST_DIR}/bin/wild-central-${ARCH}"
|
||||||
|
cp "${BUILD_DIR}/wild-central_${VERSION}_${ARCH}.deb" "${DIST_DIR}/packages/"
|
||||||
|
|
||||||
|
echo "Package created: ${DIST_DIR}/packages/wild-central_${VERSION}_${ARCH}.deb"
|
||||||
|
echo "Daemon binary copied: ${DIST_DIR}/bin/wild-central-${ARCH}"
|
||||||
2
go.mod
2
go.mod
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,6 +47,9 @@ type API struct {
|
|||||||
crowdsec *crowdsec.Manager
|
crowdsec *crowdsec.Manager
|
||||||
vpn *wireguard.Manager
|
vpn *wireguard.Manager
|
||||||
certbot *certbot.Manager
|
certbot *certbot.Manager
|
||||||
|
authelia *authelia.Manager // Authelia authentication service manager
|
||||||
|
dnsFilter *dnsfilter.Manager // DNS filtering manager
|
||||||
|
dnsFilterRunner *dnsfilter.Runner // DNS filter background update runner
|
||||||
domains *domains.Manager // Domain registration manager
|
domains *domains.Manager // Domain registration manager
|
||||||
sseManager *sse.Manager // SSE manager for real-time events
|
sseManager *sse.Manager // SSE manager for real-time events
|
||||||
port int // Running API port (for config-driven routes)
|
port int // Running API port (for config-driven routes)
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
492
internal/api/v1/handlers_authelia.go
Normal file
492
internal/api/v1/handlers_authelia.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,11 +12,18 @@ 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"`
|
||||||
|
RequiredZones []string `json:"requiredZones"`
|
||||||
|
Permissions cloudflarePermissions `json:"permissions"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
351
internal/api/v1/handlers_dnsfilter.go
Normal file
351
internal/api/v1/handlers_dnsfilter.go
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/wild-cloud/wild-central/internal/config"
|
||||||
|
"github.com/wild-cloud/wild-central/internal/dnsfilter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DNSFilterStatus returns the current DNS filter status and stats.
|
||||||
|
func (api *API) DNSFilterStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state, err := config.LoadState(api.statePath())
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to load state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := api.dnsFilter.GetStats()
|
||||||
|
stats.Enabled = state.Cloud.DNSFilter.Enabled
|
||||||
|
|
||||||
|
if stats.Enabled {
|
||||||
|
stats.QueryStats = api.dnsFilter.GetQueryStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSFilterGetConfig returns the DNS filter configuration from state.yaml.
|
||||||
|
func (api *API) DNSFilterGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state, err := config.LoadState(api.statePath())
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to load state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, state.Cloud.DNSFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSFilterUpdateConfig enables/disables DNS filtering and updates settings.
|
||||||
|
func (api *API) DNSFilterUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
IntervalHours *int `json:"intervalHours"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := config.LoadState(api.statePath())
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to load state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Enabled != nil {
|
||||||
|
state.Cloud.DNSFilter.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
if req.IntervalHours != nil {
|
||||||
|
state.Cloud.DNSFilter.IntervalHours = *req.IntervalHours
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.SaveState(state, api.statePath()); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.Cloud.DNSFilter.Enabled {
|
||||||
|
if err := api.dnsFilter.EnsureDataDir(); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create data dir: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start or restart the runner
|
||||||
|
interval := state.Cloud.DNSFilter.IntervalHours
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 24
|
||||||
|
}
|
||||||
|
api.dnsFilterRunner.Start(api.ctx, interval)
|
||||||
|
|
||||||
|
slog.Info("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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
86
internal/authelia/TODO.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Authelia Packaging TODO
|
||||||
|
|
||||||
|
These steps must be added to the postinst script (`wild-cloud/dist/debian/DEBIAN/postinst`)
|
||||||
|
when packaging Wild Central for distribution.
|
||||||
|
|
||||||
|
## Install Authelia
|
||||||
|
|
||||||
|
```bash
|
||||||
|
if ! command -v authelia &>/dev/null; then
|
||||||
|
curl -fsSL https://apt.authelia.com/organization/signing.asc | \
|
||||||
|
gpg --dearmor -o /usr/share/keyrings/authelia-archive-keyring.gpg
|
||||||
|
echo "deb [signed-by=/usr/share/keyrings/authelia-archive-keyring.gpg] \
|
||||||
|
https://apt.authelia.com/integration/apt/repo/stable/debian debian main" \
|
||||||
|
> /etc/apt/sources.list.d/authelia.list
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y authelia
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install HAProxy Lua scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /etc/haproxy/lua
|
||||||
|
curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua \
|
||||||
|
https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua
|
||||||
|
curl -fsSL -o /etc/haproxy/lua/haproxy-lua-http.lua \
|
||||||
|
https://raw.githubusercontent.com/haproxytech/haproxy-lua-http/master/http.lua
|
||||||
|
apt-get install -y lua-json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Systemd service override
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /etc/systemd/system/authelia.service.d
|
||||||
|
cat > /etc/systemd/system/authelia.service.d/wild-central.conf << EOF
|
||||||
|
[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=/usr/bin/authelia --config /var/lib/wild-central/authelia/configuration.yml
|
||||||
|
EOF
|
||||||
|
systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /var/lib/wild-central/authelia
|
||||||
|
chown wildcloud:wildcloud /var/lib/wild-central/authelia
|
||||||
|
chmod 700 /var/lib/wild-central/authelia
|
||||||
|
```
|
||||||
|
|
||||||
|
## Polkit rule (manages all Wild Central services, not just Authelia)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /etc/polkit-1/rules.d/50-wild-central.rules << 'EOF'
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||||
|
subject.isInGroup("wildcloud")) {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Certificate Issues TODO
|
||||||
|
|
||||||
|
Problems discovered during Authelia integration that need fixing in the cert management system.
|
||||||
|
|
||||||
|
## `hasCertForDomain` doesn't verify cert actually covers the domain
|
||||||
|
|
||||||
|
`hasCertForDomain()` in `helpers.go` checks if a PEM file exists at the expected path (e.g. `payne.io.pem` for wildcard coverage of `auth.payne.io`), but never verifies the cert's Subject/SAN actually matches. We had a `*.payne.io.pem` file that was actually a cert for `central.payne.io` — it passed the existence check but served the wrong cert at runtime, causing TLS errors.
|
||||||
|
|
||||||
|
**Fix:** Parse the cert and verify the SAN covers the requested domain, or at minimum check for `CN=*.domain` when relying on a wildcard.
|
||||||
|
|
||||||
|
## Cert provisioning deploy hook can produce misnamed files
|
||||||
|
|
||||||
|
The certbot deploy hook builds an HAProxy PEM from `/etc/letsencrypt/live/{cert-name}/`. The cert name doesn't always match the domain (e.g. certbot may name a `*.payne.io` wildcard cert as `payne.io`). If the PEM filename doesn't match what `HAProxyCertPath()` expects, the cert won't be found or will be confused with a different cert.
|
||||||
|
|
||||||
|
**Fix:** The deploy hook or `BuildHAProxyCert` should name the PEM based on the cert's actual SAN, not just the certbot cert name.
|
||||||
|
|
||||||
|
## UI has no "re-provision" option when cert exists but is wrong
|
||||||
|
|
||||||
|
The Certificates UI considers a cert valid if the PEM file exists and is non-empty. There's no way to re-provision if the cert covers the wrong domain or is otherwise invalid. The user had to manually delete the bad PEM to trigger re-provisioning.
|
||||||
|
|
||||||
|
**Fix:** Add a "Re-provision" or "Renew" action per domain in the Certificates UI, and show what domain(s) the cert actually covers (parsed from SAN).
|
||||||
257
internal/authelia/config.go
Normal file
257
internal/authelia/config.go
Normal 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
|
||||||
|
}
|
||||||
205
internal/authelia/manager.go
Normal file
205
internal/authelia/manager.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// Package authelia manages the Authelia authentication service.
|
||||||
|
// Authelia provides centralized authentication for network services via
|
||||||
|
// HAProxy forward-auth and acts as an OIDC provider for apps with native support.
|
||||||
|
package authelia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status represents the current state of the Authelia service
|
||||||
|
type Status struct {
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
PID int `json:"pid,omitempty"`
|
||||||
|
LastRestart time.Time `json:"lastRestart,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager handles Authelia configuration and service lifecycle
|
||||||
|
type Manager struct {
|
||||||
|
dataDir string // e.g. /var/lib/wild-central/authelia
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new Authelia manager
|
||||||
|
func NewManager(dataDir string) *Manager {
|
||||||
|
return &Manager{dataDir: dataDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureDataDir creates the Authelia data directory if it doesn't exist
|
||||||
|
func (m *Manager) EnsureDataDir() error {
|
||||||
|
if err := os.MkdirAll(m.dataDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("creating authelia data dir: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInstalled checks if the authelia binary exists on PATH
|
||||||
|
func (m *Manager) IsInstalled() bool {
|
||||||
|
_, err := exec.LookPath("authelia")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const luaScriptPath = "/etc/haproxy/lua/haproxy-auth-request.lua"
|
||||||
|
|
||||||
|
// HasLuaScript checks if the HAProxy auth-request Lua script is installed
|
||||||
|
func (m *Manager) HasLuaScript() bool {
|
||||||
|
info, err := os.Stat(luaScriptPath)
|
||||||
|
return err == nil && info.Size() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns the current Authelia service status
|
||||||
|
func (m *Manager) GetStatus() (*Status, error) {
|
||||||
|
status := &Status{}
|
||||||
|
|
||||||
|
cmd := exec.Command("systemctl", "is-active", "--quiet", "authelia")
|
||||||
|
status.Active = cmd.Run() == nil
|
||||||
|
|
||||||
|
if status.Active {
|
||||||
|
if out, err := exec.Command("systemctl", "show", "authelia.service", "--property=MainPID").Output(); err == nil {
|
||||||
|
parts := strings.Split(strings.TrimSpace(string(out)), "=")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
if pid, err := strconv.Atoi(parts[1]); err == nil {
|
||||||
|
status.PID = pid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := exec.Command("systemctl", "show", "authelia.service", "--property=ActiveEnterTimestamp").Output(); err == nil {
|
||||||
|
parts := strings.Split(strings.TrimSpace(string(out)), "=")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", parts[1]); err == nil {
|
||||||
|
status.LastRestart = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := exec.Command("authelia", "--version").Output(); err == nil {
|
||||||
|
s := string(out)
|
||||||
|
if i := strings.Index(s, "version "); i >= 0 {
|
||||||
|
v := s[i+8:]
|
||||||
|
if j := strings.IndexByte(v, ' '); j > 0 {
|
||||||
|
v = v[:j]
|
||||||
|
}
|
||||||
|
status.Version = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestartService restarts the Authelia systemd service and verifies it stays running.
|
||||||
|
func (m *Manager) RestartService() error {
|
||||||
|
cmd := exec.Command("systemctl", "restart", "authelia.service")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to restart authelia: %w (output: %s)", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give it a moment to start (or crash)
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// Check if it's actually running
|
||||||
|
if exec.Command("systemctl", "is-active", "--quiet", "authelia.service").Run() != nil {
|
||||||
|
// It crashed — grab the error from the journal
|
||||||
|
reason := m.lastError()
|
||||||
|
if reason != "" {
|
||||||
|
return fmt.Errorf("authelia started but crashed: %s", reason)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("authelia started but crashed (check journalctl -u authelia.service)")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("authelia service restarted", "component", "authelia")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastError extracts the most recent error-level message from the Authelia journal.
|
||||||
|
// Authelia logs look like: time="..." level=error msg="..." error="..." provider=...
|
||||||
|
func (m *Manager) lastError() string {
|
||||||
|
cmd := exec.Command("journalctl", "-u", "authelia.service", "-n", "20", "--no-pager", "-o", "cat")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Find the last line containing level=error
|
||||||
|
var lastErr string
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
if strings.Contains(line, "level=error") {
|
||||||
|
lastErr = line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Extract error="..." field (has the actual detail)
|
||||||
|
if i := strings.Index(lastErr, "error=\""); i >= 0 {
|
||||||
|
detail := lastErr[i+7:]
|
||||||
|
if j := strings.Index(detail, "\""); j >= 0 {
|
||||||
|
return detail[:j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to msg="..." field
|
||||||
|
if i := strings.Index(lastErr, "msg=\""); i >= 0 {
|
||||||
|
detail := lastErr[i+5:]
|
||||||
|
if j := strings.Index(detail, "\""); j >= 0 {
|
||||||
|
return detail[:j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopService stops the Authelia systemd service
|
||||||
|
func (m *Manager) StopService() error {
|
||||||
|
cmd := exec.Command("systemctl", "stop", "authelia.service")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stop authelia: %w (output: %s)", err, string(output))
|
||||||
|
}
|
||||||
|
slog.Info("authelia service stopped", "component", "authelia")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadConfig reads the current Authelia configuration file
|
||||||
|
func (m *Manager) ReadConfig() (string, error) {
|
||||||
|
data, err := os.ReadFile(m.configPath())
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading authelia config: %w", err)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataDir returns the Authelia data directory path
|
||||||
|
func (m *Manager) DataDir() string {
|
||||||
|
return m.dataDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) configPath() string {
|
||||||
|
return filepath.Join(m.dataDir, "configuration.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) usersDBPath() string {
|
||||||
|
return filepath.Join(m.dataDir, "users_database.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) sqlitePath() string {
|
||||||
|
return filepath.Join(m.dataDir, "db.sqlite3")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) jwksPath() string {
|
||||||
|
return filepath.Join(m.dataDir, "jwks.pem")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) notificationPath() string {
|
||||||
|
return filepath.Join(m.dataDir, "notification.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) oidcClientsPath() string {
|
||||||
|
return filepath.Join(m.dataDir, "oidc_clients.yml")
|
||||||
|
}
|
||||||
352
internal/authelia/manager_test.go
Normal file
352
internal/authelia/manager_test.go
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
package authelia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateConfig(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := NewManager(dir)
|
||||||
|
|
||||||
|
opts := ConfigOpts{
|
||||||
|
Domain: "auth.payne.io",
|
||||||
|
JWTSecret: "test-jwt-secret",
|
||||||
|
SessionSecret: "test-session-secret",
|
||||||
|
StorageEncKey: "test-storage-encryption-key-min-20",
|
||||||
|
OIDCHMACSecret: "test-oidc-hmac-secret",
|
||||||
|
DefaultPolicy: "one_factor",
|
||||||
|
SessionDomain: "payne.io",
|
||||||
|
DataDir: dir,
|
||||||
|
ListenAddr: "127.0.0.1:9091",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.GenerateConfig(opts); err != nil {
|
||||||
|
t.Fatalf("GenerateConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file exists
|
||||||
|
configPath := filepath.Join(dir, "configuration.yml")
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
// Check key values are present
|
||||||
|
checks := []string{
|
||||||
|
"tcp://127.0.0.1:9091/",
|
||||||
|
"one_factor",
|
||||||
|
"payne.io",
|
||||||
|
"auth.payne.io",
|
||||||
|
"Wild Cloud",
|
||||||
|
"argon2id",
|
||||||
|
"test-session-secret",
|
||||||
|
"test-storage-encryption-key-min-20",
|
||||||
|
}
|
||||||
|
for _, check := range checks {
|
||||||
|
if !strings.Contains(content, check) {
|
||||||
|
t.Errorf("config missing expected value: %q", check)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's valid YAML
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := yaml.Unmarshal(data, &parsed); err != nil {
|
||||||
|
t.Errorf("config is not valid YAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file permissions
|
||||||
|
info, _ := os.Stat(configPath)
|
||||||
|
if info.Mode().Perm() != 0600 {
|
||||||
|
t.Errorf("config permissions = %o, want 0600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateConfigWithOIDCClients(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := NewManager(dir)
|
||||||
|
|
||||||
|
opts := ConfigOpts{
|
||||||
|
Domain: "auth.payne.io",
|
||||||
|
JWTSecret: "jwt",
|
||||||
|
SessionSecret: "session",
|
||||||
|
StorageEncKey: "storage-enc-key-20chars",
|
||||||
|
OIDCHMACSecret: "oidc-hmac",
|
||||||
|
SessionDomain: "payne.io",
|
||||||
|
OIDCClients: []OIDCClient{
|
||||||
|
{
|
||||||
|
ID: "gitea",
|
||||||
|
Name: "Gitea",
|
||||||
|
Secret: "$pbkdf2-sha512$hashed",
|
||||||
|
RedirectURIs: []string{"https://git.payne.io/user/oauth2/authelia/callback"},
|
||||||
|
Scopes: []string{"openid", "profile", "email"},
|
||||||
|
Policy: "one_factor",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.GenerateConfig(opts); err != nil {
|
||||||
|
t.Fatalf("GenerateConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(filepath.Join(dir, "configuration.yml"))
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "gitea") {
|
||||||
|
t.Error("config should contain OIDC client 'gitea'")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "git.payne.io") {
|
||||||
|
t.Error("config should contain redirect URI")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateConfigDefaults(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := NewManager(dir)
|
||||||
|
|
||||||
|
// Minimal opts — should use defaults
|
||||||
|
opts := ConfigOpts{
|
||||||
|
Domain: "auth.example.com",
|
||||||
|
JWTSecret: "jwt",
|
||||||
|
SessionSecret: "session",
|
||||||
|
StorageEncKey: "enc-key-twenty-chars",
|
||||||
|
SessionDomain: "example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.GenerateConfig(opts); err != nil {
|
||||||
|
t.Fatalf("GenerateConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(filepath.Join(dir, "configuration.yml"))
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
// Should default to one_factor
|
||||||
|
if !strings.Contains(content, "one_factor") {
|
||||||
|
t.Error("should default to one_factor policy")
|
||||||
|
}
|
||||||
|
// Should default to 127.0.0.1:9091
|
||||||
|
if !strings.Contains(content, "127.0.0.1:9091") {
|
||||||
|
t.Error("should default to 127.0.0.1:9091")
|
||||||
|
}
|
||||||
|
// Without OIDCHMACSecret, should not have identity_providers
|
||||||
|
if strings.Contains(content, "identity_providers") {
|
||||||
|
t.Error("should not include OIDC section without HMAC secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserCRUD(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := NewManager(dir)
|
||||||
|
|
||||||
|
// List empty
|
||||||
|
users, err := m.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUsers empty: %v", err)
|
||||||
|
}
|
||||||
|
if len(users) != 0 {
|
||||||
|
t.Errorf("expected 0 users, got %d", len(users))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create
|
||||||
|
err = m.CreateUser(User{
|
||||||
|
Username: "admin",
|
||||||
|
Displayname: "Admin User",
|
||||||
|
Password: "supersecret",
|
||||||
|
Email: "admin@example.com",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List after create
|
||||||
|
users, err = m.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUsers: %v", err)
|
||||||
|
}
|
||||||
|
if len(users) != 1 {
|
||||||
|
t.Fatalf("expected 1 user, got %d", len(users))
|
||||||
|
}
|
||||||
|
if users[0].Username != "admin" {
|
||||||
|
t.Errorf("username = %q, want %q", users[0].Username, "admin")
|
||||||
|
}
|
||||||
|
if users[0].Displayname != "Admin User" {
|
||||||
|
t.Errorf("displayname = %q, want %q", users[0].Displayname, "Admin User")
|
||||||
|
}
|
||||||
|
if users[0].Email != "admin@example.com" {
|
||||||
|
t.Errorf("email = %q, want %q", users[0].Email, "admin@example.com")
|
||||||
|
}
|
||||||
|
// Password should be omitted in listing
|
||||||
|
if users[0].Password != "" {
|
||||||
|
t.Error("password should be empty in listing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate create should fail
|
||||||
|
err = m.CreateUser(User{Username: "admin", Password: "x", Displayname: "dup"})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("duplicate create should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update
|
||||||
|
newName := "Super Admin"
|
||||||
|
err = m.UpdateUser("admin", UserUpdate{Displayname: &newName})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateUser: %v", err)
|
||||||
|
}
|
||||||
|
users, _ = m.ListUsers()
|
||||||
|
if users[0].Displayname != "Super Admin" {
|
||||||
|
t.Errorf("after update, displayname = %q, want %q", users[0].Displayname, "Super Admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update nonexistent user
|
||||||
|
err = m.UpdateUser("nobody", UserUpdate{Displayname: &newName})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("update nonexistent should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
err = m.DeleteUser("admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteUser: %v", err)
|
||||||
|
}
|
||||||
|
users, _ = m.ListUsers()
|
||||||
|
if len(users) != 0 {
|
||||||
|
t.Errorf("after delete, expected 0 users, got %d", len(users))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete nonexistent
|
||||||
|
err = m.DeleteUser("admin")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("delete nonexistent should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUserValidation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := NewManager(dir)
|
||||||
|
|
||||||
|
if err := m.CreateUser(User{Password: "x", Displayname: "y"}); err == nil {
|
||||||
|
t.Error("empty username should fail")
|
||||||
|
}
|
||||||
|
if err := m.CreateUser(User{Username: "x", Displayname: "y"}); err == nil {
|
||||||
|
t.Error("empty password should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashPassword(t *testing.T) {
|
||||||
|
hash, err := hashPassword("testpassword")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hashPassword: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(hash, "$argon2id$v=19$") {
|
||||||
|
t.Errorf("hash should start with $argon2id$v=19$, got %q", hash[:30])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two hashes of the same password should differ (different salt)
|
||||||
|
hash2, _ := hashPassword("testpassword")
|
||||||
|
if hash == hash2 {
|
||||||
|
t.Error("two hashes of the same password should differ (random salt)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserCount(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := NewManager(dir)
|
||||||
|
|
||||||
|
if m.UserCount() != 0 {
|
||||||
|
t.Error("empty db should have 0 users")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.CreateUser(User{Username: "a", Password: "p", Displayname: "A"})
|
||||||
|
m.CreateUser(User{Username: "b", Password: "p", Displayname: "B"})
|
||||||
|
|
||||||
|
if m.UserCount() != 2 {
|
||||||
|
t.Errorf("expected 2 users, got %d", m.UserCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureUsersDB(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := NewManager(dir)
|
||||||
|
|
||||||
|
if err := m.EnsureUsersDB(); err != nil {
|
||||||
|
t.Fatalf("EnsureUsersDB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File should exist
|
||||||
|
if _, err := os.Stat(m.usersDBPath()); err != nil {
|
||||||
|
t.Errorf("users db file should exist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be idempotent
|
||||||
|
if err := m.EnsureUsersDB(); err != nil {
|
||||||
|
t.Fatalf("EnsureUsersDB (second call): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureJWKS(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := NewManager(dir)
|
||||||
|
|
||||||
|
if err := m.EnsureJWKS(); err != nil {
|
||||||
|
t.Fatalf("EnsureJWKS: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File should exist
|
||||||
|
data, err := os.ReadFile(m.jwksPath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading JWKS: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "RSA PRIVATE KEY") {
|
||||||
|
t.Error("JWKS file should contain RSA private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be idempotent (doesn't regenerate)
|
||||||
|
if err := m.EnsureJWKS(); err != nil {
|
||||||
|
t.Fatalf("EnsureJWKS (second call): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSecret(t *testing.T) {
|
||||||
|
s1, err := GenerateSecret(32)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSecret: %v", err)
|
||||||
|
}
|
||||||
|
if len(s1) != 64 { // 32 bytes = 64 hex chars
|
||||||
|
t.Errorf("secret length = %d, want 64", len(s1))
|
||||||
|
}
|
||||||
|
|
||||||
|
s2, _ := GenerateSecret(32)
|
||||||
|
if s1 == s2 {
|
||||||
|
t.Error("two generated secrets should differ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionDomainFromAuthDomain(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"auth.payne.io", "payne.io"},
|
||||||
|
{"auth.sub.example.com", "sub.example.com"},
|
||||||
|
{"localhost", "localhost"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := SessionDomainFromAuthDomain(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("SessionDomainFromAuthDomain(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsInstalled(t *testing.T) {
|
||||||
|
m := NewManager(t.TempDir())
|
||||||
|
// Just verify it doesn't panic — the result depends on whether authelia is installed
|
||||||
|
_ = m.IsInstalled()
|
||||||
|
}
|
||||||
219
internal/authelia/oidc.go
Normal file
219
internal/authelia/oidc.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package authelia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/wild-cloud/wild-central/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OIDCClient represents a registered OIDC client application
|
||||||
|
type OIDCClient struct {
|
||||||
|
ID string `yaml:"client_id" json:"clientId"`
|
||||||
|
Name string `yaml:"client_name" json:"clientName"`
|
||||||
|
Secret string `yaml:"client_secret" json:"clientSecret,omitempty"` // pbkdf2 hash in config, cleartext on create response
|
||||||
|
RedirectURIs []string `yaml:"redirect_uris" json:"redirectUris"`
|
||||||
|
Scopes []string `yaml:"scopes" json:"scopes"`
|
||||||
|
Policy string `yaml:"authorization_policy" json:"authorizationPolicy"`
|
||||||
|
ConsentMode string `yaml:"consent_mode,omitempty" json:"consentMode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDCClientUpdate holds partial updates for an OIDC client
|
||||||
|
type OIDCClientUpdate struct {
|
||||||
|
Name *string `json:"clientName,omitempty"`
|
||||||
|
RedirectURIs []string `json:"redirectUris,omitempty"`
|
||||||
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
|
Policy *string `json:"authorizationPolicy,omitempty"`
|
||||||
|
ConsentMode *string `json:"consentMode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// oidcClientsFile is the top-level structure of the OIDC clients file
|
||||||
|
type oidcClientsFile struct {
|
||||||
|
Clients []OIDCClient `yaml:"clients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOIDCClients returns all registered OIDC clients (secrets redacted)
|
||||||
|
func (m *Manager) ListOIDCClients() ([]OIDCClient, error) {
|
||||||
|
clients, err := m.readOIDCClients()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redact secrets for listing
|
||||||
|
for i := range clients {
|
||||||
|
clients[i].Secret = ""
|
||||||
|
}
|
||||||
|
return clients, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadOIDCClients returns all clients with secrets (for config generation)
|
||||||
|
func (m *Manager) ReadOIDCClients() ([]OIDCClient, error) {
|
||||||
|
return m.readOIDCClients()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddOIDCClient adds a new OIDC client. Returns the cleartext secret (shown once).
|
||||||
|
func (m *Manager) AddOIDCClient(client OIDCClient) (cleartextSecret string, err error) {
|
||||||
|
if client.ID == "" {
|
||||||
|
return "", fmt.Errorf("client_id is required")
|
||||||
|
}
|
||||||
|
if client.Name == "" {
|
||||||
|
return "", fmt.Errorf("client_name is required")
|
||||||
|
}
|
||||||
|
if len(client.RedirectURIs) == 0 {
|
||||||
|
return "", fmt.Errorf("at least one redirect_uri is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
lockPath := m.configPath() + ".lock"
|
||||||
|
err = storage.WithLock(lockPath, func() error {
|
||||||
|
clients, readErr := m.readOIDCClients()
|
||||||
|
if readErr != nil {
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range clients {
|
||||||
|
if c.ID == client.ID {
|
||||||
|
return fmt.Errorf("client %q already exists", client.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate client secret
|
||||||
|
secret, genErr := GenerateSecret(32)
|
||||||
|
if genErr != nil {
|
||||||
|
return fmt.Errorf("generating client secret: %w", genErr)
|
||||||
|
}
|
||||||
|
cleartextSecret = secret
|
||||||
|
|
||||||
|
// Hash for storage
|
||||||
|
client.Secret = hashClientSecret(secret)
|
||||||
|
|
||||||
|
// Default scopes
|
||||||
|
if len(client.Scopes) == 0 {
|
||||||
|
client.Scopes = []string{"openid", "profile", "email", "groups"}
|
||||||
|
}
|
||||||
|
if client.Policy == "" {
|
||||||
|
client.Policy = "one_factor"
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.writeOIDCClients(append(clients, client))
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOIDCClient applies partial updates to an OIDC client
|
||||||
|
func (m *Manager) UpdateOIDCClient(clientID string, updates OIDCClientUpdate) error {
|
||||||
|
lockPath := m.configPath() + ".lock"
|
||||||
|
return storage.WithLock(lockPath, func() error {
|
||||||
|
clients, err := m.readOIDCClients()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i, c := range clients {
|
||||||
|
if c.ID == clientID {
|
||||||
|
if updates.Name != nil {
|
||||||
|
clients[i].Name = *updates.Name
|
||||||
|
}
|
||||||
|
if updates.RedirectURIs != nil {
|
||||||
|
clients[i].RedirectURIs = updates.RedirectURIs
|
||||||
|
}
|
||||||
|
if updates.Scopes != nil {
|
||||||
|
clients[i].Scopes = updates.Scopes
|
||||||
|
}
|
||||||
|
if updates.Policy != nil {
|
||||||
|
clients[i].Policy = *updates.Policy
|
||||||
|
}
|
||||||
|
if updates.ConsentMode != nil {
|
||||||
|
clients[i].ConsentMode = *updates.ConsentMode
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("client %q not found", clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.writeOIDCClients(clients)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOIDCClient removes an OIDC client by ID
|
||||||
|
func (m *Manager) DeleteOIDCClient(clientID string) error {
|
||||||
|
lockPath := m.configPath() + ".lock"
|
||||||
|
return storage.WithLock(lockPath, func() error {
|
||||||
|
clients, err := m.readOIDCClients()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered []OIDCClient
|
||||||
|
found := false
|
||||||
|
for _, c := range clients {
|
||||||
|
if c.ID == clientID {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, c)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("client %q not found", clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.writeOIDCClients(filtered)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDCClientCount returns the number of registered OIDC clients
|
||||||
|
func (m *Manager) OIDCClientCount() int {
|
||||||
|
clients, err := m.readOIDCClients()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readOIDCClients reads OIDC clients from the dedicated clients file
|
||||||
|
func (m *Manager) readOIDCClients() ([]OIDCClient, error) {
|
||||||
|
data, err := os.ReadFile(m.oidcClientsPath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("reading oidc clients: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var f oidcClientsFile
|
||||||
|
if err := yaml.Unmarshal(data, &f); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing oidc clients: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.Clients, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeOIDCClients writes the OIDC clients to the dedicated clients file
|
||||||
|
func (m *Manager) writeOIDCClients(clients []OIDCClient) error {
|
||||||
|
f := oidcClientsFile{Clients: clients}
|
||||||
|
data, err := yaml.Marshal(f)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling oidc clients: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := m.oidcClientsPath() + ".tmp"
|
||||||
|
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("writing temp oidc clients: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, m.oidcClientsPath()); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("installing oidc clients: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashClientSecret wraps the secret with $plaintext$ prefix.
|
||||||
|
// Authelia accepts this format and handles hashing internally.
|
||||||
|
func hashClientSecret(secret string) string {
|
||||||
|
return "$plaintext$" + secret
|
||||||
|
}
|
||||||
242
internal/authelia/users.go
Normal file
242
internal/authelia/users.go
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
package authelia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/wild-cloud/wild-central/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User represents an Authelia user account
|
||||||
|
type User struct {
|
||||||
|
Username string `json:"username" yaml:"-"`
|
||||||
|
Displayname string `json:"displayname" yaml:"displayname"`
|
||||||
|
Password string `json:"password,omitempty" yaml:"password"` // argon2id hash (cleartext on create, never returned)
|
||||||
|
Email string `json:"email,omitempty" yaml:"email,omitempty"`
|
||||||
|
Groups []string `json:"groups,omitempty" yaml:"groups,omitempty"`
|
||||||
|
Disabled bool `json:"disabled,omitempty" yaml:"disabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserUpdate holds partial updates for a user
|
||||||
|
type UserUpdate struct {
|
||||||
|
Displayname *string `json:"displayname,omitempty"`
|
||||||
|
Password *string `json:"password,omitempty"` // cleartext, will be hashed
|
||||||
|
Email *string `json:"email,omitempty"`
|
||||||
|
Groups []string `json:"groups,omitempty"`
|
||||||
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// usersDB is the top-level structure of Authelia's users_database.yml
|
||||||
|
type usersDB struct {
|
||||||
|
Users map[string]*userEntry `yaml:"users"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userEntry struct {
|
||||||
|
Displayname string `yaml:"displayname"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
Email string `yaml:"email,omitempty"`
|
||||||
|
Groups []string `yaml:"groups,omitempty"`
|
||||||
|
Disabled bool `yaml:"disabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns all users from the database (passwords omitted)
|
||||||
|
func (m *Manager) ListUsers() ([]User, error) {
|
||||||
|
db, err := m.readUsersDB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var users []User
|
||||||
|
for name, entry := range db.Users {
|
||||||
|
users = append(users, User{
|
||||||
|
Username: name,
|
||||||
|
Displayname: entry.Displayname,
|
||||||
|
Email: entry.Email,
|
||||||
|
Groups: entry.Groups,
|
||||||
|
Disabled: entry.Disabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(users, func(i, j int) bool {
|
||||||
|
return users[i].Username < users[j].Username
|
||||||
|
})
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser adds a new user with an argon2id-hashed password
|
||||||
|
func (m *Manager) CreateUser(user User) error {
|
||||||
|
if user.Username == "" {
|
||||||
|
return fmt.Errorf("username is required")
|
||||||
|
}
|
||||||
|
if user.Password == "" {
|
||||||
|
return fmt.Errorf("password is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
lockPath := m.usersDBPath() + ".lock"
|
||||||
|
return storage.WithLock(lockPath, func() error {
|
||||||
|
db, err := m.readUsersDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := db.Users[user.Username]; exists {
|
||||||
|
return fmt.Errorf("user %q already exists", user.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := hashPassword(user.Password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hashing password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Users[user.Username] = &userEntry{
|
||||||
|
Displayname: user.Displayname,
|
||||||
|
Password: hash,
|
||||||
|
Email: user.Email,
|
||||||
|
Groups: user.Groups,
|
||||||
|
Disabled: user.Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.writeUsersDB(db)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser applies partial updates to an existing user
|
||||||
|
func (m *Manager) UpdateUser(username string, updates UserUpdate) error {
|
||||||
|
lockPath := m.usersDBPath() + ".lock"
|
||||||
|
return storage.WithLock(lockPath, func() error {
|
||||||
|
db, err := m.readUsersDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, exists := db.Users[username]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("user %q not found", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
if updates.Displayname != nil {
|
||||||
|
entry.Displayname = *updates.Displayname
|
||||||
|
}
|
||||||
|
if updates.Email != nil {
|
||||||
|
entry.Email = *updates.Email
|
||||||
|
}
|
||||||
|
if updates.Disabled != nil {
|
||||||
|
entry.Disabled = *updates.Disabled
|
||||||
|
}
|
||||||
|
if updates.Groups != nil {
|
||||||
|
entry.Groups = updates.Groups
|
||||||
|
}
|
||||||
|
if updates.Password != nil {
|
||||||
|
hash, err := hashPassword(*updates.Password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hashing password: %w", err)
|
||||||
|
}
|
||||||
|
entry.Password = hash
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.writeUsersDB(db)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser removes a user from the database
|
||||||
|
func (m *Manager) DeleteUser(username string) error {
|
||||||
|
lockPath := m.usersDBPath() + ".lock"
|
||||||
|
return storage.WithLock(lockPath, func() error {
|
||||||
|
db, err := m.readUsersDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := db.Users[username]; !exists {
|
||||||
|
return fmt.Errorf("user %q not found", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(db.Users, username)
|
||||||
|
return m.writeUsersDB(db)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserCount returns the number of users in the database
|
||||||
|
func (m *Manager) UserCount() int {
|
||||||
|
db, err := m.readUsersDB()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(db.Users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureUsersDB creates an empty users database file if one doesn't exist
|
||||||
|
func (m *Manager) EnsureUsersDB() error {
|
||||||
|
if _, err := os.Stat(m.usersDBPath()); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db := &usersDB{Users: map[string]*userEntry{}}
|
||||||
|
return m.writeUsersDB(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) readUsersDB() (*usersDB, error) {
|
||||||
|
data, err := os.ReadFile(m.usersDBPath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &usersDB{Users: map[string]*userEntry{}}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("reading users db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db := &usersDB{Users: map[string]*userEntry{}}
|
||||||
|
if err := yaml.Unmarshal(data, db); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing users db: %w", err)
|
||||||
|
}
|
||||||
|
if db.Users == nil {
|
||||||
|
db.Users = map[string]*userEntry{}
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) writeUsersDB(db *usersDB) error {
|
||||||
|
data, err := yaml.Marshal(db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling users db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := m.usersDBPath() + ".tmp"
|
||||||
|
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("writing temp users db: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, m.usersDBPath()); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("installing users db: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashPassword creates an argon2id hash in Authelia's expected format.
|
||||||
|
// Parameters tuned for Raspberry Pi: memory=64MB, iterations=3, parallelism=2.
|
||||||
|
func hashPassword(password string) (string, error) {
|
||||||
|
salt := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", fmt.Errorf("generating salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
memory = 64 * 1024 // 64MB
|
||||||
|
iterations = 3
|
||||||
|
parallel = 2
|
||||||
|
keyLen = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
hash := argon2.IDKey([]byte(password), salt, iterations, memory, uint8(parallel), keyLen)
|
||||||
|
|
||||||
|
// Authelia expects: $argon2id$v=19$m=65536,t=3,p=2$<base64-salt>$<base64-hash>
|
||||||
|
saltB64 := base64.RawStdEncoding.EncodeToString(salt)
|
||||||
|
hashB64 := base64.RawStdEncoding.EncodeToString(hash)
|
||||||
|
|
||||||
|
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||||
|
memory, iterations, parallel, saltB64, hashB64), nil
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
550
internal/dnsfilter/manager.go
Normal file
550
internal/dnsfilter/manager.go
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
package dnsfilter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wild-cloud/wild-central/internal/storage"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Blocklist represents a subscribed blocklist source.
|
||||||
|
type Blocklist struct {
|
||||||
|
ID string `yaml:"id" json:"id"`
|
||||||
|
URL string `yaml:"url,omitempty" json:"url,omitempty"`
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
|
EntryCount int `yaml:"entryCount,omitempty" json:"entryCount,omitempty"`
|
||||||
|
LastUpdated time.Time `yaml:"lastUpdated,omitempty" json:"lastUpdated,omitzero"`
|
||||||
|
LastError string `yaml:"lastError,omitempty" json:"lastError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomEntry represents a user-managed domain override.
|
||||||
|
type CustomEntry struct {
|
||||||
|
Domain string `yaml:"domain" json:"domain"`
|
||||||
|
Type string `yaml:"type" json:"type"` // "allow" or "block"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterData holds all DNS filter configuration persisted in lists.yaml.
|
||||||
|
type FilterData struct {
|
||||||
|
Lists []Blocklist `yaml:"lists" json:"lists"`
|
||||||
|
CustomEntries []CustomEntry `yaml:"customEntries" json:"customEntries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterStats holds current filtering statistics.
|
||||||
|
type FilterStats struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
TotalBlocked int `json:"totalBlocked"`
|
||||||
|
ListCount int `json:"listCount"`
|
||||||
|
EnabledLists int `json:"enabledLists"`
|
||||||
|
LastUpdated time.Time `json:"lastUpdated"`
|
||||||
|
QueryStats *QueryStats `json:"queryStats,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryStats holds DNS query statistics from dnsmasq logs.
|
||||||
|
type QueryStats struct {
|
||||||
|
TotalQueries int `json:"totalQueries"`
|
||||||
|
BlockedQueries int `json:"blockedQueries"`
|
||||||
|
BlockedPercent float64 `json:"blockedPercent"`
|
||||||
|
Period string `json:"period"` // e.g. "24h"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager handles DNS filter list management and compilation.
|
||||||
|
type Manager struct {
|
||||||
|
dataDir string // e.g. /var/lib/wild-central/dns-filter
|
||||||
|
hostsFilePath string // compiled output path (must be readable by dnsmasq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new DNS filter manager.
|
||||||
|
func NewManager(dataDir string) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
dataDir: dataDir,
|
||||||
|
hostsFilePath: filepath.Join(dataDir, "blocked.conf"), // default, override with SetHostsFilePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHostsFilePath sets the output path for the compiled hosts file.
|
||||||
|
// Must be readable by the dnsmasq process (e.g. /etc/dnsmasq.d/).
|
||||||
|
func (m *Manager) SetHostsFilePath(path string) {
|
||||||
|
m.hostsFilePath = path
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) listsPath() string { return filepath.Join(m.dataDir, "lists.yaml") }
|
||||||
|
func (m *Manager) cachePath() string { return filepath.Join(m.dataDir, "cache") }
|
||||||
|
func (m *Manager) lockPath() string { return filepath.Join(m.dataDir, "lists.yaml.lock") }
|
||||||
|
|
||||||
|
// HostsFilePath returns the path to the compiled blocked hosts file.
|
||||||
|
func (m *Manager) HostsFilePath() string { return m.hostsFilePath }
|
||||||
|
|
||||||
|
// EnsureDataDir creates the dns-filter directory structure.
|
||||||
|
func (m *Manager) EnsureDataDir() error {
|
||||||
|
if err := storage.EnsureDir(m.dataDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Also ensure the hosts file output directory exists (may differ from dataDir)
|
||||||
|
hostsDir := filepath.Dir(m.hostsFilePath)
|
||||||
|
if hostsDir != m.dataDir {
|
||||||
|
if err := storage.EnsureDir(hostsDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return storage.EnsureDir(m.cachePath(), 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFilterData reads lists.yaml. Returns empty data if file doesn't exist.
|
||||||
|
func (m *Manager) LoadFilterData() (*FilterData, error) {
|
||||||
|
data, err := os.ReadFile(m.listsPath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &FilterData{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("reading filter data: %w", err)
|
||||||
|
}
|
||||||
|
var fd FilterData
|
||||||
|
if err := yaml.Unmarshal(data, &fd); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing filter data: %w", err)
|
||||||
|
}
|
||||||
|
return &fd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveFilterData writes lists.yaml atomically with file locking.
|
||||||
|
func (m *Manager) SaveFilterData(fd *FilterData) error {
|
||||||
|
return storage.WithLock(m.lockPath(), func() error {
|
||||||
|
data, err := yaml.Marshal(fd)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling filter data: %w", err)
|
||||||
|
}
|
||||||
|
return os.WriteFile(m.listsPath(), data, 0644)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// listID returns a stable ID for a URL-based list.
|
||||||
|
func listID(url string) string {
|
||||||
|
h := sha256.Sum256([]byte(url))
|
||||||
|
return fmt.Sprintf("%x", h[:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheFile returns the cache file path for a list by ID.
|
||||||
|
func (m *Manager) cacheFile(id string) string {
|
||||||
|
return filepath.Join(m.cachePath(), id+".txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddList adds a URL-based blocklist subscription.
|
||||||
|
func (m *Manager) AddList(bl Blocklist) error {
|
||||||
|
fd, err := m.LoadFilterData()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if bl.URL != "" {
|
||||||
|
bl.ID = listID(bl.URL)
|
||||||
|
}
|
||||||
|
if bl.ID == "" {
|
||||||
|
return fmt.Errorf("list must have a URL or ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
for _, existing := range fd.Lists {
|
||||||
|
if existing.ID == bl.ID {
|
||||||
|
return fmt.Errorf("list already exists: %s", bl.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bl.Enabled = true
|
||||||
|
fd.Lists = append(fd.Lists, bl)
|
||||||
|
return m.SaveFilterData(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddUploadedList saves an uploaded blocklist file and adds it as a list entry.
|
||||||
|
func (m *Manager) AddUploadedList(name string, content []byte) (string, error) {
|
||||||
|
if err := m.EnsureDataDir(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
h := sha256.Sum256(content)
|
||||||
|
id := fmt.Sprintf("upload-%x", h[:8])
|
||||||
|
|
||||||
|
// Write content to cache
|
||||||
|
if err := os.WriteFile(m.cacheFile(id), content, 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("writing uploaded list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count entries
|
||||||
|
domains, _ := ParseFile(m.cacheFile(id))
|
||||||
|
|
||||||
|
bl := Blocklist{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Enabled: true,
|
||||||
|
EntryCount: len(domains),
|
||||||
|
LastUpdated: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
fd, err := m.LoadFilterData()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
for _, existing := range fd.Lists {
|
||||||
|
if existing.ID == id {
|
||||||
|
return id, fmt.Errorf("identical list already uploaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fd.Lists = append(fd.Lists, bl)
|
||||||
|
return id, m.SaveFilterData(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveList removes a list by ID and its cached file.
|
||||||
|
func (m *Manager) RemoveList(id string) error {
|
||||||
|
fd, err := m.LoadFilterData()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
filtered := fd.Lists[:0]
|
||||||
|
for _, bl := range fd.Lists {
|
||||||
|
if bl.ID == id {
|
||||||
|
found = true
|
||||||
|
_ = os.Remove(m.cacheFile(id))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, bl)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("list not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fd.Lists = filtered
|
||||||
|
return m.SaveFilterData(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleList enables or disables a list by ID.
|
||||||
|
func (m *Manager) ToggleList(id string, enabled bool) error {
|
||||||
|
fd, err := m.LoadFilterData()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range fd.Lists {
|
||||||
|
if fd.Lists[i].ID == id {
|
||||||
|
fd.Lists[i].Enabled = enabled
|
||||||
|
return m.SaveFilterData(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("list not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCustomEntry adds or replaces a custom allow/block entry.
|
||||||
|
func (m *Manager) SetCustomEntry(entry CustomEntry) error {
|
||||||
|
if entry.Type != "allow" && entry.Type != "block" {
|
||||||
|
return fmt.Errorf("type must be 'allow' or 'block'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fd, err := m.LoadFilterData()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace if exists
|
||||||
|
for i := range fd.CustomEntries {
|
||||||
|
if fd.CustomEntries[i].Domain == entry.Domain {
|
||||||
|
fd.CustomEntries[i].Type = entry.Type
|
||||||
|
return m.SaveFilterData(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fd.CustomEntries = append(fd.CustomEntries, entry)
|
||||||
|
return m.SaveFilterData(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveCustomEntry removes a custom entry by domain.
|
||||||
|
func (m *Manager) RemoveCustomEntry(domain string) error {
|
||||||
|
fd, err := m.LoadFilterData()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
filtered := fd.CustomEntries[:0]
|
||||||
|
for _, e := range fd.CustomEntries {
|
||||||
|
if e.Domain == domain {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, e)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("custom entry not found: %s", domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
fd.CustomEntries = filtered
|
||||||
|
return m.SaveFilterData(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLists downloads all enabled URL-based lists to cache.
|
||||||
|
func (m *Manager) UpdateLists(ctx context.Context) error {
|
||||||
|
if err := m.EnsureDataDir(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fd, err := m.LoadFilterData()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
changed := false
|
||||||
|
|
||||||
|
for i := range fd.Lists {
|
||||||
|
bl := &fd.Lists[i]
|
||||||
|
if !bl.Enabled || bl.URL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("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
|
||||||
|
}
|
||||||
89
internal/dnsfilter/parser.go
Normal file
89
internal/dnsfilter/parser.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package dnsfilter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseLine extracts a domain from a single blocklist line.
|
||||||
|
// Handles hosts format (0.0.0.0 domain), dnsmasq format (address=/domain/...),
|
||||||
|
// and plain domain lists. Returns empty string and false for unparseable lines.
|
||||||
|
func ParseLine(line string) (string, bool) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || line[0] == '#' || line[0] == '!' {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// dnsmasq format: address=/domain/0.0.0.0 or server=/domain/
|
||||||
|
if strings.HasPrefix(line, "address=/") || strings.HasPrefix(line, "server=/") {
|
||||||
|
parts := strings.SplitN(line, "/", 4)
|
||||||
|
if len(parts) >= 3 && parts[1] != "" {
|
||||||
|
return validateDomain(parts[1])
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hosts format: 0.0.0.0 domain or 127.0.0.1 domain
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 2 {
|
||||||
|
ip := fields[0]
|
||||||
|
if ip == "0.0.0.0" || ip == "127.0.0.1" || ip == "::1" || ip == "::0" {
|
||||||
|
domain := fields[1]
|
||||||
|
// Strip inline comments
|
||||||
|
if i := strings.IndexByte(domain, '#'); i >= 0 {
|
||||||
|
domain = domain[:i]
|
||||||
|
}
|
||||||
|
return validateDomain(domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain domain format: one domain per line
|
||||||
|
if len(fields) == 1 {
|
||||||
|
return validateDomain(fields[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateDomain checks that a string looks like a valid domain for blocking.
|
||||||
|
func validateDomain(d string) (string, bool) {
|
||||||
|
d = strings.ToLower(strings.TrimSpace(d))
|
||||||
|
if d == "" || !strings.Contains(d, ".") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject known non-domains
|
||||||
|
switch d {
|
||||||
|
case "localhost", "localhost.localdomain", "local", "broadcasthost",
|
||||||
|
"ip6-localhost", "ip6-loopback", "ip6-localnet",
|
||||||
|
"ip6-mcastprefix", "ip6-allnodes", "ip6-allrouters":
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject IP addresses
|
||||||
|
if net.ParseIP(d) != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return d, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFile reads a cached blocklist file and returns all unique domains.
|
||||||
|
func ParseFile(path string) (map[string]struct{}, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
domains := make(map[string]struct{})
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
if domain, ok := ParseLine(scanner.Text()); ok {
|
||||||
|
domains[domain] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domains, scanner.Err()
|
||||||
|
}
|
||||||
141
internal/dnsfilter/parser_test.go
Normal file
141
internal/dnsfilter/parser_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package dnsfilter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseLine_HostsFormat(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
line string
|
||||||
|
want string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"0.0.0.0 ads.example.com", "ads.example.com", true},
|
||||||
|
{"127.0.0.1 tracker.example.com", "tracker.example.com", true},
|
||||||
|
{"0.0.0.0 ads.example.com # comment", "ads.example.com", true},
|
||||||
|
{"::1 ads.example.com", "ads.example.com", true},
|
||||||
|
{"0.0.0.0 localhost", "", false},
|
||||||
|
{"0.0.0.0 broadcasthost", "", false},
|
||||||
|
{"0.0.0.0 localhost.localdomain", "", false},
|
||||||
|
{"127.0.0.1 local", "", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, ok := ParseLine(tt.line)
|
||||||
|
if ok != tt.wantOK || got != tt.want {
|
||||||
|
t.Errorf("ParseLine(%q) = (%q, %v), want (%q, %v)", tt.line, got, ok, tt.want, tt.wantOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLine_DnsmasqFormat(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
line string
|
||||||
|
want string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"address=/ads.example.com/0.0.0.0", "ads.example.com", true},
|
||||||
|
{"address=/tracker.example.com/", "tracker.example.com", true},
|
||||||
|
{"server=/blocked.example.com/", "blocked.example.com", true},
|
||||||
|
{"address=//", "", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, ok := ParseLine(tt.line)
|
||||||
|
if ok != tt.wantOK || got != tt.want {
|
||||||
|
t.Errorf("ParseLine(%q) = (%q, %v), want (%q, %v)", tt.line, got, ok, tt.want, tt.wantOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLine_PlainDomain(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
line string
|
||||||
|
want string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"ads.example.com", "ads.example.com", true},
|
||||||
|
{"TRACKER.Example.COM", "tracker.example.com", true},
|
||||||
|
{"nodot", "", false},
|
||||||
|
{"# comment", "", false},
|
||||||
|
{"", "", false},
|
||||||
|
{" ", "", false},
|
||||||
|
{"192.168.1.1", "", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, ok := ParseLine(tt.line)
|
||||||
|
if ok != tt.wantOK || got != tt.want {
|
||||||
|
t.Errorf("ParseLine(%q) = (%q, %v), want (%q, %v)", tt.line, got, ok, tt.want, tt.wantOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFile(t *testing.T) {
|
||||||
|
content := `# Steven Black hosts file
|
||||||
|
0.0.0.0 localhost
|
||||||
|
0.0.0.0 ads.example.com
|
||||||
|
0.0.0.0 tracker.example.com
|
||||||
|
127.0.0.1 more-ads.example.com
|
||||||
|
# duplicate
|
||||||
|
0.0.0.0 ads.example.com
|
||||||
|
`
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.hosts")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
domains, err := ParseFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 3 unique domains (localhost excluded)
|
||||||
|
if len(domains) != 3 {
|
||||||
|
t.Errorf("got %d domains, want 3", len(domains))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, expected := range []string{"ads.example.com", "tracker.example.com", "more-ads.example.com"} {
|
||||||
|
if _, ok := domains[expected]; !ok {
|
||||||
|
t.Errorf("missing expected domain: %s", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFile_MixedFormats(t *testing.T) {
|
||||||
|
content := `# Mixed format file
|
||||||
|
0.0.0.0 hosts-format.example.com
|
||||||
|
address=/dnsmasq-format.example.com/0.0.0.0
|
||||||
|
plain-domain.example.com
|
||||||
|
! adblock comment
|
||||||
|
127.0.0.1 another.example.com
|
||||||
|
`
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "mixed.txt")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
domains, err := ParseFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(domains) != 4 {
|
||||||
|
t.Errorf("got %d domains, want 4", len(domains))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, expected := range []string{
|
||||||
|
"hosts-format.example.com",
|
||||||
|
"dnsmasq-format.example.com",
|
||||||
|
"plain-domain.example.com",
|
||||||
|
"another.example.com",
|
||||||
|
} {
|
||||||
|
if _, ok := domains[expected]; !ok {
|
||||||
|
t.Errorf("missing expected domain: %s", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
internal/dnsfilter/runner.go
Normal file
115
internal/dnsfilter/runner.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package dnsfilter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Runner manages the background blocklist update goroutine.
|
||||||
|
type Runner struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
manager *Manager
|
||||||
|
cancel context.CancelFunc
|
||||||
|
trigger chan struct{}
|
||||||
|
running bool
|
||||||
|
onUpdate func() // called after lists are downloaded and compiled
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunner creates a new DNS filter update runner.
|
||||||
|
func NewRunner(manager *Manager, onUpdate func()) *Runner {
|
||||||
|
return &Runner{
|
||||||
|
manager: manager,
|
||||||
|
trigger: make(chan struct{}, 1),
|
||||||
|
onUpdate: onUpdate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start launches the background update goroutine.
|
||||||
|
// Safe to call multiple times — cancels the previous run first.
|
||||||
|
func (r *Runner) Start(parentCtx context.Context, intervalHours int) {
|
||||||
|
r.mu.Lock()
|
||||||
|
if r.cancel != nil {
|
||||||
|
r.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(parentCtx)
|
||||||
|
r.cancel = cancel
|
||||||
|
r.running = true
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if intervalHours <= 0 {
|
||||||
|
intervalHours = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
go r.run(ctx, intervalHours)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop cancels the running goroutine.
|
||||||
|
func (r *Runner) Stop() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.cancel != nil {
|
||||||
|
r.cancel()
|
||||||
|
r.cancel = nil
|
||||||
|
}
|
||||||
|
r.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning returns whether the runner is active.
|
||||||
|
func (r *Runner) IsRunning() bool {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return r.running
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger requests an immediate update (non-blocking).
|
||||||
|
func (r *Runner) Trigger() {
|
||||||
|
select {
|
||||||
|
case r.trigger <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) run(ctx context.Context, intervalHours int) {
|
||||||
|
interval := time.Duration(intervalHours) * time.Hour
|
||||||
|
|
||||||
|
slog.Info("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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -82,6 +84,10 @@ func (m *Manager) GetConfigPath() string {
|
|||||||
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
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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")
|
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))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1
main.go
1
main.go
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
<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>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 361 B After Width: | Height: | Size: 1.5 KiB |
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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,8 +190,15 @@ export function AppSidebar() {
|
|||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
||||||
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Advanced</SidebarGroupLabel>
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarGroupLabel className="cursor-pointer select-none">
|
||||||
|
Advanced
|
||||||
|
<ChevronRight className={`ml-auto h-3.5 w-3.5 transition-transform duration-200 ${advancedOpen ? 'rotate-90' : ''}`} />
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{advancedItems.map(({ to, icon: Icon, label, daemon }) => {
|
{advancedItems.map(({ to, icon: Icon, label, daemon }) => {
|
||||||
@@ -182,7 +221,9 @@ export function AppSidebar() {
|
|||||||
})}
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
|
</CollapsibleContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
</Collapsible>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|||||||
671
web/src/components/AutheliaComponent.tsx
Normal file
671
web/src/components/AutheliaComponent.tsx
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { KeyRound, BookOpen, CheckCircle, AlertCircle, Plus, Trash2, Loader2, RotateCw, ExternalLink, Copy, X, UserPlus, Globe, Pencil } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { Alert, AlertDescription } from './ui/alert';
|
||||||
|
import { Switch } from './ui/switch';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||||
|
import { useAuthelia } from '../hooks/useAuthelia';
|
||||||
|
import { domainsApi } from '../services/api';
|
||||||
|
import type { AutheliaStatus } from '../services/api/authelia';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export function AutheliaComponent() {
|
||||||
|
const {
|
||||||
|
status, isLoadingStatus,
|
||||||
|
users, isLoadingUsers,
|
||||||
|
oidcClients, isLoadingClients,
|
||||||
|
updateConfig, restart,
|
||||||
|
createUser, deleteUser,
|
||||||
|
createOIDCClient, updateOIDCClient, deleteOIDCClient,
|
||||||
|
} = useAuthelia();
|
||||||
|
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const [showAddUser, setShowAddUser] = useState(false);
|
||||||
|
const [showAddClient, setShowAddClient] = useState(false);
|
||||||
|
const [newClientSecret, setNewClientSecret] = useState<string | null>(null);
|
||||||
|
const [editingClientId, setEditingClientId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const showSuccess = (msg: string) => {
|
||||||
|
setSuccessMsg(msg);
|
||||||
|
setErrorMsg(null);
|
||||||
|
setTimeout(() => setSuccessMsg(null), 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showError = (msg: string) => {
|
||||||
|
setErrorMsg(msg);
|
||||||
|
setSuccessMsg(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingStatus) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<KeyRound className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">Authentication</h2>
|
||||||
|
<p className="text-muted-foreground">Centralized authentication for network services via Authelia</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
{successMsg && (
|
||||||
|
<Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertDescription className="text-green-700 dark:text-green-300">{successMsg}</AlertDescription>
|
||||||
|
<Button variant="ghost" size="sm" className="absolute right-2 top-2 h-6 w-6 p-0" onClick={() => setSuccessMsg(null)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{errorMsg && (
|
||||||
|
<Alert variant="error">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{errorMsg}</AlertDescription>
|
||||||
|
<Button variant="ghost" size="sm" className="absolute right-2 top-2 h-6 w-6 p-0" onClick={() => setErrorMsg(null)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Educational Card */}
|
||||||
|
<Card className="bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-900/20 dark:to-blue-900/20 border-cyan-200 dark:border-cyan-800">
|
||||||
|
<CardContent className="p-4 flex items-start gap-3">
|
||||||
|
<BookOpen className="h-5 w-5 text-cyan-600 dark:text-cyan-400 mt-0.5 shrink-0" />
|
||||||
|
<div className="text-sm text-cyan-800 dark:text-cyan-200">
|
||||||
|
<p className="font-medium mb-1">Centralized Authentication for Your Network</p>
|
||||||
|
<p>Authelia provides two ways to protect services: <strong>Forward-auth</strong> intercepts requests at HAProxy for apps without native login. <strong>OIDC</strong> lets apps like Gitea and Grafana use Authelia as their identity provider. Both share one user database and login portal.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Configuration */}
|
||||||
|
<ConfigCard
|
||||||
|
status={status}
|
||||||
|
onUpdate={updateConfig}
|
||||||
|
showSuccess={showSuccess}
|
||||||
|
showError={showError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Service Status (only if enabled) */}
|
||||||
|
{status?.enabled && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Service Status</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={status.active ? 'success' : 'destructive'} className="gap-1">
|
||||||
|
{status.active ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
|
||||||
|
{status.active ? 'Running' : 'Stopped'}
|
||||||
|
</Badge>
|
||||||
|
<Button variant="outline" size="sm" className="gap-1" onClick={() => restart.mutate(undefined, {
|
||||||
|
onSuccess: () => showSuccess('Authelia restarted'),
|
||||||
|
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to restart'),
|
||||||
|
})} disabled={restart.isPending}>
|
||||||
|
{restart.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
||||||
|
Restart
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
{status.version && <div><span className="text-muted-foreground">Version</span><div className="font-mono mt-0.5">{status.version}</div></div>}
|
||||||
|
<div><span className="text-muted-foreground">Users</span><div className="font-mono mt-0.5">{status.userCount}</div></div>
|
||||||
|
<div><span className="text-muted-foreground">OIDC Clients</span><div className="font-mono mt-0.5">{status.clientCount}</div></div>
|
||||||
|
{status.domain && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Login Portal</span>
|
||||||
|
<div className="font-mono mt-0.5">
|
||||||
|
<a href={`https://${status.domain}`} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline inline-flex items-center gap-1">
|
||||||
|
{status.domain}<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Management */}
|
||||||
|
{status?.enabled && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Users</CardTitle>
|
||||||
|
<Button size="sm" className="gap-1" onClick={() => setShowAddUser(!showAddUser)}>
|
||||||
|
{showAddUser ? <X className="h-3 w-3" /> : <UserPlus className="h-3 w-3" />}
|
||||||
|
{showAddUser ? 'Cancel' : 'Add User'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 space-y-3">
|
||||||
|
{showAddUser && (
|
||||||
|
<AddUserForm
|
||||||
|
onSubmit={(data) => {
|
||||||
|
createUser.mutate(data, {
|
||||||
|
onSuccess: () => { showSuccess(`User "${data.username}" created`); setShowAddUser(false); },
|
||||||
|
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to create user'),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
isSubmitting={createUser.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isLoadingUsers ? (
|
||||||
|
<div className="text-center py-4"><Loader2 className="h-5 w-5 animate-spin mx-auto text-muted-foreground" /></div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">No users yet. Add a user to get started.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div key={user.username} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm">{user.displayname || user.username}</span>
|
||||||
|
<span className="text-muted-foreground text-sm ml-2">({user.username})</span>
|
||||||
|
{user.email && <span className="text-muted-foreground text-xs ml-2">{user.email}</span>}
|
||||||
|
</div>
|
||||||
|
{user.disabled && <Badge variant="secondary">Disabled</Badge>}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive h-7 w-7 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete user "${user.username}"?`)) {
|
||||||
|
deleteUser.mutate(user.username, {
|
||||||
|
onSuccess: () => showSuccess(`User "${user.username}" deleted`),
|
||||||
|
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to delete user'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OIDC Clients */}
|
||||||
|
{status?.enabled && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">OIDC Clients</CardTitle>
|
||||||
|
<Button size="sm" className="gap-1" onClick={() => { setShowAddClient(!showAddClient); setNewClientSecret(null); }}>
|
||||||
|
{showAddClient ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
||||||
|
{showAddClient ? 'Cancel' : 'Add Client'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 space-y-3">
|
||||||
|
{newClientSecret && (
|
||||||
|
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
|
||||||
|
<AlertDescription className="text-amber-800 dark:text-amber-200">
|
||||||
|
<p className="font-medium mb-1">Client secret — save this now, it won't be shown again:</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="bg-muted px-2 py-1 rounded text-xs font-mono break-all">{newClientSecret}</code>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" onClick={() => navigator.clipboard.writeText(newClientSecret)}>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{showAddClient && (
|
||||||
|
<AddOIDCClientForm
|
||||||
|
onSubmit={(data) => {
|
||||||
|
createOIDCClient.mutate(data, {
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setNewClientSecret(result.clientSecret);
|
||||||
|
setShowAddClient(false);
|
||||||
|
showSuccess(`OIDC client "${data.clientId}" created`);
|
||||||
|
},
|
||||||
|
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to create client'),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
isSubmitting={createOIDCClient.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isLoadingClients ? (
|
||||||
|
<div className="text-center py-4"><Loader2 className="h-5 w-5 animate-spin mx-auto text-muted-foreground" /></div>
|
||||||
|
) : oidcClients.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">No OIDC clients. Apps with native SSO support (Gitea, Grafana, etc.) need a client registration here.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{oidcClients.map((client) => (
|
||||||
|
editingClientId === client.clientId ? (
|
||||||
|
<EditOIDCClientForm
|
||||||
|
key={client.clientId}
|
||||||
|
client={client}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
updateOIDCClient.mutate({ clientId: client.clientId, ...data }, {
|
||||||
|
onSuccess: () => { showSuccess(`Client "${client.clientName}" updated`); setEditingClientId(null); },
|
||||||
|
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to update client'),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingClientId(null)}
|
||||||
|
isSubmitting={updateOIDCClient.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div key={client.clientId} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm">{client.clientName}</span>
|
||||||
|
<span className="text-muted-foreground text-xs ml-2 font-mono">{client.clientId}</span>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5 font-mono">
|
||||||
|
{client.redirectUris.join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">{client.authorizationPolicy}</Badge>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setEditingClientId(client.clientId)}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive h-7 w-7 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete OIDC client "${client.clientName}"?`)) {
|
||||||
|
deleteOIDCClient.mutate(client.clientId, {
|
||||||
|
onSuccess: () => showSuccess(`Client "${client.clientName}" deleted`),
|
||||||
|
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to delete client'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Protected Domains */}
|
||||||
|
{status?.enabled && <ProtectedDomainsCard authDomain={status.domain} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sub-components ---
|
||||||
|
|
||||||
|
function ConfigCard({ status, onUpdate, showSuccess, showError }: {
|
||||||
|
status: AutheliaStatus | undefined;
|
||||||
|
onUpdate: ReturnType<typeof useAuthelia>['updateConfig'];
|
||||||
|
showSuccess: (msg: string) => void;
|
||||||
|
showError: (msg: string) => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const { register, handleSubmit, watch, setValue, reset } = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
domain: '',
|
||||||
|
defaultPolicy: 'one_factor',
|
||||||
|
smtpHost: '',
|
||||||
|
smtpPort: 587,
|
||||||
|
smtpUsername: '',
|
||||||
|
smtpSender: '',
|
||||||
|
smtpPassword: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync form with status data once on load, not continuously
|
||||||
|
if (status && !initialized) {
|
||||||
|
reset({
|
||||||
|
domain: status.domain ?? '',
|
||||||
|
defaultPolicy: status.defaultPolicy || 'one_factor',
|
||||||
|
smtpHost: status.smtp?.host ?? '',
|
||||||
|
smtpPort: status.smtp?.port || 587,
|
||||||
|
smtpUsername: status.smtp?.username ?? '',
|
||||||
|
smtpSender: status.smtp?.sender ?? '',
|
||||||
|
smtpPassword: '',
|
||||||
|
});
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = status?.enabled ?? false;
|
||||||
|
const isUpdating = onUpdate.isPending;
|
||||||
|
|
||||||
|
const doUpdate = (data: { enabled?: boolean; domain?: string; defaultPolicy?: string; smtpHost?: string; smtpPort?: number; smtpUsername?: string; smtpSender?: string; smtpPassword?: string }) => {
|
||||||
|
onUpdate.mutate(data, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditing(false);
|
||||||
|
showSuccess('Configuration updated');
|
||||||
|
},
|
||||||
|
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to update config'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-l-4 border-l-blue-500">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Configuration</CardTitle>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Label htmlFor="authelia-enabled" className="text-sm">Enabled</Label>
|
||||||
|
<Switch
|
||||||
|
id="authelia-enabled"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (!checked || status?.domain) {
|
||||||
|
doUpdate({ enabled: checked });
|
||||||
|
} else {
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isUpdating || (!status?.installed && !enabled)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
{!status?.installed && !enabled && (
|
||||||
|
<p className="text-sm text-muted-foreground">Authelia is not installed. Install it first: <code className="bg-muted px-1 rounded">apt install authelia</code></p>
|
||||||
|
)}
|
||||||
|
{(enabled || editing) && (
|
||||||
|
<form onSubmit={handleSubmit((data) => {
|
||||||
|
const update: any = { ...data, enabled: true };
|
||||||
|
if (!update.smtpPassword) delete update.smtpPassword; // don't send empty password
|
||||||
|
doUpdate(update);
|
||||||
|
})} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="auth-domain">Auth Portal Domain</Label>
|
||||||
|
<Input {...register('domain', { required: 'Required' })} id="auth-domain" placeholder="auth.example.com" className="mt-1 font-mono" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="default-policy">Default Policy</Label>
|
||||||
|
<Select value={watch('defaultPolicy')} onValueChange={(v) => setValue('defaultPolicy', v)}>
|
||||||
|
<SelectTrigger className="mt-1" id="default-policy">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="one_factor">One Factor (password only)</SelectItem>
|
||||||
|
<SelectItem value="two_factor">Two Factor (password + TOTP)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 border-t">
|
||||||
|
<p className="text-sm font-medium mb-2">Email Notifications (SMTP)</p>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">Required for password resets and 2FA enrollment. Without SMTP, notifications are written to a local file.</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label htmlFor="smtp-host">SMTP Host</Label>
|
||||||
|
<Input {...register('smtpHost')} id="smtp-host" placeholder="smtp.gmail.com" className="mt-1 font-mono" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="smtp-port">Port</Label>
|
||||||
|
<Input {...register('smtpPort', { valueAsNumber: true })} id="smtp-port" type="number" placeholder="587" className="mt-1 font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mt-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="smtp-sender">Sender Email</Label>
|
||||||
|
<Input {...register('smtpSender')} id="smtp-sender" placeholder="auth@example.com" className="mt-1 font-mono" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="smtp-username">Username</Label>
|
||||||
|
<Input {...register('smtpUsername')} id="smtp-username" placeholder="(defaults to sender)" className="mt-1 font-mono" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="smtp-password">Password</Label>
|
||||||
|
<Input {...register('smtpPassword')} id="smtp-password" type="password" placeholder={status?.smtp?.host ? '••••••••' : ''} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{editing && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" size="sm" disabled={isUpdating}>
|
||||||
|
{isUpdating && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||||
|
Enable & Save
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setEditing(false)} disabled={isUpdating}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{enabled && !editing && (
|
||||||
|
<Button type="submit" size="sm" disabled={isUpdating}>
|
||||||
|
{isUpdating && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddUserForm({ onSubmit, isSubmitting }: {
|
||||||
|
onSubmit: (data: { username: string; displayname: string; password: string; email?: string }) => void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}) {
|
||||||
|
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||||
|
defaultValues: { username: '', displayname: '', password: '', email: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="p-3 bg-muted/30 rounded-md space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="new-username">Username</Label>
|
||||||
|
<Input {...register('username', { required: 'Required' })} id="new-username" className="mt-1" />
|
||||||
|
{errors.username && <p className="text-sm text-red-600 mt-1">{errors.username.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="new-displayname">Display Name</Label>
|
||||||
|
<Input {...register('displayname', { required: 'Required' })} id="new-displayname" className="mt-1" />
|
||||||
|
{errors.displayname && <p className="text-sm text-red-600 mt-1">{errors.displayname.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="new-email">Email</Label>
|
||||||
|
<Input {...register('email')} id="new-email" type="email" className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="new-password">Password</Label>
|
||||||
|
<Input {...register('password', { required: 'Required', minLength: { value: 8, message: 'Min 8 characters' } })} id="new-password" type="password" className="mt-1" />
|
||||||
|
{errors.password && <p className="text-sm text-red-600 mt-1">{errors.password.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm" disabled={isSubmitting}>
|
||||||
|
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddOIDCClientForm({ onSubmit, isSubmitting }: {
|
||||||
|
onSubmit: (data: { clientId: string; clientName: string; redirectUris: string[]; authorizationPolicy?: string }) => void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}) {
|
||||||
|
const { register, handleSubmit, formState: { errors }, watch, setValue } = useForm({
|
||||||
|
defaultValues: { clientId: '', clientName: '', redirectUri: '', authorizationPolicy: 'one_factor' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit((data) => {
|
||||||
|
onSubmit({
|
||||||
|
clientId: data.clientId,
|
||||||
|
clientName: data.clientName,
|
||||||
|
redirectUris: [data.redirectUri],
|
||||||
|
authorizationPolicy: data.authorizationPolicy,
|
||||||
|
});
|
||||||
|
})} className="p-3 bg-muted/30 rounded-md space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="client-id">Client ID</Label>
|
||||||
|
<Input {...register('clientId', { required: 'Required' })} id="client-id" placeholder="gitea" className="mt-1 font-mono" />
|
||||||
|
{errors.clientId && <p className="text-sm text-red-600 mt-1">{errors.clientId.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="client-name">Client Name</Label>
|
||||||
|
<Input {...register('clientName', { required: 'Required' })} id="client-name" placeholder="Gitea" className="mt-1" />
|
||||||
|
{errors.clientName && <p className="text-sm text-red-600 mt-1">{errors.clientName.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="redirect-uri">Redirect URI</Label>
|
||||||
|
<Input {...register('redirectUri', { required: 'Required' })} id="redirect-uri" placeholder="https://git.example.com/user/oauth2/authelia/callback" className="mt-1 font-mono text-sm" />
|
||||||
|
{errors.redirectUri && <p className="text-sm text-red-600 mt-1">{errors.redirectUri.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="client-policy">Authorization Policy</Label>
|
||||||
|
<Select value={watch('authorizationPolicy')} onValueChange={(v) => setValue('authorizationPolicy', v)}>
|
||||||
|
<SelectTrigger className="mt-1" id="client-policy">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="one_factor">One Factor</SelectItem>
|
||||||
|
<SelectItem value="two_factor">Two Factor</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm" disabled={isSubmitting}>
|
||||||
|
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||||
|
Create Client
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditOIDCClientForm({ client, onSubmit, onCancel, isSubmitting }: {
|
||||||
|
client: { clientId: string; clientName: string; redirectUris: string[]; authorizationPolicy: string };
|
||||||
|
onSubmit: (data: { clientName?: string; redirectUris?: string[]; authorizationPolicy?: string }) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}) {
|
||||||
|
const { register, handleSubmit, watch, setValue } = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
clientName: client.clientName,
|
||||||
|
redirectUri: client.redirectUris[0] ?? '',
|
||||||
|
authorizationPolicy: client.authorizationPolicy || 'one_factor',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit((data) => {
|
||||||
|
onSubmit({
|
||||||
|
clientName: data.clientName,
|
||||||
|
redirectUris: [data.redirectUri],
|
||||||
|
authorizationPolicy: data.authorizationPolicy,
|
||||||
|
});
|
||||||
|
})} className="p-3 bg-muted/30 rounded-md space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Client ID</Label>
|
||||||
|
<Input value={client.clientId} disabled className="mt-1 font-mono" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-client-name">Client Name</Label>
|
||||||
|
<Input {...register('clientName')} id="edit-client-name" className="mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-redirect-uri">Redirect URI</Label>
|
||||||
|
<Input {...register('redirectUri')} id="edit-redirect-uri" className="mt-1 font-mono text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-client-policy">Authorization Policy</Label>
|
||||||
|
<Select value={watch('authorizationPolicy')} onValueChange={(v) => setValue('authorizationPolicy', v)}>
|
||||||
|
<SelectTrigger className="mt-1" id="edit-client-policy">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="one_factor">One Factor</SelectItem>
|
||||||
|
<SelectItem value="two_factor">Two Factor</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" size="sm" disabled={isSubmitting}>
|
||||||
|
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={isSubmitting}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProtectedDomainsCard({ authDomain }: { authDomain: string }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const domainsQuery = useQuery({
|
||||||
|
queryKey: ['domains'],
|
||||||
|
queryFn: () => domainsApi.list(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleAuthMutation = useMutation({
|
||||||
|
mutationFn: async ({ domain, enabled }: { domain: string; enabled: boolean }) => {
|
||||||
|
return apiClient.patch(`/api/v1/domains/${domain}`, { auth: { enabled, policy: 'one_factor' } });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['domains'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const httpDomains = (domainsQuery.data?.domains ?? []).filter(
|
||||||
|
(d) => d.backend.type === 'http' && d.source !== 'authelia' && d.source !== 'central' && d.domain !== authDomain
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle className="text-base">Protected Domains</CardTitle>
|
||||||
|
<Badge variant="secondary" className="text-xs">Forward-Auth</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Toggle forward-auth protection for L7 HTTP services. Apps with native OIDC support (Gitea, Grafana, etc.) should use an OIDC client above instead.
|
||||||
|
</p>
|
||||||
|
{httpDomains.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4 flex items-center justify-center gap-2">
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
No L7 HTTP domains registered yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{httpDomains.map((domain) => {
|
||||||
|
const authEnabled = (domain as any).auth?.enabled ?? false;
|
||||||
|
return (
|
||||||
|
<div key={domain.domain} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
|
||||||
|
<div>
|
||||||
|
<span className="font-mono text-sm">{domain.domain}</span>
|
||||||
|
<span className="text-muted-foreground text-xs ml-2">({domain.source})</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={authEnabled}
|
||||||
|
onCheckedChange={(checked) => toggleAuthMutation.mutate({ domain: domain.domain, enabled: checked })}
|
||||||
|
disabled={toggleAuthMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import apiClient for the PATCH call in ProtectedDomainsCard
|
||||||
|
import { apiClient } from '../services/api/client';
|
||||||
@@ -82,8 +82,12 @@ function titleCaseScenario(s: string): string {
|
|||||||
).join(' ');
|
).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 && (
|
||||||
|
|||||||
@@ -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,16 +121,37 @@ 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="flex items-center gap-4">
|
||||||
<div className="p-2 bg-primary/10 rounded-lg">
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
<LayoutDashboard className="h-6 w-6 text-primary" />
|
<LayoutDashboard className="h-6 w-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold">Dashboard</h2>
|
<h2 className="text-2xl font-semibold">Dashboard</h2>
|
||||||
<p className="text-muted-foreground">Infrastructure health and service prerequisites</p>
|
<p className="text-muted-foreground">System health overview</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{/* Warnings */}
|
{/* Warnings */}
|
||||||
{hasWarnings && (
|
{hasWarnings && (
|
||||||
@@ -99,21 +168,39 @@ 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
|
||||||
|
key={svc.key}
|
||||||
|
label={svc.label}
|
||||||
|
daemon={svc.key}
|
||||||
|
icon={svc.icon}
|
||||||
|
active={daemons[svc.key]?.active}
|
||||||
|
version={daemons[svc.key]?.version}
|
||||||
|
loading={statusLoading}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-3">Configuration</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Cloudflare */}
|
||||||
|
<Card className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 bg-orange-500/10 rounded-xl">
|
||||||
|
<Cloud className="h-5 w-5 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<div className="font-semibold">Cloudflare</div>
|
||||||
</div>
|
</div>
|
||||||
{cfLoading ? (
|
{cfLoading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
@@ -133,29 +220,22 @@ export function DashboardComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cfLoading ? (
|
{cfLoading ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
Checking token...
|
Verifying...
|
||||||
</div>
|
</div>
|
||||||
) : verification?.tokenValid ? (
|
) : verification?.tokenValid ? (
|
||||||
<div className="space-y-2 ml-7">
|
<div className="space-y-3">
|
||||||
{verification.zones && verification.zones.length > 0 && (
|
<div className="space-y-1">
|
||||||
<div className="flex items-start gap-2 text-sm">
|
<PermissionRow label="Zone:Read" description="List DNS zones" ok={verification.permissions?.zone} />
|
||||||
<Globe className="h-4 w-4 text-muted-foreground mt-0.5" />
|
<PermissionRow label="DNS:Edit" description="Manage DNS records" ok={verification.permissions?.dns} />
|
||||||
<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>
|
<ZoneCoverage
|
||||||
)}
|
available={verification.zones}
|
||||||
|
required={verification.requiredZones}
|
||||||
|
/>
|
||||||
{!editingToken ? (
|
{!editingToken ? (
|
||||||
<Button variant="ghost" size="sm" onClick={() => setEditingToken(true)} className="gap-1 text-muted-foreground">
|
<Button variant="ghost" size="sm" onClick={() => setEditingToken(true)} className="gap-1 h-7 text-xs text-muted-foreground">
|
||||||
<Key className="h-3 w-3" />
|
<Key className="h-3 w-3" />
|
||||||
Change Token
|
Change Token
|
||||||
</Button>
|
</Button>
|
||||||
@@ -179,13 +259,13 @@ export function DashboardComponent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-7">
|
<div>
|
||||||
{!editingToken ? (
|
{!editingToken ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{verification?.tokenConfigured
|
{verification?.tokenConfigured
|
||||||
? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}`
|
? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}`
|
||||||
: 'An API token is required for DNS and TLS features'}
|
: 'Required for DNS and TLS'}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditingToken(true)} className="gap-1">
|
<Button variant="outline" size="sm" onClick={() => setEditingToken(true)} className="gap-1">
|
||||||
<Key className="h-3 w-3" />
|
<Key className="h-3 w-3" />
|
||||||
@@ -222,199 +302,142 @@ export function DashboardComponent() {
|
|||||||
)}
|
)}
|
||||||
</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" />
|
|
||||||
<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>
|
|
||||||
<span className="text-muted-foreground">Last checked</span>
|
|
||||||
<div className="font-mono mt-0.5">
|
|
||||||
{ddnsStatus.lastChecked ? new Date(ddnsStatus.lastChecked).toLocaleTimeString() : '--'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Record list (read-only — derived from public domains) */}
|
|
||||||
{ddnsStatus.records?.length ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{ddnsStatus.records.map((rs: DdnsRecordStatus) => (
|
|
||||||
<div key={rs.name} className="flex items-center gap-2 text-xs">
|
|
||||||
{rs.ok ? (
|
|
||||||
<CheckCircle className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="font-mono bg-muted px-2 py-0.5 rounded">{rs.name}</span>
|
|
||||||
{rs.ok && rs.ip && (
|
|
||||||
<span className="text-muted-foreground font-mono">{rs.ip}</span>
|
|
||||||
)}
|
|
||||||
{!rs.ok && rs.error && (
|
|
||||||
<span className="text-red-500 truncate" title={rs.error}>{rs.error}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Records are derived from public domains. Toggle Public on the Domains page to manage.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
No public domains registered. Set a domain to Public on the Domains page to enable DDNS.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => triggerDdns()}
|
|
||||||
disabled={isTriggering}
|
|
||||||
className="gap-1"
|
|
||||||
>
|
|
||||||
{isTriggering ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
|
||||||
Check Now
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground ml-7">
|
|
||||||
DDNS is not enabled. Enable it in the global config to keep public domain DNS records in sync with your public IP.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 3. Daemon Status */}
|
|
||||||
<Card className="p-4 border-l-4 border-l-purple-500">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Server className="h-5 w-5 text-purple-500" />
|
|
||||||
<div className="font-medium">Daemon Status</div>
|
|
||||||
</div>
|
|
||||||
{statusLoading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
||||||
) : centralStatus ? (
|
|
||||||
<Badge variant="success" className="gap-1">
|
|
||||||
<CheckCircle className="h-3 w-3" />
|
|
||||||
Running
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="destructive">Offline</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{statusLoading ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Loading status...
|
|
||||||
</div>
|
|
||||||
) : centralStatus ? (
|
|
||||||
<div className="space-y-3 ml-7">
|
|
||||||
{/* Service badges */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<ServiceBadge name="dnsmasq" active={centralStatus?.daemons?.dnsmasq?.active} />
|
|
||||||
<ServiceBadge name="haproxy" active={centralStatus?.daemons?.haproxy?.active} />
|
|
||||||
<ServiceBadge name="nftables" active={centralStatus?.daemons?.nftables?.active} />
|
|
||||||
<ServiceBadge name="wireguard" active={centralStatus?.daemons?.wireguard?.active} />
|
|
||||||
<ServiceBadge name="crowdsec" active={centralStatus?.daemons?.crowdsec?.active} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Version and uptime */}
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Version</span>
|
|
||||||
<div className="font-mono font-medium mt-0.5">{centralStatus.version || 'Unknown'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Uptime</span>
|
|
||||||
<div className="font-mono mt-0.5">{centralStatus.uptime || '--'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={restartDaemon}
|
|
||||||
disabled={isRestarting}
|
|
||||||
className="gap-1"
|
|
||||||
>
|
|
||||||
{isRestarting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
|
||||||
{isRestarting ? 'Restarting...' : 'Restart Daemon'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground ml-7">Cannot reach the Wild Central daemon.</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 4. Gateway Router */}
|
|
||||||
<Card className="p-4 border-l-4 border-l-amber-500 bg-gradient-to-r from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Router className="h-5 w-5 text-amber-500" />
|
<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>
|
<div className="font-semibold">Router</div>
|
||||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
<div className="text-xs text-muted-foreground">LAN setup checklist</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<p>Configure your LAN router so Wild Central can serve traffic:</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1.5 text-xs">
|
||||||
<li>
|
<li>
|
||||||
Set the router's DNS server to Wild Central
|
Set the router's DNS server to Wild Central
|
||||||
{dnsSettings?.ip && (
|
{dnsSettings?.ip && (
|
||||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">
|
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">{dnsSettings.ip}</span>
|
||||||
{dnsSettings.ip}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Port-forward to Wild Central:{' '}
|
Forward these ports to Wild Central:
|
||||||
{[
|
<div className="flex flex-wrap gap-1 mt-1 ml-4">
|
||||||
{ port: 443, proto: 'TCP', label: 'HTTPS' },
|
{ports.map((p, i) => (
|
||||||
{ port: 80, proto: 'TCP', label: 'HTTP' },
|
<span key={i} className="font-mono bg-muted px-2 py-0.5 rounded">
|
||||||
...(vpnConfig?.enabled ? [{ port: vpnConfig.listenPort || 51820, proto: 'UDP', label: 'VPN' }] : []),
|
{p.port}/{p.proto}
|
||||||
...((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>}
|
{p.label && <span className="text-muted-foreground/60"> ({p.label})</span>}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
501
web/src/components/DnsFilterComponent.tsx
Normal file
501
web/src/components/DnsFilterComponent.tsx
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input, Label } from './ui';
|
||||||
|
import { Alert, AlertDescription } from './ui/alert';
|
||||||
|
import { Switch } from './ui/switch';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from './ui/select';
|
||||||
|
import {
|
||||||
|
ShieldBan, Loader2, Plus, Trash2, RotateCw, Upload,
|
||||||
|
CheckCircle, AlertCircle, X, ExternalLink, List,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useDnsFilter } from '../hooks/useDnsFilter';
|
||||||
|
|
||||||
|
export function DnsFilterComponent() {
|
||||||
|
const {
|
||||||
|
status, isLoadingStatus, config,
|
||||||
|
lists, customEntries, suggestedLists,
|
||||||
|
updateConfig, isUpdatingConfig,
|
||||||
|
addList, isAddingList,
|
||||||
|
removeList, toggleList,
|
||||||
|
addCustomEntry, removeCustomEntry,
|
||||||
|
triggerUpdate, isUpdating,
|
||||||
|
uploadList, isUploading,
|
||||||
|
} = useDnsFilter();
|
||||||
|
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Add list form
|
||||||
|
const [newListUrl, setNewListUrl] = useState('');
|
||||||
|
const [newListName, setNewListName] = useState('');
|
||||||
|
const [showAddList, setShowAddList] = useState(false);
|
||||||
|
|
||||||
|
// Upload form
|
||||||
|
const [uploadName, setUploadName] = useState('');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Custom entry form
|
||||||
|
const [showAddCustom, setShowAddCustom] = useState(false);
|
||||||
|
const [newCustomDomain, setNewCustomDomain] = useState('');
|
||||||
|
const [newCustomType, setNewCustomType] = useState<'allow' | 'block'>('block');
|
||||||
|
|
||||||
|
const showSuccess = (msg: string) => {
|
||||||
|
setSuccessMessage(msg);
|
||||||
|
setErrorMessage(null);
|
||||||
|
setTimeout(() => setSuccessMessage(null), 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showError = (msg: string) => {
|
||||||
|
setErrorMessage(msg);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
setTimeout(() => setErrorMessage(null), 8000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleEnabled = async () => {
|
||||||
|
try {
|
||||||
|
await updateConfig({ enabled: !status?.enabled });
|
||||||
|
showSuccess(status?.enabled ? 'DNS filtering disabled' : 'DNS filtering enabled');
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to update config: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddList = async () => {
|
||||||
|
if (!newListUrl || !newListName) return;
|
||||||
|
try {
|
||||||
|
await addList({ url: newListUrl, name: newListName });
|
||||||
|
setNewListUrl('');
|
||||||
|
setNewListName('');
|
||||||
|
setShowAddList(false);
|
||||||
|
showSuccess(`Added list: ${newListName}`);
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to add list: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSuggested = async (url: string, name: string) => {
|
||||||
|
try {
|
||||||
|
await addList({ url, name });
|
||||||
|
showSuccess(`Added list: ${name}`);
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to add list: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
const file = fileInputRef.current?.files?.[0];
|
||||||
|
if (!file || !uploadName) return;
|
||||||
|
try {
|
||||||
|
await uploadList({ name: uploadName, file });
|
||||||
|
setUploadName('');
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
showSuccess(`Uploaded list: ${uploadName}`);
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to upload: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveList = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await removeList(id);
|
||||||
|
showSuccess('List removed');
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to remove list: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleList = async (id: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await toggleList({ id, enabled });
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to toggle list: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCustom = async () => {
|
||||||
|
if (!newCustomDomain) return;
|
||||||
|
try {
|
||||||
|
await addCustomEntry({ domain: newCustomDomain, type: newCustomType });
|
||||||
|
setNewCustomDomain('');
|
||||||
|
setShowAddCustom(false);
|
||||||
|
showSuccess(`Added ${newCustomType} entry for ${newCustomDomain}`);
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to add entry: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCustom = async (domain: string) => {
|
||||||
|
try {
|
||||||
|
await removeCustomEntry(domain);
|
||||||
|
showSuccess('Custom entry removed');
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to remove entry: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTriggerUpdate = async () => {
|
||||||
|
try {
|
||||||
|
await triggerUpdate();
|
||||||
|
showSuccess('Update triggered — lists will refresh in the background');
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to trigger update: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter suggested lists to exclude already-added ones
|
||||||
|
const addedIds = new Set(lists.map(l => l.id));
|
||||||
|
const availableSuggested = suggestedLists.filter(s => !addedIds.has(s.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<ShieldBan className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">DNS Filter</h2>
|
||||||
|
<p className="text-muted-foreground">Block ads, trackers, and malware at the DNS level</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
{successMessage && (
|
||||||
|
<Alert className="border-green-500 bg-green-50 dark:bg-green-900/20">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertDescription className="flex items-center justify-between">
|
||||||
|
<span className="text-green-800 dark:text-green-200">{successMessage}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSuccessMessage(null)}><X className="h-3 w-3" /></Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert variant="error">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription className="flex items-center justify-between">
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setErrorMessage(null)}><X className="h-3 w-3" /></Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Card */}
|
||||||
|
<Card className="border-l-4 border-l-blue-500">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">Status</CardTitle>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{status?.enabled && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleTriggerUpdate}
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
|
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <RotateCw className="h-4 w-4 mr-1" />}
|
||||||
|
Update Now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="dns-filter-toggle" className="text-sm">
|
||||||
|
{status?.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="dns-filter-toggle"
|
||||||
|
checked={status?.enabled ?? false}
|
||||||
|
onCheckedChange={handleToggleEnabled}
|
||||||
|
disabled={isUpdatingConfig || isLoadingStatus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{status?.enabled && (
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Query stats */}
|
||||||
|
{status.queryStats && (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-muted rounded-md p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold font-mono">{status.queryStats.totalQueries.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Queries ({status.queryStats.period})</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded-md p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold font-mono text-red-500">{status.queryStats.blockedQueries.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Blocked</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded-md p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold font-mono text-red-500">{status.queryStats.blockedPercent.toFixed(1)}%</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Block rate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-muted rounded-md p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold font-mono">{status.totalBlocked.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Domains in blocklist</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded-md p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold font-mono">{status.enabledLists}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Active lists</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded-md p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold font-mono">{status.listCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Total lists</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded-md p-3 text-center">
|
||||||
|
<p className="text-sm font-mono">
|
||||||
|
{status.lastUpdated ? new Date(status.lastUpdated).toLocaleString() : 'Never'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Last updated</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{config && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Lists update every {config.intervalHours || 24} hours
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Blocklists Card */}
|
||||||
|
<Card className="border-l-4 border-l-green-500">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<List className="h-5 w-5" />
|
||||||
|
Blocklists
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowAddList(!showAddList)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Add list form */}
|
||||||
|
{showAddList && (
|
||||||
|
<Card className="p-4 bg-muted/50">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium text-sm">Add blocklist by URL</h4>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="list-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="list-name"
|
||||||
|
placeholder="e.g. My Custom Blocklist"
|
||||||
|
value={newListName}
|
||||||
|
onChange={e => setNewListName(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="list-url">URL</Label>
|
||||||
|
<Input
|
||||||
|
id="list-url"
|
||||||
|
placeholder="https://example.com/blocklist.txt"
|
||||||
|
value={newListUrl}
|
||||||
|
onChange={e => setNewListUrl(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={handleAddList} disabled={isAddingList || !newListUrl || !newListName}>
|
||||||
|
{isAddingList ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plus className="h-4 w-4 mr-1" />}
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowAddList(false)}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload */}
|
||||||
|
<div className="border-t pt-3 mt-3">
|
||||||
|
<h4 className="font-medium text-sm mb-2">Or upload a file</h4>
|
||||||
|
<div className="flex gap-2 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="upload-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="upload-name"
|
||||||
|
placeholder="My uploaded list"
|
||||||
|
value={uploadName}
|
||||||
|
onChange={e => setUploadName(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="upload-file">File</Label>
|
||||||
|
<Input id="upload-file" type="file" ref={fileInputRef} accept=".txt,.hosts,.conf" className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={handleUpload} disabled={isUploading || !uploadName}>
|
||||||
|
{isUploading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Upload className="h-4 w-4 mr-1" />}
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current lists */}
|
||||||
|
{lists.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<ShieldBan className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="font-medium">No blocklists added</p>
|
||||||
|
<p className="text-sm mt-1">Add a blocklist URL or choose from the suggestions below</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lists.map(list => (
|
||||||
|
<div key={list.id} className="flex items-center gap-3 p-3 bg-muted/50 rounded-md">
|
||||||
|
<Switch
|
||||||
|
checked={list.enabled}
|
||||||
|
onCheckedChange={(checked) => handleToggleList(list.id, checked)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm truncate">{list.name}</span>
|
||||||
|
{list.entryCount > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{list.entryCount.toLocaleString()} domains
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{list.lastError && (
|
||||||
|
<Badge variant="destructive" className="text-xs">Error</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{list.url && (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono truncate mt-0.5">{list.url}</p>
|
||||||
|
)}
|
||||||
|
{!list.url && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Uploaded file</p>
|
||||||
|
)}
|
||||||
|
{list.lastError && (
|
||||||
|
<p className="text-xs text-red-500 mt-0.5">{list.lastError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleRemoveList(list.id)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suggested lists */}
|
||||||
|
{availableSuggested.length > 0 && (
|
||||||
|
<div className="border-t pt-4 mt-4">
|
||||||
|
<h4 className="text-sm font-medium mb-3 text-muted-foreground">Suggested lists</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{availableSuggested.map(suggested => (
|
||||||
|
<div key={suggested.id} className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-medium">{suggested.name}</span>
|
||||||
|
{suggested.url && (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono truncate">{suggested.url}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{suggested.url && (
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<a href={suggested.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddSuggested(suggested.url!, suggested.name)}
|
||||||
|
disabled={isAddingList}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Custom Entries Card */}
|
||||||
|
<Card className="border-l-4 border-l-blue-500">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">Custom Entries</CardTitle>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowAddCustom(!showAddCustom)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add Entry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{showAddCustom && (
|
||||||
|
<Card className="p-4 bg-muted/50">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="custom-domain">Domain</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-domain"
|
||||||
|
placeholder="ads.example.com"
|
||||||
|
value={newCustomDomain}
|
||||||
|
onChange={e => setNewCustomDomain(e.target.value)}
|
||||||
|
className="mt-1 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Label htmlFor="custom-type">Type</Label>
|
||||||
|
<Select value={newCustomType} onValueChange={(v) => setNewCustomType(v as 'allow' | 'block')}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="block">Block</SelectItem>
|
||||||
|
<SelectItem value="allow">Allow</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={handleAddCustom} disabled={!newCustomDomain}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowAddCustom(false)}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<strong>Block</strong> adds a domain to the blocklist. <strong>Allow</strong> overrides blocklists to permit a specific domain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{customEntries.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
No custom entries. Use allow entries to override blocklists for specific domains.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{customEntries.map(entry => (
|
||||||
|
<div key={entry.domain} className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50">
|
||||||
|
<Badge variant={entry.type === 'block' ? 'destructive' : 'default'} className="text-xs w-14 justify-center">
|
||||||
|
{entry.type}
|
||||||
|
</Badge>
|
||||||
|
<span className="flex-1 font-mono text-sm">{entry.domain}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleRemoveCustom(entry.domain)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
41
web/src/components/advanced/AutheliaSubsystem.tsx
Normal file
41
web/src/components/advanced/AutheliaSubsystem.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
|
import { KeyRound } from 'lucide-react';
|
||||||
|
import { autheliaApi } from '../../services/api/authelia';
|
||||||
|
import { SubsystemPage } from '../SubsystemPage';
|
||||||
|
|
||||||
|
export function AutheliaSubsystem() {
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const configQuery = useQuery({
|
||||||
|
queryKey: ['authelia', 'config', 'raw'],
|
||||||
|
queryFn: () => autheliaApi.getConfig(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const restartMutation = useMutation({
|
||||||
|
mutationFn: () => autheliaApi.restart(),
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMsg('Authelia restarted');
|
||||||
|
setErrorMsg(null);
|
||||||
|
setTimeout(() => setSuccessMsg(null), 5000);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setErrorMsg(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubsystemPage
|
||||||
|
title="Authelia"
|
||||||
|
description="Authentication and identity provider"
|
||||||
|
icon={KeyRound}
|
||||||
|
daemonKey="authelia"
|
||||||
|
configContent={configQuery.data?.content}
|
||||||
|
isLoadingConfig={configQuery.isLoading}
|
||||||
|
onRestart={() => restartMutation.mutate()}
|
||||||
|
isRestarting={restartMutation.isPending}
|
||||||
|
restartLabel="Restart Authelia"
|
||||||
|
successMessage={successMsg}
|
||||||
|
errorMessage={errorMsg}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{/* === Section 1: Source === */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">Source</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">Public (internet-accessible)</Label>
|
<Label className="text-xs">Internet-accessible</Label>
|
||||||
<Switch checked={draft.public} onCheckedChange={v => onUpdateDraft({ public: v })} />
|
<Switch checked={draft.public} onCheckedChange={v => onUpdateDraft({ public: v })} />
|
||||||
</div>
|
</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>
|
{/* === 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,9 +178,14 @@ 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>
|
</div>
|
||||||
|
)}
|
||||||
{isL7 && (
|
{isL7 && (
|
||||||
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
{hasCert ? (
|
{hasCert ? (
|
||||||
@@ -173,12 +210,79 @@ function MobileControls({
|
|||||||
Provision
|
Provision
|
||||||
</Button>
|
</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>
|
||||||
|
|
||||||
<div className="text-xs">
|
{/* === Section 3: Destination === */}
|
||||||
<span className="text-muted-foreground">Backend: </span>
|
<div className="border-t border-border/50 pt-3 space-y-2">
|
||||||
<span className="font-mono">{draft.backendAddress}</span>
|
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">Destination</div>
|
||||||
|
{editingBackend ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<input
|
||||||
|
ref={backendInputRef}
|
||||||
|
defaultValue={draft.backendAddress}
|
||||||
|
className="font-mono bg-muted rounded px-2 py-1.5 text-xs w-full border border-border focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') handleBackendSave();
|
||||||
|
if (e.key === 'Escape') setEditingBackend(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs flex-1" onClick={handleBackendSave}>Save</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-xs flex-1" onClick={() => setEditingBackend(false)}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-mono text-xs bg-muted rounded px-2 py-1">
|
||||||
|
{hasRoutes ? `${domain.routes!.length} backends` : draft.backendAddress}
|
||||||
|
</span>
|
||||||
|
{!hasRoutes && (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
||||||
|
onClick={() => setEditingBackend(true)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isL7 && domain.routes && domain.routes.length > 0 && (
|
||||||
|
<div className="bg-muted/30 rounded-md p-2 space-y-1">
|
||||||
|
<div className="text-[10px] text-muted-foreground font-medium mb-1">Route table</div>
|
||||||
|
{domain.routes.map((route, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-[10px] flex-wrap">
|
||||||
|
<span className="font-mono text-muted-foreground truncate">
|
||||||
|
{route.paths?.length ? route.paths.join(', ') : '/*'}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">{'->'}</span>
|
||||||
|
<span className="font-mono">{route.backend.address}</span>
|
||||||
|
{route.ipAllow && route.ipAllow.length > 0 && (
|
||||||
|
<span className="text-muted-foreground flex items-center gap-0.5">
|
||||||
|
{route.ipAllow.length} IP rules
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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,9 +520,10 @@ 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">
|
||||||
|
{!isMobile && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
@@ -422,6 +531,7 @@ export function DomainCard({
|
|||||||
>
|
>
|
||||||
{draft.subdomains ? '*.wildcard on' : 'wildcard off'}
|
{draft.subdomains ? '*.wildcard on' : 'wildcard off'}
|
||||||
</button>
|
</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>
|
||||||
|
|||||||
@@ -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"
|
|
||||||
/>
|
/>
|
||||||
</Link>
|
|
||||||
<Link to="/central/crowdsec" className="group nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
|
||||||
<TopologyNode
|
<TopologyNode
|
||||||
label="CrowdSec"
|
label="CrowdSec"
|
||||||
sublabel={isL4 ? 'IP only' : 'HTTP + IP'}
|
sublabel={isL4 ? 'IP only' : 'HTTP + IP'}
|
||||||
status={csOk ? 'ok' : 'na'}
|
status={csOk ? 'ok' : 'na'}
|
||||||
icon={<ShieldAlert className="h-3 w-3" />}
|
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" />}
|
||||||
className="hover:border-primary/40"
|
tooltip="Your LAN router port-forwards these ports to Wild Central"
|
||||||
tooltip="Your LAN router port-forwards these ports to Wild Central. See Dashboard for setup."
|
|
||||||
/>
|
/>
|
||||||
</Link>
|
|
||||||
{rightHandle}
|
{rightHandle}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
90
web/src/hooks/useAuthelia.ts
Normal file
90
web/src/hooks/useAuthelia.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { autheliaApi } from '../services/api/authelia';
|
||||||
|
import type { CreateUserRequest, UpdateUserRequest, CreateOIDCClientRequest, UpdateOIDCClientRequest } from '../services/api/authelia';
|
||||||
|
|
||||||
|
export function useAuthelia() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: ['authelia', 'status'],
|
||||||
|
queryFn: () => autheliaApi.getStatus(),
|
||||||
|
staleTime: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ['authelia', 'users'],
|
||||||
|
queryFn: () => autheliaApi.listUsers(),
|
||||||
|
enabled: !!statusQuery.data?.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const oidcClientsQuery = useQuery({
|
||||||
|
queryKey: ['authelia', 'oidc', 'clients'],
|
||||||
|
queryFn: () => autheliaApi.listOIDCClients(),
|
||||||
|
enabled: !!statusQuery.data?.active,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidateAll = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['authelia'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['central', 'status'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfigMutation = useMutation({
|
||||||
|
mutationFn: (data: { enabled?: boolean; domain?: string; defaultPolicy?: string; smtpHost?: string; smtpPort?: number; smtpUsername?: string; smtpSender?: string; smtpPassword?: string }) =>
|
||||||
|
autheliaApi.updateConfig(data),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restartMutation = useMutation({
|
||||||
|
mutationFn: () => autheliaApi.restart(),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createUserMutation = useMutation({
|
||||||
|
mutationFn: (req: CreateUserRequest) => autheliaApi.createUser(req),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUserMutation = useMutation({
|
||||||
|
mutationFn: ({ username, ...req }: UpdateUserRequest & { username: string }) =>
|
||||||
|
autheliaApi.updateUser(username, req),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'users'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteUserMutation = useMutation({
|
||||||
|
mutationFn: (username: string) => autheliaApi.deleteUser(username),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createOIDCClientMutation = useMutation({
|
||||||
|
mutationFn: (req: CreateOIDCClientRequest) => autheliaApi.createOIDCClient(req),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateOIDCClientMutation = useMutation({
|
||||||
|
mutationFn: ({ clientId, ...req }: UpdateOIDCClientRequest & { clientId: string }) =>
|
||||||
|
autheliaApi.updateOIDCClient(clientId, req),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteOIDCClientMutation = useMutation({
|
||||||
|
mutationFn: (clientId: string) => autheliaApi.deleteOIDCClient(clientId),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: statusQuery.data,
|
||||||
|
isLoadingStatus: statusQuery.isLoading,
|
||||||
|
users: usersQuery.data?.users ?? [],
|
||||||
|
isLoadingUsers: usersQuery.isLoading,
|
||||||
|
oidcClients: oidcClientsQuery.data?.clients ?? [],
|
||||||
|
isLoadingClients: oidcClientsQuery.isLoading,
|
||||||
|
updateConfig: updateConfigMutation,
|
||||||
|
restart: restartMutation,
|
||||||
|
createUser: createUserMutation,
|
||||||
|
updateUser: updateUserMutation,
|
||||||
|
deleteUser: deleteUserMutation,
|
||||||
|
createOIDCClient: createOIDCClientMutation,
|
||||||
|
updateOIDCClient: updateOIDCClientMutation,
|
||||||
|
deleteOIDCClient: deleteOIDCClientMutation,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { apiClient } from '../services/api/client';
|
|||||||
|
|
||||||
interface DaemonStatus {
|
interface DaemonStatus {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CentralStatus {
|
interface CentralStatus {
|
||||||
|
|||||||
101
web/src/hooks/useDnsFilter.ts
Normal file
101
web/src/hooks/useDnsFilter.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { dnsFilterApi, type CustomEntry } from '../services/api/dnsfilter';
|
||||||
|
|
||||||
|
export function useDnsFilter() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: ['dns-filter', 'status'],
|
||||||
|
queryFn: () => dnsFilterApi.getStatus(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const configQuery = useQuery({
|
||||||
|
queryKey: ['dns-filter', 'config'],
|
||||||
|
queryFn: () => dnsFilterApi.getConfig(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const listsQuery = useQuery({
|
||||||
|
queryKey: ['dns-filter', 'lists'],
|
||||||
|
queryFn: () => dnsFilterApi.getLists(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customQuery = useQuery({
|
||||||
|
queryKey: ['dns-filter', 'custom'],
|
||||||
|
queryFn: () => dnsFilterApi.getCustomEntries(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const suggestedQuery = useQuery({
|
||||||
|
queryKey: ['dns-filter', 'suggested'],
|
||||||
|
queryFn: () => dnsFilterApi.getSuggested(),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidateAll = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dns-filter'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfigMutation = useMutation({
|
||||||
|
mutationFn: dnsFilterApi.updateConfig,
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addListMutation = useMutation({
|
||||||
|
mutationFn: (data: { url: string; name: string }) => dnsFilterApi.addList(data),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeListMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => dnsFilterApi.removeList(id),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleListMutation = useMutation({
|
||||||
|
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => dnsFilterApi.toggleList(id, enabled),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addCustomMutation = useMutation({
|
||||||
|
mutationFn: (entry: CustomEntry) => dnsFilterApi.setCustomEntry(entry),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeCustomMutation = useMutation({
|
||||||
|
mutationFn: (domain: string) => dnsFilterApi.removeCustomEntry(domain),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const triggerUpdateMutation = useMutation({
|
||||||
|
mutationFn: () => dnsFilterApi.triggerUpdate(),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Delay invalidation to give the runner time to download + compile
|
||||||
|
setTimeout(invalidateAll, 3000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadListMutation = useMutation({
|
||||||
|
mutationFn: ({ name, file }: { name: string; file: File }) => dnsFilterApi.uploadList(name, file),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: statusQuery.data,
|
||||||
|
isLoadingStatus: statusQuery.isLoading,
|
||||||
|
config: configQuery.data,
|
||||||
|
lists: listsQuery.data?.lists ?? [],
|
||||||
|
customEntries: customQuery.data?.customEntries ?? [],
|
||||||
|
suggestedLists: suggestedQuery.data?.lists ?? [],
|
||||||
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
|
isUpdatingConfig: updateConfigMutation.isPending,
|
||||||
|
addList: addListMutation.mutateAsync,
|
||||||
|
isAddingList: addListMutation.isPending,
|
||||||
|
removeList: removeListMutation.mutateAsync,
|
||||||
|
toggleList: toggleListMutation.mutateAsync,
|
||||||
|
addCustomEntry: addCustomMutation.mutateAsync,
|
||||||
|
removeCustomEntry: removeCustomMutation.mutateAsync,
|
||||||
|
triggerUpdate: triggerUpdateMutation.mutateAsync,
|
||||||
|
isUpdating: triggerUpdateMutation.isPending,
|
||||||
|
uploadList: uploadListMutation.mutateAsync,
|
||||||
|
isUploading: uploadListMutation.isPending,
|
||||||
|
refetch: invalidateAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
10
web/src/router/pages/AutheliaPage.tsx
Normal file
10
web/src/router/pages/AutheliaPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../components';
|
||||||
|
import { AutheliaComponent } from '../../components/AutheliaComponent';
|
||||||
|
|
||||||
|
export function AutheliaPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<AutheliaComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
web/src/router/pages/DnsFilterPage.tsx
Normal file
10
web/src/router/pages/DnsFilterPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../components';
|
||||||
|
import { DnsFilterComponent } from '../../components/DnsFilterComponent';
|
||||||
|
|
||||||
|
export function DnsFilterPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<DnsFilterComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
web/src/router/pages/advanced/AutheliaPage.tsx
Normal file
10
web/src/router/pages/advanced/AutheliaPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../../components';
|
||||||
|
import { AutheliaSubsystem } from '../../../components/advanced';
|
||||||
|
|
||||||
|
export function AutheliaAdvancedPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<AutheliaSubsystem />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
117
web/src/services/api/authelia.ts
Normal file
117
web/src/services/api/authelia.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
94
web/src/services/api/dnsfilter.ts
Normal file
94
web/src/services/api/dnsfilter.ts
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user