test / go + panel tests (push) Successful in 4m56s
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>
106 lines
4.2 KiB
Go
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")
|
|
}
|
|
}
|