package script import ( "context" "net/http" "sync" "time" "unsafe" "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/script/jsc" "github.com/sagernet/sing-box/script/modules/surge" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing/common/logger" "github.com/adhocore/gronx" "github.com/dop251/goja" ) const defaultSurgeScriptTimeout = 10 * time.Second var _ adapter.SurgeScript = (*SurgeScript)(nil) type SurgeScript struct { ctx context.Context logger logger.ContextLogger tag string source Source cronExpression string cronTimeout time.Duration cronArguments []string cronTimer *time.Timer cronDone chan struct{} } func NewSurgeScript(ctx context.Context, logger logger.ContextLogger, options option.Script) (adapter.Script, error) { source, err := NewSource(ctx, logger, options) if err != nil { return nil, err } cronOptions := common.PtrValueOrDefault(options.SurgeOptions.CronOptions) if cronOptions.Expression != "" { if !gronx.IsValid(cronOptions.Expression) { return nil, E.New("invalid cron expression: ", cronOptions.Expression) } } return &SurgeScript{ ctx: ctx, logger: logger, tag: options.Tag, source: source, cronExpression: cronOptions.Expression, cronTimeout: time.Duration(cronOptions.Timeout), cronArguments: cronOptions.Arguments, cronDone: make(chan struct{}), }, nil } func (s *SurgeScript) Type() string { return C.ScriptTypeSurge } func (s *SurgeScript) Tag() string { return s.tag } func (s *SurgeScript) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error { return s.source.StartContext(ctx, startContext) } func (s *SurgeScript) PostStart() error { err := s.source.PostStart() if err != nil { return err } if s.cronExpression != "" { go s.loopCronEvents() } return nil } func (s *SurgeScript) loopCronEvents() { s.logger.Debug("starting event") err := s.ExecuteGeneric(s.ctx, "cron", s.cronTimeout, s.cronArguments) if err != nil { s.logger.Error(E.Cause(err, "running event")) } nextTick, err := gronx.NextTick(s.cronExpression, false) if err != nil { s.logger.Error(E.Cause(err, "determine next tick")) return } s.cronTimer = time.NewTimer(nextTick.Sub(time.Now())) s.logger.Debug("next event at: ", nextTick.Format(log.DefaultTimeFormat)) for { select { case <-s.ctx.Done(): return case <-s.cronDone: return case <-s.cronTimer.C: s.logger.Debug("starting event") err = s.ExecuteGeneric(s.ctx, "cron", s.cronTimeout, s.cronArguments) if err != nil { s.logger.Error(E.Cause(err, "running event")) } nextTick, err = gronx.NextTick(s.cronExpression, false) if err != nil { s.logger.Error(E.Cause(err, "determine next tick")) return } s.cronTimer.Reset(nextTick.Sub(time.Now())) s.logger.Debug("configured next event at: ", nextTick) } } } func (s *SurgeScript) Close() error { err := s.source.Close() if s.cronTimer != nil { s.cronTimer.Stop() close(s.cronDone) } return err } func (s *SurgeScript) ExecuteGeneric(ctx context.Context, scriptType string, timeout time.Duration, arguments []string) error { program := s.source.Program() if program == nil { return E.New("invalid script") } ctx, cancel := context.WithCancelCause(ctx) defer cancel(nil) runtime := NewRuntime(ctx, cancel) SetModules(runtime, ctx, s.logger, cancel, s.tag) surge.Enable(runtime, scriptType, arguments) if timeout == 0 { timeout = defaultSurgeScriptTimeout } ctx, timeoutCancel := context.WithTimeout(ctx, timeout) defer timeoutCancel() done := make(chan struct{}) doneFunc := common.OnceFunc(func() { close(done) }) runtime.Set("done", func(call goja.FunctionCall) goja.Value { doneFunc() return goja.Undefined() }) var ( access sync.Mutex scriptErr error ) go func() { _, err := runtime.RunProgram(program) if err != nil { access.Lock() scriptErr = err access.Unlock() doneFunc() } }() select { case <-ctx.Done(): runtime.Interrupt(ctx.Err()) return ctx.Err() case <-done: access.Lock() defer access.Unlock() if scriptErr != nil { runtime.Interrupt(scriptErr) } else { runtime.Interrupt("script done") } } return scriptErr } func (s *SurgeScript) ExecuteHTTPRequest(ctx context.Context, timeout time.Duration, request *http.Request, body []byte, binaryBody bool, arguments []string) (*adapter.HTTPRequestScriptResult, error) { program := s.source.Program() if program == nil { return nil, E.New("invalid script") } ctx, cancel := context.WithCancelCause(ctx) defer cancel(nil) runtime := NewRuntime(ctx, cancel) SetModules(runtime, ctx, s.logger, cancel, s.tag) surge.Enable(runtime, "http-request", arguments) if timeout == 0 { timeout = defaultSurgeScriptTimeout } ctx, timeoutCancel := context.WithTimeout(ctx, timeout) defer timeoutCancel() runtime.ClearInterrupt() requestObject := runtime.NewObject() requestObject.Set("url", request.URL.String()) requestObject.Set("method", request.Method) requestObject.Set("headers", jsc.HeadersToValue(runtime, request.Header)) if !binaryBody { requestObject.Set("body", string(body)) } else { requestObject.Set("body", jsc.NewUint8Array(runtime, body)) } requestObject.Set("id", F.ToString(uintptr(unsafe.Pointer(request)))) runtime.Set("request", requestObject) done := make(chan struct{}) doneFunc := common.OnceFunc(func() { close(done) }) var ( access sync.Mutex result adapter.HTTPRequestScriptResult scriptErr error ) runtime.Set("done", func(call goja.FunctionCall) goja.Value { defer doneFunc() resultObject := jsc.AssertObject(runtime, call.Argument(0), "done() argument", true) if resultObject == nil { panic(runtime.NewGoError(E.New("request rejected by script"))) } access.Lock() defer access.Unlock() result.URL = jsc.AssertString(runtime, resultObject.Get("url"), "url", true) result.Headers = jsc.AssertHTTPHeader(runtime, resultObject.Get("headers"), "headers") result.Body = jsc.AssertStringBinary(runtime, resultObject.Get("body"), "body", true) responseObject := jsc.AssertObject(runtime, resultObject.Get("response"), "response", true) if responseObject != nil { result.Response = &adapter.HTTPRequestScriptResponse{ Status: int(jsc.AssertInt(runtime, responseObject.Get("status"), "status", true)), Headers: jsc.AssertHTTPHeader(runtime, responseObject.Get("headers"), "headers"), Body: jsc.AssertStringBinary(runtime, responseObject.Get("body"), "body", true), } } return goja.Undefined() }) go func() { _, err := runtime.RunProgram(program) if err != nil { access.Lock() scriptErr = err access.Unlock() doneFunc() } }() select { case <-ctx.Done(): runtime.Interrupt(ctx.Err()) return nil, ctx.Err() case <-done: access.Lock() defer access.Unlock() if scriptErr != nil { runtime.Interrupt(scriptErr) } else { runtime.Interrupt("script done") } } return &result, scriptErr } func (s *SurgeScript) ExecuteHTTPResponse(ctx context.Context, timeout time.Duration, request *http.Request, response *http.Response, body []byte, binaryBody bool, arguments []string) (*adapter.HTTPResponseScriptResult, error) { program := s.source.Program() if program == nil { return nil, E.New("invalid script") } ctx, cancel := context.WithCancelCause(ctx) defer cancel(nil) runtime := NewRuntime(ctx, cancel) SetModules(runtime, ctx, s.logger, cancel, s.tag) surge.Enable(runtime, "http-response", arguments) if timeout == 0 { timeout = defaultSurgeScriptTimeout } ctx, timeoutCancel := context.WithTimeout(ctx, timeout) defer timeoutCancel() runtime.ClearInterrupt() requestObject := runtime.NewObject() requestObject.Set("url", request.URL.String()) requestObject.Set("method", request.Method) requestObject.Set("headers", jsc.HeadersToValue(runtime, request.Header)) requestObject.Set("id", F.ToString(uintptr(unsafe.Pointer(request)))) runtime.Set("request", requestObject) responseObject := runtime.NewObject() responseObject.Set("status", response.StatusCode) responseObject.Set("headers", jsc.HeadersToValue(runtime, response.Header)) if !binaryBody { responseObject.Set("body", string(body)) } else { responseObject.Set("body", jsc.NewUint8Array(runtime, body)) } runtime.Set("response", responseObject) done := make(chan struct{}) doneFunc := common.OnceFunc(func() { close(done) }) var ( access sync.Mutex result adapter.HTTPResponseScriptResult scriptErr error ) runtime.Set("done", func(call goja.FunctionCall) goja.Value { resultObject := jsc.AssertObject(runtime, call.Argument(0), "done() argument", true) if resultObject == nil { panic(runtime.NewGoError(E.New("response rejected by script"))) } access.Lock() defer access.Unlock() result.Status = int(jsc.AssertInt(runtime, resultObject.Get("status"), "status", true)) result.Headers = jsc.AssertHTTPHeader(runtime, resultObject.Get("headers"), "headers") result.Body = jsc.AssertStringBinary(runtime, resultObject.Get("body"), "body", true) doneFunc() return goja.Undefined() }) go func() { _, err := runtime.RunProgram(program) if err != nil { access.Lock() scriptErr = err access.Unlock() doneFunc() } }() select { case <-ctx.Done(): runtime.Interrupt(ctx.Err()) return nil, ctx.Err() case <-done: access.Lock() defer access.Unlock() if scriptErr != nil { runtime.Interrupt(scriptErr) } else { runtime.Interrupt("script done") } return &result, scriptErr } }