diff --git a/.github/diy/packages/sing-box/patches/002-support-clash-proxy-providers.patch b/.github/diy/packages/sing-box/patches/002-support-clash-proxy-providers.patch new file mode 100644 index 000000000..9de3cf99a --- /dev/null +++ b/.github/diy/packages/sing-box/patches/002-support-clash-proxy-providers.patch @@ -0,0 +1,421 @@ +diff --git a/experimental/clashapi/provider.go b/experimental/clashapi/provider.go +index 352b289..ef07e6d 100644 +--- a/experimental/clashapi/provider.go ++++ b/experimental/clashapi/provider.go +@@ -3,43 +3,65 @@ package clashapi + import ( + "context" + "net/http" ++ "os/exec" ++ "regexp" ++ ++ "github.com/sagernet/sing-box/option" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + ) + +-func proxyProviderRouter() http.Handler { ++func proxyProviderRouter(server *Server) http.Handler { + r := chi.NewRouter() +- r.Get("/", getProviders) ++ r.Get("/", getProviders(server)) + + r.Route("/{name}", func(r chi.Router) { +- r.Use(parseProviderName, findProviderByName) +- r.Get("/", getProvider) ++ r.Use(parseProviderName, findProviderByName(server)) ++ r.Get("/", getProvider(server)) + r.Put("/", updateProvider) + r.Get("/healthcheck", healthCheckProvider) + }) + return r + } + +-func getProviders(w http.ResponseWriter, r *http.Request) { +- render.JSON(w, r, render.M{ +- "providers": render.M{}, +- }) ++func getProviders(server *Server) func(w http.ResponseWriter, r *http.Request) { ++ return func(w http.ResponseWriter, r *http.Request) { ++ providers := render.M{} ++ for _, provider := range server.proxyProviders { ++ if provider.Name == "" { ++ continue ++ } ++ providers[provider.Name] = proxyProviderInfo(server, provider) ++ } ++ render.JSON(w, r, render.M{ ++ "providers": providers, ++ }) ++ } + } + +-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 getProvider(server *Server) func(w http.ResponseWriter, r *http.Request) { ++ return func(w http.ResponseWriter, r *http.Request) { ++ provider := r.Context().Value(CtxKeyProvider).(option.ClashProxyProviderOptions) ++ render.JSON(w, r, proxyProviderInfo(server, provider)) ++ } + } + + func updateProvider(w http.ResponseWriter, r *http.Request) { +- /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) +- if err := provider.Update(); err != nil { ++ provider := r.Context().Value(CtxKeyProvider).(option.ClashProxyProviderOptions) ++ if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(provider.UpdateID) { ++ render.Status(r, http.StatusServiceUnavailable) ++ render.JSON(w, r, newError("Provider update is unavailable")) ++ return ++ } ++ ++ command := exec.Command("/bin/sh", "/etc/homeproxy/scripts/update_provider.sh", provider.UpdateID) ++ if err := command.Start(); err != nil { + render.Status(r, http.StatusServiceUnavailable) + render.JSON(w, r, newError(err.Error())) + return +- }*/ ++ } ++ _ = command.Process.Release() + render.NoContent(w, r) + } + +@@ -57,18 +79,58 @@ func parseProviderName(next http.Handler) http.Handler { + }) + } + +-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 +- //} ++func findProviderByName(server *Server) func(next http.Handler) http.Handler { ++ return func(next http.Handler) http.Handler { ++ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ++ name := r.Context().Value(CtxKeyProviderName).(string) ++ for _, provider := range server.proxyProviders { ++ if provider.Name == name { ++ ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) ++ next.ServeHTTP(w, r.WithContext(ctx)) ++ return ++ } ++ } ++ render.Status(r, http.StatusNotFound) ++ render.JSON(w, r, ErrNotFound) ++ }) ++ } ++} + +- // ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) +- // next.ServeHTTP(w, r.WithContext(ctx)) +- }) ++func proxyProviderInfo(server *Server, provider option.ClashProxyProviderOptions) render.M { ++ providerType := provider.Type ++ if providerType == "" { ++ providerType = "Proxy" ++ } ++ vehicleType := provider.VehicleType ++ if vehicleType == "" { ++ vehicleType = "HTTP" ++ } ++ ++ proxies := make([]any, 0, len(provider.Proxies)) ++ for _, proxyName := range provider.Proxies { ++ proxy, exists := lookupProxyByName(server, proxyName) ++ if !exists { ++ continue ++ } ++ proxies = append(proxies, proxyInfo(server, proxy)) ++ } ++ ++ info := render.M{ ++ "name": provider.Name, ++ "type": providerType, ++ "vehicleType": vehicleType, ++ "proxies": proxies, ++ } ++ if provider.UpdatedAt != "" { ++ info["updatedAt"] = provider.UpdatedAt ++ } ++ if provider.SubscriptionInfo != nil { ++ info["subscriptionInfo"] = render.M{ ++ "Upload": provider.SubscriptionInfo.Upload, ++ "Download": provider.SubscriptionInfo.Download, ++ "Total": provider.SubscriptionInfo.Total, ++ "Expire": provider.SubscriptionInfo.Expire, ++ } ++ } ++ return info + } +diff --git a/experimental/clashapi/proxies.go b/experimental/clashapi/proxies.go +index ef88ff3..1f7d14d 100644 +--- a/experimental/clashapi/proxies.go ++++ b/experimental/clashapi/proxies.go +@@ -21,6 +21,50 @@ import ( + "github.com/go-chi/render" + ) + ++func proxyDisplayName(tag string) string { ++ switch tag { ++ case "GLOBAL": ++ return "GLOBAL" ++ case "main-out": ++ return "主代理" ++ case "main-udp-out": ++ return "UDP 主代理" ++ case "direct-out": ++ return "直连" ++ case "block-out": ++ return "拦截" ++ default: ++ return tag ++ } ++} ++ ++func lookupProxyByName(server *Server, name string) (adapter.Outbound, bool) { ++ proxy, exist := server.outbound.Outbound(name) ++ if exist { ++ return proxy, true ++ } ++ ++ for _, detour := range server.outbound.Outbounds() { ++ if proxyDisplayName(detour.Tag()) == name { ++ return detour, true ++ } ++ } ++ ++ for _, endpoint := range server.endpoint.Endpoints() { ++ if proxyDisplayName(endpoint.Tag()) == name { ++ return endpoint, true ++ } ++ } ++ ++ return nil, false ++} ++ ++func proxyDisplayNames(tags []string) []string { ++ return common.Map(tags, func(tag string) string { ++ return proxyDisplayName(tag) ++ }) ++} ++ + func proxyRouter(server *Server, router adapter.Router) http.Handler { + r := chi.NewRouter() + r.Get("/", getProxies(server)) +@@ -29,7 +73,7 @@ func proxyRouter(server *Server, router adapter.Router) http.Handler { + r.Use(parseProxyName, findProxyByName(server)) + r.Get("/", getProxy(server)) + r.Get("/delay", getProxyDelay(server)) +- r.Put("/", updateProxy) ++ r.Put("/", updateProxy(server)) + }) + return r + } +@@ -46,7 +90,7 @@ func findProxyByName(server *Server) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := r.Context().Value(CtxKeyProxyName).(string) +- proxy, exist := server.outbound.Outbound(name) ++ proxy, exist := lookupProxyByName(server, name) + if !exist { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) +@@ -68,7 +112,7 @@ func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject { + clashType = C.ProxyDisplayName(detour.Type()) + } + info.Put("type", clashType) +- info.Put("name", detour.Tag()) ++ info.Put("name", proxyDisplayName(detour.Tag())) + info.Put("udp", common.Contains(detour.Network(), N.NetworkUDP)) + delayHistory := server.urlTestHistory.LoadURLTestHistory(adapter.OutboundTag(detour)) + if delayHistory != nil { +@@ -77,8 +121,8 @@ func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject { + info.Put("history", []*adapter.URLTestHistory{}) + } + if group, isGroup := detour.(adapter.OutboundGroup); isGroup { +- info.Put("now", group.Now()) +- info.Put("all", group.All()) ++ info.Put("now", proxyDisplayName(group.Now())) ++ info.Put("all", proxyDisplayNames(group.All())) + } + return &info + } +@@ -102,10 +146,10 @@ func getProxies(server *Server) func(w http.ResponseWriter, r *http.Request) { + case C.TypeDirect, C.TypeBlock, C.TypeDNS: + continue + } +- allProxies = append(allProxies, detour.Tag()) ++ allProxies = append(allProxies, proxyDisplayName(detour.Tag())) + } + +- defaultTag := server.outbound.Default().Tag() ++ defaultTag := proxyDisplayName(server.outbound.Default().Tag()) + + sort.SliceStable(allProxies, func(i, j int) bool { + return allProxies[i] == defaultTag +@@ -114,7 +158,7 @@ func getProxies(server *Server) func(w http.ResponseWriter, r *http.Request) { + // fix clash dashboard + proxyMap.Put("GLOBAL", map[string]any{ + "type": "Fallback", +- "name": "GLOBAL", ++ "name": proxyDisplayName("GLOBAL"), + "udp": true, + "history": []*adapter.URLTestHistory{}, + "all": allProxies, +@@ -126,7 +170,7 @@ func getProxies(server *Server) func(w http.ResponseWriter, r *http.Request) { + if detour.Tag() == "" { + tag = F.ToString(i) + } else { +- tag = detour.Tag() ++ tag = proxyDisplayName(detour.Tag()) + } + proxyMap.Put(tag, proxyInfo(server, detour)) + } +@@ -159,29 +203,36 @@ 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 +- } ++func updateProxy(server *Server) func(w http.ResponseWriter, r *http.Request) { ++ return func(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.Outbound) +- selector, ok := proxy.(*group.Selector) +- if !ok { +- render.Status(r, http.StatusBadRequest) +- render.JSON(w, r, newError("Must be a Selector")) +- return +- } ++ proxy := r.Context().Value(CtxKeyProxy).(adapter.Outbound) ++ selector, ok := proxy.(*group.Selector) ++ if !ok { ++ render.Status(r, http.StatusBadRequest) ++ render.JSON(w, r, newError("Must be a Selector")) ++ return ++ } + +- if !selector.SelectOutbound(req.Name) { +- render.Status(r, http.StatusBadRequest) +- render.JSON(w, r, newError("Selector update error: not found")) +- return +- } ++ selectedName := req.Name ++ if targetProxy, exist := lookupProxyByName(server, req.Name); exist { ++ selectedName = targetProxy.Tag() ++ } ++ ++ if !selector.SelectOutbound(selectedName) { ++ render.Status(r, http.StatusBadRequest) ++ render.JSON(w, r, newError("Selector update error: not found")) ++ return ++ } + +- render.NoContent(w, r) ++ render.NoContent(w, r) ++ } + } + + func getProxyDelay(server *Server) func(w http.ResponseWriter, r *http.Request) { +diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go +index 950dca0..e4c1210 100644 +--- a/experimental/clashapi/server.go ++++ b/experimental/clashapi/server.go +@@ -60,6 +60,7 @@ type Server struct { + externalUI string + externalUIDownloadURL string + externalUIDownloadDetour string ++ proxyProviders []option.ClashProxyProviderOptions + } + + func NewServer(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) { +@@ -82,6 +83,7 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op + externalController: options.ExternalController != "", + externalUIDownloadURL: options.ExternalUIDownloadURL, + externalUIDownloadDetour: options.ExternalUIDownloadDetour, ++ proxyProviders: options.ProxyProviders, + } + s.urlTestHistory = service.FromContext[adapter.URLTestHistoryStorage](ctx) + if s.urlTestHistory == nil { +@@ -122,7 +124,7 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op + r.Mount("/proxies", proxyRouter(s, s.router)) + r.Mount("/rules", ruleRouter(s.router)) + r.Mount("/connections", connectionRouter(s.ctx, s.router, trafficManager)) +- r.Mount("/providers/proxies", proxyProviderRouter()) ++ r.Mount("/providers/proxies", proxyProviderRouter(s)) + r.Mount("/providers/rules", ruleProviderRouter()) + r.Mount("/script", scriptRouter()) + r.Mount("/profile", profileRouter()) +diff --git a/option/experimental.go b/option/experimental.go +index bf0df9e..50de31d 100644 +--- a/option/experimental.go ++++ b/option/experimental.go +@@ -19,15 +19,16 @@ type CacheFileOptions struct { + } + + type ClashAPIOptions struct { +- ExternalController string `json:"external_controller,omitempty"` +- ExternalUI string `json:"external_ui,omitempty"` +- ExternalUIDownloadURL string `json:"external_ui_download_url,omitempty"` +- ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty"` +- Secret string `json:"secret,omitempty"` +- DefaultMode string `json:"default_mode,omitempty"` +- ModeList []string `json:"-"` +- AccessControlAllowOrigin badoption.Listable[string] `json:"access_control_allow_origin,omitempty"` +- AccessControlAllowPrivateNetwork bool `json:"access_control_allow_private_network,omitempty"` ++ ExternalController string `json:"external_controller,omitempty"` ++ ExternalUI string `json:"external_ui,omitempty"` ++ ExternalUIDownloadURL string `json:"external_ui_download_url,omitempty"` ++ ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty"` ++ Secret string `json:"secret,omitempty"` ++ DefaultMode string `json:"default_mode,omitempty"` ++ ProxyProviders []ClashProxyProviderOptions `json:"proxy_providers,omitempty"` ++ ModeList []string `json:"-"` ++ AccessControlAllowOrigin badoption.Listable[string] `json:"access_control_allow_origin,omitempty"` ++ AccessControlAllowPrivateNetwork bool `json:"access_control_allow_private_network,omitempty"` + + // Deprecated: migrated to global cache file + CacheFile string `json:"cache_file,omitempty"` +@@ -41,6 +42,23 @@ type ClashAPIOptions struct { + StoreFakeIP bool `json:"store_fakeip,omitempty"` + } + ++type ClashProxyProviderOptions struct { ++ Name string `json:"name,omitempty"` ++ Type string `json:"type,omitempty"` ++ VehicleType string `json:"vehicle_type,omitempty"` ++ Proxies []string `json:"proxies,omitempty"` ++ UpdatedAt string `json:"updated_at,omitempty"` ++ UpdateID string `json:"update_id,omitempty"` ++ SubscriptionInfo *ClashProxyProviderSubscriptionInfo `json:"subscription_info,omitempty"` ++} ++ ++type ClashProxyProviderSubscriptionInfo struct { ++ Upload uint64 `json:"upload,omitempty"` ++ Download uint64 `json:"download,omitempty"` ++ Total uint64 `json:"total,omitempty"` ++ Expire int64 `json:"expire,omitempty"` ++} ++ + type V2RayAPIOptions struct { + Listen string `json:"listen,omitempty"` + Stats *V2RayStatsServiceOptions `json:"stats,omitempty"`