This commit is contained in:
iceBear67
2026-07-24 13:29:16 +00:00
commit 2275bd7dc4
29 changed files with 3641 additions and 0 deletions
+169
View File
@@ -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
}
+134
View File
@@ -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
}
+473
View File
@@ -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)
}
+111
View File
@@ -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
}
+42
View File
@@ -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)
}
+208
View File
@@ -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)
}
}
+128
View File
@@ -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 ""
}
+185
View File
@@ -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
}
+314
View File
@@ -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
}
+248
View File
@@ -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"
}
+114
View File
@@ -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"`
}