328 lines
8.9 KiB
Go
328 lines
8.9 KiB
Go
package rule
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/sagernet/sing-box/adapter"
|
|
"github.com/sagernet/sing-box/common/srs"
|
|
C "github.com/sagernet/sing-box/constant"
|
|
"github.com/sagernet/sing-box/experimental/deprecated"
|
|
"github.com/sagernet/sing-box/option"
|
|
"github.com/sagernet/sing/common"
|
|
E "github.com/sagernet/sing/common/exceptions"
|
|
F "github.com/sagernet/sing/common/format"
|
|
"github.com/sagernet/sing/common/json"
|
|
"github.com/sagernet/sing/common/logger"
|
|
"github.com/sagernet/sing/common/x/list"
|
|
"github.com/sagernet/sing/service"
|
|
"github.com/sagernet/sing/service/filemanager"
|
|
"github.com/sagernet/sing/service/pause"
|
|
|
|
"go4.org/netipx"
|
|
)
|
|
|
|
var _ adapter.RuleSet = (*RemoteRuleSet)(nil)
|
|
|
|
type RemoteRuleSet struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
logger logger.ContextLogger
|
|
outbound adapter.OutboundManager
|
|
tag string
|
|
url string
|
|
initialPath string
|
|
options option.RuleSet
|
|
updateInterval time.Duration
|
|
httpClient *http.Client
|
|
access sync.RWMutex
|
|
rules []adapter.HeadlessRule
|
|
metadata adapter.RuleSetMetadata
|
|
lastUpdated time.Time
|
|
lastEtag string
|
|
cacheFile adapter.CacheFile
|
|
pauseManager pause.Manager
|
|
callbacks list.List[adapter.RuleSetUpdateCallback]
|
|
refs atomic.Int32
|
|
}
|
|
|
|
func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, tag string, options option.RuleSet) (*RemoteRuleSet, error) {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
var updateInterval time.Duration
|
|
if options.RemoteOptions.UpdateInterval > 0 {
|
|
updateInterval = time.Duration(options.RemoteOptions.UpdateInterval)
|
|
} else {
|
|
updateInterval = 24 * time.Hour
|
|
}
|
|
var initialPath string
|
|
if options.RemoteOptions.InitialPath != "" {
|
|
initialPath = filemanager.BasePath(ctx, strings.ReplaceAll(options.RemoteOptions.InitialPath, C.RuleSetTagPlaceholder, tag))
|
|
initialPath, _ = filepath.Abs(initialPath)
|
|
}
|
|
return &RemoteRuleSet{
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
outbound: service.FromContext[adapter.OutboundManager](ctx),
|
|
logger: logger,
|
|
tag: tag,
|
|
url: strings.ReplaceAll(options.RemoteOptions.URL, C.RuleSetTagPlaceholder, tag),
|
|
initialPath: initialPath,
|
|
options: options,
|
|
updateInterval: updateInterval,
|
|
pauseManager: service.FromContext[pause.Manager](ctx),
|
|
}, nil
|
|
}
|
|
|
|
func (s *RemoteRuleSet) Name() string {
|
|
return s.tag
|
|
}
|
|
|
|
func (s *RemoteRuleSet) String() string {
|
|
return strings.Join(F.MapToString(s.rules), " ")
|
|
}
|
|
|
|
func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error {
|
|
s.cacheFile = service.FromContext[adapter.CacheFile](s.ctx)
|
|
transport, err := s.resolveTransport()
|
|
if err != nil {
|
|
return E.Cause(err, "create rule-set http client")
|
|
}
|
|
startContext.Register(transport)
|
|
s.httpClient = &http.Client{Transport: transport}
|
|
if s.cacheFile != nil {
|
|
if savedSet := s.cacheFile.LoadRuleSet(s.tag); savedSet != nil {
|
|
err = s.loadBytes(savedSet.Content)
|
|
if err != nil {
|
|
s.logger.Warn(E.Cause(err, "restore cached rule-set, will refetch"))
|
|
} else {
|
|
s.lastUpdated = savedSet.LastUpdated
|
|
s.lastEtag = savedSet.LastEtag
|
|
}
|
|
}
|
|
}
|
|
var loadedFromInitialPath bool
|
|
if s.lastUpdated.IsZero() && s.initialPath != "" {
|
|
var content []byte
|
|
content, err = filemanager.ReadFile(s.ctx, s.initialPath)
|
|
if err == nil {
|
|
err = s.loadBytes(content)
|
|
}
|
|
if err != nil {
|
|
s.logger.Warn(E.Cause(err, "load initial rule-set from ", s.initialPath))
|
|
} else {
|
|
loadedFromInitialPath = true
|
|
}
|
|
}
|
|
if s.lastUpdated.IsZero() && !loadedFromInitialPath {
|
|
err = s.fetch(ctx, true)
|
|
if err != nil {
|
|
return E.Cause(err, "initial rule-set: ", s.tag)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *RemoteRuleSet) Metadata() adapter.RuleSetMetadata {
|
|
s.access.RLock()
|
|
defer s.access.RUnlock()
|
|
return s.metadata
|
|
}
|
|
|
|
func (s *RemoteRuleSet) ExtractIPSet() []*netipx.IPSet {
|
|
s.access.RLock()
|
|
defer s.access.RUnlock()
|
|
return common.FlatMap(s.rules, extractIPSetFromRule)
|
|
}
|
|
|
|
func (s *RemoteRuleSet) IncRef() {
|
|
s.refs.Add(1)
|
|
}
|
|
|
|
func (s *RemoteRuleSet) DecRef() {
|
|
if s.refs.Add(-1) < 0 {
|
|
panic("rule-set: negative refs")
|
|
}
|
|
}
|
|
|
|
func (s *RemoteRuleSet) Cleanup() {
|
|
if s.refs.Load() == 0 {
|
|
s.rules = nil
|
|
}
|
|
}
|
|
|
|
func (s *RemoteRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] {
|
|
s.access.Lock()
|
|
defer s.access.Unlock()
|
|
return s.callbacks.PushBack(callback)
|
|
}
|
|
|
|
func (s *RemoteRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUpdateCallback]) {
|
|
s.access.Lock()
|
|
defer s.access.Unlock()
|
|
s.callbacks.Remove(element)
|
|
}
|
|
|
|
func (s *RemoteRuleSet) loadBytes(content []byte) error {
|
|
var (
|
|
ruleSet option.PlainRuleSetCompat
|
|
err error
|
|
)
|
|
switch s.options.Format {
|
|
case C.RuleSetFormatSource:
|
|
ruleSet, err = json.UnmarshalExtended[option.PlainRuleSetCompat](content)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
case C.RuleSetFormatBinary:
|
|
ruleSet, err = srs.Read(bytes.NewReader(content), false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return E.New("unknown rule-set format: ", s.options.Format)
|
|
}
|
|
plainRuleSet, err := ruleSet.Upgrade()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rules := make([]adapter.HeadlessRule, len(plainRuleSet.Rules))
|
|
for i, ruleOptions := range plainRuleSet.Rules {
|
|
rules[i], err = NewHeadlessRule(s.ctx, ruleOptions)
|
|
if err != nil {
|
|
return E.Cause(err, "parse rule_set.rules.[", i, "]")
|
|
}
|
|
}
|
|
metadata := buildRuleSetMetadata(plainRuleSet.Rules)
|
|
err = validateRuleSetMetadataUpdate(s.ctx, s.tag, metadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.access.Lock()
|
|
s.metadata = metadata
|
|
s.rules = rules
|
|
callbacks := s.callbacks.Array()
|
|
s.access.Unlock()
|
|
for _, callback := range callbacks {
|
|
callback(s)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *RemoteRuleSet) updateOnce() {
|
|
err := s.fetch(s.ctx, false)
|
|
if err != nil {
|
|
s.logger.Error("fetch rule-set ", s.tag, ": ", err)
|
|
} else if s.refs.Load() == 0 {
|
|
s.rules = nil
|
|
}
|
|
}
|
|
|
|
func (s *RemoteRuleSet) fetch(ctx context.Context, isStart bool) error {
|
|
s.logger.Debug("updating rule-set ", s.tag, " from URL: ", s.url)
|
|
request, err := http.NewRequest("GET", s.url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.lastEtag != "" {
|
|
request.Header.Set("If-None-Match", s.lastEtag)
|
|
}
|
|
if !isStart {
|
|
defer s.httpClient.CloseIdleConnections()
|
|
}
|
|
response, err := s.httpClient.Do(request.WithContext(ctx))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer response.Body.Close()
|
|
switch response.StatusCode {
|
|
case http.StatusOK:
|
|
case http.StatusNotModified:
|
|
s.lastUpdated = time.Now()
|
|
if s.cacheFile != nil {
|
|
savedRuleSet := s.cacheFile.LoadRuleSet(s.tag)
|
|
if savedRuleSet != nil {
|
|
savedRuleSet.LastUpdated = s.lastUpdated
|
|
err = s.cacheFile.SaveRuleSet(s.tag, savedRuleSet)
|
|
if err != nil {
|
|
s.logger.Error("save rule-set updated time: ", err)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
s.logger.Info("update rule-set ", s.tag, ": not modified")
|
|
return nil
|
|
default:
|
|
return E.New("unexpected status: ", response.Status)
|
|
}
|
|
content, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = s.loadBytes(content)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
eTagHeader := response.Header.Get("Etag")
|
|
if eTagHeader != "" {
|
|
s.lastEtag = eTagHeader
|
|
}
|
|
s.lastUpdated = time.Now()
|
|
if s.cacheFile != nil {
|
|
err = s.cacheFile.SaveRuleSet(s.tag, &adapter.SavedBinary{
|
|
LastUpdated: s.lastUpdated,
|
|
Content: content,
|
|
LastEtag: s.lastEtag,
|
|
})
|
|
if err != nil {
|
|
s.logger.Error("save rule-set cache: ", err)
|
|
}
|
|
}
|
|
s.logger.Info("updated rule-set ", s.tag)
|
|
return nil
|
|
}
|
|
|
|
func (s *RemoteRuleSet) resolveTransport() (adapter.HTTPTransport, error) {
|
|
httpClientManager := service.FromContext[adapter.HTTPClientManager](s.ctx)
|
|
if s.options.RemoteOptions.HTTPClient != nil && !s.options.RemoteOptions.HTTPClient.IsEmpty() {
|
|
if s.options.RemoteOptions.DownloadDetour != "" { //nolint:staticcheck
|
|
return nil, E.New("http_client is conflict with deprecated download_detour field")
|
|
}
|
|
return httpClientManager.ResolveTransport(s.ctx, s.logger, *s.options.RemoteOptions.HTTPClient)
|
|
}
|
|
if s.options.RemoteOptions.DownloadDetour != "" { //nolint:staticcheck
|
|
deprecated.Report(s.ctx, deprecated.OptionLegacyRuleSetDownloadDetour)
|
|
return httpClientManager.ResolveTransport(s.ctx, s.logger, option.HTTPClientOptions{
|
|
DialerOptions: option.DialerOptions{
|
|
Detour: s.options.RemoteOptions.DownloadDetour, //nolint:staticcheck
|
|
},
|
|
DisableEmptyDirectCheck: true,
|
|
})
|
|
}
|
|
defaultTransport := httpClientManager.DefaultTransport()
|
|
if defaultTransport == nil {
|
|
return nil, E.New("default http client transport is not initialized")
|
|
}
|
|
return defaultTransport, nil
|
|
}
|
|
|
|
func (s *RemoteRuleSet) Close() error {
|
|
s.rules = nil
|
|
s.cancel()
|
|
return nil
|
|
}
|
|
|
|
func (s *RemoteRuleSet) Match(metadata *adapter.InboundContext) bool {
|
|
return matchAnyHeadlessRule(s.rules, metadata)
|
|
}
|
|
|
|
func (s *RemoteRuleSet) mergeableRule() *DefaultHeadlessRule {
|
|
return mergeableRuleIn(s.rules)
|
|
}
|