sing-box/script/source_remote.go
2025-02-02 17:27:29 +08:00

224 lines
5.8 KiB
Go

package script
import (
"context"
"crypto/tls"
"io"
"net"
"net/http"
"runtime"
"time"
"github.com/sagernet/sing-box/adapter"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
F "github.com/sagernet/sing/common/format"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/common/ntp"
"github.com/sagernet/sing/service"
"github.com/sagernet/sing/service/pause"
"github.com/dop251/goja"
)
var _ Source = (*RemoteSource)(nil)
type RemoteSource struct {
ctx context.Context
cancel context.CancelFunc
logger logger.Logger
outbound adapter.OutboundManager
options option.Script
updateInterval time.Duration
dialer N.Dialer
program *goja.Program
lastUpdated time.Time
lastEtag string
updateTicker *time.Ticker
cacheFile adapter.CacheFile
pauseManager pause.Manager
}
func NewRemoteSource(ctx context.Context, logger logger.Logger, options option.Script) (*RemoteSource, 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
}
return &RemoteSource{
ctx: ctx,
cancel: cancel,
logger: logger,
outbound: service.FromContext[adapter.OutboundManager](ctx),
options: options,
updateInterval: updateInterval,
pauseManager: service.FromContext[pause.Manager](ctx),
}, nil
}
func (s *RemoteSource) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error {
s.cacheFile = service.FromContext[adapter.CacheFile](s.ctx)
var dialer N.Dialer
if s.options.RemoteOptions.DownloadDetour != "" {
outbound, loaded := s.outbound.Outbound(s.options.RemoteOptions.DownloadDetour)
if !loaded {
return E.New("download detour not found: ", s.options.RemoteOptions.DownloadDetour)
}
dialer = outbound
} else {
dialer = s.outbound.Default()
}
s.dialer = dialer
if s.cacheFile != nil {
if savedSet := s.cacheFile.LoadScript(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.fetchOnce(ctx, startContext)
if err != nil {
return E.Cause(err, "initial rule-set: ", s.options.Tag)
}
}
s.updateTicker = time.NewTicker(s.updateInterval)
return nil
}
func (s *RemoteSource) PostStart() error {
go s.loopUpdate()
return nil
}
func (s *RemoteSource) Program() *goja.Program {
return s.program
}
func (s *RemoteSource) loadBytes(content []byte) error {
program, err := goja.Compile(F.ToString("script:", s.options.Tag), string(content), false)
if err != nil {
return err
}
s.program = program
return nil
}
func (s *RemoteSource) loopUpdate() {
if time.Since(s.lastUpdated) > s.updateInterval {
err := s.fetchOnce(s.ctx, nil)
if err != nil {
s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err)
}
}
for {
runtime.GC()
select {
case <-s.ctx.Done():
return
case <-s.updateTicker.C:
s.pauseManager.WaitActive()
err := s.fetchOnce(s.ctx, nil)
if err != nil {
s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err)
}
}
}
}
func (s *RemoteSource) fetchOnce(ctx context.Context, startContext *adapter.HTTPStartContext) error {
s.logger.Debug("updating script ", s.options.Tag, " from URL: ", s.options.RemoteOptions.URL)
var httpClient *http.Client
if startContext != nil {
httpClient = startContext.HTTPClient(s.options.RemoteOptions.DownloadDetour, s.dialer)
} else {
httpClient = &http.Client{
Transport: &http.Transport{
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: C.TCPTimeout,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return s.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
TLSClientConfig: &tls.Config{
Time: ntp.TimeFuncFromContext(s.ctx),
RootCAs: adapter.RootPoolFromContext(s.ctx),
},
},
}
}
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 := 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.LoadScript(s.options.Tag)
if savedRuleSet != nil {
savedRuleSet.LastUpdated = s.lastUpdated
err = s.cacheFile.SaveScript(s.options.Tag, savedRuleSet)
if err != nil {
s.logger.Error("save script updated time: ", err)
return nil
}
}
}
s.logger.Info("update script ", 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.SaveScript(s.options.Tag, &adapter.SavedBinary{
LastUpdated: s.lastUpdated,
Content: content,
LastEtag: s.lastEtag,
})
if err != nil {
s.logger.Error("save script cache: ", err)
}
}
s.logger.Info("updated script ", s.options.Tag)
return nil
}
func (s *RemoteSource) Close() error {
if s.updateTicker != nil {
s.updateTicker.Stop()
}
s.cancel()
return nil
}