Files
shater/shater/generate/ruleset_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

2091 lines
77 KiB
Go

package generate
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/sagernet/sing-box/common/srs"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/shater/model"
)
// findRouteRuleWithRuleSet returns the first emitted default route rule whose
// rule_set matcher references tag, or nil.
func findRouteRuleWithRuleSet(rt *option.RouteOptions, tag string) *option.DefaultRule {
if rt == nil {
return nil
}
for i := range rt.Rules {
dr := &rt.Rules[i].DefaultOptions
for _, rs := range dr.RawDefaultRule.RuleSet {
if rs == tag {
return dr
}
}
}
return nil
}
// hasRulesetRule reports whether any emitted route rule references the rule-set
// named name (tag rs-<name>). Since schema v2 a rule's destination is ALWAYS a
// rule-set reference, so this is how a test says "that rule was emitted" — the
// former "does any rule carry this dst domain" question has no answer any more.
func hasRulesetRule(rt *option.RouteOptions, name string) bool {
return findRouteRuleWithRuleSet(rt, routeRulesetTagPrefix+name) != nil
}
func ruleSetByTag(rt *option.RouteOptions, tag string) (option.RuleSet, bool) {
if rt == nil {
return option.RuleSet{}, false
}
for _, rs := range rt.RuleSet {
if rs.Tag == tag {
return rs, true
}
}
return option.RuleSet{}, false
}
// dnsRuleReferencesRuleSet reports whether any emitted DNS rule's rule_set matcher
// references tag (used to check a filter blocklist's block rule covers a category).
func dnsRuleReferencesRuleSet(dns *option.DNSOptions, tag string) bool {
if dns == nil {
return false
}
for i := range dns.Rules {
for _, rs := range dns.Rules[i].DefaultOptions.RawDefaultDNSRule.RuleSet {
if rs == tag {
return true
}
}
}
return false
}
func contains(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
// TestRoutingRuleSetInlineDomain is the DoD case: an inline DOMAIN ruleset "ads"
// referenced by a rule "block-ads" (dst_ruleset=[ads], target=block) materialises
// a rule-set tagged rs-ads carrying the domains as domain_suffix, and the route
// rule references it via rule_set and routes to block. Pure codegen (no box.New),
// so it runs on every platform.
func TestRoutingRuleSetInlineDomain(t *testing.T) {
m := &model.Model{
Globals: nonDNSGlobals(),
Rulesets: []model.Ruleset{
{Name: "ads", Type: "domain", Source: "inline", Entries: []string{"ads.example", "doubleclick.net"}},
},
Rules: []model.Rule{
{Name: "block-ads", Enabled: true, Order: 10, DstRuleset: []string{"ads"}, Target: "block"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(warns) != 0 {
t.Fatalf("unexpected warnings: %v", warns)
}
rs, ok := ruleSetByTag(opts.Route, "rs-ads")
if !ok {
t.Fatalf("rule-set rs-ads not emitted; route=%+v", opts.Route)
}
if rs.Type != C.RuleSetTypeInline || len(rs.InlineOptions.Rules) != 1 {
t.Fatalf("rs-ads must be a single-rule inline rule-set, got %+v", rs)
}
hr := rs.InlineOptions.Rules[0].DefaultOptions
if !contains(hr.DomainSuffix, "ads.example") || !contains(hr.DomainSuffix, "doubleclick.net") {
t.Fatalf("rs-ads domain_suffix must carry both bare entries, got %+v", hr.DomainSuffix)
}
if len(hr.IPCIDR) != 0 {
t.Fatalf("domain ruleset must not set ip_cidr, got %+v", hr.IPCIDR)
}
dr := findRouteRuleWithRuleSet(opts.Route, "rs-ads")
if dr == nil {
t.Fatalf("no route rule references rs-ads; rules=%+v", opts.Route.Rules)
}
if dr.RuleAction.Action != C.RuleActionTypeRoute || dr.RuleAction.RouteOptions.Outbound != tagBlock {
t.Fatalf("route rule must route to %q, got %+v", tagBlock, dr.RuleAction)
}
}
// TestRoutingRuleSetInlineDomainRegex: `regexp:` is a matcher the shared domain
// classifier does NOT know — it belongs to the routing plane, and it used to be
// peeled off inside ruleMatchers, which no longer sees any domains at all. It
// therefore had to move into the inline rule-set with the rest of the destination
// vocabulary (peelDomainRegexes), or every migrated `regexp:` entry would have
// been reported as an unknown prefix and silently dropped: a routing rule that
// looks configured and matches nothing.
func TestRoutingRuleSetInlineDomainRegex(t *testing.T) {
m := &model.Model{
Globals: nonDNSGlobals(),
Rulesets: []model.Ruleset{
{Name: "ads", Type: "domain", Source: "inline", Entries: []string{`regexp:^ads\.`}},
},
Rules: []model.Rule{
{Name: "block-ads", Enabled: true, Order: 10, DstRuleset: []string{"ads"}, Target: "block"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(warns) != 0 {
t.Fatalf("a valid regexp: entry must not warn: %v", warns)
}
rs, ok := ruleSetByTag(opts.Route, "rs-ads")
if !ok {
t.Fatalf("a regexp-only rule-set must still materialise; route=%+v", opts.Route)
}
hr := rs.InlineOptions.Rules[0].DefaultOptions
if len(hr.DomainRegex) != 1 || hr.DomainRegex[0] != `^ads\.` {
t.Fatalf("domain_regex = %+v, want [^ads\\.]", hr.DomainRegex)
}
if len(hr.Domain)+len(hr.DomainSuffix)+len(hr.DomainKeyword)+len(hr.IPCIDR) != 0 {
t.Fatalf("the regexp entry must not leak into another matcher: %+v", hr)
}
dr := findRouteRuleWithRuleSet(opts.Route, "rs-ads")
if dr == nil {
t.Fatalf("no route rule references rs-ads; rules=%+v", opts.Route.Rules)
}
if dr.RuleAction.RouteOptions.Outbound != tagBlock {
t.Fatalf("route rule must route to %q, got %+v", tagBlock, dr.RuleAction)
}
}
// TestRoutingRuleSetInlineIPCIDR: an ipcidr ruleset fills ip_cidr (not domain*),
// and the route rule references it.
func TestRoutingRuleSetInlineIPCIDR(t *testing.T) {
m := &model.Model{
Globals: nonDNSGlobals(),
Rulesets: []model.Ruleset{
{Name: "cn", Type: "ipcidr", Source: "inline", Entries: []string{"10.0.0.0/8", "192.168.0.0/16"}},
},
Rules: []model.Rule{
{Name: "cn-direct", Enabled: true, Order: 10, DstRuleset: []string{"cn"}, Target: "direct"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(warns) != 0 {
t.Fatalf("unexpected warnings: %v", warns)
}
rs, ok := ruleSetByTag(opts.Route, "rs-cn")
if !ok {
t.Fatalf("rule-set rs-cn not emitted; route=%+v", opts.Route)
}
hr := rs.InlineOptions.Rules[0].DefaultOptions
if !contains(hr.IPCIDR, "10.0.0.0/8") || !contains(hr.IPCIDR, "192.168.0.0/16") {
t.Fatalf("rs-cn ip_cidr must carry both entries, got %+v", hr.IPCIDR)
}
if len(hr.DomainSuffix)+len(hr.Domain)+len(hr.DomainKeyword) != 0 {
t.Fatalf("ipcidr ruleset must not set domain matchers, got %+v", hr)
}
if findRouteRuleWithRuleSet(opts.Route, "rs-cn") == nil {
t.Fatalf("no route rule references rs-cn; rules=%+v", opts.Route.Rules)
}
}
// TestRoutingRuleSetUndefinedWarnsSkipped: a dst_ruleset naming an UNDEFINED
// ruleset warns and is skipped — no rs- rule-set, no route rule, no panic (so
// box.New would accept the config).
func TestRoutingRuleSetUndefinedWarnsSkipped(t *testing.T) {
m := &model.Model{
Globals: model.DefaultGlobals(),
Rules: []model.Rule{
{Name: "ghost", Enabled: true, Order: 10, DstRuleset: []string{"nope"}, Target: "block"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
var warned bool
for _, w := range warns {
if strings.Contains(w, "dst_ruleset") && strings.Contains(w, "not defined") {
warned = true
}
}
if !warned {
t.Fatalf("expected an undefined-dst_ruleset warning, got %v", warns)
}
if _, ok := ruleSetByTag(opts.Route, "rs-nope"); ok {
t.Fatalf("undefined ruleset must not materialise a rule-set")
}
if findRouteRuleWithRuleSet(opts.Route, "rs-nope") != nil {
t.Fatalf("undefined ruleset must not emit a route rule")
}
}
// TestRoutingRuleSetUnusedNotEmitted: a `config ruleset` that no enabled rule
// references produces nothing (don't emit unused ones).
func TestRoutingRuleSetUnusedNotEmitted(t *testing.T) {
m := &model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{
{Name: "unused", Type: "domain", Source: "inline", Entries: []string{"x.example"}},
},
}
opts, _, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if _, ok := ruleSetByTag(opts.Route, "rs-unused"); ok {
t.Fatalf("unused ruleset must not be emitted; route=%+v", opts.Route)
}
}
// TestRoutingRuleSetGeositeCategory: a geosite-source ruleset with ONE category
// materialises a REMOTE .srs rule-set pointing at the official sing-geosite repo,
// fetched through http_client{detour:direct}, and the route rule references it.
// The single-category tag now carries the category suffix (rs-<name>-<category>).
func TestRoutingRuleSetGeositeCategory(t *testing.T) {
m := &model.Model{
Globals: nonDNSGlobals(),
Rulesets: []model.Ruleset{
{Name: "yt", Source: "geosite", Categories: []string{"youtube"}},
},
Rules: []model.Rule{
{Name: "yt-direct", Enabled: true, Order: 10, DstRuleset: []string{"yt"}, Target: "direct"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(warns) != 0 {
t.Fatalf("unexpected warnings: %v", warns)
}
rs, ok := ruleSetByTag(opts.Route, "rs-yt-youtube")
if !ok {
t.Fatalf("rule-set rs-yt-youtube not emitted; route=%+v", opts.Route)
}
if rs.Type != C.RuleSetTypeRemote {
t.Fatalf("rs-yt-youtube must be a remote rule-set, got type %q", rs.Type)
}
if rs.Format != C.RuleSetFormatBinary {
t.Fatalf("rs-yt-youtube must be binary (.srs) format, got %q", rs.Format)
}
const wantURL = "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-youtube.srs"
if rs.RemoteOptions.URL != wantURL {
t.Fatalf("rs-yt-youtube url = %q, want %q", rs.RemoteOptions.URL, wantURL)
}
if hc := rs.RemoteOptions.HTTPClient; hc == nil || hc.Detour != "direct" {
t.Fatalf("rs-yt-youtube must fetch via http_client{detour:direct}, got %+v", hc)
}
if findRouteRuleWithRuleSet(opts.Route, "rs-yt-youtube") == nil {
t.Fatalf("no route rule references rs-yt-youtube; rules=%+v", opts.Route.Rules)
}
}
// TestRoutingRuleSetGeositeMultiCategory is the multi-chip DoD case: a geosite
// ruleset with TWO categories materialises TWO remote .srs rule-sets (tags
// rs-<name>-<cat>) with the exact per-category URLs, and the ONE route rule that
// references the ruleset matches BOTH tags.
func TestRoutingRuleSetGeositeMultiCategory(t *testing.T) {
m := &model.Model{
Globals: nonDNSGlobals(),
Rulesets: []model.Ruleset{
{Name: "social", Source: "geosite", Categories: []string{"youtube", "telegram"}},
},
Rules: []model.Rule{
{Name: "social-direct", Enabled: true, Order: 10, DstRuleset: []string{"social"}, Target: "direct"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(warns) != 0 {
t.Fatalf("unexpected warnings: %v", warns)
}
cases := []struct{ tag, url string }{
{"rs-social-youtube", "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-youtube.srs"},
{"rs-social-telegram", "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-telegram.srs"},
}
for _, c := range cases {
rs, ok := ruleSetByTag(opts.Route, c.tag)
if !ok {
t.Fatalf("rule-set %s not emitted; route=%+v", c.tag, opts.Route)
}
if rs.Type != C.RuleSetTypeRemote || rs.RemoteOptions.URL != c.url {
t.Fatalf("%s: type=%q url=%q, want remote %q", c.tag, rs.Type, rs.RemoteOptions.URL, c.url)
}
}
// The single route rule references BOTH category tags.
dr := findRouteRuleWithRuleSet(opts.Route, "rs-social-youtube")
if dr == nil {
t.Fatalf("no route rule references rs-social-youtube; rules=%+v", opts.Route.Rules)
}
if !contains(dr.RawDefaultRule.RuleSet, "rs-social-youtube") || !contains(dr.RawDefaultRule.RuleSet, "rs-social-telegram") {
t.Fatalf("route rule must match BOTH category tags, got rule_set=%+v", dr.RawDefaultRule.RuleSet)
}
}
// TestRoutingRuleSetGeoipCountryLowercased: a geoip-source ruleset with an
// uppercase category ("RU") materialises a remote geoip-<cc>.srs URL with the
// country code normalised to lowercase (geoip-ru.srs); the tag is likewise
// lower-cased (rs-ru-ru).
func TestRoutingRuleSetGeoipCountryLowercased(t *testing.T) {
m := &model.Model{
Globals: nonDNSGlobals(),
Rulesets: []model.Ruleset{
{Name: "ru", Source: "geoip", Categories: []string{"RU"}},
},
Rules: []model.Rule{
{Name: "ru-direct", Enabled: true, Order: 10, DstRuleset: []string{"ru"}, Target: "direct"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(warns) != 0 {
t.Fatalf("unexpected warnings: %v", warns)
}
rs, ok := ruleSetByTag(opts.Route, "rs-ru-ru")
if !ok {
t.Fatalf("rule-set rs-ru-ru not emitted; route=%+v", opts.Route)
}
if rs.Type != C.RuleSetTypeRemote {
t.Fatalf("rs-ru-ru must be a remote rule-set, got type %q", rs.Type)
}
const wantURL = "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-ru.srs"
if rs.RemoteOptions.URL != wantURL {
t.Fatalf("rs-ru-ru url = %q, want %q", rs.RemoteOptions.URL, wantURL)
}
if findRouteRuleWithRuleSet(opts.Route, "rs-ru-ru") == nil {
t.Fatalf("no route rule references rs-ru-ru; rules=%+v", opts.Route.Rules)
}
}
// TestRoutingRuleSetGeositeNoCategoryWarnsSkipped: a geosite source with NO
// category warns and is skipped — no rs- rule-set, no route rule, no panic (so
// box.New would accept the config). Fail-open, matching the undefined case.
func TestRoutingRuleSetGeositeNoCategoryWarnsSkipped(t *testing.T) {
m := &model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{
{Name: "bad", Source: "geosite"}, // no category
},
Rules: []model.Rule{
{Name: "x", Enabled: true, Order: 10, DstRuleset: []string{"bad"}, Target: "block"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
var warned bool
for _, w := range warns {
if strings.Contains(w, "geosite source needs a category") {
warned = true
}
}
if !warned {
t.Fatalf("expected a geosite-no-category warning, got %v", warns)
}
if _, ok := ruleSetByTag(opts.Route, "rs-bad"); ok {
t.Fatalf("category-less geosite ruleset must not materialise a rule-set")
}
if findRouteRuleWithRuleSet(opts.Route, "rs-bad") != nil {
t.Fatalf("category-less geosite ruleset must not emit a route rule")
}
}
// TestFilterGeositeBlocklistRemote: a geosite-source blocklist with a Category
// materialises a REMOTE sing-geosite .srs rule-set (bl-<name>) — the DNS-filter
// counterpart of TestRoutingRuleSetGeositeCategory. Pure codegen (no box.New).
func TestFilterGeositeBlocklistRemote(t *testing.T) {
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
m := &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "doh", Address: "https://1.1.1.1/dns-query", Detour: "direct"},
},
Blocklists: []model.Blocklist{
{Name: "ads", Enabled: true, Source: "geosite", Categories: []string{"category-ads-all"}},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(warns) != 0 {
t.Fatalf("unexpected warnings: %v", warns)
}
rs, ok := ruleSetByTag(opts.Route, "bl-ads-category-ads-all")
if !ok {
t.Fatalf("rule-set bl-ads-category-ads-all not emitted; route=%+v", opts.Route)
}
if rs.Type != C.RuleSetTypeRemote {
t.Fatalf("bl-ads-category-ads-all must be a remote rule-set, got type %q", rs.Type)
}
const wantURL = "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs"
if rs.RemoteOptions.URL != wantURL {
t.Fatalf("bl-ads-category-ads-all url = %q, want %q", rs.RemoteOptions.URL, wantURL)
}
if hc := rs.RemoteOptions.HTTPClient; hc == nil || hc.Detour != "direct" {
t.Fatalf("bl-ads-category-ads-all must fetch via http_client{detour:direct}, got %+v", hc)
}
}
// TestFilterGeositeBlocklistMultiCategory: a geosite blocklist with TWO categories
// materialises TWO remote sing-geosite rule-sets (tags bl-<name>-<cat>), and BOTH
// are referenced by the block DNS rule so either category is filtered.
func TestFilterGeositeBlocklistMultiCategory(t *testing.T) {
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
m := &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "doh", Address: "https://1.1.1.1/dns-query", Detour: "direct"},
},
Blocklists: []model.Blocklist{
{Name: "junk", Enabled: true, Source: "geosite", Categories: []string{"category-ads-all", "malware"}},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(warns) != 0 {
t.Fatalf("unexpected warnings: %v", warns)
}
cases := []struct{ tag, url string }{
{"bl-junk-category-ads-all", "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs"},
{"bl-junk-malware", "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-malware.srs"},
}
for _, c := range cases {
rs, ok := ruleSetByTag(opts.Route, c.tag)
if !ok {
t.Fatalf("rule-set %s not emitted; route=%+v", c.tag, opts.Route)
}
if rs.Type != C.RuleSetTypeRemote || rs.RemoteOptions.URL != c.url {
t.Fatalf("%s: type=%q url=%q, want remote %q", c.tag, rs.Type, rs.RemoteOptions.URL, c.url)
}
// A DNS rule (block) must reference this tag.
if !dnsRuleReferencesRuleSet(opts.DNS, c.tag) {
t.Fatalf("no DNS rule references %s; dns=%+v", c.tag, opts.DNS)
}
}
}
// TestFilterGeositeNoCategoryWarnsSkipped: a geosite blocklist with NO category
// warns and is skipped (fail-open), no bl- rule-set emitted.
func TestFilterGeositeNoCategoryWarnsSkipped(t *testing.T) {
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
m := &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "doh", Address: "https://1.1.1.1/dns-query", Detour: "direct"},
},
Blocklists: []model.Blocklist{
{Name: "bad", Enabled: true, Source: "geosite"}, // no category
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
var warned bool
for _, w := range warns {
if strings.Contains(w, "geosite source needs a category") {
warned = true
}
}
if !warned {
t.Fatalf("expected a geosite-no-category warning, got %v", warns)
}
if _, ok := ruleSetByTag(opts.Route, "bl-bad"); ok {
t.Fatalf("category-less geosite blocklist must not materialise a rule-set")
}
}
// TestRoutingAndFilterRuleSetsCoexist: a DNS-filter blocklist (bl-*) and a
// routing ruleset (rs-*) live in one config with distinct tags, no collision.
func TestRoutingAndFilterRuleSetsCoexist(t *testing.T) {
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
m := &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "doh", Address: "https://1.1.1.1/dns-query", Detour: "direct"},
},
Blocklists: []model.Blocklist{
{Name: "ads", Enabled: true, Source: "inline", Entries: []string{"tracker.example"}},
},
Rulesets: []model.Ruleset{
{Name: "ads", Type: "domain", Source: "inline", Entries: []string{"route.example"}},
},
Rules: []model.Rule{
{Name: "via-rs", Enabled: true, Order: 10, DstRuleset: []string{"ads"}, Target: "direct"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(warns) != 0 {
t.Fatalf("unexpected warnings: %v", warns)
}
if _, ok := ruleSetByTag(opts.Route, "bl-ads"); !ok {
t.Fatalf("filter rule-set bl-ads missing; route=%+v", opts.Route)
}
if _, ok := ruleSetByTag(opts.Route, "rs-ads"); !ok {
t.Fatalf("routing rule-set rs-ads missing; route=%+v", opts.Route)
}
// Distinct tags: bl-ads carries tracker.example, rs-ads carries route.example.
bl, _ := ruleSetByTag(opts.Route, "bl-ads")
rs, _ := ruleSetByTag(opts.Route, "rs-ads")
if !contains(bl.InlineOptions.Rules[0].DefaultOptions.DomainSuffix, "tracker.example") {
t.Fatalf("bl-ads must carry the blocklist entry, got %+v", bl)
}
if !contains(rs.InlineOptions.Rules[0].DefaultOptions.DomainSuffix, "route.example") {
t.Fatalf("rs-ads must carry the ruleset entry, got %+v", rs)
}
}
// --- audit regressions: fail-open guards on user-typed list content -----------
// init makes the WHOLE package's test suite deterministic and offline: the R5
// reachability probe would otherwise issue real HEAD requests to
// raw.githubusercontent.com for every geosite/geoip/url list any test builds.
// Tests that care about unreachability call withRuleSetProbe to override it.
func init() {
ruleSetProbe = func(string) ruleSetVerdict { return ruleSetUsable() }
// R10: never download a real blocklist from a test.
listFetcher = func(url string) ([]byte, error) {
return nil, fmt.Errorf("no stub fetcher installed for %s", url)
}
// Compiled artifacts must never touch the real /etc/shater during tests.
listsDirOverride = os.TempDir()
}
// withListFetcher swaps the list downloader and gives the compiled artifacts a
// per-test directory, so nothing leaks between tests or onto the real filesystem.
func withListFetcher(t *testing.T, fetch func(string) ([]byte, error)) {
t.Helper()
prevFetch, prevDir := listFetcher, listsDirOverride
listFetcher = fetch
listsDirOverride = t.TempDir()
t.Cleanup(func() { listFetcher, listsDirOverride = prevFetch, prevDir })
}
// withRuleSetProbe swaps the reachability probe for the duration of a test and
// clears the memo on both sides so results never leak between tests. It keeps the
// plain reachable/not-reachable shape most tests want; withRuleSetVerdict is for
// the tests that care WHY a list was refused.
func withRuleSetProbe(t *testing.T, probe func(string) bool) {
t.Helper()
withRuleSetVerdict(t, func(url string) ruleSetVerdict {
if probe(url) {
return ruleSetUsable()
}
return ruleSetUnusable("its source %q is unreachable right now", url)
})
}
// withRuleSetVerdict is withRuleSetProbe with the full verdict, for the format-
// version refusal that must be distinguishable from an outage.
func withRuleSetVerdict(t *testing.T, probe func(string) ruleSetVerdict) {
t.Helper()
prev := ruleSetProbe
resetRuleSetProbeCache()
ruleSetProbe = probe
t.Cleanup(func() {
ruleSetProbe = prev
resetRuleSetProbeCache()
})
}
// warnsHave reports whether any warning contains sub.
func warnsHaveSub(warns []string, sub string) bool {
for _, w := range warns {
if strings.Contains(w, sub) {
return true
}
}
return false
}
// generateWithURLBlocklist builds a filter-enabled model with one url blocklist
// named "probe" and returns the config plus warnings.
func generateWithURLBlocklist(t *testing.T, url string) (option.Options, []string) {
t.Helper()
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
m := &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "udp", Address: "1.1.1.1", Detour: "direct"},
},
Blocklists: []model.Blocklist{
{Name: "probe", Enabled: true, Source: "url", URL: url},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
return opts, warns
}
// mustCompileList generates a url blocklist and returns the domains actually
// compiled into the artifact.
func mustCompileList(t *testing.T, url string) []string {
t.Helper()
opts, warns := generateWithURLBlocklist(t, url)
rs, ok := ruleSetByTag(opts.Route, "bl-probe")
if !ok {
t.Fatalf("bl-probe must materialise; warnings=%v", warns)
}
data, err := os.ReadFile(rs.LocalOptions.Path)
if err != nil {
t.Fatalf("artifact missing: %v", err)
}
compat, err := srs.Read(bytes.NewReader(data), false)
if err != nil {
t.Fatalf("artifact is not a valid rule-set: %v", err)
}
plain, err := compat.Upgrade()
if err != nil {
t.Fatalf("Upgrade: %v", err)
}
return compiledDomains(t, plain)
}
// compiledDomains recovers the domain list from a compiled rule-set. srs stores
// domain/domain_suffix as a COMPILED succinct matcher, not as strings, so the
// round-trip yields DomainMatcher and the entries come back via its Dump():
// domainList are exact names, prefixList the suffixes.
func compiledDomains(t *testing.T, plain option.PlainRuleSet) []string {
t.Helper()
if len(plain.Rules) != 1 {
t.Fatalf("expected exactly one headless rule, got %d", len(plain.Rules))
}
m := plain.Rules[0].DefaultOptions.DomainMatcher
if m == nil {
t.Fatalf("compiled rule carries no domain matcher: %+v", plain.Rules[0].DefaultOptions)
}
exact, suffixes := m.Dump()
return append(append([]string{}, exact...), suffixes...)
}
// countRuleSetsWithTag counts the rule-sets carrying tag (0, 1 or — the bug — >1).
func countRuleSetsWithTag(rt *option.RouteOptions, tag string) int {
if rt == nil {
return 0
}
n := 0
for _, rs := range rt.RuleSet {
if rs.Tag == tag {
n++
}
}
return n
}
// TestInlineDomainRuleDropsMarkerOnlyEntries is the regression for the
// marker-only entry class. A list line that is nothing but its marker used to be
// emitted as an EMPTY matcher token, with two different catastrophic outcomes:
//
// "." -> domain_suffix:[""] -> box.New: "domain_suffix: empty item is
// not allowed" => the WHOLE config fails
// "full:" -> domain:[""] -> box.New: "domain: empty item is not
// allowed" => the WHOLE config fails
// "keyword:" -> domain_keyword:[""] -> strings.Contains(host,"") is TRUE for
// every host => NXDOMAIN for the entire
// internet, silently
//
// All three are one keystroke away in a free-text blocklist, so they must be
// dropped while the real entries on the same list survive.
func TestInlineDomainRuleDropsMarkerOnlyEntries(t *testing.T) {
hr, ok := inlineDomainRule([]string{".", "full:", "keyword:", " . ", "ads.example"})
if !ok {
t.Fatalf("a list with one usable entry must still produce a rule")
}
for _, s := range hr.DomainSuffix {
if strings.TrimSpace(s) == "" {
t.Fatalf("empty domain_suffix token emitted (aborts box.New); got %q", hr.DomainSuffix)
}
}
for _, s := range hr.Domain {
if strings.TrimSpace(s) == "" {
t.Fatalf("empty domain token emitted (aborts box.New); got %q", hr.Domain)
}
}
for _, s := range hr.DomainKeyword {
if strings.TrimSpace(s) == "" {
t.Fatalf("empty domain_keyword token emitted (matches EVERY domain); got %q", hr.DomainKeyword)
}
}
if !contains(hr.DomainSuffix, "ads.example") {
t.Fatalf("the usable entry must survive; got %+v", hr)
}
// A list consisting ONLY of marker-only entries yields nothing at all, so the
// caller warns + skips the list instead of emitting a match-everything rule.
if _, ok := inlineDomainRule([]string{".", "full:", "keyword:", " "}); ok {
t.Fatalf("a marker-only list must produce NO rule (it would match everything)")
}
}
// --- R5: an unreachable remote list must never stop the engine starting -------
// unreachableProbe fails every URL — the "router booted before the ISP did" state.
func unreachableProbe(string) bool { return false }
// countRemoteRuleSets counts remote (network-fetching) rule-sets in a config.
// This is THE structural invariant behind R5: RemoteRuleSet.StartContext is the
// only thing that can fail router.Start over a missing network, so a config with
// zero remote rule-sets simply cannot be stopped by an absent WAN.
func countRemoteRuleSets(opts option.Options) int {
if opts.Route == nil {
return 0
}
n := 0
for _, rs := range opts.Route.RuleSet {
if rs.Type == C.RuleSetTypeRemote {
n++
}
}
return n
}
// TestOfflineEmitsNoRemoteRuleSets is the core R5 guarantee. With no network, a
// model stuffed with every remote-list flavour (routing url, routing geosite,
// routing geoip, filter url, filter geosite) must yield a config carrying NO
// remote rule-set at all — so there is nothing for the engine to fetch and
// nothing that can fail its start.
//
// Without this, RemoteRuleSet.StartContext returns an error, ruleSetStartGroup is
// FastFail, router.Start fails, box.Start fails, engine.Apply fails, the engine
// never comes up and the fail-closed netplane leaves the LAN dead — permanently,
// because recovering needs the internet that is missing.
func TestOfflineEmitsNoRemoteRuleSets(t *testing.T) {
withRuleSetProbe(t, unreachableProbe)
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
m := &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "udp", Address: "1.1.1.1", Detour: "direct"},
},
Rulesets: []model.Ruleset{
{Name: "u", Source: "url", URL: "https://example.invalid/list.srs"},
{Name: "gs", Source: "geosite", Categories: []string{"youtube"}},
{Name: "gi", Source: "geoip", Categories: []string{"ru"}},
{Name: "inl", Source: "inline", Entries: []string{"keep.example"}},
},
Rules: []model.Rule{
{Name: "r-u", Enabled: true, Order: 1, DstRuleset: []string{"u"}, Target: "block"},
{Name: "r-gs", Enabled: true, Order: 2, DstRuleset: []string{"gs"}, Target: "block"},
{Name: "r-gi", Enabled: true, Order: 3, DstRuleset: []string{"gi"}, Target: "block"},
{Name: "r-inl", Enabled: true, Order: 4, DstRuleset: []string{"inl"}, Target: "block"},
},
Blocklists: []model.Blocklist{
{Name: "bu", Enabled: true, Source: "url", URL: "https://example.invalid/bl.srs"},
{Name: "bgs", Enabled: true, Source: "geosite", Categories: []string{"category-ads-all"}},
{Name: "binl", Enabled: true, Source: "inline", Entries: []string{"ads.example"}},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if n := countRemoteRuleSets(opts); n != 0 {
t.Fatalf("offline config still carries %d remote rule-set(s) — engine start would fail and the LAN would stay down; route=%+v", n, opts.Route.RuleSet)
}
// Requirement (2): not applied must be LOUD, not silent.
if !warnsHaveSub(warns, "RULESET-NOT-APPLIED") {
t.Fatalf("an omitted remote list must warn loudly, got %v", warns)
}
// Requirement (3): the INLINE lists still work — degrading the remote ones must
// not take the offline-capable ones with them.
if _, ok := ruleSetByTag(opts.Route, "rs-inl"); !ok {
t.Fatalf("inline routing ruleset must survive an offline boot")
}
if _, ok := ruleSetByTag(opts.Route, "bl-binl"); !ok {
t.Fatalf("inline blocklist must survive an offline boot")
}
}
// TestOfflineRoutingRuleDegradesToSkipped: requirement (3) for the routing plane.
// A rule whose ONLY matcher was an unreachable ruleset must be OMITTED (so the
// traffic follows the next matching rule / Final), never emitted with an empty or
// missing rule_set — which would silently become a match-all.
func TestOfflineRoutingRuleDegradesToSkipped(t *testing.T) {
withRuleSetProbe(t, unreachableProbe)
m := &model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{
{Name: "gs", Source: "geosite", Categories: []string{"youtube"}},
},
Rules: []model.Rule{
{Name: "r", Enabled: true, DstRuleset: []string{"gs"}, Target: "block"},
},
}
opts, _, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
for _, r := range opts.Route.Rules {
d := r.DefaultOptions
if d.RuleAction.Action != C.RuleActionTypeRoute {
continue // sniff / hijack-dns
}
// Any surviving route rule must carry a real matcher; a rule with no matcher
// at all would match everything and blackhole the network.
if len(d.RuleSet) == 0 && len(d.Domain) == 0 && len(d.DomainSuffix) == 0 &&
len(d.IPCIDR) == 0 && len(d.SourceIPCIDR) == 0 && len(d.Port) == 0 &&
len(d.Protocol) == 0 && len(d.Network) == 0 {
t.Fatalf("a matcher-less route rule survived — it would match ALL traffic; rule=%+v", d)
}
if len(d.RuleSet) > 0 {
t.Fatalf("no rule may reference the omitted rule-set; got %+v", d.RuleSet)
}
}
}
// TestOfflineBlocklistBlocksNothing is requirement (3) for the DNS plane, and the
// one that matters most: an unreachable BLOCKLIST must block NOTHING. The opposite
// failure — an empty list collapsing into "matches everything" — is the D1/D2 trap
// in another costume and would NXDOMAIN the entire internet.
func TestOfflineBlocklistBlocksNothing(t *testing.T) {
withRuleSetProbe(t, unreachableProbe)
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
m := &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "udp", Address: "1.1.1.1", Detour: "direct"},
},
Blocklists: []model.Blocklist{
{Name: "ads", Enabled: true, Source: "geosite", Categories: []string{"category-ads-all"}},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if !warnsHaveSub(warns, "RULESET-NOT-APPLIED") {
t.Fatalf("expected an unreachable warning, got %v", warns)
}
if opts.DNS == nil {
t.Fatalf("nil DNS")
}
for i, r := range opts.DNS.Rules {
d := r.DefaultOptions
if d.DNSRuleAction.Action != C.RuleActionTypePredefined {
continue
}
// A predefined (blocking) DNS rule with NO matcher would answer every query
// NXDOMAIN. That must never be what an unavailable blocklist degrades into.
if len(d.RuleSet) == 0 && len(d.Domain) == 0 && len(d.DomainSuffix) == 0 &&
len(d.DomainKeyword) == 0 && len(d.SourceIPCIDR) == 0 {
t.Fatalf("matcher-less block rule at [%d] — it would NXDOMAIN the whole internet; rule=%+v", i, d)
}
if len(d.RuleSet) > 0 && contains(d.RuleSet, "bl-ads-category-ads-all") {
t.Fatalf("block rule references the omitted rule-set %q", "bl-ads-category-ads-all")
}
}
}
// TestRemoteListPickedUpWhenReachable is the recovery half: the same model that
// degraded offline must materialise fully once the network is back, which is what
// the per-minute reconcile relies on.
func TestRemoteListPickedUpWhenReachable(t *testing.T) {
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
newModel := func() *model.Model {
return &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "udp", Address: "1.1.1.1", Detour: "direct"},
},
Rulesets: []model.Ruleset{
{Name: "gs", Source: "geosite", Categories: []string{"youtube"}},
},
Rules: []model.Rule{
{Name: "r", Enabled: true, DstRuleset: []string{"gs"}, Target: "block"},
},
Blocklists: []model.Blocklist{
{Name: "ads", Enabled: true, Source: "url", URL: "https://lists.example/ads.srs"},
},
}
}
// Boot with no WAN: nothing remote, engine-safe.
withRuleSetProbe(t, unreachableProbe)
offline, _, err := GenerateWithWarnings(newModel())
if err != nil {
t.Fatalf("Generate (offline): %v", err)
}
if countRemoteRuleSets(offline) != 0 {
t.Fatalf("offline pass must emit no remote rule-sets")
}
// WAN comes up; the next reconcile regenerates and everything lands.
withRuleSetProbe(t, func(string) bool { return true })
online, warns, err := GenerateWithWarnings(newModel())
if err != nil {
t.Fatalf("Generate (online): %v", err)
}
if warnsHaveSub(warns, "RULESET-NOT-APPLIED") {
t.Fatalf("no unreachable warning expected once online, got %v", warns)
}
rs, ok := ruleSetByTag(online.Route, "rs-gs-youtube")
if !ok || rs.Type != C.RuleSetTypeRemote {
t.Fatalf("routing geosite list must materialise once reachable; route=%+v", online.Route.RuleSet)
}
bl, ok := ruleSetByTag(online.Route, "bl-ads")
if !ok || bl.Type != C.RuleSetTypeRemote {
t.Fatalf("url blocklist must materialise once reachable")
}
if findRouteRuleWithRuleSet(online.Route, "rs-gs-youtube") == nil {
t.Fatalf("the rule must reference the now-available rule-set")
}
}
// TestNotAppliedWarningIsDistinguishable pins the operator-visibility requirement.
// An omitted list produces NO row in GET /api/ruleset/status (that endpoint
// projects the engine's ACTIVE rule-sets), so it is indistinguishable there from a
// list that was never configured. The warning is therefore the only signal, and it
// must carry a stable, greppable marker plus enough context to act on: which list,
// which URL, which tag, and that it self-heals.
func TestNotAppliedWarningIsDistinguishable(t *testing.T) {
withRuleSetProbe(t, unreachableProbe)
m := &model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{
{Name: "geo", Source: "url", URL: "https://lists.example/geo.srs"},
},
Rules: []model.Rule{
{Name: "r", Enabled: true, DstRuleset: []string{"geo"}, Target: "block"},
},
}
_, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
var found string
for _, w := range warns {
if strings.Contains(w, "RULESET-NOT-APPLIED") {
found = w
}
}
if found == "" {
t.Fatalf("expected a RULESET-NOT-APPLIED warning, got %v", warns)
}
// It must name the list, the source and the tag, and say it is NOT active — a
// bare "unreachable" would not tell an operator whether the list is even
// configured.
for _, want := range []string{`ruleset "geo"`, "NOT ACTIVE", "https://lists.example/geo.srs", `"rs-geo"`, "reconcile"} {
if !strings.Contains(found, want) {
t.Fatalf("warning must mention %q; got %q", want, found)
}
}
}
// TestRuleSetProbeIsMemoised: the probe must not re-run per call, or a reconcile
// (once a minute, several lists) would hammer the network from inside generate.
func TestRuleSetProbeIsMemoised(t *testing.T) {
var calls int
withRuleSetProbe(t, func(string) bool { calls++; return true })
m := &model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{
{Name: "gs", Source: "geosite", Categories: []string{"youtube"}},
},
Rules: []model.Rule{
{Name: "r", Enabled: true, DstRuleset: []string{"gs"}, Target: "block"},
},
}
for range 5 {
if _, _, err := GenerateWithWarnings(m); err != nil {
t.Fatalf("Generate: %v", err)
}
}
if calls != 1 {
t.Fatalf("probe ran %d times across 5 generates; it must be memoised to 1", calls)
}
}
// --- R6: a rule enabled by the active PROFILE must get its rule-set ----------
// TestProfileEnabledRuleMaterialisesRuleSet is the R6 regression.
// buildRoutingRuleSets used to walk b.m.Rules (the raw model) instead of
// b.effectiveRules (the active profile's enable/disable applied). A rule
// switched OFF in the config and switched ON by the active profile therefore never
// had its dst_ruleset materialised: ruleMatchers found no tags, dropped the
// matcher and silently skipped the rule. Switching profiles appeared to do nothing.
func TestProfileEnabledRuleMaterialisesRuleSet(t *testing.T) {
m := &model.Model{
Globals: model.Globals{ActiveProfile: "work", KillSwitch: "closed"},
Profiles: []model.Profile{
{Name: "work", Enabled: true, EnableRules: []string{"stream"}},
},
Rulesets: []model.Ruleset{
{Name: "video", Source: "inline", Entries: []string{"youtube.com"}},
},
Rules: []model.Rule{
// DISABLED in the config; the active profile turns it on.
{Name: "stream", Enabled: false, DstRuleset: []string{"video"}, Target: "block"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if _, ok := ruleSetByTag(opts.Route, "rs-video"); !ok {
t.Fatalf("a rule enabled by the active profile must have its rule-set materialised; route=%+v (warnings %v)", opts.Route.RuleSet, warns)
}
dr := findRouteRuleWithRuleSet(opts.Route, "rs-video")
if dr == nil {
t.Fatalf("the profile-enabled rule must be emitted and reference rs-video; rules=%+v", opts.Route.Rules)
}
if dr.RuleAction.RouteOptions.Outbound != tagBlock {
t.Fatalf("rule must route to block, got %+v", dr.RuleAction)
}
}
// TestProfileDisabledRuleDropsRuleSet is the mirror: a rule enabled in the config
// but switched OFF by the active profile must not materialise its rule-set either
// (no orphan rule-set, no dangling reference).
func TestProfileDisabledRuleDropsRuleSet(t *testing.T) {
m := &model.Model{
Globals: model.Globals{ActiveProfile: "quiet", KillSwitch: "closed"},
Profiles: []model.Profile{
{Name: "quiet", Enabled: true, DisableRules: []string{"stream"}},
},
Rulesets: []model.Ruleset{
{Name: "video", Source: "inline", Entries: []string{"youtube.com"}},
},
Rules: []model.Rule{
{Name: "stream", Enabled: true, DstRuleset: []string{"video"}, Target: "block"},
},
}
opts, _, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if _, ok := ruleSetByTag(opts.Route, "rs-video"); ok {
t.Fatalf("a profile-disabled rule must not leave its rule-set behind")
}
if findRouteRuleWithRuleSet(opts.Route, "rs-video") != nil {
t.Fatalf("no rule may reference rs-video")
}
}
// --- R3: file source with an unreadable path --------------------------------
// TestFileSourceMissingPathSkipped: LocalRuleSet.reloadFile treats an unreadable
// path as FATAL — box.New refuses to start and the router loses its tunnel because
// a user deleted the file behind one blocklist. It must be warned + skipped like
// every other bad input here.
func TestFileSourceMissingPathSkipped(t *testing.T) {
missing := filepath.Join(t.TempDir(), "does-not-exist.srs")
t.Run("routing ruleset", func(t *testing.T) {
m := &model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{
{Name: "gone", Source: "file", Path: missing},
},
Rules: []model.Rule{
{Name: "r", Enabled: true, DstRuleset: []string{"gone"}, Target: "block"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if _, ok := ruleSetByTag(opts.Route, "rs-gone"); ok {
t.Fatalf("a ruleset pointing at a missing file must not be emitted")
}
if !warnsHaveSub(warns, "unreadable") {
t.Fatalf("expected an unreadable-file warning, got %v", warns)
}
})
t.Run("filter blocklist", func(t *testing.T) {
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
m := &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "doh", Address: "https://1.1.1.1/dns-query", Detour: "direct"},
},
Blocklists: []model.Blocklist{
{Name: "gone", Enabled: true, Source: "file", Path: missing},
{Name: "ads", Enabled: true, Source: "inline", Entries: []string{"ads.example"}},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if _, ok := ruleSetByTag(opts.Route, "bl-gone"); ok {
t.Fatalf("a blocklist pointing at a missing file must not be emitted")
}
if _, ok := ruleSetByTag(opts.Route, "bl-ads"); !ok {
t.Fatalf("the healthy blocklist must still materialise")
}
if !warnsHaveSub(warns, "unreadable") {
t.Fatalf("expected an unreadable-file warning, got %v", warns)
}
})
}
// TestFileSourceExistingPathEmitted: a real, readable file still produces a local
// rule-set with the extension-derived format (.json => source, else binary).
func TestFileSourceExistingPathEmitted(t *testing.T) {
dir := t.TempDir()
jsonPath := filepath.Join(dir, "list.json")
if err := os.WriteFile(jsonPath, []byte(`{"version":3,"rules":[]}`), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
srsPath := filepath.Join(dir, "list.srs")
if err := os.WriteFile(srsPath, []byte{0x53, 0x52, 0x53}, 0o644); err != nil {
t.Fatalf("write: %v", err)
}
m := &model.Model{
Globals: nonDNSGlobals(),
Rulesets: []model.Ruleset{
{Name: "j", Source: "file", Path: jsonPath},
{Name: "s", Source: "file", Path: srsPath},
},
Rules: []model.Rule{
{Name: "r1", Enabled: true, Order: 1, DstRuleset: []string{"j"}, Target: "block"},
{Name: "r2", Enabled: true, Order: 2, DstRuleset: []string{"s"}, Target: "block"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(warns) != 0 {
t.Fatalf("unexpected warnings: %v", warns)
}
rj, ok := ruleSetByTag(opts.Route, "rs-j")
if !ok || rj.Type != C.RuleSetTypeLocal || rj.Format != C.RuleSetFormatSource {
t.Fatalf("rs-j must be a local source-format rule-set, got %+v", rj)
}
rs, ok := ruleSetByTag(opts.Route, "rs-s")
if !ok || rs.Format != C.RuleSetFormatBinary {
t.Fatalf("rs-s must be a local binary-format rule-set, got %+v", rs)
}
}
// TestFileSourceDirectoryRejected: a path that is a directory would also abort
// box.New (os.ReadFile on a dir errors), so it is treated the same way.
func TestFileSourceDirectoryRejected(t *testing.T) {
m := &model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{
{Name: "d", Source: "file", Path: t.TempDir()},
},
Rules: []model.Rule{
{Name: "r", Enabled: true, DstRuleset: []string{"d"}, Target: "block"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if _, ok := ruleSetByTag(opts.Route, "rs-d"); ok {
t.Fatalf("a directory path must not be emitted as a rule-set")
}
if !warnsHaveSub(warns, "is a directory") {
t.Fatalf("expected a directory warning, got %v", warns)
}
}
// --- R4: case + IDNA normalisation ------------------------------------------
// TestDomainEntriesNormalised: the engine stores rule entries VERBATIM but
// lowercases the queried host, and DNS queries arrive punycoded. So an entry typed
// "Ads.Example" or "реклама.рф" silently never matched anything. Both are
// normalised at generate time.
func TestDomainEntriesNormalised(t *testing.T) {
hr, ok := inlineDomainRule([]string{"Ads.EXAMPLE", "full:Exact.Example", "реклама.рф", "keyword:TRACK"})
if !ok {
t.Fatalf("expected a rule")
}
if !contains(hr.DomainSuffix, "ads.example") {
t.Fatalf("entries must be lower-cased, got %+v", hr.DomainSuffix)
}
if !contains(hr.Domain, "exact.example") {
t.Fatalf("full: entries must be lower-cased, got %+v", hr.Domain)
}
if !contains(hr.DomainKeyword, "track") {
t.Fatalf("keywords must be lower-cased (the host is lowered before Contains), got %+v", hr.DomainKeyword)
}
// The unicode entry must become punycode, which is what actually arrives in a query.
var havePuny bool
for _, s := range hr.DomainSuffix {
if strings.HasPrefix(s, "xn--") {
havePuny = true
}
}
if !havePuny {
t.Fatalf("a unicode entry must be punycoded, got %+v", hr.DomainSuffix)
}
}
// TestDomainEntriesNormalisationIsLossless: an entry idna cannot convert is kept
// verbatim rather than dropped — normalisation must never lose a user's entry.
func TestDomainEntriesNormalisationIsLossless(t *testing.T) {
in := []string{"ads.example", "*.weird.example", "xn--80aswg.xn--p1ai"}
hr, ok := inlineDomainRule(in)
if !ok {
t.Fatalf("expected a rule")
}
if got := len(hr.Domain) + len(hr.DomainSuffix) + len(hr.DomainKeyword); got != len(in) {
t.Fatalf("normalisation dropped entries: %d in, %d out (%+v)", len(in), got, hr)
}
// An already-punycoded entry must pass through untouched.
if !contains(hr.DomainSuffix, "xn--80aswg.xn--p1ai") {
t.Fatalf("an already-ASCII punycode entry must be preserved verbatim, got %+v", hr.DomainSuffix)
}
}
// --- R10: url blocklists in the formats the real world publishes -------------
// stevenBlackSample is a faithful slice of a real hosts file: title comments, the
// loopback boilerplate, inline comments, tabs, multiple names per line, blank
// lines and CRLF.
const stevenBlackSample = "# Title: StevenBlack/hosts\r\n" +
"# This hosts file is a merged collection\r\n" +
"\r\n" +
"127.0.0.1 localhost\n" +
"127.0.0.1 localhost.localdomain\n" +
"255.255.255.255 broadcasthost\n" +
"::1 localhost\n" +
"ff02::1 ip6-allnodes\n" +
"\n" +
"# Start of the block list\n" +
"0.0.0.0 doubleclick.net\n" +
"0.0.0.0\tads.example.com\t# inline comment\n" +
"0.0.0.0 tracker.example.org analytics.example.org\n" +
"0.0.0.0 UPPER.Example.NET\n" +
"\n"
// TestURLBlocklistHostsFormat is the R10 acceptance case: the exact StevenBlack
// URL from the bench report must now produce a working, compiled blocklist instead
// of "invalid sing-box rule-set file" and a refused config.
func TestURLBlocklistHostsFormat(t *testing.T) {
var fetched string
withListFetcher(t, func(url string) ([]byte, error) {
fetched = url
return []byte(stevenBlackSample), nil
})
const listURL = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
m := &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "udp", Address: "1.1.1.1", Detour: "direct"},
},
Blocklists: []model.Blocklist{
{Name: "stevenblack", Enabled: true, Source: "url", URL: listURL},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if fetched != listURL {
t.Fatalf("the list must be downloaded once; fetched=%q", fetched)
}
rs, ok := ruleSetByTag(opts.Route, "bl-stevenblack")
if !ok {
t.Fatalf("bl-stevenblack must materialise; route=%+v (warnings %v)", opts.Route.RuleSet, warns)
}
// LOCAL, not remote: engine start then has nothing to fetch and cannot fail.
if rs.Type != C.RuleSetTypeLocal {
t.Fatalf("a text list must be compiled to a LOCAL rule-set, got type %q", rs.Type)
}
if rs.Format != C.RuleSetFormatBinary {
t.Fatalf("compiled artifact must be binary .srs, got %q", rs.Format)
}
// The artifact exists, is a real .srs, and holds exactly the parsed domains.
data, readErr := os.ReadFile(rs.LocalOptions.Path)
if readErr != nil {
t.Fatalf("compiled artifact missing at %s: %v", rs.LocalOptions.Path, readErr)
}
compat, srsErr := srs.Read(bytes.NewReader(data), false)
if srsErr != nil {
t.Fatalf("the artifact must be a valid sing-box rule-set: %v", srsErr)
}
plain, upErr := compat.Upgrade()
if upErr != nil {
t.Fatalf("Upgrade: %v", upErr)
}
got := compiledDomains(t, plain)
for _, want := range []string{
"doubleclick.net", "ads.example.com", "tracker.example.org",
"analytics.example.org", "upper.example.net", // lower-cased
} {
if !contains(got, want) {
t.Fatalf("compiled list must contain %q; got %v", want, got)
}
}
// Hosts boilerplate must NEVER be imported — blocking localhost would break
// local name resolution outright.
for _, never := range []string{"localhost", "localhost.localdomain", "broadcasthost", "ip6-allnodes"} {
if contains(got, never) {
t.Fatalf("hosts boilerplate %q must not be imported", never)
}
}
}
// TestURLBlocklistPlainDomainList: the other format public lists ship in.
func TestURLBlocklistPlainDomainList(t *testing.T) {
withListFetcher(t, func(string) ([]byte, error) {
return []byte("! OISD style comment\nads.example.com\ntracker.example.org\n\n||adblock.example.net^\n"), nil
})
domains := mustCompileList(t, "https://big.oisd.nl/domainswild")
for _, want := range []string{"ads.example.com", "tracker.example.org", "adblock.example.net"} {
if !contains(domains, want) {
t.Fatalf("plain/abp list must contain %q; got %v", want, domains)
}
}
}
// TestURLBlocklistSrsStillRemote: a URL that really does serve .srs keeps the
// existing REMOTE behaviour, so sing-box goes on owning fetch/cache/update/status.
func TestURLBlocklistSrsStillRemote(t *testing.T) {
withListFetcher(t, func(url string) ([]byte, error) {
t.Fatalf("a .srs URL must NOT be downloaded by the generator; got %s", url)
return nil, nil
})
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
m := &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "udp", Address: "1.1.1.1", Detour: "direct"},
},
Blocklists: []model.Blocklist{
{Name: "srs", Enabled: true, Source: "url", URL: "https://lists.example/ads.srs"},
{Name: "json", Enabled: true, Source: "url", URL: "https://lists.example/ads.json?v=2"},
},
}
opts, _, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
srsSet, ok := ruleSetByTag(opts.Route, "bl-srs")
if !ok || srsSet.Type != C.RuleSetTypeRemote || srsSet.Format != C.RuleSetFormatBinary {
t.Fatalf(".srs URL must stay a remote binary rule-set; got %+v", srsSet)
}
jsonSet, ok := ruleSetByTag(opts.Route, "bl-json")
if !ok || jsonSet.Type != C.RuleSetTypeRemote || jsonSet.Format != C.RuleSetFormatSource {
t.Fatalf(".json URL must stay a remote SOURCE rule-set (query string ignored); got %+v", jsonSet)
}
}
// TestURLBlocklistOversizeRefused: the download cap must refuse rather than
// truncate — a half-parsed blocklist is a silent protection gap.
func TestURLBlocklistOversizeRefused(t *testing.T) {
withListFetcher(t, func(string) ([]byte, error) {
return nil, fmt.Errorf("list is larger than the %d MiB download limit; use a smaller list, or a geosite category (source=geosite) which is pre-compiled", listMaxDownloadBytes>>20)
})
_, warns := generateWithURLBlocklist(t, "https://lists.example/huge.txt")
if !warnsHaveSub(warns, "RULESET-NOT-APPLIED") || !warnsHaveSub(warns, "download limit") {
t.Fatalf("an oversize list must be refused with a size message, got %v", warns)
}
if !warnsHaveSub(warns, "geosite") {
t.Fatalf("the message must point at the working alternative, got %v", warns)
}
}
// TestURLBlocklistUnparseableRefused: a URL that serves something that is not a
// list at all (an HTML error page, say) must say so in words a user can act on —
// this is what replaces "invalid sing-box rule-set file".
func TestURLBlocklistUnparseableRefused(t *testing.T) {
withListFetcher(t, func(string) ([]byte, error) {
return []byte("<!DOCTYPE html>\n<html><body>404 Not Found</body></html>\n"), nil
})
opts, warns := generateWithURLBlocklist(t, "https://lists.example/notalist")
if _, ok := ruleSetByTag(opts.Route, "bl-probe"); ok {
t.Fatalf("an unparseable list must not materialise a rule-set")
}
if !warnsHaveSub(warns, "no usable domains") {
t.Fatalf("expected a 'no usable domains' message, got %v", warns)
}
if !warnsHaveSub(warns, "hosts file") {
t.Fatalf("the message must name the expected formats, got %v", warns)
}
}
// TestURLBlocklistOfflineDegrades: with no network and no artifact, the list is
// simply not applied — the engine still starts, and a blocklist that is not
// applied blocks NOTHING (never everything).
func TestURLBlocklistOfflineDegrades(t *testing.T) {
withListFetcher(t, func(string) ([]byte, error) {
return nil, fmt.Errorf("cannot download: dial tcp: no route to host")
})
opts, warns := generateWithURLBlocklist(t, "https://lists.example/ads.txt")
if _, ok := ruleSetByTag(opts.Route, "bl-probe"); ok {
t.Fatalf("an undownloadable list must not materialise")
}
if countRemoteRuleSets(opts) != 0 {
t.Fatalf("no remote rule-set may be emitted for a text list")
}
if !warnsHaveSub(warns, "RULESET-NOT-APPLIED") {
t.Fatalf("expected the not-applied marker, got %v", warns)
}
// No matcher-less block rule may appear (the D1/D2 trap).
if opts.DNS != nil {
for _, r := range opts.DNS.Rules {
d := r.DefaultOptions
if d.DNSRuleAction.Action == C.RuleActionTypePredefined &&
len(d.RuleSet) == 0 && len(d.Domain) == 0 && len(d.DomainSuffix) == 0 &&
len(d.DomainKeyword) == 0 && len(d.SourceIPCIDR) == 0 {
t.Fatalf("a matcher-less block rule appeared — it would NXDOMAIN everything")
}
}
}
}
// TestURLBlocklistKeepsOldArtifactOnRefreshFailure: once compiled, a later network
// failure must NOT drop the list. An out-of-date blocklist still blocks; dropping
// it would silently disable ad-filtering on every transient outage.
func TestURLBlocklistKeepsOldArtifactOnRefreshFailure(t *testing.T) {
dir := t.TempDir()
prevFetch, prevDir := listFetcher, listsDirOverride
listsDirOverride = dir
t.Cleanup(func() { listFetcher, listsDirOverride = prevFetch, prevDir })
// First pass: online, compiles the artifact.
listFetcher = func(string) ([]byte, error) { return []byte("ads.example.com\n"), nil }
opts, _ := generateWithURLBlocklist(t, "https://lists.example/ads.txt")
rs, ok := ruleSetByTag(opts.Route, "bl-probe")
if !ok {
t.Fatalf("first pass must compile the list")
}
artifact := rs.LocalOptions.Path
if _, err := os.Stat(artifact); err != nil {
t.Fatalf("artifact missing: %v", err)
}
// Force staleness so the next pass attempts a refresh.
old := time.Now().Add(-48 * time.Hour)
if err := os.Chtimes(artifact, old, old); err != nil {
t.Fatalf("Chtimes: %v", err)
}
// Second pass: offline. The list must survive on the old artifact.
listFetcher = func(string) ([]byte, error) { return nil, fmt.Errorf("network unreachable") }
opts2, warns2 := generateWithURLBlocklist(t, "https://lists.example/ads.txt")
rs2, ok := ruleSetByTag(opts2.Route, "bl-probe")
if !ok {
t.Fatalf("a refresh failure must NOT drop an already-compiled list; warnings=%v", warns2)
}
if rs2.LocalOptions.Path != artifact {
t.Fatalf("must keep using the same artifact, got %q", rs2.LocalOptions.Path)
}
if !warnsHaveSub(warns2, "continuing with the copy compiled earlier") {
t.Fatalf("expected a 'continuing with previous copy' warning, got %v", warns2)
}
if warnsHaveSub(warns2, "RULESET-NOT-APPLIED") {
t.Fatalf("the list IS applied (from the old artifact); it must not be reported as not-applied")
}
}
// TestURLBlocklistNotRefetchedWhileFresh: update_interval must be honoured, or a
// per-minute reconcile would re-download multi-megabyte lists forever.
func TestURLBlocklistNotRefetchedWhileFresh(t *testing.T) {
var calls int
withListFetcher(t, func(string) ([]byte, error) {
calls++
return []byte("ads.example.com\n"), nil
})
for range 5 {
generateWithURLBlocklist(t, "https://lists.example/ads.txt")
}
if calls != 1 {
t.Fatalf("a fresh artifact must not be re-downloaded; fetched %d times", calls)
}
}
// TestParseDomainListRejectsJunk pins the parser's refusal rules: filter syntax,
// wildcards, IPs and bare labels can never match a domain query, so importing them
// would be a silent dud (the R9 lesson applied to fetched content).
func TestParseDomainListRejectsJunk(t *testing.T) {
got, _ := parseDomainList([]byte(strings.Join([]string{
"good.example.com",
"*.wildcard.example", // wildcard syntax
"/regex/", // regex rule
"ads.example.com$third-party", // filter options
"192.0.2.1", // bare IP
"2001:db8::1", // bare IPv6
"10.0.0.0/8", // CIDR
"localhost", // boilerplate
"nodot", // not a domain
"# comment only",
"! abp comment",
"also-good.example.org",
}, "\n")))
for _, want := range []string{"good.example.com", "also-good.example.org"} {
if !contains(got, want) {
t.Fatalf("valid entry %q must survive; got %v", want, got)
}
}
if len(got) != 2 {
t.Fatalf("only the two valid domains may be imported; got %v", got)
}
for _, s := range got {
if strings.ContainsAny(s, "*/$:") {
t.Fatalf("junk leaked into the compiled list: %q", s)
}
}
}
// TestParseDomainListHostsEdgeCases covers the messy real-world shapes.
func TestParseDomainListHostsEdgeCases(t *testing.T) {
got, _ := parseDomainList([]byte(stevenBlackSample))
if len(got) != 5 {
t.Fatalf("expected 5 domains from the sample, got %d (%v)", len(got), got)
}
// Unicode is punycoded, matching what actually arrives in a DNS query.
uni, _ := parseDomainList([]byte("0.0.0.0 реклама.рф\n"))
if len(uni) != 1 || !strings.HasPrefix(uni[0], "xn--") {
t.Fatalf("a unicode entry must be punycoded, got %v", uni)
}
}
// --- R9: `suffix:` everywhere, and unknown prefixes must not fail silently ----
// TestSuffixPrefixAcceptedInDomainLists is the R9 regression. `suffix:` is the
// documented marker in ROUTING rules, so users naturally reach for it in device
// and filter lists too. It used to be handed to the matcher as a literal domain
// suffix containing a colon — which cannot occur in a domain name, so it matched
// NOTHING, silently. In a parental-control block list that means the site loads.
func TestSuffixPrefixAcceptedInDomainLists(t *testing.T) {
hr, ok := inlineDomainRule([]string{"suffix:example.org", "suffix:wikipedia.org"})
if !ok {
t.Fatalf("suffix: entries must produce a rule")
}
for _, want := range []string{"example.org", "wikipedia.org"} {
if !contains(hr.DomainSuffix, want) {
t.Fatalf("suffix:%s must become the domain_suffix %q; got %+v", want, want, hr.DomainSuffix)
}
}
// The marker must be stripped, never kept as part of the value.
for _, s := range hr.DomainSuffix {
if strings.Contains(s, ":") {
t.Fatalf("a colon survived into the matcher (%q) — it can never match a domain", s)
}
}
// In a bare-is-suffix context, explicit suffix: and a plain entry agree.
plain, _ := inlineDomainRule([]string{"example.org"})
if len(plain.DomainSuffix) != 1 || plain.DomainSuffix[0] != "example.org" {
t.Fatalf("plain entry baseline changed: %+v", plain.DomainSuffix)
}
}
// TestSuffixPrefixCaseAndSpaces: markers are matched case-insensitively and
// tolerate whitespace, because a user typing "Suffix: example.org" means exactly
// what they appear to mean.
func TestSuffixPrefixCaseAndSpaces(t *testing.T) {
hr, ok := inlineDomainRule([]string{"Suffix:example.org", " SUFFIX: wikipedia.org ", "Full:Exact.Example", "KeyWord: Track"})
if !ok {
t.Fatalf("expected a rule")
}
if !contains(hr.DomainSuffix, "example.org") || !contains(hr.DomainSuffix, "wikipedia.org") {
t.Fatalf("case/space-tolerant suffix: parsing broken: %+v", hr.DomainSuffix)
}
if !contains(hr.Domain, "exact.example") {
t.Fatalf("case-tolerant full: parsing broken: %+v", hr.Domain)
}
if !contains(hr.DomainKeyword, "track") {
t.Fatalf("case-tolerant keyword: parsing broken: %+v", hr.DomainKeyword)
}
}
// TestUnrecognisedPrefixWarnsAndIsDropped: an unknown `word:` prefix is provably
// unmatchable (a domain cannot contain ":"), so it must be reported and dropped —
// never loaded into the matcher where it fails in silence.
func TestUnrecognisedPrefixWarnsAndIsDropped(t *testing.T) {
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
m := &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "udp", Address: "1.1.1.1", Detour: "direct"},
},
Blocklists: []model.Blocklist{
{Name: "ads", Enabled: true, Source: "inline", Entries: []string{
"regex:^ads", "geosite:ads", "domain:example.com", "good.example",
}},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
rs, ok := ruleSetByTag(opts.Route, "bl-ads")
if !ok {
t.Fatalf("the healthy entry must still produce a rule-set")
}
hr := rs.InlineOptions.Rules[0].DefaultOptions
all := append(append([]string{}, hr.Domain...), append(hr.DomainSuffix, hr.DomainKeyword...)...)
for _, s := range all {
if strings.Contains(s, ":") {
t.Fatalf("an unrecognised-prefix entry leaked into the matcher: %q", s)
}
}
if !contains(hr.DomainSuffix, "good.example") {
t.Fatalf("the valid entry must survive; got %+v", hr)
}
// Every bad prefix reported, with the entity named so the panel can deep-link.
for _, want := range []string{`regex:`, `geosite:`, `domain:`} {
var seen bool
for _, w := range warns {
if strings.Contains(w, "unrecognised prefix") && strings.Contains(w, want) {
seen = true
}
}
if !seen {
t.Fatalf("expected a warning for prefix %q; got %v", want, warns)
}
}
for _, w := range warns {
if strings.Contains(w, "unrecognised prefix") && !strings.HasPrefix(w, `blocklist "ads": `) {
t.Fatalf("warning must carry the `kind \"name\": ` prefix so the panel can attribute it; got %q", w)
}
}
}
// TestUnrecognisedPrefixNotTriggeredByIPv6 is the false-positive guard the R9
// brief calls out: ip_cidr lists are full of colons and must never be checked
// against the domain-prefix rule. Both layers are asserted — the ipcidr branch
// never consults the classifier, and the predicate itself refuses IP literals.
func TestUnrecognisedPrefixNotTriggeredByIPv6(t *testing.T) {
// Layer 1: the predicate ignores anything that parses as an address/prefix.
for _, e := range []string{
"2001:db8::1", "fe80::1", "::1", "2001:db8::/32", "fc00::/7", "10.0.0.0/8", "192.0.2.1",
} {
if marker, bad := unrecognisedDomainPrefix(e); bad {
t.Fatalf("%q is an IP/CIDR literal and must never be reported as a bad prefix (got %q)", e, marker)
}
}
// Layer 2: an ipcidr ruleset full of IPv6 produces no prefix warnings at all.
m := &model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{
{Name: "v6", Type: "ipcidr", Source: "inline", Entries: []string{
"2001:db8::/32", "fe80::/10", "fc00::/7", "10.0.0.0/8",
}},
},
Rules: []model.Rule{
{Name: "r", Enabled: true, DstRuleset: []string{"v6"}, Target: "block"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
for _, w := range warns {
if strings.Contains(w, "unrecognised prefix") {
t.Fatalf("an ip_cidr list must never trigger the domain-prefix check; got %q", w)
}
}
rs, ok := ruleSetByTag(opts.Route, "rs-v6")
if !ok || len(rs.InlineOptions.Rules[0].DefaultOptions.IPCIDR) != 4 {
t.Fatalf("all four CIDRs must survive; got %+v", rs)
}
}
// TestUnrecognisedPrefixIgnoresNonPrefixColons: entries whose colon is not a
// `word:` marker (a numeric IPv6 group, a bare colon, a URL-ish paste) must not be
// mis-reported. They are still unmatchable as domains, but claiming a bad PREFIX
// would be the wrong diagnosis.
func TestUnrecognisedPrefixIgnoresNonPrefixColons(t *testing.T) {
for _, e := range []string{"2001:db8", "::", ":8080", "-bad:x"} {
if _, bad := unrecognisedDomainPrefix(e); bad {
t.Fatalf("%q has no word-like prefix and must not be reported", e)
}
}
// A word-like prefix IS reported, including one with digits/hyphens.
for _, e := range []string{"regex:^a", "my-prefix:x", "a1:b"} {
if _, bad := unrecognisedDomainPrefix(e); !bad {
t.Fatalf("%q has an unrecognised word prefix and must be reported", e)
}
}
// Recognised markers are never reported.
for _, e := range []string{"suffix:a.example", "full:a.example", "keyword:ads", "Suffix:a.example"} {
if _, bad := unrecognisedDomainPrefix(e); bad {
t.Fatalf("%q is a recognised marker and must not be reported", e)
}
}
}
// TestDeviceListSuffixPrefix is the exact bench reproduction: a per-device block
// list using suffix: must actually block. This is the parental-control case where
// silence is worst — the parent believes the site is blocked and it opens.
func TestDeviceListSuffixPrefix(t *testing.T) {
m := &model.Model{
Globals: model.Globals{ResolverDefault: "cf"},
Resolvers: []model.Resolver{
{Name: "cf", Type: "udp", Address: "1.1.1.1", Detour: "direct"},
},
Devices: []model.Device{{
Name: "kid", Enabled: true, IP: "192.168.1.50",
Block: []string{"suffix:wikipedia.org", "suffix:example.org"},
}},
}
b := newBuilder(m)
opts := b.buildDNS()
if opts == nil {
t.Fatalf("buildDNS returned nil")
}
if len(b.warnings) != 0 {
t.Fatalf("suffix: is valid here and must not warn; got %v", b.warnings)
}
var blocked []string
for _, r := range opts.Rules {
d := r.DefaultOptions
if d.DNSRuleAction.Action == C.RuleActionTypePredefined && hasStr(d.SourceIPCIDR, "192.168.1.50/32") {
blocked = append(blocked, d.DomainSuffix...)
}
}
for _, want := range []string{"wikipedia.org", "example.org"} {
if !contains(blocked, want) {
t.Fatalf("device block suffix:%s must produce the domain_suffix %q; got %v", want, want, blocked)
}
}
}
// TestDeviceListUnrecognisedPrefixWarns: the same list with a bad prefix reports
// it, names the device and the list, and drops the entry.
func TestDeviceListUnrecognisedPrefixWarns(t *testing.T) {
m := &model.Model{
Globals: model.Globals{ResolverDefault: "cf"},
Resolvers: []model.Resolver{
{Name: "cf", Type: "udp", Address: "1.1.1.1", Detour: "direct"},
},
Devices: []model.Device{{
Name: "kid", Enabled: true, IP: "192.168.1.50",
Block: []string{"regex:games", "games.example"},
}},
}
b := newBuilder(m)
opts := b.buildDNS()
if opts == nil {
t.Fatalf("buildDNS returned nil")
}
var found string
for _, w := range b.warnings {
if strings.Contains(w, "unrecognised prefix") {
found = w
}
}
if found == "" {
t.Fatalf("expected an unrecognised-prefix warning, got %v", b.warnings)
}
// device "<name>": ... so apply/warnings.go recovers Section=device, Name=kid
// (and classifies it `warning`, since device is not a protection section).
if !strings.HasPrefix(found, `device "kid": `) {
t.Fatalf("warning must name the device for panel attribution; got %q", found)
}
if !strings.Contains(found, "block entry") {
t.Fatalf("warning must say WHICH list; got %q", found)
}
// The good entry still blocks; the bad one is gone.
for _, r := range opts.Rules {
d := r.DefaultOptions
if d.DNSRuleAction.Action != C.RuleActionTypePredefined {
continue
}
for _, s := range append(append([]string{}, d.Domain...), append(d.DomainSuffix, d.DomainKeyword...)...) {
if strings.Contains(s, ":") {
t.Fatalf("bad-prefix entry leaked into a device matcher: %q", s)
}
}
}
}
// TestInlineDomainRuleMarkerVariantsStillClassify guards the non-degenerate forms
// so the emptiness filter did not break normal classification.
func TestInlineDomainRuleMarkerVariantsStillClassify(t *testing.T) {
hr, ok := inlineDomainRule([]string{".sub.example", "full:exact.example", "keyword:track", "bare.example"})
if !ok {
t.Fatalf("expected a rule")
}
if !contains(hr.DomainSuffix, "sub.example") || !contains(hr.DomainSuffix, "bare.example") {
t.Fatalf("suffix classification broken: %+v", hr.DomainSuffix)
}
if !contains(hr.Domain, "exact.example") {
t.Fatalf("full: classification broken: %+v", hr.Domain)
}
if !contains(hr.DomainKeyword, "track") {
t.Fatalf("keyword: classification broken: %+v", hr.DomainKeyword)
}
}
// TestDNSRuleDropsMarkerOnlyEntries: the same class in the routing dns_rule path
// (dns.go), where a BARE entry is an exact Domain rather than a suffix.
func TestDNSRuleDropsMarkerOnlyEntries(t *testing.T) {
r, ok := newBuilder(&model.Model{}).dnsRule(model.DNSRule{
MatchDomain: []string{".", "full:", "keyword:", "exact.example"},
Resolver: "cf",
})
if !ok {
t.Fatalf("a dns_rule with one usable entry must still be emitted")
}
raw := r.DefaultOptions.RawDefaultDNSRule
for _, s := range append(append([]string{}, raw.Domain...), append(raw.DomainSuffix, raw.DomainKeyword...)...) {
if strings.TrimSpace(s) == "" {
t.Fatalf("empty matcher token emitted; rule=%+v", raw)
}
}
if !contains(raw.Domain, "exact.example") {
t.Fatalf("bare entry must stay an EXACT domain in a dns_rule; got %+v", raw)
}
// Marker-only ONLY => no matcher at all => rule not emitted.
if _, ok := newBuilder(&model.Model{}).dnsRule(model.DNSRule{MatchDomain: []string{"keyword:", "."}, Resolver: "cf"}); ok {
t.Fatalf("a marker-only dns_rule must not be emitted (empty keyword matches everything)")
}
}
// TestBlocklistMarkerOnlyEntriesSkipped is the end-to-end shape of the same bug:
// a blocklist whose entries are all marker-only must be warned + skipped, while a
// healthy list in the same model still materialises.
func TestBlocklistMarkerOnlyEntriesSkipped(t *testing.T) {
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
m := &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "doh", Address: "https://1.1.1.1/dns-query", Detour: "direct"},
},
Blocklists: []model.Blocklist{
{Name: "broken", Enabled: true, Source: "inline", Entries: []string{".", "keyword:"}},
{Name: "ads", Enabled: true, Source: "inline", Entries: []string{"ads.example"}},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if _, ok := ruleSetByTag(opts.Route, "bl-broken"); ok {
t.Fatalf("a marker-only blocklist must not materialise a rule-set")
}
if !warnsHaveSub(warns, "no usable entries") {
t.Fatalf("expected a skip warning for the marker-only blocklist, got %v", warns)
}
if _, ok := ruleSetByTag(opts.Route, "bl-ads"); !ok {
t.Fatalf("the healthy blocklist must still materialise; route=%+v", opts.Route)
}
}
// TestRulesetIPCIDRBadEntrySkipped: an unparseable ip_cidr entry used to reach
// NewIPCIDRItem, which returns an error and aborts box.New — one typo in an
// ip_cidr list meant NO config loaded. It must now be warned + skipped while the
// valid entries on the same list survive.
func TestRulesetIPCIDRBadEntrySkipped(t *testing.T) {
m := &model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{
{Name: "cn", Type: "ipcidr", Source: "inline", Entries: []string{"10.0.0.0/8", "not-an-ip", "10.0.0.0/99", "203.0.113.7"}},
},
Rules: []model.Rule{
{Name: "r", Enabled: true, DstRuleset: []string{"cn"}, Target: "block"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
rs, ok := ruleSetByTag(opts.Route, "rs-cn")
if !ok {
t.Fatalf("rs-cn must still materialise from the valid entries; route=%+v", opts.Route)
}
got := rs.InlineOptions.Rules[0].DefaultOptions.IPCIDR
if contains(got, "not-an-ip") || contains(got, "10.0.0.0/99") {
t.Fatalf("unparseable ip_cidr entry leaked into the rule-set (aborts box.New): %+v", got)
}
if !contains(got, "10.0.0.0/8") || !contains(got, "203.0.113.7") {
t.Fatalf("valid entries (incl. a bare address) must survive: %+v", got)
}
if !warnsHaveSub(warns, "bad ip_cidr entry") {
t.Fatalf("expected a bad-entry warning, got %v", warns)
}
}
// TestRulesetIPCIDRAllBadSkipped: when NOTHING parses the ruleset is skipped
// entirely rather than emitting an empty rule-set (which box.New also rejects).
func TestRulesetIPCIDRAllBadSkipped(t *testing.T) {
m := &model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{
{Name: "cn", Type: "ipcidr", Source: "inline", Entries: []string{"nope", "also/nope"}},
},
Rules: []model.Rule{
{Name: "r", Enabled: true, DstRuleset: []string{"cn"}, Target: "block"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if _, ok := ruleSetByTag(opts.Route, "rs-cn"); ok {
t.Fatalf("an all-bad ipcidr ruleset must not materialise")
}
if !warnsHaveSub(warns, "no usable entries") {
t.Fatalf("expected a skip warning, got %v", warns)
}
}
// TestDuplicateBlocklistNameDeduped: two blocklists sharing a Name both wanted the
// tag bl-dup. The router hard-errors with "duplicate rule-set tag" and the WHOLE
// config fails to load, so the second must be dropped with a warning instead.
func TestDuplicateBlocklistNameDeduped(t *testing.T) {
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
m := &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "doh", Address: "https://1.1.1.1/dns-query", Detour: "direct"},
},
Blocklists: []model.Blocklist{
{Name: "dup", Enabled: true, Source: "inline", Entries: []string{"a.example"}},
{Name: "dup", Enabled: true, Source: "inline", Entries: []string{"b.example"}},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if n := countRuleSetsWithTag(opts.Route, "bl-dup"); n != 1 {
t.Fatalf("bl-dup must be emitted exactly once (a duplicate aborts box.New), got %d", n)
}
if !warnsHaveSub(warns, "duplicate rule-set tag") {
t.Fatalf("expected a duplicate-tag warning, got %v", warns)
}
}
// TestDuplicateGeoCategoryChipDeduped: the same geosite category chip added twice
// (and, for geoip, "RU"+"ru" which normalise to one tag) must not emit the tag
// twice — the same box.New-aborting duplicate, reachable from the panel's chip
// editor.
func TestDuplicateGeoCategoryChipDeduped(t *testing.T) {
m := &model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{
{Name: "geo", Source: "geosite", Categories: []string{"youtube", "youtube"}},
{Name: "cc", Source: "geoip", Categories: []string{"RU", "ru"}},
},
Rules: []model.Rule{
{Name: "a", Enabled: true, Order: 1, DstRuleset: []string{"geo"}, Target: "block"},
{Name: "b", Enabled: true, Order: 2, DstRuleset: []string{"cc"}, Target: "block"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if n := countRuleSetsWithTag(opts.Route, "rs-geo-youtube"); n != 1 {
t.Fatalf("rs-geo-youtube emitted %d times, want 1", n)
}
if n := countRuleSetsWithTag(opts.Route, "rs-cc-ru"); n != 1 {
t.Fatalf("rs-cc-ru emitted %d times, want 1 (RU and ru normalise to one tag)", n)
}
if !warnsHaveSub(warns, "duplicate rule-set tag") {
t.Fatalf("expected duplicate-tag warnings, got %v", warns)
}
if findRouteRuleWithRuleSet(opts.Route, "rs-geo-youtube") == nil {
t.Fatalf("the rule must still reference rs-geo-youtube")
}
}
// TestRuleSetTagsGloballyUnique is the invariant behind the two tests above: no
// tag may repeat across the merged Route.RuleSet, whatever the input.
func TestRuleSetTagsGloballyUnique(t *testing.T) {
g := model.DefaultGlobals()
g.DNSFilter = true
g.ResolverDefault = "cf"
m := &model.Model{
Globals: g,
Resolvers: []model.Resolver{
{Name: "cf", Type: "doh", Address: "https://1.1.1.1/dns-query", Detour: "direct"},
},
Rulesets: []model.Ruleset{
{Name: "x", Source: "inline", Entries: []string{"a.example"}},
{Name: "geo", Source: "geosite", Categories: []string{"ads", "ads", "ads"}},
},
Rules: []model.Rule{
{Name: "r1", Enabled: true, Order: 1, DstRuleset: []string{"x"}, Target: "block"},
{Name: "r2", Enabled: true, Order: 2, DstRuleset: []string{"geo"}, Target: "block"},
},
Blocklists: []model.Blocklist{
{Name: "l", Enabled: true, Source: "inline", Entries: []string{"b.example"}},
{Name: "l", Enabled: true, Source: "inline", Entries: []string{"c.example"}},
},
Allowlists: []model.Allowlist{
{Name: "l", Enabled: true, Source: "inline", Entries: []string{"d.example"}},
},
}
opts, _, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
seen := map[string]bool{}
for _, rs := range opts.Route.RuleSet {
if seen[rs.Tag] {
t.Fatalf("duplicate rule-set tag %q in Route.RuleSet — box.New would abort the whole config", rs.Tag)
}
seen[rs.Tag] = true
}
// bl-l and al-l use different prefixes, so both must survive.
if !seen["bl-l"] || !seen["al-l"] {
t.Fatalf("bl-/al- prefixes must not collide; tags=%v", seen)
}
}