Files
shater/shater/generate/route_unreachable_test.go
T
omarandClaude Opus 5 88a82c7297 feat(routing): report rules that can never fire (B1)
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>
2026-07-25 12:36:36 +03:00

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")
}
}