diff --git a/adapter/experimental.go b/adapter/experimental.go index 17f2379a..e9a5f903 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -35,6 +35,11 @@ type OutboundGroup interface { All() []string } +type URLTestGroup interface { + OutboundGroup + URLTest(ctx context.Context, url string) (map[string]uint16, error) +} + func OutboundTag(detour Outbound) string { if group, isGroup := detour.(OutboundGroup); isGroup { return group.Now() diff --git a/experimental/clashapi/api_meta.go b/experimental/clashapi/api_meta.go new file mode 100644 index 00000000..bfdee1b8 --- /dev/null +++ b/experimental/clashapi/api_meta.go @@ -0,0 +1,78 @@ +package clashapi + +import ( + "bytes" + "net/http" + "time" + + "github.com/sagernet/sing-box/common/json" + "github.com/sagernet/sing-box/experimental/clashapi/trafficontrol" + "github.com/sagernet/websocket" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +// API created by Clash.Meta + +func (s *Server) setupMetaAPI(r chi.Router) { + r.Get("/memory", memory(s.trafficManager)) + r.Mount("/group", groupRouter(s)) +} + +type Memory struct { + Inuse uint64 `json:"inuse"` + OSLimit uint64 `json:"oslimit"` // maybe we need it in the future +} + +func memory(trafficManager *trafficontrol.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 + first := true + for range tick.C { + buf.Reset() + + inuse := trafficManager.Snapshot().Memory + + // make chat.js begin with zero + // this is shit var,but we need output 0 for first time + if first { + first = false + inuse = 0 + } + if err := json.NewEncoder(buf).Encode(Memory{ + Inuse: inuse, + OSLimit: 0, + }); 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 + } + } + } +} diff --git a/experimental/clashapi/api_meta_group.go b/experimental/clashapi/api_meta_group.go new file mode 100644 index 00000000..34cf26e2 --- /dev/null +++ b/experimental/clashapi/api_meta_group.go @@ -0,0 +1,132 @@ +package clashapi + +import ( + "context" + "net/http" + "strconv" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/badjson" + "github.com/sagernet/sing-box/common/urltest" + "github.com/sagernet/sing-box/outbound" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/batch" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func groupRouter(server *Server) http.Handler { + r := chi.NewRouter() + r.Get("/", getGroups(server)) + r.Route("/{name}", func(r chi.Router) { + r.Use(parseProxyName, findProxyByName(server.router)) + r.Get("/", getGroup(server)) + r.Get("/delay", getGroupDelay(server)) + }) + return r +} + +func getGroups(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + groups := common.Map(common.Filter(server.router.Outbounds(), func(it adapter.Outbound) bool { + _, isGroup := it.(adapter.OutboundGroup) + return isGroup + }), func(it adapter.Outbound) *badjson.JSONObject { + return proxyInfo(server, it) + }) + render.JSON(w, r, render.M{ + "proxies": groups, + }) + } +} + +func getGroup(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + proxy := r.Context().Value(CtxKeyProxy).(adapter.Outbound) + if _, ok := proxy.(adapter.OutboundGroup); ok { + render.JSON(w, r, proxyInfo(server, proxy)) + return + } + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + } +} + +func getGroupDelay(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + proxy := r.Context().Value(CtxKeyProxy).(adapter.Outbound) + group, ok := proxy.(adapter.OutboundGroup) + if !ok { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + return + } + + query := r.URL.Query() + url := query.Get("url") + timeout, err := strconv.ParseInt(query.Get("timeout"), 10, 32) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, ErrBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), time.Millisecond*time.Duration(timeout)) + defer cancel() + + var result map[string]uint16 + if urlTestGroup, isURLTestGroup := group.(adapter.URLTestGroup); isURLTestGroup { + result, err = urlTestGroup.URLTest(ctx, url) + } else { + outbounds := common.FilterNotNil(common.Map(group.All(), func(it string) adapter.Outbound { + itOutbound, _ := server.router.Outbound(it) + return itOutbound + })) + b, _ := batch.New(ctx, batch.WithConcurrencyNum[any](10)) + checked := make(map[string]bool) + result = make(map[string]uint16) + var resultAccess sync.Mutex + for _, detour := range outbounds { + tag := detour.Tag() + realTag := outbound.RealTag(detour) + if checked[realTag] { + continue + } + checked[realTag] = true + p, loaded := server.router.Outbound(realTag) + if !loaded { + continue + } + b.Go(realTag, func() (any, error) { + t, err := urltest.URLTest(ctx, url, p) + if err != nil { + server.logger.Debug("outbound ", tag, " unavailable: ", err) + server.urlTestHistory.DeleteURLTestHistory(realTag) + } else { + server.logger.Debug("outbound ", tag, " available: ", t, "ms") + server.urlTestHistory.StoreURLTestHistory(realTag, &urltest.History{ + Time: time.Now(), + Delay: t, + }) + resultAccess.Lock() + result[tag] = t + resultAccess.Unlock() + } + return nil, nil + }) + } + b.Wait() + } + + if err != nil { + render.Status(r, http.StatusGatewayTimeout) + render.JSON(w, r, newError(err.Error())) + return + } + + render.JSON(w, r, result) + } +} diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index ceb92092..b40076b6 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -109,6 +109,8 @@ func NewServer(router adapter.Router, logFactory log.ObservableFactory, options r.Mount("/profile", profileRouter()) r.Mount("/cache", cacheRouter(router)) r.Mount("/dns", dnsRouter(router)) + + server.setupMetaAPI(r) }) if options.ExternalUI != "" { server.externalUI = C.BasePath(os.ExpandEnv(options.ExternalUI)) @@ -406,5 +408,5 @@ func getLogs(logFactory log.ObservableFactory) func(w http.ResponseWriter, r *ht } func version(w http.ResponseWriter, r *http.Request) { - render.JSON(w, r, render.M{"version": "sing-box " + C.Version, "premium": true}) + render.JSON(w, r, render.M{"version": "sing-box " + C.Version, "premium": true, "meta": true}) } diff --git a/experimental/clashapi/server_resources.go b/experimental/clashapi/server_resources.go index 570e029e..91d4652d 100644 --- a/experimental/clashapi/server_resources.go +++ b/experimental/clashapi/server_resources.go @@ -40,7 +40,7 @@ func (s *Server) downloadExternalUI() error { if s.externalUIDownloadURL != "" { downloadURL = s.externalUIDownloadURL } else { - downloadURL = "https://github.com/Dreamacro/clash-dashboard/archive/refs/heads/gh-pages.zip" + downloadURL = "https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip" } s.logger.Info("downloading external ui") var detour adapter.Outbound diff --git a/experimental/clashapi/trafficontrol/manager.go b/experimental/clashapi/trafficontrol/manager.go index c1472f34..8df624a9 100644 --- a/experimental/clashapi/trafficontrol/manager.go +++ b/experimental/clashapi/trafficontrol/manager.go @@ -1,6 +1,7 @@ package trafficontrol import ( + "runtime" "time" "github.com/sagernet/sing-box/experimental/clashapi/compatible" @@ -18,12 +19,15 @@ type Manager struct { connections compatible.Map[string, tracker] ticker *time.Ticker done chan struct{} + // process *process.Process + memory uint64 } func NewManager() *Manager { manager := &Manager{ ticker: time.NewTicker(time.Second), done: make(chan struct{}), + // process: &process.Process{Pid: int32(os.Getpid())}, } go manager.handle() return manager @@ -58,10 +62,18 @@ func (m *Manager) Snapshot() *Snapshot { return true }) + //if memoryInfo, err := m.process.MemoryInfo(); err == nil { + // m.memory = memoryInfo.RSS + //} else { + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + m.memory = memStats.StackInuse + memStats.HeapInuse + memStats.HeapIdle - memStats.HeapReleased + return &Snapshot{ UploadTotal: m.uploadTotal.Load(), DownloadTotal: m.downloadTotal.Load(), Connections: connections, + Memory: m.memory, } } @@ -100,4 +112,5 @@ type Snapshot struct { DownloadTotal int64 `json:"downloadTotal"` UploadTotal int64 `json:"uploadTotal"` Connections []tracker `json:"connections"` + Memory uint64 `json:"memory"` } diff --git a/outbound/urltest.go b/outbound/urltest.go index cfb59c82..21c5b5da 100644 --- a/outbound/urltest.go +++ b/outbound/urltest.go @@ -4,6 +4,7 @@ import ( "context" "net" "sort" + "sync" "time" "github.com/sagernet/sing-box/adapter" @@ -71,7 +72,7 @@ func (s *URLTest) Start() error { return s.group.Start() } -func (s URLTest) Close() error { +func (s *URLTest) Close() error { return common.Close( common.PtrOrNil(s.group), ) @@ -85,6 +86,10 @@ func (s *URLTest) All() []string { return s.tags } +func (s *URLTest) URLTest(ctx context.Context, link string) (map[string]uint16, error) { + return s.group.URLTest(ctx, link) +} + func (s *URLTest) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { outbound := s.group.Select(network) conn, err := outbound.DialContext(ctx, network, destination) @@ -249,8 +254,14 @@ func (g *URLTestGroup) loopCheck() { } func (g *URLTestGroup) checkOutbounds() { - b, _ := batch.New(context.Background(), batch.WithConcurrencyNum[any](10)) + _, _ = g.URLTest(context.Background(), g.link) +} + +func (g *URLTestGroup) URLTest(ctx context.Context, link string) (map[string]uint16, error) { + b, _ := batch.New(ctx, batch.WithConcurrencyNum[any](10)) checked := make(map[string]bool) + result := make(map[string]uint16) + var resultAccess sync.Mutex for _, detour := range g.outbounds { tag := detour.Tag() realTag := RealTag(detour) @@ -269,7 +280,7 @@ func (g *URLTestGroup) checkOutbounds() { b.Go(realTag, func() (any, error) { ctx, cancel := context.WithTimeout(context.Background(), C.TCPTimeout) defer cancel() - t, err := urltest.URLTest(ctx, g.link, p) + t, err := urltest.URLTest(ctx, link, p) if err != nil { g.logger.Debug("outbound ", tag, " unavailable: ", err) g.history.DeleteURLTestHistory(realTag) @@ -279,9 +290,13 @@ func (g *URLTestGroup) checkOutbounds() { Time: time.Now(), Delay: t, }) + resultAccess.Lock() + result[tag] = t + resultAccess.Unlock() } return nil, nil }) } b.Wait() + return result, nil }