From 7d42b91ed6d1d4892b9a5b8db1e86f552ca93e0c Mon Sep 17 00:00:00 2001 From: Muse Date: Tue, 20 Oct 2026 10:17:27 +0000 Subject: [PATCH] =?UTF-8?q?=E7=B2=BE=E7=AE=80:=20=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E6=9E=81=E7=AE=80=E8=BE=93=E5=87=BA:=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E5=88=86=E6=B5=81=E8=A7=84=E5=88=99=E6=B3=A8=E5=85=A5(Happ/Cla?= =?UTF-8?q?sh/Incy=20=E8=BF=9C=E7=A8=8B=E8=A7=84=E5=88=99=E6=BA=90+remote?= =?UTF-8?q?=5Frouting=20=E6=8B=89=E5=8F=96=E7=BC=93=E5=AD=98+RemoteRouting?= =?UTF-8?q?Job=E3=80=81Clash=20=E8=BF=9C=E7=A8=8B=E8=A7=84=E5=88=99?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E7=B0=87=E3=80=81subJsonRules=20=E6=B3=A8?= =?UTF-8?q?=E5=85=A5);=20=E4=BF=9D=E7=95=99=E5=A4=9A=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E4=B8=8E=E9=BB=98=E8=AE=A4=E6=9E=81=E7=AE=80?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/sub/clash_service.go | 360 +-------- internal/sub/controller.go | 111 +-- internal/sub/external_config_test.go | 2 +- internal/sub/external_only_sub_test.go | 4 +- internal/sub/host_sub_test.go | 12 +- internal/sub/json_flow_gate_test.go | 6 +- internal/sub/json_service.go | 12 +- internal/sub/json_service_test.go | 36 +- internal/sub/mutation_audit_test.go | 38 +- internal/sub/remote_routing.go | 623 --------------- internal/sub/remote_routing_test.go | 750 ------------------ internal/sub/sub.go | 42 - .../sub/sub_balancer_protocol_tag_test.go | 2 +- internal/sub/sub_balancer_test.go | 23 +- internal/sub/sub_json_observatory_test.go | 2 +- internal/sub/sub_panic_test.go | 10 +- internal/sub/sub_scale_test.go | 2 +- internal/sub/vless_route_sub_test.go | 4 +- internal/web/cadence_test.go | 2 - internal/web/entity/entity.go | 7 - internal/web/job/remote_routing_job.go | 31 - internal/web/service/setting.go | 55 -- internal/web/service/setting_clash_test.go | 8 - .../service/setting_remote_routing_test.go | 38 - internal/web/web.go | 7 - 25 files changed, 87 insertions(+), 2100 deletions(-) delete mode 100644 internal/sub/remote_routing.go delete mode 100644 internal/sub/remote_routing_test.go delete mode 100644 internal/web/job/remote_routing_job.go delete mode 100644 internal/web/service/setting_remote_routing_test.go diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index 2a35747..2fedaa4 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -1,26 +1,22 @@ package sub import ( - "errors" "fmt" "maps" "strings" "github.com/goccy/go-json" - yaml "github.com/goccy/go-yaml" "github.com/mhsanaei/3x-ui/v3/internal/database/model" wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) type SubClashService struct { - enableRouting bool - clashRules string - SubService *SubService + SubService *SubService } -func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService { - return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService} +func NewSubClashService(subService *SubService) *SubClashService { + return &SubClashService{SubService: subService} } func (s *SubClashService) GetClash(subId string, host string) (string, string, error) { @@ -98,19 +94,6 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e "rules": []string{"MATCH,PROXY"}, } - if s.enableRouting { - resolved, remoteDocument, remote, resolveErr := resolveClashRoutingSource(s.clashRules) - if resolveErr == nil && strings.TrimSpace(resolved) != "" { - if remote { - if err := mergeRemoteClashRules(config, remoteDocument); err != nil { - return "", "", err - } - } else if err := mergeClashRulesYAML(config, resolved); err != nil { - return "", "", err - } - } - } - finalYAML, err := marshalClashYAML(config) if err != nil { return "", "", err @@ -789,340 +772,3 @@ func cloneMap(src map[string]any) map[string]any { maps.Copy(dst, src) return dst } - -func mergeClashRulesYAML(base map[string]any, raw string) error { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil - } - - var custom any - if err := yaml.Unmarshal([]byte(raw), &custom); err != nil { - mergeClashRules(base, linesToClashRules(raw)) - return nil - } - - switch typed := custom.(type) { - case []any: - mergeClashRules(base, typed) - case map[string]any: - for key, value := range typed { - if key == "rules" { - if ruleList, ok := asAnySlice(value); ok { - mergeClashRules(base, ruleList) - } - continue - } - base[key] = value - } - default: - mergeClashRules(base, linesToClashRules(raw)) - } - - return nil -} - -// mergeRemoteClashRules lets remote update only the route graph (see -// remoteClashAllowedKey) and never mutates remote: cached documents are shared. -func mergeRemoteClashRules(base map[string]any, remote map[string]any) error { - if len(remote) == 0 { - return fmt.Errorf("remote Clash routing source must be a YAML map") - } - - for key, value := range remote { - if !remoteClashAllowedKey(key) { - continue - } - if err := validateRemoteClashValue(key, value); err != nil { - return err - } - switch key { - case "rules": - rules, _ := asAnySlice(value) - mergeClashRules(base, rules) - case "proxy-groups": - groups, _ := asAnySlice(value) - base["proxy-groups"] = mergeClashProxyGroups(base["proxy-groups"], groups) - default: - base[key] = value - } - } - return validateClashRouteGraph(base) -} - -func validateRemoteClashValue(key string, value any) error { - switch key { - case "rules": - rules, ok := asAnySlice(value) - if !ok { - return fmt.Errorf("remote Clash rules must be a list") - } - for _, rule := range rules { - text, ok := rule.(string) - if !ok || strings.TrimSpace(text) == "" { - return fmt.Errorf("remote Clash rules must contain non-empty strings") - } - } - case "proxy-groups": - groups, ok := asAnySlice(value) - if !ok { - return fmt.Errorf("remote Clash proxy-groups must be a list") - } - seen := make(map[string]struct{}, len(groups)) - for _, groupValue := range groups { - group, ok := groupValue.(map[string]any) - if !ok { - return fmt.Errorf("remote Clash proxy-groups must contain named group maps with a type") - } - name, nameOK := group["name"].(string) - groupType, typeOK := group["type"].(string) - if !nameOK || !typeOK || strings.TrimSpace(name) == "" || strings.TrimSpace(groupType) == "" { - return fmt.Errorf("remote Clash proxy-groups must contain named group maps with a type") - } - name = strings.TrimSpace(name) - if _, duplicate := seen[name]; duplicate { - return fmt.Errorf("remote Clash proxy-group name %q is duplicated", name) - } - seen[name] = struct{}{} - if useValue, exists := group["use"]; exists { - use, ok := asAnySlice(useValue) - if !ok || len(use) > 0 { - return fmt.Errorf("remote Clash proxy-group %q cannot use proxy-providers", name) - } - } - } - case "rule-providers": - providers, ok := value.(map[string]any) - if !ok { - return fmt.Errorf("remote Clash rule-providers must be a map") - } - for name, provider := range providers { - if strings.TrimSpace(name) == "" { - return fmt.Errorf("remote Clash rule-provider name must not be empty") - } - if _, ok := provider.(map[string]any); !ok { - return fmt.Errorf("remote Clash rule-provider %q must be a map", name) - } - } - } - return nil -} - -func remoteClashAllowedKey(key string) bool { - switch key { - case "proxy-groups", "rule-providers", "rules": - return true - default: - return false - } -} - -func validateClashRouteGraph(config map[string]any) error { - known := map[string]struct{}{ - "DIRECT": {}, "REJECT": {}, "REJECT-DROP": {}, "REJECT-TINYGIF": {}, "PASS": {}, "GLOBAL": {}, - } - if proxies, ok := asAnySlice(config["proxies"]); ok { - for _, value := range proxies { - proxy, ok := value.(map[string]any) - if !ok { - continue - } - if name, ok := proxy["name"].(string); ok && strings.TrimSpace(name) != "" { - known[strings.TrimSpace(name)] = struct{}{} - } - } - } - - groups, _ := asAnySlice(config["proxy-groups"]) - for _, value := range groups { - if name := clashProxyGroupName(value); name != "" { - known[name] = struct{}{} - } - } - for _, value := range groups { - group, ok := value.(map[string]any) - if !ok { - continue - } - name := clashProxyGroupName(group) - refs, exists := group["proxies"] - if !exists { - continue - } - proxies, ok := asAnySlice(refs) - if !ok { - return fmt.Errorf("Clash proxy-group %q proxies must be a list", name) - } - for _, refValue := range proxies { - ref, ok := refValue.(string) - if !ok || strings.TrimSpace(ref) == "" { - return fmt.Errorf("Clash proxy-group %q contains an invalid proxy reference", name) - } - ref = strings.TrimSpace(ref) - if _, exists := known[ref]; !exists { - return fmt.Errorf("Clash proxy-group %q references unknown proxy or group %q", name, ref) - } - } - } - - providers, _ := config["rule-providers"].(map[string]any) - for providerName, value := range providers { - provider, ok := value.(map[string]any) - if !ok { - continue - } - via, ok := provider["proxy"].(string) - if !ok || strings.TrimSpace(via) == "" { - continue - } - via = strings.TrimSpace(via) - if _, exists := known[via]; !exists { - return fmt.Errorf("Clash rule-provider %q references unknown proxy or group %q", providerName, via) - } - } - - rules, _ := asAnySlice(config["rules"]) - for _, value := range rules { - rule, ok := value.(string) - if !ok || strings.TrimSpace(rule) == "" { - return errors.New("Clash rules must contain non-empty strings") - } - parts := strings.Split(rule, ",") - for i := range parts { - parts[i] = strings.TrimSpace(parts[i]) - } - if len(parts) < 2 { - return fmt.Errorf("invalid Clash rule %q", rule) - } - if strings.EqualFold(parts[0], "RULE-SET") { - if len(parts) < 3 { - return fmt.Errorf("invalid Clash RULE-SET rule %q", rule) - } - if _, exists := providers[parts[1]]; !exists { - return fmt.Errorf("Clash rule references unknown rule-provider %q", parts[1]) - } - } - targetIndex := len(parts) - 1 - // Mihomo IP rules may carry trailing no-resolve / src option flags. - for targetIndex >= 1 && (strings.EqualFold(parts[targetIndex], "no-resolve") || strings.EqualFold(parts[targetIndex], "src")) { - targetIndex-- - } - if targetIndex < 1 { - return fmt.Errorf("invalid Clash rule target in %q", rule) - } - target := parts[targetIndex] - if _, exists := known[target]; !exists { - return fmt.Errorf("Clash rule references unknown proxy or group %q", target) - } - } - return nil -} - -func mergeClashProxyGroups(baseValue any, remoteGroups []any) []any { - baseGroups, _ := asAnySlice(baseValue) - baseByName := make(map[string]any, len(baseGroups)) - baseOrder := make([]string, 0, len(baseGroups)) - for _, group := range baseGroups { - name := clashProxyGroupName(group) - if name == "" { - continue - } - baseByName[name] = group - baseOrder = append(baseOrder, name) - } - - merged := make([]any, 0, len(remoteGroups)+len(baseGroups)) - seen := make(map[string]struct{}, len(remoteGroups)+len(baseGroups)) - for _, group := range remoteGroups { - name := clashProxyGroupName(group) - if name == "" { - continue - } - if _, duplicate := seen[name]; duplicate { - continue - } - seen[name] = struct{}{} - merged = append(merged, group) - } - for _, name := range baseOrder { - if _, replaced := seen[name]; replaced { - continue - } - merged = append(merged, baseByName[name]) - } - return merged -} - -func clashProxyGroupName(value any) string { - group, ok := value.(map[string]any) - if !ok { - return "" - } - name, _ := group["name"].(string) - return strings.TrimSpace(name) -} - -func mergeClashRules(base map[string]any, customRules []any) { - if len(customRules) == 0 { - return - } - - baseRules, _ := asAnySlice(base["rules"]) - if hasClashMatchRule(customRules) { - base["rules"] = customRules - return - } - - merged := make([]any, 0, len(customRules)+len(baseRules)) - merged = append(merged, customRules...) - merged = append(merged, baseRules...) - base["rules"] = merged -} - -func asAnySlice(value any) ([]any, bool) { - switch typed := value.(type) { - case []any: - return typed, true - case []string: - out := make([]any, 0, len(typed)) - for _, item := range typed { - out = append(out, item) - } - return out, true - case []map[string]any: - out := make([]any, 0, len(typed)) - for _, item := range typed { - out = append(out, item) - } - return out, true - default: - return nil, false - } -} - -func hasClashMatchRule(rules []any) bool { - for _, rule := range rules { - ruleText, ok := rule.(string) - if !ok { - continue - } - parts := strings.SplitN(ruleText, ",", 2) - if strings.EqualFold(strings.TrimSpace(parts[0]), "MATCH") { - return true - } - } - return false -} - -func linesToClashRules(raw string) []any { - lines := strings.Split(raw, "\n") - rules := make([]any, 0, len(lines)) - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - rules = append(rules, line) - } - return rules -} diff --git a/internal/sub/controller.go b/internal/sub/controller.go index 70f3dd3..32f74ac 100644 --- a/internal/sub/controller.go +++ b/internal/sub/controller.go @@ -45,16 +45,11 @@ type cachedSubTemplate struct { // SUBController handles HTTP requests for subscription links and JSON configurations. type SUBController struct { - subTitle string - subSupportUrl string - subProfileUrl string - subAnnounce string - subEnableRouting bool - subRoutingRules string - subHideSettings bool - - subIncyEnableRouting bool - subIncyRoutingRules string + subTitle string + subSupportUrl string + subProfileUrl string + subAnnounce string + subHideSettings bool subPath string subJsonPath string @@ -96,23 +91,15 @@ type subControllerConfig struct { remarkTemplate string updateInterval string - subJsonMux string - subJsonRules string - subJsonFinalMask string - subJsonObservatory string - subClashEnableRouting bool - subClashRules string + subJsonMux string + subJsonFinalMask string + subJsonObservatory string - subTitle string - subSupportURL string - subProfileURL string - subAnnounce string - subEnableRouting bool - subRoutingRules string - subHideSettings bool - - subIncyEnableRouting bool - subIncyRoutingRules string + subTitle string + subSupportURL string + subProfileURL string + subAnnounce string + subHideSettings bool } type SUBControllerOption func(*subControllerConfig) @@ -173,10 +160,6 @@ func WithSUBJsonMux(value string) SUBControllerOption { return func(config *subControllerConfig) { config.subJsonMux = value } } -func WithSUBJsonRules(value string) SUBControllerOption { - return func(config *subControllerConfig) { config.subJsonRules = value } -} - func WithSUBJsonFinalMask(value string) SUBControllerOption { return func(config *subControllerConfig) { config.subJsonFinalMask = value } } @@ -185,14 +168,6 @@ func WithSUBJsonObservatory(value string) SUBControllerOption { return func(config *subControllerConfig) { config.subJsonObservatory = value } } -func WithSUBClashEnableRouting(value bool) SUBControllerOption { - return func(config *subControllerConfig) { config.subClashEnableRouting = value } -} - -func WithSUBClashRules(value string) SUBControllerOption { - return func(config *subControllerConfig) { config.subClashRules = value } -} - func WithSUBTitle(value string) SUBControllerOption { return func(config *subControllerConfig) { config.subTitle = value } } @@ -209,26 +184,10 @@ func WithSUBAnnounce(value string) SUBControllerOption { return func(config *subControllerConfig) { config.subAnnounce = value } } -func WithSUBEnableRouting(value bool) SUBControllerOption { - return func(config *subControllerConfig) { config.subEnableRouting = value } -} - -func WithSUBRoutingRules(value string) SUBControllerOption { - return func(config *subControllerConfig) { config.subRoutingRules = value } -} - func WithSUBHideSettings(value bool) SUBControllerOption { return func(config *subControllerConfig) { config.subHideSettings = value } } -func WithSUBIncyEnableRouting(value bool) SUBControllerOption { - return func(config *subControllerConfig) { config.subIncyEnableRouting = value } -} - -func WithSUBIncyRoutingRules(value string) SUBControllerOption { - return func(config *subControllerConfig) { config.subIncyRoutingRules = value } -} - func defaultSUBControllerConfig() subControllerConfig { return subControllerConfig{ subPath: "/sub/", @@ -248,19 +207,14 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo } sub := NewSubService(config.remarkTemplate) - subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub) + subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonFinalMask, sub) subJsonSvc.SetObservatoryConfig(config.subJsonObservatory) a := &SUBController{ - subTitle: config.subTitle, - subSupportUrl: config.subSupportURL, - subProfileUrl: config.subProfileURL, - subAnnounce: config.subAnnounce, - subEnableRouting: config.subEnableRouting, - subRoutingRules: config.subRoutingRules, - subHideSettings: config.subHideSettings, - - subIncyEnableRouting: config.subIncyEnableRouting, - subIncyRoutingRules: config.subIncyRoutingRules, + subTitle: config.subTitle, + subSupportUrl: config.subSupportURL, + subProfileUrl: config.subProfileURL, + subAnnounce: config.subAnnounce, + subHideSettings: config.subHideSettings, subPath: config.subPath, subJsonPath: config.subJsonPath, @@ -277,7 +231,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo subService: sub, subJsonService: subJsonSvc, - subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub), + subClashService: NewSubClashService(sub), subTemplateCache: map[string]*cachedSubTemplate{}, } @@ -426,15 +380,7 @@ func (a *SUBController) subs(c *gin.Context) { header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000) profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI) metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL) - a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings) - - if a.subIncyEnableRouting && a.subIncyRoutingRules != "" { - incyRules, _, err := resolveIncyRoutingSource(a.subIncyRoutingRules) - if err == nil && strings.TrimSpace(incyRules) != "" { - result.WriteString(incyRules) - result.WriteString("\n") - } - } + a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings) if a.subEncrypt { c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String()))) @@ -749,7 +695,7 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co } return subReq }, subId, profileURL) - a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings) + a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings) if rawDownload { c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`) } @@ -797,7 +743,7 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool { } return subReq }, subId, profileURL) - a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings) + a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings) if rawDownload { c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`) } else if metadata.Title != "" { @@ -817,8 +763,6 @@ func (a *SUBController) ApplyCommonHeaders( profileSupportUrl string, profileUrl string, profileAnnounce string, - profileEnableRouting bool, - profileRoutingRules string, profileHideSettings bool, ) { c.Writer.Header().Set("Subscription-Userinfo", header) @@ -838,15 +782,6 @@ func (a *SUBController) ApplyCommonHeaders( c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce))) } - // Advanced (Happ). Routing stays independent of the enable flag; remote - // values come only from the validated cache and never delay this response. - rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules) - if profileEnableRouting { - c.Writer.Header().Set("Routing-Enable", "true") - } - if (routingErr == nil || !remote) && strings.TrimSpace(rules) != "" { - c.Writer.Header().Set("Routing", rules) - } if profileHideSettings { c.Writer.Header().Set("Hide-Settings", "1") } diff --git a/internal/sub/external_config_test.go b/internal/sub/external_config_test.go index c42be14..7a6a63f 100644 --- a/internal/sub/external_config_test.go +++ b/internal/sub/external_config_test.go @@ -155,7 +155,7 @@ func TestLinkDisplayName(t *testing.T) { func TestClashProxyFromExternalTrojanReality(t *testing.T) { link := "trojan://provider-pass@37.27.201.56:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv" - svc := NewSubClashService(false, "", NewSubService("")) + svc := NewSubClashService(NewSubService("")) proxy := svc.clashProxyFromExternal(link, "DE-Provider") if proxy == nil { t.Fatal("expected a clash proxy, got nil") diff --git a/internal/sub/external_only_sub_test.go b/internal/sub/external_only_sub_test.go index 8d5941e..c390690 100644 --- a/internal/sub/external_only_sub_test.go +++ b/internal/sub/external_only_sub_test.go @@ -28,7 +28,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) { base := NewSubService("") - jsonService := NewSubJsonService("", "", "", base) + jsonService := NewSubJsonService("", "", base) jsonOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", false) if err != nil { t.Fatalf("GetJson err = %v", err) @@ -56,7 +56,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) { t.Fatalf("standards-compliant GetJson profile count = %d, want 1", len(configs)) } - clashOut, _, err := NewSubClashService(false, "", base).GetClash("ext-only", "sub.example.com") + clashOut, _, err := NewSubClashService(base).GetClash("ext-only", "sub.example.com") if err != nil { t.Fatalf("GetClash err = %v", err) } diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index a57b85e..b2b066b 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -261,7 +261,7 @@ func TestSub_HostAllowInsecure(t *testing.T) { t.Fatalf("raw link should carry allowInsecure=1: %s", strings.Join(links, "\n")) } - clash := NewSubClashService(false, "", NewSubService("")) + clash := NewSubClashService(NewSubService("")) yaml, _, err := clash.GetClash("s1", "req.example.com") if err != nil { t.Fatalf("GetClash: %v", err) @@ -282,7 +282,7 @@ func TestSub_HostHeaderReachesClashAndJson(t *testing.T) { HostHeader: "cdn.example.com", Path: "/ws-path", }) - clash := NewSubClashService(false, "", NewSubService("")) + clash := NewSubClashService(NewSubService("")) yaml, _, err := clash.GetClash("s1", "req.example.com") if err != nil { t.Fatalf("GetClash: %v", err) @@ -294,7 +294,7 @@ func TestSub_HostHeaderReachesClashAndJson(t *testing.T) { t.Fatalf("clash ws-opts should carry the host record's path:\n%s", yaml) } - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) out, _, err := js.GetJson("s1", "req.example.com", false) if err != nil { t.Fatalf("GetJson: %v", err) @@ -336,7 +336,7 @@ func TestSub_HostSockoptJSON(t *testing.T) { InboundId: ib.Id, SortOrder: 0, Remark: "SO", Address: "so.cdn.com", Port: 8443, Security: "tls", SockoptParams: `{"tcpFastOpen":true}`, }) - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) out, _, err := js.GetJson("s1", "req.example.com", false) if err != nil { t.Fatalf("GetJson: %v", err) @@ -354,7 +354,7 @@ func TestSub_HostMuxJSON(t *testing.T) { InboundId: ib.Id, SortOrder: 0, Remark: "MX", Address: "mx.cdn.com", Port: 8443, Security: "tls", MuxParams: `{"enabled":true,"concurrency":8}`, }) - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) out, _, err := js.GetJson("s1", "req.example.com", false) if err != nil { t.Fatalf("GetJson: %v", err) @@ -409,7 +409,7 @@ func TestSub_ExcludeFromSubTypes(t *testing.T) { t.Fatalf("host not excluded from raw should appear in GetSubs") } - clash := NewSubClashService(false, "", NewSubService("")) + clash := NewSubClashService(NewSubService("")) yaml, _, err := clash.GetClash("s1", "req.example.com") if err != nil { t.Fatalf("GetClash: %v", err) diff --git a/internal/sub/json_flow_gate_test.go b/internal/sub/json_flow_gate_test.go index b820573..d7a2f15 100644 --- a/internal/sub/json_flow_gate_test.go +++ b/internal/sub/json_flow_gate_test.go @@ -51,7 +51,7 @@ func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) { t.Fatalf("raw link must not carry a flow on ws+tls: %s", joined) } - clash := NewSubClashService(false, "", NewSubService("")) + clash := NewSubClashService(NewSubService("")) yaml, _, err := clash.GetClash("s1", "req.example.com") if err != nil { t.Fatalf("GetClash: %v", err) @@ -60,7 +60,7 @@ func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) { t.Fatalf("clash proxy must not carry a flow on ws+tls:\n%s", yaml) } - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) out, _, err := js.GetJson("s1", "req.example.com", false) if err != nil { t.Fatalf("GetJson: %v", err) @@ -76,7 +76,7 @@ func TestSub_JSONKeepsFlowOnTcpTLS(t *testing.T) { seedFlowInbound(t, "s1", "tcpflow", 4602, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`) - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) out, _, err := js.GetJson("s1", "req.example.com", false) if err != nil { t.Fatalf("GetJson: %v", err) diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 15cd824..5c7abe1 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -34,7 +34,7 @@ type SubJsonService struct { } // NewSubJsonService creates a new JSON subscription service with the given configuration. -func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService { +func NewSubJsonService(mux string, finalMask string, subService *SubService) *SubJsonService { var configJson map[string]any var defaultOutbounds []json_util.RawMessage _ = json.Unmarshal([]byte(defaultJson), &configJson) @@ -45,16 +45,6 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S } } - if rules != "" { - var newRules []any - routing, _ := configJson["routing"].(map[string]any) - defaultRules, _ := routing["rules"].([]any) - _ = json.Unmarshal([]byte(rules), &newRules) - defaultRules = append(newRules, defaultRules...) - routing["rules"] = defaultRules - configJson["routing"] = routing - } - return &SubJsonService{ configJson: configJson, defaultOutbounds: defaultOutbounds, diff --git a/internal/sub/json_service_test.go b/internal/sub/json_service_test.go index c7c6a48..2d211ff 100644 --- a/internal/sub/json_service_test.go +++ b/internal/sub/json_service_test.go @@ -37,7 +37,7 @@ func outboundSettings(t *testing.T, raw []byte) map[string]any { func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) { finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}` - svc := NewSubJsonService("", "", finalMask, nil) + svc := NewSubJsonService("", finalMask, nil) if hasDirectOutOutbound(svc) { t.Fatal("direct_out outbound must never be emitted") @@ -74,7 +74,7 @@ func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) { func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) { finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}` - svc := NewSubJsonService("", "", finalMask, nil) + svc := NewSubJsonService("", finalMask, nil) stream := svc.streamData(`{ "network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}, @@ -94,7 +94,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) { } func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) { - svc := NewSubJsonService("", "", "", nil) + svc := NewSubJsonService("", "", nil) stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "") if _, ok := stream["finalmask"]; ok { t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty") @@ -108,7 +108,7 @@ func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) { // the JSON subscription must emit that form, not an array, or v2ray clients fail // to import the config (#5401). func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) { - svc := NewSubJsonService("", "", "", nil) + svc := NewSubJsonService("", "", nil) stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`, "") tls, _ := stream["tlsSettings"].(map[string]any) @@ -121,7 +121,7 @@ func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) { } func TestSubJsonServiceTLSCipherSuitesForwarded(t *testing.T) { - svc := NewSubJsonService("", "", "", nil) + svc := NewSubJsonService("", "", nil) stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","cipherSuites":"TLS_AES_256_GCM_SHA384","settings":{}}}`, "") tls, _ := stream["tlsSettings"].(map[string]any) @@ -140,7 +140,7 @@ func TestSubJsonServiceVlessFlattened(t *testing.T) { inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`} client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"} - settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, "")) + settings := outboundSettings(t, NewSubJsonService("", "", nil).genVless(&SubService{}, inbound, nil, client, "")) if _, ok := settings["vnext"]; ok { t.Fatal("vless outbound must not use vnext") } @@ -153,7 +153,7 @@ func TestSubJsonServiceVlessFlowSuppressedByDisableFlow(t *testing.T) { inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`, DisableFlow: true} client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"} - settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, "")) + settings := outboundSettings(t, NewSubJsonService("", "", nil).genVless(&SubService{}, inbound, nil, client, "")) if _, ok := settings["flow"]; ok { t.Fatalf("DisableFlow inbound must not carry a flow in the JSON outbound: %#v", settings) } @@ -163,7 +163,7 @@ func TestSubJsonServiceVmessFlattened(t *testing.T) { inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`} client := model.Client{ID: "uuid-2"} - settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client, "")) + settings := outboundSettings(t, NewSubJsonService("", "", nil).genVnext(inbound, nil, client, "")) if _, ok := settings["vnext"]; ok { t.Fatal("vmess outbound must not use vnext") } @@ -179,7 +179,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) { trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`} client := model.Client{Password: "p4ss"} - settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, trojan, nil, client, "")) + settings := outboundSettings(t, NewSubJsonService("", "", nil).genServer(&SubService{}, trojan, nil, client, "")) server := firstServer(settings) if server == nil { t.Fatalf("trojan outbound must use a servers array, got: %#v", settings) @@ -192,7 +192,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) { } ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`} - ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, ss, nil, client, "")) + ssSettings := outboundSettings(t, NewSubJsonService("", "", nil).genServer(&SubService{}, ss, nil, client, "")) ssServer := firstServer(ssSettings) if ssServer == nil { t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings) @@ -204,7 +204,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) { func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) { globalMux := `{"enabled":true,"concurrency":8}` - svc := NewSubJsonService(globalMux, "", "", nil) + svc := NewSubJsonService(globalMux, "", nil) // When xmux is present in xhttpSettings, the per-inbound xmux handles // multiplexing and the legacy outbound.Mux must NOT be set. @@ -251,7 +251,7 @@ func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) { func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) { globalMux := `{"enabled":true,"concurrency":8}` - svc := NewSubJsonService(globalMux, "", "", nil) + svc := NewSubJsonService(globalMux, "", nil) // When no xmux is present, the global subJsonMux should be used. stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}` @@ -305,7 +305,7 @@ func realitySpiderXFromStream(t *testing.T, svc *SubJsonService, clientKey strin } func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) { - svc := NewSubJsonService("", "", "", nil) + svc := NewSubJsonService("", "", nil) alice := realitySpiderXFromStream(t, svc, "subAlice") if again := realitySpiderXFromStream(t, svc, "subAlice"); again != alice { @@ -321,13 +321,13 @@ func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) { // security whose settings key is missing or null previously panicked the // subscription request. func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) { - withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil) + withMask := NewSubJsonService("", `{"tcp":[{"type":"fragment"}]}`, nil) stream := withMask.streamData("not-json", "clientKey") if _, ok := stream["finalmask"]; !ok { t.Fatal("finalMask must still apply when stream settings fail to parse") } - svc := NewSubJsonService("", "", "", nil) + svc := NewSubJsonService("", "", nil) noReality := svc.streamData(`{"network":"tcp","security":"reality"}`, "clientKey") if v, ok := noReality["realitySettings"]; ok { t.Fatalf("missing realitySettings must stay absent, got %v", v) @@ -339,7 +339,7 @@ func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) { } func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) { - svc := NewSubJsonService("", "", "", nil) + svc := NewSubJsonService("", "", nil) stream := svc.streamData(`{ "network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}}, @@ -384,7 +384,7 @@ func TestSubJsonServiceWireguard(t *testing.T) { AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"}, } - raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client) + raw := NewSubJsonService("", "", nil).genWireguard(inbound, client) if raw == nil { t.Fatal("genWireguard returned nil for a valid wireguard client") } @@ -428,7 +428,7 @@ func TestSubJsonServiceWireguardNoKey(t *testing.T) { inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`} client := model.Client{Email: "user"} - if raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client); raw != nil { + if raw := NewSubJsonService("", "", nil).genWireguard(inbound, client); raw != nil { t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw) } } diff --git a/internal/sub/mutation_audit_test.go b/internal/sub/mutation_audit_test.go index 778fbe0..c5027af 100644 --- a/internal/sub/mutation_audit_test.go +++ b/internal/sub/mutation_audit_test.go @@ -25,29 +25,9 @@ func initMutDB(t *testing.T) { t.Cleanup(func() { _ = database.CloseDB() }) } -// --- json_service.go:40 — rules are merged into routing only when non-empty --- - -func TestSubJsonService_CustomRulesPrepended(t *testing.T) { - rules := `[{"type":"field","domain":["geosite:ads"],"outboundTag":"block"}]` - svc := NewSubJsonService("", rules, "", nil) - - routing, ok := svc.configJson["routing"].(map[string]any) - if !ok { - t.Fatalf("routing missing: %#v", svc.configJson["routing"]) - } - got, _ := routing["rules"].([]any) - // default.json ships exactly 1 rule; the custom rule must be prepended. - if len(got) != 2 { - t.Fatalf("rules len = %d, want 2 (custom prepended to default)", len(got)) - } - first, _ := got[0].(map[string]any) - if domains, _ := first["domain"].([]any); len(domains) != 1 || domains[0] != "geosite:ads" { - t.Fatalf("custom rule must come first, got %#v", got[0]) - } -} func TestSubJsonService_EmptyRulesLeavesDefault(t *testing.T) { - svc := NewSubJsonService("", "", "", nil) + svc := NewSubJsonService("", "", nil) routing, _ := svc.configJson["routing"].(map[string]any) got, _ := routing["rules"].([]any) if len(got) != 1 { @@ -67,12 +47,12 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) { wantMux bool protocol model.Protocol }{ - {"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS}, - {"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS}, - {"server mux", NewSubJsonService(mux, "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan}, - {"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS}, - {"vless no mux", NewSubJsonService("", "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS}, - {"server no mux", NewSubJsonService("", "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan}, + {"vmess mux", NewSubJsonService(mux, "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS}, + {"vless mux", NewSubJsonService(mux, "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS}, + {"server mux", NewSubJsonService(mux, "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan}, + {"vmess no mux", NewSubJsonService("", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS}, + {"vless no mux", NewSubJsonService("", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS}, + {"server no mux", NewSubJsonService("", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -103,7 +83,7 @@ func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) { // finalMask is non-empty (passes the len(fm)==0 early return) but its only // key is an empty tcp slice, which mergeFinalMask drops → merged is empty, // so applyGlobalFinalMask must NOT set finalmask. - svc := NewSubJsonService("", "", `{"tcp":[]}`, nil) + svc := NewSubJsonService("", `{"tcp":[]}`, nil) stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "") if _, ok := stream["finalmask"]; ok { t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"]) @@ -111,7 +91,7 @@ func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) { // Sanity: a finalMask that DOES merge to something still gets set, so the // guard is the only distinguishing factor. - svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil) + svc2 := NewSubJsonService("", `{"tcp":[{"type":"fragment"}]}`, nil) stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "") if _, ok := stream2["finalmask"]; !ok { t.Fatal("non-empty finalMask must be set") diff --git a/internal/sub/remote_routing.go b/internal/sub/remote_routing.go deleted file mode 100644 index dbfe985..0000000 --- a/internal/sub/remote_routing.go +++ /dev/null @@ -1,623 +0,0 @@ -package sub - -import ( - "context" - "crypto/tls" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "sync" - "time" - - yaml "github.com/goccy/go-yaml" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/logger" - "github.com/mhsanaei/3x-ui/v3/internal/util/common" - "github.com/mhsanaei/3x-ui/v3/internal/util/netsafe" -) - -// Remote sources reuse the existing settings fields (one HTTPS URL = remote, -// else inline) so no second mode toggle can disagree with the field contents. - -type remoteRoutingKind string - -const ( - remoteRoutingHapp remoteRoutingKind = "happ" - remoteRoutingClash remoteRoutingKind = "clash" - - remoteRoutingCacheTTL = 10 * time.Minute - remoteRoutingRetryDelay = 30 * time.Second - remoteRoutingHTTPTimeout = 6 * time.Second - remoteRoutingHappMaxBody = 16 << 10 // 16 KiB; Happ emits the result in a response header - remoteRoutingHappMaxValue = 8 << 10 // normalized Routing header value - remoteRoutingClashMaxBody = 2 << 20 // 2 MiB -) - -var errRemoteRoutingUnavailable = errors.New("remote routing source is temporarily unavailable") - -type remoteRoutingKey struct { - kind remoteRoutingKind - source string -} - -type remoteRoutingCacheEntry struct { - Source string `json:"source"` - Content string `json:"content"` - FetchedAt int64 `json:"fetchedAt"` - ETag string `json:"etag,omitempty"` - LastModified string `json:"lastModified,omitempty"` - Clash map[string]any `json:"-"` -} - -func (e remoteRoutingCacheEntry) fetchedTime() time.Time { - return time.Unix(e.FetchedAt, 0) -} - -type remoteRoutingFetch struct { - done chan struct{} - err error -} - -type remoteRoutingResolver struct { - mu sync.Mutex - loadMu sync.Mutex - loaded bool - loadInFlight bool - entries map[remoteRoutingKey]remoteRoutingCacheEntry - inflight map[remoteRoutingKey]*remoteRoutingFetch - lastAttempt map[remoteRoutingKey]time.Time - client *http.Client - now func() time.Time - persist bool -} - -func newRemoteRoutingResolver(client *http.Client, persist bool) *remoteRoutingResolver { - return &remoteRoutingResolver{ - entries: make(map[remoteRoutingKey]remoteRoutingCacheEntry), - inflight: make(map[remoteRoutingKey]*remoteRoutingFetch), - lastAttempt: make(map[remoteRoutingKey]time.Time), - client: client, - now: time.Now, - persist: persist, - } -} - -var routingSourceResolver = newRemoteRoutingResolver(newRemoteRoutingHTTPClient(), true) - -// resolveRoutingSource serves a remote source from the validated cache without -// ever blocking on network; inline values pass through (bool reports remote). -func resolveRoutingSource(kind remoteRoutingKind, raw string) (string, bool, error) { - return routingSourceResolver.resolve(kind, raw) -} - -func (r *remoteRoutingResolver) resolve(kind remoteRoutingKind, raw string) (string, bool, error) { - entry, remote, err := r.resolveEntry(kind, raw) - if !remote { - return raw, false, err - } - return entry.Content, true, err -} - -func resolveClashRoutingSource(raw string) (string, map[string]any, bool, error) { - entry, remote, err := routingSourceResolver.resolveEntry(remoteRoutingClash, raw) - if !remote { - return raw, nil, false, err - } - return entry.Content, entry.Clash, true, err -} - -func (r *remoteRoutingResolver) resolveEntry(kind remoteRoutingKind, raw string) (remoteRoutingCacheEntry, bool, error) { - source, remote, err := common.ParseRemoteRoutingURL(raw) - if err != nil { - return remoteRoutingCacheEntry{}, true, err - } - if !remote { - return remoteRoutingCacheEntry{}, false, nil - } - - r.triggerPersistedLoad() - - key := remoteRoutingKey{kind: kind, source: source} - now := r.now() - - r.mu.Lock() - cached, hasCached := r.entries[key] - if hasCached && now.Sub(cached.fetchedTime()) < remoteRoutingCacheTTL { - r.mu.Unlock() - return cached, true, nil - } - - if _, ok := r.inflight[key]; ok { - r.mu.Unlock() - if hasCached { - return cached, true, nil - } - return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable - } - - if attemptedAt, attempted := r.lastAttempt[key]; attempted && now.Sub(attemptedAt) < remoteRoutingRetryDelay { - r.mu.Unlock() - if hasCached { - return cached, true, nil - } - return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable - } - - fetch := &remoteRoutingFetch{done: make(chan struct{})} - r.inflight[key] = fetch - r.mu.Unlock() - - common.GoRecover("remote-routing-refresh", func() { r.refresh(key, cached, hasCached, fetch) }) - if hasCached { - return cached, true, nil - } - return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable -} - -// RefreshRemoteRoutingSources warms and refreshes configured remote sources -// from the cron job. Concurrent resolver reads are safe; fetches coalesce. -func RefreshRemoteRoutingSources(happ, clash string) { - for kind, raw := range map[remoteRoutingKind]string{ - remoteRoutingHapp: happ, - remoteRoutingClash: clash, - } { - _, remote, parseErr := common.ParseRemoteRoutingURL(raw) - if parseErr != nil { - logger.Warningf("Remote %s routing source is invalid", kind) - continue - } - if remote { - _ = routingSourceResolver.refreshSource(kind, raw) - } - } -} - -func (r *remoteRoutingResolver) refreshSource(kind remoteRoutingKind, raw string) error { - source, remote, err := common.ParseRemoteRoutingURL(raw) - if err != nil || !remote { - return err - } - r.ensurePersistedLoaded() - - key := remoteRoutingKey{kind: kind, source: source} - now := r.now() - r.mu.Lock() - previous, hasPrevious := r.entries[key] - if hasPrevious && now.Sub(previous.fetchedTime()) < remoteRoutingCacheTTL { - r.mu.Unlock() - return nil - } - if fetch, ok := r.inflight[key]; ok { - done := fetch.done - r.mu.Unlock() - <-done - return fetch.err - } - if attemptedAt, attempted := r.lastAttempt[key]; attempted && now.Sub(attemptedAt) < remoteRoutingRetryDelay { - r.mu.Unlock() - return errRemoteRoutingUnavailable - } - fetch := &remoteRoutingFetch{done: make(chan struct{})} - r.inflight[key] = fetch - r.mu.Unlock() - - r.refresh(key, previous, hasPrevious, fetch) - return fetch.err -} - -func (r *remoteRoutingResolver) refresh(key remoteRoutingKey, previous remoteRoutingCacheEntry, hasPrevious bool, fetch *remoteRoutingFetch) { - entry, err := r.fetch(key, previous, hasPrevious) - now := r.now() - - r.mu.Lock() - r.lastAttempt[key] = now - if err == nil { - r.entries[key] = entry - } - fetch.err = err - delete(r.inflight, key) - close(fetch.done) - r.mu.Unlock() - - if err != nil { - if hasPrevious { - logger.Warningf("Remote %s routing refresh from %s failed; keeping the last valid value", key.kind, remoteRoutingHost(key.source)) - } else { - logger.Warningf("Remote %s routing refresh from %s failed; no validated value is cached", key.kind, remoteRoutingHost(key.source)) - } - return - } - if r.persist { - r.persistEntry(key.kind, entry) - } -} - -func (r *remoteRoutingResolver) fetch(key remoteRoutingKey, previous remoteRoutingCacheEntry, hasPrevious bool) (entry remoteRoutingCacheEntry, err error) { - // Remote bytes reach the YAML/JSON parsers below; a parser panic must - // degrade to a failed refresh (keeping last-good), not crash the panel. - defer func() { - if panicValue := recover(); panicValue != nil { - entry, err = remoteRoutingCacheEntry{}, fmt.Errorf("remote routing fetch panicked: %v", panicValue) - } - }() - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, key.source, nil) - if err != nil { - return remoteRoutingCacheEntry{}, err - } - req.Header.Set("User-Agent", "3x-ui-remote-routing/1.0") - if hasPrevious { - if previous.ETag != "" { - req.Header.Set("If-None-Match", previous.ETag) - } - if previous.LastModified != "" { - req.Header.Set("If-Modified-Since", previous.LastModified) - } - } - - resp, err := r.client.Do(req) - if err != nil { - return remoteRoutingCacheEntry{}, err - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotModified { - if !hasPrevious { - return remoteRoutingCacheEntry{}, errors.New("remote source returned 304 without a cached value") - } - previous.FetchedAt = r.now().Unix() - if etag := strings.TrimSpace(resp.Header.Get("ETag")); etag != "" { - previous.ETag = etag - } - if modified := strings.TrimSpace(resp.Header.Get("Last-Modified")); modified != "" { - previous.LastModified = modified - } - return previous, nil - } - if key.kind == remoteRoutingHapp && isRemoteHappRedirect(resp.StatusCode) { - location := strings.TrimSpace(resp.Header.Get("Location")) - content, locationErr := normalizeHappRouting([]byte(location)) - if locationErr != nil { - return remoteRoutingCacheEntry{}, fmt.Errorf("invalid Happ redirect target: %w", locationErr) - } - if len(content) > remoteRoutingHappMaxValue { - return remoteRoutingCacheEntry{}, errors.New("Happ routing header exceeds the size limit") - } - return remoteRoutingCacheEntry{ - Source: key.source, - Content: content, - FetchedAt: r.now().Unix(), - }, nil - } - if resp.StatusCode != http.StatusOK { - return remoteRoutingCacheEntry{}, fmt.Errorf("remote source returned HTTP %d", resp.StatusCode) - } - - limit := int64(remoteRoutingHappMaxBody) - if key.kind == remoteRoutingClash { - limit = remoteRoutingClashMaxBody - } - body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) - if err != nil { - return remoteRoutingCacheEntry{}, err - } - if int64(len(body)) > limit { - return remoteRoutingCacheEntry{}, errors.New("remote routing response exceeds the size limit") - } - - content, clash, err := normalizeRemoteRoutingContent(key.kind, body) - if err != nil { - return remoteRoutingCacheEntry{}, err - } - if key.kind == remoteRoutingHapp && len(content) > remoteRoutingHappMaxValue { - return remoteRoutingCacheEntry{}, errors.New("Happ routing header exceeds the size limit") - } - return remoteRoutingCacheEntry{ - Source: key.source, - Content: content, - FetchedAt: r.now().Unix(), - ETag: strings.TrimSpace(resp.Header.Get("ETag")), - LastModified: strings.TrimSpace(resp.Header.Get("Last-Modified")), - Clash: clash, - }, nil -} - -func isRemoteHappRedirect(status int) bool { - switch status { - case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, - http.StatusTemporaryRedirect, http.StatusPermanentRedirect: - return true - default: - return false - } -} - -func normalizeRemoteRoutingContent(kind remoteRoutingKind, body []byte) (string, map[string]any, error) { - switch kind { - case remoteRoutingHapp: - content, err := normalizeHappRouting(body) - return content, nil, err - case remoteRoutingClash: - return normalizeClashRouting(body) - default: - return "", nil, fmt.Errorf("unsupported remote routing kind %q", kind) - } -} - -func normalizeHappRouting(body []byte) (string, error) { - text := strings.TrimSpace(string(body)) - if text == "" { - return "", errors.New("empty Happ routing response") - } - - if strings.HasPrefix(text, "{") { - compact, err := validateAndCompactJSONObject([]byte(text)) - if err != nil { - return "", fmt.Errorf("invalid Happ routing JSON: %w", err) - } - return "happ://routing/onadd/" + base64.StdEncoding.EncodeToString(compact), nil - } - if strings.ContainsAny(text, "\r\n") { - return "", errors.New("Happ deeplink must be a single line") - } - - payload := "" - for _, prefix := range []string{"happ://routing/onadd/", "happ://routing/add/"} { - if after, ok := strings.CutPrefix(text, prefix); ok { - payload = after - break - } - } - if payload == "" { - return "", errors.New("Happ response is neither routing JSON nor a routing deeplink") - } - decoded, err := decodeRoutingBase64(payload) - if err != nil { - return "", fmt.Errorf("invalid Happ routing payload: %w", err) - } - if _, err := validateAndCompactJSONObject(decoded); err != nil { - return "", fmt.Errorf("invalid Happ routing payload JSON: %w", err) - } - return text, nil -} - -func validateAndCompactJSONObject(raw []byte) ([]byte, error) { - var object map[string]any - if err := json.Unmarshal(raw, &object); err != nil { - return nil, err - } - if object == nil { - return nil, errors.New("expected a JSON object") - } - return json.Marshal(object) -} - -func decodeRoutingBase64(value string) ([]byte, error) { - value = strings.TrimSpace(value) - encodings := []*base64.Encoding{ - base64.StdEncoding, - base64.RawStdEncoding, - base64.URLEncoding, - base64.RawURLEncoding, - } - var lastErr error - for _, encoding := range encodings { - decoded, err := encoding.DecodeString(value) - if err == nil { - return decoded, nil - } - lastErr = err - } - return nil, lastErr -} - -func normalizeClashRouting(body []byte) (string, map[string]any, error) { - text := strings.TrimSpace(string(body)) - if text == "" { - return "", nil, errors.New("empty Clash routing response") - } - var document map[string]any - if err := yaml.Unmarshal([]byte(text), &document); err != nil { - return "", nil, fmt.Errorf("invalid Clash routing YAML: %w", err) - } - if len(document) == 0 { - return "", nil, errors.New("Clash routing response must be a YAML map") - } - hasSupportedKey := false - for key := range document { - if remoteClashAllowedKey(key) { - hasSupportedKey = true - break - } - } - if !hasSupportedKey { - return "", nil, errors.New("Clash routing response has no supported routing keys") - } - base := map[string]any{ - "proxies": []map[string]any{{"name": "validation-node", "type": "vless"}}, - "proxy-groups": []map[string]any{{ - "name": "PROXY", "type": "select", "proxies": []string{"validation-node", "DIRECT"}, - }}, - "rules": []string{"MATCH,PROXY"}, - } - if err := mergeRemoteClashRules(base, document); err != nil { - return "", nil, fmt.Errorf("invalid remote Clash routing schema: %w", err) - } - return text, document, nil -} - -func resolveIncyRoutingSource(raw string) (string, bool, error) { - source, remote, err := common.ParseRemoteRoutingURL(raw) - if err != nil || !remote { - return raw, remote, err - } - return "incy://autorouting/onadd/" + source, true, nil -} - -func newRemoteRoutingHTTPClient() *http.Client { - transport := &http.Transport{ - Proxy: nil, - DialContext: netsafe.SSRFGuardedDialContext, - ForceAttemptHTTP2: true, - TLSHandshakeTimeout: 4 * time.Second, - ResponseHeaderTimeout: 5 * time.Second, - TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, - } - return &http.Client{ - Timeout: remoteRoutingHTTPTimeout, - Transport: transport, - CheckRedirect: checkRemoteRoutingRedirect, - } -} - -func checkRemoteRoutingRedirect(req *http.Request, via []*http.Request) error { - if len(via) >= 5 { - return errors.New("stopped after 5 redirects") - } - if strings.EqualFold(req.URL.Scheme, "happ") { - // routing.help-style services publish the deeplink as the final Location; - // hand the 3xx back to fetch(), which validates it without a request. - return http.ErrUseLastResponse - } - if !strings.EqualFold(req.URL.Scheme, "https") || req.URL.Hostname() == "" || req.URL.User != nil { - return errors.New("remote routing redirect must stay on an absolute HTTPS URL") - } - // The guarded dialer re-resolves, validates and connects to the same public - // address, including on every HTTPS redirect hop. - return nil -} - -func remoteRoutingHost(source string) string { - u, err := url.Parse(source) - if err != nil || u.Hostname() == "" { - return "unknown host" - } - return u.Hostname() -} - -func remoteRoutingSettingKey(kind remoteRoutingKind) string { - return "_subRemoteRoutingCache_" + string(kind) -} - -func (r *remoteRoutingResolver) ensurePersistedLoaded() { - if !r.persist { - return - } - r.mu.Lock() - loaded := r.loaded - r.mu.Unlock() - if loaded { - return - } - - r.loadMu.Lock() - defer r.loadMu.Unlock() - r.mu.Lock() - loaded = r.loaded - r.mu.Unlock() - if loaded { - return - } - db := database.GetDB() - if db == nil { - return - } - sqlDB, err := db.DB() - if err != nil || sqlDB.Ping() != nil { - return - } - r.loadPersisted() - r.mu.Lock() - r.loaded = true - r.mu.Unlock() -} - -// triggerPersistedLoad keeps SQLite off the subscription request path: requests -// schedule at most one background load; the startup job loads synchronously. -func (r *remoteRoutingResolver) triggerPersistedLoad() { - if !r.persist { - return - } - r.mu.Lock() - if r.loaded || r.loadInFlight { - r.mu.Unlock() - return - } - r.loadInFlight = true - r.mu.Unlock() - - common.GoRecover("remote-routing-cache-load", func() { - defer func() { - r.mu.Lock() - r.loadInFlight = false - r.mu.Unlock() - }() - r.ensurePersistedLoaded() - }) -} - -func (r *remoteRoutingResolver) loadPersisted() { - loaded := make(map[remoteRoutingKey]remoteRoutingCacheEntry, 2) - for _, kind := range []remoteRoutingKind{remoteRoutingHapp, remoteRoutingClash} { - var setting model.Setting - err := database.GetDB().Where("key = ?", remoteRoutingSettingKey(kind)).First(&setting).Error - if err != nil { - continue - } - var entry remoteRoutingCacheEntry - if json.Unmarshal([]byte(setting.Value), &entry) != nil || entry.Source == "" || entry.Content == "" || entry.FetchedAt <= 0 { - continue - } - if _, remote, err := common.ParseRemoteRoutingURL(entry.Source); err != nil || !remote { - continue - } - normalized, clash, err := normalizeRemoteRoutingContent(kind, []byte(entry.Content)) - if err != nil { - continue - } - if kind == remoteRoutingHapp && len(normalized) > remoteRoutingHappMaxValue { - continue - } - entry.Content = normalized - entry.Clash = clash - loaded[remoteRoutingKey{kind: kind, source: entry.Source}] = entry - } - r.mu.Lock() - for key, entry := range loaded { - current, exists := r.entries[key] - if !exists || entry.FetchedAt > current.FetchedAt { - r.entries[key] = entry - } - } - r.mu.Unlock() -} - -func (r *remoteRoutingResolver) persistEntry(kind remoteRoutingKind, entry remoteRoutingCacheEntry) { - db := database.GetDB() - if db == nil { - return - } - encoded, err := json.Marshal(entry) - if err != nil { - return - } - key := remoteRoutingSettingKey(kind) - var setting model.Setting - err = db.Where("key = ?", key).First(&setting).Error - if database.IsNotFound(err) { - err = db.Create(&model.Setting{Key: key, Value: string(encoded)}).Error - } else if err == nil { - setting.Value = string(encoded) - err = db.Save(&setting).Error - } - if err != nil { - logger.Warningf("Could not persist the last valid %s remote routing value", kind) - } -} diff --git a/internal/sub/remote_routing_test.go b/internal/sub/remote_routing_test.go deleted file mode 100644 index c843083..0000000 --- a/internal/sub/remote_routing_test.go +++ /dev/null @@ -1,750 +0,0 @@ -package sub - -import ( - "encoding/base64" - "errors" - "io" - "net/http" - "net/http/httptest" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/gin-gonic/gin" - yaml "github.com/goccy/go-yaml" - - "github.com/mhsanaei/3x-ui/v3/internal/database" -) - -func mergeRemoteClashRulesYAML(base map[string]any, raw string) error { - var remote map[string]any - if err := yaml.Unmarshal([]byte(strings.TrimSpace(raw)), &remote); err != nil { - return err - } - return mergeRemoteClashRules(base, remote) -} - -type remoteRoutingRoundTripper func(*http.Request) (*http.Response, error) - -func (fn remoteRoutingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - return fn(req) -} - -func remoteRoutingTestClient(fn remoteRoutingRoundTripper) *http.Client { - return &http.Client{Transport: fn} -} - -func remoteRoutingResponse(status int, body string) *http.Response { - return &http.Response{ - StatusCode: status, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(body)), - } -} - -func waitRemoteRoutingIdle(t *testing.T, resolver *remoteRoutingResolver) { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for { - resolver.mu.Lock() - inflight := len(resolver.inflight) - resolver.mu.Unlock() - if inflight == 0 { - return - } - if time.Now().After(deadline) { - t.Fatal("remote routing refresh did not finish") - } - time.Sleep(time.Millisecond) - } -} - -func waitRemoteRoutingLoadIdle(t *testing.T, resolver *remoteRoutingResolver) { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for { - resolver.mu.Lock() - loading := resolver.loadInFlight - resolver.mu.Unlock() - if !loading { - return - } - if time.Now().After(deadline) { - t.Fatal("persisted routing cache load did not finish") - } - time.Sleep(time.Millisecond) - } -} - -func primeRemoteRouting(t *testing.T, resolver *remoteRoutingResolver, kind remoteRoutingKind, source string) string { - t.Helper() - if err := resolver.refreshSource(kind, source); err != nil { - t.Fatalf("prime remote routing: %v", err) - } - value, remote, err := resolver.resolve(kind, source) - if err != nil || !remote || value == "" { - t.Fatalf("primed resolve got=%q remote=%v err=%v", value, remote, err) - } - return value -} - -func TestNormalizeHappRoutingAcceptsJSONAndDeeplink(t *testing.T) { - deeplink, err := normalizeHappRouting([]byte(`{"Name":"RoscomVPN","GlobalProxy":"true"}`)) - if err != nil { - t.Fatalf("normalize JSON: %v", err) - } - const prefix = "happ://routing/onadd/" - if !strings.HasPrefix(deeplink, prefix) { - t.Fatalf("deeplink = %q", deeplink) - } - decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(deeplink, prefix)) - if err != nil || !strings.Contains(string(decoded), `"Name":"RoscomVPN"`) { - t.Fatalf("decoded payload = %q, err=%v", decoded, err) - } - - if got, err := normalizeHappRouting([]byte(deeplink + "\n")); err != nil || got != deeplink { - t.Fatalf("ready deeplink got=%q err=%v", got, err) - } - if _, err := normalizeHappRouting([]byte("routing.help")); err == nil { - t.Fatal("invalid Happ response was accepted") - } -} - -func TestRemoteRoutingResolverAcceptsHappRedirect(t *testing.T) { - deeplink, err := normalizeHappRouting([]byte(`{"Name":"redirected"}`)) - if err != nil { - t.Fatalf("normalize: %v", err) - } - var requests atomic.Int32 - client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) { - requests.Add(1) - response := remoteRoutingResponse(http.StatusFound, "") - response.Header.Set("Location", deeplink) - response.Request = req - return response, nil - }) - client.CheckRedirect = checkRemoteRoutingRedirect - resolver := newRemoteRoutingResolver(client, false) - - const source = "https://routing.example/" - if err := resolver.refreshSource(remoteRoutingHapp, source); err != nil { - t.Fatalf("refresh redirect: %v", err) - } - got, remote, err := resolver.resolve(remoteRoutingHapp, source) - if err != nil || !remote || got != deeplink { - t.Fatalf("redirect resolve got=%q remote=%v err=%v", got, remote, err) - } - if requests.Load() != 1 { - t.Fatalf("network requests = %d, want 1", requests.Load()) - } -} - -func TestRemoteRoutingResolverHandlesHappNotModified(t *testing.T) { - var requests atomic.Int32 - client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) { - if requests.Add(1) == 1 { - response := remoteRoutingResponse(http.StatusOK, `{"Name":"etagged"}`) - response.Header.Set("ETag", `"v1"`) - return response, nil - } - if req.Header.Get("If-None-Match") != `"v1"` { - t.Errorf("If-None-Match = %q", req.Header.Get("If-None-Match")) - } - return remoteRoutingResponse(http.StatusNotModified, ""), nil - }) - resolver := newRemoteRoutingResolver(client, false) - now := time.Unix(1_800_000_000, 0) - resolver.now = func() time.Time { return now } - const source = "https://example.com/default.json" - - first := primeRemoteRouting(t, resolver, remoteRoutingHapp, source) - now = now.Add(remoteRoutingCacheTTL + time.Second) - second, _, err := resolver.resolve(remoteRoutingHapp, source) - if err != nil || second != first { - t.Fatalf("stale resolve got=%q err=%v", second, err) - } - waitRemoteRoutingIdle(t, resolver) - now = now.Add(time.Minute) - third, _, err := resolver.resolve(remoteRoutingHapp, source) - if err != nil || third != first { - t.Fatalf("refreshed cache got=%q err=%v", third, err) - } - if requests.Load() != 2 { - t.Fatalf("requests = %d, want 2", requests.Load()) - } -} - -func TestRemoteRoutingResolverDoesNotBlockAndCoalescesColdFetch(t *testing.T) { - var requests atomic.Int32 - started := make(chan struct{}) - release := make(chan struct{}) - var startOnce sync.Once - client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) { - requests.Add(1) - startOnce.Do(func() { close(started) }) - <-release - return remoteRoutingResponse(http.StatusOK, `{"Name":"RoscomVPN"}`), nil - }) - resolver := newRemoteRoutingResolver(client, false) - const source = "https://example.com/default.json" - - results := make(chan error, 8) - for range 8 { - go func() { - _, remote, err := resolver.resolve(remoteRoutingHapp, source) - if !remote { - results <- errors.New("source was not classified as remote") - return - } - results <- err - }() - } - <-started - for range 8 { - select { - case err := <-results: - if !errors.Is(err, errRemoteRoutingUnavailable) { - t.Fatalf("cold resolve err=%v", err) - } - case <-time.After(100 * time.Millisecond): - t.Fatal("cold resolve blocked on the remote fetch") - } - } - if got := requests.Load(); got != 1 { - t.Fatalf("requests = %d, want 1", got) - } - close(release) - waitRemoteRoutingIdle(t, resolver) - if got, _, err := resolver.resolve(remoteRoutingHapp, source); err != nil || !strings.HasPrefix(got, "happ://routing/onadd/") { - t.Fatalf("cached resolve got=%q err=%v", got, err) - } - if got := requests.Load(); got != 1 { - t.Fatalf("cached request count = %d, want 1", got) - } -} - -func TestRemoteRoutingResolverServesStaleAfterFailedRefresh(t *testing.T) { - var requests atomic.Int32 - refreshStarted := make(chan struct{}) - releaseRefresh := make(chan struct{}) - var startOnce sync.Once - fail := atomic.Bool{} - client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) { - requests.Add(1) - if fail.Load() { - startOnce.Do(func() { close(refreshStarted) }) - <-releaseRefresh - return remoteRoutingResponse(http.StatusBadGateway, "bad gateway"), nil - } - return remoteRoutingResponse(http.StatusOK, `{"Name":"last-good"}`), nil - }) - resolver := newRemoteRoutingResolver(client, false) - now := time.Unix(1_800_000_000, 0) - resolver.now = func() time.Time { return now } - const source = "https://example.com/default.json" - - first := primeRemoteRouting(t, resolver, remoteRoutingHapp, source) - fail.Store(true) - now = now.Add(remoteRoutingCacheTTL + time.Second) - startedAt := time.Now() - stale, remote, err := resolver.resolve(remoteRoutingHapp, source) - if err != nil || !remote || stale != first { - t.Fatalf("stale resolve got=%q remote=%v err=%v", stale, remote, err) - } - if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond { - t.Fatalf("stale resolve blocked for %v", elapsed) - } - select { - case <-refreshStarted: - case <-time.After(time.Second): - t.Fatal("refresh did not run") - } - close(releaseRefresh) - - waitRemoteRoutingIdle(t, resolver) - - if got, _, err := resolver.resolve(remoteRoutingHapp, source); err != nil || got != first { - t.Fatalf("negative-cache resolve got=%q err=%v", got, err) - } - if got := requests.Load(); got != 2 { - t.Fatalf("requests = %d, want 2", got) - } -} - -func TestRemoteRoutingResolverLoadsPersistedLastGood(t *testing.T) { - initSubDB(t) - - deeplink, err := normalizeHappRouting([]byte(`{"Name":"persisted"}`)) - if err != nil { - t.Fatalf("normalize: %v", err) - } - const source = "https://example.com/default.json" - entry := remoteRoutingCacheEntry{ - Source: source, Content: deeplink, FetchedAt: time.Now().Add(-time.Hour).Unix(), ETag: `"v1"`, - } - newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, entry) - - resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) { - return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil - }), true) - resolver.ensurePersistedLoaded() - got, remote, err := resolver.resolve(remoteRoutingHapp, source) - if err != nil || !remote || got != deeplink { - t.Fatalf("persisted resolve got=%q remote=%v err=%v", got, remote, err) - } - waitRemoteRoutingIdle(t, resolver) -} - -func TestRemoteRoutingResolverDoesNotBlockOnPersistedLoad(t *testing.T) { - started := make(chan struct{}) - release := make(chan struct{}) - var startOnce sync.Once - resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) { - startOnce.Do(func() { close(started) }) - <-release - return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil - }), true) - - resolver.loadMu.Lock() - loadLocked := true - t.Cleanup(func() { - if loadLocked { - resolver.loadMu.Unlock() - } - }) - - startedAt := time.Now() - _, remote, err := resolver.resolve(remoteRoutingHapp, "https://example.com/default.json") - if !remote || !errors.Is(err, errRemoteRoutingUnavailable) { - t.Fatalf("resolve remote=%v err=%v", remote, err) - } - if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond { - t.Fatalf("resolve blocked on persisted cache load for %v", elapsed) - } - - resolver.loadMu.Unlock() - loadLocked = false - close(release) - select { - case <-started: - case <-time.After(time.Second): - t.Fatal("background refresh did not start") - } - waitRemoteRoutingIdle(t, resolver) - waitRemoteRoutingLoadIdle(t, resolver) -} - -func TestRemoteRoutingResolverRejectsOversizedPersistedHappValue(t *testing.T) { - initSubDB(t) - - deeplink, err := normalizeHappRouting([]byte(`{"Name":"` + strings.Repeat("x", remoteRoutingHappMaxValue) + `"}`)) - if err != nil || len(deeplink) <= remoteRoutingHappMaxValue { - t.Fatalf("oversized fixture length=%d err=%v", len(deeplink), err) - } - const source = "https://example.com/oversized.json" - newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, remoteRoutingCacheEntry{ - Source: source, Content: deeplink, FetchedAt: time.Now().Unix(), - }) - - resolver := newRemoteRoutingResolver(nil, true) - resolver.ensurePersistedLoaded() - resolver.mu.Lock() - _, exists := resolver.entries[remoteRoutingKey{kind: remoteRoutingHapp, source: source}] - resolver.mu.Unlock() - if exists { - t.Fatal("oversized persisted Happ routing value was loaded") - } -} - -func TestRemoteRoutingResolverDoesNotReplaceClashCacheWithInvalidSchema(t *testing.T) { - var requests atomic.Int32 - client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) { - if requests.Add(1) == 1 { - return remoteRoutingResponse(http.StatusOK, "rules:\n - MATCH,PROXY\n"), nil - } - return remoteRoutingResponse(http.StatusOK, "rules: not-a-list\n"), nil - }) - resolver := newRemoteRoutingResolver(client, false) - now := time.Unix(1_800_000_000, 0) - resolver.now = func() time.Time { return now } - const source = "https://example.com/routing.yaml" - - first := primeRemoteRouting(t, resolver, remoteRoutingClash, source) - now = now.Add(remoteRoutingCacheTTL + time.Second) - second, _, err := resolver.resolve(remoteRoutingClash, source) - if err != nil || second != first { - t.Fatalf("invalid refresh replaced last-good: got=%q err=%v", second, err) - } - waitRemoteRoutingIdle(t, resolver) - second, _, err = resolver.resolve(remoteRoutingClash, source) - if err != nil || second != first { - t.Fatalf("invalid refresh replaced last-good after completion: got=%q err=%v", second, err) - } - if requests.Load() != 2 { - t.Fatalf("requests = %d, want 2", requests.Load()) - } -} - -func TestApplyCommonHeadersResolvesRemoteHappAndFailsClosed(t *testing.T) { - gin.SetMode(gin.TestMode) - oldResolver := routingSourceResolver - t.Cleanup(func() { routingSourceResolver = oldResolver }) - - routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) { - return remoteRoutingResponse(http.StatusOK, `{"Name":"RoscomVPN"}`), nil - }), false) - const source = "https://example.com/default.json" - primeRemoteRouting(t, routingSourceResolver, remoteRoutingHapp, source) - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - (&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", true, source, false) - if recorder.Header().Get("Routing-Enable") != "true" || !strings.HasPrefix(recorder.Header().Get("Routing"), "happ://routing/onadd/") { - t.Fatalf("headers = %#v", recorder.Header()) - } - - recorder = httptest.NewRecorder() - ctx, _ = gin.CreateTestContext(recorder) - (&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, source, false) - if recorder.Header().Get("Routing-Enable") != "" || !strings.HasPrefix(recorder.Header().Get("Routing"), "happ://routing/onadd/") { - t.Fatalf("independent routing headers = %#v", recorder.Header()) - } - - routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) { - return remoteRoutingResponse(http.StatusOK, "routing.help"), nil - }), false) - recorder = httptest.NewRecorder() - ctx, _ = gin.CreateTestContext(recorder) - (&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", true, "https://example.com/bad", false) - if recorder.Header().Get("Routing-Enable") != "true" || recorder.Header().Get("Routing") != "" { - t.Fatalf("invalid remote source leaked routing headers: %#v", recorder.Header()) - } - waitRemoteRoutingIdle(t, routingSourceResolver) -} - -func TestResolveIncyRemoteSourceUsesAutorouting(t *testing.T) { - got, remote, err := resolveIncyRoutingSource("https://example.com/DEFAULT.JSON") - if err != nil || !remote || got != "incy://autorouting/onadd/https://example.com/DEFAULT.JSON" { - t.Fatalf("got=%q remote=%v err=%v", got, remote, err) - } - inline := "incy://routing/onadd/abc" - if got, remote, err := resolveIncyRoutingSource(inline); err != nil || remote || got != inline { - t.Fatalf("inline got=%q remote=%v err=%v", got, remote, err) - } -} - -func TestMergeRemoteClashRulesPreservesGeneratedProxies(t *testing.T) { - originalProxy := map[string]any{"name": "vpn-node", "type": "vless"} - base := map[string]any{ - "proxies": []map[string]any{originalProxy}, - "proxy-groups": []map[string]any{{ - "name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"}, - }}, - "rules": []string{"MATCH,PROXY"}, - } - remote := ` -proxies: - - name: attacker-controlled -proxy-providers: - prov: - url: -external-controller: 0.0.0.0:9090 -allow-lan: true -mixed-port: 7890 -dns: - enable: true -tun: - enable: true -proxy-groups: - - name: VPN - type: select - include-all: true - - name: PROXY - type: select - proxies: [VPN] -rule-providers: - roscom: - type: http - url: https://example.com/rules.mrs -rules: - - RULE-SET,roscom,PROXY - - MATCH,PROXY -` - if err := mergeRemoteClashRulesYAML(base, remote); err != nil { - t.Fatalf("merge: %v", err) - } - proxies, ok := base["proxies"].([]map[string]any) - if !ok || len(proxies) != 1 || proxies[0]["name"] != "vpn-node" { - t.Fatalf("generated proxies were replaced: %#v", base["proxies"]) - } - if _, exists := base["proxy-providers"]; exists { - t.Fatal("remote proxy-providers were imported") - } - if _, exists := base["external-controller"]; exists { - t.Fatal("unsafe top-level key was imported") - } - for _, key := range []string{"allow-lan", "mixed-port", "dns", "tun"} { - if _, exists := base[key]; exists { - t.Fatalf("client-local key %q was imported", key) - } - } - if _, exists := base["rule-providers"]; !exists { - t.Fatal("rule-providers were not imported") - } - groups, ok := asAnySlice(base["proxy-groups"]) - if !ok || len(groups) != 2 || clashProxyGroupName(groups[0]) != "VPN" || clashProxyGroupName(groups[1]) != "PROXY" { - t.Fatalf("proxy groups = %#v", base["proxy-groups"]) - } - rules, ok := asAnySlice(base["rules"]) - if !ok || len(rules) != 2 || rules[1] != "MATCH,PROXY" { - t.Fatalf("rules = %#v", base["rules"]) - } -} - -func TestMergeRemoteClashRulesKeepsBaseProxyGroupWhenRemoteOmitsIt(t *testing.T) { - base := map[string]any{ - "proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}}, - "proxy-groups": []map[string]any{{ - "name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"}, - }}, - "rules": []string{"MATCH,PROXY"}, - } - if err := mergeRemoteClashRulesYAML(base, `proxy-groups: - - name: Extra - type: select - proxies: [PROXY] -rules: - - MATCH,PROXY -`); err != nil { - t.Fatalf("merge: %v", err) - } - groups, ok := asAnySlice(base["proxy-groups"]) - if !ok || len(groups) != 2 || clashProxyGroupName(groups[0]) != "Extra" || clashProxyGroupName(groups[1]) != "PROXY" { - t.Fatalf("proxy groups = %#v", base["proxy-groups"]) - } -} - -func TestRemoteRoutingRejectsOversizedHappValues(t *testing.T) { - largeJSON := `{"Name":"large","Rules":"` + strings.Repeat("a", remoteRoutingHappMaxValue) + `"}` - largeDeeplink, err := normalizeHappRouting([]byte(largeJSON)) - if err != nil { - t.Fatalf("prepare large deeplink: %v", err) - } - - tests := []struct { - name string - response func(*http.Request) *http.Response - wantErr string - }{ - { - name: "response body", - response: func(*http.Request) *http.Response { - return remoteRoutingResponse(http.StatusOK, strings.Repeat("x", remoteRoutingHappMaxBody+1)) - }, - wantErr: "response exceeds the size limit", - }, - { - name: "normalized header", - response: func(*http.Request) *http.Response { - return remoteRoutingResponse(http.StatusOK, largeJSON) - }, - wantErr: "header exceeds the size limit", - }, - { - name: "redirect header", - response: func(req *http.Request) *http.Response { - response := remoteRoutingResponse(http.StatusFound, "") - response.Header.Set("Location", largeDeeplink) - response.Request = req - return response - }, - wantErr: "header exceeds the size limit", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) { - return tt.response(req), nil - }) - client.CheckRedirect = checkRemoteRoutingRedirect - resolver := newRemoteRoutingResolver(client, false) - err := resolver.refreshSource(remoteRoutingHapp, "https://example.com/rules") - if err == nil || !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("err=%v, want %q", err, tt.wantErr) - } - }) - } -} - -func TestRemoteRoutingRefreshTurnsPanicsIntoErrors(t *testing.T) { - client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) { - panic("transport exploded") - }) - resolver := newRemoteRoutingResolver(client, false) - err := resolver.refreshSource(remoteRoutingHapp, "https://example.com/rules") - if err == nil || !strings.Contains(err.Error(), "panicked") { - t.Fatalf("err=%v, want the panic converted into an error", err) - } - // The inflight slot must be released so later refreshes are not wedged. - waitRemoteRoutingIdle(t, resolver) -} - -func TestRemoteRoutingHTTPClientRejectsLoopback(t *testing.T) { - resolver := newRemoteRoutingResolver(newRemoteRoutingHTTPClient(), false) - startedAt := time.Now() - err := resolver.refreshSource(remoteRoutingHapp, "https://127.0.0.1:1/rules") - if err == nil { - t.Fatal("loopback remote source was accepted") - } - if elapsed := time.Since(startedAt); elapsed > 2*time.Second { - t.Fatalf("loopback rejection took %v", elapsed) - } -} - -func TestRemoteRoutingPersistedLoadRetriesAfterDatabaseBecomesReady(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "x-ui.db") - if err := database.InitDB(dbPath); err != nil { - t.Fatalf("init db: %v", err) - } - t.Cleanup(func() { _ = database.CloseDB() }) - - deeplink, err := normalizeHappRouting([]byte(`{"Name":"persisted-after-ready"}`)) - if err != nil { - t.Fatalf("normalize: %v", err) - } - const source = "https://example.com/default.json" - newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, remoteRoutingCacheEntry{ - Source: source, Content: deeplink, FetchedAt: time.Now().Unix(), - }) - if err := database.CloseDB(); err != nil { - t.Fatalf("close db: %v", err) - } - - resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) { - return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil - }), true) - if _, _, err := resolver.resolve(remoteRoutingHapp, source); !errors.Is(err, errRemoteRoutingUnavailable) { - t.Fatalf("closed-db resolve err=%v", err) - } - waitRemoteRoutingIdle(t, resolver) - waitRemoteRoutingLoadIdle(t, resolver) - if err := database.InitDB(dbPath); err != nil { - t.Fatalf("reopen db: %v", err) - } - resolver.triggerPersistedLoad() - waitRemoteRoutingLoadIdle(t, resolver) - got, remote, err := resolver.resolve(remoteRoutingHapp, source) - if err != nil || !remote || got != deeplink { - t.Fatalf("reloaded resolve got=%q remote=%v err=%v", got, remote, err) - } -} - -func TestRemoteClashRouteGraphValidation(t *testing.T) { - tests := []struct { - name string - remote string - wantErr string - }{ - { - name: "missing group name", - remote: "proxy-groups:\n - type: select\n proxies: [vpn-node]\nrules:\n - MATCH,PROXY\n", - wantErr: "named group maps", - }, - { - name: "duplicate group name", - remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - MATCH,A\n", - wantErr: "duplicated", - }, - { - name: "unknown group reference", - remote: "proxy-groups:\n - {name: A, type: select, proxies: [missing]}\nrules:\n - MATCH,A\n", - wantErr: "unknown proxy or group", - }, - { - name: "remote proxy provider use", - remote: "proxy-groups:\n - name: A\n type: select\n use: [manual-provider]\nrules:\n - MATCH,A\n", - wantErr: "cannot use proxy-providers", - }, - { - name: "unknown rule provider", - remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - RULE-SET,missing,A\n - MATCH,A\n", - wantErr: "unknown rule-provider", - }, - { - name: "unknown rule target", - remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - MATCH,missing\n", - wantErr: "unknown proxy or group", - }, - { - name: "unknown provider download proxy", - remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrule-providers:\n p: {type: http, url: https://example.com/p.mrs, proxy: missing}\nrules:\n - RULE-SET,p,A\n - MATCH,A\n", - wantErr: "rule-provider \"p\" references unknown", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - base := map[string]any{ - "proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}}, - "proxy-groups": []map[string]any{{ - "name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"}, - }}, - "rules": []string{"MATCH,PROXY"}, - } - err := mergeRemoteClashRulesYAML(base, tt.remote) - if err == nil || !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("err=%v, want %q", err, tt.wantErr) - } - }) - } -} - -func TestRemoteClashRouteGraphAcceptsLogicalRulesAndCachedDocument(t *testing.T) { - const remote = ` -proxy-groups: - - name: Auto - type: url-test - include-all: true - - name: Video - type: select - proxies: [Auto, DIRECT] -rule-providers: - video: - type: http - url: https://example.com/video.mrs - proxy: Auto -rules: - - RULE-SET,video,Video - - AND,((NETWORK,TCP),(DST-PORT,443)),Video - - GEOIP,private,DIRECT,no-resolve - - IP-CIDR,192.168.0.0/16,DIRECT,no-resolve,src - - MATCH,Auto -` - var requests atomic.Int32 - resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) { - requests.Add(1) - return remoteRoutingResponse(http.StatusOK, remote), nil - }), false) - const source = "https://example.com/routing.yaml" - if err := resolver.refreshSource(remoteRoutingClash, source); err != nil { - t.Fatalf("refresh: %v", err) - } - entry, remoteSource, err := resolver.resolveEntry(remoteRoutingClash, source) - if err != nil || !remoteSource || entry.Clash == nil { - t.Fatalf("entry remote=%v parsed=%v err=%v", remoteSource, entry.Clash != nil, err) - } - base := map[string]any{ - "proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}}, - "proxy-groups": []map[string]any{{ - "name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"}, - }}, - "rules": []string{"MATCH,PROXY"}, - } - if err := mergeRemoteClashRules(base, entry.Clash); err != nil { - t.Fatalf("merge cached document: %v", err) - } - if requests.Load() != 1 { - t.Fatalf("requests=%d, want 1", requests.Load()) - } -} diff --git a/internal/sub/sub.go b/internal/sub/sub.go index 3d5a784..a22da6d 100644 --- a/internal/sub/sub.go +++ b/internal/sub/sub.go @@ -145,11 +145,6 @@ func (s *Server) initRouter() (*gin.Engine, error) { SubJsonMux = "" } - SubJsonRules, err := s.settingService.GetSubJsonRules() - if err != nil { - SubJsonRules = "" - } - SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask() if err != nil { SubJsonFinalMask = "" @@ -160,16 +155,6 @@ func (s *Server) initRouter() (*gin.Engine, error) { SubJsonObservatory = "" } - SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting() - if err != nil { - SubClashEnableRouting = false - } - - SubClashRules, err := s.settingService.GetSubClashRules() - if err != nil { - SubClashRules = "" - } - SubTitle, err := s.settingService.GetSubTitle() if err != nil { SubTitle = "" @@ -190,31 +175,11 @@ func (s *Server) initRouter() (*gin.Engine, error) { SubAnnounce = "" } - SubEnableRouting, err := s.settingService.GetSubEnableRouting() - if err != nil { - return nil, err - } - - SubRoutingRules, err := s.settingService.GetSubRoutingRules() - if err != nil { - SubRoutingRules = "" - } - SubHideSettings, err := s.settingService.GetSubHideSettings() if err != nil { SubHideSettings = false } - SubIncyEnableRouting, err := s.settingService.GetSubIncyEnableRouting() - if err != nil { - SubIncyEnableRouting = false - } - - SubIncyRoutingRules, err := s.settingService.GetSubIncyRoutingRules() - if err != nil { - SubIncyRoutingRules = "" - } - // set per-request localizer from headers/cookies engine.Use(locale.LocalizerMiddleware()) @@ -284,20 +249,13 @@ func (s *Server) initRouter() (*gin.Engine, error) { WithSUBRemarkTemplate(RemarkTemplate), WithSUBUpdateInterval(SubUpdates), WithSUBJsonMux(SubJsonMux), - WithSUBJsonRules(SubJsonRules), WithSUBJsonFinalMask(SubJsonFinalMask), WithSUBJsonObservatory(SubJsonObservatory), - WithSUBClashEnableRouting(SubClashEnableRouting), - WithSUBClashRules(SubClashRules), WithSUBTitle(SubTitle), WithSUBSupportURL(SubSupportUrl), WithSUBProfileURL(SubProfileUrl), WithSUBAnnounce(SubAnnounce), - WithSUBEnableRouting(SubEnableRouting), - WithSUBRoutingRules(SubRoutingRules), WithSUBHideSettings(SubHideSettings), - WithSUBIncyEnableRouting(SubIncyEnableRouting), - WithSUBIncyRoutingRules(SubIncyRoutingRules), ) return engine, nil diff --git a/internal/sub/sub_balancer_protocol_tag_test.go b/internal/sub/sub_balancer_protocol_tag_test.go index f1a091d..c55e11d 100644 --- a/internal/sub/sub_balancer_protocol_tag_test.go +++ b/internal/sub/sub_balancer_protocol_tag_test.go @@ -44,7 +44,7 @@ func TestSubJson_BalancerMemberTagUsesProtocol(t *testing.T) { Remark: "proto", Strategy: "random", InboundIds: []int{vm.Id}, SortOrder: 1, Enabled: true, }) - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) out, _, err := js.GetJson("s1", "req.example.com", true) if err != nil { t.Fatalf("GetJson: %v", err) diff --git a/internal/sub/sub_balancer_test.go b/internal/sub/sub_balancer_test.go index f295e11..1602e90 100644 --- a/internal/sub/sub_balancer_test.go +++ b/internal/sub/sub_balancer_test.go @@ -56,8 +56,7 @@ func TestSubJson_BalancerDocument(t *testing.T) { Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id, ws.Id}, SortOrder: 1, Enabled: true, }) - rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]` - js := NewSubJsonService("", rules, "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) out, _, err := js.GetJson("s1", "req.example.com", true) if err != nil { t.Fatalf("GetJson: %v", err) @@ -103,8 +102,8 @@ func TestSubJson_BalancerDocument(t *testing.T) { t.Fatalf("balancer catch-all rule missing balancerTag: %s", ruleJSON) } proxyRules := strings.Count(string(ruleJSON), `"balancerTag"`) - if proxyRules != 2 { // custom rule + default catch-all - t.Fatalf("balancerTag rules = %d, want 2: %s", proxyRules, ruleJSON) + if proxyRules != 1 { // default catch-all only (custom sub rules were removed) + t.Fatalf("balancerTag rules = %d, want 1: %s", proxyRules, ruleJSON) } observatory, _ := balancerDoc["burstObservatory"].(map[string]any) @@ -155,7 +154,7 @@ func TestSubJson_BalancerOrderInterleavesWithInbounds(t *testing.T) { Remark: "bal", Strategy: "roundRobin", InboundIds: []int{later.Id, first.Id}, SortOrder: 1, Enabled: true, }) - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) out, _, err := js.GetJson("s1", "req.example.com", true) if err != nil { t.Fatalf("GetJson: %v", err) @@ -189,7 +188,7 @@ func TestSubJson_BalancerDisabledAndEmptySkipped(t *testing.T) { Remark: "nomembers", Strategy: "random", InboundIds: []int{inbound.Id + 100}, SortOrder: 1, Enabled: true, }) - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) out, _, err := js.GetJson("s1", "req.example.com", true) if err != nil { t.Fatalf("GetJson: %v", err) @@ -213,7 +212,7 @@ func TestSubJson_BalancerTagDedup(t *testing.T) { Remark: "dedup", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true, }) - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) out, _, err := js.GetJson("s1", "req.example.com", true) if err != nil { t.Fatalf("GetJson: %v", err) @@ -241,7 +240,7 @@ func TestSubJson_BalancerObservatoryConditional(t *testing.T) { Remark: "pinger", Strategy: "leastPing", InboundIds: []int{lp.Id}, SortOrder: 2, Enabled: true, }) - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`) out, _, err := js.GetJson("s1", "req.example.com", true) if err != nil { @@ -287,7 +286,7 @@ func TestSubJson_BalancerExcludesDisabledInbound(t *testing.T) { Remark: "bal", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true, }) - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) out, _, err := js.GetJson("s1", "req.example.com", true) if err != nil { t.Fatalf("GetJson: %v", err) @@ -322,7 +321,7 @@ func TestSubJson_BalancerSkippedWhenAllMembersDisabled(t *testing.T) { Remark: "empty", Strategy: "random", InboundIds: []int{only.Id}, SortOrder: 1, Enabled: true, }) - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) out, _, err := js.GetJson("s1", "req.example.com", true) if err != nil { t.Fatalf("GetJson: %v", err) @@ -345,7 +344,7 @@ func TestSubJson_BalancerObservatoryConnectivityDefaultEmpty(t *testing.T) { Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true, }) - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) out, _, err := js.GetJson("s1", "req.example.com", true) if err != nil { t.Fatalf("GetJson: %v", err) @@ -386,7 +385,7 @@ func TestSubJson_BalancerObservatoryAlwaysEmittedForProbingStrategies(t *testing Remark: "pinger", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true, }) - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) js.SetObservatoryConfig(`{"enabled":false}`) out, _, err := js.GetJson("s1", "req.example.com", true) if err != nil { diff --git a/internal/sub/sub_json_observatory_test.go b/internal/sub/sub_json_observatory_test.go index d4aaa5a..64fc598 100644 --- a/internal/sub/sub_json_observatory_test.go +++ b/internal/sub/sub_json_observatory_test.go @@ -29,7 +29,7 @@ func TestSubJson_ObservatoryConfigInvalidValuesFallBack(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) js.SetObservatoryConfig(tc.cfg) out, _, err := js.GetJson("s1", "req.example.com", true) if err != nil { diff --git a/internal/sub/sub_panic_test.go b/internal/sub/sub_panic_test.go index 334dcb0..69db39a 100644 --- a/internal/sub/sub_panic_test.go +++ b/internal/sub/sub_panic_test.go @@ -68,7 +68,7 @@ func TestGetJsonToleratesHysteriaWithoutHysteriaSettings(t *testing.T) { t.Fatalf("seed client_inbound: %v", err) } - jsonService := NewSubJsonService("", "", "", NewSubService("")) + jsonService := NewSubJsonService("", "", NewSubService("")) out, _, err := jsonService.GetJson(subId, "sub.example.com", true) if err != nil { t.Fatalf("GetJson: %v", err) @@ -83,7 +83,7 @@ func TestGetJsonToleratesNonStringRealityShortId(t *testing.T) { stream := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["sni.example.com"],"shortIds":[42],"settings":{"publicKey":"pk"}}}` seedSubInbound(t, "rlty1", "rlty", 46400, 1, stream) - jsonService := NewSubJsonService("", "", "", NewSubService("")) + jsonService := NewSubJsonService("", "", NewSubService("")) out, _, err := jsonService.GetJson("rlty1", "sub.example.com", true) if err != nil { t.Fatalf("GetJson: %v", err) @@ -99,7 +99,7 @@ func TestGetClashEmitsPinnedCertSha256(t *testing.T) { stream := `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"pin.sni","settings":{"pinnedPeerCertSha256":["` + pin + `"]}}}` seedSubInbound(t, "pin1", "pin", 46300, 1, stream) - out, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("pin1", "sub.example.com") + out, _, err := NewSubClashService(NewSubService("")).GetClash("pin1", "sub.example.com") if err != nil { t.Fatalf("GetClash: %v", err) } @@ -113,7 +113,7 @@ func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) { stream := `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":"cdn.example.com"}]}` seedSubInbound(t, "extp1", "extp", 46500, 1, stream) - jsonService := NewSubJsonService("", "", "", NewSubService("")) + jsonService := NewSubJsonService("", "", NewSubService("")) jsonOut, _, err := jsonService.GetJson("extp1", "sub.example.com", true) if err != nil { t.Fatalf("GetJson: %v", err) @@ -122,7 +122,7 @@ func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) { t.Fatal("GetJson returned empty for an externalProxy entry missing port") } - clashOut, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("extp1", "sub.example.com") + clashOut, _, err := NewSubClashService(NewSubService("")).GetClash("extp1", "sub.example.com") if err != nil { t.Fatalf("GetClash: %v", err) } diff --git a/internal/sub/sub_scale_test.go b/internal/sub/sub_scale_test.go index d333984..6251298 100644 --- a/internal/sub/sub_scale_test.go +++ b/internal/sub/sub_scale_test.go @@ -209,7 +209,7 @@ func TestGetSubsScale(t *testing.T) { t.Fatalf("GetSubs links = %d, want 3", len(links)) } - jsonSvc := NewSubJsonService("", "", "", &SubService{}) + jsonSvc := NewSubJsonService("", "", &SubService{}) start = time.Now() for range reps { body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com", false) diff --git a/internal/sub/vless_route_sub_test.go b/internal/sub/vless_route_sub_test.go index 35d1468..58df680 100644 --- a/internal/sub/vless_route_sub_test.go +++ b/internal/sub/vless_route_sub_test.go @@ -51,7 +51,7 @@ func TestSub_HostVlessRoute_JSON(t *testing.T) { ib := seedSubInbound(t, "s1", "vrj", 4501, 1, wsTLSStream) seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "J", Address: "j.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"}) - js := NewSubJsonService("", "", "", NewSubService("")) + js := NewSubJsonService("", "", NewSubService("")) out, _, err := js.GetJson("s1", "req.example.com", false) if err != nil { t.Fatalf("GetJson: %v", err) @@ -69,7 +69,7 @@ func TestSub_HostVlessRoute_Clash(t *testing.T) { ib := seedSubInbound(t, "s1", "vrc", 4502, 1, wsTLSStream) seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "C", Address: "c.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"}) - clash := NewSubClashService(false, "", NewSubService("")) + clash := NewSubClashService(NewSubService("")) yaml, _, err := clash.GetClash("s1", "req.example.com") if err != nil { t.Fatalf("GetClash: %v", err) diff --git a/internal/web/cadence_test.go b/internal/web/cadence_test.go index 2983f98..9886af7 100644 --- a/internal/web/cadence_test.go +++ b/internal/web/cadence_test.go @@ -23,8 +23,6 @@ func TestJobCadencesAreValidCronSpecs(t *testing.T) { "cadenceNodeHeartbeat": cadenceNodeHeartbeat, "cadenceNodeTraffic": cadenceNodeTraffic, "cadenceOutboundSub": cadenceOutboundSub, - "cadenceRemoteRouting": cadenceRemoteRouting, - "cadenceCheckHash": cadenceCheckHash, "cadenceCPUAlarm": cadenceCPUAlarm, } for name, spec := range cadences { diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go index 58ba3ef..e92a083 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -51,10 +51,6 @@ type AllSetting struct { SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"` SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"` SubAnnounce string `json:"subAnnounce" form:"subAnnounce"` - SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` - SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"` - SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"` - SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"` SubListen string `json:"subListen" form:"subListen"` SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"` SubPath string `json:"subPath" form:"subPath"` @@ -70,10 +66,7 @@ type AllSetting struct { SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` SubClashPath string `json:"subClashPath" form:"subClashPath"` SubClashURI string `json:"subClashURI" form:"subClashURI"` - SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` - SubClashRules string `json:"subClashRules" form:"subClashRules"` SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` - SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"` SubThemeDir string `json:"subThemeDir" form:"subThemeDir"` diff --git a/internal/web/job/remote_routing_job.go b/internal/web/job/remote_routing_job.go deleted file mode 100644 index 2b368c9..0000000 --- a/internal/web/job/remote_routing_job.go +++ /dev/null @@ -1,31 +0,0 @@ -package job - -import ( - "github.com/mhsanaei/3x-ui/v3/internal/logger" - "github.com/mhsanaei/3x-ui/v3/internal/sub" - "github.com/mhsanaei/3x-ui/v3/internal/web/service" -) - -// RemoteRoutingJob keeps remote Happ and Clash/Mihomo routing URLs warm: all -// network work runs here (cron + startup warm), never in a request handler. -type RemoteRoutingJob struct { - settingService service.SettingService -} - -func NewRemoteRoutingJob() *RemoteRoutingJob { - return &RemoteRoutingJob{} -} - -func (j *RemoteRoutingJob) Run() { - happ, err := j.settingService.GetSubRoutingRules() - if err != nil { - logger.Warning("Could not read Happ routing source:", err) - return - } - clash, err := j.settingService.GetSubClashRules() - if err != nil { - logger.Warning("Could not read Clash routing source:", err) - return - } - sub.RefreshRemoteRoutingSources(happ, clash) -} diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index 8cacbcb..abefe80 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -81,11 +81,7 @@ var defaultValueMap = map[string]string{ "subSupportUrl": "", "subProfileUrl": "", "subAnnounce": "", - "subEnableRouting": "false", - "subRoutingRules": "", "subHideSettings": "false", - "subIncyEnableRouting": "false", - "subIncyRoutingRules": "", "subListen": "", "subPort": "2096", "subPath": "/sub/", @@ -100,10 +96,7 @@ var defaultValueMap = map[string]string{ "subClashEnable": "false", "subClashPath": "/clash/", "subClashURI": "", - "subClashEnableRouting": "false", - "subClashRules": "", "subJsonMux": "", - "subJsonRules": "", "subJsonFinalMask": "", "subJsonObservatory": "", "subThemeDir": "", @@ -623,26 +616,10 @@ func (s *SettingService) GetSubAnnounce() (string, error) { return s.getString("subAnnounce") } -func (s *SettingService) GetSubEnableRouting() (bool, error) { - return s.getBool("subEnableRouting") -} - -func (s *SettingService) GetSubRoutingRules() (string, error) { - return s.getString("subRoutingRules") -} - func (s *SettingService) GetSubHideSettings() (bool, error) { return s.getBool("subHideSettings") } -func (s *SettingService) GetSubIncyEnableRouting() (bool, error) { - return s.getBool("subIncyEnableRouting") -} - -func (s *SettingService) GetSubIncyRoutingRules() (string, error) { - return s.getString("subIncyRoutingRules") -} - func (s *SettingService) GetSubListen() (string, error) { return s.getString("subListen") } @@ -711,22 +688,10 @@ func (s *SettingService) GetSubClashURI() (string, error) { return s.getString("subClashURI") } -func (s *SettingService) GetSubClashEnableRouting() (bool, error) { - return s.getBool("subClashEnableRouting") -} - -func (s *SettingService) GetSubClashRules() (string, error) { - return s.getString("subClashRules") -} - func (s *SettingService) GetSubJsonMux() (string, error) { return s.getString("subJsonMux") } -func (s *SettingService) GetSubJsonRules() (string, error) { - return s.getString("subJsonRules") -} - func (s *SettingService) GetSubJsonFinalMask() (string, error) { return s.getString("subJsonFinalMask") } @@ -913,26 +878,6 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error { // the scheme instead of forcing SanitizeHTTPURL's http(s)-only rule. allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl) allSetting.SubProfileUrl = common.EnsureURLScheme(allSetting.SubProfileUrl) - for name, value := range map[string]*string{ - "Happ routing source": &allSetting.SubRoutingRules, - "Clash/Mihomo routing source": &allSetting.SubClashRules, - "Incy routing source": &allSetting.SubIncyRoutingRules, - } { - if err := validateRemoteRoutingURLSetting(name, value); err != nil { - return err - } - } - return nil -} - -func validateRemoteRoutingURLSetting(name string, value *string) error { - canonical, remote, err := common.ParseRemoteRoutingURL(*value) - if err != nil { - return common.NewError(name, err.Error()) - } - if remote { - *value = canonical - } return nil } diff --git a/internal/web/service/setting_clash_test.go b/internal/web/service/setting_clash_test.go index ac701b4..d62bb78 100644 --- a/internal/web/service/setting_clash_test.go +++ b/internal/web/service/setting_clash_test.go @@ -128,8 +128,6 @@ func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) { settings.SubClashEnable = true settings.SubClashPath = "/clash-custom/" settings.SubClashURI = "https://subscriptions.example.com/clash-custom/" - settings.SubClashEnableRouting = true - settings.SubClashRules = "GEOIP,private,DIRECT" if err := s.UpdateAllSetting(settings); err != nil { t.Fatal(err) @@ -172,12 +170,6 @@ func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) { if got.SubClashURI != "https://subscriptions.example.com/clash-custom/" { t.Fatalf("subClashURI = %q, want %q", got.SubClashURI, "https://subscriptions.example.com/clash-custom/") } - if !got.SubClashEnableRouting { - t.Fatal("subClashEnableRouting = false, want true") - } - if got.SubClashRules != "GEOIP,private,DIRECT" { - t.Fatalf("subClashRules = %q, want %q", got.SubClashRules, "GEOIP,private,DIRECT") - } } func TestUpdateAllSettingRejectsInvalidClashUserAgentRegex(t *testing.T) { diff --git a/internal/web/service/setting_remote_routing_test.go b/internal/web/service/setting_remote_routing_test.go deleted file mode 100644 index 4276b2f..0000000 --- a/internal/web/service/setting_remote_routing_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package service - -import ( - "strings" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/web/entity" -) - -func TestValidateRemoteRoutingURLSettings(t *testing.T) { - tests := []struct { - name string - value string - want string - wantError string - }{ - {name: "valid HTTPS", value: " https://example.com/rules#fragment ", want: "https://example.com/rules"}, - {name: "credentials", value: "https://user:pass@example.com/rules", wantError: "must not contain URL credentials"}, - {name: "missing host", value: "https:///rules", wantError: "absolute HTTPS URL"}, - {name: "legacy HTTP stays inline", value: "http://example.com/rules", want: "http://example.com/rules"}, - {name: "multiline Clash stays inline", value: "https://example.com/rules\nMATCH,PROXY", want: "https://example.com/rules\nMATCH,PROXY"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - settings := &entity.AllSetting{SubRoutingRules: tt.value} - err := validateSettingsURLs(settings) - if tt.wantError != "" { - if err == nil || !strings.Contains(err.Error(), tt.wantError) { - t.Fatalf("err=%v, want %q", err, tt.wantError) - } - return - } - if err != nil || settings.SubRoutingRules != tt.want { - t.Fatalf("value=%q err=%v", settings.SubRoutingRules, err) - } - }) - } -} diff --git a/internal/web/web.go b/internal/web/web.go index 2dc8cb9..ca25c51 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -295,9 +295,7 @@ const ( cadenceNodeTraffic = "@every 5s" cadenceOutboundSub = "@every 5m" cadenceReapOrphans = "@every 5m" - cadenceRemoteRouting = "@every 5m" cadenceXrayLogPrune = "@every 10m" - cadenceCheckHash = "@every 2m" // cpu.Percent samples over a full minute (blocking), so a finer cadence just // stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway. cadenceCPUAlarm = "@every 1m" @@ -348,11 +346,6 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) { _, _ = s.cron.AddJob(cadenceReapOrphans, job.NewReapSyncOrphansJob()) - // Warm permanent routing URLs immediately and refresh them outside the - // latency-sensitive subscription request path. - remoteRoutingJob := job.NewRemoteRoutingJob() - _, _ = s.cron.AddJob(cadenceRemoteRouting, remoteRoutingJob) - common.GoRecover("remote-routing-warm", remoteRoutingJob.Run) // check client ips from log file every day _, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())