Explorar o código

Add basic clash api

世界 %!s(int64=3) %!d(string=hai) anos
pai
achega
c5b3e8b042

+ 1 - 0
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

+ 4 - 0
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

+ 13 - 0
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
+}

+ 42 - 15
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),
 	)
 }

+ 1 - 1
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
 	}

+ 23 - 0
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)
+}

+ 17 - 0
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
+}

+ 49 - 0
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{}}
+}

+ 49 - 0
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)
+}

+ 97 - 0
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)
+	}
+}

+ 14 - 0
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)
+}

+ 22 - 0
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}
+}

+ 53 - 0
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
+		}
+	}*/
+}

+ 74 - 0
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))
+	})
+}

+ 122 - 0
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,
+	})
+}

+ 58 - 0
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))
+	})
+}

+ 41 - 0
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,
+		})
+	}
+}

+ 98 - 0
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)
+}

+ 298 - 0
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})
+}

+ 94 - 0
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"`
+}

+ 162 - 0
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
+}

+ 7 - 3
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

+ 11 - 0
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=

+ 5 - 0
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)

+ 2 - 0
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

+ 60 - 0
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 {

+ 3 - 4
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) {

+ 9 - 7
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"`

+ 10 - 0
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"`
+}

+ 21 - 5
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 {

+ 8 - 0
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()

+ 8 - 0
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()

+ 5 - 0
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

+ 11 - 0
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=

+ 3 - 3
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{

+ 3 - 3
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{