474 lines
14 KiB
Go
474 lines
14 KiB
Go
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)
|
|
}
|