diff --git a/adapter/inbound.go b/adapter/inbound.go index d874d833..7ee00f1c 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -16,6 +16,7 @@ type Inbound interface { type InboundContext struct { Inbound string + InboundType string Network string Source M.Socksaddr Destination M.Socksaddr diff --git a/adapter/router.go b/adapter/router.go index a848618f..741f3a45 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -34,10 +34,14 @@ type Router interface { AutoDetectInterface() bool AutoDetectInterfaceName() string AutoDetectInterfaceIndex() int + + Rules() []Rule + SetTrafficController(controller TrafficController) } type Rule interface { Service + Type() string UpdateGeosite() error Match(metadata *InboundContext) bool Outbound() string diff --git a/adapter/traffic_controller.go b/adapter/traffic_controller.go new file mode 100644 index 00000000..8d61fb1e --- /dev/null +++ b/adapter/traffic_controller.go @@ -0,0 +1,13 @@ +package adapter + +import ( + "context" + "net" + + N "github.com/sagernet/sing/common/network" +) + +type TrafficController interface { + RoutedConnection(ctx context.Context, conn net.Conn, metadata InboundContext, matchedRule Rule) net.Conn + RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata InboundContext, matchedRule Rule) N.PacketConn +} diff --git a/box.go b/box.go index 9b654547..c1816dbd 100644 --- a/box.go +++ b/box.go @@ -7,6 +7,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/experimental/clashapi" "github.com/sagernet/sing-box/inbound" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" @@ -20,20 +21,27 @@ import ( var _ adapter.Service = (*Box)(nil) type Box struct { - createdAt time.Time - router adapter.Router - inbounds []adapter.Inbound - outbounds []adapter.Outbound - logFactory log.Factory - logger log.ContextLogger - logFile *os.File + createdAt time.Time + router adapter.Router + inbounds []adapter.Inbound + outbounds []adapter.Outbound + logFactory log.Factory + logger log.ContextLogger + logFile *os.File + clashServer *clashapi.Server } func New(ctx context.Context, options option.Options) (*Box, error) { createdAt := time.Now() logOptions := common.PtrValueOrDefault(options.Log) + var needClashAPI bool + if options.Experimental != nil && options.Experimental.ClashAPI != nil && options.Experimental.ClashAPI.ExternalController != "" { + needClashAPI = true + } + var logFactory log.Factory + var observableLogFactory log.ObservableFactory var logFile *os.File if logOptions.Disabled { logFactory = log.NewNOPFactory() @@ -58,7 +66,12 @@ func New(ctx context.Context, options option.Options) (*Box, error) { FullTimestamp: logOptions.Timestamp, TimestampFormat: "-0700 2006-01-02 15:04:05", } - logFactory = log.NewFactory(logFormatter, logWriter) + if needClashAPI { + observableLogFactory = log.NewObservableFactory(logFormatter, logWriter) + logFactory = observableLogFactory + } else { + logFactory = log.NewFactory(logFormatter, logWriter) + } if logOptions.Level != "" { logLevel, err := log.ParseLevel(logOptions.Level) if err != nil { @@ -127,14 +140,21 @@ func New(ctx context.Context, options option.Options) (*Box, error) { if err != nil { return nil, err } + + var clashServer *clashapi.Server + if needClashAPI { + clashServer = clashapi.NewServer(router, observableLogFactory, common.PtrValueOrDefault(options.Experimental.ClashAPI)) + router.SetTrafficController(clashServer) + } return &Box{ - router: router, - inbounds: inbounds, - outbounds: outbounds, - createdAt: createdAt, - logFactory: logFactory, - logger: logFactory.NewLogger(""), - logFile: logFile, + router: router, + inbounds: inbounds, + outbounds: outbounds, + createdAt: createdAt, + logFactory: logFactory, + logger: logFactory.NewLogger(""), + logFile: logFile, + clashServer: clashServer, }, nil } @@ -152,6 +172,12 @@ func (s *Box) Start() error { return err } } + if s.clashServer != nil { + err = s.clashServer.Start() + if err != nil { + return E.Cause(err, "start clash api") + } + } s.logger.Info("sing-box started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)") return nil } @@ -166,5 +192,6 @@ func (s *Box) Close() error { return common.Close( s.router, common.PtrOrNil(s.logFile), + common.PtrOrNil(s.clashServer), ) } diff --git a/cmd/sing-box/cmd_run.go b/cmd/sing-box/cmd_run.go index a4c35052..ff8581fc 100644 --- a/cmd/sing-box/cmd_run.go +++ b/cmd/sing-box/cmd_run.go @@ -32,7 +32,7 @@ func run(cmd *cobra.Command, args []string) { } if disableColor { if options.Log == nil { - options.Log = &option.LogOption{} + options.Log = &option.LogOptions{} } options.Log.DisableColor = true } diff --git a/experimental/clashapi/cache.go b/experimental/clashapi/cache.go new file mode 100644 index 00000000..a9b75bc6 --- /dev/null +++ b/experimental/clashapi/cache.go @@ -0,0 +1,23 @@ +package clashapi + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func cacheRouter() http.Handler { + r := chi.NewRouter() + r.Post("/fakeip/flush", flushFakeip) + return r +} + +func flushFakeip(w http.ResponseWriter, r *http.Request) { + /*if err := cachefile.Cache().FlushFakeip(); err != nil { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, newError(err.Error())) + return + }*/ + render.NoContent(w, r) +} diff --git a/experimental/clashapi/common.go b/experimental/clashapi/common.go new file mode 100644 index 00000000..416289c0 --- /dev/null +++ b/experimental/clashapi/common.go @@ -0,0 +1,17 @@ +package clashapi + +import ( + "net/http" + "net/url" + + "github.com/go-chi/chi/v5" +) + +// When name is composed of a partial escape string, Golang does not unescape it +func getEscapeParam(r *http.Request, paramName string) string { + param := chi.URLParam(r, paramName) + if newParam, err := url.PathUnescape(param); err == nil { + param = newParam + } + return param +} diff --git a/experimental/clashapi/compatible/map.go b/experimental/clashapi/compatible/map.go new file mode 100644 index 00000000..9f4b3671 --- /dev/null +++ b/experimental/clashapi/compatible/map.go @@ -0,0 +1,49 @@ +package compatible + +import "sync" + +// Map is a generics sync.Map +type Map[K comparable, V any] struct { + m sync.Map +} + +func (m *Map[K, V]) Load(key K) (V, bool) { + v, ok := m.m.Load(key) + if !ok { + return *new(V), false + } + + return v.(V), ok +} + +func (m *Map[K, V]) Store(key K, value V) { + m.m.Store(key, value) +} + +func (m *Map[K, V]) Delete(key K) { + m.m.Delete(key) +} + +func (m *Map[K, V]) Range(f func(key K, value V) bool) { + m.m.Range(func(key, value any) bool { + return f(key.(K), value.(V)) + }) +} + +func (m *Map[K, V]) LoadOrStore(key K, value V) (V, bool) { + v, ok := m.m.LoadOrStore(key, value) + return v.(V), ok +} + +func (m *Map[K, V]) LoadAndDelete(key K) (V, bool) { + v, ok := m.m.LoadAndDelete(key) + if !ok { + return *new(V), false + } + + return v.(V), ok +} + +func New[K comparable, V any]() *Map[K, V] { + return &Map[K, V]{m: sync.Map{}} +} diff --git a/experimental/clashapi/configs.go b/experimental/clashapi/configs.go new file mode 100644 index 00000000..7280758e --- /dev/null +++ b/experimental/clashapi/configs.go @@ -0,0 +1,49 @@ +package clashapi + +import ( + "net/http" + + "github.com/sagernet/sing-box/log" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func configRouter(logFactory log.Factory) http.Handler { + r := chi.NewRouter() + r.Get("/", getConfigs(logFactory)) + r.Put("/", updateConfigs) + r.Patch("/", patchConfigs) + return r +} + +type configSchema struct { + Port *int `json:"port"` + SocksPort *int `json:"socks-port"` + RedirPort *int `json:"redir-port"` + TProxyPort *int `json:"tproxy-port"` + MixedPort *int `json:"mixed-port"` + AllowLan *bool `json:"allow-lan"` + BindAddress *string `json:"bind-address"` + Mode string `json:"mode"` + LogLevel string `json:"log-level"` + IPv6 *bool `json:"ipv6"` + Tun any `json:"tun"` +} + +func getConfigs(logFactory log.Factory) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + render.JSON(w, r, &configSchema{ + Mode: "Rule", + LogLevel: log.FormatLevel(logFactory.Level()), + }) + } +} + +func patchConfigs(w http.ResponseWriter, r *http.Request) { + render.NoContent(w, r) +} + +func updateConfigs(w http.ResponseWriter, r *http.Request) { + render.NoContent(w, r) +} diff --git a/experimental/clashapi/connections.go b/experimental/clashapi/connections.go new file mode 100644 index 00000000..0079497e --- /dev/null +++ b/experimental/clashapi/connections.go @@ -0,0 +1,97 @@ +package clashapi + +import ( + "bytes" + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/sagernet/sing-box/experimental/clashapi/trafficontroll" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/gorilla/websocket" +) + +func connectionRouter(trafficManager *trafficontroll.Manager) http.Handler { + r := chi.NewRouter() + r.Get("/", getConnections(trafficManager)) + r.Delete("/", closeAllConnections(trafficManager)) + r.Delete("/{id}", closeConnection(trafficManager)) + return r +} + +func getConnections(trafficManager *trafficontroll.Manager) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if !websocket.IsWebSocketUpgrade(r) { + snapshot := trafficManager.Snapshot() + render.JSON(w, r, snapshot) + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + intervalStr := r.URL.Query().Get("interval") + interval := 1000 + if intervalStr != "" { + t, err := strconv.Atoi(intervalStr) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + + interval = t + } + + buf := &bytes.Buffer{} + sendSnapshot := func() error { + buf.Reset() + snapshot := trafficManager.Snapshot() + if err := json.NewEncoder(buf).Encode(snapshot); err != nil { + return err + } + return conn.WriteMessage(websocket.TextMessage, buf.Bytes()) + } + + if err = sendSnapshot(); err != nil { + return + } + + tick := time.NewTicker(time.Millisecond * time.Duration(interval)) + defer tick.Stop() + for range tick.C { + if err = sendSnapshot(); err != nil { + break + } + } + } +} + +func closeConnection(trafficManager *trafficontroll.Manager) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + snapshot := trafficManager.Snapshot() + for _, c := range snapshot.Connections { + if id == c.ID() { + c.Close() + break + } + } + render.NoContent(w, r) + } +} + +func closeAllConnections(trafficManager *trafficontroll.Manager) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + snapshot := trafficManager.Snapshot() + for _, c := range snapshot.Connections { + c.Close() + } + render.NoContent(w, r) + } +} diff --git a/experimental/clashapi/ctxkeys.go b/experimental/clashapi/ctxkeys.go new file mode 100644 index 00000000..3a888026 --- /dev/null +++ b/experimental/clashapi/ctxkeys.go @@ -0,0 +1,14 @@ +package clashapi + +var ( + CtxKeyProxyName = contextKey("proxy name") + CtxKeyProviderName = contextKey("provider name") + CtxKeyProxy = contextKey("proxy") + CtxKeyProvider = contextKey("provider") +) + +type contextKey string + +func (c contextKey) String() string { + return "clash context key " + string(c) +} diff --git a/experimental/clashapi/errors.go b/experimental/clashapi/errors.go new file mode 100644 index 00000000..7aaf76b7 --- /dev/null +++ b/experimental/clashapi/errors.go @@ -0,0 +1,22 @@ +package clashapi + +var ( + ErrUnauthorized = newError("Unauthorized") + ErrBadRequest = newError("Body invalid") + ErrForbidden = newError("Forbidden") + ErrNotFound = newError("Resource not found") + ErrRequestTimeout = newError("Timeout") +) + +// HTTPError is custom HTTP error for API +type HTTPError struct { + Message string `json:"message"` +} + +func (e *HTTPError) Error() string { + return e.Message +} + +func newError(msg string) *HTTPError { + return &HTTPError{Message: msg} +} diff --git a/experimental/clashapi/profile.go b/experimental/clashapi/profile.go new file mode 100644 index 00000000..4e20754f --- /dev/null +++ b/experimental/clashapi/profile.go @@ -0,0 +1,53 @@ +package clashapi + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func profileRouter() http.Handler { + r := chi.NewRouter() + r.Get("/tracing", subscribeTracing) + return r +} + +func subscribeTracing(w http.ResponseWriter, r *http.Request) { + // if !profile.Tracing.Load() { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + //return + //} + + /*wsConn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + ch := make(chan map[string]any, 1024) + sub := event.Subscribe() + defer event.UnSubscribe(sub) + buf := &bytes.Buffer{} + + go func() { + for elm := range sub { + select { + case ch <- elm: + default: + } + } + close(ch) + }() + + for elm := range ch { + buf.Reset() + if err := json.NewEncoder(buf).Encode(elm); err != nil { + break + } + + if err := wsConn.WriteMessage(websocket.TextMessage, buf.Bytes()); err != nil { + break + } + }*/ +} diff --git a/experimental/clashapi/provider.go b/experimental/clashapi/provider.go new file mode 100644 index 00000000..91b35f3e --- /dev/null +++ b/experimental/clashapi/provider.go @@ -0,0 +1,74 @@ +package clashapi + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func proxyProviderRouter() http.Handler { + r := chi.NewRouter() + r.Get("/", getProviders) + + r.Route("/{name}", func(r chi.Router) { + r.Use(parseProviderName, findProviderByName) + r.Get("/", getProvider) + r.Put("/", updateProvider) + r.Get("/healthcheck", healthCheckProvider) + }) + return r +} + +func getProviders(w http.ResponseWriter, r *http.Request) { + render.JSON(w, r, render.M{ + "providers": []string{}, + }) +} + +func getProvider(w http.ResponseWriter, r *http.Request) { + /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) + render.JSON(w, r, provider)*/ + render.NoContent(w, r) +} + +func updateProvider(w http.ResponseWriter, r *http.Request) { + /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) + if err := provider.Update(); err != nil { + render.Status(r, http.StatusServiceUnavailable) + render.JSON(w, r, newError(err.Error())) + return + }*/ + render.NoContent(w, r) +} + +func healthCheckProvider(w http.ResponseWriter, r *http.Request) { + /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) + provider.HealthCheck()*/ + render.NoContent(w, r) +} + +func parseProviderName(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := getEscapeParam(r, "name") + ctx := context.WithValue(r.Context(), CtxKeyProviderName, name) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func findProviderByName(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + /*name := r.Context().Value(CtxKeyProviderName).(string) + providers := tunnel.ProxyProviders() + provider, exist := providers[name] + if !exist {*/ + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + //return + //} + + // ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) + // next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/experimental/clashapi/proxies.go b/experimental/clashapi/proxies.go new file mode 100644 index 00000000..f1ebd64a --- /dev/null +++ b/experimental/clashapi/proxies.go @@ -0,0 +1,122 @@ +package clashapi + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func proxyRouter() http.Handler { + r := chi.NewRouter() + r.Get("/", getProxies) + + r.Route("/{name}", func(r chi.Router) { + r.Use(parseProxyName, findProxyByName) + r.Get("/", getProxy) + r.Get("/delay", getProxyDelay) + r.Put("/", updateProxy) + }) + return r +} + +func parseProxyName(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := getEscapeParam(r, "name") + ctx := context.WithValue(r.Context(), CtxKeyProxyName, name) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func findProxyByName(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + /*name := r.Context().Value(CtxKeyProxyName).(string) + proxies := tunnel.Proxies() + proxy, exist := proxies[name] + if !exist {*/ + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + return + //} + + // ctx := context.WithValue(r.Context(), CtxKeyProxy, proxy) + // next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func getProxies(w http.ResponseWriter, r *http.Request) { + // proxies := tunnel.Proxies() + render.JSON(w, r, render.M{ + "proxies": []string{}, + }) +} + +func getProxy(w http.ResponseWriter, r *http.Request) { + /* proxy := r.Context().Value(CtxKeyProxy).(C.Proxy) + render.JSON(w, r, proxy)*/ + render.Status(r, http.StatusServiceUnavailable) +} + +type UpdateProxyRequest struct { + Name string `json:"name"` +} + +func updateProxy(w http.ResponseWriter, r *http.Request) { + /* req := UpdateProxyRequest{} + if err := render.DecodeJSON(r.Body, &req); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + + proxy := r.Context().Value(CtxKeyProxy).(*adapter.Proxy) + selector, ok := proxy.ProxyAdapter.(*outboundgroup.Selector) + if !ok { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError("Must be a Selector")) + return + } + + if err := selector.Set(req.Name); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError(fmt.Sprintf("Selector update error: %s", err.Error()))) + return + } + + cachefile.Cache().SetSelected(proxy.Name(), req.Name)*/ + render.NoContent(w, r) +} + +func getProxyDelay(w http.ResponseWriter, r *http.Request) { + /* query := r.URL.Query() + url := query.Get("url") + timeout, err := strconv.ParseInt(query.Get("timeout"), 10, 16) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + + proxy := r.Context().Value(CtxKeyProxy).(C.Proxy) + + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*time.Duration(timeout)) + defer cancel() + + delay, err := proxy.URLTest(ctx, url) + if ctx.Err() != nil { + render.Status(r, http.StatusGatewayTimeout) + render.JSON(w, r, ErrRequestTimeout) + return + } + + if err != nil || delay == 0 { + render.Status(r, http.StatusServiceUnavailable) + render.JSON(w, r, newError("An error occurred in the delay test")) + return + } + */ + render.JSON(w, r, render.M{ + "delay": 114514, + }) +} diff --git a/experimental/clashapi/ruleprovider.go b/experimental/clashapi/ruleprovider.go new file mode 100644 index 00000000..4a410854 --- /dev/null +++ b/experimental/clashapi/ruleprovider.go @@ -0,0 +1,58 @@ +package clashapi + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func ruleProviderRouter() http.Handler { + r := chi.NewRouter() + r.Get("/", getRuleProviders) + + r.Route("/{name}", func(r chi.Router) { + r.Use(parseProviderName, findRuleProviderByName) + r.Get("/", getRuleProvider) + r.Put("/", updateRuleProvider) + }) + return r +} + +func getRuleProviders(w http.ResponseWriter, r *http.Request) { + render.JSON(w, r, render.M{ + "providers": []string{}, + }) +} + +func getRuleProvider(w http.ResponseWriter, r *http.Request) { + // provider := r.Context().Value(CtxKeyProvider).(provider.RuleProvider) + // render.JSON(w, r, provider) + render.NoContent(w, r) +} + +func updateRuleProvider(w http.ResponseWriter, r *http.Request) { + /*provider := r.Context().Value(CtxKeyProvider).(provider.RuleProvider) + if err := provider.Update(); err != nil { + render.Status(r, http.StatusServiceUnavailable) + render.JSON(w, r, newError(err.Error())) + return + }*/ + render.NoContent(w, r) +} + +func findRuleProviderByName(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + /*name := r.Context().Value(CtxKeyProviderName).(string) + providers := tunnel.RuleProviders() + provider, exist := providers[name] + if !exist {*/ + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + //return + //} + + // ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) + // next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/experimental/clashapi/rules.go b/experimental/clashapi/rules.go new file mode 100644 index 00000000..6ab5dda1 --- /dev/null +++ b/experimental/clashapi/rules.go @@ -0,0 +1,41 @@ +package clashapi + +import ( + "net/http" + + "github.com/sagernet/sing-box/adapter" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func ruleRouter(router adapter.Router) http.Handler { + r := chi.NewRouter() + r.Get("/", getRules(router)) + return r +} + +type Rule struct { + Type string `json:"type"` + Payload string `json:"payload"` + Proxy string `json:"proxy"` +} + +func getRules(router adapter.Router) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + rawRules := router.Rules() + + var rules []Rule + for _, rule := range rawRules { + rules = append(rules, Rule{ + Type: rule.Type(), + Payload: rule.String(), + Proxy: rule.Outbound(), + }) + } + + render.JSON(w, r, render.M{ + "rules": rules, + }) + } +} diff --git a/experimental/clashapi/script.go b/experimental/clashapi/script.go new file mode 100644 index 00000000..a7b52f97 --- /dev/null +++ b/experimental/clashapi/script.go @@ -0,0 +1,98 @@ +package clashapi + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func scriptRouter() http.Handler { + r := chi.NewRouter() + r.Post("/", testScript) + r.Patch("/", patchScript) + return r +} + +/*type TestScriptRequest struct { + Script *string `json:"script"` + Metadata C.Metadata `json:"metadata"` +}*/ + +func testScript(w http.ResponseWriter, r *http.Request) { + /* req := TestScriptRequest{} + if err := render.DecodeJSON(r.Body, &req); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + + fn := tunnel.ScriptFn() + if req.Script == nil && fn == nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError("should send `script`")) + return + } + + if !req.Metadata.Valid() { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError("metadata not valid")) + return + } + + if req.Script != nil { + var err error + fn, err = script.ParseScript(*req.Script) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError(err.Error())) + return + } + } + + ctx, _ := script.MakeContext(tunnel.ProxyProviders(), tunnel.RuleProviders()) + + thread := &starlark.Thread{} + ret, err := starlark.Call(thread, fn, starlark.Tuple{ctx, script.MakeMetadata(&req.Metadata)}, nil) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError(err.Error())) + return + } + + elm, ok := ret.(starlark.String) + if !ok { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, "script fn must return a string") + return + } + + render.JSON(w, r, render.M{ + "result": string(elm), + })*/ + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError("not implemented")) +} + +type PatchScriptRequest struct { + Script string `json:"script"` +} + +func patchScript(w http.ResponseWriter, r *http.Request) { + /*req := PatchScriptRequest{} + if err := render.DecodeJSON(r.Body, &req); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + + fn, err := script.ParseScript(req.Script) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, newError(err.Error())) + return + } + + tunnel.UpdateScript(fn)*/ + render.NoContent(w, r) +} diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go new file mode 100644 index 00000000..50b9d4cc --- /dev/null +++ b/experimental/clashapi/server.go @@ -0,0 +1,298 @@ +package clashapi + +import ( + "bytes" + "context" + "net" + "net/http" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/clashapi/trafficontroll" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + N "github.com/sagernet/sing/common/network" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/cors" + "github.com/go-chi/render" + "github.com/goccy/go-json" + "github.com/gorilla/websocket" +) + +var ( + _ adapter.Service = (*Server)(nil) + _ adapter.TrafficController = (*Server)(nil) +) + +type Server struct { + logger log.Logger + httpServer *http.Server + trafficManager *trafficontroll.Manager +} + +func NewServer(router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) *Server { + trafficManager := trafficontroll.NewManager() + chiRouter := chi.NewRouter() + cors := cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, + AllowedHeaders: []string{"Content-Type", "Authorization"}, + MaxAge: 300, + }) + chiRouter.Use(cors.Handler) + chiRouter.Group(func(r chi.Router) { + r.Use(authentication(options.Secret)) + r.Get("/", hello) + r.Get("/logs", getLogs(logFactory)) + r.Get("/traffic", traffic(trafficManager)) + r.Get("/version", version) + r.Mount("/configs", configRouter(logFactory)) + r.Mount("/proxies", proxyRouter()) + r.Mount("/rules", ruleRouter(router)) + r.Mount("/connections", connectionRouter(trafficManager)) + r.Mount("/providers/proxies", proxyProviderRouter()) + r.Mount("/providers/rules", ruleProviderRouter()) + r.Mount("/script", scriptRouter()) + r.Mount("/profile", profileRouter()) + r.Mount("/cache", cacheRouter()) + }) + + return &Server{ + logFactory.NewLogger("clash-api"), + &http.Server{ + Addr: options.ExternalController, + Handler: chiRouter, + }, + trafficManager, + } +} + +func (s *Server) Start() error { + listener, err := net.Listen("tcp", s.httpServer.Addr) + if err != nil { + return E.Cause(err, "external controller listen error") + } + s.logger.Info("restful api listening at ", listener.Addr()) + go func() { + err = s.httpServer.Serve(listener) + if err != nil && !E.IsClosed(err) { + log.Error("external controller serve error: ", err) + } + }() + return nil +} + +func (s *Server) Close() error { + return s.httpServer.Close() +} + +func (s *Server) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule) net.Conn { + return trafficontroll.NewTCPTracker(conn, s.trafficManager, castMetadata(metadata), matchedRule) +} + +func (s *Server) RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, matchedRule adapter.Rule) N.PacketConn { + return trafficontroll.NewUDPTracker(conn, s.trafficManager, castMetadata(metadata), matchedRule) +} + +func castMetadata(metadata adapter.InboundContext) trafficontroll.Metadata { + var inbound string + if metadata.Inbound != "" { + inbound = metadata.InboundType + "/" + metadata.Inbound + } else { + inbound = metadata.InboundType + } + var domain string + if metadata.Domain != "" { + domain = metadata.Domain + } else { + domain = metadata.Destination.Fqdn + } + return trafficontroll.Metadata{ + NetWork: metadata.Network, + Type: inbound, + SrcIP: metadata.Source.Addr, + DstIP: metadata.Destination.Addr, + SrcPort: F.ToString(metadata.Source.Port), + DstPort: F.ToString(metadata.Destination.Port), + Host: domain, + DNSMode: "normal", + } +} + +func authentication(serverSecret string) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + if serverSecret == "" { + next.ServeHTTP(w, r) + return + } + + // Browser websocket not support custom header + if websocket.IsWebSocketUpgrade(r) && r.URL.Query().Get("token") != "" { + token := r.URL.Query().Get("token") + if token != serverSecret { + render.Status(r, http.StatusUnauthorized) + render.JSON(w, r, ErrUnauthorized) + return + } + next.ServeHTTP(w, r) + return + } + + header := r.Header.Get("Authorization") + bearer, token, found := strings.Cut(header, " ") + + hasInvalidHeader := bearer != "Bearer" + hasInvalidSecret := !found || token != serverSecret + if hasInvalidHeader || hasInvalidSecret { + render.Status(r, http.StatusUnauthorized) + render.JSON(w, r, ErrUnauthorized) + return + } + next.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) + } +} + +func hello(w http.ResponseWriter, r *http.Request) { + render.JSON(w, r, render.M{"hello": "clash"}) +} + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +type Traffic struct { + Up int64 `json:"up"` + Down int64 `json:"down"` +} + +func traffic(trafficManager *trafficontroll.Manager) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + var wsConn *websocket.Conn + if websocket.IsWebSocketUpgrade(r) { + var err error + wsConn, err = upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + } + + if wsConn == nil { + w.Header().Set("Content-Type", "application/json") + render.Status(r, http.StatusOK) + } + + tick := time.NewTicker(time.Second) + defer tick.Stop() + buf := &bytes.Buffer{} + var err error + for range tick.C { + buf.Reset() + up, down := trafficManager.Now() + if err := json.NewEncoder(buf).Encode(Traffic{ + Up: up, + Down: down, + }); err != nil { + break + } + + if wsConn == nil { + _, err = w.Write(buf.Bytes()) + w.(http.Flusher).Flush() + } else { + err = wsConn.WriteMessage(websocket.TextMessage, buf.Bytes()) + } + + if err != nil { + break + } + } + } +} + +type Log struct { + Type string `json:"type"` + Payload string `json:"payload"` +} + +func getLogs(logFactory log.ObservableFactory) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + levelText := r.URL.Query().Get("level") + if levelText == "" { + levelText = "info" + } + + level, ok := log.ParseLevel(levelText) + if ok != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + + var wsConn *websocket.Conn + if websocket.IsWebSocketUpgrade(r) { + var err error + wsConn, err = upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + } + + if wsConn == nil { + w.Header().Set("Content-Type", "application/json") + render.Status(r, http.StatusOK) + } + + subscription, done, err := logFactory.Subscribe() + if err != nil { + log.Warn(err) + render.Status(r, http.StatusInternalServerError) + return + } + defer logFactory.UnSubscribe(subscription) + + buf := &bytes.Buffer{} + var logEntry log.Entry + for { + select { + case <-done: + return + case logEntry = <-subscription: + } + if logEntry.Level > level { + continue + } + buf.Reset() + err = json.NewEncoder(buf).Encode(Log{ + Type: log.FormatLevel(logEntry.Level), + Payload: logEntry.Message, + }) + if err != nil { + break + } + if wsConn == nil { + _, err = w.Write(buf.Bytes()) + w.(http.Flusher).Flush() + } else { + err = wsConn.WriteMessage(websocket.TextMessage, buf.Bytes()) + } + + if err != nil { + break + } + } + } +} + +func version(w http.ResponseWriter, r *http.Request) { + render.JSON(w, r, render.M{"version": "sing-box " + C.Version, "premium": false}) +} diff --git a/experimental/clashapi/trafficontroll/manager.go b/experimental/clashapi/trafficontroll/manager.go new file mode 100644 index 00000000..60f8edfe --- /dev/null +++ b/experimental/clashapi/trafficontroll/manager.go @@ -0,0 +1,94 @@ +package trafficontroll + +import ( + "time" + + "github.com/sagernet/sing-box/experimental/clashapi/compatible" + + "go.uber.org/atomic" +) + +type Manager struct { + connections compatible.Map[string, tracker] + uploadTemp *atomic.Int64 + downloadTemp *atomic.Int64 + uploadBlip *atomic.Int64 + downloadBlip *atomic.Int64 + uploadTotal *atomic.Int64 + downloadTotal *atomic.Int64 +} + +func NewManager() *Manager { + manager := &Manager{ + uploadTemp: atomic.NewInt64(0), + downloadTemp: atomic.NewInt64(0), + uploadBlip: atomic.NewInt64(0), + downloadBlip: atomic.NewInt64(0), + uploadTotal: atomic.NewInt64(0), + downloadTotal: atomic.NewInt64(0), + } + go manager.handle() + return manager +} + +func (m *Manager) Join(c tracker) { + m.connections.Store(c.ID(), c) +} + +func (m *Manager) Leave(c tracker) { + m.connections.Delete(c.ID()) +} + +func (m *Manager) PushUploaded(size int64) { + m.uploadTemp.Add(size) + m.uploadTotal.Add(size) +} + +func (m *Manager) PushDownloaded(size int64) { + m.downloadTemp.Add(size) + m.downloadTotal.Add(size) +} + +func (m *Manager) Now() (up int64, down int64) { + return m.uploadBlip.Load(), m.downloadBlip.Load() +} + +func (m *Manager) Snapshot() *Snapshot { + connections := []tracker{} + m.connections.Range(func(_ string, value tracker) bool { + connections = append(connections, value) + return true + }) + + return &Snapshot{ + UploadTotal: m.uploadTotal.Load(), + DownloadTotal: m.downloadTotal.Load(), + Connections: connections, + } +} + +func (m *Manager) ResetStatistic() { + m.uploadTemp.Store(0) + m.uploadBlip.Store(0) + m.uploadTotal.Store(0) + m.downloadTemp.Store(0) + m.downloadBlip.Store(0) + m.downloadTotal.Store(0) +} + +func (m *Manager) handle() { + ticker := time.NewTicker(time.Second) + + for range ticker.C { + m.uploadBlip.Store(m.uploadTemp.Load()) + m.uploadTemp.Store(0) + m.downloadBlip.Store(m.downloadTemp.Load()) + m.downloadTemp.Store(0) + } +} + +type Snapshot struct { + DownloadTotal int64 `json:"downloadTotal"` + UploadTotal int64 `json:"uploadTotal"` + Connections []tracker `json:"connections"` +} diff --git a/experimental/clashapi/trafficontroll/tracker.go b/experimental/clashapi/trafficontroll/tracker.go new file mode 100644 index 00000000..9676e128 --- /dev/null +++ b/experimental/clashapi/trafficontroll/tracker.go @@ -0,0 +1,162 @@ +package trafficontroll + +import ( + "net" + "net/netip" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/buf" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/gofrs/uuid" + "go.uber.org/atomic" +) + +type Metadata struct { + NetWork string `json:"network"` + Type string `json:"type"` + SrcIP netip.Addr `json:"sourceIP"` + DstIP netip.Addr `json:"destinationIP"` + SrcPort string `json:"sourcePort"` + DstPort string `json:"destinationPort"` + Host string `json:"host"` + DNSMode string `json:"dnsMode"` + ProcessPath string `json:"processPath"` +} + +type tracker interface { + ID() string + Close() error +} + +type trackerInfo struct { + UUID uuid.UUID `json:"id"` + Metadata Metadata `json:"metadata"` + UploadTotal *atomic.Int64 `json:"upload"` + DownloadTotal *atomic.Int64 `json:"download"` + Start time.Time `json:"start"` + Chain []string `json:"chains"` + Rule string `json:"rule"` + RulePayload string `json:"rulePayload"` +} + +type tcpTracker struct { + net.Conn `json:"-"` + *trackerInfo + manager *Manager +} + +func (tt *tcpTracker) ID() string { + return tt.UUID.String() +} + +func (tt *tcpTracker) Read(b []byte) (int, error) { + n, err := tt.Conn.Read(b) + download := int64(n) + tt.manager.PushDownloaded(download) + tt.DownloadTotal.Add(download) + return n, err +} + +func (tt *tcpTracker) Write(b []byte) (int, error) { + n, err := tt.Conn.Write(b) + upload := int64(n) + tt.manager.PushUploaded(upload) + tt.UploadTotal.Add(upload) + return n, err +} + +func (tt *tcpTracker) Close() error { + tt.manager.Leave(tt) + return tt.Conn.Close() +} + +func NewTCPTracker(conn net.Conn, manager *Manager, metadata Metadata, rule adapter.Rule) *tcpTracker { + uuid, _ := uuid.NewV4() + + t := &tcpTracker{ + Conn: conn, + manager: manager, + trackerInfo: &trackerInfo{ + UUID: uuid, + Start: time.Now(), + Metadata: metadata, + Chain: []string{}, + Rule: "", + UploadTotal: atomic.NewInt64(0), + DownloadTotal: atomic.NewInt64(0), + }, + } + + if rule != nil { + t.trackerInfo.Rule = rule.Outbound() + t.trackerInfo.RulePayload = rule.String() + } + + manager.Join(t) + return t +} + +type udpTracker struct { + N.PacketConn `json:"-"` + *trackerInfo + manager *Manager +} + +func (ut *udpTracker) ID() string { + return ut.UUID.String() +} + +func (ut *udpTracker) ReadPacket(buffer *buf.Buffer) (destination M.Socksaddr, err error) { + destination, err = ut.PacketConn.ReadPacket(buffer) + if err == nil { + download := int64(buffer.Len()) + ut.manager.PushDownloaded(download) + ut.DownloadTotal.Add(download) + } + return +} + +func (ut *udpTracker) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error { + upload := int64(buffer.Len()) + err := ut.PacketConn.WritePacket(buffer, destination) + if err != nil { + return err + } + ut.manager.PushUploaded(upload) + ut.UploadTotal.Add(upload) + return nil +} + +func (ut *udpTracker) Close() error { + ut.manager.Leave(ut) + return ut.PacketConn.Close() +} + +func NewUDPTracker(conn N.PacketConn, manager *Manager, metadata Metadata, rule adapter.Rule) *udpTracker { + uuid, _ := uuid.NewV4() + + ut := &udpTracker{ + PacketConn: conn, + manager: manager, + trackerInfo: &trackerInfo{ + UUID: uuid, + Start: time.Now(), + Metadata: metadata, + Chain: []string{}, + Rule: "", + UploadTotal: atomic.NewInt64(0), + DownloadTotal: atomic.NewInt64(0), + }, + } + + if rule != nil { + ut.trackerInfo.Rule = rule.Outbound() + ut.trackerInfo.RulePayload = rule.String() + } + + manager.Join(ut) + return ut +} diff --git a/go.mod b/go.mod index 578d4ec1..9398c276 100644 --- a/go.mod +++ b/go.mod @@ -4,25 +4,29 @@ go 1.18 require ( github.com/database64128/tfo-go v1.1.0 + github.com/go-chi/chi/v5 v5.0.7 + github.com/go-chi/cors v1.2.1 + github.com/go-chi/render v1.0.1 github.com/goccy/go-json v0.9.10 + github.com/gorilla/websocket v1.5.0 github.com/logrusorgru/aurora v2.0.3+incompatible github.com/oschwald/maxminddb-golang v1.9.0 github.com/sagernet/sing v0.0.0-20220718035659-3d74b823ed56 github.com/sagernet/sing-dns v0.0.0-20220711062726-c64e938e4619 github.com/sagernet/sing-shadowsocks v0.0.0-20220717063942-45a2ad9cd41f github.com/sagernet/sing-tun v0.0.0-20220717030718-f53aabff275f + github.com/sagernet/sing-vmess v0.0.0-20220718031323-07c377156e4a github.com/spf13/cobra v1.5.0 github.com/stretchr/testify v1.8.0 + go.uber.org/atomic v1.9.0 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d golang.org/x/net v0.0.0-20220708220712-1185a9018129 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 ) -require github.com/sagernet/sing-vmess v0.0.0-20220718031323-07c377156e4a - require ( github.com/davecgh/go-spew v1.1.1 // indirect; indirectg - github.com/gofrs/uuid v4.2.0+incompatible // indirect + github.com/gofrs/uuid v4.2.0+incompatible github.com/google/btree v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/klauspost/cpuid/v2 v2.0.12 // indirect diff --git a/go.sum b/go.sum index 069efa00..856940c6 100644 --- a/go.sum +++ b/go.sum @@ -4,12 +4,20 @@ github.com/database64128/tfo-go v1.1.0/go.mod h1:95pOT8bnV3P2Lmu9upHNWFHz6dYGJ9c github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= +github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8= +github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= github.com/goccy/go-json v0.9.10 h1:hCeNmprSNLB8B8vQKWl6DpuH0t60oEs+TAk9a7CScKc= github.com/goccy/go-json v0.9.10/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -43,6 +51,7 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -51,6 +60,8 @@ github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYp github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695APm9hlsSMoOoE65U4/TcqNj90mc69Rlg= github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/net v0.0.0-20220708220712-1185a9018129 h1:vucSRfWwTsoXro7P+3Cjlr6flUMtzCwzlvkxEQtHHB0= diff --git a/inbound/default.go b/inbound/default.go index 6ea59f43..06808dfa 100644 --- a/inbound/default.go +++ b/inbound/default.go @@ -158,6 +158,7 @@ func (a *myInboundAdapter) loopTCPIn() { ctx := log.ContextWithNewID(a.ctx) var metadata adapter.InboundContext metadata.Inbound = a.tag + metadata.InboundType = a.protocol metadata.SniffEnabled = a.listenOptions.SniffEnabled metadata.SniffOverrideDestination = a.listenOptions.SniffOverrideDestination metadata.DomainStrategy = dns.DomainStrategy(a.listenOptions.DomainStrategy) @@ -191,6 +192,7 @@ func (a *myInboundAdapter) loopUDPIn() { buffer.Truncate(n) var metadata adapter.InboundContext metadata.Inbound = a.tag + metadata.InboundType = a.protocol metadata.SniffEnabled = a.listenOptions.SniffEnabled metadata.SniffOverrideDestination = a.listenOptions.SniffOverrideDestination metadata.DomainStrategy = dns.DomainStrategy(a.listenOptions.DomainStrategy) @@ -222,6 +224,7 @@ func (a *myInboundAdapter) loopUDPOOBIn() { buffer.Truncate(n) var metadata adapter.InboundContext metadata.Inbound = a.tag + metadata.InboundType = a.protocol metadata.SniffEnabled = a.listenOptions.SniffEnabled metadata.SniffOverrideDestination = a.listenOptions.SniffOverrideDestination metadata.DomainStrategy = dns.DomainStrategy(a.listenOptions.DomainStrategy) @@ -247,6 +250,7 @@ func (a *myInboundAdapter) loopUDPInThreadSafe() { buffer.Truncate(n) var metadata adapter.InboundContext metadata.Inbound = a.tag + metadata.InboundType = a.protocol metadata.SniffEnabled = a.listenOptions.SniffEnabled metadata.SniffOverrideDestination = a.listenOptions.SniffOverrideDestination metadata.DomainStrategy = dns.DomainStrategy(a.listenOptions.DomainStrategy) @@ -274,6 +278,7 @@ func (a *myInboundAdapter) loopUDPOOBInThreadSafe() { buffer.Truncate(n) var metadata adapter.InboundContext metadata.Inbound = a.tag + metadata.InboundType = a.protocol metadata.SniffEnabled = a.listenOptions.SniffEnabled metadata.SniffOverrideDestination = a.listenOptions.SniffOverrideDestination metadata.DomainStrategy = dns.DomainStrategy(a.listenOptions.DomainStrategy) diff --git a/inbound/tun.go b/inbound/tun.go index da3b66b4..06a5e49c 100644 --- a/inbound/tun.go +++ b/inbound/tun.go @@ -102,6 +102,7 @@ func (t *Tun) NewConnection(ctx context.Context, conn net.Conn, upstreamMetadata ctx = log.ContextWithNewID(ctx) var metadata adapter.InboundContext metadata.Inbound = t.tag + metadata.InboundType = C.TypeTun metadata.Network = C.NetworkTCP metadata.Source = upstreamMetadata.Source metadata.Destination = upstreamMetadata.Destination @@ -122,6 +123,7 @@ func (t *Tun) NewPacketConnection(ctx context.Context, conn N.PacketConn, upstre ctx = log.ContextWithNewID(ctx) var metadata adapter.InboundContext metadata.Inbound = t.tag + metadata.InboundType = C.TypeTun metadata.Network = C.NetworkUDP metadata.Source = upstreamMetadata.Source metadata.Destination = upstreamMetadata.Destination diff --git a/log/format.go b/log/format.go index 7df870a7..1c2420bd 100644 --- a/log/format.go +++ b/log/format.go @@ -78,6 +78,66 @@ func (f Formatter) Format(ctx context.Context, level Level, tag string, message return message } +func (f Formatter) FormatWithSimple(ctx context.Context, level Level, tag string, message string, timestamp time.Time) (string, string) { + levelString := strings.ToUpper(FormatLevel(level)) + if !f.DisableColors { + switch level { + case LevelDebug, LevelTrace: + levelString = aurora.White(levelString).String() + case LevelInfo: + levelString = aurora.Cyan(levelString).String() + case LevelWarn: + levelString = aurora.Yellow(levelString).String() + case LevelError, LevelFatal, LevelPanic: + levelString = aurora.Red(levelString).String() + } + } + if tag != "" { + message = tag + ": " + message + } + messageSimple := message + var id uint32 + var hasId bool + if ctx != nil { + id, hasId = IDFromContext(ctx) + } + if hasId { + if !f.DisableColors { + var color aurora.Color + color = aurora.Color(uint8(id)) + color %= 215 + row := uint(color / 36) + column := uint(color % 36) + + var r, g, b float32 + r = float32(row * 51) + g = float32(column / 6 * 51) + b = float32((column % 6) * 51) + luma := 0.2126*r + 0.7152*g + 0.0722*b + if luma < 60 { + row = 5 - row + column = 35 - column + color = aurora.Color(row*36 + column) + } + color += 16 + color = color << 16 + color |= 1 << 14 + message = F.ToString("[", aurora.Colorize(id, color).String(), "] ", message) + } else { + message = F.ToString("[", id, "] ", message) + } + } + switch { + case f.DisableTimestamp: + message = levelString + " " + message + case f.FullTimestamp: + message = F.ToString(int(timestamp.Sub(f.BaseTime)/time.Second)) + " " + levelString + " " + message + default: + message = levelString + "[" + xd(int(timestamp.Sub(f.BaseTime)/time.Second), 4) + "] " + message + } + return message, messageSimple +} + func xd(value int, x int) string { message := strconv.Itoa(value) for len(message) < x { diff --git a/log/observable.go b/log/observable.go index ce0ed34a..f0379fd5 100644 --- a/log/observable.go +++ b/log/observable.go @@ -66,17 +66,16 @@ func (l *observableLogger) Log(ctx context.Context, level Level, args []any) { if level > l.level { return } - message := l.formatter.Format(ctx, level, l.tag, F.ToString(args...), time.Now()) + "\n" + message, messageSimple := l.formatter.FormatWithSimple(ctx, level, l.tag, F.ToString(args...), time.Now()) if level == LevelPanic { panic(message) } l.writer.Write([]byte(message)) + l.writer.Write([]byte{'\n'}) if level == LevelFatal { os.Exit(1) } - if l.subscriber != nil { - l.subscriber.Emit(Entry{level, message}) - } + l.subscriber.Emit(Entry{level, messageSimple}) } func (l *observableLogger) Trace(args ...any) { diff --git a/option/config.go b/option/config.go index f0c7e26f..f149cb2c 100644 --- a/option/config.go +++ b/option/config.go @@ -11,11 +11,12 @@ import ( ) type _Options struct { - Log *LogOption `json:"log,omitempty"` - DNS *DNSOptions `json:"dns,omitempty"` - Inbounds []Inbound `json:"inbounds,omitempty"` - Outbounds []Outbound `json:"outbounds,omitempty"` - Route *RouteOptions `json:"route,omitempty"` + Log *LogOptions `json:"log,omitempty"` + DNS *DNSOptions `json:"dns,omitempty"` + Inbounds []Inbound `json:"inbounds,omitempty"` + Outbounds []Outbound `json:"outbounds,omitempty"` + Route *RouteOptions `json:"route,omitempty"` + Experimental *ExperimentalOptions `json:"experimental,omitempty"` } type Options _Options @@ -41,10 +42,11 @@ func (o Options) Equals(other Options) bool { common.PtrEquals(o.DNS, other.DNS) && common.SliceEquals(o.Inbounds, other.Inbounds) && common.ComparableSliceEquals(o.Outbounds, other.Outbounds) && - common.PtrEquals(o.Route, other.Route) + common.PtrEquals(o.Route, other.Route) && + common.ComparablePtrEquals(o.Experimental, other.Experimental) } -type LogOption struct { +type LogOptions struct { Disabled bool `json:"disabled,omitempty"` Level string `json:"level,omitempty"` Output string `json:"output,omitempty"` diff --git a/option/experimental.go b/option/experimental.go new file mode 100644 index 00000000..10fcc661 --- /dev/null +++ b/option/experimental.go @@ -0,0 +1,10 @@ +package option + +type ExperimentalOptions struct { + ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"` +} + +type ClashAPIOptions struct { + ExternalController string `json:"external_controller,omitempty"` + Secret string `json:"secret,omitempty"` +} diff --git a/route/router.go b/route/router.go index bd55aee3..25e42d69 100644 --- a/route/router.go +++ b/route/router.go @@ -69,6 +69,8 @@ type Router struct { autoDetectInterface bool defaultInterface string interfaceMonitor DefaultInterfaceMonitor + + trafficController adapter.TrafficController } func NewRouter(ctx context.Context, logger log.ContextLogger, dnsLogger log.ContextLogger, options option.RouteOptions, dnsOptions option.DNSOptions) (*Router, error) { @@ -438,11 +440,14 @@ func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata ad metadata.DestinationAddresses = addresses r.dnsLogger.DebugContext(ctx, "resolved [", strings.Join(F.MapToString(metadata.DestinationAddresses), " "), "]") } - detour := r.match(ctx, metadata, r.defaultOutboundForConnection) + matchedRule, detour := r.match(ctx, metadata, r.defaultOutboundForConnection) if !common.Contains(detour.Network(), C.NetworkTCP) { conn.Close() return E.New("missing supported outbound, closing connection") } + if r.trafficController != nil { + conn = r.trafficController.RoutedConnection(ctx, conn, metadata, matchedRule) + } return detour.NewConnection(ctx, conn, metadata) } @@ -480,11 +485,14 @@ func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, m metadata.DestinationAddresses = addresses r.dnsLogger.DebugContext(ctx, "resolved [", strings.Join(F.MapToString(metadata.DestinationAddresses), " "), "]") } - detour := r.match(ctx, metadata, r.defaultOutboundForPacketConnection) + matchedRule, detour := r.match(ctx, metadata, r.defaultOutboundForPacketConnection) if !common.Contains(detour.Network(), C.NetworkUDP) { conn.Close() return E.New("missing supported outbound, closing packet connection") } + if r.trafficController != nil { + conn = r.trafficController.RoutedPacketConnection(ctx, conn, metadata, matchedRule) + } return detour.NewPacketConnection(ctx, conn, metadata) } @@ -500,18 +508,18 @@ func (r *Router) LookupDefault(ctx context.Context, domain string) ([]netip.Addr return r.dnsClient.Lookup(ctx, r.matchDNS(ctx), domain, r.defaultDomainStrategy) } -func (r *Router) match(ctx context.Context, metadata adapter.InboundContext, defaultOutbound adapter.Outbound) adapter.Outbound { +func (r *Router) match(ctx context.Context, metadata adapter.InboundContext, defaultOutbound adapter.Outbound) (adapter.Rule, adapter.Outbound) { for i, rule := range r.rules { if rule.Match(&metadata) { detour := rule.Outbound() r.logger.DebugContext(ctx, "match[", i, "] ", rule.String(), " => ", detour) if outbound, loaded := r.Outbound(detour); loaded { - return outbound + return rule, outbound } r.logger.ErrorContext(ctx, "outbound not found: ", detour) } } - return defaultOutbound + return nil, defaultOutbound } func (r *Router) matchDNS(ctx context.Context) dns.Transport { @@ -559,6 +567,14 @@ func (r *Router) AutoDetectInterfaceIndex() int { return r.interfaceMonitor.DefaultInterfaceIndex() } +func (r *Router) Rules() []adapter.Rule { + return r.rules +} + +func (r *Router) SetTrafficController(controller adapter.TrafficController) { + r.trafficController = controller +} + func hasGeoRule(rules []option.Rule, cond func(rule option.DefaultRule) bool) bool { for _, rule := range rules { switch rule.Type { diff --git a/route/rule.go b/route/rule.go index 1647815c..203c2567 100644 --- a/route/rule.go +++ b/route/rule.go @@ -48,6 +48,10 @@ type DefaultRule struct { outbound string } +func (r *DefaultRule) Type() string { + return C.RuleTypeDefault +} + type RuleItem interface { Match(metadata *adapter.InboundContext) bool String() string @@ -238,6 +242,10 @@ type LogicalRule struct { outbound string } +func (r *LogicalRule) Type() string { + return C.RuleTypeLogical +} + func (r *LogicalRule) UpdateGeosite() error { for _, rule := range r.rules { err := rule.UpdateGeosite() diff --git a/route/rule_dns.go b/route/rule_dns.go index 8635a1d1..7cd22384 100644 --- a/route/rule_dns.go +++ b/route/rule_dns.go @@ -47,6 +47,10 @@ type DefaultDNSRule struct { outbound string } +func (r *DefaultDNSRule) Type() string { + return C.RuleTypeDefault +} + func NewDefaultDNSRule(router adapter.Router, logger log.ContextLogger, options option.DefaultDNSRule) (*DefaultDNSRule, error) { rule := &DefaultDNSRule{ outbound: options.Server, @@ -199,6 +203,10 @@ type LogicalDNSRule struct { outbound string } +func (r *LogicalDNSRule) Type() string { + return C.RuleTypeLogical +} + func (r *LogicalDNSRule) UpdateGeosite() error { for _, rule := range r.rules { err := rule.UpdateGeosite() diff --git a/test/go.mod b/test/go.mod index 4256bf3d..d56292e2 100644 --- a/test/go.mod +++ b/test/go.mod @@ -22,9 +22,13 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/docker/distribution v2.8.1+incompatible // indirect github.com/docker/go-units v0.4.0 // indirect + github.com/go-chi/chi/v5 v5.0.7 // indirect + github.com/go-chi/cors v1.2.1 // indirect + github.com/go-chi/render v1.0.1 // indirect github.com/goccy/go-json v0.9.10 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.0.1 // indirect + github.com/gorilla/websocket v1.5.0 // indirect github.com/klauspost/cpuid/v2 v2.0.12 // indirect github.com/kr/text v0.2.0 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect @@ -42,6 +46,7 @@ require ( github.com/sirupsen/logrus v1.8.1 // indirect github.com/vishvananda/netlink v1.1.0 // indirect github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 // indirect + go.uber.org/atomic v1.9.0 // indirect golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect golang.org/x/time v0.0.0-20191024005414-555d28b269f0 // indirect diff --git a/test/go.sum b/test/go.sum index 3eba11d8..dbfe2620 100644 --- a/test/go.sum +++ b/test/go.sum @@ -17,6 +17,12 @@ github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKoh github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= +github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8= +github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= github.com/goccy/go-json v0.9.10 h1:hCeNmprSNLB8B8vQKWl6DpuH0t60oEs+TAk9a7CScKc= github.com/goccy/go-json v0.9.10/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= @@ -29,6 +35,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -73,6 +81,7 @@ github.com/spyzhov/ajson v0.7.1/go.mod h1:63V+CGM6f1Bu/p4nLIN8885ojBdt88TbLoSFzy github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -83,6 +92,8 @@ github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695AP github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/test/shadowsocks_test.go b/test/shadowsocks_test.go index 3a681458..08ee5c36 100644 --- a/test/shadowsocks_test.go +++ b/test/shadowsocks_test.go @@ -75,7 +75,7 @@ func testShadowsocksInboundWithShadowsocksRust(t *testing.T, method string, pass Cmd: []string{"-s", F.ToString("127.0.0.1:", serverPort), "-b", F.ToString("0.0.0.0:", clientPort), "-m", method, "-k", password, "-U"}, }) startInstance(t, option.Options{ - Log: &option.LogOption{ + Log: &option.LogOptions{ Level: "error", }, Inbounds: []option.Inbound{ @@ -107,7 +107,7 @@ func testShadowsocksOutboundWithShadowsocksRust(t *testing.T, method string, pas Cmd: []string{"-s", F.ToString("0.0.0.0:", serverPort), "-m", method, "-k", password, "-U"}, }) startInstance(t, option.Options{ - Log: &option.LogOption{ + Log: &option.LogOptions{ Level: "error", }, Inbounds: []option.Inbound{ @@ -144,7 +144,7 @@ func testShadowsocksSelf(t *testing.T, method string, password string) { clientPort := mkPort(t) testPort := mkPort(t) startInstance(t, option.Options{ - Log: &option.LogOption{ + Log: &option.LogOptions{ Level: "error", }, Inbounds: []option.Inbound{ diff --git a/test/vmess_test.go b/test/vmess_test.go index 17ce3edf..3e26fbc4 100644 --- a/test/vmess_test.go +++ b/test/vmess_test.go @@ -139,7 +139,7 @@ func testVMessInboundWithV2Ray(t *testing.T, security string, uuid uuid.UUID, au }) startInstance(t, option.Options{ - Log: &option.LogOption{ + Log: &option.LogOptions{ Level: "error", }, Inbounds: []option.Inbound{ @@ -193,7 +193,7 @@ func testVMessOutboundWithV2Ray(t *testing.T, security string, uuid uuid.UUID, g }) startInstance(t, option.Options{ - Log: &option.LogOption{ + Log: &option.LogOptions{ Level: "error", }, Inbounds: []option.Inbound{ @@ -233,7 +233,7 @@ func testVMessSelf(t *testing.T, security string, uuid uuid.UUID, globalPadding clientPort := mkPort(t) testPort := mkPort(t) startInstance(t, option.Options{ - Log: &option.LogOption{ + Log: &option.LogOptions{ Level: "error", }, Inbounds: []option.Inbound{