package rule import ( "bytes" "context" "io" "net/http" "runtime" "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/pause" "go4.org/netipx" ) var _ adapter.RuleSet = (*RemoteRuleSet)(nil) type RemoteRuleSet struct { ctx context.Context cancel context.CancelFunc logger logger.ContextLogger outbound adapter.OutboundManager options option.RuleSet updateInterval time.Duration httpClient *http.Client access sync.RWMutex rules []adapter.HeadlessRule metadata adapter.RuleSetMetadata lastUpdated time.Time lastEtag string updateTicker *time.Ticker cacheFile adapter.CacheFile pauseManager pause.Manager callbacks list.List[adapter.RuleSetUpdateCallback] refs atomic.Int32 } func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) *RemoteRuleSet { 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 } return &RemoteRuleSet{ ctx: ctx, cancel: cancel, outbound: service.FromContext[adapter.OutboundManager](ctx), logger: logger, options: options, updateInterval: updateInterval, pauseManager: service.FromContext[pause.Manager](ctx), } } func (s *RemoteRuleSet) Name() string { return s.options.Tag } func (s *RemoteRuleSet) String() string { return strings.Join(F.MapToString(s.rules), " ") } func (s *RemoteRuleSet) StartContext(ctx context.Context) error { s.cacheFile = service.FromContext[adapter.CacheFile](s.ctx) httpClientManager := service.FromContext[adapter.HTTPClientManager](s.ctx) if httpClientManager == nil { return E.New("missing http client manager in context") } transport, err := s.resolveTransport(httpClientManager) if err != nil { return E.Cause(err, "create rule-set http client") } s.httpClient = &http.Client{Transport: transport} if s.cacheFile != nil { if savedSet := s.cacheFile.LoadRuleSet(s.options.Tag); savedSet != nil { err = s.loadBytes(savedSet.Content) if err != nil { return E.Cause(err, "restore cached rule-set") } s.lastUpdated = savedSet.LastUpdated s.lastEtag = savedSet.LastEtag } } if s.lastUpdated.IsZero() { err = s.fetch(ctx) if err != nil { return E.Cause(err, "initial rule-set: ", s.options.Tag) } } s.updateTicker = time.NewTicker(s.updateInterval) return nil } func (s *RemoteRuleSet) PostStart() error { go s.loopUpdate() 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.options.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) loopUpdate() { if time.Since(s.lastUpdated) > s.updateInterval { err := s.fetch(s.ctx) if err != nil { s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err) } else if s.refs.Load() == 0 { s.rules = nil } } for { runtime.GC() select { case <-s.ctx.Done(): return case <-s.updateTicker.C: s.updateOnce() } } } func (s *RemoteRuleSet) updateOnce() { err := s.fetch(s.ctx) if err != nil { s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err) } else if s.refs.Load() == 0 { s.rules = nil } } func (s *RemoteRuleSet) fetch(ctx context.Context) error { s.logger.Debug("updating rule-set ", s.options.Tag, " from URL: ", s.options.RemoteOptions.URL) request, err := http.NewRequest("GET", s.options.RemoteOptions.URL, nil) if err != nil { return err } if s.lastEtag != "" { request.Header.Set("If-None-Match", s.lastEtag) } response, err := s.httpClient.Do(request.WithContext(ctx)) if err != nil { return err } switch response.StatusCode { case http.StatusOK: case http.StatusNotModified: s.lastUpdated = time.Now() if s.cacheFile != nil { savedRuleSet := s.cacheFile.LoadRuleSet(s.options.Tag) if savedRuleSet != nil { savedRuleSet.LastUpdated = s.lastUpdated err = s.cacheFile.SaveRuleSet(s.options.Tag, savedRuleSet) if err != nil { s.logger.Error("save rule-set updated time: ", err) return nil } } } s.logger.Info("update rule-set ", s.options.Tag, ": not modified") return nil default: return E.New("unexpected status: ", response.Status) } content, err := io.ReadAll(response.Body) if err != nil { response.Body.Close() return err } err = s.loadBytes(content) if err != nil { response.Body.Close() return err } response.Body.Close() eTagHeader := response.Header.Get("Etag") if eTagHeader != "" { s.lastEtag = eTagHeader } s.lastUpdated = time.Now() if s.cacheFile != nil { err = s.cacheFile.SaveRuleSet(s.options.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.options.Tag) return nil } func (s *RemoteRuleSet) resolveTransport(manager adapter.HTTPClientManager) (http.RoundTripper, error) { 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 manager.ResolveTransport(s.logger, *s.options.RemoteOptions.HTTPClient) } if s.options.RemoteOptions.DownloadDetour != "" { //nolint:staticcheck deprecated.Report(s.ctx, deprecated.OptionLegacyRuleSetDownloadDetour) var httpClientOptions option.HTTPClientOptions httpClientOptions.DialerOptions = option.DialerOptions{ Detour: s.options.RemoteOptions.DownloadDetour, //nolint:staticcheck } return manager.ResolveTransport(s.logger, httpClientOptions) } defaultTransport := manager.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() if s.updateTicker != nil { s.updateTicker.Stop() } if s.httpClient != nil { s.httpClient.CloseIdleConnections() } return nil } func (s *RemoteRuleSet) Match(metadata *adapter.InboundContext) bool { return !s.matchStates(metadata).isEmpty() } func (s *RemoteRuleSet) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { return s.matchStatesWithBase(metadata, 0) } func (s *RemoteRuleSet) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { var stateSet ruleMatchStateSet for _, rule := range s.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleMatchCache() stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) } return stateSet }