Files
sing-vis/internal/engine/conditions.go
T
2026-07-24 13:29:16 +00:00

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