209 lines
7.1 KiB
Go
209 lines
7.1 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"sing-vis/internal/dnsx"
|
|
)
|
|
|
|
// fakeResolver returns canned DoH answers so route/DNS matching is testable
|
|
// offline (no network). Unknown names resolve to no addresses.
|
|
type fakeResolver struct {
|
|
answers map[string]*dnsx.Result
|
|
}
|
|
|
|
func (f *fakeResolver) Server() string { return "fake-doh" }
|
|
|
|
func (f *fakeResolver) Resolve(_ context.Context, name string, _ string) (*dnsx.Result, error) {
|
|
if r, ok := f.answers[name]; ok {
|
|
return r, nil
|
|
}
|
|
return &dnsx.Result{Name: name}, nil
|
|
}
|
|
|
|
func analyzeOne(t *testing.T, cfg, input string, assumeResolved bool, res dnsx.Resolver) InputTrace {
|
|
t.Helper()
|
|
out, err := Analyze(context.Background(), Request{
|
|
Config: cfg,
|
|
Inputs: []string{input},
|
|
AssumeResolved: assumeResolved,
|
|
Resolver: res,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Analyze(%q) error: %v", input, err)
|
|
}
|
|
if len(out.Inputs) != 1 {
|
|
t.Fatalf("Analyze(%q): expected 1 input trace, got %d", input, len(out.Inputs))
|
|
}
|
|
return out.Inputs[0]
|
|
}
|
|
|
|
func routeOutbound(t *testing.T, it InputTrace) *RouteDecision {
|
|
t.Helper()
|
|
if it.Route == nil || it.Route.Decision == nil {
|
|
t.Fatalf("input %q: missing route decision", it.Input)
|
|
}
|
|
return it.Route.Decision
|
|
}
|
|
|
|
func TestDomainSuffix(t *testing.T) {
|
|
cfg := `{"route":{"rules":[{"domain_suffix":["google.com"],"outbound":"proxy"}],"final":"direct"}}`
|
|
|
|
it := analyzeOne(t, cfg, "www.google.com", true, &fakeResolver{})
|
|
d := routeOutbound(t, it)
|
|
if d.Outbound != "proxy" || d.FromFinal {
|
|
t.Errorf("www.google.com: got outbound=%q fromFinal=%v, want proxy/false", d.Outbound, d.FromFinal)
|
|
}
|
|
if it.Route.SelectedIndex != 0 {
|
|
t.Errorf("www.google.com: selectedIndex=%d, want 0", it.Route.SelectedIndex)
|
|
}
|
|
|
|
it = analyzeOne(t, cfg, "example.org", true, &fakeResolver{})
|
|
d = routeOutbound(t, it)
|
|
if d.Outbound != "direct" || !d.FromFinal {
|
|
t.Errorf("example.org: got outbound=%q fromFinal=%v, want direct/true", d.Outbound, d.FromFinal)
|
|
}
|
|
}
|
|
|
|
func TestInlineRuleSet(t *testing.T) {
|
|
cfg := `{"route":{
|
|
"rules":[{"rule_set":["cn"],"outbound":"direct"}],
|
|
"rule_set":[{"type":"inline","tag":"cn","rules":[{"domain_suffix":["baidu.com"]}]}],
|
|
"final":"proxy"}}`
|
|
|
|
it := analyzeOne(t, cfg, "www.baidu.com", true, &fakeResolver{})
|
|
d := routeOutbound(t, it)
|
|
if d.Outbound != "direct" {
|
|
t.Errorf("www.baidu.com: outbound=%q, want direct", d.Outbound)
|
|
}
|
|
// The rule_set condition should report a match with a non-negative matched idx.
|
|
step := it.Route.Steps[0]
|
|
if step.Status != StatusMatch {
|
|
t.Errorf("rule_set step status=%q, want match", step.Status)
|
|
}
|
|
var found bool
|
|
for _, c := range step.Conditions {
|
|
if c.Field == "rule_set" && c.RuleSet != nil {
|
|
found = true
|
|
if c.RuleSet.Status != StatusMatch || c.RuleSet.MatchedIdx != 0 {
|
|
t.Errorf("rule_set eval: status=%q matchedIdx=%d, want match/0", c.RuleSet.Status, c.RuleSet.MatchedIdx)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("no rule_set condition found in step")
|
|
}
|
|
|
|
it = analyzeOne(t, cfg, "www.google.com", true, &fakeResolver{})
|
|
if d := routeOutbound(t, it); d.Outbound != "proxy" || !d.FromFinal {
|
|
t.Errorf("www.google.com: outbound=%q fromFinal=%v, want proxy/true", d.Outbound, d.FromFinal)
|
|
}
|
|
}
|
|
|
|
func TestLogicalInvert(t *testing.T) {
|
|
// Inverted logical rule: matches everything EXCEPT *.google.com.
|
|
cfg := `{"route":{"rules":[
|
|
{"type":"logical","mode":"and","invert":true,
|
|
"rules":[{"domain_suffix":["google.com"]}],"outbound":"not-google"}
|
|
],"final":"proxy"}}`
|
|
|
|
it := analyzeOne(t, cfg, "example.com", true, &fakeResolver{})
|
|
if d := routeOutbound(t, it); d.Outbound != "not-google" {
|
|
t.Errorf("example.com: outbound=%q, want not-google (inverted match)", d.Outbound)
|
|
}
|
|
|
|
it = analyzeOne(t, cfg, "www.google.com", true, &fakeResolver{})
|
|
if d := routeOutbound(t, it); d.Outbound != "proxy" || !d.FromFinal {
|
|
t.Errorf("www.google.com: outbound=%q fromFinal=%v, want proxy/true (inverted no-match)", d.Outbound, d.FromFinal)
|
|
}
|
|
}
|
|
|
|
func TestResolveThenIPCIDR(t *testing.T) {
|
|
cfg := `{"route":{"rules":[
|
|
{"domain_suffix":["example.com"],"action":"resolve"},
|
|
{"ip_cidr":["1.2.3.0/24"],"outbound":"matched-ip"}
|
|
],"final":"proxy"}}`
|
|
res := &fakeResolver{answers: map[string]*dnsx.Result{
|
|
"host.example.com": {Name: "host.example.com", IPv4: []string{"1.2.3.4"}},
|
|
}}
|
|
|
|
// AssumeResolved=false: ip_cidr only matches after the explicit resolve action.
|
|
it := analyzeOne(t, cfg, "host.example.com", false, res)
|
|
if d := routeOutbound(t, it); d.Outbound != "matched-ip" {
|
|
t.Errorf("host.example.com: outbound=%q, want matched-ip", d.Outbound)
|
|
}
|
|
// The resolve step should carry an effect line mentioning the resolved IP.
|
|
if it.Route.Steps[0].Effect == "" {
|
|
t.Error("resolve step missing effect line")
|
|
}
|
|
|
|
// A domain that resolves outside the CIDR falls through to final.
|
|
res2 := &fakeResolver{answers: map[string]*dnsx.Result{
|
|
"other.example.com": {Name: "other.example.com", IPv4: []string{"9.9.9.9"}},
|
|
}}
|
|
it = analyzeOne(t, cfg, "other.example.com", false, res2)
|
|
if d := routeOutbound(t, it); d.Outbound != "proxy" || !d.FromFinal {
|
|
t.Errorf("other.example.com: outbound=%q fromFinal=%v, want proxy/true", d.Outbound, d.FromFinal)
|
|
}
|
|
}
|
|
|
|
func TestDNSServerSelection(t *testing.T) {
|
|
cfg := `{"dns":{
|
|
"servers":[
|
|
{"tag":"proxy-dns","type":"https","server":"1.1.1.1","detour":"proxy"},
|
|
{"tag":"local-dns","type":"udp","server":"223.5.5.5","detour":"direct"}
|
|
],
|
|
"rules":[{"domain_suffix":["cn.example"],"server":"local-dns"}],
|
|
"final":"proxy-dns"}}`
|
|
|
|
it := analyzeOne(t, cfg, "site.cn.example", true, &fakeResolver{})
|
|
if it.DNS == nil || it.DNS.Decision == nil {
|
|
t.Fatal("missing DNS decision")
|
|
}
|
|
d := it.DNS.Decision
|
|
if d.Server != "local-dns" || d.FromFinal {
|
|
t.Errorf("site.cn.example: dns server=%q fromFinal=%v, want local-dns/false", d.Server, d.FromFinal)
|
|
}
|
|
if d.ServerInfo == nil || d.ServerInfo.Detour != "direct" {
|
|
t.Errorf("site.cn.example: dns detour=%v, want direct", d.ServerInfo)
|
|
}
|
|
|
|
it = analyzeOne(t, cfg, "other.example", true, &fakeResolver{})
|
|
d = it.DNS.Decision
|
|
if d.Server != "proxy-dns" || !d.FromFinal {
|
|
t.Errorf("other.example: dns server=%q fromFinal=%v, want proxy-dns/true", d.Server, d.FromFinal)
|
|
}
|
|
if d.ServerInfo == nil || d.ServerInfo.Detour != "proxy" {
|
|
t.Errorf("other.example: dns detour=%v, want proxy", d.ServerInfo)
|
|
}
|
|
}
|
|
|
|
func TestRawIPInput(t *testing.T) {
|
|
cfg := `{"route":{"rules":[{"ip_cidr":["10.0.0.0/8"],"outbound":"lan"}],"final":"wan"}}`
|
|
|
|
it := analyzeOne(t, cfg, "10.1.2.3", true, &fakeResolver{})
|
|
if it.Kind != "ip" {
|
|
t.Errorf("10.1.2.3: kind=%q, want ip", it.Kind)
|
|
}
|
|
if it.DNS != nil {
|
|
t.Error("raw IP should have no DNS trace")
|
|
}
|
|
if d := routeOutbound(t, it); d.Outbound != "lan" {
|
|
t.Errorf("10.1.2.3: outbound=%q, want lan", d.Outbound)
|
|
}
|
|
|
|
it = analyzeOne(t, cfg, "8.8.8.8", true, &fakeResolver{})
|
|
if d := routeOutbound(t, it); d.Outbound != "wan" || !d.FromFinal {
|
|
t.Errorf("8.8.8.8: outbound=%q fromFinal=%v, want wan/true", d.Outbound, d.FromFinal)
|
|
}
|
|
}
|
|
|
|
func TestInvalidInput(t *testing.T) {
|
|
cfg := `{"route":{"rules":[],"final":"proxy"}}`
|
|
it := analyzeOne(t, cfg, "not a valid host!!", true, &fakeResolver{})
|
|
if it.Kind != "invalid" {
|
|
t.Errorf("kind=%q, want invalid", it.Kind)
|
|
}
|
|
}
|