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>
2091 lines
77 KiB
Go
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)
|
|
}
|
|
}
|