Compare commits
3 Commits
1d1d6c605c
...
1e7d93256e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e7d93256e | ||
|
|
65c7e56b0a | ||
|
|
d0b645b9b5 |
4
TODO.md
4
TODO.md
@@ -16,3 +16,7 @@ Affects: keila (CORS headers), plausible (Private-Network-Access header).
|
|||||||
## Input validation for header values
|
## Input validation for header values
|
||||||
|
|
||||||
Header values with special characters (newlines, NULs) should be rejected at registration time. Currently values are Go-`%q`-quoted in the HAProxy output which handles spaces/commas but may produce backslash escapes that HAProxy doesn't understand for edge cases.
|
Header values with special characters (newlines, NULs) should be rejected at registration time. Currently values are Go-`%q`-quoted in the HAProxy output which handles spaces/commas but may produce backslash escapes that HAProxy doesn't understand for edge cases.
|
||||||
|
|
||||||
|
## APT package: Authelia bare-metal installation
|
||||||
|
|
||||||
|
When building the wild-central apt package in `dist/`, include Authelia as a dependency or document its installation. Bare-metal installation instructions: https://www.authelia.com/integration/deployment/bare-metal/
|
||||||
|
|||||||
10
dist/README.md
vendored
10
dist/README.md
vendored
@@ -6,16 +6,16 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Import the signing key
|
# Import the signing key
|
||||||
curl -fsSL https://aptly.cloud2.payne.io/wild-central.gpg \
|
curl -fsSL https://aptly.cloud.payne.io/public.key \
|
||||||
| sudo tee /usr/share/keyrings/wild-central.gpg > /dev/null
|
| gpg --dearmor | sudo tee /usr/share/keyrings/wild.gpg > /dev/null
|
||||||
|
|
||||||
# Add the repository
|
# Add the repository
|
||||||
sudo tee /etc/apt/sources.list.d/wild-central.sources <<EOF
|
sudo tee /etc/apt/sources.list.d/wild.sources <<EOF
|
||||||
Types: deb
|
Types: deb
|
||||||
URIs: https://aptly.cloud2.payne.io
|
URIs: https://aptly.cloud.payne.io
|
||||||
Suites: stable
|
Suites: stable
|
||||||
Components: main
|
Components: main
|
||||||
Signed-By: /usr/share/keyrings/wild-central.gpg
|
Signed-By: /usr/share/keyrings/wild.gpg
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
sudo apt update && sudo apt install wild-central
|
sudo apt update && sudo apt install wild-central
|
||||||
|
|||||||
23
dist/scripts/deploy-apt-repository.sh
vendored
23
dist/scripts/deploy-apt-repository.sh
vendored
@@ -14,19 +14,22 @@ APTLY_USER="${APTLY_USER:-aptly}"
|
|||||||
APTLY_GPG_KEY="${APTLY_GPG_KEY:-}"
|
APTLY_GPG_KEY="${APTLY_GPG_KEY:-}"
|
||||||
|
|
||||||
VERSION=$(cat ../VERSION 2>/dev/null || echo "0.0.0-dev")
|
VERSION=$(cat ../VERSION 2>/dev/null || echo "0.0.0-dev")
|
||||||
REPO_NAME="wild-central"
|
REPO_NAME="wild"
|
||||||
DIST="${DIST:-stable}"
|
DIST="${DIST:-stable}"
|
||||||
COMPONENT="main"
|
COMPONENT="main"
|
||||||
PACKAGE_DIR="dist/packages"
|
PACKAGE_DIR="dist/packages"
|
||||||
SNAP_NAME="${REPO_NAME}-${VERSION}"
|
SNAP_NAME="wild-central-${VERSION}"
|
||||||
AUTH="${APTLY_USER}:${APTLY_PASS}"
|
AUTH="${APTLY_USER}:${APTLY_PASS}"
|
||||||
|
PACKAGE_GLOB="wild-central_${VERSION}_*.deb"
|
||||||
|
|
||||||
echo "Deploying Wild Central ${VERSION} to APT repository..."
|
echo "Deploying Wild Central ${VERSION} to APT repository..."
|
||||||
echo " URL: ${APTLY_URL}"
|
echo " URL: ${APTLY_URL}"
|
||||||
|
|
||||||
# Signing config
|
# Signing config — use server-side GPG key
|
||||||
|
# The key must exist in Aptly's GPG keyring (auto-generated by the init-gpg container)
|
||||||
|
# Get the fingerprint: curl -sf ${APTLY_URL}/public.key | gpg --with-colons --import-options show-only --import | grep fpr | cut -d: -f10
|
||||||
if [ -n "$APTLY_GPG_KEY" ]; then
|
if [ -n "$APTLY_GPG_KEY" ]; then
|
||||||
SIGNING="{\"GpgKey\":\"${APTLY_GPG_KEY}\"}"
|
SIGNING="{\"GpgKey\":\"${APTLY_GPG_KEY}\",\"Batch\":true}"
|
||||||
else
|
else
|
||||||
echo "APTLY_GPG_KEY not set — publishing without GPG signature"
|
echo "APTLY_GPG_KEY not set — publishing without GPG signature"
|
||||||
SIGNING="{\"Skip\":true}"
|
SIGNING="{\"Skip\":true}"
|
||||||
@@ -63,7 +66,7 @@ echo ""
|
|||||||
echo "Uploading packages..."
|
echo "Uploading packages..."
|
||||||
UPLOAD_DIR="${REPO_NAME}-${VERSION}"
|
UPLOAD_DIR="${REPO_NAME}-${VERSION}"
|
||||||
FOUND=0
|
FOUND=0
|
||||||
for DEB in "${PACKAGE_DIR}"/wild-central_"${VERSION}"_*.deb; do
|
for DEB in "${PACKAGE_DIR}"/${PACKAGE_GLOB}; do
|
||||||
[ -f "$DEB" ] || continue
|
[ -f "$DEB" ] || continue
|
||||||
FOUND=1
|
FOUND=1
|
||||||
FILENAME=$(basename "$DEB")
|
FILENAME=$(basename "$DEB")
|
||||||
@@ -114,7 +117,7 @@ if [ -z "$PUBLISH_PREFIX" ]; then
|
|||||||
> /dev/null
|
> /dev/null
|
||||||
else
|
else
|
||||||
echo "Updating published '${DIST}'..."
|
echo "Updating published '${DIST}'..."
|
||||||
URL_PREFIX=$(echo "$PUBLISH_PREFIX" | sed 's/^\.$/:/')
|
URL_PREFIX=$(echo "$PUBLISH_PREFIX" | sed 's/^\.$/_/')
|
||||||
api PUT "/publish/${URL_PREFIX}/${DIST}" "$UPDATE_BODY" > /dev/null
|
api PUT "/publish/${URL_PREFIX}/${DIST}" "$UPDATE_BODY" > /dev/null
|
||||||
fi
|
fi
|
||||||
echo " Published"
|
echo " Published"
|
||||||
@@ -124,15 +127,15 @@ echo "Wild Central ${VERSION} is live."
|
|||||||
echo ""
|
echo ""
|
||||||
echo "To install on a new device:"
|
echo "To install on a new device:"
|
||||||
echo ""
|
echo ""
|
||||||
echo " curl -fsSL ${APTLY_URL}/wild-central.gpg \\"
|
echo " curl -fsSL ${APTLY_URL}/public.key \\"
|
||||||
echo " | sudo tee /usr/share/keyrings/wild-central.gpg > /dev/null"
|
echo " | gpg --dearmor | sudo tee /usr/share/keyrings/wild.gpg > /dev/null"
|
||||||
echo ""
|
echo ""
|
||||||
echo " sudo tee /etc/apt/sources.list.d/wild-central.sources <<EOF"
|
echo " sudo tee /etc/apt/sources.list.d/wild.sources <<EOF"
|
||||||
echo " Types: deb"
|
echo " Types: deb"
|
||||||
echo " URIs: ${APTLY_URL}"
|
echo " URIs: ${APTLY_URL}"
|
||||||
echo " Suites: ${DIST}"
|
echo " Suites: ${DIST}"
|
||||||
echo " Components: ${COMPONENT}"
|
echo " Components: ${COMPONENT}"
|
||||||
echo " Signed-By: /usr/share/keyrings/wild-central.gpg"
|
echo " Signed-By: /usr/share/keyrings/wild.gpg"
|
||||||
echo " EOF"
|
echo " EOF"
|
||||||
echo ""
|
echo ""
|
||||||
echo " sudo apt update && sudo apt install wild-central"
|
echo " sudo apt update && sudo apt install wild-central"
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ When a service is registered, Central automatically:
|
|||||||
|
|
||||||
| Effect | Passthrough | Terminate |
|
| Effect | Passthrough | Terminate |
|
||||||
|--------|-------------|-----------|
|
|--------|-------------|-----------|
|
||||||
| **LAN DNS** | `address=/<domain>/<backend-IP>` — direct to backend | `address=/<domain>/<central-IP>` — through Central |
|
| **LAN DNS** | `host-record=<domain>,<backend-IP>` (exact) or `address=/<domain>/<backend-IP>` (wildcard if subdomains:true) | `host-record=<domain>,<central-IP>` (exact) or `address=/<domain>/<central-IP>` (wildcard if subdomains:true) |
|
||||||
| **LAN DNS (private)** | Also `local=/<domain>/` — prevents upstream forwarding | Same |
|
| **AAAA filtering** | `filter-AAAA` globally strips IPv6 from upstream responses (prevents Happy Eyeballs on IPv4-only LAN) | Same |
|
||||||
| **Public DNS** | DDNS A record if public | Same |
|
| **Public DNS** | DDNS A record if public | Same |
|
||||||
| **Proxy** | L4 SNI passthrough. Subdomains adds `*.domain` matching. | L7 HTTP reverse proxy by Host header. Routes add path-based ACLs. |
|
| **Proxy** | L4 SNI passthrough. Subdomains adds `*.domain` matching. | L7 HTTP reverse proxy by Host header. Routes add path-based ACLs. |
|
||||||
| **TLS** | Backend handles — Central passes through | Central provisions cert via Let's Encrypt |
|
| **TLS** | Backend handles — Central passes through | Central provisions cert via Let's Encrypt |
|
||||||
@@ -147,7 +147,7 @@ Central creates: LAN DNS → Central IP, public DNS A record, HAProxy L7 TLS ter
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Central creates: LAN DNS → Central IP (with `local=/`), HAProxy L7 reverse proxy, TLS cert via certbot. No public DNS.
|
Central creates: LAN DNS → Central IP (host-record=, exact match), HAProxy L7 reverse proxy, TLS cert via certbot. No public DNS.
|
||||||
|
|
||||||
### Service with custom response headers
|
### Service with custom response headers
|
||||||
|
|
||||||
|
|||||||
@@ -168,17 +168,18 @@ func TestReconciliation_HAProxyConfigFromDomains(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestReconciliation_DnsmasqConfigFromDomains verifies that reconciliation
|
// TestReconciliation_DnsmasqConfigFromDomains verifies that reconciliation
|
||||||
// correctly generates dnsmasq config from registered domains, including
|
// correctly generates dnsmasq config from registered domains. Wildcard
|
||||||
// the critical reach-based local=/ directives.
|
// domains (subdomains:true) get address=/, exact-match domains get
|
||||||
|
// host-record=. No local=/ directives — AAAA is handled by filter-AAAA.
|
||||||
func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
|
func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
|
||||||
api, _ := setupTestAPI(t)
|
api, _ := setupTestAPI(t)
|
||||||
|
|
||||||
centralIP := "192.168.8.151"
|
centralIP := "192.168.8.151"
|
||||||
|
|
||||||
// Register domains with different reach and backend types
|
// Register domains with different backend types and subdomain settings
|
||||||
testDomains := []domains.Domain{
|
testDomains := []domains.Domain{
|
||||||
{
|
{
|
||||||
// tcp-passthrough, public → DNS points to k8s LB IP, no local=/
|
// tcp-passthrough, wildcard → address=/ with backend IP
|
||||||
DomainName: "cloud.payne.io",
|
DomainName: "cloud.payne.io",
|
||||||
Source: "wild-cloud",
|
Source: "wild-cloud",
|
||||||
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
|
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
|
||||||
@@ -186,13 +187,13 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
|
|||||||
Public: true,
|
Public: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// http, internal → DNS points to Central IP, has local=/
|
// http, exact → host-record= with Central IP
|
||||||
DomainName: "my-api.payne.io",
|
DomainName: "my-api.payne.io",
|
||||||
Source: "wild-works",
|
Source: "wild-works",
|
||||||
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
|
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// http, public → DNS points to Central IP, NO local=/
|
// http, exact → host-record= with Central IP
|
||||||
DomainName: "public-app.payne.io",
|
DomainName: "public-app.payne.io",
|
||||||
Source: "wild-works",
|
Source: "wild-works",
|
||||||
Backend: domains.Backend{Address: "192.168.8.60:8080", Type: domains.BackendHTTP},
|
Backend: domains.Backend{Address: "192.168.8.60:8080", Type: domains.BackendHTTP},
|
||||||
@@ -228,33 +229,33 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
|
|||||||
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
||||||
Domain: dom.DomainName,
|
Domain: dom.DomainName,
|
||||||
IP: dnsIP,
|
IP: dnsIP,
|
||||||
|
Wildcard: dom.Subdomains,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
|
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
|
||||||
|
|
||||||
// --- TCP passthrough (public) → backend IP ---
|
// --- Wildcard (subdomains:true) → address=/ with backend IP ---
|
||||||
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
|
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
|
||||||
t.Errorf("tcp-passthrough domain must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg)
|
t.Errorf("wildcard domain must use address=/ with backend IP:\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HTTP domains → Central IP ---
|
// --- Exact-match HTTP domains → host-record= with Central IP ---
|
||||||
if !strings.Contains(dnsmasqCfg, "address=/my-api.payne.io/192.168.8.151") {
|
if !strings.Contains(dnsmasqCfg, "host-record=my-api.payne.io,192.168.8.151") {
|
||||||
t.Errorf("http/internal domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
|
t.Errorf("exact-match http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
if !strings.Contains(dnsmasqCfg, "address=/public-app.payne.io/192.168.8.151") {
|
if !strings.Contains(dnsmasqCfg, "host-record=public-app.payne.io,192.168.8.151") {
|
||||||
t.Errorf("http/public domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
|
t.Errorf("exact-match http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- All domains get local=/ to prevent AAAA leaking upstream (Happy Eyeballs) ---
|
// --- No local=/ directives (AAAA handled by filter-AAAA) ---
|
||||||
if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") {
|
if strings.Contains(dnsmasqCfg, "local=/") {
|
||||||
t.Errorf("domain must have local=/ entry:\n%s", dnsmasqCfg)
|
t.Errorf("must not produce local=/ directives:\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
if !strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") {
|
|
||||||
t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg)
|
// --- filter-AAAA present ---
|
||||||
}
|
if !strings.Contains(dnsmasqCfg, "filter-AAAA") {
|
||||||
if !strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") {
|
t.Errorf("must include filter-AAAA directive:\n%s", dnsmasqCfg)
|
||||||
t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,17 +348,18 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
|
|||||||
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
||||||
Domain: dom.DomainName,
|
Domain: dom.DomainName,
|
||||||
IP: dnsIP,
|
IP: dnsIP,
|
||||||
|
Wildcard: dom.Subdomains,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
|
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
|
||||||
|
|
||||||
// TCP passthrough → DNS points to backend (k8s LB), NOT central
|
// TCP passthrough with subdomains → address=/ to backend (k8s LB), NOT central
|
||||||
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
|
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
|
||||||
t.Errorf("tcp-passthrough DNS must point to backend IP 192.168.8.240:\n%s", dnsmasqCfg)
|
t.Errorf("wildcard tcp-passthrough DNS must use address=/ to backend IP 192.168.8.240:\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
if strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.151") {
|
if strings.Contains(dnsmasqCfg, "host-record=cloud.payne.io,192.168.8.151") {
|
||||||
t.Errorf("tcp-passthrough DNS must NOT point to Central IP:\n%s", dnsmasqCfg)
|
t.Errorf("wildcard tcp-passthrough DNS must NOT use host-record= to Central IP:\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,18 +441,19 @@ func TestReconciliation_DNSOnlyNoGateway(t *testing.T) {
|
|||||||
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
||||||
Domain: dom.DomainName,
|
Domain: dom.DomainName,
|
||||||
IP: dnsIP,
|
IP: dnsIP,
|
||||||
|
Wildcard: dom.Subdomains,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
|
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
|
||||||
|
|
||||||
// dns-only domain should resolve to its backend IP (192.168.8.222)
|
// dns-only domain (public, no subdomains) → host-record= with backend IP
|
||||||
if !strings.Contains(dnsmasqCfg, "address=/dev.payne.io/192.168.8.222") {
|
if !strings.Contains(dnsmasqCfg, "host-record=dev.payne.io,192.168.8.222") {
|
||||||
t.Errorf("dns-only domain must point DNS to backend IP:\n%s", dnsmasqCfg)
|
t.Errorf("dns-only domain must use host-record= with backend IP:\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
// HTTP domain should resolve to Central IP
|
// HTTP domain (no subdomains) → host-record= with Central IP
|
||||||
if !strings.Contains(dnsmasqCfg, "address=/app.payne.io/192.168.8.151") {
|
if !strings.Contains(dnsmasqCfg, "host-record=app.payne.io,192.168.8.151") {
|
||||||
t.Errorf("http domain must point DNS to Central IP:\n%s", dnsmasqCfg)
|
t.Errorf("http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ func (api *API) reconcileNetworking() {
|
|||||||
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
||||||
Domain: dom.DomainName,
|
Domain: dom.DomainName,
|
||||||
IP: dnsIP,
|
IP: dnsIP,
|
||||||
|
Wildcard: dom.Subdomains,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -349,6 +349,11 @@ func getCloudflareZoneID(apiToken, zoneName string) (string, error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Result []struct {
|
Result []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -374,6 +379,11 @@ func getCloudflareRecordID(apiToken, zoneID, recordName, recordType string) (id,
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", "", fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Result []cloudflareRecord `json:"result"`
|
Result []cloudflareRecord `json:"result"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// DNSEntry represents a single domain-to-IP DNS mapping for dnsmasq.
|
// DNSEntry represents a single domain-to-IP DNS mapping for dnsmasq.
|
||||||
// Each entry produces both local=/ and address=/ directives.
|
// Exact-match domains use host-record= (SOA/NS queries forwarded upstream,
|
||||||
// local=/ makes dnsmasq authoritative for the domain, which prevents
|
// enabling ACME DNS-01 validation). Wildcard domains use address=/ to resolve
|
||||||
// AAAA queries from leaking to upstream DNS. Without it, upstream could
|
// all subdomains. Happy Eyeballs (AAAA leaking) is handled globally by
|
||||||
// return public IPv6 records and browsers using Happy Eyeballs (RFC 8305)
|
// filter-AAAA in the config template.
|
||||||
// would try the unreachable IPv6 path first, adding latency on LAN.
|
|
||||||
type DNSEntry struct {
|
type DNSEntry struct {
|
||||||
Domain string // FQDN to resolve (e.g. "cloud.payne.io")
|
Domain string // FQDN to resolve (e.g. "cloud.payne.io")
|
||||||
IP string // target IPv4 address
|
IP string // target IPv4 address
|
||||||
|
Wildcard bool // true: address=/ (all subdomains); false: host-record= (exact match)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigGenerator handles dnsmasq configuration generation
|
// ConfigGenerator handles dnsmasq configuration generation
|
||||||
@@ -75,12 +75,17 @@ func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string
|
|||||||
if entry.Domain == "" || entry.IP == "" {
|
if entry.Domain == "" || entry.IP == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// local=/ makes dnsmasq authoritative for the domain so AAAA queries
|
if entry.Wildcard {
|
||||||
// return empty instead of leaking to upstream DNS. Without this,
|
// Wildcard: resolves domain + all subdomains to this IP.
|
||||||
// upstream could return public IPv6 records and browsers using Happy
|
// Note: address=/ blocks forwarding for the entire subtree,
|
||||||
// Eyeballs (RFC 8305) would try the unreachable IPv6 path first,
|
// so SOA walks stop here. For non-apex domains this is fine —
|
||||||
// adding 250ms-2s latency on LAN.
|
// the walk continues up to the parent (which uses host-record=).
|
||||||
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", entry.Domain, entry.Domain, entry.IP)
|
resolution_section += fmt.Sprintf("address=/%s/%s\n", entry.Domain, entry.IP)
|
||||||
|
} else {
|
||||||
|
// Exact match: only this specific name. SOA/NS/TXT queries
|
||||||
|
// are forwarded upstream, enabling ACME DNS-01 validation.
|
||||||
|
resolution_section += fmt.Sprintf("host-record=%s,%s\n", entry.Domain, entry.IP)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
template := `# Configuration file for dnsmasq.
|
template := `# Configuration file for dnsmasq.
|
||||||
@@ -91,8 +96,9 @@ bind-interfaces
|
|||||||
domain-needed
|
domain-needed
|
||||||
bogus-priv
|
bogus-priv
|
||||||
no-resolv
|
no-resolv
|
||||||
|
filter-AAAA
|
||||||
|
|
||||||
# DNS Local Resolution - Central server handles these domains authoritatively
|
# DNS Local Resolution - registered domain A records
|
||||||
%s
|
%s
|
||||||
server=1.1.1.1
|
server=1.1.1.1
|
||||||
server=8.8.8.8
|
server=8.8.8.8
|
||||||
@@ -113,10 +119,12 @@ log-dhcp
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestartService reloads the dnsmasq service (SIGHUP — no downtime).
|
// RestartService fully restarts the dnsmasq service.
|
||||||
// Falls back to restart if reload fails.
|
// A full restart is required because SIGHUP (reload) does not re-read
|
||||||
|
// host-record= directives — only address=/ and a few other directives
|
||||||
|
// are reloaded on SIGHUP.
|
||||||
func (g *ConfigGenerator) RestartService() error {
|
func (g *ConfigGenerator) RestartService() error {
|
||||||
cmd := exec.Command("systemctl", "reload-or-restart", "dnsmasq.service")
|
cmd := exec.Command("systemctl", "restart", "dnsmasq.service")
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to reload dnsmasq: %w (output: %s)", err, string(output))
|
return fmt.Errorf("failed to reload dnsmasq: %w (output: %s)", err, string(output))
|
||||||
@@ -187,9 +195,10 @@ func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count domains in config by counting local=/ directives
|
// Count domains in config by counting host-record= and address=/ directives
|
||||||
if data, err := os.ReadFile(g.configPath); err == nil {
|
if data, err := os.ReadFile(g.configPath); err == nil {
|
||||||
status.DomainsConfigured = strings.Count(string(data), "\nlocal=/")
|
config := string(data)
|
||||||
|
status.DomainsConfigured = strings.Count(config, "host-record=") + strings.Count(config, "\naddress=/")
|
||||||
}
|
}
|
||||||
|
|
||||||
return status, nil
|
return status, nil
|
||||||
@@ -303,18 +312,11 @@ func (g *ConfigGenerator) ValidateConfig(configPath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReloadService sends a SIGHUP to dnsmasq to reload configuration.
|
// ReloadService restarts dnsmasq to apply configuration changes.
|
||||||
// Lighter weight than a full restart. Falls back to RestartService on failure.
|
// Uses a full restart because SIGHUP does not re-read host-record= directives.
|
||||||
func (g *ConfigGenerator) ReloadService() error {
|
func (g *ConfigGenerator) ReloadService() error {
|
||||||
cmd := exec.Command("systemctl", "reload", "dnsmasq.service")
|
|
||||||
_, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("reload failed, attempting restart", "component", "dnsmasq", "error", err)
|
|
||||||
return g.RestartService()
|
return g.RestartService()
|
||||||
}
|
}
|
||||||
slog.Info("dnsmasq service reloaded", "component", "dnsmasq")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfigureSystemDNS configures systemd-resolved to use the local dnsmasq server
|
// ConfigureSystemDNS configures systemd-resolved to use the local dnsmasq server
|
||||||
// This should only be called on first start of dnsmasq
|
// This should only be called on first start of dnsmasq
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ func entry(domain, ip string) DNSEntry {
|
|||||||
return DNSEntry{Domain: domain, IP: ip}
|
return DNSEntry{Domain: domain, IP: ip}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: Generate with entries produces expected DNS entries
|
func wildcardEntry(domain, ip string) DNSEntry {
|
||||||
|
return DNSEntry{Domain: domain, IP: ip, Wildcard: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Generate with exact-match entries produces host-record= directives
|
||||||
func TestGenerate_WithEntries(t *testing.T) {
|
func TestGenerate_WithEntries(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
entries := []DNSEntry{
|
entries := []DNSEntry{
|
||||||
@@ -22,17 +26,11 @@ func TestGenerate_WithEntries(t *testing.T) {
|
|||||||
|
|
||||||
out := g.Generate(nil, entries)
|
out := g.Generate(nil, entries)
|
||||||
|
|
||||||
if !strings.Contains(out, "local=/cloud.example.com/") {
|
if !strings.Contains(out, "host-record=cloud.example.com,192.168.1.10") {
|
||||||
t.Errorf("expected local=/ for domain, got:\n%s", out)
|
t.Errorf("expected host-record for domain, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") {
|
if !strings.Contains(out, "host-record=internal.example.com,192.168.1.10") {
|
||||||
t.Errorf("expected address entry for domain, got:\n%s", out)
|
t.Errorf("expected host-record for internal domain, got:\n%s", out)
|
||||||
}
|
|
||||||
if !strings.Contains(out, "local=/internal.example.com/") {
|
|
||||||
t.Errorf("expected local=/ for internal domain, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") {
|
|
||||||
t.Errorf("expected address entry for internal domain, got:\n%s", out)
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "server=1.1.1.1") {
|
if !strings.Contains(out, "server=1.1.1.1") {
|
||||||
t.Errorf("expected upstream DNS server, got:\n%s", out)
|
t.Errorf("expected upstream DNS server, got:\n%s", out)
|
||||||
@@ -62,8 +60,8 @@ func TestGenerate_EntryWithoutIP(t *testing.T) {
|
|||||||
|
|
||||||
out := g.Generate(nil, []DNSEntry{entry("cloud.example.com", "")})
|
out := g.Generate(nil, []DNSEntry{entry("cloud.example.com", "")})
|
||||||
|
|
||||||
if strings.Contains(out, "address=/") {
|
if strings.Contains(out, "host-record=") {
|
||||||
t.Errorf("expected no address= entries for empty IP, got:\n%s", out)
|
t.Errorf("expected no host-record entries for empty IP, got:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,78 +196,98 @@ func TestNewConfigGenerator_DefaultPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: Domain-only entry (no internal domain pair)
|
// Test: Exact-match domain produces host-record=
|
||||||
func TestGenerate_DomainOnly(t *testing.T) {
|
func TestGenerate_ExactMatchDomain(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.State{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
out := g.Generate(globalCfg, []DNSEntry{entry("my-api.payne.io", "192.168.8.151")})
|
out := g.Generate(globalCfg, []DNSEntry{entry("my-api.payne.io", "192.168.8.151")})
|
||||||
|
|
||||||
if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") {
|
if !strings.Contains(out, "host-record=my-api.payne.io,192.168.8.151") {
|
||||||
t.Errorf("expected address entry for domain, got:\n%s", out)
|
t.Errorf("expected host-record entry for domain, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "local=/my-api.payne.io/") {
|
if strings.Contains(out, "address=/") {
|
||||||
t.Errorf("expected local=/ entry for domain, got:\n%s", out)
|
t.Errorf("exact-match domain must not produce address=/ directive:\n%s", out)
|
||||||
}
|
}
|
||||||
// Must NOT have empty entries
|
if strings.Contains(out, "local=/") {
|
||||||
if strings.Contains(out, "local=//") {
|
t.Errorf("must not produce local=/ directive:\n%s", out)
|
||||||
t.Errorf("unexpected empty local=// in output:\n%s", out)
|
|
||||||
}
|
|
||||||
if strings.Contains(out, "address=//") {
|
|
||||||
t.Errorf("unexpected empty address=// in output:\n%s", out)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: All domains get local=/ to prevent AAAA leaking upstream
|
// Test: Wildcard domain produces address=/ without local=/
|
||||||
func TestGenerate_AllDomainsGetLocal(t *testing.T) {
|
func TestGenerate_WildcardDomain(t *testing.T) {
|
||||||
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
|
out := g.Generate(globalCfg, []DNSEntry{wildcardEntry("cloud.payne.io", "192.168.8.240")})
|
||||||
|
|
||||||
|
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
|
||||||
|
t.Errorf("expected address=/ entry for wildcard domain, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "host-record=") {
|
||||||
|
t.Errorf("wildcard domain must not produce host-record= directive:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "local=/") {
|
||||||
|
t.Errorf("must not produce local=/ directive:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: No local=/ directives are ever generated
|
||||||
|
func TestGenerate_NoLocalDirectives(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.State{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
// Both "internal" and "public" domains should get local=/ —
|
|
||||||
// prevents AAAA queries from leaking to upstream DNS (Happy Eyeballs).
|
|
||||||
entries := []DNSEntry{
|
entries := []DNSEntry{
|
||||||
entry("internal-api.payne.io", "192.168.8.151"),
|
entry("internal-api.payne.io", "192.168.8.151"),
|
||||||
entry("cloud.payne.io", "192.168.8.240"),
|
wildcardEntry("cloud.payne.io", "192.168.8.240"),
|
||||||
}
|
}
|
||||||
|
|
||||||
out := g.Generate(globalCfg, entries)
|
out := g.Generate(globalCfg, entries)
|
||||||
|
|
||||||
if !strings.Contains(out, "local=/internal-api.payne.io/") {
|
if strings.Contains(out, "local=/") {
|
||||||
t.Errorf("expected local=/ for internal domain, got:\n%s", out)
|
t.Errorf("must never produce local=/ directives:\n%s", out)
|
||||||
}
|
|
||||||
if !strings.Contains(out, "local=/cloud.payne.io/") {
|
|
||||||
t.Errorf("expected local=/ for public domain (prevents AAAA leaking), got:\n%s", out)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: Mix of domains each get their own entries
|
// Test: filter-AAAA appears in generated config
|
||||||
|
func TestGenerate_FilterAAAA(t *testing.T) {
|
||||||
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
|
|
||||||
|
out := g.Generate(nil, nil)
|
||||||
|
|
||||||
|
if !strings.Contains(out, "filter-AAAA") {
|
||||||
|
t.Errorf("expected filter-AAAA in config, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Mix of exact and wildcard domains
|
||||||
func TestGenerate_MixedDomains(t *testing.T) {
|
func TestGenerate_MixedDomains(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.State{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
entries := []DNSEntry{
|
entries := []DNSEntry{
|
||||||
entry("cloud.payne.io", "192.168.8.240"), // k8s passthrough
|
wildcardEntry("cloud.payne.io", "192.168.8.240"), // wildcard
|
||||||
entry("central.payne.io", "192.168.8.151"), // Central HTTP
|
entry("central.payne.io", "192.168.8.151"), // exact
|
||||||
entry("wild-cloud.payne.io", "192.168.8.151"), // service HTTP
|
entry("wild-cloud.payne.io", "192.168.8.151"), // exact
|
||||||
}
|
}
|
||||||
|
|
||||||
out := g.Generate(globalCfg, entries)
|
out := g.Generate(globalCfg, entries)
|
||||||
|
|
||||||
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
|
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
|
||||||
t.Errorf("expected address for cloud domain, got:\n%s", out)
|
t.Errorf("expected address=/ for wildcard domain, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") {
|
if !strings.Contains(out, "host-record=central.payne.io,192.168.8.151") {
|
||||||
t.Errorf("expected address for central, got:\n%s", out)
|
t.Errorf("expected host-record for exact domain, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "address=/wild-cloud.payne.io/192.168.8.151") {
|
if !strings.Contains(out, "host-record=wild-cloud.payne.io,192.168.8.151") {
|
||||||
t.Errorf("expected address for wild-cloud, got:\n%s", out)
|
t.Errorf("expected host-record for exact domain, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if strings.Contains(out, "local=//") {
|
if strings.Contains(out, "local=/") {
|
||||||
t.Errorf("unexpected empty local=// in output:\n%s", out)
|
t.Errorf("must not produce local=/ directives:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: empty entries list produces base config with no address= entries
|
// Test: empty entries list produces base config with no DNS entries
|
||||||
func TestGenerate_EmptyEntries(t *testing.T) {
|
func TestGenerate_EmptyEntries(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.State{}
|
globalCfg := &config.State{}
|
||||||
@@ -282,15 +300,15 @@ func TestGenerate_EmptyEntries(t *testing.T) {
|
|||||||
if !strings.Contains(out, "server=1.1.1.1") {
|
if !strings.Contains(out, "server=1.1.1.1") {
|
||||||
t.Errorf("expected upstream DNS server, got:\n%s", out)
|
t.Errorf("expected upstream DNS server, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if strings.Contains(out, "address=/") {
|
if strings.Contains(out, "host-record=") {
|
||||||
t.Errorf("expected no address= entries with empty entries, got:\n%s", out)
|
t.Errorf("expected no host-record entries with empty entries, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if strings.Contains(out, "local=/") {
|
if strings.Contains(out, "address=/") {
|
||||||
t.Errorf("expected no local=/ entries with empty entries, got:\n%s", out)
|
t.Errorf("expected no address=/ entries with empty entries, got:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: multiple entries each get their own DNS records
|
// Test: multiple exact-match entries each get their own DNS records
|
||||||
func TestGenerate_MultipleEntries(t *testing.T) {
|
func TestGenerate_MultipleEntries(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.State{}
|
globalCfg := &config.State{}
|
||||||
@@ -304,11 +322,8 @@ func TestGenerate_MultipleEntries(t *testing.T) {
|
|||||||
out := g.Generate(globalCfg, entries)
|
out := g.Generate(globalCfg, entries)
|
||||||
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", e.Domain, e.IP)) {
|
if !strings.Contains(out, fmt.Sprintf("host-record=%s,%s", e.Domain, e.IP)) {
|
||||||
t.Errorf("expected address entry for %s, got:\n%s", e.Domain, out)
|
t.Errorf("expected host-record entry for %s, got:\n%s", e.Domain, out)
|
||||||
}
|
|
||||||
if !strings.Contains(out, fmt.Sprintf("local=/%s/", e.Domain)) {
|
|
||||||
t.Errorf("expected local=/ entry for %s, got:\n%s", e.Domain, out)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,7 +392,7 @@ func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: verify no address=// or local=// entries appear
|
// Test: verify no leaked empty entries appear
|
||||||
func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
|
func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
cfg := &config.State{}
|
cfg := &config.State{}
|
||||||
@@ -387,7 +402,8 @@ func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
|
|||||||
entries []DNSEntry
|
entries []DNSEntry
|
||||||
}{
|
}{
|
||||||
{"empty", nil},
|
{"empty", nil},
|
||||||
{"domain_only", []DNSEntry{entry("example.com", "10.0.0.1")}},
|
{"exact_domain", []DNSEntry{entry("example.com", "10.0.0.1")}},
|
||||||
|
{"wildcard_domain", []DNSEntry{wildcardEntry("example.com", "10.0.0.1")}},
|
||||||
{"no_ip", []DNSEntry{entry("example.com", "")}},
|
{"no_ip", []DNSEntry{entry("example.com", "")}},
|
||||||
{"no_domain", []DNSEntry{entry("", "10.0.0.1")}},
|
{"no_domain", []DNSEntry{entry("", "10.0.0.1")}},
|
||||||
}
|
}
|
||||||
@@ -395,11 +411,14 @@ func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
|
|||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
out := g.Generate(cfg, tc.entries)
|
out := g.Generate(cfg, tc.entries)
|
||||||
|
if strings.Contains(out, "host-record=,") {
|
||||||
|
t.Errorf("leaked empty host-record in output:\n%s", out)
|
||||||
|
}
|
||||||
if strings.Contains(out, "address=//") {
|
if strings.Contains(out, "address=//") {
|
||||||
t.Errorf("leaked empty address=// in output:\n%s", out)
|
t.Errorf("leaked empty address=// in output:\n%s", out)
|
||||||
}
|
}
|
||||||
if strings.Contains(out, "local=//") {
|
if strings.Contains(out, "local=/") {
|
||||||
t.Errorf("leaked empty local=// in output:\n%s", out)
|
t.Errorf("must not produce local=/ directives:\n%s", out)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export function AppSidebar() {
|
|||||||
{ to: '/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const },
|
{ to: '/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const },
|
||||||
{ to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
{ to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
||||||
{ to: '/advanced/authelia', icon: KeyRound, label: 'Authelia', daemon: 'authelia' as const },
|
{ to: '/advanced/authelia', icon: KeyRound, label: 'Authelia', daemon: 'authelia' as const },
|
||||||
|
{ to: '/advanced/ddns', icon: Globe, label: 'DDNS', daemon: undefined },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
160
web/src/components/advanced/DdnsSubsystem.tsx
Normal file
160
web/src/components/advanced/DdnsSubsystem.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { Card, CardContent } from '../ui/card';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Alert, AlertDescription } from '../ui/alert';
|
||||||
|
import { Badge } from '../ui/badge';
|
||||||
|
import { Loader2, CheckCircle, AlertCircle, RefreshCw, Globe } from 'lucide-react';
|
||||||
|
import { useDdns } from '../../hooks/useDdns';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ddnsConfigApi } from '../../services/api/settings';
|
||||||
|
|
||||||
|
function formatTime(iso?: string): string {
|
||||||
|
if (!iso) return '--';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '--';
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DdnsSubsystem() {
|
||||||
|
const { status, isLoadingStatus, trigger, isTriggering } = useDdns();
|
||||||
|
|
||||||
|
const configQuery = useQuery({
|
||||||
|
queryKey: ['ddns', 'config'],
|
||||||
|
queryFn: () => ddnsConfigApi.get(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = configQuery.data;
|
||||||
|
const records = status?.records ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<Globe className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">Dynamic DNS</h2>
|
||||||
|
<p className="text-muted-foreground">Cloudflare DNS records managed by Wild Central</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status && (
|
||||||
|
<Badge variant={status.enabled ? 'success' : 'secondary'} className="gap-1">
|
||||||
|
{status.enabled ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
|
||||||
|
{status.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global error */}
|
||||||
|
{status?.lastError && (
|
||||||
|
<Alert variant="error">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{status.lastError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status + Actions */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm flex-1">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Public IP</span>
|
||||||
|
<div className="font-mono mt-0.5">{status?.currentIP ?? '--'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Provider</span>
|
||||||
|
<div className="font-mono mt-0.5">{config?.provider ?? '--'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Last Checked</span>
|
||||||
|
<div className="font-mono mt-0.5 text-xs">{formatTime(status?.lastChecked)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Last Updated</span>
|
||||||
|
<div className="font-mono mt-0.5 text-xs">{formatTime(status?.lastUpdated)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1 ml-4 shrink-0"
|
||||||
|
onClick={() => trigger()}
|
||||||
|
disabled={isTriggering || !status?.enabled}
|
||||||
|
>
|
||||||
|
{isTriggering ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||||
|
Sync Now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{config?.intervalMinutes && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Checks every {config.intervalMinutes} minute{config.intervalMinutes !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Records table */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="text-sm font-medium mb-3">Managed Records</h3>
|
||||||
|
{isLoadingStatus ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Globe className="h-10 w-10 text-muted-foreground mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{status?.enabled
|
||||||
|
? 'No public domains registered. Mark a domain as public to create a DDNS record.'
|
||||||
|
: 'DDNS is disabled. Enable it in the Domains settings to manage DNS records.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left">
|
||||||
|
<th className="pb-2 font-medium text-muted-foreground">Record</th>
|
||||||
|
<th className="pb-2 font-medium text-muted-foreground">IP</th>
|
||||||
|
<th className="pb-2 font-medium text-muted-foreground text-right">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{records.map((r) => (
|
||||||
|
<tr key={r.name} className="border-b last:border-0">
|
||||||
|
<td className="py-2 font-mono text-xs">{r.name}</td>
|
||||||
|
<td className="py-2 font-mono text-xs">{r.ip ?? '--'}</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
{r.ok ? (
|
||||||
|
<Badge variant="success" className="gap-1 text-xs">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
Synced
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Badge variant="destructive" className="gap-1 text-xs">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
Error
|
||||||
|
</Badge>
|
||||||
|
{r.error && (
|
||||||
|
<span className="text-xs text-muted-foreground max-w-[200px] truncate" title={r.error}>
|
||||||
|
{r.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ export { NftablesSubsystem } from './NftablesSubsystem';
|
|||||||
export { WireguardSubsystem } from './WireguardSubsystem';
|
export { WireguardSubsystem } from './WireguardSubsystem';
|
||||||
export { CrowdsecSubsystem } from './CrowdsecSubsystem';
|
export { CrowdsecSubsystem } from './CrowdsecSubsystem';
|
||||||
export { AutheliaSubsystem } from './AutheliaSubsystem';
|
export { AutheliaSubsystem } from './AutheliaSubsystem';
|
||||||
|
export { DdnsSubsystem } from './DdnsSubsystem';
|
||||||
|
|||||||
10
web/src/router/pages/advanced/DdnsPage.tsx
Normal file
10
web/src/router/pages/advanced/DdnsPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../../components';
|
||||||
|
import { DdnsSubsystem } from '../../../components/advanced';
|
||||||
|
|
||||||
|
export function DdnsPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<DdnsSubsystem />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ export { NftablesPage } from './NftablesPage';
|
|||||||
export { WireguardPage } from './WireguardPage';
|
export { WireguardPage } from './WireguardPage';
|
||||||
export { CrowdsecPage } from './CrowdsecPage';
|
export { CrowdsecPage } from './CrowdsecPage';
|
||||||
export { AutheliaAdvancedPage } from './AutheliaPage';
|
export { AutheliaAdvancedPage } from './AutheliaPage';
|
||||||
|
export { DdnsPage } from './DdnsPage';
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage,
|
HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage,
|
||||||
CrowdsecPage as CrowdsecAdvancedPage,
|
CrowdsecPage as CrowdsecAdvancedPage,
|
||||||
AutheliaAdvancedPage,
|
AutheliaAdvancedPage,
|
||||||
|
DdnsPage,
|
||||||
} from './pages/advanced';
|
} from './pages/advanced';
|
||||||
|
|
||||||
export const routes: RouteObject[] = [
|
export const routes: RouteObject[] = [
|
||||||
@@ -34,6 +35,7 @@ export const routes: RouteObject[] = [
|
|||||||
{ 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 /> },
|
{ path: 'advanced/authelia', element: <AutheliaAdvancedPage /> },
|
||||||
|
{ path: 'advanced/ddns', element: <DdnsPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user