From 1e7d93256ee418f4733f680bb9cad5c461dd60bb Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 13 Jul 2026 00:26:13 +0000 Subject: [PATCH] Support custom domains. Fix host-record/address resolution. --- TODO.md | 4 + docs/registrations.md | 6 +- .../api/v1/handlers_reconciliation_test.go | 77 +++++---- internal/api/v1/helpers.go | 5 +- internal/dnsmasq/config.go | 60 +++---- internal/dnsmasq/config_test.go | 141 ++++++++------- web/src/components/AppSidebar.tsx | 1 + web/src/components/advanced/DdnsSubsystem.tsx | 160 ++++++++++++++++++ web/src/components/advanced/index.ts | 1 + web/src/router/pages/advanced/DdnsPage.tsx | 10 ++ web/src/router/pages/advanced/index.ts | 1 + web/src/router/routes.tsx | 2 + 12 files changed, 336 insertions(+), 132 deletions(-) create mode 100644 web/src/components/advanced/DdnsSubsystem.tsx create mode 100644 web/src/router/pages/advanced/DdnsPage.tsx diff --git a/TODO.md b/TODO.md index 063f159..6e156f8 100644 --- a/TODO.md +++ b/TODO.md @@ -16,3 +16,7 @@ Affects: keila (CORS headers), plausible (Private-Network-Access header). ## Input validation for header values Header values with special characters (newlines, NULs) should be rejected at registration time. Currently values are Go-`%q`-quoted in the HAProxy output which handles spaces/commas but may produce backslash escapes that HAProxy doesn't understand for edge cases. + +## APT package: Authelia bare-metal installation + +When building the wild-central apt package in `dist/`, include Authelia as a dependency or document its installation. Bare-metal installation instructions: https://www.authelia.com/integration/deployment/bare-metal/ diff --git a/docs/registrations.md b/docs/registrations.md index e433e2d..00b4219 100644 --- a/docs/registrations.md +++ b/docs/registrations.md @@ -113,8 +113,8 @@ When a service is registered, Central automatically: | Effect | Passthrough | Terminate | |--------|-------------|-----------| -| **LAN DNS** | `address=//` — direct to backend | `address=//` — through Central | -| **LAN DNS (private)** | Also `local=//` — prevents upstream forwarding | Same | +| **LAN DNS** | `host-record=,` (exact) or `address=//` (wildcard if subdomains:true) | `host-record=,` (exact) or `address=//` (wildcard if subdomains:true) | +| **AAAA filtering** | `filter-AAAA` globally strips IPv6 from upstream responses (prevents Happy Eyeballs on IPv4-only LAN) | Same | | **Public DNS** | DDNS A record if public | Same | | **Proxy** | L4 SNI passthrough. Subdomains adds `*.domain` matching. | L7 HTTP reverse proxy by Host header. Routes add path-based ACLs. | | **TLS** | Backend handles — Central passes through | Central provisions cert via Let's Encrypt | @@ -147,7 +147,7 @@ Central creates: LAN DNS → Central IP, public DNS A record, HAProxy L7 TLS ter } ``` -Central creates: LAN DNS → Central IP (with `local=/`), HAProxy L7 reverse proxy, TLS cert via certbot. No public DNS. +Central creates: LAN DNS → Central IP (host-record=, exact match), HAProxy L7 reverse proxy, TLS cert via certbot. No public DNS. ### Service with custom response headers diff --git a/internal/api/v1/handlers_reconciliation_test.go b/internal/api/v1/handlers_reconciliation_test.go index 1120a38..735bbc9 100644 --- a/internal/api/v1/handlers_reconciliation_test.go +++ b/internal/api/v1/handlers_reconciliation_test.go @@ -168,17 +168,18 @@ func TestReconciliation_HAProxyConfigFromDomains(t *testing.T) { } // TestReconciliation_DnsmasqConfigFromDomains verifies that reconciliation -// correctly generates dnsmasq config from registered domains, including -// the critical reach-based local=/ directives. +// correctly generates dnsmasq config from registered domains. Wildcard +// domains (subdomains:true) get address=/, exact-match domains get +// host-record=. No local=/ directives — AAAA is handled by filter-AAAA. func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) { api, _ := setupTestAPI(t) centralIP := "192.168.8.151" - // Register domains with different reach and backend types + // Register domains with different backend types and subdomain settings testDomains := []domains.Domain{ { - // tcp-passthrough, public → DNS points to k8s LB IP, no local=/ + // tcp-passthrough, wildcard → address=/ with backend IP DomainName: "cloud.payne.io", Source: "wild-cloud", Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough}, @@ -186,13 +187,13 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) { Public: true, }, { - // http, internal → DNS points to Central IP, has local=/ + // http, exact → host-record= with Central IP DomainName: "my-api.payne.io", Source: "wild-works", Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP}, }, { - // http, public → DNS points to Central IP, NO local=/ + // http, exact → host-record= with Central IP DomainName: "public-app.payne.io", Source: "wild-works", Backend: domains.Backend{Address: "192.168.8.60:8080", Type: domains.BackendHTTP}, @@ -226,35 +227,35 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) { } dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{ - Domain: dom.DomainName, - IP: dnsIP, + Domain: dom.DomainName, + IP: dnsIP, + Wildcard: dom.Subdomains, }) } dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries) - // --- TCP passthrough (public) → backend IP --- + // --- Wildcard (subdomains:true) → address=/ with backend IP --- if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") { - t.Errorf("tcp-passthrough domain must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg) + t.Errorf("wildcard domain must use address=/ with backend IP:\n%s", dnsmasqCfg) } - // --- HTTP domains → Central IP --- - if !strings.Contains(dnsmasqCfg, "address=/my-api.payne.io/192.168.8.151") { - t.Errorf("http/internal domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg) + // --- Exact-match HTTP domains → host-record= with Central IP --- + if !strings.Contains(dnsmasqCfg, "host-record=my-api.payne.io,192.168.8.151") { + t.Errorf("exact-match http domain must use host-record= with Central IP:\n%s", dnsmasqCfg) } - if !strings.Contains(dnsmasqCfg, "address=/public-app.payne.io/192.168.8.151") { - t.Errorf("http/public domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg) + if !strings.Contains(dnsmasqCfg, "host-record=public-app.payne.io,192.168.8.151") { + t.Errorf("exact-match http domain must use host-record= with Central IP:\n%s", dnsmasqCfg) } - // --- All domains get local=/ to prevent AAAA leaking upstream (Happy Eyeballs) --- - if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") { - t.Errorf("domain must have local=/ entry:\n%s", dnsmasqCfg) + // --- No local=/ directives (AAAA handled by filter-AAAA) --- + if strings.Contains(dnsmasqCfg, "local=/") { + t.Errorf("must not produce local=/ directives:\n%s", dnsmasqCfg) } - if !strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") { - t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg) - } - if !strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") { - t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg) + + // --- filter-AAAA present --- + if !strings.Contains(dnsmasqCfg, "filter-AAAA") { + t.Errorf("must include filter-AAAA directive:\n%s", dnsmasqCfg) } } @@ -345,19 +346,20 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) { dnsIP = extractHost(dom.Backend.Address) } dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{ - Domain: dom.DomainName, - IP: dnsIP, + Domain: dom.DomainName, + IP: dnsIP, + Wildcard: dom.Subdomains, }) } dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries) - // TCP passthrough → DNS points to backend (k8s LB), NOT central + // TCP passthrough with subdomains → address=/ to backend (k8s LB), NOT central if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") { - t.Errorf("tcp-passthrough DNS must point to backend IP 192.168.8.240:\n%s", dnsmasqCfg) + t.Errorf("wildcard tcp-passthrough DNS must use address=/ to backend IP 192.168.8.240:\n%s", dnsmasqCfg) } - if strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.151") { - t.Errorf("tcp-passthrough DNS must NOT point to Central IP:\n%s", dnsmasqCfg) + if strings.Contains(dnsmasqCfg, "host-record=cloud.payne.io,192.168.8.151") { + t.Errorf("wildcard tcp-passthrough DNS must NOT use host-record= to Central IP:\n%s", dnsmasqCfg) } } @@ -437,20 +439,21 @@ func TestReconciliation_DNSOnlyNoGateway(t *testing.T) { dnsIP = extractHost(dom.Backend.Address) } dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{ - Domain: dom.DomainName, - IP: dnsIP, + Domain: dom.DomainName, + IP: dnsIP, + Wildcard: dom.Subdomains, }) } dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries) - // dns-only domain should resolve to its backend IP (192.168.8.222) - if !strings.Contains(dnsmasqCfg, "address=/dev.payne.io/192.168.8.222") { - t.Errorf("dns-only domain must point DNS to backend IP:\n%s", dnsmasqCfg) + // dns-only domain (public, no subdomains) → host-record= with backend IP + if !strings.Contains(dnsmasqCfg, "host-record=dev.payne.io,192.168.8.222") { + t.Errorf("dns-only domain must use host-record= with backend IP:\n%s", dnsmasqCfg) } - // HTTP domain should resolve to Central IP - if !strings.Contains(dnsmasqCfg, "address=/app.payne.io/192.168.8.151") { - t.Errorf("http domain must point DNS to Central IP:\n%s", dnsmasqCfg) + // HTTP domain (no subdomains) → host-record= with Central IP + if !strings.Contains(dnsmasqCfg, "host-record=app.payne.io,192.168.8.151") { + t.Errorf("http domain must use host-record= with Central IP:\n%s", dnsmasqCfg) } } diff --git a/internal/api/v1/helpers.go b/internal/api/v1/helpers.go index 4c8aab1..3350754 100644 --- a/internal/api/v1/helpers.go +++ b/internal/api/v1/helpers.go @@ -242,8 +242,9 @@ func (api *API) reconcileNetworking() { } dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{ - Domain: dom.DomainName, - IP: dnsIP, + Domain: dom.DomainName, + IP: dnsIP, + Wildcard: dom.Subdomains, }) } diff --git a/internal/dnsmasq/config.go b/internal/dnsmasq/config.go index c6a231d..1e9c5e3 100644 --- a/internal/dnsmasq/config.go +++ b/internal/dnsmasq/config.go @@ -14,14 +14,14 @@ import ( ) // DNSEntry represents a single domain-to-IP DNS mapping for dnsmasq. -// Each entry produces both local=/ and address=/ directives. -// local=/ makes dnsmasq authoritative for the domain, which prevents -// AAAA queries from leaking to upstream DNS. Without it, upstream could -// return public IPv6 records and browsers using Happy Eyeballs (RFC 8305) -// would try the unreachable IPv6 path first, adding latency on LAN. +// Exact-match domains use host-record= (SOA/NS queries forwarded upstream, +// enabling ACME DNS-01 validation). Wildcard domains use address=/ to resolve +// all subdomains. Happy Eyeballs (AAAA leaking) is handled globally by +// filter-AAAA in the config template. type DNSEntry struct { - Domain string // FQDN to resolve (e.g. "cloud.payne.io") - IP string // target IPv4 address + Domain string // FQDN to resolve (e.g. "cloud.payne.io") + IP string // target IPv4 address + Wildcard bool // true: address=/ (all subdomains); false: host-record= (exact match) } // ConfigGenerator handles dnsmasq configuration generation @@ -75,12 +75,17 @@ func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string if entry.Domain == "" || entry.IP == "" { continue } - // local=/ makes dnsmasq authoritative for the domain so AAAA queries - // return empty instead of leaking to upstream DNS. Without this, - // upstream could return public IPv6 records and browsers using Happy - // Eyeballs (RFC 8305) would try the unreachable IPv6 path first, - // adding 250ms-2s latency on LAN. - resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", entry.Domain, entry.Domain, entry.IP) + if entry.Wildcard { + // Wildcard: resolves domain + all subdomains to this IP. + // Note: address=/ blocks forwarding for the entire subtree, + // so SOA walks stop here. For non-apex domains this is fine — + // the walk continues up to the parent (which uses host-record=). + resolution_section += fmt.Sprintf("address=/%s/%s\n", entry.Domain, entry.IP) + } else { + // Exact match: only this specific name. SOA/NS/TXT queries + // are forwarded upstream, enabling ACME DNS-01 validation. + resolution_section += fmt.Sprintf("host-record=%s,%s\n", entry.Domain, entry.IP) + } } template := `# Configuration file for dnsmasq. @@ -91,8 +96,9 @@ bind-interfaces domain-needed bogus-priv no-resolv +filter-AAAA -# DNS Local Resolution - Central server handles these domains authoritatively +# DNS Local Resolution - registered domain A records %s server=1.1.1.1 server=8.8.8.8 @@ -113,10 +119,12 @@ log-dhcp return result } -// RestartService reloads the dnsmasq service (SIGHUP — no downtime). -// Falls back to restart if reload fails. +// RestartService fully restarts the dnsmasq service. +// A full restart is required because SIGHUP (reload) does not re-read +// host-record= directives — only address=/ and a few other directives +// are reloaded on SIGHUP. func (g *ConfigGenerator) RestartService() error { - cmd := exec.Command("systemctl", "reload-or-restart", "dnsmasq.service") + cmd := exec.Command("systemctl", "restart", "dnsmasq.service") output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to reload dnsmasq: %w (output: %s)", err, string(output)) @@ -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 { - status.DomainsConfigured = strings.Count(string(data), "\nlocal=/") + config := string(data) + status.DomainsConfigured = strings.Count(config, "host-record=") + strings.Count(config, "\naddress=/") } return status, nil @@ -303,17 +312,10 @@ func (g *ConfigGenerator) ValidateConfig(configPath string) error { return nil } -// ReloadService sends a SIGHUP to dnsmasq to reload configuration. -// Lighter weight than a full restart. Falls back to RestartService on failure. +// ReloadService restarts dnsmasq to apply configuration changes. +// Uses a full restart because SIGHUP does not re-read host-record= directives. func (g *ConfigGenerator) ReloadService() error { - cmd := exec.Command("systemctl", "reload", "dnsmasq.service") - _, err := cmd.CombinedOutput() - if err != nil { - slog.Error("reload failed, attempting restart", "component", "dnsmasq", "error", err) - return g.RestartService() - } - slog.Info("dnsmasq service reloaded", "component", "dnsmasq") - return nil + return g.RestartService() } // ConfigureSystemDNS configures systemd-resolved to use the local dnsmasq server diff --git a/internal/dnsmasq/config_test.go b/internal/dnsmasq/config_test.go index 5ed7938..33126c0 100644 --- a/internal/dnsmasq/config_test.go +++ b/internal/dnsmasq/config_test.go @@ -12,7 +12,11 @@ func entry(domain, ip string) DNSEntry { return DNSEntry{Domain: domain, IP: ip} } -// Test: Generate with entries produces expected DNS entries +func wildcardEntry(domain, ip string) DNSEntry { + return DNSEntry{Domain: domain, IP: ip, Wildcard: true} +} + +// Test: Generate with exact-match entries produces host-record= directives func TestGenerate_WithEntries(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") entries := []DNSEntry{ @@ -22,17 +26,11 @@ func TestGenerate_WithEntries(t *testing.T) { out := g.Generate(nil, entries) - if !strings.Contains(out, "local=/cloud.example.com/") { - t.Errorf("expected local=/ for domain, got:\n%s", out) + if !strings.Contains(out, "host-record=cloud.example.com,192.168.1.10") { + t.Errorf("expected host-record for domain, got:\n%s", out) } - if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") { - t.Errorf("expected address entry for domain, got:\n%s", out) - } - if !strings.Contains(out, "local=/internal.example.com/") { - t.Errorf("expected local=/ for internal domain, got:\n%s", out) - } - if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") { - t.Errorf("expected address entry for internal domain, got:\n%s", out) + if !strings.Contains(out, "host-record=internal.example.com,192.168.1.10") { + t.Errorf("expected host-record for internal domain, got:\n%s", out) } if !strings.Contains(out, "server=1.1.1.1") { t.Errorf("expected upstream DNS server, got:\n%s", out) @@ -62,8 +60,8 @@ func TestGenerate_EntryWithoutIP(t *testing.T) { out := g.Generate(nil, []DNSEntry{entry("cloud.example.com", "")}) - if strings.Contains(out, "address=/") { - t.Errorf("expected no address= entries for empty IP, got:\n%s", out) + if strings.Contains(out, "host-record=") { + t.Errorf("expected no host-record entries for empty IP, got:\n%s", out) } } @@ -198,78 +196,98 @@ func TestNewConfigGenerator_DefaultPath(t *testing.T) { } } -// Test: Domain-only entry (no internal domain pair) -func TestGenerate_DomainOnly(t *testing.T) { +// Test: Exact-match domain produces host-record= +func TestGenerate_ExactMatchDomain(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.State{} out := g.Generate(globalCfg, []DNSEntry{entry("my-api.payne.io", "192.168.8.151")}) - if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") { - t.Errorf("expected address entry for domain, got:\n%s", out) + if !strings.Contains(out, "host-record=my-api.payne.io,192.168.8.151") { + t.Errorf("expected host-record entry for domain, got:\n%s", out) } - if !strings.Contains(out, "local=/my-api.payne.io/") { - t.Errorf("expected local=/ entry for domain, got:\n%s", out) + if strings.Contains(out, "address=/") { + t.Errorf("exact-match domain must not produce address=/ directive:\n%s", out) } - // Must NOT have empty entries - if strings.Contains(out, "local=//") { - t.Errorf("unexpected empty local=// in output:\n%s", out) - } - if strings.Contains(out, "address=//") { - t.Errorf("unexpected empty address=// in output:\n%s", out) + if strings.Contains(out, "local=/") { + t.Errorf("must not produce local=/ directive:\n%s", out) } } -// Test: All domains get local=/ to prevent AAAA leaking upstream -func TestGenerate_AllDomainsGetLocal(t *testing.T) { +// Test: Wildcard domain produces address=/ without local=/ +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") globalCfg := &config.State{} - // Both "internal" and "public" domains should get local=/ — - // prevents AAAA queries from leaking to upstream DNS (Happy Eyeballs). entries := []DNSEntry{ entry("internal-api.payne.io", "192.168.8.151"), - entry("cloud.payne.io", "192.168.8.240"), + wildcardEntry("cloud.payne.io", "192.168.8.240"), } out := g.Generate(globalCfg, entries) - if !strings.Contains(out, "local=/internal-api.payne.io/") { - t.Errorf("expected local=/ for internal domain, got:\n%s", out) - } - if !strings.Contains(out, "local=/cloud.payne.io/") { - t.Errorf("expected local=/ for public domain (prevents AAAA leaking), got:\n%s", out) + if strings.Contains(out, "local=/") { + t.Errorf("must never produce local=/ directives:\n%s", out) } } -// Test: Mix of domains each get their own entries +// Test: filter-AAAA appears in generated config +func TestGenerate_FilterAAAA(t *testing.T) { + g := 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) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.State{} entries := []DNSEntry{ - entry("cloud.payne.io", "192.168.8.240"), // k8s passthrough - entry("central.payne.io", "192.168.8.151"), // Central HTTP - entry("wild-cloud.payne.io", "192.168.8.151"), // service HTTP + wildcardEntry("cloud.payne.io", "192.168.8.240"), // wildcard + entry("central.payne.io", "192.168.8.151"), // exact + entry("wild-cloud.payne.io", "192.168.8.151"), // exact } out := g.Generate(globalCfg, entries) if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") { - t.Errorf("expected address for cloud domain, got:\n%s", out) + t.Errorf("expected address=/ for wildcard domain, got:\n%s", out) } - if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") { - t.Errorf("expected address for central, got:\n%s", out) + if !strings.Contains(out, "host-record=central.payne.io,192.168.8.151") { + t.Errorf("expected host-record for exact domain, got:\n%s", out) } - if !strings.Contains(out, "address=/wild-cloud.payne.io/192.168.8.151") { - t.Errorf("expected address for wild-cloud, got:\n%s", out) + if !strings.Contains(out, "host-record=wild-cloud.payne.io,192.168.8.151") { + t.Errorf("expected host-record for exact domain, got:\n%s", out) } - if strings.Contains(out, "local=//") { - t.Errorf("unexpected empty local=// in output:\n%s", out) + if strings.Contains(out, "local=/") { + t.Errorf("must not produce local=/ directives:\n%s", out) } } -// Test: empty entries list produces base config with no address= entries +// Test: empty entries list produces base config with no DNS entries func TestGenerate_EmptyEntries(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.State{} @@ -282,15 +300,15 @@ func TestGenerate_EmptyEntries(t *testing.T) { if !strings.Contains(out, "server=1.1.1.1") { t.Errorf("expected upstream DNS server, got:\n%s", out) } - if strings.Contains(out, "address=/") { - t.Errorf("expected no address= entries with empty entries, got:\n%s", out) + if strings.Contains(out, "host-record=") { + t.Errorf("expected no host-record entries with empty entries, got:\n%s", out) } - if strings.Contains(out, "local=/") { - t.Errorf("expected no local=/ entries with empty entries, got:\n%s", out) + if strings.Contains(out, "address=/") { + t.Errorf("expected no address=/ entries with empty entries, got:\n%s", out) } } -// Test: multiple entries each get their own DNS records +// Test: multiple exact-match entries each get their own DNS records func TestGenerate_MultipleEntries(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.State{} @@ -304,11 +322,8 @@ func TestGenerate_MultipleEntries(t *testing.T) { out := g.Generate(globalCfg, entries) for _, e := range entries { - if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", e.Domain, e.IP)) { - t.Errorf("expected address entry for %s, got:\n%s", e.Domain, out) - } - if !strings.Contains(out, fmt.Sprintf("local=/%s/", e.Domain)) { - t.Errorf("expected local=/ entry for %s, got:\n%s", e.Domain, out) + if !strings.Contains(out, fmt.Sprintf("host-record=%s,%s", e.Domain, e.IP)) { + t.Errorf("expected host-record entry for %s, got:\n%s", e.Domain, out) } } } @@ -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) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") cfg := &config.State{} @@ -387,7 +402,8 @@ func TestGenerate_NoLeakedEmptyEntries(t *testing.T) { entries []DNSEntry }{ {"empty", nil}, - {"domain_only", []DNSEntry{entry("example.com", "10.0.0.1")}}, + {"exact_domain", []DNSEntry{entry("example.com", "10.0.0.1")}}, + {"wildcard_domain", []DNSEntry{wildcardEntry("example.com", "10.0.0.1")}}, {"no_ip", []DNSEntry{entry("example.com", "")}}, {"no_domain", []DNSEntry{entry("", "10.0.0.1")}}, } @@ -395,11 +411,14 @@ func TestGenerate_NoLeakedEmptyEntries(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { out := g.Generate(cfg, tc.entries) + if strings.Contains(out, "host-record=,") { + t.Errorf("leaked empty host-record in output:\n%s", out) + } if strings.Contains(out, "address=//") { t.Errorf("leaked empty address=// in output:\n%s", out) } - if strings.Contains(out, "local=//") { - t.Errorf("leaked empty local=// in output:\n%s", out) + if strings.Contains(out, "local=/") { + t.Errorf("must not produce local=/ directives:\n%s", out) } }) } diff --git a/web/src/components/AppSidebar.tsx b/web/src/components/AppSidebar.tsx index 2a9a678..8e31d67 100644 --- a/web/src/components/AppSidebar.tsx +++ b/web/src/components/AppSidebar.tsx @@ -104,6 +104,7 @@ export function AppSidebar() { { to: '/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const }, { to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const }, { to: '/advanced/authelia', icon: KeyRound, label: 'Authelia', daemon: 'authelia' as const }, + { to: '/advanced/ddns', icon: Globe, label: 'DDNS', daemon: undefined }, ]; return ( diff --git a/web/src/components/advanced/DdnsSubsystem.tsx b/web/src/components/advanced/DdnsSubsystem.tsx new file mode 100644 index 0000000..a6b5a18 --- /dev/null +++ b/web/src/components/advanced/DdnsSubsystem.tsx @@ -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 ( +
+ {/* Header */} +
+
+
+ +
+
+

Dynamic DNS

+

Cloudflare DNS records managed by Wild Central

+
+
+ {status && ( + + {status.enabled ? : } + {status.enabled ? 'Enabled' : 'Disabled'} + + )} +
+ + {/* Global error */} + {status?.lastError && ( + + + {status.lastError} + + )} + + {/* Status + Actions */} + + +
+
+
+ Public IP +
{status?.currentIP ?? '--'}
+
+
+ Provider +
{config?.provider ?? '--'}
+
+
+ Last Checked +
{formatTime(status?.lastChecked)}
+
+
+ Last Updated +
{formatTime(status?.lastUpdated)}
+
+
+ +
+ {config?.intervalMinutes && ( +

+ Checks every {config.intervalMinutes} minute{config.intervalMinutes !== 1 ? 's' : ''} +

+ )} +
+
+ + {/* Records table */} + + +

Managed Records

+ {isLoadingStatus ? ( +
+ +
+ ) : records.length === 0 ? ( +
+ +

+ {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.'} +

+
+ ) : ( +
+ + + + + + + + + + {records.map((r) => ( + + + + + + ))} + +
RecordIPStatus
{r.name}{r.ip ?? '--'} + {r.ok ? ( + + + Synced + + ) : ( + + + + Error + + {r.error && ( + + {r.error} + + )} + + )} +
+
+ )} +
+
+
+ ); +} diff --git a/web/src/components/advanced/index.ts b/web/src/components/advanced/index.ts index 8d03c97..468a36b 100644 --- a/web/src/components/advanced/index.ts +++ b/web/src/components/advanced/index.ts @@ -4,3 +4,4 @@ export { NftablesSubsystem } from './NftablesSubsystem'; export { WireguardSubsystem } from './WireguardSubsystem'; export { CrowdsecSubsystem } from './CrowdsecSubsystem'; export { AutheliaSubsystem } from './AutheliaSubsystem'; +export { DdnsSubsystem } from './DdnsSubsystem'; diff --git a/web/src/router/pages/advanced/DdnsPage.tsx b/web/src/router/pages/advanced/DdnsPage.tsx new file mode 100644 index 0000000..4bbfdb6 --- /dev/null +++ b/web/src/router/pages/advanced/DdnsPage.tsx @@ -0,0 +1,10 @@ +import { ErrorBoundary } from '../../../components'; +import { DdnsSubsystem } from '../../../components/advanced'; + +export function DdnsPage() { + return ( + + + + ); +} diff --git a/web/src/router/pages/advanced/index.ts b/web/src/router/pages/advanced/index.ts index e36527e..23b6111 100644 --- a/web/src/router/pages/advanced/index.ts +++ b/web/src/router/pages/advanced/index.ts @@ -4,3 +4,4 @@ export { NftablesPage } from './NftablesPage'; export { WireguardPage } from './WireguardPage'; export { CrowdsecPage } from './CrowdsecPage'; export { AutheliaAdvancedPage } from './AutheliaPage'; +export { DdnsPage } from './DdnsPage'; diff --git a/web/src/router/routes.tsx b/web/src/router/routes.tsx index 636ad92..be6007d 100644 --- a/web/src/router/routes.tsx +++ b/web/src/router/routes.tsx @@ -13,6 +13,7 @@ import { HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage, CrowdsecPage as CrowdsecAdvancedPage, AutheliaAdvancedPage, + DdnsPage, } from './pages/advanced'; export const routes: RouteObject[] = [ @@ -34,6 +35,7 @@ export const routes: RouteObject[] = [ { path: 'advanced/wireguard', element: }, { path: 'advanced/crowdsec', element: }, { path: 'advanced/authelia', element: }, + { path: 'advanced/ddns', element: }, ], }, {