A routing rule with no conditions at all is not matched in sequence — it
becomes the engine's route Final (generate/route.go buildRoute points Final
at it and moves on). Two consequences were invisible everywhere:
* two condition-less rules retire each other, and the LAST one by Order
wins, so an earlier "default -> direct" is dead while looking live;
* a condition-less rule can NEVER retire a rule that HAS conditions —
those are emitted ahead of Final whatever their Order.
A config in the field had two rules both named `default`, both with zero
conditions, order 20 -> direct and order 100 -> group:auto. One of the two
did nothing, the log was clean, and the panel drew both rows with the same
"default route · final" badge.
model.RuleReachability is the one implementation of the verdict, in the
stdlib-only leaf both consumers import, so the warning and the panel badge
cannot drift. generate.isCatchAll / effectiveRuleTarget / sortedRuleIndices
now delegate to it — three copies of "what is a default and what order do
rules run in" was how this would come back.
Scope is deliberately narrow: only condition-less over condition-less, which
is certain from the config. Whether one conditional rule's matchers subsume
another's is not decidable here, and a false "never fires" badge on a working
rule is worse than no badge.
Profiles are honoured: the analysis runs on the EFFECTIVE rules
(Model.EffectiveRules applies the active WAN profile's enable/disable), so a
rule the profile switched off is not blamed for retiring anything, and one it
switched on is. A SCHEDULED default never retires anything — outside its
window the rule above it is the default again — but can itself be retired by
an unscheduled one below it, which makes its schedule pure decoration.
Apply-time this reaches the operator through the existing status warnings,
graded by consequence rather than by "a setting is dead": critical when the
surviving default is `direct` while the retired one asked for a tunnel or a
block (the operator's default policy is not in effect and everything
unmatched leaves on the plain WAN); warning otherwise. The field config's own
shape — a dead `direct` under a live tunnel — is the warning case.
GET /api/rules/reachability serves the same verdict to the panel, the routing
analogue of the per-chain `used` flag on /api/groups/health. Keyed by index
into Rules, not by name: this config has two rules called `default`.
Diagnosis only — nothing is renamed, reordered, disabled or dropped, and
apply keeps working on a config that already has two defaults.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
6.8 KiB
Go
174 lines
6.8 KiB
Go
// B1: a routing rule that can never fire, reported instead of applied silently.
|
|
//
|
|
// The field config that prompted this had TWO rules named `default`, both with
|
|
// zero conditions — order 20 -> direct and order 100 -> group:auto. buildRoute
|
|
// points route Final at a condition-less rule and moves on, so the LAST one wins
|
|
// and the other is a dead setting. Nothing anywhere said so: the log was clean and
|
|
// the panel drew both rows identically.
|
|
//
|
|
// These tests pin the diagnosis AND the behaviour it describes, because a warning
|
|
// that disagrees with what the generator actually does is worse than none.
|
|
package generate
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/sagernet/sing-box/shater/model"
|
|
)
|
|
|
|
// warnAbout returns the warnings mentioning `rule "name"`.
|
|
func warnAbout(warns []string, name string) []string {
|
|
var out []string
|
|
for _, w := range warns {
|
|
if strings.Contains(w, `rule "`+name+`"`) {
|
|
out = append(out, w)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func warnsMatching(warns []string, substr string) []string {
|
|
var out []string
|
|
for _, w := range warns {
|
|
if strings.Contains(w, substr) {
|
|
out = append(out, w)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
const neverApplied = "is never applied"
|
|
|
|
// twoDefaultsModel reproduces the field config: two condition-less rules sharing
|
|
// the name `default`, differing only in Order and target.
|
|
func twoDefaultsModel(lowTarget, highTarget string) *model.Model {
|
|
g := model.DefaultGlobals()
|
|
g.KillSwitch = "closed"
|
|
return &model.Model{
|
|
Globals: g,
|
|
Nodes: []model.Node{{Name: "n1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.1:8388#n1"}},
|
|
Groups: []model.Group{{Name: "auto", Strategy: "leastping", Nodes: []string{"n1"}}},
|
|
Rules: []model.Rule{
|
|
{Name: "default", Enabled: true, Order: 20, Target: lowTarget},
|
|
{Name: "default", Enabled: true, Order: 100, Target: highTarget},
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestTwoCatchAllRulesWarnAndLastWins is the B1 regression. The order-100 rule is
|
|
// the default the engine uses (route Final), and the order-20 one is reported as
|
|
// never applied — naming the rule that supersedes it.
|
|
func TestTwoCatchAllRulesWarnAndLastWins(t *testing.T) {
|
|
opts, warns, err := GenerateWithWarnings(twoDefaultsModel("direct", "group:auto"))
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
if got := opts.Route.Final; got != "auto" {
|
|
t.Fatalf("route Final = %q, want the LAST catch-all's target %q", got, "auto")
|
|
}
|
|
got := warnsMatching(warns, neverApplied)
|
|
if len(got) != 1 {
|
|
t.Fatalf("want exactly one never-applied warning, got %d: %q", len(got), warns)
|
|
}
|
|
// It must name the superseding rule AND its order — with both rules called
|
|
// `default`, the order is the only thing that tells the two apart.
|
|
// Targets are quoted as the OPERATOR wrote them (`group:auto`), not as the
|
|
// engine tag they resolve to (`auto`) — the warning has to be readable next to
|
|
// the config, not next to the generated JSON.
|
|
for _, want := range []string{`rule "default"`, "order 100", `"group:auto"`, `"direct"`} {
|
|
if !strings.Contains(got[0], want) {
|
|
t.Fatalf("warning must mention %s, got: %s", want, got[0])
|
|
}
|
|
}
|
|
// Diagnosis only: neither rule is renamed, reordered or dropped.
|
|
if len(opts.Route.Rules) == 0 {
|
|
t.Fatal("route rules disappeared")
|
|
}
|
|
}
|
|
|
|
// TestTwoCatchAllsDirectWinsIsCritical: when the surviving default is `direct` and
|
|
// the retired one asked for a tunnel, everything unmatched leaves on the plain
|
|
// WAN. The warning must say so in the words apply/warnings.go grades critical —
|
|
// this is the case where a healthy-looking panel is a lie.
|
|
func TestTwoCatchAllsDirectWinsIsCritical(t *testing.T) {
|
|
_, warns, err := GenerateWithWarnings(twoDefaultsModel("group:auto", "direct"))
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
got := warnsMatching(warns, neverApplied)
|
|
if len(got) != 1 {
|
|
t.Fatalf("want exactly one never-applied warning, got %d: %q", len(got), warns)
|
|
}
|
|
const marker = "leaves over the plain WAN with your real IP address"
|
|
if !strings.Contains(got[0], marker) {
|
|
t.Fatalf("a retired tunnel default under a live direct default must carry %q, got: %s", marker, got[0])
|
|
}
|
|
}
|
|
|
|
// TestTwoCatchAllsTunnelWinsIsNotCritical: the field config's actual shape — the
|
|
// dead rule is `direct` and the live default is the tunnel. That is a dead
|
|
// setting, not a leak, so it must NOT carry the critical marker.
|
|
func TestTwoCatchAllsTunnelWinsIsNotCritical(t *testing.T) {
|
|
_, warns, err := GenerateWithWarnings(twoDefaultsModel("direct", "group:auto"))
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
for _, w := range warnsMatching(warns, neverApplied) {
|
|
if strings.Contains(w, "leaves over the plain WAN with your real IP address") {
|
|
t.Fatalf("a dead direct default under a live tunnel default is not a leak: %s", w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSingleCatchAllDoesNotWarn: the ordinary config — specific rules plus ONE
|
|
// default — must stay silent. A badge on a working rule teaches the operator to
|
|
// ignore the badge.
|
|
func TestSingleCatchAllDoesNotWarn(t *testing.T) {
|
|
g := model.DefaultGlobals()
|
|
g.KillSwitch = "closed"
|
|
m := &model.Model{
|
|
Globals: g,
|
|
Nodes: []model.Node{{Name: "n1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.1:8388#n1"}},
|
|
Groups: []model.Group{{Name: "auto", Strategy: "leastping", Nodes: []string{"n1"}}},
|
|
Rules: []model.Rule{
|
|
{Name: "ads", Enabled: true, Order: 10, DstPort: "443", Target: "block"},
|
|
// A specific rule ordered BELOW the default: still emitted ahead of Final,
|
|
// so it is not retired either.
|
|
{Name: "default", Enabled: true, Order: 20, Target: "group:auto"},
|
|
{Name: "late", Enabled: true, Order: 900, DstPort: "8080", Target: "direct"},
|
|
},
|
|
}
|
|
_, warns, err := GenerateWithWarnings(m)
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
if got := warnsMatching(warns, neverApplied); len(got) != 0 {
|
|
t.Fatalf("a config with one default must not report anything never-applied, got: %q", got)
|
|
}
|
|
if got := warnAbout(warns, "late"); len(got) != 0 {
|
|
t.Fatalf("a specific rule below the default is not shadowed by it, got: %q", got)
|
|
}
|
|
}
|
|
|
|
// TestProfileDisabledCatchAllDoesNotShadow: the active profile switches the later
|
|
// default off, so the earlier one is the live default and must not be badged.
|
|
// Judging the raw config would blame the wrong rule on every profile router.
|
|
func TestProfileDisabledCatchAllDoesNotShadow(t *testing.T) {
|
|
m := twoDefaultsModel("group:auto", "direct")
|
|
m.Rules[1].Name = "fallback" // profiles address rules by name
|
|
m.Globals.ActiveProfile = "home"
|
|
m.Profiles = []model.Profile{{Name: "home", Enabled: true, DisableRules: []string{"fallback"}}}
|
|
|
|
opts, warns, err := GenerateWithWarnings(m)
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
if got := warnsMatching(warns, neverApplied); len(got) != 0 {
|
|
t.Fatalf("a profile-disabled default shadows nothing, got: %q", got)
|
|
}
|
|
if got := opts.Route.Final; got != "auto" {
|
|
t.Fatalf("route Final = %q, want the surviving default's target %q", got, "auto")
|
|
}
|
|
}
|