init
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
// Package dnsx provides a DNS-over-HTTPS resolver used to resolve domains to IP
|
||||
// addresses when route rules depend on the resolved IP (ip_cidr, IP rule sets)
|
||||
// or when a rule's action is "resolve".
|
||||
//
|
||||
// It uses the DoH JSON API (https://developers.google.com/speed/public-dns/docs/doh/json,
|
||||
// also implemented by Cloudflare) rather than the RFC 8484 wireformat. In a
|
||||
// browser this matters: a JSON GET with `Accept: application/dns-json` is a CORS
|
||||
// "simple request", so it avoids the preflight that a wireformat POST with a
|
||||
// custom Content-Type would trigger, and it needs no DNS message packer.
|
||||
package dnsx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Resolver resolves a hostname to A/AAAA records.
|
||||
type Resolver interface {
|
||||
// Resolve returns resolved addresses for name. strategy is one of
|
||||
// "", "prefer_ipv4", "prefer_ipv6", "ipv4_only", "ipv6_only".
|
||||
Resolve(ctx context.Context, name string, strategy string) (*Result, error)
|
||||
Server() string
|
||||
}
|
||||
|
||||
// Result holds resolved addresses and diagnostic info about the query.
|
||||
type Result struct {
|
||||
Name string `json:"name"`
|
||||
IPv4 []string `json:"ipv4"`
|
||||
IPv6 []string `json:"ipv6"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// All returns v4+v6 addresses honoring the strategy ordering.
|
||||
func (r *Result) All(strategy string) []string {
|
||||
switch strategy {
|
||||
case "ipv4_only":
|
||||
return r.IPv4
|
||||
case "ipv6_only":
|
||||
return r.IPv6
|
||||
case "prefer_ipv6":
|
||||
return append(append([]string{}, r.IPv6...), r.IPv4...)
|
||||
default: // prefer_ipv4 / unset
|
||||
return append(append([]string{}, r.IPv4...), r.IPv6...)
|
||||
}
|
||||
}
|
||||
|
||||
// DoHResolver implements Resolver against a DoH JSON endpoint.
|
||||
type DoHResolver struct {
|
||||
server string
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]*Result
|
||||
}
|
||||
|
||||
// NewDoHResolver builds a resolver for the given DoH endpoint URL.
|
||||
func NewDoHResolver(server string) *DoHResolver {
|
||||
return &DoHResolver{
|
||||
server: server,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
cache: map[string]*Result{},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DoHResolver) Server() string { return d.server }
|
||||
|
||||
// DNS record types used by the JSON API.
|
||||
const (
|
||||
typeA = 1
|
||||
typeAAAA = 28
|
||||
)
|
||||
|
||||
// Resolve queries A and AAAA records for name over DoH JSON, caching per resolver.
|
||||
func (d *DoHResolver) Resolve(ctx context.Context, name string, strategy string) (*Result, error) {
|
||||
name = strings.TrimSuffix(strings.ToLower(name), ".")
|
||||
d.mu.Lock()
|
||||
if r, ok := d.cache[name]; ok {
|
||||
d.mu.Unlock()
|
||||
return r, nil
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
res := &Result{Name: name}
|
||||
var firstErr error
|
||||
|
||||
if strategy != "ipv6_only" {
|
||||
v4, err := d.query(ctx, name, typeA)
|
||||
if err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
res.IPv4 = v4
|
||||
}
|
||||
if strategy != "ipv4_only" {
|
||||
v6, err := d.query(ctx, name, typeAAAA)
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
res.IPv6 = v6
|
||||
}
|
||||
|
||||
if len(res.IPv4) == 0 && len(res.IPv6) == 0 && firstErr != nil {
|
||||
res.Error = firstErr.Error()
|
||||
return res, firstErr
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.cache[name] = res
|
||||
d.mu.Unlock()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// jsonResponse is the DoH JSON API response shape (Google/Cloudflare).
|
||||
type jsonResponse struct {
|
||||
Status int `json:"Status"`
|
||||
Answer []struct {
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
Data string `json:"data"`
|
||||
} `json:"Answer"`
|
||||
Comment string `json:"Comment,omitempty"`
|
||||
}
|
||||
|
||||
func (d *DoHResolver) query(ctx context.Context, name string, qtype int) ([]string, error) {
|
||||
endpoint, err := url.Parse(d.server)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid DoH server %q: %w", d.server, err)
|
||||
}
|
||||
q := endpoint.Query()
|
||||
q.Set("name", name)
|
||||
q.Set("type", fmt.Sprint(qtype))
|
||||
q.Set("ct", "application/dns-json")
|
||||
endpoint.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Accept is a CORS-safelisted header, so this stays a simple request.
|
||||
req.Header.Set("Accept", "application/dns-json")
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("DoH status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var parsed jsonResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("parse DoH JSON: %w", err)
|
||||
}
|
||||
var out []string
|
||||
for _, a := range parsed.Answer {
|
||||
if a.Type == qtype {
|
||||
out = append(out, a.Data)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func analyze(ctx context.Context, req Request) (*Result, error) {
|
||||
cfg, err := ParseConfig(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := &Result{Warnings: append([]string{}, cfg.Warnings...)}
|
||||
if req.Resolver != nil {
|
||||
res.DoHServer = req.Resolver.Server()
|
||||
}
|
||||
rs := newRuleSetResolver(ctx, cfg, req.RuleSetFiles, &res.Warnings)
|
||||
|
||||
for _, raw := range req.Inputs {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
res.Inputs = append(res.Inputs, analyzeInput(ctx, cfg, rs, req, line))
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func analyzeInput(ctx context.Context, cfg *Config, rs *ruleSetResolver, req Request, line string) InputTrace {
|
||||
it := InputTrace{Input: line}
|
||||
host := normalizeInput(line)
|
||||
ec := &evalCtx{ctx: ctx, network: req.Network, rs: rs, resolver: req.Resolver}
|
||||
|
||||
if addr, ok := parseIPInput(host); ok {
|
||||
it.Kind = "ip"
|
||||
ec.destIsIP = true
|
||||
ec.destAddr = addr
|
||||
it.Route = ec.matchRoute(cfg)
|
||||
return it
|
||||
}
|
||||
|
||||
if !looksLikeDomain(host) {
|
||||
it.Kind = "invalid"
|
||||
it.Error = "not a valid domain or IP address"
|
||||
return it
|
||||
}
|
||||
|
||||
it.Kind = "domain"
|
||||
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||
ec.host = host
|
||||
|
||||
// Resolve via DoH for display and (optionally) to let IP rules match.
|
||||
if req.Resolver != nil {
|
||||
r, _ := req.Resolver.Resolve(ctx, host, "")
|
||||
if r != nil {
|
||||
it.Resolved = &ResolvedInfo{Server: req.Resolver.Server(), IPv4: r.IPv4, IPv6: r.IPv6, Error: r.Error}
|
||||
ec.resolved = it.Resolved
|
||||
if req.AssumeResolved {
|
||||
if addrs := parseAddrs(r.All("")); len(addrs) > 0 {
|
||||
ec.setAddresses(addrs)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
it.Resolved = &ResolvedInfo{Server: req.Resolver.Server(), Error: "resolution failed"}
|
||||
}
|
||||
}
|
||||
|
||||
it.DNS = ec.matchDNS(cfg)
|
||||
it.Route = ec.matchRoute(cfg)
|
||||
return it
|
||||
}
|
||||
|
||||
// normalizeInput strips scheme, path, userinfo and a trailing :port so pasted
|
||||
// URLs or host:port strings still analyze correctly.
|
||||
func normalizeInput(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
if i := strings.Index(s, "://"); i >= 0 {
|
||||
s = s[i+3:]
|
||||
}
|
||||
if i := strings.IndexByte(s, '/'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if i := strings.LastIndexByte(s, '@'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
// Strip a trailing :port for domains / IPv4 (but not raw IPv6 which has many colons).
|
||||
if strings.Count(s, ":") == 1 {
|
||||
host, port, ok := strings.Cut(s, ":")
|
||||
if ok && isAllDigits(port) && host != "" {
|
||||
s = host
|
||||
}
|
||||
}
|
||||
// Strip [ ] around bracketed IPv6.
|
||||
s = strings.TrimPrefix(s, "[")
|
||||
s = strings.TrimSuffix(s, "]")
|
||||
return s
|
||||
}
|
||||
|
||||
func parseIPInput(s string) (netip.Addr, bool) {
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
return addr, true
|
||||
}
|
||||
|
||||
func looksLikeDomain(s string) bool {
|
||||
if s == "" || len(s) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '-' || r == '_' || r == '*') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isAllDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/domain"
|
||||
"go4.org/netipx"
|
||||
|
||||
"sing-vis/internal/dnsx"
|
||||
)
|
||||
|
||||
// Condition groups (see docs/configuration/route/rule.md matching formula).
|
||||
const (
|
||||
groupDestAddr = "dest_addr"
|
||||
groupSrcAddr = "src_addr"
|
||||
groupDestPort = "dest_port"
|
||||
groupSrcPort = "src_port"
|
||||
groupRuleSet = "rule_set"
|
||||
groupOther = "other"
|
||||
)
|
||||
|
||||
// evalCtx carries the per-input matching state.
|
||||
//
|
||||
// It intentionally does NOT use sing-box's adapter.InboundContext: importing the
|
||||
// adapter package pulls in the full outbound/dialer/sing-tun dependency tree,
|
||||
// which does not compile for GOARCH=wasm. The only rule fields that depend on an
|
||||
// InboundContext are domain / network / query_type, and those are matched here
|
||||
// directly (domain via sing/common/domain, the source of truth reused verbatim
|
||||
// by route/rule.DomainItem; network / query_type are plain membership tests).
|
||||
type evalCtx struct {
|
||||
ctx context.Context
|
||||
network string // assumed connection network ("", "tcp", "udp")
|
||||
queryType uint16 // DNS query type in effect (0 = unknown)
|
||||
host string // lowercased domain (empty for IP input)
|
||||
destIsIP bool
|
||||
destAddr netip.Addr
|
||||
destResolved bool // addresses populated (via resolve/assume)
|
||||
addresses []netip.Addr // resolved/assumed destination addresses
|
||||
rs *ruleSetResolver
|
||||
resolver dnsx.Resolver
|
||||
resolved *ResolvedInfo // cached DoH result for the host
|
||||
}
|
||||
|
||||
// setAddresses records resolved destination addresses so IP-based conditions
|
||||
// (ip_cidr, ip rule sets) can match them.
|
||||
func (ec *evalCtx) setAddresses(addrs []netip.Addr) {
|
||||
ec.addresses = addrs
|
||||
ec.destResolved = true
|
||||
}
|
||||
|
||||
// matchFields is the normalized, matchable subset shared by route, DNS and
|
||||
// headless rules.
|
||||
type matchFields struct {
|
||||
domain []string
|
||||
domainSuffix []string
|
||||
domainKeyword []string
|
||||
domainRegex []string
|
||||
ipCIDR []string
|
||||
ipIsPrivate bool
|
||||
srcIPCIDR []string
|
||||
srcIPIsPriv bool
|
||||
port []uint16
|
||||
portRange []string
|
||||
srcPort []uint16
|
||||
srcPortRange []string
|
||||
network []string
|
||||
queryType []option.DNSQueryType
|
||||
ruleSet []string
|
||||
rsMatchSource bool
|
||||
invert bool
|
||||
|
||||
// Pre-compiled matchers from binary (.srs) rule sets.
|
||||
rawDomain *domain.Matcher
|
||||
rawIPSet *netipx.IPSet
|
||||
|
||||
unknowns []condKV // fields we cannot evaluate offline (assumptions)
|
||||
dnsFilter []condKV // DNS response-address filters (not applicable to query routing)
|
||||
}
|
||||
|
||||
type condKV struct{ field, value string }
|
||||
|
||||
// orStatus combines OR-group members.
|
||||
func orStatus(members []string) string {
|
||||
unknown := false
|
||||
for _, s := range members {
|
||||
if s == StatusMatch {
|
||||
return StatusMatch
|
||||
}
|
||||
if s == StatusUnknown {
|
||||
unknown = true
|
||||
}
|
||||
}
|
||||
if unknown {
|
||||
return StatusUnknown
|
||||
}
|
||||
return StatusNoMatch
|
||||
}
|
||||
|
||||
// andStatus combines AND members.
|
||||
func andStatus(members []string) string {
|
||||
unknown := false
|
||||
for _, s := range members {
|
||||
if s == StatusNoMatch {
|
||||
return StatusNoMatch
|
||||
}
|
||||
if s == StatusUnknown {
|
||||
unknown = true
|
||||
}
|
||||
}
|
||||
if unknown {
|
||||
return StatusUnknown
|
||||
}
|
||||
return StatusMatch
|
||||
}
|
||||
|
||||
func invertStatus(s string) string {
|
||||
switch s {
|
||||
case StatusMatch:
|
||||
return StatusNoMatch
|
||||
case StatusNoMatch:
|
||||
return StatusMatch
|
||||
default:
|
||||
return StatusUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// evalFields evaluates a normalized rule against the context, producing an
|
||||
// overall tri-state status and the per-condition breakdown. An empty rule
|
||||
// (no conditions) matches everything.
|
||||
func (ec *evalCtx) evalFields(mf matchFields) (string, []CondEval) {
|
||||
var conds []CondEval
|
||||
var groupStatuses []string // AND across non-empty groups + other conds
|
||||
|
||||
// --- destination address group (OR) ---
|
||||
var da []string
|
||||
if len(mf.domain) > 0 {
|
||||
st, matched := ec.matchDomainExact(mf.domain)
|
||||
conds = append(conds, CondEval{Field: "domain", Value: joinVals(mf.domain), Group: groupDestAddr, Status: st, Matched: matched})
|
||||
da = append(da, st)
|
||||
}
|
||||
if len(mf.domainSuffix) > 0 {
|
||||
st, matched := ec.matchDomainSuffix(mf.domainSuffix)
|
||||
conds = append(conds, CondEval{Field: "domain_suffix", Value: joinVals(mf.domainSuffix), Group: groupDestAddr, Status: st, Matched: matched})
|
||||
da = append(da, st)
|
||||
}
|
||||
if len(mf.domainKeyword) > 0 {
|
||||
st, matched := ec.matchKeyword(mf.domainKeyword)
|
||||
conds = append(conds, CondEval{Field: "domain_keyword", Value: joinVals(mf.domainKeyword), Group: groupDestAddr, Status: st, Matched: matched})
|
||||
da = append(da, st)
|
||||
}
|
||||
if len(mf.domainRegex) > 0 {
|
||||
st, matched := ec.matchRegex(mf.domainRegex)
|
||||
conds = append(conds, CondEval{Field: "domain_regex", Value: joinVals(mf.domainRegex), Group: groupDestAddr, Status: st, Matched: matched})
|
||||
da = append(da, st)
|
||||
}
|
||||
if mf.rawDomain != nil {
|
||||
st := StatusNoMatch
|
||||
if ec.host != "" && mf.rawDomain.Match(ec.host) {
|
||||
st = StatusMatch
|
||||
}
|
||||
conds = append(conds, CondEval{Field: "domain/domain_suffix", Value: "«compiled set»", Group: groupDestAddr, Status: st})
|
||||
da = append(da, st)
|
||||
}
|
||||
if len(mf.ipCIDR) > 0 {
|
||||
st, matched, note := ec.matchIPCIDR(mf.ipCIDR, false)
|
||||
conds = append(conds, CondEval{Field: "ip_cidr", Value: joinVals(mf.ipCIDR), Group: groupDestAddr, Status: st, Matched: matched, Note: note})
|
||||
da = append(da, st)
|
||||
}
|
||||
if mf.rawIPSet != nil {
|
||||
st, note := ec.matchRawIPSet(mf.rawIPSet)
|
||||
conds = append(conds, CondEval{Field: "ip_cidr", Value: "«compiled set»", Group: groupDestAddr, Status: st, Note: note})
|
||||
da = append(da, st)
|
||||
}
|
||||
if mf.ipIsPrivate {
|
||||
st, note := ec.matchIPIsPrivate(false)
|
||||
conds = append(conds, CondEval{Field: "ip_is_private", Value: "true", Group: groupDestAddr, Status: st, Note: note})
|
||||
da = append(da, st)
|
||||
}
|
||||
if len(da) > 0 {
|
||||
groupStatuses = append(groupStatuses, orStatus(da))
|
||||
}
|
||||
|
||||
// --- source address group (OR) — source is unknown offline ---
|
||||
var sa []string
|
||||
if len(mf.srcIPCIDR) > 0 {
|
||||
conds = append(conds, CondEval{Field: "source_ip_cidr", Value: joinVals(mf.srcIPCIDR), Group: groupSrcAddr, Status: StatusUnknown, Note: "client source address is unknown"})
|
||||
sa = append(sa, StatusUnknown)
|
||||
}
|
||||
if mf.srcIPIsPriv {
|
||||
conds = append(conds, CondEval{Field: "source_ip_is_private", Value: "true", Group: groupSrcAddr, Status: StatusUnknown, Note: "client source address is unknown"})
|
||||
sa = append(sa, StatusUnknown)
|
||||
}
|
||||
if len(sa) > 0 {
|
||||
groupStatuses = append(groupStatuses, orStatus(sa))
|
||||
}
|
||||
|
||||
// --- destination port group (OR) — port is unknown for a bare domain/IP ---
|
||||
var dp []string
|
||||
if len(mf.port) > 0 {
|
||||
conds = append(conds, CondEval{Field: "port", Value: joinU16(mf.port), Group: groupDestPort, Status: StatusUnknown, Note: "destination port is not part of the query"})
|
||||
dp = append(dp, StatusUnknown)
|
||||
}
|
||||
if len(mf.portRange) > 0 {
|
||||
conds = append(conds, CondEval{Field: "port_range", Value: joinVals(mf.portRange), Group: groupDestPort, Status: StatusUnknown, Note: "destination port is not part of the query"})
|
||||
dp = append(dp, StatusUnknown)
|
||||
}
|
||||
if len(dp) > 0 {
|
||||
groupStatuses = append(groupStatuses, orStatus(dp))
|
||||
}
|
||||
|
||||
// --- source port group (OR) — unknown ---
|
||||
var sp []string
|
||||
if len(mf.srcPort) > 0 {
|
||||
conds = append(conds, CondEval{Field: "source_port", Value: joinU16(mf.srcPort), Group: groupSrcPort, Status: StatusUnknown, Note: "client source port is unknown"})
|
||||
sp = append(sp, StatusUnknown)
|
||||
}
|
||||
if len(mf.srcPortRange) > 0 {
|
||||
conds = append(conds, CondEval{Field: "source_port_range", Value: joinVals(mf.srcPortRange), Group: groupSrcPort, Status: StatusUnknown, Note: "client source port is unknown"})
|
||||
sp = append(sp, StatusUnknown)
|
||||
}
|
||||
if len(sp) > 0 {
|
||||
groupStatuses = append(groupStatuses, orStatus(sp))
|
||||
}
|
||||
|
||||
// --- rule_set group (OR across tags) ---
|
||||
if len(mf.ruleSet) > 0 {
|
||||
var rsStatuses []string
|
||||
for _, tag := range mf.ruleSet {
|
||||
rse := ec.rs.evaluate(tag, ec, mf.rsMatchSource)
|
||||
conds = append(conds, CondEval{Field: "rule_set", Value: tag, Group: groupRuleSet, Status: rse.Status, RuleSet: rse})
|
||||
rsStatuses = append(rsStatuses, rse.Status)
|
||||
}
|
||||
groupStatuses = append(groupStatuses, orStatus(rsStatuses))
|
||||
}
|
||||
|
||||
// --- "other" fields (AND) ---
|
||||
if len(mf.network) > 0 {
|
||||
st := ec.matchNetwork(mf.network)
|
||||
note := ""
|
||||
if st == StatusUnknown {
|
||||
note = "connection network (tcp/udp) not specified"
|
||||
}
|
||||
conds = append(conds, CondEval{Field: "network", Value: joinVals(mf.network), Group: groupOther, Status: st, Note: note})
|
||||
groupStatuses = append(groupStatuses, st)
|
||||
}
|
||||
if len(mf.queryType) > 0 {
|
||||
st, matched := ec.matchQueryType(mf.queryType)
|
||||
conds = append(conds, CondEval{Field: "query_type", Value: queryTypeList(mf.queryType), Group: groupOther, Status: st, Matched: matched, Note: "evaluated for the DNS query type shown"})
|
||||
groupStatuses = append(groupStatuses, st)
|
||||
}
|
||||
// DNS response-address filters: not applicable to query-routing.
|
||||
for _, kv := range mf.dnsFilter {
|
||||
conds = append(conds, CondEval{Field: kv.field, Value: kv.value, Group: groupOther, Status: StatusUnknown, Note: "matches the DNS response addresses, evaluated after resolution"})
|
||||
groupStatuses = append(groupStatuses, StatusUnknown)
|
||||
}
|
||||
// Unknown/undeterminable fields (protocol, process, clash_mode, ...).
|
||||
for _, kv := range mf.unknowns {
|
||||
conds = append(conds, CondEval{Field: kv.field, Value: kv.value, Group: groupOther, Status: StatusUnknown, Note: "cannot be determined offline"})
|
||||
groupStatuses = append(groupStatuses, StatusUnknown)
|
||||
}
|
||||
|
||||
status := StatusMatch
|
||||
if len(groupStatuses) > 0 {
|
||||
status = andStatus(groupStatuses)
|
||||
}
|
||||
if mf.invert {
|
||||
status = invertStatus(status)
|
||||
}
|
||||
return status, conds
|
||||
}
|
||||
|
||||
// ---- individual matchers (reusing sing-box primitives where useful) ----
|
||||
|
||||
func (ec *evalCtx) matchDomainExact(domains []string) (string, string) {
|
||||
if ec.host == "" {
|
||||
return StatusNoMatch, ""
|
||||
}
|
||||
if domainMatcher(domains, nil).Match(ec.host) {
|
||||
for _, d := range domains {
|
||||
if strings.EqualFold(strings.TrimSuffix(d, "."), ec.host) {
|
||||
return StatusMatch, d
|
||||
}
|
||||
}
|
||||
return StatusMatch, ""
|
||||
}
|
||||
return StatusNoMatch, ""
|
||||
}
|
||||
|
||||
func (ec *evalCtx) matchDomainSuffix(suffixes []string) (string, string) {
|
||||
if ec.host == "" {
|
||||
return StatusNoMatch, ""
|
||||
}
|
||||
if domainMatcher(nil, suffixes).Match(ec.host) {
|
||||
for _, s := range suffixes {
|
||||
if domainMatcher(nil, []string{s}).Match(ec.host) {
|
||||
return StatusMatch, s
|
||||
}
|
||||
}
|
||||
return StatusMatch, ""
|
||||
}
|
||||
return StatusNoMatch, ""
|
||||
}
|
||||
|
||||
// domainMatcher builds a sing/common/domain matcher for the given exact domains
|
||||
// and suffixes. This mirrors route/rule.NewDomainItem exactly (it calls
|
||||
// domain.NewMatcher(domains, domainSuffixes, false)), so matching stays faithful
|
||||
// to sing-box's succinct-set suffix logic without importing route/rule.
|
||||
func domainMatcher(domains, suffixes []string) *domain.Matcher {
|
||||
return domain.NewMatcher(domains, suffixes, false)
|
||||
}
|
||||
|
||||
func (ec *evalCtx) matchKeyword(keywords []string) (string, string) {
|
||||
if ec.host == "" {
|
||||
return StatusNoMatch, ""
|
||||
}
|
||||
for _, kw := range keywords {
|
||||
if kw != "" && strings.Contains(ec.host, strings.ToLower(kw)) {
|
||||
return StatusMatch, kw
|
||||
}
|
||||
}
|
||||
return StatusNoMatch, ""
|
||||
}
|
||||
|
||||
func (ec *evalCtx) matchRegex(exprs []string) (string, string) {
|
||||
if ec.host == "" {
|
||||
return StatusNoMatch, ""
|
||||
}
|
||||
for _, e := range exprs {
|
||||
re, err := regexp.Compile(e)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if re.MatchString(ec.host) {
|
||||
return StatusMatch, e
|
||||
}
|
||||
}
|
||||
return StatusNoMatch, ""
|
||||
}
|
||||
|
||||
// matchIPCIDR evaluates an ip_cidr condition. For a domain destination it is
|
||||
// UNKNOWN until addresses are resolved; then it matches those addresses.
|
||||
func (ec *evalCtx) matchIPCIDR(cidrs []string, isSource bool) (string, string, string) {
|
||||
if isSource {
|
||||
return StatusUnknown, "", "client source address is unknown"
|
||||
}
|
||||
addrs := ec.matchAddrs()
|
||||
if len(addrs) == 0 {
|
||||
if ec.host != "" {
|
||||
return StatusUnknown, "", "requires the resolved IP (domain not resolved for this evaluation)"
|
||||
}
|
||||
return StatusNoMatch, "", ""
|
||||
}
|
||||
for _, cidr := range cidrs {
|
||||
p, err := netip.ParsePrefix(strings.TrimSpace(cidr))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if p.Contains(a.Unmap()) || p.Contains(a) {
|
||||
return StatusMatch, cidr, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return StatusNoMatch, "", ""
|
||||
}
|
||||
|
||||
func (ec *evalCtx) matchRawIPSet(set *netipx.IPSet) (string, string) {
|
||||
addrs := ec.matchAddrs()
|
||||
if len(addrs) == 0 {
|
||||
if ec.host != "" {
|
||||
return StatusUnknown, "requires the resolved IP"
|
||||
}
|
||||
return StatusNoMatch, ""
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if set.Contains(a.Unmap()) || set.Contains(a) {
|
||||
return StatusMatch, ""
|
||||
}
|
||||
}
|
||||
return StatusNoMatch, ""
|
||||
}
|
||||
|
||||
func (ec *evalCtx) matchIPIsPrivate(isSource bool) (string, string) {
|
||||
if isSource {
|
||||
return StatusUnknown, "client source address is unknown"
|
||||
}
|
||||
addrs := ec.matchAddrs()
|
||||
if len(addrs) == 0 {
|
||||
if ec.host != "" {
|
||||
return StatusUnknown, "requires the resolved IP"
|
||||
}
|
||||
return StatusNoMatch, ""
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() {
|
||||
return StatusMatch, ""
|
||||
}
|
||||
}
|
||||
return StatusNoMatch, ""
|
||||
}
|
||||
|
||||
// matchAddrs returns the destination addresses available for IP matching.
|
||||
func (ec *evalCtx) matchAddrs() []netip.Addr {
|
||||
if ec.destIsIP {
|
||||
return []netip.Addr{ec.destAddr}
|
||||
}
|
||||
return ec.addresses
|
||||
}
|
||||
|
||||
func (ec *evalCtx) matchNetwork(networks []string) string {
|
||||
if ec.network == "" {
|
||||
return StatusUnknown
|
||||
}
|
||||
// route/rule.NetworkItem matches when the connection network is in the set.
|
||||
for _, n := range networks {
|
||||
if n == ec.network {
|
||||
return StatusMatch
|
||||
}
|
||||
}
|
||||
return StatusNoMatch
|
||||
}
|
||||
|
||||
func (ec *evalCtx) matchQueryType(types []option.DNSQueryType) (string, string) {
|
||||
if ec.queryType == 0 {
|
||||
return StatusUnknown, ""
|
||||
}
|
||||
// route/rule.QueryTypeItem matches when the query type is in the set.
|
||||
for _, t := range types {
|
||||
if uint16(t) == ec.queryType {
|
||||
return StatusMatch, queryTypeName(ec.queryType)
|
||||
}
|
||||
}
|
||||
return StatusNoMatch, ""
|
||||
}
|
||||
|
||||
// ---- display helpers ----
|
||||
|
||||
func joinVals(v []string) string {
|
||||
if len(v) <= 4 {
|
||||
return strings.Join(v, ", ")
|
||||
}
|
||||
return strings.Join(v[:4], ", ") + fmt.Sprintf(", …(+%d)", len(v)-4)
|
||||
}
|
||||
|
||||
func joinU16(v []uint16) string {
|
||||
parts := make([]string, 0, len(v))
|
||||
for _, p := range v {
|
||||
parts = append(parts, fmt.Sprint(p))
|
||||
}
|
||||
return joinVals(parts)
|
||||
}
|
||||
|
||||
func queryTypeList(types []option.DNSQueryType) string {
|
||||
parts := make([]string, 0, len(types))
|
||||
for _, t := range types {
|
||||
parts = append(parts, queryTypeName(uint16(t)))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
var queryTypeNames = map[uint16]string{1: "A", 28: "AAAA", 5: "CNAME", 15: "MX", 16: "TXT", 12: "PTR", 33: "SRV", 65: "HTTPS", 64: "SVCB"}
|
||||
|
||||
func queryTypeName(t uint16) string {
|
||||
if n, ok := queryTypeNames[t]; ok {
|
||||
return n
|
||||
}
|
||||
return fmt.Sprintf("TYPE%d", t)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"github.com/sagernet/sing-box/option"
|
||||
)
|
||||
|
||||
func dnsActionOf(r option.DNSRule) actionInfo {
|
||||
var a option.DNSRuleAction
|
||||
if r.Type == "logical" {
|
||||
a = r.LogicalOptions.DNSRuleAction
|
||||
} else {
|
||||
a = r.DefaultOptions.DNSRuleAction
|
||||
}
|
||||
typ := a.Action
|
||||
if typ == "" {
|
||||
typ = "route"
|
||||
}
|
||||
ai := actionInfo{typ: typ}
|
||||
switch typ {
|
||||
case "route":
|
||||
ai.server = a.RouteOptions.Server
|
||||
ai.terminal = true
|
||||
ai.detail = "route → server " + orDefault(ai.server, "(default)")
|
||||
case "route-options":
|
||||
ai.detail = "route-options (non-terminal)"
|
||||
case "reject":
|
||||
m := a.RejectOptions.Method
|
||||
if m == "" {
|
||||
m = "default"
|
||||
}
|
||||
ai.terminal = true
|
||||
ai.detail = "reject (" + m + ")"
|
||||
case "predefined":
|
||||
ai.terminal = true
|
||||
ai.detail = "predefined response"
|
||||
case "evaluate":
|
||||
ai.server = a.RouteOptions.Server
|
||||
ai.detail = "evaluate (non-terminal)"
|
||||
case "respond":
|
||||
ai.terminal = true
|
||||
ai.detail = "respond"
|
||||
default:
|
||||
ai.terminal = true
|
||||
ai.detail = typ
|
||||
}
|
||||
return ai
|
||||
}
|
||||
|
||||
// matchDNS evaluates DNS rules for the host to determine which DNS server / DNS
|
||||
// rule action is hit. Evaluated for A queries (the common resolution path).
|
||||
func (ec *evalCtx) matchDNS(cfg *Config) *DNSTrace {
|
||||
prevQT := ec.queryType
|
||||
ec.queryType = 1 // dns.TypeA
|
||||
defer func() { ec.queryType = prevQT }()
|
||||
|
||||
tr := &DNSTrace{QueryType: "A", MatchedIndex: -1, Final: cfg.effectiveDNSFinal()}
|
||||
hadConditional := false
|
||||
|
||||
for i, r := range cfg.DNSRules {
|
||||
re := ec.evalDNSRuleNode(r)
|
||||
re.Index = i
|
||||
re.Reached = true
|
||||
a := dnsActionOf(r)
|
||||
re.ActionType = a.typ
|
||||
re.ActionText = a.detail
|
||||
re.Terminal = a.terminal
|
||||
|
||||
switch re.Status {
|
||||
case StatusMatch:
|
||||
if !a.terminal {
|
||||
re.Effect = "matched but non-terminal; continues scanning"
|
||||
tr.Steps = append(tr.Steps, re)
|
||||
continue
|
||||
}
|
||||
tr.Steps = append(tr.Steps, re)
|
||||
tr.MatchedIndex = i
|
||||
tr.Decision = ec.dnsDecision(cfg, a, false, hadConditional)
|
||||
return tr
|
||||
case StatusUnknown:
|
||||
if a.terminal {
|
||||
re.Effect = "could match here if its undetermined conditions hold"
|
||||
hadConditional = true
|
||||
}
|
||||
tr.Steps = append(tr.Steps, re)
|
||||
default:
|
||||
tr.Steps = append(tr.Steps, re)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall through to dns.final.
|
||||
final := cfg.effectiveDNSFinal()
|
||||
tr.Decision = ec.dnsDecision(cfg, actionInfo{typ: "route", server: final, terminal: true, detail: "route → server " + orDefault(final, "(first server)")}, true, hadConditional)
|
||||
if len(cfg.DNSRules) == 0 {
|
||||
tr.Note = "no DNS rules; the final server is always used"
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
func (ec *evalCtx) dnsDecision(cfg *Config, a actionInfo, fromFinal, assumed bool) *DNSDecision {
|
||||
d := &DNSDecision{
|
||||
ActionType: a.typ,
|
||||
Server: a.server,
|
||||
Detail: a.detail,
|
||||
FromFinal: fromFinal,
|
||||
Assumed: assumed,
|
||||
}
|
||||
if a.server != "" {
|
||||
d.ServerInfo = cfg.findDNSServer(a.server)
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Package engine evaluates a sing-box configuration against a domain or IP and
|
||||
// produces a step-by-step explanation of how DNS and route rules match.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"sing-vis/internal/dnsx"
|
||||
)
|
||||
|
||||
// RuleSetFile is an uploaded rule-set payload for a type:local rule set, keyed by
|
||||
// the rule-set tag (or its configured path). Format is "source" or "binary"; for
|
||||
// "binary" Data is base64-encoded, for "source" it is the raw JSON text.
|
||||
type RuleSetFile struct {
|
||||
Format string `json:"format"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// Request bundles everything needed to analyze a set of inputs.
|
||||
type Request struct {
|
||||
Config string
|
||||
Inputs []string
|
||||
RuleSetFiles map[string]RuleSetFile
|
||||
Network string // optional assumed network: "", "tcp", "udp"
|
||||
// AssumeResolved pre-resolves domains via DoH before route matching so that
|
||||
// ip_cidr / IP rule-set rules can match the resolved addresses (matching user
|
||||
// intuition). When false, IP rules only match after an explicit resolve action.
|
||||
AssumeResolved bool
|
||||
Resolver dnsx.Resolver
|
||||
}
|
||||
|
||||
// Result is the top-level analysis response.
|
||||
type Result struct {
|
||||
DoHServer string `json:"dohServer"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Inputs []InputTrace `json:"inputs"`
|
||||
}
|
||||
|
||||
// Analyze is the entry point; implemented in analyze.go.
|
||||
func Analyze(ctx context.Context, req Request) (*Result, error) {
|
||||
return analyze(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/option"
|
||||
sjson "github.com/sagernet/sing/common/json"
|
||||
)
|
||||
|
||||
// Config is the parsed subset of a sing-box configuration relevant to routing.
|
||||
type Config struct {
|
||||
RouteRules []option.Rule
|
||||
RouteRuleSets []option.RuleSet
|
||||
RouteFinal string
|
||||
|
||||
DNSRules []option.DNSRule
|
||||
DNSFinal string
|
||||
DNSServers []DNSServerInfo
|
||||
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// ParseConfig parses a raw sing-box JSON (JSONC allowed) configuration into the
|
||||
// routing-relevant structures, using sing-box's own option unmarshalers so that
|
||||
// rule/action/rule-set dispatch is version-accurate.
|
||||
func ParseConfig(text string) (*Config, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("empty configuration")
|
||||
}
|
||||
ctx := context.Background()
|
||||
var raw map[string]json.RawMessage
|
||||
if err := sjson.UnmarshalContext(ctx, []byte(text), &raw); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
|
||||
if rm, ok := raw["route"]; ok && len(rm) > 0 {
|
||||
var route struct {
|
||||
Rules []option.Rule `json:"rules"`
|
||||
RuleSet []option.RuleSet `json:"rule_set"`
|
||||
Final string `json:"final"`
|
||||
}
|
||||
if err := sjson.UnmarshalContext(ctx, rm, &route); err != nil {
|
||||
return nil, fmt.Errorf("route: %w", err)
|
||||
}
|
||||
cfg.RouteRules = route.Rules
|
||||
cfg.RouteRuleSets = route.RuleSet
|
||||
cfg.RouteFinal = route.Final
|
||||
}
|
||||
|
||||
if rm, ok := raw["dns"]; ok && len(rm) > 0 {
|
||||
var dnsSec struct {
|
||||
Rules []option.DNSRule `json:"rules"`
|
||||
Final string `json:"final"`
|
||||
Servers []json.RawMessage `json:"servers"`
|
||||
}
|
||||
if err := sjson.UnmarshalContext(ctx, rm, &dnsSec); err != nil {
|
||||
return nil, fmt.Errorf("dns: %w", err)
|
||||
}
|
||||
cfg.DNSRules = dnsSec.Rules
|
||||
cfg.DNSFinal = dnsSec.Final
|
||||
cfg.DNSServers = parseDNSServers(dnsSec.Servers)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// parseDNSServers extracts display metadata from dns.servers generically so we
|
||||
// don't need the DNS transport registry (which would pull in the whole protocol
|
||||
// dependency tree).
|
||||
func parseDNSServers(servers []json.RawMessage) []DNSServerInfo {
|
||||
var out []DNSServerInfo
|
||||
for _, raw := range servers {
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
continue
|
||||
}
|
||||
info := DNSServerInfo{
|
||||
Tag: asString(m["tag"]),
|
||||
Type: asString(m["type"]),
|
||||
Detour: asString(m["detour"]),
|
||||
Address: firstString(m, "server", "address"),
|
||||
}
|
||||
out = append(out, info)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func firstString(m map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if s, ok := m[k].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findDNSServer returns the server info for a tag, if present.
|
||||
func (c *Config) findDNSServer(tag string) *DNSServerInfo {
|
||||
for i := range c.DNSServers {
|
||||
if c.DNSServers[i].Tag == tag {
|
||||
return &c.DNSServers[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// effectiveDNSFinal returns the DNS server tag used when no rule matches: the
|
||||
// configured dns.final, or the first server tag if unset.
|
||||
func (c *Config) effectiveDNSFinal() string {
|
||||
if c.DNSFinal != "" {
|
||||
return c.DNSFinal
|
||||
}
|
||||
if len(c.DNSServers) > 0 {
|
||||
return c.DNSServers[0].Tag
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/option"
|
||||
)
|
||||
|
||||
type actionInfo struct {
|
||||
typ string
|
||||
outbound string
|
||||
detail string
|
||||
terminal bool
|
||||
isResolve bool
|
||||
strategy string
|
||||
server string
|
||||
}
|
||||
|
||||
func routeActionOf(r option.Rule) actionInfo {
|
||||
var a option.RuleAction
|
||||
if r.Type == "logical" {
|
||||
a = r.LogicalOptions.RuleAction
|
||||
} else {
|
||||
a = r.DefaultOptions.RuleAction
|
||||
}
|
||||
typ := a.Action
|
||||
if typ == "" {
|
||||
typ = "route"
|
||||
}
|
||||
ai := actionInfo{typ: typ}
|
||||
switch typ {
|
||||
case "route":
|
||||
ai.outbound = a.RouteOptions.Outbound
|
||||
ai.terminal = true
|
||||
ai.detail = "route → " + orDefault(ai.outbound, "(default outbound)")
|
||||
case "route-options":
|
||||
ai.detail = "route-options (non-terminal)"
|
||||
case "reject":
|
||||
m := a.RejectOptions.Method
|
||||
if m == "" {
|
||||
m = "default"
|
||||
}
|
||||
ai.terminal = true
|
||||
ai.detail = "reject (" + m + ")"
|
||||
case "hijack-dns":
|
||||
ai.terminal = true
|
||||
ai.detail = "hijack-dns"
|
||||
case "sniff":
|
||||
ai.detail = "sniff (non-terminal)"
|
||||
case "resolve":
|
||||
ai.isResolve = true
|
||||
ai.strategy = safeStrategy(a.ResolveOptions.Strategy)
|
||||
ai.server = a.ResolveOptions.Server
|
||||
ai.detail = "resolve"
|
||||
if ai.strategy != "" {
|
||||
ai.detail += " (" + ai.strategy + ")"
|
||||
}
|
||||
case "direct":
|
||||
ai.terminal = true
|
||||
ai.detail = "direct"
|
||||
ai.outbound = "direct"
|
||||
case "bypass":
|
||||
ai.outbound = a.BypassOptions.Outbound
|
||||
ai.terminal = ai.outbound != ""
|
||||
ai.detail = "bypass"
|
||||
default:
|
||||
ai.terminal = true
|
||||
ai.detail = typ
|
||||
}
|
||||
return ai
|
||||
}
|
||||
|
||||
// matchRoute evaluates route rules top-to-bottom, first terminal match wins,
|
||||
// handling non-terminal resolve/sniff actions and the final fallback.
|
||||
func (ec *evalCtx) matchRoute(cfg *Config) *RouteTrace {
|
||||
tr := &RouteTrace{SelectedIndex: -1, Final: cfg.RouteFinal}
|
||||
hadConditional := false
|
||||
|
||||
for i, r := range cfg.RouteRules {
|
||||
re := ec.evalRuleNode(r, false)
|
||||
re.Index = i
|
||||
re.Reached = true
|
||||
a := routeActionOf(r)
|
||||
re.ActionType = a.typ
|
||||
re.ActionText = a.detail
|
||||
re.Terminal = a.terminal
|
||||
|
||||
switch re.Status {
|
||||
case StatusMatch:
|
||||
if a.isResolve {
|
||||
addrs := ec.performResolve(a.strategy)
|
||||
if len(addrs) > 0 {
|
||||
re.Effect = "resolved → " + strings.Join(addrStrings(addrs), ", ") + " (IP rules below can now match)"
|
||||
} else {
|
||||
re.Effect = "resolve produced no addresses"
|
||||
}
|
||||
tr.Steps = append(tr.Steps, re)
|
||||
continue
|
||||
}
|
||||
if !a.terminal {
|
||||
re.Effect = "matched but non-terminal; continues scanning"
|
||||
tr.Steps = append(tr.Steps, re)
|
||||
continue
|
||||
}
|
||||
tr.Steps = append(tr.Steps, re)
|
||||
tr.SelectedIndex = i
|
||||
tr.Decision = &RouteDecision{
|
||||
ActionType: a.typ,
|
||||
Outbound: a.outbound,
|
||||
Detail: a.detail,
|
||||
Assumed: hadConditional,
|
||||
}
|
||||
return tr
|
||||
case StatusUnknown:
|
||||
if a.terminal {
|
||||
re.Effect = "could match here if its undetermined conditions hold"
|
||||
hadConditional = true
|
||||
}
|
||||
tr.Steps = append(tr.Steps, re)
|
||||
default:
|
||||
tr.Steps = append(tr.Steps, re)
|
||||
}
|
||||
}
|
||||
|
||||
tr.Decision = &RouteDecision{
|
||||
ActionType: "route",
|
||||
Outbound: effectiveRouteFinal(cfg),
|
||||
Detail: "route → " + orDefault(effectiveRouteFinal(cfg), "(first outbound)"),
|
||||
FromFinal: true,
|
||||
Assumed: hadConditional,
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
// performResolve resolves the host via DoH and records the addresses so IP-based
|
||||
// rules below can match. It reuses any cached resolution.
|
||||
func (ec *evalCtx) performResolve(strategy string) []netip.Addr {
|
||||
if ec.host == "" || ec.resolver == nil {
|
||||
return ec.addresses
|
||||
}
|
||||
res, _ := ec.resolver.Resolve(ec.ctx, ec.host, strategy)
|
||||
if res == nil {
|
||||
return ec.addresses
|
||||
}
|
||||
addrs := parseAddrs(res.All(strategy))
|
||||
if len(addrs) > 0 {
|
||||
ec.setAddresses(addrs)
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
func effectiveRouteFinal(cfg *Config) string {
|
||||
return cfg.RouteFinal // empty => sing-box uses the first outbound
|
||||
}
|
||||
|
||||
func orDefault(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func safeStrategy(s option.DomainStrategy) (out string) {
|
||||
defer func() { _ = recover() }()
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func parseAddrs(ss []string) []netip.Addr {
|
||||
var out []netip.Addr
|
||||
for _, s := range ss {
|
||||
if a, err := netip.ParseAddr(s); err == nil {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addrStrings(addrs []netip.Addr) []string {
|
||||
out := make([]string, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
out = append(out, a.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/json/badoption"
|
||||
)
|
||||
|
||||
// ---- field extraction ----
|
||||
|
||||
func fieldsFromRoute(r option.RawDefaultRule) matchFields {
|
||||
mf := matchFields{
|
||||
domain: r.Domain,
|
||||
domainSuffix: r.DomainSuffix,
|
||||
domainKeyword: r.DomainKeyword,
|
||||
domainRegex: r.DomainRegex,
|
||||
ipCIDR: r.IPCIDR,
|
||||
ipIsPrivate: r.IPIsPrivate,
|
||||
srcIPCIDR: r.SourceIPCIDR,
|
||||
srcIPIsPriv: r.SourceIPIsPrivate,
|
||||
port: r.Port,
|
||||
portRange: r.PortRange,
|
||||
srcPort: r.SourcePort,
|
||||
srcPortRange: r.SourcePortRange,
|
||||
network: r.Network,
|
||||
ruleSet: r.RuleSet,
|
||||
rsMatchSource: r.RuleSetIPCIDRMatchSource || r.Deprecated_RulesetIPCIDRMatchSource,
|
||||
invert: r.Invert,
|
||||
}
|
||||
addUnknownList(&mf, "inbound", r.Inbound)
|
||||
addUnknownList(&mf, "protocol", r.Protocol)
|
||||
addUnknownList(&mf, "client", r.Client)
|
||||
addUnknownList(&mf, "auth_user", r.AuthUser)
|
||||
addUnknownList(&mf, "user", r.User)
|
||||
addUnknownList(&mf, "process_name", r.ProcessName)
|
||||
addUnknownList(&mf, "process_path", r.ProcessPath)
|
||||
addUnknownList(&mf, "process_path_regex", r.ProcessPathRegex)
|
||||
addUnknownList(&mf, "package_name", r.PackageName)
|
||||
addUnknownList(&mf, "package_name_regex", r.PackageNameRegex)
|
||||
addUnknownList(&mf, "wifi_ssid", r.WIFISSID)
|
||||
addUnknownList(&mf, "wifi_bssid", r.WIFIBSSID)
|
||||
addUnknownList(&mf, "source_mac_address", r.SourceMACAddress)
|
||||
addUnknownList(&mf, "source_hostname", r.SourceHostname)
|
||||
addUnknownList(&mf, "preferred_by", r.PreferredBy)
|
||||
addUnknownDeprecated(&mf, "geosite", r.Geosite)
|
||||
addUnknownDeprecated(&mf, "geoip", r.GeoIP)
|
||||
addUnknownDeprecated(&mf, "source_geoip", r.SourceGeoIP)
|
||||
if r.ClashMode != "" {
|
||||
mf.unknowns = append(mf.unknowns, condKV{"clash_mode", r.ClashMode})
|
||||
}
|
||||
if r.IPVersion != 0 {
|
||||
mf.unknowns = append(mf.unknowns, condKV{"ip_version", intStr(r.IPVersion)})
|
||||
}
|
||||
if r.NetworkIsExpensive {
|
||||
mf.unknowns = append(mf.unknowns, condKV{"network_is_expensive", "true"})
|
||||
}
|
||||
if r.NetworkIsConstrained {
|
||||
mf.unknowns = append(mf.unknowns, condKV{"network_is_constrained", "true"})
|
||||
}
|
||||
if len(r.NetworkType) > 0 {
|
||||
mf.unknowns = append(mf.unknowns, condKV{"network_type", interfaceTypes(r.NetworkType)})
|
||||
}
|
||||
return mf
|
||||
}
|
||||
|
||||
func fieldsFromDNS(r option.RawDefaultDNSRule) matchFields {
|
||||
mf := matchFields{
|
||||
domain: r.Domain,
|
||||
domainSuffix: r.DomainSuffix,
|
||||
domainKeyword: r.DomainKeyword,
|
||||
domainRegex: r.DomainRegex,
|
||||
srcIPCIDR: r.SourceIPCIDR,
|
||||
srcIPIsPriv: r.SourceIPIsPrivate,
|
||||
port: r.Port,
|
||||
portRange: r.PortRange,
|
||||
srcPort: r.SourcePort,
|
||||
srcPortRange: r.SourcePortRange,
|
||||
network: r.Network,
|
||||
queryType: r.QueryType,
|
||||
ruleSet: r.RuleSet,
|
||||
rsMatchSource: r.RuleSetIPCIDRMatchSource || r.Deprecated_RulesetIPCIDRMatchSource,
|
||||
invert: r.Invert,
|
||||
}
|
||||
// DNS ip_cidr / ip_is_private / ip_accept_any and response_* are response
|
||||
// filters, not query-routing conditions.
|
||||
if len(r.IPCIDR) > 0 {
|
||||
mf.dnsFilter = append(mf.dnsFilter, condKV{"ip_cidr", joinVals(r.IPCIDR)})
|
||||
}
|
||||
if r.IPIsPrivate {
|
||||
mf.dnsFilter = append(mf.dnsFilter, condKV{"ip_is_private", "true"})
|
||||
}
|
||||
if r.IPAcceptAny {
|
||||
mf.dnsFilter = append(mf.dnsFilter, condKV{"ip_accept_any", "true"})
|
||||
}
|
||||
if r.ResponseRcode != nil {
|
||||
mf.dnsFilter = append(mf.dnsFilter, condKV{"response_rcode", "set"})
|
||||
}
|
||||
if r.MatchResponse != nil {
|
||||
mf.dnsFilter = append(mf.dnsFilter, condKV{"match_response", "set"})
|
||||
}
|
||||
addUnknownList(&mf, "inbound", r.Inbound)
|
||||
addUnknownList(&mf, "protocol", r.Protocol)
|
||||
addUnknownList(&mf, "auth_user", r.AuthUser)
|
||||
addUnknownList(&mf, "user", r.User)
|
||||
addUnknownList(&mf, "outbound", r.Outbound)
|
||||
addUnknownList(&mf, "process_name", r.ProcessName)
|
||||
addUnknownList(&mf, "process_path", r.ProcessPath)
|
||||
addUnknownList(&mf, "package_name", r.PackageName)
|
||||
addUnknownList(&mf, "wifi_ssid", r.WIFISSID)
|
||||
addUnknownList(&mf, "wifi_bssid", r.WIFIBSSID)
|
||||
addUnknownDeprecated(&mf, "geosite", r.Geosite)
|
||||
if r.ClashMode != "" {
|
||||
mf.unknowns = append(mf.unknowns, condKV{"clash_mode", r.ClashMode})
|
||||
}
|
||||
if r.IPVersion != 0 {
|
||||
mf.unknowns = append(mf.unknowns, condKV{"ip_version", intStr(r.IPVersion)})
|
||||
}
|
||||
return mf
|
||||
}
|
||||
|
||||
func fieldsFromHeadless(r option.DefaultHeadlessRule) matchFields {
|
||||
mf := matchFields{
|
||||
domainKeyword: r.DomainKeyword,
|
||||
domainRegex: r.DomainRegex,
|
||||
srcIPCIDR: r.SourceIPCIDR,
|
||||
port: r.Port,
|
||||
portRange: r.PortRange,
|
||||
srcPort: r.SourcePort,
|
||||
srcPortRange: r.SourcePortRange,
|
||||
network: r.Network,
|
||||
queryType: r.QueryType,
|
||||
invert: r.Invert,
|
||||
}
|
||||
// Prefer pre-compiled matchers (present in binary .srs rule sets).
|
||||
if r.DomainMatcher != nil {
|
||||
mf.rawDomain = r.DomainMatcher
|
||||
} else {
|
||||
mf.domain = r.Domain
|
||||
mf.domainSuffix = r.DomainSuffix
|
||||
}
|
||||
if r.IPSet != nil {
|
||||
mf.rawIPSet = r.IPSet
|
||||
} else {
|
||||
mf.ipCIDR = r.IPCIDR
|
||||
}
|
||||
if r.AdGuardDomainMatcher != nil || len(r.AdGuardDomain) > 0 {
|
||||
mf.unknowns = append(mf.unknowns, condKV{"adguard_domain", "«set»"})
|
||||
}
|
||||
addUnknownList(&mf, "process_name", r.ProcessName)
|
||||
addUnknownList(&mf, "process_path", r.ProcessPath)
|
||||
addUnknownList(&mf, "package_name", r.PackageName)
|
||||
addUnknownList(&mf, "wifi_ssid", r.WIFISSID)
|
||||
addUnknownList(&mf, "wifi_bssid", r.WIFIBSSID)
|
||||
if r.NetworkIsExpensive {
|
||||
mf.unknowns = append(mf.unknowns, condKV{"network_is_expensive", "true"})
|
||||
}
|
||||
if r.NetworkIsConstrained {
|
||||
mf.unknowns = append(mf.unknowns, condKV{"network_is_constrained", "true"})
|
||||
}
|
||||
if len(r.NetworkType) > 0 {
|
||||
mf.unknowns = append(mf.unknowns, condKV{"network_type", interfaceTypes(r.NetworkType)})
|
||||
}
|
||||
return mf
|
||||
}
|
||||
|
||||
func addUnknownList(mf *matchFields, field string, v badoption.Listable[string]) {
|
||||
if len(v) > 0 {
|
||||
mf.unknowns = append(mf.unknowns, condKV{field, joinVals(v)})
|
||||
}
|
||||
}
|
||||
|
||||
func addUnknownDeprecated(mf *matchFields, field string, v badoption.Listable[string]) {
|
||||
if len(v) > 0 {
|
||||
mf.unknowns = append(mf.unknowns, condKV{field + " (deprecated/removed)", joinVals(v)})
|
||||
}
|
||||
}
|
||||
|
||||
func interfaceTypes(v badoption.Listable[option.InterfaceType]) string {
|
||||
parts := make([]string, 0, len(v))
|
||||
for _, t := range v {
|
||||
parts = append(parts, string(t))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func intStr(i int) string { return joinVals([]string{itoa(i)}) }
|
||||
|
||||
func itoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := i < 0
|
||||
if neg {
|
||||
i = -i
|
||||
}
|
||||
var b [20]byte
|
||||
pos := len(b)
|
||||
for i > 0 {
|
||||
pos--
|
||||
b[pos] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
if neg {
|
||||
pos--
|
||||
b[pos] = '-'
|
||||
}
|
||||
return string(b[pos:])
|
||||
}
|
||||
|
||||
// ---- rule-node evaluation (conditions only; action handled by caller) ----
|
||||
|
||||
// evalRuleNode evaluates a route/DNS rule's match conditions recursively.
|
||||
func (ec *evalCtx) evalRuleNode(r option.Rule, dns bool) RuleEval {
|
||||
if r.Type == "logical" {
|
||||
return ec.evalLogical(r.LogicalOptions.Mode, r.LogicalOptions.Rules, r.LogicalOptions.Invert, dns)
|
||||
}
|
||||
var mf matchFields
|
||||
if dns {
|
||||
// A DNS rule's default variant is carried on a separate type; caller
|
||||
// passes route-shaped rules only via evalDNSRuleNode. This branch is for
|
||||
// route rules.
|
||||
}
|
||||
mf = fieldsFromRoute(r.DefaultOptions.RawDefaultRule)
|
||||
status, conds := ec.evalFields(mf)
|
||||
return RuleEval{
|
||||
Type: "default",
|
||||
Status: status,
|
||||
Invert: mf.invert,
|
||||
Conditions: conds,
|
||||
Summary: summarize(conds, mf.invert),
|
||||
}
|
||||
}
|
||||
|
||||
// evalDNSRuleNode evaluates a DNS rule's match conditions recursively.
|
||||
func (ec *evalCtx) evalDNSRuleNode(r option.DNSRule) RuleEval {
|
||||
if r.Type == "logical" {
|
||||
return ec.evalLogicalDNS(r.LogicalOptions.Mode, r.LogicalOptions.Rules, r.LogicalOptions.Invert)
|
||||
}
|
||||
mf := fieldsFromDNS(r.DefaultOptions.RawDefaultDNSRule)
|
||||
status, conds := ec.evalFields(mf)
|
||||
return RuleEval{
|
||||
Type: "default",
|
||||
Status: status,
|
||||
Invert: mf.invert,
|
||||
Conditions: conds,
|
||||
Summary: summarize(conds, mf.invert),
|
||||
}
|
||||
}
|
||||
|
||||
func (ec *evalCtx) evalLogical(mode string, rules []option.Rule, invert bool, dns bool) RuleEval {
|
||||
if mode == "" {
|
||||
mode = "and"
|
||||
}
|
||||
var subs []RuleEval
|
||||
var statuses []string
|
||||
for _, sub := range rules {
|
||||
se := ec.evalRuleNode(sub, dns)
|
||||
subs = append(subs, se)
|
||||
statuses = append(statuses, se.Status)
|
||||
}
|
||||
var status string
|
||||
if mode == "or" {
|
||||
status = orStatus(statuses)
|
||||
} else {
|
||||
status = andStatus(statuses)
|
||||
}
|
||||
if invert {
|
||||
status = invertStatus(status)
|
||||
}
|
||||
return RuleEval{Type: "logical", Mode: mode, Status: status, Invert: invert, Sub: subs, Summary: "logical " + mode}
|
||||
}
|
||||
|
||||
func (ec *evalCtx) evalLogicalDNS(mode string, rules []option.DNSRule, invert bool) RuleEval {
|
||||
if mode == "" {
|
||||
mode = "and"
|
||||
}
|
||||
var subs []RuleEval
|
||||
var statuses []string
|
||||
for _, sub := range rules {
|
||||
se := ec.evalDNSRuleNode(sub)
|
||||
subs = append(subs, se)
|
||||
statuses = append(statuses, se.Status)
|
||||
}
|
||||
var status string
|
||||
if mode == "or" {
|
||||
status = orStatus(statuses)
|
||||
} else {
|
||||
status = andStatus(statuses)
|
||||
}
|
||||
if invert {
|
||||
status = invertStatus(status)
|
||||
}
|
||||
return RuleEval{Type: "logical", Mode: mode, Status: status, Invert: invert, Sub: subs, Summary: "logical " + mode}
|
||||
}
|
||||
|
||||
func summarize(conds []CondEval, invert bool) string {
|
||||
if len(conds) == 0 {
|
||||
return "(match all)"
|
||||
}
|
||||
parts := make([]string, 0, len(conds))
|
||||
for _, c := range conds {
|
||||
v := c.Value
|
||||
if len(v) > 40 {
|
||||
v = v[:40] + "…"
|
||||
}
|
||||
parts = append(parts, c.Field+"="+v)
|
||||
}
|
||||
s := strings.Join(parts, " ")
|
||||
if invert {
|
||||
s = "NOT(" + s + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/common/srs"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
sjson "github.com/sagernet/sing/common/json"
|
||||
)
|
||||
|
||||
// ruleSetResolver loads and evaluates rule sets referenced by rules.
|
||||
type ruleSetResolver struct {
|
||||
ctx context.Context
|
||||
byTag map[string]option.RuleSet
|
||||
files map[string]RuleSetFile
|
||||
loaded map[string]*loadedRuleSet
|
||||
warnings *[]string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type loadedRuleSet struct {
|
||||
tag string
|
||||
typ string
|
||||
rules []option.HeadlessRule
|
||||
err string
|
||||
}
|
||||
|
||||
func newRuleSetResolver(ctx context.Context, cfg *Config, files map[string]RuleSetFile, warnings *[]string) *ruleSetResolver {
|
||||
byTag := map[string]option.RuleSet{}
|
||||
for _, rs := range cfg.RouteRuleSets {
|
||||
for _, tag := range rs.Tag {
|
||||
byTag[tag] = rs
|
||||
}
|
||||
}
|
||||
return &ruleSetResolver{
|
||||
ctx: ctx,
|
||||
byTag: byTag,
|
||||
files: files,
|
||||
loaded: map[string]*loadedRuleSet{},
|
||||
warnings: warnings,
|
||||
http: &http.Client{Timeout: 20 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ruleSetResolver) load(tag string) *loadedRuleSet {
|
||||
if l, ok := r.loaded[tag]; ok {
|
||||
return l
|
||||
}
|
||||
l := &loadedRuleSet{tag: tag}
|
||||
r.loaded[tag] = l // set early to avoid cycles
|
||||
|
||||
rs, ok := r.byTag[tag]
|
||||
if !ok {
|
||||
l.err = "rule_set not defined in route.rule_set"
|
||||
return l
|
||||
}
|
||||
l.typ = rs.Type
|
||||
switch rs.Type {
|
||||
case "inline":
|
||||
l.rules = rs.InlineOptions.Rules
|
||||
case "local":
|
||||
r.loadFromFile(l, rs)
|
||||
case "remote":
|
||||
r.loadRemote(l, rs)
|
||||
default:
|
||||
l.err = "unsupported rule_set type: " + rs.Type
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (r *ruleSetResolver) loadFromFile(l *loadedRuleSet, rs option.RuleSet) {
|
||||
// Local rule sets read from an on-disk path we don't have; the user uploads
|
||||
// the file content keyed by the rule-set tag (or its path).
|
||||
f, ok := r.files[l.tag]
|
||||
if !ok {
|
||||
f, ok = r.files[rs.LocalOptions.Path]
|
||||
}
|
||||
if !ok {
|
||||
l.err = fmt.Sprintf("local rule-set file not provided (upload the file for tag %q or path %q)", l.tag, rs.LocalOptions.Path)
|
||||
return
|
||||
}
|
||||
format := f.Format
|
||||
if format == "" {
|
||||
format = rs.Format
|
||||
}
|
||||
if format == "" {
|
||||
format = ruleSetFormatFromPath(rs.LocalOptions.Path)
|
||||
}
|
||||
data := []byte(f.Data)
|
||||
if format == "binary" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(f.Data)); err == nil {
|
||||
data = decoded
|
||||
}
|
||||
}
|
||||
r.parseInto(l, data, format)
|
||||
}
|
||||
|
||||
func (r *ruleSetResolver) loadRemote(l *loadedRuleSet, rs option.RuleSet) {
|
||||
url := rs.RemoteOptions.URL
|
||||
if url == "" {
|
||||
l.err = "remote rule-set has no url"
|
||||
return
|
||||
}
|
||||
format := rs.Format
|
||||
if format == "" {
|
||||
format = ruleSetFormatFromPath(url)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
l.err = err.Error()
|
||||
return
|
||||
}
|
||||
req.Header.Set("User-Agent", "sing-box")
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
l.err = "fetch failed: " + err.Error()
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
l.err = fmt.Sprintf("fetch failed: HTTP %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
l.err = err.Error()
|
||||
return
|
||||
}
|
||||
r.parseInto(l, data, format)
|
||||
}
|
||||
|
||||
func (r *ruleSetResolver) parseInto(l *loadedRuleSet, data []byte, format string) {
|
||||
if format == "binary" {
|
||||
compat, err := srs.Read(bytes.NewReader(data), true)
|
||||
if err != nil {
|
||||
l.err = "parse binary rule-set: " + err.Error()
|
||||
return
|
||||
}
|
||||
plain, err := compat.Upgrade()
|
||||
if err != nil {
|
||||
l.err = err.Error()
|
||||
return
|
||||
}
|
||||
l.rules = plain.Rules
|
||||
return
|
||||
}
|
||||
// source format
|
||||
var compat option.PlainRuleSetCompat
|
||||
if err := sjson.UnmarshalContext(r.ctx, data, &compat); err != nil {
|
||||
l.err = "parse source rule-set: " + err.Error()
|
||||
return
|
||||
}
|
||||
plain, err := compat.Upgrade()
|
||||
if err != nil {
|
||||
l.err = err.Error()
|
||||
return
|
||||
}
|
||||
l.rules = plain.Rules
|
||||
}
|
||||
|
||||
// evaluate matches a rule-set tag against the context. A rule set matches if ANY
|
||||
// of its headless rules matches (OR).
|
||||
func (r *ruleSetResolver) evaluate(tag string, ec *evalCtx, matchSource bool) *RuleSetEval {
|
||||
l := r.load(tag)
|
||||
out := &RuleSetEval{Tag: tag, Type: l.typ, MatchedIdx: -1, Count: len(l.rules)}
|
||||
if l.err != "" {
|
||||
out.Status = StatusUnknown
|
||||
out.Error = l.err
|
||||
return out
|
||||
}
|
||||
statuses := make([]string, 0, len(l.rules))
|
||||
var firstMatch, firstUnknown *RuleEval
|
||||
firstMatchIdx := -1
|
||||
for i, hr := range l.rules {
|
||||
re := ec.evalHeadless(hr)
|
||||
re.Index = i
|
||||
statuses = append(statuses, re.Status)
|
||||
if re.Status == StatusMatch && firstMatch == nil {
|
||||
c := re
|
||||
firstMatch = &c
|
||||
firstMatchIdx = i
|
||||
}
|
||||
if re.Status == StatusUnknown && firstUnknown == nil {
|
||||
c := re
|
||||
firstUnknown = &c
|
||||
}
|
||||
}
|
||||
out.Status = orStatus(statuses)
|
||||
// Attach a representative headless-rule detail (the decisive one) to keep the
|
||||
// payload small for large sets.
|
||||
switch out.Status {
|
||||
case StatusMatch:
|
||||
out.MatchedIdx = firstMatchIdx
|
||||
if firstMatch != nil {
|
||||
out.Rules = []RuleEval{*firstMatch}
|
||||
}
|
||||
case StatusUnknown:
|
||||
if firstUnknown != nil {
|
||||
out.Rules = []RuleEval{*firstUnknown}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// evalHeadless evaluates one headless rule (default or logical).
|
||||
func (ec *evalCtx) evalHeadless(hr option.HeadlessRule) RuleEval {
|
||||
if hr.Type == "logical" {
|
||||
lo := hr.LogicalOptions
|
||||
mode := lo.Mode
|
||||
if mode == "" {
|
||||
mode = "and"
|
||||
}
|
||||
var subs []RuleEval
|
||||
var statuses []string
|
||||
for _, sub := range lo.Rules {
|
||||
se := ec.evalHeadless(sub)
|
||||
subs = append(subs, se)
|
||||
statuses = append(statuses, se.Status)
|
||||
}
|
||||
var status string
|
||||
if mode == "or" {
|
||||
status = orStatus(statuses)
|
||||
} else {
|
||||
status = andStatus(statuses)
|
||||
}
|
||||
if lo.Invert {
|
||||
status = invertStatus(status)
|
||||
}
|
||||
return RuleEval{Type: "logical", Mode: mode, Status: status, Invert: lo.Invert, Sub: subs, Summary: "logical " + mode}
|
||||
}
|
||||
mf := fieldsFromHeadless(hr.DefaultOptions)
|
||||
status, conds := ec.evalFields(mf)
|
||||
return RuleEval{Type: "default", Status: status, Invert: mf.invert, Conditions: conds, Summary: summarize(conds, mf.invert)}
|
||||
}
|
||||
|
||||
func ruleSetFormatFromPath(path string) string {
|
||||
if strings.HasSuffix(strings.ToLower(path), ".srs") {
|
||||
return "binary"
|
||||
}
|
||||
return "source"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package engine
|
||||
|
||||
// Tri-state status for a condition / rule evaluation.
|
||||
const (
|
||||
StatusMatch = "match"
|
||||
StatusNoMatch = "no_match"
|
||||
StatusUnknown = "unknown" // depends on connection attributes we cannot know offline
|
||||
)
|
||||
|
||||
// InputTrace is the analysis result for one input line (a domain or IP).
|
||||
type InputTrace struct {
|
||||
Input string `json:"input"`
|
||||
Kind string `json:"kind"` // "domain" | "ip" | "invalid"
|
||||
Error string `json:"error,omitempty"`
|
||||
Resolved *ResolvedInfo `json:"resolved,omitempty"`
|
||||
DNS *DNSTrace `json:"dns,omitempty"`
|
||||
Route *RouteTrace `json:"route,omitempty"`
|
||||
}
|
||||
|
||||
// ResolvedInfo holds the DoH resolution result for a domain.
|
||||
type ResolvedInfo struct {
|
||||
Server string `json:"server"`
|
||||
IPv4 []string `json:"ipv4,omitempty"`
|
||||
IPv6 []string `json:"ipv6,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DNSTrace explains which DNS rule (and thus which DNS server / action) a domain
|
||||
// hits during DNS resolution.
|
||||
type DNSTrace struct {
|
||||
QueryType string `json:"queryType"` // the query type used for evaluation (A)
|
||||
Steps []RuleEval `json:"steps"`
|
||||
MatchedIndex int `json:"matchedIndex"` // -1 => fell through to final
|
||||
Final string `json:"final"` // dns.final server tag (or effective default)
|
||||
Decision *DNSDecision `json:"decision"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// DNSDecision is the resolved outcome of DNS routing.
|
||||
type DNSDecision struct {
|
||||
ActionType string `json:"actionType"` // route|reject|predefined|route-options|...
|
||||
Server string `json:"server,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
ServerInfo *DNSServerInfo `json:"serverInfo,omitempty"`
|
||||
FromFinal bool `json:"fromFinal"` // decided by dns.final, not a rule
|
||||
Assumed bool `json:"assumed"` // decision relied on unknown-condition assumptions
|
||||
}
|
||||
|
||||
// DNSServerInfo describes a configured DNS server referenced by a route action.
|
||||
type DNSServerInfo struct {
|
||||
Tag string `json:"tag"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Detour string `json:"detour,omitempty"` // outbound used to reach this DNS server
|
||||
}
|
||||
|
||||
// RouteTrace explains which route rule / rule-set a domain or IP hits and the
|
||||
// final outbound.
|
||||
type RouteTrace struct {
|
||||
Steps []RuleEval `json:"steps"`
|
||||
SelectedIndex int `json:"selectedIndex"` // -1 => fell through to final
|
||||
Final string `json:"final"`
|
||||
Decision *RouteDecision `json:"decision"`
|
||||
}
|
||||
|
||||
// RouteDecision is the resolved outcome of route matching.
|
||||
type RouteDecision struct {
|
||||
ActionType string `json:"actionType"` // route|reject|hijack-dns
|
||||
Outbound string `json:"outbound,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
FromFinal bool `json:"fromFinal"`
|
||||
Assumed bool `json:"assumed"`
|
||||
}
|
||||
|
||||
// RuleEval is the evaluation of one rule (default or logical) in a rule list.
|
||||
type RuleEval struct {
|
||||
Index int `json:"index"`
|
||||
Type string `json:"type"` // "default" | "logical"
|
||||
Status string `json:"status"` // match|no_match|unknown
|
||||
Summary string `json:"summary"`
|
||||
ActionType string `json:"actionType"`
|
||||
ActionText string `json:"actionText"`
|
||||
Terminal bool `json:"terminal"`
|
||||
Reached bool `json:"reached"` // false for rules after the terminal match (not shown)
|
||||
Invert bool `json:"invert,omitempty"`
|
||||
Conditions []CondEval `json:"conditions,omitempty"`
|
||||
// Logical rule fields.
|
||||
Mode string `json:"mode,omitempty"` // and|or
|
||||
Sub []RuleEval `json:"sub,omitempty"`
|
||||
// Non-terminal side effects (resolve action results, notes).
|
||||
Effect string `json:"effect,omitempty"`
|
||||
}
|
||||
|
||||
// CondEval is the evaluation of a single condition within a rule.
|
||||
type CondEval struct {
|
||||
Field string `json:"field"`
|
||||
Value string `json:"value"`
|
||||
Group string `json:"group"` // dest_addr|src_addr|dest_port|src_port|other|rule_set
|
||||
Status string `json:"status"`
|
||||
Matched string `json:"matched,omitempty"` // the specific value that matched, if known
|
||||
Note string `json:"note,omitempty"`
|
||||
RuleSet *RuleSetEval `json:"ruleSet,omitempty"`
|
||||
}
|
||||
|
||||
// RuleSetEval is the evaluation of a referenced rule set.
|
||||
type RuleSetEval struct {
|
||||
Tag string `json:"tag"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
MatchedIdx int `json:"matchedIdx"` // index of the matched headless rule, -1 if none
|
||||
Rules []RuleEval `json:"rules,omitempty"`
|
||||
Count int `json:"count"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user