Files
shater/shater/model/dnsintercept_test.go
T
omarandClaude Opus 5 a0de597d69
test / go + panel tests (push) Successful in 4m56s
feat(dns): intercept by default, and bootstrap node addresses off the tunnel
The posture was inverted. A client using the DHCP-supplied resolver — the router
itself — was NOT intercepted: dnsmasq answered and forwarded to the ISP in the
clear, so the filter, the blocklists, the per-device rules and BlockDoH were all
inert for exactly the clients that did nothing wrong. A client that hardcoded
8.8.8.8 to route around us WAS intercepted, by the catch-all. Meanwhile the
docs promised no DNS leaks. The default now matches the promise.

Turning it on crosses a threshold that was already dangerous for anyone with two
resolvers. Above one transport, a node's domain server address stops being
resolved by the transport directly and goes through the client DNS plane
instead — so a blocklist entry, a block_doh NXDOMAIN or any dns_rule can answer
your own node's hostname, and one sloppy line in an ad list stops being an ad
that got through and becomes a tunnel that never comes up.

So the fix is gated on having two or more transports, not on the intercept
toggle: resolver_default plus resolver_fallback always reached that threshold,
long before this change. When no endpoint_resolver is configured the plane now
carries a bootstrap server — the default resolver cloned with its detour
dropped, keeping its type, so a DoH default stays DoH and only the tunnel hop
goes. An explicit endpoint_resolver still wins.

This is not a restore of the previous behaviour and the comment says so: at one
transport the dialer used the default resolver WITH its detour, so a lone
DoH-through-the-tunnel resolver was already a bootstrap loop. It is strictly
better than what came before.

Existing installs keep whatever they set — the config file is a conffile and is
never replaced — and an explicit dns_intercept '0' survives the render-parse
round trip, which a default-true bool otherwise makes easy to lose.

The no-resolver warning stays, and no default resolver is shipped to silence it:
a placeholder would remove the sentence without moving a single query, and the
panel would then say a resolver was configured while nothing was filtered. Its
wording is corrected instead — .lan keeps working through the built-in local
transport, which the old text denied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 04:54:15 +03:00

106 lines
4.2 KiB
Go

package model
// Tests for the `dns_intercept` default flip (D24): forcing ALL LAN plaintext
// :53 — including the queries a client sends to the ROUTER, which is what DHCP
// hands out — into the engine is now the DEFAULT, not an opt-in.
//
// Two properties are pinned here, and they pull in opposite directions:
//
// 1. a config that never mentions the option (every install written before it
// existed, and the state a hand-edited file is usually in) must come back ON;
// 2. an EXPLICIT `option dns_intercept '0'` must stay OFF — including across a
// WriteUCI->ReadUCI round-trip, where a bool omitted at false would be re-read
// as the seed and silently flip the operator's decision back on.
//
// (2) is the reason render.go emits booleans always. It is the exact trap that
// makes a default-true bool different from a default-false one, so it gets a test
// of its own rather than riding on the generic round-trip fixture.
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestDNSInterceptDefaultOn: the seed and an option-less config both say ON.
func TestDNSInterceptDefaultOn(t *testing.T) {
if !DefaultGlobals().DNSIntercept {
t.Fatal("DefaultGlobals().DNSIntercept = false, want true (D24: intercept is opt-out)")
}
m, err := ParseUCIExport("package shater\n\nconfig globals 'globals'\n\toption enabled '1'\n")
if err != nil {
t.Fatal(err)
}
if !m.Globals.DNSIntercept {
t.Fatal("a config with no dns_intercept option parsed as OFF; an absent option must fall back to the ON seed")
}
}
// TestDNSInterceptExplicitOffPreserved: the operator's opt-out survives both the
// parse (over an ON seed) and the render->parse round-trip.
func TestDNSInterceptExplicitOffPreserved(t *testing.T) {
m, err := ParseUCIExport("package shater\n\nconfig globals 'globals'\n" +
"\toption enabled '1'\n\toption dns_intercept '0'\n")
if err != nil {
t.Fatal(err)
}
if m.Globals.DNSIntercept {
t.Fatal("explicit dns_intercept '0' was overwritten by the ON seed")
}
rendered := RenderUCIExport(m)
if !strings.Contains(rendered, "option dns_intercept '0'") {
t.Fatalf("render must emit the explicit '0' (an omitted bool would be re-read as ON):\n%s", rendered)
}
back, err := ParseUCIExport(rendered)
if err != nil {
t.Fatal(err)
}
if back.Globals.DNSIntercept {
t.Fatal("dns_intercept '0' did not survive the WriteUCI->ReadUCI round-trip — the opt-out would be silently re-enabled on the next save")
}
}
// TestDNSInterceptExplicitOnParses: the ON spelling is read as ON too (a seed
// that happens to agree must not be the only reason the value is true).
func TestDNSInterceptExplicitOnParses(t *testing.T) {
m, err := ParseUCIExport("package shater\n\nconfig globals 'globals'\n\toption dns_intercept '1'\n")
if err != nil {
t.Fatal(err)
}
if !m.Globals.DNSIntercept {
t.Fatal("explicit dns_intercept '1' parsed as OFF")
}
}
// TestShippedConfigEnablesDNSIntercept reads the file the package actually
// installs. /etc/config/shater is a CONFFILE: it is written once, on first
// install, and never replaced on upgrade — so a fresh install's DNS posture is
// decided by this file's literal text, not by DefaultGlobals. The two must agree,
// and only a test that reads the shipped bytes can say that they do.
//
// It also pins the inert shipping posture (enabled '0') the file's own header
// promises, since both facts live in the same section.
func TestShippedConfigEnablesDNSIntercept(t *testing.T) {
path := filepath.Join("..", "..", "openwrt", "shater-core", "files", "etc", "config", "shater")
raw, err := os.ReadFile(path)
if err != nil {
t.Skipf("shipped config not readable from this checkout (%v)", err)
}
text := string(raw)
if !strings.Contains(text, "option dns_intercept '1'") {
t.Error("the shipped /etc/config/shater must set dns_intercept '1' explicitly: a fresh install reads this file, and an operator who later opts out must see the option they are flipping")
}
m, err := ParseUCIExport(text)
if err != nil {
t.Fatalf("shipped config does not parse: %v", err)
}
if !m.Globals.DNSIntercept {
t.Error("shipped config parses with DNSIntercept off")
}
if m.Globals.Enabled {
t.Error("shipped config must stay inert (enabled '0'): a fresh install may not touch connectivity")
}
}