Переглянути джерело

feat(sub): bake Happ/INCY routing profiles into the JSON subscription (#6402)

* feat(sub): parse generic Happ/INCY routing payloads for the JSON subscription

Accepts the routing-rules format emitted for Happ and INCY (inline JSON,
happ:// or incy:// deeplink, or a remote https:// URL resolved through the
existing remote routing cache). The JSON subscription will bake these
rules into its documents so header-ignoring clients still get routing.

* feat(sub): bake Happ/INCY routing profiles into JSON subscription documents

When subJsonRoutingRules is set, every emitted document (per-inbound and
balancer alike) carries the profile's dns and routing rules baked in, so
header-ignoring clients like Happ and INCY still get routing; the legacy
simple-rules merge only applies when no profile is set. The balancer
document builder keeps rewriting proxy-tag rules to the balancer.

* feat(sub): add the subJsonRoutingRules setting

Plumbed from the settings store through the subscription server into
SubJsonService, so admins can set a routing profile once and every JSON
subscription document carries it.

* chore(api): regenerate OpenAPI artifacts for subJsonRoutingRules

* feat(web): routing profile editor for the JSON subscription

A textarea inside the JSON card accepts the routing profile (inline JSON,
happ/incy deeplink, or https URL) with a remote-source badge; the badge
helper moves to a shared module. Keys added to all 13 locales.

* fix(sub): warm and lazily resolve the baked JSON routing source

The routing profile was resolved once at service construction: a remote
URL that was cold at that moment baked default routing forever, and the
cron job never warmed it. The job now warms the subJsonRoutingRules URL,
and the profile resolves per request with an in-memory memo (a failed
resolve is not cached), so a warmed cache takes effect without a restart.

* feat(sub): fall back to the JSON routing profile for the Routing header

Happ and INCY download the geo files a routing profile references
through the Routing response header. When the Happ header setting was
blank the header stayed unset, and clients fetched no geo files even
though a JSON routing profile was configured. A blank setting now falls
back to the JSON profile: happ/incy deeplinks pass through, inline JSON
and remote URLs are normalized to a happ:// deeplink; an unusable or
oversized value leaves the header unset. Locale captions mention the
fallback.

* fix(sub): pass routingRules arg at call sites added by main

Main gained four NewSubJsonService call sites after this branch forked;
update them to the five-arg signature so internal/sub builds again.

* fix(sub): address code review findings on the baked JSON routing

The memoised baked template never invalidated, so an edited remote
profile kept serving the superseded dns/routing subtrees until a panel
restart; bakedTemplate now re-resolves the spec per request and rebuilds
only when the payload actually changed (regression-tested).

subJsonRoutingRules shared the happ persistence row with
subRoutingRules, so only the last-written setting survived a restart;
it now resolves under its own jsonhapp kind with the same validation
and size caps. The setting also joins validateSettingsURLs, so remote
values are canonicalised and bad URLs are rejected on save.

Also: drop the unreachable half of the remote-source guard, cut the
overlong comment blocks to the two-line convention, and deduplicate
remoteSourceBadge in the General tab. Merges upstream/main (call sites
for the widened NewSubJsonService signature).

* style(sub): gofumpt the json_routing imports

* fix(sub): accept happ add/ deeplinks and bound the routing warning

The baked-JSON routing parser only recognised happ://routing/onadd/, but
normalizeHappRouting treats happ://routing/add/ as an equally valid routing
deeplink. An operator pasting the add/ form got the Routing header set, so
the panel looked configured, while every JSON subscription document silently
carried the default routing instead of their profile.

resolveJsonRoutingSpec logged one warning per call and bakedTemplate calls it
once per emitted document, so a single fetch of an unusable profile wrote one
identical warning per document. On the public subscription server that floods
the 10240-entry buffer the panel's log view reads, evicting real entries. Log
only when the message changes, and reset on a successful resolve so a profile
that recovers and fails again is still reported.

Also resolve the template once in buildBalancerConfig: two resolves could
straddle a profile refresh and pair one revision's dns with the other's
routing.
DIMFLIX 8 годин тому
батько
коміт
2dd903ea8e
47 змінених файлів з 1302 додано та 113 видалено
  1. 8 0
      docs/public/openapi.json
  2. 8 0
      frontend/public/openapi.json
  3. 2 0
      frontend/src/generated/examples.ts
  4. 8 0
      frontend/src/generated/schemas.ts
  5. 2 0
      frontend/src/generated/types.ts
  6. 2 0
      frontend/src/generated/zod.ts
  7. 1 0
      frontend/src/models/setting.ts
  8. 14 0
      frontend/src/pages/settings/SubscriptionFormatsTab.tsx
  9. 2 6
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  10. 6 0
      frontend/src/pages/settings/subscriptionShared.tsx
  11. 1 0
      frontend/src/schemas/setting.ts
  12. 29 17
      internal/sub/controller.go
  13. 1 1
      internal/sub/external_only_sub_test.go
  14. 3 3
      internal/sub/host_sub_test.go
  15. 2 2
      internal/sub/json_flow_gate_test.go
  16. 4 4
      internal/sub/json_info_node_test.go
  17. 366 0
      internal/sub/json_routing.go
  18. 447 0
      internal/sub/json_routing_baked_test.go
  19. 192 0
      internal/sub/json_routing_test.go
  20. 50 9
      internal/sub/json_service.go
  21. 21 21
      internal/sub/json_service_test.go
  22. 10 10
      internal/sub/mutation_audit_test.go
  23. 27 17
      internal/sub/remote_routing.go
  24. 6 0
      internal/sub/sub.go
  25. 1 1
      internal/sub/sub_balancer_protocol_tag_test.go
  26. 12 12
      internal/sub/sub_balancer_test.go
  27. 1 1
      internal/sub/sub_json_observatory_test.go
  28. 3 3
      internal/sub/sub_panic_test.go
  29. 1 1
      internal/sub/sub_scale_test.go
  30. 1 1
      internal/sub/vless_route_sub_test.go
  31. 1 0
      internal/web/entity/entity.go
  32. 6 1
      internal/web/job/remote_routing_job.go
  33. 9 3
      internal/web/service/setting.go
  34. 29 0
      internal/web/service/setting_remote_routing_test.go
  35. 2 0
      internal/web/translation/ar-EG.json
  36. 2 0
      internal/web/translation/en-US.json
  37. 2 0
      internal/web/translation/es-ES.json
  38. 2 0
      internal/web/translation/fa-IR.json
  39. 2 0
      internal/web/translation/id-ID.json
  40. 2 0
      internal/web/translation/ja-JP.json
  41. 2 0
      internal/web/translation/pt-BR.json
  42. 2 0
      internal/web/translation/ru-RU.json
  43. 2 0
      internal/web/translation/tr-TR.json
  44. 2 0
      internal/web/translation/uk-UA.json
  45. 2 0
      internal/web/translation/vi-VN.json
  46. 2 0
      internal/web/translation/zh-CN.json
  47. 2 0
      internal/web/translation/zh-TW.json

+ 8 - 0
docs/public/openapi.json

@@ -323,6 +323,9 @@
           "subJsonPath": {
             "type": "string"
           },
+          "subJsonRoutingRules": {
+            "type": "string"
+          },
           "subJsonRules": {
             "type": "string"
           },
@@ -547,6 +550,7 @@
           "subJsonMux",
           "subJsonObservatory",
           "subJsonPath",
+          "subJsonRoutingRules",
           "subJsonRules",
           "subJsonURI",
           "subJsonUserAgentRegex",
@@ -907,6 +911,9 @@
           "subJsonPath": {
             "type": "string"
           },
+          "subJsonRoutingRules": {
+            "type": "string"
+          },
           "subJsonRules": {
             "type": "string"
           },
@@ -1138,6 +1145,7 @@
           "subJsonMux",
           "subJsonObservatory",
           "subJsonPath",
+          "subJsonRoutingRules",
           "subJsonRules",
           "subJsonURI",
           "subJsonUserAgentRegex",

+ 8 - 0
frontend/public/openapi.json

@@ -323,6 +323,9 @@
           "subJsonPath": {
             "type": "string"
           },
+          "subJsonRoutingRules": {
+            "type": "string"
+          },
           "subJsonRules": {
             "type": "string"
           },
@@ -547,6 +550,7 @@
           "subJsonMux",
           "subJsonObservatory",
           "subJsonPath",
+          "subJsonRoutingRules",
           "subJsonRules",
           "subJsonURI",
           "subJsonUserAgentRegex",
@@ -907,6 +911,9 @@
           "subJsonPath": {
             "type": "string"
           },
+          "subJsonRoutingRules": {
+            "type": "string"
+          },
           "subJsonRules": {
             "type": "string"
           },
@@ -1138,6 +1145,7 @@
           "subJsonMux",
           "subJsonObservatory",
           "subJsonPath",
+          "subJsonRoutingRules",
           "subJsonRules",
           "subJsonURI",
           "subJsonUserAgentRegex",

+ 2 - 0
frontend/src/generated/examples.ts

@@ -93,6 +93,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subJsonMux": "",
     "subJsonObservatory": "",
     "subJsonPath": "",
+    "subJsonRoutingRules": "",
     "subJsonRules": "",
     "subJsonURI": "",
     "subJsonUserAgentRegex": "",
@@ -233,6 +234,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subJsonMux": "",
     "subJsonObservatory": "",
     "subJsonPath": "",
+    "subJsonRoutingRules": "",
     "subJsonRules": "",
     "subJsonURI": "",
     "subJsonUserAgentRegex": "",

+ 8 - 0
frontend/src/generated/schemas.ts

@@ -297,6 +297,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonPath": {
         "type": "string"
       },
+      "subJsonRoutingRules": {
+        "type": "string"
+      },
       "subJsonRules": {
         "type": "string"
       },
@@ -521,6 +524,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonMux",
       "subJsonObservatory",
       "subJsonPath",
+      "subJsonRoutingRules",
       "subJsonRules",
       "subJsonURI",
       "subJsonUserAgentRegex",
@@ -881,6 +885,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonPath": {
         "type": "string"
       },
+      "subJsonRoutingRules": {
+        "type": "string"
+      },
       "subJsonRules": {
         "type": "string"
       },
@@ -1112,6 +1119,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonMux",
       "subJsonObservatory",
       "subJsonPath",
+      "subJsonRoutingRules",
       "subJsonRules",
       "subJsonURI",
       "subJsonUserAgentRegex",

+ 2 - 0
frontend/src/generated/types.ts

@@ -101,6 +101,7 @@ export interface AllSetting {
   subJsonMux: string;
   subJsonObservatory: string;
   subJsonPath: string;
+  subJsonRoutingRules: string;
   subJsonRules: string;
   subJsonURI: string;
   subJsonUserAgentRegex: string;
@@ -242,6 +243,7 @@ export interface AllSettingView {
   subJsonMux: string;
   subJsonObservatory: string;
   subJsonPath: string;
+  subJsonRoutingRules: string;
   subJsonRules: string;
   subJsonURI: string;
   subJsonUserAgentRegex: string;

+ 2 - 0
frontend/src/generated/zod.ts

@@ -117,6 +117,7 @@ export const AllSettingSchema = z.object({
   subJsonMux: z.string(),
   subJsonObservatory: z.string(),
   subJsonPath: z.string(),
+  subJsonRoutingRules: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),
   subJsonUserAgentRegex: z.string(),
@@ -259,6 +260,7 @@ export const AllSettingViewSchema = z.object({
   subJsonMux: z.string(),
   subJsonObservatory: z.string(),
   subJsonPath: z.string(),
+  subJsonRoutingRules: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),
   subJsonUserAgentRegex: z.string(),

+ 1 - 0
frontend/src/models/setting.ts

@@ -69,6 +69,7 @@ export class AllSetting {
   subClashRules = '';
   subJsonMux = '';
   subJsonRules = '';
+  subJsonRoutingRules = '';
   subJsonFinalMask = '';
   subJsonObservatory = '';
   subThemeDir = '';

+ 14 - 0
frontend/src/pages/settings/SubscriptionFormatsTab.tsx

@@ -16,6 +16,7 @@ import { GoRegexInput } from '@/components/form';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
+import { remoteSourceBadge } from './subscriptionShared';
 import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
 import './SubscriptionFormatsTab.css';
 
@@ -220,6 +221,19 @@ export default function SubscriptionFormatsTab({
                       onChange={(value) => updateSetting({ subJsonUserAgentRegex: value })}
                     />
                   </SettingListItem>
+                  <SettingListItem
+                    paddings="small"
+                    title={t('pages.settings.subJsonRoutingRules')}
+                    badge={remoteSourceBadge(allSetting.subJsonRoutingRules)}
+                    description={t('pages.settings.subJsonRoutingRulesDesc')}
+                  >
+                    <Input.TextArea
+                      value={allSetting.subJsonRoutingRules}
+                      placeholder="happ://routing/onadd/... , routing JSON, or https://.../DEFAULT.JSON"
+                      onChange={(e) => updateSetting({ subJsonRoutingRules: e.target.value })}
+                      autoSize={{ minRows: 2, maxRows: 6 }}
+                    />
+                  </SettingListItem>
                 </Card>
               )}
               {allSetting.subClashEnable && (

+ 2 - 6
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -1,4 +1,4 @@
-import { Alert, Button, Input, InputNumber, Switch, Tabs, Tag } from 'antd';
+import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
 import {
   BranchesOutlined,
   CompassOutlined,
@@ -18,17 +18,13 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
 import HappSettingsContent from './HappSettingsContent';
+import { remoteSourceBadge } from './subscriptionShared';
 
 interface SubscriptionGeneralTabProps {
   allSetting: AllSetting;
   updateSetting: (patch: Partial<AllSetting>) => void;
 }
 
-const isRemoteRoutingSource = (value: string) => /^https:\/\/\S+$/i.test(value.trim());
-
-const remoteSourceBadge = (value: string) =>
-  isRemoteRoutingSource(value) ? <Tag color="blue">HTTPS URL</Tag> : undefined;
-
 export default function SubscriptionGeneralTab({
   allSetting,
   updateSetting,

+ 6 - 0
frontend/src/pages/settings/subscriptionShared.tsx

@@ -0,0 +1,6 @@
+import { Tag } from 'antd';
+
+export const isRemoteRoutingSource = (value: string) => /^https:\/\/\S+$/i.test(value.trim());
+
+export const remoteSourceBadge = (value: string) =>
+  isRemoteRoutingSource(value) ? <Tag color="blue">HTTPS URL</Tag> : undefined;

+ 1 - 0
frontend/src/schemas/setting.ts

@@ -74,6 +74,7 @@ export const AllSettingSchema = z
     subClashRules: z.string().optional(),
     subJsonMux: z.string().optional(),
     subJsonRules: z.string().optional(),
+    subJsonRoutingRules: z.string().optional(),
     subJsonFinalMask: z.string().optional(),
     subJsonObservatory: z.string().optional(),
     subHideSettings: z.boolean().optional(),

+ 29 - 17
internal/sub/controller.go

@@ -45,14 +45,15 @@ 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
-	happConfig       HappConfig
+	subTitle            string
+	subSupportUrl       string
+	subProfileUrl       string
+	subAnnounce         string
+	subEnableRouting    bool
+	subRoutingRules     string
+	subJsonRoutingRules string
+	subHideSettings     bool
+	happConfig          HappConfig
 
 	subIncyEnableRouting bool
 	subIncyRoutingRules  string
@@ -99,6 +100,7 @@ type subControllerConfig struct {
 
 	subJsonMux            string
 	subJsonRules          string
+	subJsonRoutingRules   string
 	subJsonFinalMask      string
 	subJsonObservatory    string
 	subClashEnableRouting bool
@@ -179,6 +181,10 @@ func WithSUBJsonRules(value string) SUBControllerOption {
 	return func(config *subControllerConfig) { config.subJsonRules = value }
 }
 
+func WithSUBJsonRoutingRules(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonRoutingRules = value }
+}
+
 func WithSUBJsonFinalMask(value string) SUBControllerOption {
 	return func(config *subControllerConfig) { config.subJsonFinalMask = value }
 }
@@ -254,17 +260,18 @@ 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.subJsonRules, config.subJsonFinalMask, config.subJsonRoutingRules, 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,
-		happConfig:       config.happConfig,
+		subTitle:            config.subTitle,
+		subSupportUrl:       config.subSupportURL,
+		subProfileUrl:       config.subProfileURL,
+		subAnnounce:         config.subAnnounce,
+		subEnableRouting:    config.subEnableRouting,
+		subRoutingRules:     config.subRoutingRules,
+		subJsonRoutingRules: config.subJsonRoutingRules,
+		subHideSettings:     config.subHideSettings,
+		happConfig:          config.happConfig,
 
 		subIncyEnableRouting: config.subIncyEnableRouting,
 		subIncyRoutingRules:  config.subIncyRoutingRules,
@@ -853,6 +860,11 @@ func (a *SUBController) ApplyCommonHeaders(
 	}
 
 	rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
+	if strings.TrimSpace(profileRoutingRules) == "" {
+		// Happ/INCY fetch the geo files the baked JSON rules reference through
+		// this header, so a blank Happ setting falls back to the JSON profile.
+		rules, remote, routingErr = jsonRoutingHeaderSource(a.subJsonRoutingRules), false, nil
+	}
 	// The off values undo a previously pushed setting, so they ride the same
 	// opt-in as every other Happ header rather than reaching every Happ client.
 	happManaged := a.happConfig.AutoDetect && c.Request != nil && IsHappClient(c.GetHeader("User-Agent"))

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

+ 3 - 3
internal/sub/host_sub_test.go

@@ -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)

+ 2 - 2
internal/sub/json_flow_gate_test.go

@@ -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)

+ 4 - 4
internal/sub/json_info_node_test.go

@@ -56,7 +56,7 @@ func TestSubJson_InfoNode_Active(t *testing.T) {
 
 	sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
 	sub.subInfoNodeEnable = true
-	jsonSvc := NewSubJsonService("", "", "", sub)
+	jsonSvc := NewSubJsonService("", "", "", "", sub)
 
 	out, _, err := jsonSvc.GetJson("sub-json", "sub.example.com", false)
 	if err != nil {
@@ -115,7 +115,7 @@ func TestSubJson_InfoNode_Expired(t *testing.T) {
 	sub := NewSubService("{{INBOUND}}")
 	sub.subInfoNodeEnable = true
 	sub.subExpiredTemplate = service.DefaultSubExpiredTemplate
-	jsonSvc := NewSubJsonService("", "", "", sub)
+	jsonSvc := NewSubJsonService("", "", "", "", sub)
 
 	out, _, err := jsonSvc.GetJson("sub-json-exp", "sub.example.com", false)
 	if err != nil {
@@ -175,7 +175,7 @@ func TestSubJson_InfoNode_Depleted(t *testing.T) {
 	sub := NewSubService("{{INBOUND}}")
 	sub.subInfoNodeEnable = true
 	sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
-	jsonSvc := NewSubJsonService("", "", "", sub)
+	jsonSvc := NewSubJsonService("", "", "", "", sub)
 
 	out, _, err := jsonSvc.GetJson("sub-json-dep", "sub.example.com", false)
 	if err != nil {
@@ -234,7 +234,7 @@ func TestSubJson_InfoNode_StatusActive(t *testing.T) {
 
 	sub := NewSubService("{{EMAIL}}|{{STATUS_EMOJI}} {{STATUS}}")
 	sub.subInfoNodeEnable = true
-	jsonSvc := NewSubJsonService("", "", "", sub)
+	jsonSvc := NewSubJsonService("", "", "", "", sub)
 
 	out, _, err := jsonSvc.GetJson("sub-json-status", "sub.example.com", false)
 	if err != nil {

+ 366 - 0
internal/sub/json_routing.go

@@ -0,0 +1,366 @@
+package sub
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"maps"
+	"slices"
+	"strings"
+	"sync/atomic"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+)
+
+// jsonRoutingSpec is the canonical form of the generic Happ/INCY routing
+// payload (inline JSON, happ:// or incy:// deeplink, or remote URL).
+type jsonRoutingSpec struct {
+	DomainStrategy    string
+	RemoteDNSDomain   string
+	RemoteDNSIP       string
+	DomesticDNSDomain string
+	DomesticDNSIP     string
+	DnsHosts          map[string]string
+	RouteOrder        []string // e.g. {"block","proxy","direct"}; default {"block","direct","proxy"}
+	DirectSites       []string
+	DirectIp          []string
+	ProxySites        []string
+	ProxyIp           []string
+	BlockSites        []string
+	BlockIp           []string
+}
+
+func (s jsonRoutingSpec) empty() bool {
+	return s.DomainStrategy == "" && s.RemoteDNSDomain == "" && s.RemoteDNSIP == "" &&
+		s.DomesticDNSDomain == "" && s.DomesticDNSIP == "" && len(s.DnsHosts) == 0 &&
+		len(s.RouteOrder) == 0 && len(s.DirectSites) == 0 && len(s.DirectIp) == 0 &&
+		len(s.ProxySites) == 0 && len(s.ProxyIp) == 0 && len(s.BlockSites) == 0 && len(s.BlockIp) == 0
+}
+
+// equal reports whether two specs carry the same routing payload, so a
+// rebuilt template only replaces the memoised one when the profile changed.
+func (s jsonRoutingSpec) equal(other jsonRoutingSpec) bool {
+	return s.DomainStrategy == other.DomainStrategy &&
+		s.RemoteDNSDomain == other.RemoteDNSDomain && s.RemoteDNSIP == other.RemoteDNSIP &&
+		s.DomesticDNSDomain == other.DomesticDNSDomain && s.DomesticDNSIP == other.DomesticDNSIP &&
+		maps.Equal(s.DnsHosts, other.DnsHosts) && slices.Equal(s.RouteOrder, other.RouteOrder) &&
+		slices.Equal(s.DirectSites, other.DirectSites) && slices.Equal(s.DirectIp, other.DirectIp) &&
+		slices.Equal(s.ProxySites, other.ProxySites) && slices.Equal(s.ProxyIp, other.ProxyIp) &&
+		slices.Equal(s.BlockSites, other.BlockSites) && slices.Equal(s.BlockIp, other.BlockIp)
+}
+
+// Both happ forms appear in the wild; normalizeHappRouting accepts each.
+var jsonRoutingDeeplinkPrefixes = []string{
+	"happ://routing/onadd/", "happ://routing/add/", "incy://routing/onadd/",
+}
+
+// bakedTemplate resolves once per emitted document, so an unusable profile
+// must not write one identical warning per document on every public fetch.
+var lastJsonRoutingWarning atomic.Value
+
+// resolveJsonRoutingSpec parses the routing payload, degrading to an empty
+// spec on error — a bad setting must never take the subscription server down.
+func resolveJsonRoutingSpec(raw string) jsonRoutingSpec {
+	spec, remote, err := parseJsonRoutingSpec(raw)
+	if err != nil {
+		warning := "subJsonRoutingRules: " + err.Error()
+		if remote {
+			warning = "subJsonRoutingRules: remote source unavailable, emitting default routing"
+		}
+		if previous, _ := lastJsonRoutingWarning.Load().(string); previous != warning {
+			lastJsonRoutingWarning.Store(warning)
+			logger.Warning(warning)
+		}
+		return jsonRoutingSpec{}
+	}
+	lastJsonRoutingWarning.Store("")
+	return spec
+}
+
+// jsonRoutingHeaderSource turns the JSON routing setting into a Routing header
+// value; blank means unusable, and the header then stays unset.
+func jsonRoutingHeaderSource(raw string) string {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" {
+		return ""
+	}
+	if strings.HasPrefix(trimmed, "incy://") {
+		_, rest, ok := cutAnyPrefix(trimmed, jsonRoutingDeeplinkPrefixes)
+		if !ok {
+			return ""
+		}
+		decoded, err := decodeRoutingBase64(rest)
+		if err != nil {
+			return ""
+		}
+		if _, err := validateAndCompactJSONObject(decoded); err != nil || len(trimmed) > remoteRoutingHappMaxValue {
+			return ""
+		}
+		return trimmed
+	}
+	resolved, _, err := resolveRoutingSource(remoteRoutingJson, trimmed)
+	if err != nil {
+		return ""
+	}
+	content, err := normalizeHappRouting([]byte(resolved))
+	if err != nil || len(content) > remoteRoutingHappMaxValue {
+		return ""
+	}
+	return content
+}
+
+// parseJsonRoutingSpec resolves raw (inline JSON, happ:// or incy:// deeplink,
+// or https:// URL) into a spec; the caller degrades on error, never fails.
+func parseJsonRoutingSpec(raw string) (jsonRoutingSpec, bool, error) {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" {
+		return jsonRoutingSpec{}, false, nil
+	}
+
+	if _, remote, err := common.ParseRemoteRoutingURL(trimmed); remote {
+		if err != nil {
+			return jsonRoutingSpec{}, true, err
+		}
+		resolved, remote, err := resolveRoutingSource(remoteRoutingJson, trimmed)
+		if err != nil || !remote {
+			return jsonRoutingSpec{}, true, err
+		}
+		trimmed = resolved
+	}
+
+	payload := []byte(trimmed)
+	if _, rest, ok := cutAnyPrefix(trimmed, jsonRoutingDeeplinkPrefixes); ok {
+		decoded, err := decodeRoutingBase64(rest)
+		if err != nil {
+			return jsonRoutingSpec{}, false, fmt.Errorf("invalid routing deeplink payload: %w", err)
+		}
+		payload = decoded
+	} else if !strings.HasPrefix(trimmed, "{") {
+		return jsonRoutingSpec{}, false, errors.New("routing payload must be a JSON object or a happ/incy deeplink")
+	}
+
+	var object map[string]any
+	if err := json.Unmarshal(payload, &object); err != nil {
+		return jsonRoutingSpec{}, false, fmt.Errorf("invalid routing payload JSON: %w", err)
+	}
+	if object == nil {
+		return jsonRoutingSpec{}, false, errors.New("routing payload must be a JSON object")
+	}
+
+	spec, err := buildJsonRoutingSpec(object)
+	if err != nil {
+		return jsonRoutingSpec{}, false, err
+	}
+	return spec, false, nil
+}
+
+func cutAnyPrefix(s string, prefixes []string) (string, string, bool) {
+	for _, prefix := range prefixes {
+		if after, ok := strings.CutPrefix(s, prefix); ok {
+			return prefix, after, true
+		}
+	}
+	return "", "", false
+}
+
+func buildJsonRoutingSpec(object map[string]any) (jsonRoutingSpec, error) {
+	spec := jsonRoutingSpec{}
+	var err error
+	if spec.DomainStrategy, err = routingString(object, "DomainStrategy"); err != nil {
+		return spec, err
+	}
+	if spec.RemoteDNSDomain, err = routingString(object, "RemoteDNSDomain"); err != nil {
+		return spec, err
+	}
+	if spec.RemoteDNSIP, err = routingString(object, "RemoteDNSIP"); err != nil {
+		return spec, err
+	}
+	if spec.DomesticDNSDomain, err = routingString(object, "DomesticDNSDomain"); err != nil {
+		return spec, err
+	}
+	if spec.DomesticDNSIP, err = routingString(object, "DomesticDNSIP"); err != nil {
+		return spec, err
+	}
+	if spec.DirectSites, err = routingList(object, "DirectSites"); err != nil {
+		return spec, err
+	}
+	if spec.DirectIp, err = routingList(object, "DirectIp"); err != nil {
+		return spec, err
+	}
+	if spec.ProxySites, err = routingList(object, "ProxySites"); err != nil {
+		return spec, err
+	}
+	if spec.ProxyIp, err = routingList(object, "ProxyIp"); err != nil {
+		return spec, err
+	}
+	if spec.BlockSites, err = routingList(object, "BlockSites"); err != nil {
+		return spec, err
+	}
+	if spec.BlockIp, err = routingList(object, "BlockIp"); err != nil {
+		return spec, err
+	}
+	if spec.DnsHosts, err = routingHosts(object, "DnsHosts"); err != nil {
+		return spec, err
+	}
+	order, err := routingString(object, "RouteOrder")
+	if err != nil {
+		return spec, err
+	}
+	for _, segment := range strings.Split(order, "-") {
+		switch segment {
+		case "block", "proxy", "direct":
+			spec.RouteOrder = append(spec.RouteOrder, segment)
+		}
+	}
+	return spec, nil
+}
+
+func routingString(object map[string]any, key string) (string, error) {
+	value, ok := object[key]
+	if !ok || value == nil {
+		return "", nil
+	}
+	text, ok := value.(string)
+	if !ok {
+		return "", fmt.Errorf("routing field %q must be a string", key)
+	}
+	return text, nil
+}
+
+func routingList(object map[string]any, key string) ([]string, error) {
+	value, ok := object[key]
+	if !ok || value == nil {
+		return nil, nil
+	}
+	entries, ok := value.([]any)
+	if !ok {
+		return nil, fmt.Errorf("routing field %q must be an array of strings", key)
+	}
+	list := make([]string, 0, len(entries))
+	for _, entry := range entries {
+		text, ok := entry.(string)
+		if !ok {
+			return nil, fmt.Errorf("routing field %q must be an array of strings", key)
+		}
+		list = append(list, text)
+	}
+	return list, nil
+}
+
+func routingHosts(object map[string]any, key string) (map[string]string, error) {
+	value, ok := object[key]
+	if !ok || value == nil {
+		return nil, nil
+	}
+	raw, ok := value.(map[string]any)
+	if !ok {
+		return nil, fmt.Errorf("routing field %q must be a string-to-string map", key)
+	}
+	hosts := make(map[string]string, len(raw))
+	for name, entry := range raw {
+		address, ok := entry.(string)
+		if !ok {
+			return nil, fmt.Errorf("routing field %q must be a string-to-string map", key)
+		}
+		hosts[name] = address
+	}
+	return hosts, nil
+}
+
+func routeOrderGroups(order []string) []string {
+	if len(order) == 0 {
+		return []string{"block", "direct", "proxy"}
+	}
+	return order
+}
+
+// applyJsonRouting patches the base template with the spec's dns and routing
+// subtrees, mirroring the njs patcher panel admins previously ran behind nginx.
+func applyJsonRouting(configJson map[string]any, spec jsonRoutingSpec) {
+	domestic := spec.DomesticDNSDomain
+	if domestic == "" {
+		ip := spec.DomesticDNSIP
+		if ip == "" {
+			ip = "77.88.8.8"
+		}
+		domestic = "https://" + ip + "/dns-query"
+	}
+	remote := spec.RemoteDNSDomain
+	if remote == "" {
+		ip := spec.RemoteDNSIP
+		if ip == "" {
+			ip = "8.8.8.8"
+		}
+		remote = "https://" + ip + "/dns-query"
+	}
+
+	dns := map[string]any{
+		"tag":           "dns_out",
+		"queryStrategy": "UseIP",
+		"servers":       []any{},
+	}
+	if len(spec.DirectSites) > 0 {
+		dns["servers"] = append(dns["servers"].([]any), map[string]any{
+			"address": domestic,
+			"domains": spec.DirectSites,
+		})
+	}
+	dns["servers"] = append(dns["servers"].([]any), map[string]any{
+		"address":      remote,
+		"skipFallback": false,
+	})
+	if len(spec.DnsHosts) > 0 {
+		dns["hosts"] = spec.DnsHosts
+	}
+
+	domainStrategy := spec.DomainStrategy
+	if domainStrategy == "" {
+		domainStrategy = "IPIfNonMatch"
+	}
+
+	groups := map[string][]map[string]any{
+		"block": {
+			{"domain": stringList(spec.BlockSites), "outboundTag": "block"},
+			{"ip": stringList(spec.BlockIp), "outboundTag": "block"},
+		},
+		"direct": {
+			{"domain": stringList(spec.DirectSites), "outboundTag": "direct"},
+			{"ip": stringList(spec.DirectIp), "outboundTag": "direct"},
+		},
+		"proxy": {
+			{"domain": stringList(spec.ProxySites), "outboundTag": "proxy"},
+			{"ip": stringList(spec.ProxyIp), "outboundTag": "proxy"},
+		},
+	}
+	rules := make([]any, 0, len(routeOrderGroups(spec.RouteOrder))*2+1)
+	for _, group := range routeOrderGroups(spec.RouteOrder) {
+		for _, rule := range groups[group] {
+			var key string
+			if _, ok := rule["domain"]; ok {
+				key = "domain"
+			} else {
+				key = "ip"
+			}
+			if len(rule[key].([]string)) == 0 {
+				continue
+			}
+			entry := map[string]any{"type": "field", key: rule[key], "outboundTag": rule["outboundTag"]}
+			rules = append(rules, entry)
+		}
+	}
+	rules = append(rules, map[string]any{"type": "field", "network": "tcp,udp", "outboundTag": "proxy"})
+
+	configJson["dns"] = dns
+	configJson["routing"] = map[string]any{
+		"domainStrategy": domainStrategy,
+		"rules":          rules,
+	}
+}
+
+func stringList(list []string) []string {
+	if len(list) == 0 {
+		return nil
+	}
+	return list
+}

+ 447 - 0
internal/sub/json_routing_baked_test.go

@@ -0,0 +1,447 @@
+package sub
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+const bakedRoutingPayload = `{
+	"DomainStrategy": "IPIfNonMatch",
+	"RemoteDNSDomain": "https://8.8.8.8/dns-query",
+	"RemoteDNSIP": "8.8.8.8",
+	"DomesticDNSDomain": "https://77.88.8.8/dns-query",
+	"DomesticDNSIP": "77.88.8.8",
+	"DnsHosts": {"lknpd.nalog.ru": "213.24.64.181"},
+	"RouteOrder": "block-proxy-direct",
+	"DirectSites": ["geosite:category-ru"],
+	"DirectIp": ["geoip:private"],
+	"ProxySites": ["geosite:youtube"],
+	"BlockSites": ["geosite:category-ads"]
+}`
+
+func ruleSignatures(t *testing.T, doc map[string]any) []string {
+	t.Helper()
+	routing, _ := doc["routing"].(map[string]any)
+	rules, _ := routing["rules"].([]any)
+	signatures := make([]string, 0, len(rules))
+	for _, rule := range rules {
+		m, _ := rule.(map[string]any)
+		target, _ := m["outboundTag"].(string)
+		if target == "" {
+			target = "balancer:" + m["balancerTag"].(string)
+		}
+		kind := "ip"
+		if _, has := m["domain"]; has {
+			kind = "domain"
+		}
+		if _, has := m["network"]; has {
+			kind = "network"
+		}
+		signatures = append(signatures, kind+"->"+target)
+	}
+	return signatures
+}
+
+func assertBakedRouting(t *testing.T, doc map[string]any, wantRules []string, proxyTag string) {
+	t.Helper()
+	dns, _ := doc["dns"].(map[string]any)
+	if dns == nil {
+		t.Fatalf("doc has no dns:\n%v", doc)
+	}
+	if dns["tag"] != "dns_out" || dns["queryStrategy"] != "UseIP" {
+		t.Fatalf("dns header = %v", dns)
+	}
+	servers, _ := dns["servers"].([]any)
+	if len(servers) != 2 {
+		t.Fatalf("dns servers = %d, want 2 (domestic + remote): %v", len(servers), servers)
+	}
+	first, _ := servers[0].(map[string]any)
+	if first["address"] != "https://77.88.8.8/dns-query" {
+		t.Fatalf("domestic dns = %v", first)
+	}
+	if domains, _ := first["domains"].([]any); strings.Join(stringify(domains), ",") != "geosite:category-ru" {
+		t.Fatalf("domestic dns domains = %v", first["domains"])
+	}
+	second, _ := servers[1].(map[string]any)
+	if second["address"] != "https://8.8.8.8/dns-query" {
+		t.Fatalf("remote dns = %v", second)
+	}
+	hosts, _ := dns["hosts"].(map[string]any)
+	if hosts["lknpd.nalog.ru"] != "213.24.64.181" {
+		t.Fatalf("dns hosts = %v", dns["hosts"])
+	}
+
+	routing, _ := doc["routing"].(map[string]any)
+	if routing["domainStrategy"] != "IPIfNonMatch" {
+		t.Fatalf("domainStrategy = %v", routing["domainStrategy"])
+	}
+	want := make([]string, 0, len(wantRules))
+	for _, rule := range wantRules {
+		want = append(want, strings.Replace(rule, "PROXY", proxyTag, 1))
+	}
+	got := ruleSignatures(t, doc)
+	if strings.Join(got, ",") != strings.Join(want, ",") {
+		t.Fatalf("rules = %v\nwant %v", got, want)
+	}
+}
+
+func TestSubJson_BakedRoutingInEveryDocument(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4801, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+
+	js := NewSubJsonService("", "", "", bakedRoutingPayload, NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	if len(docs) != 1 {
+		t.Fatalf("docs = %d, want 1:\n%s", len(docs), out)
+	}
+	want := []string{"domain->block", "domain->PROXY", "domain->direct", "ip->direct", "network->PROXY"}
+	assertBakedRouting(t, docs[0], want, "proxy")
+}
+
+func TestSubJson_BakedRoutingReplacesLegacyRules(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4802, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+
+	legacy := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
+	js := NewSubJsonService("", legacy, "", bakedRoutingPayload, NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	routing, _ := docs[0]["routing"].(map[string]any)
+	ruleJSON, _ := json.Marshal(routing["rules"])
+	if strings.Contains(string(ruleJSON), "geosite:example") {
+		t.Fatalf("legacy subJsonRules must not leak into baked docs: %s", ruleJSON)
+	}
+}
+
+func TestSubJson_BakedRoutingWithBalancer(t *testing.T) {
+	seedSubDB(t)
+	tcp := seedSubInbound(t, "s1", "tcpin", 4803, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id}, SortOrder: 1, Enabled: true,
+	})
+
+	js := NewSubJsonService("", "", "", bakedRoutingPayload, NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	if len(docs) != 2 {
+		t.Fatalf("docs = %d, want 2 (inbound + balancer):\n%s", len(docs), out)
+	}
+
+	// Manual doc keeps the plain proxy tag.
+	assertBakedRouting(t, findDocByRemarks(docs, "tcpin-tcpin@e"), []string{
+		"domain->block", "domain->PROXY", "domain->direct", "ip->direct", "network->PROXY",
+	}, "proxy")
+
+	// Balancer doc routes proxy groups into the balancer.
+	balancerDoc := findDocByRemarks(docs, "auto")
+	want := []string{"domain->block", "domain->balancer:balancer", "domain->direct", "ip->direct", "network->balancer:balancer"}
+	assertBakedRouting(t, balancerDoc, want, "balancer:balancer")
+}
+
+func TestSubJson_BakedRoutingInvalidFallsBackToDefault(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4804, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+
+	js := NewSubJsonService("", "", "", "not json at all", NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson must survive a bad routing payload: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	routing, _ := docs[0]["routing"].(map[string]any)
+	rules, _ := json.Marshal(routing["rules"])
+	if !strings.Contains(string(rules), `"outboundTag":"proxy"`) {
+		t.Fatalf("default routing missing: %s", rules)
+	}
+}
+
+func TestSubJson_LegacyRulesStillWorkWithoutBakedRouting(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4805, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+
+	legacy := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
+	js := NewSubJsonService("", legacy, "", "", NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	routing, _ := docs[0]["routing"].(map[string]any)
+	ruleJSON, _ := json.Marshal(routing["rules"])
+	if !strings.Contains(string(ruleJSON), "geosite:example") {
+		t.Fatalf("legacy rules missing: %s", ruleJSON)
+	}
+}
+
+func TestSubJson_BakedRoutingRemoteWarmsAfterColdStart(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4806, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+
+	oldResolver := routingSourceResolver
+	t.Cleanup(func() { routingSourceResolver = oldResolver })
+	const source = "https://example.com/DEFAULT.JSON"
+	routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		return remoteRoutingResponse(200, mustMarshal(t, fullRoutingPayload())), nil
+	}), false)
+
+	js := NewSubJsonService("", "", "", source, NewSubService(""))
+	// Cold: no request has primed the resolver cache yet.
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	routing, _ := docs[0]["routing"].(map[string]any)
+	if routing["domainStrategy"] != "AsIs" {
+		t.Fatalf("cold doc must keep default routing: %v", routing["domainStrategy"])
+	}
+
+	// The cron job warms the cache; the next request must bake the profile.
+	primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
+	out, _, err = js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs = parseSubJsonDocs(t, out)
+	routing, _ = docs[0]["routing"].(map[string]any)
+	if routing["domainStrategy"] != "IPIfNonMatch" {
+		t.Fatalf("warm doc must carry the profile: %v", routing["domainStrategy"])
+	}
+	dns, _ := docs[0]["dns"].(map[string]any)
+	servers, _ := dns["servers"].([]any)
+	if len(servers) != 2 {
+		t.Fatalf("warm doc dns servers = %v", servers)
+	}
+}
+
+func TestApplyCommonHeadersFallsBackToJsonRoutingProfile(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+
+	var object map[string]any
+	if err := json.Unmarshal([]byte(bakedRoutingPayload), &object); err != nil {
+		t.Fatalf("payload: %v", err)
+	}
+	happDeeplink := "happ://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(mustMarshal(t, object)))
+	incyDeeplink := "incy://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(mustMarshal(t, map[string]any{"Name": "RoscomVPN"})))
+
+	cases := []struct {
+		name      string
+		jsonRules string
+		happRules string
+		want      string
+	}{
+		{name: "inline json becomes a happ deeplink", jsonRules: bakedRoutingPayload, want: happDeeplink},
+		{name: "happ deeplink passes through", jsonRules: happDeeplink, want: happDeeplink},
+		{name: "incy deeplink passes through", jsonRules: incyDeeplink, want: incyDeeplink},
+		{name: "blank profile keeps the header unset", jsonRules: "", want: ""},
+		{name: "unusable profile keeps the header unset", jsonRules: "happ://routing/onadd/%%%", want: ""},
+		{name: "explicit happ rules take precedence", jsonRules: bakedRoutingPayload, happRules: "happ://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(`{"A":1}`)), want: "happ://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(`{"A":1}`))},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			recorder := httptest.NewRecorder()
+			ctx, _ := gin.CreateTestContext(recorder)
+			(&SUBController{subJsonRoutingRules: tc.jsonRules}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, tc.happRules, false)
+			if got := recorder.Header().Get("Routing"); got != tc.want {
+				t.Fatalf("Routing = %q, want %q", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestApplyCommonHeadersJsonRoutingRemoteFailsClosed(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	oldResolver := routingSourceResolver
+	t.Cleanup(func() { routingSourceResolver = oldResolver })
+
+	const source = "https://example.com/DEFAULT.JSON"
+	routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		return remoteRoutingResponse(200, mustMarshal(t, fullRoutingPayload())), nil
+	}), false)
+
+	recorder := httptest.NewRecorder()
+	ctx, _ := gin.CreateTestContext(recorder)
+	(&SUBController{subJsonRoutingRules: source}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, "", false)
+	if got := recorder.Header().Get("Routing"); got != "" {
+		t.Fatalf("cold cache must keep the header unset, got %q", got)
+	}
+
+	primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
+	recorder = httptest.NewRecorder()
+	ctx, _ = gin.CreateTestContext(recorder)
+	(&SUBController{subJsonRoutingRules: source}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, "", false)
+	got := recorder.Header().Get("Routing")
+	if !strings.HasPrefix(got, "happ://routing/onadd/") {
+		t.Fatalf("warm cache Routing = %q", got)
+	}
+	decoded, err := decodeRoutingBase64(strings.TrimPrefix(got, "happ://routing/onadd/"))
+	if err != nil {
+		t.Fatalf("deeplink payload: %v", err)
+	}
+	var payload map[string]any
+	if err := json.Unmarshal(decoded, &payload); err != nil {
+		t.Fatalf("deeplink JSON: %v", err)
+	}
+	if payload["Name"] != "RoscomVPN" {
+		t.Fatalf("deeplink payload = %v", payload)
+	}
+	waitRemoteRoutingIdle(t, routingSourceResolver)
+}
+
+func TestSubJson_BakedRoutingRemoteUpdateReachesDocuments(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4807, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+
+	oldResolver := routingSourceResolver
+	t.Cleanup(func() { routingSourceResolver = oldResolver })
+	const source = "https://example.com/DEFAULT.JSON"
+
+	current := mustMarshal(t, fullRoutingPayload())
+	routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		return remoteRoutingResponse(200, current), nil
+	}), false)
+
+	js := NewSubJsonService("", "", "", source, NewSubService(""))
+	// A cold resolver fails closed (default routing); prime the cache first.
+	primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	routing, _ := docs[0]["routing"].(map[string]any)
+	if routing["domainStrategy"] != "IPIfNonMatch" {
+		t.Fatalf("first doc must carry the profile: %v", routing["domainStrategy"])
+	}
+
+	// The operator edits the published profile; after the cache TTL expires,
+	// the next request must re-bake the template with the new payload.
+	updated := fullRoutingPayload()
+	updated["DomainStrategy"] = "AsIs"
+	current = mustMarshal(t, updated)
+	waitRemoteRoutingIdle(t, routingSourceResolver)
+	staleKey := remoteRoutingKey{kind: remoteRoutingJson, source: source}
+	routingSourceResolver.mu.Lock()
+	entry := routingSourceResolver.entries[staleKey]
+	entry.FetchedAt = time.Now().Add(-remoteRoutingCacheTTL - time.Minute).Unix()
+	routingSourceResolver.entries[staleKey] = entry
+	delete(routingSourceResolver.lastAttempt, staleKey)
+	routingSourceResolver.mu.Unlock()
+	primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
+	out, _, err = js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs = parseSubJsonDocs(t, out)
+	routing, _ = docs[0]["routing"].(map[string]any)
+	if routing["domainStrategy"] != "AsIs" {
+		t.Fatalf("updated profile must reach the documents without a restart: %v", routing["domainStrategy"])
+	}
+	waitRemoteRoutingIdle(t, routingSourceResolver)
+}
+
+func TestRemoteRoutingJsonHasItsOwnPersistedRow(t *testing.T) {
+	seedSubDB(t)
+	const source = "https://example.com/DEFAULT.JSON"
+
+	// Two happ-payload settings pointing at different sources must not
+	// overwrite each other's persisted cache rows.
+	happSource := "https://example.com/HAPP.json"
+	for _, tc := range []struct {
+		kind    remoteRoutingKind
+		source  string
+		payload string
+	}{
+		{kind: remoteRoutingHapp, source: happSource, payload: `{"Name":"happ-profile"}`},
+		{kind: remoteRoutingJson, source: source, payload: `{"Name":"json-profile"}`},
+	} {
+		deeplink, err := normalizeHappRouting([]byte(tc.payload))
+		if err != nil {
+			t.Fatalf("normalize: %v", err)
+		}
+		newRemoteRoutingResolver(nil, false).persistEntry(tc.kind, remoteRoutingCacheEntry{
+			Source: tc.source, Content: deeplink, FetchedAt: time.Now().Unix(),
+		})
+	}
+
+	for _, tc := range []struct {
+		kind   remoteRoutingKind
+		source string
+		want   string
+	}{
+		{kind: remoteRoutingHapp, source: happSource, want: "happ-profile"},
+		{kind: remoteRoutingJson, source: source, want: "json-profile"},
+	} {
+		resolver := newRemoteRoutingResolver(nil, true)
+		resolver.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
+		resolver.ensurePersistedLoaded()
+		got, remote, err := resolver.resolve(tc.kind, tc.source)
+		if err != nil || !remote {
+			t.Fatalf("resolve kind=%s: remote=%v err=%v", tc.kind, remote, err)
+		}
+		decoded, err := decodeRoutingBase64(strings.TrimPrefix(got, "happ://routing/onadd/"))
+		if err != nil {
+			t.Fatalf("decode kind=%s: %v", tc.kind, err)
+		}
+		var payload map[string]any
+		if json.Unmarshal(decoded, &payload) != nil || payload["Name"] != tc.want {
+			t.Fatalf("kind=%s payload = %s", tc.kind, decoded)
+		}
+	}
+}
+
+const maxSubLogScan = 10240
+
+func routingWarningCount(t *testing.T) int {
+	t.Helper()
+	n := 0
+	for _, line := range logger.GetLogs(maxSubLogScan, "warning") {
+		if strings.Contains(line, "subJsonRoutingRules") {
+			n++
+		}
+	}
+	return n
+}
+
+// A public subscription fetch must not write one warning per emitted document:
+// the 10k in-memory buffer the panel's log view reads is evicted by the flood.
+func TestSubJson_BadRoutingProfileWarnsOncePerRequest(t *testing.T) {
+	seedSubDB(t)
+	for i, name := range []string{"w1", "w2", "w3", "w4", "w5", "w6"} {
+		seedSubInbound(t, "s1", name, 4870+i, 1, `{"network":"tcp","security":"none"}`)
+	}
+
+	js := NewSubJsonService("", "", "", "not json at all", NewSubService(""))
+	before := routingWarningCount(t)
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	if len(docs) < 6 {
+		t.Fatalf("docs = %d, want >= 6:\n%s", len(docs), out)
+	}
+	if got := routingWarningCount(t) - before; got > 1 {
+		t.Fatalf("one request emitting %d documents logged %d warnings, want at most 1", len(docs), got)
+	}
+}

+ 192 - 0
internal/sub/json_routing_test.go

@@ -0,0 +1,192 @@
+package sub
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"net/http"
+	"strings"
+	"testing"
+)
+
+func mustMarshal(t *testing.T, v any) string {
+	t.Helper()
+	data, err := json.Marshal(v)
+	if err != nil {
+		t.Fatalf("marshal: %v", err)
+	}
+	return string(data)
+}
+
+func b64Std(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
+func b64URL(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }
+
+func fullRoutingPayload() map[string]any {
+	return map[string]any{
+		"Name":              "RoscomVPN",
+		"DomainStrategy":    "IPIfNonMatch",
+		"RemoteDNSDomain":   "https://8.8.8.8/dns-query",
+		"RemoteDNSIP":       "8.8.8.8",
+		"DomesticDNSDomain": "https://77.88.8.8/dns-query",
+		"DomesticDNSIP":     "77.88.8.8",
+		"DnsHosts":          map[string]any{"lknpd.nalog.ru": "213.24.64.181"},
+		"RouteOrder":        "block-proxy-direct",
+		"DirectSites":       []any{"geosite:category-ru", "geosite:private"},
+		"DirectIp":          []any{"geoip:private"},
+		"ProxySites":        []any{"geosite:youtube"},
+		"ProxyIp":           []any{},
+		"BlockSites":        []any{"geosite:category-ads"},
+		"BlockIp":           []any{},
+	}
+}
+
+func TestParseJsonRoutingSpecMapsAllFields(t *testing.T) {
+	spec, remote, err := parseJsonRoutingSpec(mustMarshal(t, fullRoutingPayload()))
+	if err != nil || remote {
+		t.Fatalf("parse: err=%v remote=%v", err, remote)
+	}
+	want := jsonRoutingSpec{
+		DomainStrategy:    "IPIfNonMatch",
+		RemoteDNSDomain:   "https://8.8.8.8/dns-query",
+		RemoteDNSIP:       "8.8.8.8",
+		DomesticDNSDomain: "https://77.88.8.8/dns-query",
+		DomesticDNSIP:     "77.88.8.8",
+		DnsHosts:          map[string]string{"lknpd.nalog.ru": "213.24.64.181"},
+		RouteOrder:        []string{"block", "proxy", "direct"},
+		DirectSites:       []string{"geosite:category-ru", "geosite:private"},
+		DirectIp:          []string{"geoip:private"},
+		ProxySites:        []string{"geosite:youtube"},
+		BlockSites:        []string{"geosite:category-ads"},
+	}
+	if spec.DomainStrategy != want.DomainStrategy || spec.RemoteDNSIP != want.RemoteDNSIP ||
+		spec.DomesticDNSDomain != want.DomesticDNSDomain || len(spec.DnsHosts) != 1 || spec.DnsHosts["lknpd.nalog.ru"] != "213.24.64.181" ||
+		strings.Join(spec.RouteOrder, ",") != strings.Join(want.RouteOrder, ",") ||
+		strings.Join(spec.DirectSites, ",") != strings.Join(want.DirectSites, ",") ||
+		strings.Join(spec.DirectIp, ",") != strings.Join(want.DirectIp, ",") ||
+		strings.Join(spec.ProxySites, ",") != strings.Join(want.ProxySites, ",") ||
+		strings.Join(spec.BlockSites, ",") != strings.Join(want.BlockSites, ",") {
+		t.Fatalf("spec = %+v\nwant %+v", spec, want)
+	}
+}
+
+func TestParseJsonRoutingSpecPartialPayload(t *testing.T) {
+	spec, _, err := parseJsonRoutingSpec(`{"DirectSites":["geosite:private"],"DomainStrategy":"AsIs"}`)
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	if spec.DomainStrategy != "AsIs" || len(spec.DirectSites) != 1 || spec.DirectSites[0] != "geosite:private" {
+		t.Fatalf("spec = %+v", spec)
+	}
+	if len(spec.RouteOrder) != 0 || len(spec.DnsHosts) != 0 || spec.RemoteDNSIP != "" {
+		t.Fatalf("unset fields must stay zero: %+v", spec)
+	}
+	if spec.empty() {
+		t.Fatalf("empty() must report false when any field is set: %+v", spec)
+	}
+}
+
+func TestParseJsonRoutingSpecRouteOrderUnknownSegments(t *testing.T) {
+	spec, _, err := parseJsonRoutingSpec(`{"RouteOrder":"block-foo-direct"}`)
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	if strings.Join(spec.RouteOrder, ",") != "block,direct" {
+		t.Fatalf("RouteOrder = %v", spec.RouteOrder)
+	}
+}
+
+func TestParseJsonRoutingSpecDeeplinks(t *testing.T) {
+	payload := mustMarshal(t, fullRoutingPayload())
+	cases := []string{
+		"happ://routing/onadd/" + b64Std(payload),
+		"incy://routing/onadd/" + b64Std(payload),
+		"happ://routing/onadd/" + b64URL(payload),
+	}
+	for _, raw := range cases {
+		spec, _, err := parseJsonRoutingSpec(raw)
+		if err != nil {
+			t.Fatalf("parse %q: %v", raw[:32], err)
+		}
+		if spec.DomainStrategy != "IPIfNonMatch" || len(spec.DirectSites) != 2 || spec.RouteOrder[1] != "proxy" {
+			t.Fatalf("spec from %q = %+v", raw[:32], spec)
+		}
+	}
+}
+
+func TestParseJsonRoutingSpecRejectsBadPayloads(t *testing.T) {
+	cases := []string{
+		"not json at all",
+		"[1,2,3]",
+		`{"DirectSites":"geosite:private"}`,
+		`{"DirectSites":["a",1]}`,
+		`{"DnsHosts":{"a":1}}`,
+		`{"DomainStrategy":5}`,
+		"happ://routing/onadd/!!!!not-base64!!!!",
+	}
+	for _, raw := range cases {
+		if _, _, err := parseJsonRoutingSpec(raw); err == nil {
+			t.Fatalf("payload %q was accepted", raw)
+		}
+	}
+}
+
+func TestParseJsonRoutingSpecEmpty(t *testing.T) {
+	for _, raw := range []string{"", "   ", "\n"} {
+		spec, remote, err := parseJsonRoutingSpec(raw)
+		if err != nil || remote || !spec.empty() {
+			t.Fatalf("raw=%q spec=%+v remote=%v err=%v", raw, spec, remote, err)
+		}
+	}
+}
+
+func TestParseJsonRoutingSpecRemoteURL(t *testing.T) {
+	oldResolver := routingSourceResolver
+	t.Cleanup(func() { routingSourceResolver = oldResolver })
+	routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		return remoteRoutingResponse(200, mustMarshal(t, fullRoutingPayload())), nil
+	}), false)
+
+	const source = "https://example.com/DEFAULT.JSON"
+	primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
+	spec, _, err := parseJsonRoutingSpec(source)
+	if err != nil {
+		t.Fatalf("parse: err=%v", err)
+	}
+	if spec.DomainStrategy != "IPIfNonMatch" || len(spec.BlockSites) != 1 {
+		t.Fatalf("spec = %+v", spec)
+	}
+}
+
+func TestParseJsonRoutingSpecRemoteUnavailable(t *testing.T) {
+	oldResolver := routingSourceResolver
+	t.Cleanup(func() { routingSourceResolver = oldResolver })
+	routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		return remoteRoutingResponse(200, "routing.help"), nil
+	}), false)
+	if _, _, err := parseJsonRoutingSpec("https://example.com/bad"); err == nil {
+		t.Fatal("unavailable remote source must error")
+	}
+}
+
+// normalizeHappRouting accepts happ://routing/add/ as a routing deeplink
+// (remote_routing.go), so the baked-JSON parser has to accept it too.
+func TestParseJsonRoutingSpecAcceptsAddDeeplink(t *testing.T) {
+	payload := mustMarshal(t, fullRoutingPayload())
+	for _, prefix := range []string{"happ://routing/onadd/", "happ://routing/add/", "incy://routing/onadd/"} {
+		t.Run(prefix, func(t *testing.T) {
+			if _, err := normalizeHappRouting([]byte(prefix + b64Std(payload))); err != nil &&
+				!strings.HasPrefix(prefix, "incy://") {
+				t.Fatalf("normalizeHappRouting rejects %s: %v", prefix, err)
+			}
+			spec, remote, err := parseJsonRoutingSpec(prefix + b64Std(payload))
+			if err != nil || remote {
+				t.Fatalf("parse %s: err=%v remote=%v", prefix, err, remote)
+			}
+			if spec.empty() {
+				t.Fatalf("parse %s: spec is empty, routing would not be baked", prefix)
+			}
+			if spec.DomainStrategy != "IPIfNonMatch" {
+				t.Fatalf("parse %s: DomainStrategy = %q", prefix, spec.DomainStrategy)
+			}
+		})
+	}
+}

+ 50 - 9
internal/sub/json_service.go

@@ -9,6 +9,7 @@ import (
 	"slices"
 	"sort"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -30,11 +31,22 @@ type SubJsonService struct {
 	mux              string
 	observatory      subBalancerObservatoryConfig
 
+	// bakedRouting is re-resolved per request: a remote URL may be cold at
+	// construction time and warm up later via the cron job.
+	routingRules   string
+	bakedRoutingMu sync.Mutex
+	bakedRouting   *bakedRoutingState
+
 	SubService *SubService
 }
 
+type bakedRoutingState struct {
+	spec       jsonRoutingSpec
+	configJson map[string]any
+}
+
 // 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, rules string, finalMask string, routingRules string, subService *SubService) *SubJsonService {
 	var configJson map[string]any
 	var defaultOutbounds []json_util.RawMessage
 	_ = json.Unmarshal([]byte(defaultJson), &configJson)
@@ -45,7 +57,9 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
 		}
 	}
 
-	if rules != "" {
+	// A baked routing profile replaces the template's dns and routing subtrees
+	// outright; the legacy simple-rules setting only applies without a profile.
+	if routingRules == "" && rules != "" {
 		var newRules []any
 		routing, _ := configJson["routing"].(map[string]any)
 		defaultRules, _ := routing["rules"].([]any)
@@ -60,11 +74,35 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
 		defaultOutbounds: defaultOutbounds,
 		finalMask:        finalMask,
 		mux:              mux,
+		routingRules:     routingRules,
 		observatory:      defaultSubBalancerObservatoryConfig(),
 		SubService:       subService,
 	}
 }
 
+// Re-resolved per call so an upstream edit reaches the documents without a
+// restart; a failed resolve keeps the last good template.
+func (s *SubJsonService) bakedTemplate() map[string]any {
+	if s.routingRules == "" {
+		return s.configJson
+	}
+	spec := resolveJsonRoutingSpec(s.routingRules)
+	s.bakedRoutingMu.Lock()
+	defer s.bakedRoutingMu.Unlock()
+	if s.bakedRouting != nil {
+		if spec.empty() || spec.equal(s.bakedRouting.spec) {
+			return s.bakedRouting.configJson
+		}
+	} else if spec.empty() {
+		return s.configJson
+	}
+	template := make(map[string]any, len(s.configJson)+2)
+	maps.Copy(template, s.configJson)
+	applyJsonRouting(template, spec)
+	s.bakedRouting = &bakedRoutingState{spec: spec, configJson: template}
+	return template
+}
+
 // GetJson generates a JSON subscription configuration for the given subscription ID and host.
 func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bool) (string, string, error) {
 	subReq := s.SubService.ForRequest(host)
@@ -153,7 +191,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 			newOutbounds := []json_util.RawMessage{outbound}
 			newOutbounds = append(newOutbounds, s.defaultOutbounds...)
 			newConfigJson := make(map[string]any)
-			maps.Copy(newConfigJson, s.configJson)
+			maps.Copy(newConfigJson, s.bakedTemplate())
 			newConfigJson["outbounds"] = newOutbounds
 			newConfigJson["remarks"] = remark
 			newConfig, _ := json.MarshalIndent(newConfigJson, "", "  ")
@@ -455,9 +493,12 @@ func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entrie
 	outbounds := append([]json_util.RawMessage{}, proxies...)
 	outbounds = append(outbounds, s.defaultOutbounds...)
 
-	// The routing subtree in s.configJson is shared by every emitted document;
-	// clone it (and each rule map) before pointing rules at the balancer.
-	baseRouting, _ := s.configJson["routing"].(map[string]any)
+	// One template per document: two resolves could straddle a profile refresh
+	// and pair this document's dns with the other revision's routing.
+	template := s.bakedTemplate()
+	// Clone the shared routing subtree (and each rule map) before pointing
+	// rules at the balancer.
+	baseRouting, _ := template["routing"].(map[string]any)
 	routing := make(map[string]any, len(baseRouting)+1)
 	maps.Copy(routing, baseRouting)
 	baseRules, _ := baseRouting["rules"].([]any)
@@ -493,8 +534,8 @@ func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entrie
 	}
 	routing["balancers"] = []any{balancerEntry}
 
-	newConfigJson := make(map[string]any, len(s.configJson)+2)
-	maps.Copy(newConfigJson, s.configJson)
+	newConfigJson := make(map[string]any, len(template)+2)
+	maps.Copy(newConfigJson, template)
 	newConfigJson["outbounds"] = outbounds
 	newConfigJson["remarks"] = balancer.Remark
 	newConfigJson["routing"] = routing
@@ -614,7 +655,7 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 
 		newOutbounds = append(newOutbounds, s.defaultOutbounds...)
 		newConfigJson := make(map[string]any)
-		maps.Copy(newConfigJson, s.configJson)
+		maps.Copy(newConfigJson, s.bakedTemplate())
 
 		transport, _ := newStream["network"].(string)
 		newConfigJson["outbounds"] = newOutbounds

+ 21 - 21
internal/sub/json_service_test.go

@@ -36,7 +36,7 @@ func outboundSettings(t *testing.T, raw []byte) map[string]any {
 }
 
 func TestDefaultJSONUsesCompatibleLocalInbounds(t *testing.T) {
-	svc := NewSubJsonService("", "", "", nil)
+	svc := NewSubJsonService("", "", "", "", nil)
 	inbounds, ok := svc.configJson["inbounds"].([]any)
 	if !ok {
 		t.Fatalf("default JSON inbounds = %#v, want array", svc.configJson["inbounds"])
@@ -81,7 +81,7 @@ func TestDefaultJSONUsesCompatibleLocalInbounds(t *testing.T) {
 
 func TestSubJsonServiceVisionFlowDisablesTCPMuxOnly(t *testing.T) {
 	globalMux := `{"enabled":true,"concurrency":8,"xudpConcurrency":16,"xudpProxyUDP443":"reject"}`
-	svc := NewSubJsonService(globalMux, "", "", nil)
+	svc := NewSubJsonService(globalMux, "", "", "", nil)
 	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
 
 	decode := func(raw []byte) map[string]any {
@@ -119,7 +119,7 @@ func TestSubJsonServiceVisionFlowDisablesTCPMuxOnly(t *testing.T) {
 
 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")
@@ -156,7 +156,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"}},
@@ -176,7 +176,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")
@@ -190,7 +190,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)
@@ -203,7 +203,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)
@@ -222,7 +222,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")
 	}
@@ -235,7 +235,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)
 	}
@@ -245,7 +245,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")
 	}
@@ -261,7 +261,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)
@@ -274,7 +274,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)
@@ -286,7 +286,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.
@@ -333,7 +333,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"}}`
@@ -387,7 +387,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 {
@@ -403,13 +403,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)
@@ -421,7 +421,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"}},
@@ -466,7 +466,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")
 	}
@@ -510,13 +510,13 @@ 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)
 	}
 }
 
 func TestSubJsonServiceSkipsAmneziaWG(t *testing.T) {
-	if got := NewSubJsonService("", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, &model.Inbound{Listen: "203.0.113.8", Port: 51820, Protocol: model.AmneziaWG}, model.Client{}, "sub.example.com"); len(got) != 0 {
+	if got := NewSubJsonService("", "", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, &model.Inbound{Listen: "203.0.113.8", Port: 51820, Protocol: model.AmneziaWG}, model.Client{}, "sub.example.com"); len(got) != 0 {
 		t.Fatalf("getConfig emitted %d unsupported AmneziaWG Xray config(s)", len(got))
 	}
 }

+ 10 - 10
internal/sub/mutation_audit_test.go

@@ -29,7 +29,7 @@ func initMutDB(t *testing.T) {
 
 func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
 	rules := `[{"type":"field","domain":["geosite:ads"],"outboundTag":"block"}]`
-	svc := NewSubJsonService("", rules, "", nil)
+	svc := NewSubJsonService("", rules, "", "", nil)
 
 	routing, ok := svc.configJson["routing"].(map[string]any)
 	if !ok {
@@ -47,7 +47,7 @@ func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
 }
 
 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 +67,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 +103,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 +111,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")

+ 27 - 17
internal/sub/remote_routing.go

@@ -30,6 +30,7 @@ type remoteRoutingKind string
 
 const (
 	remoteRoutingHapp  remoteRoutingKind = "happ"
+	remoteRoutingJson  remoteRoutingKind = "jsonhapp"
 	remoteRoutingClash remoteRoutingKind = "clash"
 
 	remoteRoutingCacheTTL     = 10 * time.Minute
@@ -40,6 +41,12 @@ const (
 	remoteRoutingClashMaxBody = 2 << 20  // 2 MiB
 )
 
+// isHappPayloadKind reports whether the kind carries a happ-payload source:
+// same validation and size caps, but a separate persisted cache row.
+func isHappPayloadKind(kind remoteRoutingKind) bool {
+	return kind == remoteRoutingHapp || kind == remoteRoutingJson
+}
+
 var errRemoteRoutingUnavailable = errors.New("remote routing source is temporarily unavailable")
 
 type remoteRoutingKey struct {
@@ -167,19 +174,22 @@ func (r *remoteRoutingResolver) resolveEntry(kind remoteRoutingKind, raw string)
 }
 
 // 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,
+// from the cron job; concurrent resolver reads are safe, fetches coalesce.
+func RefreshRemoteRoutingSources(happ, clash, jsonRouting string) {
+	for kind, raw := range map[remoteRoutingKind][]string{
+		remoteRoutingHapp:  {happ},
+		remoteRoutingJson:  {jsonRouting},
+		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)
+		for _, source := range raw {
+			_, remote, parseErr := common.ParseRemoteRoutingURL(source)
+			if parseErr != nil {
+				logger.Warningf("Remote %s routing source is invalid", kind)
+				continue
+			}
+			if remote {
+				_ = routingSourceResolver.refreshSource(kind, source)
+			}
 		}
 	}
 }
@@ -286,7 +296,7 @@ func (r *remoteRoutingResolver) fetch(key remoteRoutingKey, previous remoteRouti
 		}
 		return previous, nil
 	}
-	if key.kind == remoteRoutingHapp && isRemoteHappRedirect(resp.StatusCode) {
+	if isHappPayloadKind(key.kind) && isRemoteHappRedirect(resp.StatusCode) {
 		location := strings.TrimSpace(resp.Header.Get("Location"))
 		content, locationErr := normalizeHappRouting([]byte(location))
 		if locationErr != nil {
@@ -321,7 +331,7 @@ func (r *remoteRoutingResolver) fetch(key remoteRoutingKey, previous remoteRouti
 	if err != nil {
 		return remoteRoutingCacheEntry{}, err
 	}
-	if key.kind == remoteRoutingHapp && len(content) > remoteRoutingHappMaxValue {
+	if isHappPayloadKind(key.kind) && len(content) > remoteRoutingHappMaxValue {
 		return remoteRoutingCacheEntry{}, errors.New("Happ routing header exceeds the size limit")
 	}
 	return remoteRoutingCacheEntry{
@@ -346,7 +356,7 @@ func isRemoteHappRedirect(status int) bool {
 
 func normalizeRemoteRoutingContent(kind remoteRoutingKind, body []byte) (string, map[string]any, error) {
 	switch kind {
-	case remoteRoutingHapp:
+	case remoteRoutingHapp, remoteRoutingJson:
 		content, err := normalizeHappRouting(body)
 		return content, nil, err
 	case remoteRoutingClash:
@@ -573,7 +583,7 @@ func (r *remoteRoutingResolver) triggerPersistedLoad() {
 
 func (r *remoteRoutingResolver) loadPersisted() {
 	loaded := make(map[remoteRoutingKey]remoteRoutingCacheEntry, 2)
-	for _, kind := range []remoteRoutingKind{remoteRoutingHapp, remoteRoutingClash} {
+	for _, kind := range []remoteRoutingKind{remoteRoutingHapp, remoteRoutingJson, remoteRoutingClash} {
 		var setting model.Setting
 		err := database.GetDB().Where("key = ?", remoteRoutingSettingKey(kind)).First(&setting).Error
 		if err != nil {
@@ -590,7 +600,7 @@ func (r *remoteRoutingResolver) loadPersisted() {
 		if err != nil {
 			continue
 		}
-		if kind == remoteRoutingHapp && len(normalized) > remoteRoutingHappMaxValue {
+		if isHappPayloadKind(kind) && len(normalized) > remoteRoutingHappMaxValue {
 			continue
 		}
 		entry.Content = normalized

+ 6 - 0
internal/sub/sub.go

@@ -150,6 +150,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		SubJsonRules = ""
 	}
 
+	SubJsonRoutingRules, err := s.settingService.GetSubJsonRoutingRules()
+	if err != nil {
+		SubJsonRoutingRules = ""
+	}
+
 	SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
 	if err != nil {
 		SubJsonFinalMask = ""
@@ -310,6 +315,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		WithSUBUpdateInterval(SubUpdates),
 		WithSUBJsonMux(SubJsonMux),
 		WithSUBJsonRules(SubJsonRules),
+		WithSUBJsonRoutingRules(SubJsonRoutingRules),
 		WithSUBJsonFinalMask(SubJsonFinalMask),
 		WithSUBJsonObservatory(SubJsonObservatory),
 		WithSUBClashEnableRouting(SubClashEnableRouting),

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

+ 12 - 12
internal/sub/sub_balancer_test.go

@@ -57,7 +57,7 @@ func TestSubJson_BalancerDocument(t *testing.T) {
 	})
 
 	rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
-	js := NewSubJsonService("", rules, "", NewSubService(""))
+	js := NewSubJsonService("", rules, "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
@@ -155,7 +155,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 +189,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 +213,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 +241,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 +287,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 +322,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 +345,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 +386,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 {
@@ -444,7 +444,7 @@ func TestSubJson_BalancerLeastLoadCosts(t *testing.T) {
 		MemberWeights: map[int]float64{fast.Id: 0.2}, 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)
@@ -476,7 +476,7 @@ func TestSubJson_BalancerLeastLoadWithoutWeightsOmitsCosts(t *testing.T) {
 		Remark: "plain", Strategy: "leastLoad", 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)
@@ -498,7 +498,7 @@ func TestSubJson_BalancerCostsSkippedForNonLeastLoadStrategy(t *testing.T) {
 		MemberWeights: map[int]float64{a.Id: 0.5}, 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)

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

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

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

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

+ 1 - 0
internal/web/entity/entity.go

@@ -107,6 +107,7 @@ type AllSetting struct {
 	SubClashRules               string `json:"subClashRules" form:"subClashRules"`
 	SubJsonMux                  string `json:"subJsonMux" form:"subJsonMux"`
 	SubJsonRules                string `json:"subJsonRules" form:"subJsonRules"`
+	SubJsonRoutingRules         string `json:"subJsonRoutingRules" form:"subJsonRoutingRules"`
 	SubJsonFinalMask            string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
 	SubJsonObservatory          string `json:"subJsonObservatory" form:"subJsonObservatory"`
 	SubThemeDir                 string `json:"subThemeDir" form:"subThemeDir"`

+ 6 - 1
internal/web/job/remote_routing_job.go

@@ -27,5 +27,10 @@ func (j *RemoteRoutingJob) Run() {
 		logger.Warning("Could not read Clash routing source:", err)
 		return
 	}
-	sub.RefreshRemoteRoutingSources(happ, clash)
+	jsonRouting, err := j.settingService.GetSubJsonRoutingRules()
+	if err != nil {
+		logger.Warning("Could not read JSON subscription routing source:", err)
+		return
+	}
+	sub.RefreshRemoteRoutingSources(happ, clash, jsonRouting)
 }

+ 9 - 3
internal/web/service/setting.go

@@ -145,6 +145,7 @@ var defaultValueMap = map[string]string{
 	"subClashRules":               "",
 	"subJsonMux":                  "",
 	"subJsonRules":                "",
+	"subJsonRoutingRules":         "",
 	"subJsonFinalMask":            "",
 	"subJsonObservatory":          "",
 	"subThemeDir":                 "",
@@ -1027,6 +1028,10 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
 	return s.getString("subJsonRules")
 }
 
+func (s *SettingService) GetSubJsonRoutingRules() (string, error) {
+	return s.getString("subJsonRoutingRules")
+}
+
 func (s *SettingService) GetSubJsonFinalMask() (string, error) {
 	return s.getString("subJsonFinalMask")
 }
@@ -1489,9 +1494,10 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error {
 		}
 	}
 	for name, value := range map[string]*string{
-		"Happ routing source":         &allSetting.SubRoutingRules,
-		"Clash/Mihomo routing source": &allSetting.SubClashRules,
-		"Incy routing source":         &allSetting.SubIncyRoutingRules,
+		"Happ routing source":              &allSetting.SubRoutingRules,
+		"Clash/Mihomo routing source":      &allSetting.SubClashRules,
+		"Incy routing source":              &allSetting.SubIncyRoutingRules,
+		"JSON subscription routing source": &allSetting.SubJsonRoutingRules,
 	} {
 		if err := validateRemoteRoutingURLSetting(name, value); err != nil {
 			return err

+ 29 - 0
internal/web/service/setting_remote_routing_test.go

@@ -36,3 +36,32 @@ func TestValidateRemoteRoutingURLSettings(t *testing.T) {
 		})
 	}
 }
+
+func TestValidateSubJsonRoutingRulesSetting(t *testing.T) {
+	tests := []struct {
+		name      string
+		value     string
+		want      string
+		wantError string
+	}{
+		{name: "remote URL is canonicalised", value: " https://example.com/DEFAULT.JSON#frag ", want: "https://example.com/DEFAULT.JSON"},
+		{name: "remote URL with credentials is rejected", value: "https://user:[email protected]/DEFAULT.JSON", wantError: "JSON subscription routing source"},
+		{name: "inline JSON passes through", value: "{\"DirectSites\":[\"geosite:cat\"]}", want: "{\"DirectSites\":[\"geosite:cat\"]}"},
+		{name: "blank stays blank", value: "", want: ""},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			settings := &entity.AllSetting{SubJsonRoutingRules: 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.SubJsonRoutingRules != tt.want {
+				t.Fatalf("value=%q err=%v", settings.SubJsonRoutingRules, err)
+			}
+		})
+	}
+}

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -1167,6 +1167,8 @@
       "subJsonAlwaysArrayDesc": "يعيد مسار اشتراك JSON الصريح كمصفوفة حتى عند وجود ملف واحد، وفقًا لمعيار XTLS. تستخدم استجابات JSON المكتشفة تلقائيًا المصفوفات دائمًا. عطّله للاحتفاظ باستجابة الكائن الواحد القديمة.",
       "subJsonUserAgentRegex": "تعبير User-Agent لعملاء Xray JSON",
       "subJsonUserAgentRegexDesc": "تعبير Go RE2 منتظم يُطابَق مع وكيل المستخدم (User-Agent) لاختيار صيغة Xray JSON تلقائيًا على رابط الاشتراك القياسي. فارغ افتراضيًا، لذا يبقى الاكتشاف التلقائي معطلاً حتى تحدد نمطًا للعملاء الذين تريد خدمتهم. يحصل العملاء الآخرون على الاستجابة الخام/Base64. أعد تشغيل اللوحة بعد التغيير.",
+      "subJsonRoutingRules": "ملف توجيه اشتراك JSON",
+      "subJsonRoutingRulesDesc": "JSON لقواعد التوجيه، أو رابط happ:// أو incy://، أو رابط https://. عند تعيينه، يستبدل DNS وقواعد التوجيه لاشتراك JSON (تُدمج في الإعداد للعملاء الذين يتجاهلون الترويسات، مثل Happ وINCY مع الموازنات من جانب العميل). إذا كان إعداد ترويسة Happ فارغًا، يُرسَل نفس الملف تلقائيًا كترويسة Routing (يُحوَّل JSON والروابط إلى ديب‌لينك happ://) ليحمّل العملاء ملفات geo المطابقة.",
       "subClashAutoDetect": "اكتشاف عملاء Clash/Mihomo تلقائيًا",
       "subClashAutoDetectDesc": "عند التفعيل، يتلقى عملاء Clash/Mihomo المعروفون الذين يطلبون رابط الاشتراك القياسي إعداد Clash بصيغة YAML تلقائيًا. تستمر المتصفحات في عرض صفحة الاشتراك، ويحصل العملاء الآخرون على الاستجابة الخام/Base64، وتبقى روابط JSON وClash الصريحة متاحة. يتطلب تفعيل اشتراك Clash/Mihomo وإعادة تشغيل اللوحة لتطبيق التغيير.",
       "subClashUserAgentRegex": "تعبير User-Agent لعملاء Clash/Mihomo",

+ 2 - 0
internal/web/translation/en-US.json

@@ -1289,6 +1289,8 @@
       "subJsonAlwaysArrayDesc": "Return the explicit JSON subscription endpoint as an array even when it contains one profile, as required by the XTLS subscription standard. Auto-detected JSON responses always use arrays. Leave disabled to preserve the legacy single-object response.",
       "subJsonUserAgentRegex": "Xray JSON User-Agent regex",
       "subJsonUserAgentRegexDesc": "Go RE2 regular expression matched against the client's User-Agent to auto-select the Xray JSON format on the standard subscription URL. Empty by default, so auto-detection stays off until you set a pattern for the clients you want to serve. Other clients keep the raw/base64 response. Restart the panel after changes.",
+      "subJsonRoutingRules": "JSON subscription routing profile",
+      "subJsonRoutingRulesDesc": "Routing JSON, happ:// or incy:// deeplink, or https:// URL. When set, it replaces the JSON subscription's DNS and routing rules (baked into the config for clients that ignore headers, e.g. Happ and INCY with client-side balancers). If the Happ routing header setting is empty, the same profile is automatically sent as the Routing header (inline JSON and URLs as a happ:// deeplink) so clients fetch the matching geo files.",
       "subClashAutoDetect": "Auto-detect Clash/Mihomo clients",
       "subClashAutoDetectDesc": "When enabled, recognized Clash/Mihomo clients requesting the standard subscription URL receive Clash YAML automatically. Browsers still show the subscription page, other clients keep the raw/base64 response, and the explicit JSON and Clash URLs remain available. Requires Clash/Mihomo subscription to be enabled and a panel restart to apply.",
       "subClashUserAgentRegex": "Clash/Mihomo User-Agent regex",

+ 2 - 0
internal/web/translation/es-ES.json

@@ -1167,6 +1167,8 @@
       "subJsonAlwaysArrayDesc": "Devuelve el endpoint JSON explícito como una matriz incluso con un solo perfil, según el estándar XTLS. Las respuestas JSON detectadas automáticamente siempre usan matrices. Desactívalo para conservar la respuesta heredada de un solo objeto.",
       "subJsonUserAgentRegex": "Expresión User-Agent de Xray JSON",
       "subJsonUserAgentRegexDesc": "Expresión regular Go RE2 que se compara con el User-Agent del cliente para seleccionar automáticamente el formato Xray JSON en la URL de suscripción estándar. Vacía de forma predeterminada, por lo que la detección automática permanece desactivada hasta que definas un patrón para los clientes que quieras atender. Los demás clientes conservan la respuesta sin procesar/Base64. Reinicia el panel después de cambiarla.",
+      "subJsonRoutingRules": "Perfil de enrutamiento para la suscripción JSON",
+      "subJsonRoutingRulesDesc": "JSON de enrutamiento, deeplink happ:// o incy://, o URL https://. Si se establece, reemplaza el DNS y las reglas de enrutamiento de la suscripción JSON (integrados en la configuración para clientes que ignoran cabeceras, p. ej. Happ e INCY con balanceadores del lado cliente). Si el ajuste de cabecera de Happ está vacío, el mismo perfil se envía automáticamente como cabecera Routing (el JSON y las URL como deeplink happ://) para que los clientes descarguen los archivos geo coincidentes.",
       "subClashAutoDetect": "Detectar automáticamente clientes Clash/Mihomo",
       "subClashAutoDetectDesc": "Al activarlo, los clientes Clash/Mihomo reconocidos que soliciten la URL de suscripción estándar recibirán automáticamente YAML de Clash. Los navegadores seguirán mostrando la página de suscripción, los demás clientes conservarán la respuesta sin procesar/Base64 y las URL explícitas de JSON y Clash seguirán disponibles. Requiere activar la suscripción Clash/Mihomo y reiniciar el panel para aplicar el cambio.",
       "subClashUserAgentRegex": "Expresión User-Agent de Clash/Mihomo",

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -1171,6 +1171,8 @@
       "subJsonAlwaysArrayDesc": "مسیر صریح اشتراک JSON را حتی برای یک پروفایل، مطابق استاندارد XTLS به‌صورت آرایه برمی‌گرداند. پاسخ‌های JSON تشخیص‌داده‌شده همیشه آرایه هستند. برای حفظ پاسخ قدیمی تک‌شیء غیرفعال بگذارید.",
       "subJsonUserAgentRegex": "عبارت User-Agent برای Xray JSON",
       "subJsonUserAgentRegexDesc": "عبارت منظم Go RE2 که با عامل کاربر (User-Agent) کلاینت مطابقت داده می‌شود تا صیغه Xray JSON روی آدرس استاندارد اشتراک به‌طور خودکار انتخاب شود. به‌طور پیش‌فرض خالی است، بنابراین تشخیص خودکار غیرفعال می‌ماند تا زمانی که الگویی برای کلاینت‌های موردنظر خود تعیین کنید. سایر کلاینت‌ها پاسخ خام/Base64 را دریافت می‌کنند. پس از تغییر، پنل را راه‌اندازی مجدد کنید.",
+      "subJsonRoutingRules": "پروفایل مسیریابی اشتراک JSON",
+      "subJsonRoutingRulesDesc": "JSON قواعد مسیریابی، دیپ‌لینک happ:// یا incy://، یا آدرس https://. وقتی تنظیم شود، DNS و قواعد مسیریابی اشتراک JSON را جایگزین می‌کند (در پیکربندی برای کلاینت‌هایی که هدرها را نادیده می‌گیرند تعبیه می‌شود، مانند Happ و INCY با بالانسر سمت کلاینت). اگر تنظیم هدر Happ خالی باشد، همین پروفایل به‌صورت خودکار در هدر Routing ارسال می‌شود (JSON و آدرس‌ها به‌صورت دیپ‌لینک happ://) تا کلاینت‌ها فایل‌های geo منطبق را دانلود کنند.",
       "subClashAutoDetect": "تشخیص خودکار کلاینت‌های Clash/Mihomo",
       "subClashAutoDetectDesc": "با فعال‌سازی، کلاینت‌های شناخته‌شده Clash/Mihomo که آدرس استاندارد اشتراک را درخواست می‌کنند، به‌طور خودکار پیکربندی Clash با فرمت YAML دریافت می‌کنند. مرورگرها همچنان صفحه اشتراک را نمایش می‌دهند، سایر کلاینت‌ها پاسخ خام/Base64 را دریافت می‌کنند و آدرس‌های صریح JSON و Clash در دسترس می‌مانند. برای اعمال تغییر، اشتراک Clash/Mihomo باید فعال باشد و پنل راه‌اندازی مجدد شود.",
       "subClashUserAgentRegex": "عبارت User-Agent برای Clash/Mihomo",

+ 2 - 0
internal/web/translation/id-ID.json

@@ -1167,6 +1167,8 @@
       "subJsonAlwaysArrayDesc": "Mengembalikan endpoint langganan JSON eksplisit sebagai larik meski hanya berisi satu profil, sesuai standar XTLS. Respons JSON yang terdeteksi otomatis selalu menggunakan larik. Nonaktifkan untuk mempertahankan respons objek tunggal lama.",
       "subJsonUserAgentRegex": "Regex User-Agent Xray JSON",
       "subJsonUserAgentRegexDesc": "Ekspresi reguler Go RE2 yang dicocokkan dengan User-Agent klien untuk memilih format Xray JSON secara otomatis pada URL langganan standar. Kosong secara bawaan, sehingga deteksi otomatis tetap nonaktif hingga Anda menetapkan pola untuk klien yang ingin dilayani. Klien lain tetap menerima respons mentah/Base64. Mulai ulang panel setelah mengubahnya.",
+      "subJsonRoutingRules": "Profil routing langganan JSON",
+      "subJsonRoutingRulesDesc": "JSON routing, deeplink happ:// atau incy://, atau URL https://. Jika diisi, menggantikan DNS dan aturan routing langganan JSON (dibake ke dalam konfigurasi untuk klien yang mengabaikan header, mis. Happ dan INCY dengan balancer sisi klien). Jika pengaturan header Happ kosong, profil yang sama otomatis dikirim sebagai header Routing (JSON dan URL sebagai deeplink happ://) agar klien mengunduh berkas geo yang cocok.",
       "subClashAutoDetect": "Deteksi otomatis klien Clash/Mihomo",
       "subClashAutoDetectDesc": "Jika diaktifkan, klien Clash/Mihomo yang dikenali dan meminta URL langganan standar akan otomatis menerima YAML Clash. Browser tetap menampilkan halaman langganan, klien lain tetap menerima respons mentah/Base64, dan URL JSON serta Clash eksplisit tetap tersedia. Langganan Clash/Mihomo harus diaktifkan dan panel harus dimulai ulang agar perubahan diterapkan.",
       "subClashUserAgentRegex": "Regex User-Agent Clash/Mihomo",

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -1167,6 +1167,8 @@
       "subJsonAlwaysArrayDesc": "プロファイルが 1 件でも、明示的な JSON サブスクリプションを XTLS 標準に従う配列として返します。自動検出された JSON 応答は常に配列です。無効時は従来の単一オブジェクト応答を維持します。",
       "subJsonUserAgentRegex": "Xray JSON User-Agent 正規表現",
       "subJsonUserAgentRegexDesc": "標準サブスクリプション URL で Xray JSON 形式を自動選択するため、クライアントの User-Agent と照合する Go RE2 正規表現です。既定では空欄のため、対象とするクライアント向けのパターンを設定するまで自動判別は無効のままです。その他のクライアントには従来の raw/Base64 応答を返します。変更後にパネルを再起動してください。",
+      "subJsonRoutingRules": "JSON サブスクリプションのルーティングプロファイル",
+      "subJsonRoutingRulesDesc": "ルーティング JSON、happ:// または incy:// のディープリンク、もしくは https:// URL。設定すると JSON サブスクリプションの DNS とルーティングルールを置き換えます(ヘッダーを無視するクライアント、例えばクライアントサイドバランサーを使う Happ や INCY 向けに設定へ組み込まれます)。Happ のルーティングヘッダー設定が空の場合、同じプロファイルが自動的に Routing ヘッダーとして送信され(JSON と URL は happ:// ディープリンクに変換)、クライアントは対応する geo ファイルを取得します。",
       "subClashAutoDetect": "Clash/Mihomo クライアントを自動検出",
       "subClashAutoDetectDesc": "有効にすると、標準サブスクリプション URL を要求する既知の Clash/Mihomo クライアントへ Clash YAML を自動的に返します。ブラウザには引き続きサブスクリプションページを表示し、その他のクライアントには従来の raw/Base64 応答を返します。明示的な JSON および Clash URL も引き続き利用できます。Clash/Mihomo サブスクリプションを有効にし、適用のためにパネルを再起動する必要があります。",
       "subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表現",

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -1167,6 +1167,8 @@
       "subJsonAlwaysArrayDesc": "Retorna o endpoint JSON explícito como matriz mesmo com apenas um perfil, conforme o padrão XTLS. Respostas JSON detectadas automaticamente sempre usam matrizes. Desative para preservar a resposta legada de objeto único.",
       "subJsonUserAgentRegex": "Expressão User-Agent do Xray JSON",
       "subJsonUserAgentRegexDesc": "Expressão regular Go RE2 comparada com o User-Agent do cliente para selecionar automaticamente o formato Xray JSON na URL de assinatura padrão. Vazia por padrão, então a detecção automática permanece desativada até você definir um padrão para os clientes que deseja atender. Os outros clientes mantêm a resposta bruta/Base64. Reinicie o painel após alterá-la.",
+      "subJsonRoutingRules": "Perfil de roteamento da assinatura JSON",
+      "subJsonRoutingRulesDesc": "JSON de roteamento, deeplink happ:// ou incy://, ou URL https://. Quando definido, substitui o DNS e as regras de roteamento da assinatura JSON (embutidos na configuração para clientes que ignoram cabeçalhos, ex.: Happ e INCY com balanceadores no lado do cliente). Se a configuração de cabeçalho do Happ estiver vazia, o mesmo perfil é enviado automaticamente como cabeçalho Routing (JSON e URLs como deeplink happ://) para que os clientes baixem os arquivos geo correspondentes.",
       "subClashAutoDetect": "Detectar clientes Clash/Mihomo automaticamente",
       "subClashAutoDetectDesc": "Quando ativado, clientes Clash/Mihomo reconhecidos que solicitarem a URL de assinatura padrão receberão automaticamente YAML do Clash. Os navegadores continuarão exibindo a página de assinatura, os outros clientes manterão a resposta bruta/Base64 e as URLs explícitas de JSON e Clash continuarão disponíveis. Requer a assinatura Clash/Mihomo ativada e a reinicialização do painel para aplicar a alteração.",
       "subClashUserAgentRegex": "Expressão User-Agent do Clash/Mihomo",

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -1167,6 +1167,8 @@
       "subJsonAlwaysArrayDesc": "Возвращает явную JSON-подписку как массив даже при одном профиле согласно стандарту XTLS. Автоматически определённые JSON-ответы всегда используют массив. Отключите, чтобы сохранить прежний ответ в виде одного объекта.",
       "subJsonUserAgentRegex": "Регулярное выражение User-Agent Xray JSON",
       "subJsonUserAgentRegexDesc": "Регулярное выражение Go RE2, сопоставляемое с User-Agent клиента для автоматического выбора формата Xray JSON на стандартном URL подписки. По умолчанию пустое, поэтому автоопределение отключено, пока вы не зададите шаблон для нужных клиентов. Остальные клиенты получают ответ raw/Base64. После изменения перезапустите панель.",
+      "subJsonRoutingRules": "Профиль маршрутизации для JSON-подписки",
+      "subJsonRoutingRulesDesc": "JSON правил маршрутизации, диплинк happ:// или incy://, либо https:// URL. Если задан, заменяет DNS и правила маршрутизации JSON-подписки (правила встраиваются в конфиг для клиентов, игнорирующих заголовки, например Happ и INCY с клиентскими балансировщиками). Если настройка заголовка Happ пуста, этот же профиль автоматически отправляется в заголовке Routing (JSON и URL — в виде диплинка happ://), чтобы клиенты скачивали подходящие geo-файлы.",
       "subClashAutoDetect": "Автоматически определять клиентов Clash/Mihomo",
       "subClashAutoDetectDesc": "Если параметр включён, распознанные клиенты Clash/Mihomo при запросе стандартного URL подписки автоматически получают Clash YAML. Браузеры по-прежнему показывают страницу подписки, остальные клиенты получают прежний ответ raw/Base64, а явные URL JSON и Clash остаются доступными. Для применения требуется включить подписку Clash/Mihomo и перезапустить панель.",
       "subClashUserAgentRegex": "Регулярное выражение User-Agent Clash/Mihomo",

+ 2 - 0
internal/web/translation/tr-TR.json

@@ -1167,6 +1167,8 @@
       "subJsonAlwaysArrayDesc": "Açık JSON abonelik uç noktasını tek profil olsa bile XTLS standardına uygun bir dizi olarak döndürür. Otomatik algılanan JSON yanıtları her zaman dizi kullanır. Eski tek nesne yanıtını korumak için devre dışı bırakın.",
       "subJsonUserAgentRegex": "Xray JSON User-Agent düzenli ifadesi",
       "subJsonUserAgentRegexDesc": "Standart abonelik URL'sinde Xray JSON biçimini otomatik seçmek için istemcinin User-Agent değeriyle eşleştirilen Go RE2 düzenli ifadesi. Varsayılan olarak boştur; bu nedenle hizmet vermek istediğiniz istemciler için bir desen belirleyene kadar otomatik algılama kapalı kalır. Diğer istemciler ham/Base64 yanıtını almaya devam eder. Değişiklikten sonra paneli yeniden başlatın.",
+      "subJsonRoutingRules": "JSON aboneliği yönlendirme profili",
+      "subJsonRoutingRulesDesc": "Yönlendirme JSON'u, happ:// veya incy:// deeplink'i ya da https:// URL'si. Ayarlandığında JSON aboneliğinin DNS ve yönlendirme kurallarını değiştirir (başlıkları yok sayan istemciler için yapılandırmaya gömülür, örn. istemci tarafı dengeleyicileri olan Happ ve INCY). Happ yönlendirme başlığı ayarı boşsa, aynı profil otomatik olarak Routing başlığı olarak gönderilir (JSON ve URL'ler happ:// deeplink'i olarak) ve istemciler eşleşen geo dosyalarını indirir.",
       "subClashAutoDetect": "Clash/Mihomo istemcilerini otomatik algıla",
       "subClashAutoDetectDesc": "Etkinleştirildiğinde, standart abonelik URL'sini isteyen tanınmış Clash/Mihomo istemcileri otomatik olarak Clash YAML alır. Tarayıcılar abonelik sayfasını göstermeye devam eder, diğer istemciler ham/Base64 yanıtını almaya devam eder ve açık JSON ile Clash URL'leri kullanılabilir kalır. Uygulanması için Clash/Mihomo aboneliğinin etkinleştirilmesi ve panelin yeniden başlatılması gerekir.",
       "subClashUserAgentRegex": "Clash/Mihomo User-Agent düzenli ifadesi",

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -1167,6 +1167,8 @@
       "subJsonAlwaysArrayDesc": "Повертає явну JSON-підписку як масив навіть для одного профілю відповідно до стандарту XTLS. Автоматично визначені JSON-відповіді завжди використовують масив. Вимкніть, щоб зберегти попередню відповідь одним об’єктом.",
       "subJsonUserAgentRegex": "Регулярний вираз User-Agent Xray JSON",
       "subJsonUserAgentRegexDesc": "Регулярний вираз Go RE2, який зіставляється з User-Agent клієнта для автоматичного вибору формату Xray JSON на стандартній URL-адресі підписки. Типово порожнє, тому автоматичне визначення вимкнено, доки ви не задасте шаблон для потрібних клієнтів. Інші клієнти отримують відповідь raw/Base64. Після зміни перезапустіть панель.",
+      "subJsonRoutingRules": "Профіль маршрутизації для JSON-підписки",
+      "subJsonRoutingRulesDesc": "JSON правил маршрутизації, діплінк happ:// або incy://, або https:// URL. Якщо задано, замінює DNS і правила маршрутизації JSON-підписки (вбудовуються в конфігурацію для клієнтів, що ігнорують заголовки, напр. Happ і INCY з клієнтськими балансувальниками). Якщо налаштування заголовка Happ порожнє, цей самий профіль автоматично надсилається в заголовку Routing (JSON і URL — у вигляді діплінка happ://), щоб клієнти завантажували відповідні geo-файли.",
       "subClashAutoDetect": "Автоматично визначати клієнтів Clash/Mihomo",
       "subClashAutoDetectDesc": "Якщо параметр увімкнено, розпізнані клієнти Clash/Mihomo під час запиту стандартної URL-адреси підписки автоматично отримують Clash YAML. Браузери й надалі показують сторінку підписки, інші клієнти отримують звичайну відповідь raw/Base64, а явні URL-адреси JSON і Clash залишаються доступними. Для застосування потрібно ввімкнути підписку Clash/Mihomo та перезапустити панель.",
       "subClashUserAgentRegex": "Регулярний вираз User-Agent Clash/Mihomo",

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -1167,6 +1167,8 @@
       "subJsonAlwaysArrayDesc": "Trả endpoint đăng ký JSON tường minh dưới dạng mảng ngay cả khi chỉ có một hồ sơ, theo tiêu chuẩn XTLS. Phản hồi JSON được tự động nhận diện luôn dùng mảng. Tắt để giữ phản hồi một đối tượng kiểu cũ.",
       "subJsonUserAgentRegex": "Biểu thức User-Agent Xray JSON",
       "subJsonUserAgentRegexDesc": "Biểu thức chính quy Go RE2 được so khớp với User-Agent của ứng dụng để tự động chọn định dạng Xray JSON trên URL đăng ký tiêu chuẩn. Mặc định để trống, nên tính năng tự động nhận diện vẫn tắt cho đến khi bạn đặt mẫu cho các ứng dụng muốn phục vụ. Các ứng dụng khác tiếp tục nhận phản hồi thô/Base64. Khởi động lại bảng điều khiển sau khi thay đổi.",
+      "subJsonRoutingRules": "Hồ sơ định tuyến cho đăng ký JSON",
+      "subJsonRoutingRulesDesc": "JSON định tuyến, deeplink happ:// hoặc incy://, hoặc URL https://. Khi được đặt, nó thay thế DNS và quy tắc định tuyến của đăng ký JSON (được nhúng vào cấu hình cho các client bỏ qua header, ví dụ Happ và INCY với bộ cân bằng phía client). Nếu cài đặt header của Happ để trống, chính hồ sơ đó sẽ được tự động gửi dưới dạng header Routing (JSON và URL thành deeplink happ://) để client tải các tệp geo khớp.",
       "subClashAutoDetect": "Tự động nhận diện ứng dụng Clash/Mihomo",
       "subClashAutoDetectDesc": "Khi bật, các ứng dụng Clash/Mihomo được nhận diện khi yêu cầu URL đăng ký tiêu chuẩn sẽ tự động nhận cấu hình Clash YAML. Trình duyệt vẫn hiển thị trang đăng ký, các ứng dụng khác tiếp tục nhận phản hồi thô/Base64, còn các URL JSON và Clash riêng vẫn khả dụng. Cần bật đăng ký Clash/Mihomo và khởi động lại bảng điều khiển để áp dụng thay đổi.",
       "subClashUserAgentRegex": "Biểu thức User-Agent Clash/Mihomo",

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -1167,6 +1167,8 @@
       "subJsonAlwaysArrayDesc": "即使只有一个配置,也将显式 JSON 订阅端点返回为数组,以符合 XTLS 订阅标准。自动识别的 JSON 响应始终使用数组。禁用时保留旧版单对象响应。",
       "subJsonUserAgentRegex": "Xray JSON User-Agent 正则表达式",
       "subJsonUserAgentRegexDesc": "用于与客户端 User-Agent 进行匹配,从而在标准订阅 URL 上自动选择 Xray JSON 格式的 Go RE2 正则表达式。默认留空,因此在为需要服务的客户端设置规则之前,自动识别保持关闭。其他客户端继续获得原始/Base64 响应。更改后请重启面板。",
+      "subJsonRoutingRules": "JSON 订阅路由配置",
+      "subJsonRoutingRulesDesc": "路由 JSON、happ:// 或 incy:// 深度链接,或 https:// URL。设置后,将替换 JSON 订阅的 DNS 和路由规则(为忽略响应头的客户端直接嵌入配置,例如使用客户端侧负载均衡的 Happ 和 INCY)。若 Happ 路由响应头设置为空,同一配置会自动作为 Routing 响应头发送(内联 JSON 和 URL 转换为 happ:// 深度链接),以便客户端下载匹配的 geo 文件。",
       "subClashAutoDetect": "自动识别 Clash/Mihomo 客户端",
       "subClashAutoDetectDesc": "启用后,使用标准订阅 URL 的已识别 Clash/Mihomo 客户端将自动获得 Clash YAML。浏览器仍显示订阅页面,其他客户端继续获得原始/Base64 响应,独立的 JSON 和 Clash URL 仍然可用。需要启用 Clash/Mihomo 订阅并重启面板才能生效。",
       "subClashUserAgentRegex": "Clash/Mihomo User-Agent 正则表达式",

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -1167,6 +1167,8 @@
       "subJsonAlwaysArrayDesc": "即使只有一個設定,也將明確 JSON 訂閱端點回傳為陣列,以符合 XTLS 訂閱標準。自動識別的 JSON 回應始終使用陣列。停用時保留舊版單一物件回應。",
       "subJsonUserAgentRegex": "Xray JSON User-Agent 正規表示式",
       "subJsonUserAgentRegexDesc": "用於與用戶端 User-Agent 進行比對,以便在標準訂閱 URL 上自動選擇 Xray JSON 格式的 Go RE2 正規表示式。預設留空,因此在為要服務的用戶端設定規則之前,自動辨識會保持關閉。其他用戶端繼續取得原始/Base64 回應。變更後請重新啟動面板。",
+      "subJsonRoutingRules": "JSON 訂閱路由設定檔",
+      "subJsonRoutingRulesDesc": "路由 JSON、happ:// 或 incy:// 深層連結,或 https:// URL。設定後,將取代 JSON 訂閱的 DNS 與路由規則(為忽略回應標頭的用戶端直接內嵌至設定,例如使用用戶端負載平衡器的 Happ 與 INCY)。若 Happ 路由標頭設定為空,同一設定檔會自動作為 Routing 標頭傳送(內嵌 JSON 與 URL 轉換為 happ:// 深層連結),讓用戶端下載相符的 geo 檔案。",
       "subClashAutoDetect": "自動識別 Clash/Mihomo 用戶端",
       "subClashAutoDetectDesc": "啟用後,使用標準訂閱 URL 的已識別 Clash/Mihomo 用戶端將自動取得 Clash YAML。瀏覽器仍會顯示訂閱頁面,其他用戶端繼續取得原始/Base64 回應,獨立的 JSON 和 Clash URL 仍可使用。需要啟用 Clash/Mihomo 訂閱並重新啟動面板才能生效。",
       "subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表示式",