249 lines
6.0 KiB
Go
249 lines
6.0 KiB
Go
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"
|
|
}
|