2 Комити 37c5e0bfd2 ... 5038fa1cec

Аутор SHA1 Порука Датум
  MHSanaei 5038fa1cec i18n: sync 12 locales with en-US — add missing Hosts/subscription keys пре 10 часа
  Sanaei 709b332d17 feat(hosts): managed Hosts for per-host subscription link overrides (#5409) пре 10 часа
100 измењених фајлова са 6359 додато и 953 уклоњено
  1. 945 26
      frontend/public/openapi.json
  2. 60 0
      frontend/src/api/queries/useHostMutations.ts
  3. 33 0
      frontend/src/api/queries/useHostsQuery.ts
  4. 6 0
      frontend/src/api/queryKeys.ts
  5. 71 0
      frontend/src/components/form/RemarkTemplateField.tsx
  6. 43 0
      frontend/src/components/form/RemarkVarPicker.tsx
  7. 3 0
      frontend/src/components/form/index.ts
  8. 47 6
      frontend/src/generated/examples.ts
  9. 181 26
      frontend/src/generated/schemas.ts
  10. 38 6
      frontend/src/generated/types.ts
  11. 39 6
      frontend/src/generated/zod.ts
  12. 6 3
      frontend/src/layouts/AppSidebar.tsx
  13. 50 0
      frontend/src/lib/hosts/host-link.ts
  14. 70 0
      frontend/src/lib/remark/remarkVariables.ts
  15. 76 0
      frontend/src/lib/xray/forms/transport/CustomSockoptList.tsx
  16. 1 1
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  17. 13 28
      frontend/src/lib/xray/inbound-link.ts
  18. 10 23
      frontend/src/lib/xray/link-label.tsx
  19. 1 3
      frontend/src/models/setting.ts
  20. 93 0
      frontend/src/pages/api-docs/endpoints.ts
  21. 1 1
      frontend/src/pages/clients/ClientInfoModal.tsx
  22. 1 1
      frontend/src/pages/clients/ClientQrModal.tsx
  23. 2 2
      frontend/src/pages/clients/SubLinksModal.tsx
  24. 335 0
      frontend/src/pages/hosts/HostFormModal.tsx
  25. 58 0
      frontend/src/pages/hosts/HostList.css
  26. 195 0
      frontend/src/pages/hosts/HostList.tsx
  27. 210 0
      frontend/src/pages/hosts/HostsPage.tsx
  28. 55 0
      frontend/src/pages/hosts/json-forms/HostFinalMaskForm.tsx
  29. 25 0
      frontend/src/pages/hosts/json-forms/HostMuxForm.tsx
  30. 43 0
      frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx
  31. 68 0
      frontend/src/pages/hosts/json-forms/OutboundSubtreeJsonForm.tsx
  32. 50 0
      frontend/src/pages/hosts/json-forms/helpers.ts
  33. 3 0
      frontend/src/pages/hosts/json-forms/index.ts
  34. 2 7
      frontend/src/pages/inbounds/InboundsPage.tsx
  35. 4 25
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  36. 0 209
      frontend/src/pages/inbounds/form/transport/external-proxy.tsx
  37. 0 1
      frontend/src/pages/inbounds/form/transport/index.ts
  38. 6 143
      frontend/src/pages/inbounds/form/transport/sockopt.tsx
  39. 1 5
      frontend/src/pages/inbounds/info/InboundInfoModal.tsx
  40. 0 1
      frontend/src/pages/inbounds/info/types.ts
  41. 1 6
      frontend/src/pages/inbounds/qr/QrCodeModal.tsx
  42. 0 2
      frontend/src/pages/inbounds/useInbounds.ts
  43. 1 1
      frontend/src/pages/settings/EmailTab.tsx
  44. 9 70
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  45. 1 1
      frontend/src/pages/settings/TelegramTab.tsx
  46. 3 1
      frontend/src/pages/sub/SubPage.tsx
  47. 7 1
      frontend/src/pages/xray/outbounds/OutboundCardList.tsx
  48. 9 2
      frontend/src/pages/xray/outbounds/OutboundsTab.css
  49. 9 0
      frontend/src/pages/xray/outbounds/OutboundsTab.tsx
  50. 10 82
      frontend/src/pages/xray/outbounds/transport/sockopt.tsx
  51. 2 0
      frontend/src/routes.tsx
  52. 116 0
      frontend/src/schemas/api/host.ts
  53. 0 1
      frontend/src/schemas/defaults.ts
  54. 1 3
      frontend/src/schemas/setting.ts
  55. 41 0
      frontend/src/styles/page-cards.css
  56. 40 0
      frontend/src/styles/page-shell.css
  57. 1 17
      frontend/src/test/__snapshots__/inbound-form-blocks.test.tsx.snap
  58. 54 0
      frontend/src/test/host-link.test.ts
  59. 67 0
      frontend/src/test/host-schema.test.ts
  60. 5 23
      frontend/src/test/inbound-form-blocks.test.tsx
  61. 29 0
      frontend/src/test/link-label.test.ts
  62. 26 0
      frontend/src/test/remark-template-field.test.tsx
  63. 30 0
      frontend/src/test/remark-variables.test.ts
  64. 131 0
      internal/database/db.go
  65. 151 0
      internal/database/host_migration_test.go
  66. 84 0
      internal/database/host_test.go
  67. 1 0
      internal/database/migrate_data.go
  68. 45 0
      internal/database/model/host_test.go
  69. 54 0
      internal/database/model/model.go
  70. 213 0
      internal/sub/characterization_test.go
  71. 24 6
      internal/sub/clash_service.go
  72. 10 10
      internal/sub/clash_service_test.go
  73. 9 5
      internal/sub/controller.go
  74. 128 0
      internal/sub/endpoint.go
  75. 113 0
      internal/sub/endpoint_test.go
  76. 1 1
      internal/sub/external_config_test.go
  77. 1 1
      internal/sub/external_only_sub_test.go
  78. 315 0
      internal/sub/host_sub.go
  79. 364 0
      internal/sub/host_sub_test.go
  80. 30 13
      internal/sub/json_service.go
  81. 4 4
      internal/sub/json_service_test.go
  82. 2 6
      internal/sub/links.go
  83. 7 7
      internal/sub/mutation_audit_test.go
  84. 322 0
      internal/sub/remark_vars.go
  85. 282 0
      internal/sub/remark_vars_test.go
  86. 85 143
      internal/sub/service.go
  87. 1 1
      internal/sub/service_dedup_test.go
  88. 3 3
      internal/sub/service_flow_test.go
  89. 2 2
      internal/sub/service_sharelink_test.go
  90. 1 1
      internal/sub/service_sort_test.go
  91. 1 2
      internal/sub/service_test.go
  92. 3 8
      internal/sub/sub.go
  93. 5 0
      internal/web/controller/api.go
  94. 2 0
      internal/web/controller/api_docs_test.go
  95. 194 0
      internal/web/controller/host.go
  96. 146 0
      internal/web/controller/host_test.go
  97. 5 7
      internal/web/entity/entity.go
  98. 130 0
      internal/web/service/host.go
  99. 179 0
      internal/web/service/host_test.go
  100. 4 0
      internal/web/service/inbound.go

+ 945 - 26
frontend/public/openapi.json

@@ -124,8 +124,8 @@
             "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
             "type": "string"
           },
-          "remarkModel": {
-            "description": "Remark model pattern for inbounds",
+          "remarkTemplate": {
+            "description": "Subscription remark template ({{VAR}} tokens) rendered per client",
             "type": "string"
           },
           "restartXrayOnClientDisable": {
@@ -210,10 +210,6 @@
             "description": "Domain for subscription server validation",
             "type": "string"
           },
-          "subEmailInRemark": {
-            "description": "Include email in subscription remark/name",
-            "type": "boolean"
-          },
           "subEnable": {
             "description": "Subscription server settings\nEnable subscription server",
             "type": "boolean"
@@ -275,10 +271,6 @@
             "description": "Subscription global routing rules (Only for Happ)",
             "type": "string"
           },
-          "subShowInfo": {
-            "description": "Show client information in subscriptions",
-            "type": "boolean"
-          },
           "subSupportUrl": {
             "description": "Subscription support URL",
             "type": "string"
@@ -424,7 +416,7 @@
           "ldapVlessField",
           "pageSize",
           "panelOutbound",
-          "remarkModel",
+          "remarkTemplate",
           "restartXrayOnClientDisable",
           "sessionMaxAge",
           "smtpCpu",
@@ -444,7 +436,6 @@
           "subClashRules",
           "subClashURI",
           "subDomain",
-          "subEmailInRemark",
           "subEnable",
           "subEnableRouting",
           "subEncrypt",
@@ -460,7 +451,6 @@
           "subPort",
           "subProfileUrl",
           "subRoutingRules",
-          "subShowInfo",
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
@@ -610,8 +600,8 @@
             "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
             "type": "string"
           },
-          "remarkModel": {
-            "description": "Remark model pattern for inbounds",
+          "remarkTemplate": {
+            "description": "Subscription remark template ({{VAR}} tokens) rendered per client",
             "type": "string"
           },
           "restartXrayOnClientDisable": {
@@ -696,10 +686,6 @@
             "description": "Domain for subscription server validation",
             "type": "string"
           },
-          "subEmailInRemark": {
-            "description": "Include email in subscription remark/name",
-            "type": "boolean"
-          },
           "subEnable": {
             "description": "Subscription server settings\nEnable subscription server",
             "type": "boolean"
@@ -761,10 +747,6 @@
             "description": "Subscription global routing rules (Only for Happ)",
             "type": "string"
           },
-          "subShowInfo": {
-            "description": "Show client information in subscriptions",
-            "type": "boolean"
-          },
           "subSupportUrl": {
             "description": "Subscription support URL",
             "type": "string"
@@ -917,7 +899,7 @@
           "ldapVlessField",
           "pageSize",
           "panelOutbound",
-          "remarkModel",
+          "remarkTemplate",
           "restartXrayOnClientDisable",
           "sessionMaxAge",
           "smtpCpu",
@@ -937,7 +919,6 @@
           "subClashRules",
           "subClashURI",
           "subDomain",
-          "subEmailInRemark",
           "subEnable",
           "subEnableRouting",
           "subEncrypt",
@@ -953,7 +934,6 @@
           "subPort",
           "subProfileUrl",
           "subRoutingRules",
-          "subShowInfo",
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
@@ -1352,6 +1332,181 @@
         ],
         "type": "object"
       },
+      "Host": {
+        "description": "Host is an override endpoint attached to an inbound: at subscription time each\nenabled host renders one share link/proxy with its own address/port/TLS/etc.,\nsuperseding the legacy externalProxy array. Free-JSON fields are stored as\ntext and parsed in the sub layer; slice fields use the json serializer.",
+        "properties": {
+          "address": {
+            "example": "cdn.example.com",
+            "type": "string"
+          },
+          "allowInsecure": {
+            "type": "boolean"
+          },
+          "alpn": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "createdAt": {
+            "type": "integer"
+          },
+          "echConfigList": {
+            "type": "string"
+          },
+          "excludeFromSubTypes": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "finalMask": {
+            "description": "FinalMask is a JSON object of xray finalmask masks (tcp/udp/quicParams),\nmerged into this host's JSON-subscription stream. Empty = no override.",
+            "type": "string"
+          },
+          "fingerprint": {
+            "type": "string"
+          },
+          "hostHeader": {
+            "type": "string"
+          },
+          "id": {
+            "example": 1,
+            "type": "integer"
+          },
+          "inboundId": {
+            "example": 1,
+            "type": "integer"
+          },
+          "isDisabled": {
+            "type": "boolean"
+          },
+          "isHidden": {
+            "type": "boolean"
+          },
+          "keepSniBlank": {
+            "type": "boolean"
+          },
+          "mihomoIpVersion": {
+            "enum": [
+              "dual",
+              "ipv4",
+              "ipv6",
+              "ipv4-prefer",
+              "ipv6-prefer"
+            ],
+            "type": "string"
+          },
+          "mihomoX25519": {
+            "type": "boolean"
+          },
+          "muxParams": {},
+          "nodeGuids": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "overrideSniFromAddress": {
+            "type": "boolean"
+          },
+          "path": {
+            "type": "string"
+          },
+          "pinnedPeerCertSha256": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "port": {
+            "example": 8443,
+            "maximum": 65535,
+            "minimum": 0,
+            "type": "integer"
+          },
+          "remark": {
+            "example": "cdn-front",
+            "maxLength": 256,
+            "type": "string"
+          },
+          "security": {
+            "enum": [
+              "same",
+              "tls",
+              "none",
+              "reality"
+            ],
+            "example": "same",
+            "type": "string"
+          },
+          "serverDescription": {
+            "maxLength": 64,
+            "type": "string"
+          },
+          "shuffleHost": {
+            "type": "boolean"
+          },
+          "sni": {
+            "type": "string"
+          },
+          "sockoptParams": {},
+          "sortOrder": {
+            "type": "integer"
+          },
+          "tags": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "updatedAt": {
+            "type": "integer"
+          },
+          "verifyPeerCertByName": {
+            "type": "boolean"
+          },
+          "vlessRoute": {
+            "description": "VlessRoute is a free-form port/range routing spec (e.g. \"53,443,1000-2000\");\nstored verbatim, format-validated on the frontend.",
+            "type": "string"
+          }
+        },
+        "required": [
+          "address",
+          "allowInsecure",
+          "alpn",
+          "createdAt",
+          "echConfigList",
+          "excludeFromSubTypes",
+          "finalMask",
+          "fingerprint",
+          "hostHeader",
+          "id",
+          "inboundId",
+          "isDisabled",
+          "isHidden",
+          "keepSniBlank",
+          "mihomoIpVersion",
+          "mihomoX25519",
+          "muxParams",
+          "overrideSniFromAddress",
+          "path",
+          "pinnedPeerCertSha256",
+          "port",
+          "remark",
+          "security",
+          "serverDescription",
+          "shuffleHost",
+          "sni",
+          "sockoptParams",
+          "sortOrder",
+          "tags",
+          "updatedAt",
+          "verifyPeerCertByName",
+          "vlessRoute"
+        ],
+        "type": "object"
+      },
       "Inbound": {
         "description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.",
         "properties": {
@@ -1994,6 +2149,10 @@
       "name": "Nodes",
       "description": "Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes."
     },
+    {
+      "name": "Hosts",
+      "description": "Per-inbound override endpoints. Each enabled host renders one extra subscription link/proxy with its own address/port/TLS, superseding the legacy externalProxy array. All endpoints under /panel/api/hosts."
+    },
     {
       "name": "Backup",
       "description": "Operations that interact with the configured Telegram bot."
@@ -7016,6 +7175,766 @@
         }
       }
     },
+    "/panel/api/hosts/list": {
+      "get": {
+        "tags": [
+          "Hosts"
+        ],
+        "summary": "List every host across all inbounds, grouped by inbound then ordered by sort order.",
+        "operationId": "get_panel_api_hosts_list",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "type": "array",
+                      "items": {
+                        "$ref": "#/components/schemas/Host"
+                      }
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "address": "cdn.example.com",
+                      "allowInsecure": false,
+                      "alpn": [
+                        ""
+                      ],
+                      "createdAt": 0,
+                      "echConfigList": "",
+                      "excludeFromSubTypes": [
+                        ""
+                      ],
+                      "finalMask": "",
+                      "fingerprint": "",
+                      "hostHeader": "",
+                      "id": 1,
+                      "inboundId": 1,
+                      "isDisabled": false,
+                      "isHidden": false,
+                      "keepSniBlank": false,
+                      "mihomoIpVersion": "dual",
+                      "mihomoX25519": false,
+                      "muxParams": null,
+                      "nodeGuids": [
+                        ""
+                      ],
+                      "overrideSniFromAddress": false,
+                      "path": "",
+                      "pinnedPeerCertSha256": [
+                        ""
+                      ],
+                      "port": 8443,
+                      "remark": "cdn-front",
+                      "security": "same",
+                      "serverDescription": "",
+                      "shuffleHost": false,
+                      "sni": "",
+                      "sockoptParams": null,
+                      "sortOrder": 0,
+                      "tags": [
+                        ""
+                      ],
+                      "updatedAt": 0,
+                      "verifyPeerCertByName": false,
+                      "vlessRoute": ""
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/hosts/get/{id}": {
+      "get": {
+        "tags": [
+          "Hosts"
+        ],
+        "summary": "Fetch a single host by ID.",
+        "operationId": "get_panel_api_hosts_get_id",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Host ID.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/Host"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "address": "cdn.example.com",
+                    "allowInsecure": false,
+                    "alpn": [
+                      ""
+                    ],
+                    "createdAt": 0,
+                    "echConfigList": "",
+                    "excludeFromSubTypes": [
+                      ""
+                    ],
+                    "finalMask": "",
+                    "fingerprint": "",
+                    "hostHeader": "",
+                    "id": 1,
+                    "inboundId": 1,
+                    "isDisabled": false,
+                    "isHidden": false,
+                    "keepSniBlank": false,
+                    "mihomoIpVersion": "dual",
+                    "mihomoX25519": false,
+                    "muxParams": null,
+                    "nodeGuids": [
+                      ""
+                    ],
+                    "overrideSniFromAddress": false,
+                    "path": "",
+                    "pinnedPeerCertSha256": [
+                      ""
+                    ],
+                    "port": 8443,
+                    "remark": "cdn-front",
+                    "security": "same",
+                    "serverDescription": "",
+                    "shuffleHost": false,
+                    "sni": "",
+                    "sockoptParams": null,
+                    "sortOrder": 0,
+                    "tags": [
+                      ""
+                    ],
+                    "updatedAt": 0,
+                    "verifyPeerCertByName": false,
+                    "vlessRoute": ""
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/hosts/byInbound/{inboundId}": {
+      "get": {
+        "tags": [
+          "Hosts"
+        ],
+        "summary": "Fetch one inbound's hosts, ordered by sort order then id.",
+        "operationId": "get_panel_api_hosts_byInbound_inboundId",
+        "parameters": [
+          {
+            "name": "inboundId",
+            "in": "path",
+            "required": true,
+            "description": "Inbound ID.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "type": "array",
+                      "items": {
+                        "$ref": "#/components/schemas/Host"
+                      }
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "address": "cdn.example.com",
+                      "allowInsecure": false,
+                      "alpn": [
+                        ""
+                      ],
+                      "createdAt": 0,
+                      "echConfigList": "",
+                      "excludeFromSubTypes": [
+                        ""
+                      ],
+                      "finalMask": "",
+                      "fingerprint": "",
+                      "hostHeader": "",
+                      "id": 1,
+                      "inboundId": 1,
+                      "isDisabled": false,
+                      "isHidden": false,
+                      "keepSniBlank": false,
+                      "mihomoIpVersion": "dual",
+                      "mihomoX25519": false,
+                      "muxParams": null,
+                      "nodeGuids": [
+                        ""
+                      ],
+                      "overrideSniFromAddress": false,
+                      "path": "",
+                      "pinnedPeerCertSha256": [
+                        ""
+                      ],
+                      "port": 8443,
+                      "remark": "cdn-front",
+                      "security": "same",
+                      "serverDescription": "",
+                      "shuffleHost": false,
+                      "sni": "",
+                      "sockoptParams": null,
+                      "sortOrder": 0,
+                      "tags": [
+                        ""
+                      ],
+                      "updatedAt": 0,
+                      "verifyPeerCertByName": false,
+                      "vlessRoute": ""
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/hosts/tags": {
+      "get": {
+        "tags": [
+          "Hosts"
+        ],
+        "summary": "Distinct, sorted set of tags used across all hosts.",
+        "operationId": "get_panel_api_hosts_tags",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    "CDN",
+                    "EU",
+                    "FAST"
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/hosts/add": {
+      "post": {
+        "tags": [
+          "Hosts"
+        ],
+        "summary": "Create a host on an inbound. inboundId and remark are required; security defaults to \"same\" (inherit the inbound).",
+        "operationId": "post_panel_api_hosts_add",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "inboundId": 1,
+                "remark": "cdn-front",
+                "address": "cdn.example.com",
+                "port": 8443,
+                "security": "same",
+                "sni": "",
+                "tags": [
+                  "CDN"
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/Host"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "address": "cdn.example.com",
+                    "allowInsecure": false,
+                    "alpn": [
+                      ""
+                    ],
+                    "createdAt": 0,
+                    "echConfigList": "",
+                    "excludeFromSubTypes": [
+                      ""
+                    ],
+                    "finalMask": "",
+                    "fingerprint": "",
+                    "hostHeader": "",
+                    "id": 1,
+                    "inboundId": 1,
+                    "isDisabled": false,
+                    "isHidden": false,
+                    "keepSniBlank": false,
+                    "mihomoIpVersion": "dual",
+                    "mihomoX25519": false,
+                    "muxParams": null,
+                    "nodeGuids": [
+                      ""
+                    ],
+                    "overrideSniFromAddress": false,
+                    "path": "",
+                    "pinnedPeerCertSha256": [
+                      ""
+                    ],
+                    "port": 8443,
+                    "remark": "cdn-front",
+                    "security": "same",
+                    "serverDescription": "",
+                    "shuffleHost": false,
+                    "sni": "",
+                    "sockoptParams": null,
+                    "sortOrder": 0,
+                    "tags": [
+                      ""
+                    ],
+                    "updatedAt": 0,
+                    "verifyPeerCertByName": false,
+                    "vlessRoute": ""
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/hosts/update/{id}": {
+      "post": {
+        "tags": [
+          "Hosts"
+        ],
+        "summary": "Replace a host’s content. The inbound and sort order are immutable here (use /reorder for ordering).",
+        "operationId": "post_panel_api_hosts_update_id",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Host ID.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "inboundId": 1,
+                "remark": "cdn-front",
+                "address": "cdn.example.com",
+                "port": 8443,
+                "security": "same",
+                "sni": "",
+                "tags": [
+                  "CDN"
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/Host"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "address": "cdn.example.com",
+                    "allowInsecure": false,
+                    "alpn": [
+                      ""
+                    ],
+                    "createdAt": 0,
+                    "echConfigList": "",
+                    "excludeFromSubTypes": [
+                      ""
+                    ],
+                    "finalMask": "",
+                    "fingerprint": "",
+                    "hostHeader": "",
+                    "id": 1,
+                    "inboundId": 1,
+                    "isDisabled": false,
+                    "isHidden": false,
+                    "keepSniBlank": false,
+                    "mihomoIpVersion": "dual",
+                    "mihomoX25519": false,
+                    "muxParams": null,
+                    "nodeGuids": [
+                      ""
+                    ],
+                    "overrideSniFromAddress": false,
+                    "path": "",
+                    "pinnedPeerCertSha256": [
+                      ""
+                    ],
+                    "port": 8443,
+                    "remark": "cdn-front",
+                    "security": "same",
+                    "serverDescription": "",
+                    "shuffleHost": false,
+                    "sni": "",
+                    "sockoptParams": null,
+                    "sortOrder": 0,
+                    "tags": [
+                      ""
+                    ],
+                    "updatedAt": 0,
+                    "verifyPeerCertByName": false,
+                    "vlessRoute": ""
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/hosts/del/{id}": {
+      "post": {
+        "tags": [
+          "Hosts"
+        ],
+        "summary": "Delete a host.",
+        "operationId": "post_panel_api_hosts_del_id",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Host ID.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/hosts/setEnable/{id}": {
+      "post": {
+        "tags": [
+          "Hosts"
+        ],
+        "summary": "Enable or disable a single host (disabled hosts are skipped in subscriptions).",
+        "operationId": "post_panel_api_hosts_setEnable_id",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Host ID.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "enable": true
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/hosts/reorder": {
+      "post": {
+        "tags": [
+          "Hosts"
+        ],
+        "summary": "Set host sort order by the position of each id in the array.",
+        "operationId": "post_panel_api_hosts_reorder",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "ids": [
+                  3,
+                  1,
+                  2
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/hosts/bulk/setEnable": {
+      "post": {
+        "tags": [
+          "Hosts"
+        ],
+        "summary": "Enable or disable many hosts in one call.",
+        "operationId": "post_panel_api_hosts_bulk_setEnable",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "ids": [
+                  1,
+                  2,
+                  3
+                ],
+                "enable": false
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/hosts/bulk/del": {
+      "post": {
+        "tags": [
+          "Hosts"
+        ],
+        "summary": "Delete many hosts in one call.",
+        "operationId": "post_panel_api_hosts_bulk_del",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "ids": [
+                  1,
+                  2,
+                  3
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/backuptotgbot": {
       "post": {
         "tags": [

+ 60 - 0
frontend/src/api/queries/useHostMutations.ts

@@ -0,0 +1,60 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+
+import { HttpUtil } from '@/utils';
+import { keys } from '@/api/queryKeys';
+import type { HostFormValues } from '@/schemas/api/host';
+
+const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
+
+export function useHostMutations() {
+  const queryClient = useQueryClient();
+  const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.hosts.root() });
+
+  const createMut = useMutation({
+    mutationFn: (payload: Partial<HostFormValues>) => HttpUtil.post('/panel/api/hosts/add', payload),
+    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+  });
+
+  const updateMut = useMutation({
+    mutationFn: ({ id, payload }: { id: number; payload: Partial<HostFormValues> }) =>
+      HttpUtil.post(`/panel/api/hosts/update/${id}`, payload),
+    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+  });
+
+  const removeMut = useMutation({
+    mutationFn: (id: number) => HttpUtil.post(`/panel/api/hosts/del/${id}`),
+    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+  });
+
+  const setEnableMut = useMutation({
+    mutationFn: ({ id, enable }: { id: number; enable: boolean }) =>
+      HttpUtil.post(`/panel/api/hosts/setEnable/${id}`, { enable }),
+    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+  });
+
+  const reorderMut = useMutation({
+    mutationFn: (ids: number[]) => HttpUtil.post('/panel/api/hosts/reorder', { ids }, JSON_HEADERS),
+    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+  });
+
+  const bulkEnableMut = useMutation({
+    mutationFn: ({ ids, enable }: { ids: number[]; enable: boolean }) =>
+      HttpUtil.post('/panel/api/hosts/bulk/setEnable', { ids, enable }, JSON_HEADERS),
+    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+  });
+
+  const bulkDelMut = useMutation({
+    mutationFn: (ids: number[]) => HttpUtil.post('/panel/api/hosts/bulk/del', { ids }, JSON_HEADERS),
+    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+  });
+
+  return {
+    create: (payload: Partial<HostFormValues>) => createMut.mutateAsync(payload),
+    update: (id: number, payload: Partial<HostFormValues>) => updateMut.mutateAsync({ id, payload }),
+    remove: (id: number) => removeMut.mutateAsync(id),
+    setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
+    reorder: (ids: number[]) => reorderMut.mutateAsync(ids),
+    bulkSetEnable: (ids: number[], enable: boolean) => bulkEnableMut.mutateAsync({ ids, enable }),
+    bulkDel: (ids: number[]) => bulkDelMut.mutateAsync(ids),
+  };
+}

+ 33 - 0
frontend/src/api/queries/useHostsQuery.ts

@@ -0,0 +1,33 @@
+import { useQuery } from '@tanstack/react-query';
+import { useMemo } from 'react';
+
+import { HttpUtil } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
+import { HostListSchema, type HostRecord } from '@/schemas/api/host';
+import { keys } from '@/api/queryKeys';
+
+export type { HostRecord };
+
+async function fetchHosts(): Promise<HostRecord[]> {
+  const msg = await HttpUtil.get('/panel/api/hosts/list', undefined, { silent: true });
+  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch hosts');
+  const validated = parseMsg(msg, HostListSchema, 'hosts/list');
+  return Array.isArray(validated.obj) ? validated.obj : [];
+}
+
+export function useHostsQuery() {
+  const query = useQuery({
+    queryKey: keys.hosts.list(),
+    queryFn: fetchHosts,
+  });
+
+  const hosts = useMemo(() => query.data ?? [], [query.data]);
+
+  return {
+    hosts,
+    loading: query.isFetching,
+    fetched: query.data !== undefined || query.isError,
+    fetchError: query.error ? (query.error as Error).message : '',
+    refetch: query.refetch,
+  };
+}

+ 6 - 0
frontend/src/api/queryKeys.ts

@@ -6,6 +6,12 @@ export const keys = {
     root: () => ['nodes'] as const,
     list: () => ['nodes', 'list'] as const,
   },
+  hosts: {
+    root: () => ['hosts'] as const,
+    list: () => ['hosts', 'list'] as const,
+    byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
+    tags: () => ['hosts', 'tags'] as const,
+  },
   settings: {
     root: () => ['settings'] as const,
     all: () => ['settings', 'all'] as const,

+ 71 - 0
frontend/src/components/form/RemarkTemplateField.tsx

@@ -0,0 +1,71 @@
+import { useRef } from 'react';
+import { Button, Input, Popover, Tooltip } from 'antd';
+import type { InputRef } from 'antd';
+import { CodeOutlined } from '@ant-design/icons';
+import { useTranslation } from 'react-i18next';
+
+import { hasRemarkTokens, previewRemark, wrapToken } from '@/lib/remark/remarkVariables';
+import RemarkVarPicker from './RemarkVarPicker';
+
+interface RemarkTemplateFieldProps {
+  // Injected by antd Form.Item:
+  value?: string;
+  onChange?: (value: string) => void;
+  maxLength?: number;
+  placeholder?: string;
+}
+
+/**
+ * RemarkTemplateField is a text input augmented with a {{VAR}} template picker
+ * (insert-at-caret) and a live, sample-based preview of the expanded result.
+ * Used for the global subscription Remark Template.
+ */
+export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder }: RemarkTemplateFieldProps) {
+  const { t } = useTranslation();
+  const inputRef = useRef<InputRef>(null);
+
+  function insertToken(token: string) {
+    const el = inputRef.current?.input;
+    const start = el?.selectionStart ?? value.length;
+    const end = el?.selectionEnd ?? value.length;
+    const insert = wrapToken(token);
+    const next = value.slice(0, start) + insert + value.slice(end);
+    onChange?.(maxLength ? next.slice(0, maxLength) : next);
+    const caret = start + insert.length;
+    // The controlled value updates next render; restore the caret after it.
+    requestAnimationFrame(() => {
+      el?.focus();
+      el?.setSelectionRange(caret, caret);
+    });
+  }
+
+  return (
+    <div>
+      <Input
+        ref={inputRef}
+        value={value}
+        maxLength={maxLength}
+        placeholder={placeholder}
+        onChange={(e) => onChange?.(e.target.value)}
+        addonAfter={
+          <Popover
+            content={<RemarkVarPicker onPick={insertToken} />}
+            trigger="click"
+            placement="bottomRight"
+            title={t('pages.hosts.remarkVars.title')}
+          >
+            <Tooltip title={t('pages.hosts.remarkVars.title')}>
+              <Button type="text" size="small" icon={<CodeOutlined />} style={{ margin: '0 -7px' }} />
+            </Tooltip>
+          </Popover>
+        }
+      />
+      {hasRemarkTokens(value) && (
+        <div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>
+          {t('pages.hosts.remarkVars.preview')}:{' '}
+          <span style={{ fontFamily: 'monospace' }}>{previewRemark(value) || '—'}</span>
+        </div>
+      )}
+    </div>
+  );
+}

+ 43 - 0
frontend/src/components/form/RemarkVarPicker.tsx

@@ -0,0 +1,43 @@
+import { Tag, Tooltip, Typography } from 'antd';
+import { useTranslation } from 'react-i18next';
+
+import { REMARK_VARIABLES, REMARK_VAR_GROUPS, wrapToken } from '@/lib/remark/remarkVariables';
+
+interface RemarkVarPickerProps {
+  /** Called with the bare token (e.g. "EMAIL") when a chip is clicked. */
+  onPick: (token: string) => void;
+}
+
+/**
+ * RemarkVarPicker is the grouped, tooltipped chip list of {{VAR}} tokens used by
+ * the global remark-template field.
+ */
+export default function RemarkVarPicker({ onPick }: RemarkVarPickerProps) {
+  const { t } = useTranslation();
+  return (
+    <div style={{ maxWidth: 460, maxHeight: 'min(70vh, 640px)', overflowY: 'auto' }}>
+      <Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
+        {t('pages.hosts.remarkVars.intro')}
+      </Typography.Paragraph>
+      {REMARK_VAR_GROUPS.map((group) => (
+        <div key={group} style={{ marginBottom: 8 }}>
+          <div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', opacity: 0.6, marginBottom: 4 }}>
+            {t(`pages.hosts.remarkVars.groups.${group}`)}
+          </div>
+          <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
+            {REMARK_VARIABLES.filter((v) => v.group === group).map((v) => (
+              <Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
+                <Tag
+                  onClick={() => onPick(v.token)}
+                  style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }}
+                >
+                  {wrapToken(v.token)}
+                </Tag>
+              </Tooltip>
+            ))}
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+}

+ 3 - 0
frontend/src/components/form/index.ts

@@ -2,3 +2,6 @@ export { default as DateTimePicker } from './DateTimePicker';
 export { default as JsonEditor } from './JsonEditor';
 export { default as HeaderMapEditor } from './HeaderMapEditor';
 export { default as SelectAllClearButtons } from './SelectAllClearButtons';
+export { default as RemarkTemplateField } from './RemarkTemplateField';
+export { default as RemarkVarPicker } from './RemarkVarPicker';
+export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';

+ 47 - 6
frontend/src/generated/examples.ts

@@ -27,7 +27,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "ldapVlessField": "",
     "pageSize": 0,
     "panelOutbound": "",
-    "remarkModel": "",
+    "remarkTemplate": "",
     "restartXrayOnClientDisable": false,
     "sessionMaxAge": 1,
     "smtpCpu": 0,
@@ -47,7 +47,6 @@ export const EXAMPLES: Record<string, unknown> = {
     "subClashRules": "",
     "subClashURI": "",
     "subDomain": "",
-    "subEmailInRemark": false,
     "subEnable": false,
     "subEnableRouting": false,
     "subEncrypt": false,
@@ -63,7 +62,6 @@ export const EXAMPLES: Record<string, unknown> = {
     "subPort": 1,
     "subProfileUrl": "",
     "subRoutingRules": "",
-    "subShowInfo": false,
     "subSupportUrl": "",
     "subThemeDir": "",
     "subTitle": "",
@@ -126,7 +124,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "ldapVlessField": "",
     "pageSize": 0,
     "panelOutbound": "",
-    "remarkModel": "",
+    "remarkTemplate": "",
     "restartXrayOnClientDisable": false,
     "sessionMaxAge": 1,
     "smtpCpu": 0,
@@ -146,7 +144,6 @@ export const EXAMPLES: Record<string, unknown> = {
     "subClashRules": "",
     "subClashURI": "",
     "subDomain": "",
-    "subEmailInRemark": false,
     "subEnable": false,
     "subEnableRouting": false,
     "subEncrypt": false,
@@ -162,7 +159,6 @@ export const EXAMPLES: Record<string, unknown> = {
     "subPort": 1,
     "subProfileUrl": "",
     "subRoutingRules": "",
-    "subShowInfo": false,
     "subSupportUrl": "",
     "subThemeDir": "",
     "subTitle": "",
@@ -277,6 +273,51 @@ export const EXAMPLES: Record<string, unknown> = {
     "id": 0,
     "seederName": ""
   },
+  "Host": {
+    "address": "cdn.example.com",
+    "allowInsecure": false,
+    "alpn": [
+      ""
+    ],
+    "createdAt": 0,
+    "echConfigList": "",
+    "excludeFromSubTypes": [
+      ""
+    ],
+    "finalMask": "",
+    "fingerprint": "",
+    "hostHeader": "",
+    "id": 1,
+    "inboundId": 1,
+    "isDisabled": false,
+    "isHidden": false,
+    "keepSniBlank": false,
+    "mihomoIpVersion": "dual",
+    "mihomoX25519": false,
+    "muxParams": null,
+    "nodeGuids": [
+      ""
+    ],
+    "overrideSniFromAddress": false,
+    "path": "",
+    "pinnedPeerCertSha256": [
+      ""
+    ],
+    "port": 8443,
+    "remark": "cdn-front",
+    "security": "same",
+    "serverDescription": "",
+    "shuffleHost": false,
+    "sni": "",
+    "sockoptParams": null,
+    "sortOrder": 0,
+    "tags": [
+      ""
+    ],
+    "updatedAt": 0,
+    "verifyPeerCertByName": false,
+    "vlessRoute": ""
+  },
   "Inbound": {
     "clientStats": [
       {

+ 181 - 26
frontend/src/generated/schemas.ts

@@ -98,8 +98,8 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
         "type": "string"
       },
-      "remarkModel": {
-        "description": "Remark model pattern for inbounds",
+      "remarkTemplate": {
+        "description": "Subscription remark template ({{VAR}} tokens) rendered per client",
         "type": "string"
       },
       "restartXrayOnClientDisable": {
@@ -184,10 +184,6 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Domain for subscription server validation",
         "type": "string"
       },
-      "subEmailInRemark": {
-        "description": "Include email in subscription remark/name",
-        "type": "boolean"
-      },
       "subEnable": {
         "description": "Subscription server settings\nEnable subscription server",
         "type": "boolean"
@@ -249,10 +245,6 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Subscription global routing rules (Only for Happ)",
         "type": "string"
       },
-      "subShowInfo": {
-        "description": "Show client information in subscriptions",
-        "type": "boolean"
-      },
       "subSupportUrl": {
         "description": "Subscription support URL",
         "type": "string"
@@ -398,7 +390,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "ldapVlessField",
       "pageSize",
       "panelOutbound",
-      "remarkModel",
+      "remarkTemplate",
       "restartXrayOnClientDisable",
       "sessionMaxAge",
       "smtpCpu",
@@ -418,7 +410,6 @@ export const SCHEMAS: Record<string, unknown> = {
       "subClashRules",
       "subClashURI",
       "subDomain",
-      "subEmailInRemark",
       "subEnable",
       "subEnableRouting",
       "subEncrypt",
@@ -434,7 +425,6 @@ export const SCHEMAS: Record<string, unknown> = {
       "subPort",
       "subProfileUrl",
       "subRoutingRules",
-      "subShowInfo",
       "subSupportUrl",
       "subThemeDir",
       "subTitle",
@@ -584,8 +574,8 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
         "type": "string"
       },
-      "remarkModel": {
-        "description": "Remark model pattern for inbounds",
+      "remarkTemplate": {
+        "description": "Subscription remark template ({{VAR}} tokens) rendered per client",
         "type": "string"
       },
       "restartXrayOnClientDisable": {
@@ -670,10 +660,6 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Domain for subscription server validation",
         "type": "string"
       },
-      "subEmailInRemark": {
-        "description": "Include email in subscription remark/name",
-        "type": "boolean"
-      },
       "subEnable": {
         "description": "Subscription server settings\nEnable subscription server",
         "type": "boolean"
@@ -735,10 +721,6 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Subscription global routing rules (Only for Happ)",
         "type": "string"
       },
-      "subShowInfo": {
-        "description": "Show client information in subscriptions",
-        "type": "boolean"
-      },
       "subSupportUrl": {
         "description": "Subscription support URL",
         "type": "string"
@@ -891,7 +873,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "ldapVlessField",
       "pageSize",
       "panelOutbound",
-      "remarkModel",
+      "remarkTemplate",
       "restartXrayOnClientDisable",
       "sessionMaxAge",
       "smtpCpu",
@@ -911,7 +893,6 @@ export const SCHEMAS: Record<string, unknown> = {
       "subClashRules",
       "subClashURI",
       "subDomain",
-      "subEmailInRemark",
       "subEnable",
       "subEnableRouting",
       "subEncrypt",
@@ -927,7 +908,6 @@ export const SCHEMAS: Record<string, unknown> = {
       "subPort",
       "subProfileUrl",
       "subRoutingRules",
-      "subShowInfo",
       "subSupportUrl",
       "subThemeDir",
       "subTitle",
@@ -1326,6 +1306,181 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "Host": {
+    "description": "Host is an override endpoint attached to an inbound: at subscription time each\nenabled host renders one share link/proxy with its own address/port/TLS/etc.,\nsuperseding the legacy externalProxy array. Free-JSON fields are stored as\ntext and parsed in the sub layer; slice fields use the json serializer.",
+    "properties": {
+      "address": {
+        "example": "cdn.example.com",
+        "type": "string"
+      },
+      "allowInsecure": {
+        "type": "boolean"
+      },
+      "alpn": {
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "createdAt": {
+        "type": "integer"
+      },
+      "echConfigList": {
+        "type": "string"
+      },
+      "excludeFromSubTypes": {
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "finalMask": {
+        "description": "FinalMask is a JSON object of xray finalmask masks (tcp/udp/quicParams),\nmerged into this host's JSON-subscription stream. Empty = no override.",
+        "type": "string"
+      },
+      "fingerprint": {
+        "type": "string"
+      },
+      "hostHeader": {
+        "type": "string"
+      },
+      "id": {
+        "example": 1,
+        "type": "integer"
+      },
+      "inboundId": {
+        "example": 1,
+        "type": "integer"
+      },
+      "isDisabled": {
+        "type": "boolean"
+      },
+      "isHidden": {
+        "type": "boolean"
+      },
+      "keepSniBlank": {
+        "type": "boolean"
+      },
+      "mihomoIpVersion": {
+        "enum": [
+          "dual",
+          "ipv4",
+          "ipv6",
+          "ipv4-prefer",
+          "ipv6-prefer"
+        ],
+        "type": "string"
+      },
+      "mihomoX25519": {
+        "type": "boolean"
+      },
+      "muxParams": {},
+      "nodeGuids": {
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "overrideSniFromAddress": {
+        "type": "boolean"
+      },
+      "path": {
+        "type": "string"
+      },
+      "pinnedPeerCertSha256": {
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "port": {
+        "example": 8443,
+        "maximum": 65535,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "remark": {
+        "example": "cdn-front",
+        "maxLength": 256,
+        "type": "string"
+      },
+      "security": {
+        "enum": [
+          "same",
+          "tls",
+          "none",
+          "reality"
+        ],
+        "example": "same",
+        "type": "string"
+      },
+      "serverDescription": {
+        "maxLength": 64,
+        "type": "string"
+      },
+      "shuffleHost": {
+        "type": "boolean"
+      },
+      "sni": {
+        "type": "string"
+      },
+      "sockoptParams": {},
+      "sortOrder": {
+        "type": "integer"
+      },
+      "tags": {
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "updatedAt": {
+        "type": "integer"
+      },
+      "verifyPeerCertByName": {
+        "type": "boolean"
+      },
+      "vlessRoute": {
+        "description": "VlessRoute is a free-form port/range routing spec (e.g. \"53,443,1000-2000\");\nstored verbatim, format-validated on the frontend.",
+        "type": "string"
+      }
+    },
+    "required": [
+      "address",
+      "allowInsecure",
+      "alpn",
+      "createdAt",
+      "echConfigList",
+      "excludeFromSubTypes",
+      "finalMask",
+      "fingerprint",
+      "hostHeader",
+      "id",
+      "inboundId",
+      "isDisabled",
+      "isHidden",
+      "keepSniBlank",
+      "mihomoIpVersion",
+      "mihomoX25519",
+      "muxParams",
+      "overrideSniFromAddress",
+      "path",
+      "pinnedPeerCertSha256",
+      "port",
+      "remark",
+      "security",
+      "serverDescription",
+      "shuffleHost",
+      "sni",
+      "sockoptParams",
+      "sortOrder",
+      "tags",
+      "updatedAt",
+      "verifyPeerCertByName",
+      "vlessRoute"
+    ],
+    "type": "object"
+  },
   "Inbound": {
     "description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.",
     "properties": {

+ 38 - 6
frontend/src/generated/types.ts

@@ -33,7 +33,7 @@ export interface AllSetting {
   ldapVlessField: string;
   pageSize: number;
   panelOutbound: string;
-  remarkModel: string;
+  remarkTemplate: string;
   restartXrayOnClientDisable: boolean;
   sessionMaxAge: number;
   smtpCpu: number;
@@ -53,7 +53,6 @@ export interface AllSetting {
   subClashRules: string;
   subClashURI: string;
   subDomain: string;
-  subEmailInRemark: boolean;
   subEnable: boolean;
   subEnableRouting: boolean;
   subEncrypt: boolean;
@@ -69,7 +68,6 @@ export interface AllSetting {
   subPort: number;
   subProfileUrl: string;
   subRoutingRules: string;
-  subShowInfo: boolean;
   subSupportUrl: string;
   subThemeDir: string;
   subTitle: string;
@@ -133,7 +131,7 @@ export interface AllSettingView {
   ldapVlessField: string;
   pageSize: number;
   panelOutbound: string;
-  remarkModel: string;
+  remarkTemplate: string;
   restartXrayOnClientDisable: boolean;
   sessionMaxAge: number;
   smtpCpu: number;
@@ -153,7 +151,6 @@ export interface AllSettingView {
   subClashRules: string;
   subClashURI: string;
   subDomain: string;
-  subEmailInRemark: boolean;
   subEnable: boolean;
   subEnableRouting: boolean;
   subEncrypt: boolean;
@@ -169,7 +166,6 @@ export interface AllSettingView {
   subPort: number;
   subProfileUrl: string;
   subRoutingRules: string;
-  subShowInfo: boolean;
   subSupportUrl: string;
   subThemeDir: string;
   subTitle: string;
@@ -294,6 +290,42 @@ export interface HistoryOfSeeders {
   seederName: string;
 }
 
+export interface Host {
+  address: string;
+  allowInsecure: boolean;
+  alpn: string[];
+  createdAt: number;
+  echConfigList: string;
+  excludeFromSubTypes: string[];
+  finalMask: string;
+  fingerprint: string;
+  hostHeader: string;
+  id: number;
+  inboundId: number;
+  isDisabled: boolean;
+  isHidden: boolean;
+  keepSniBlank: boolean;
+  mihomoIpVersion: string;
+  mihomoX25519: boolean;
+  muxParams: unknown;
+  nodeGuids?: string[];
+  overrideSniFromAddress: boolean;
+  path: string;
+  pinnedPeerCertSha256: string[];
+  port: number;
+  remark: string;
+  security: string;
+  serverDescription: string;
+  shuffleHost: boolean;
+  sni: string;
+  sockoptParams: unknown;
+  sortOrder: number;
+  tags: string[];
+  updatedAt: number;
+  verifyPeerCertByName: boolean;
+  vlessRoute: string;
+}
+
 export interface Inbound {
   clientStats: ClientTraffic[];
   down: number;

+ 39 - 6
frontend/src/generated/zod.ts

@@ -45,7 +45,7 @@ export const AllSettingSchema = z.object({
   ldapVlessField: z.string(),
   pageSize: z.number().int().min(0).max(1000),
   panelOutbound: z.string(),
-  remarkModel: z.string(),
+  remarkTemplate: z.string(),
   restartXrayOnClientDisable: z.boolean(),
   sessionMaxAge: z.number().int().min(1).max(525600),
   smtpCpu: z.number().int().min(0).max(100),
@@ -65,7 +65,6 @@ export const AllSettingSchema = z.object({
   subClashRules: z.string(),
   subClashURI: z.string(),
   subDomain: z.string(),
-  subEmailInRemark: z.boolean(),
   subEnable: z.boolean(),
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
@@ -81,7 +80,6 @@ export const AllSettingSchema = z.object({
   subPort: z.number().int().min(1).max(65535),
   subProfileUrl: z.string(),
   subRoutingRules: z.string(),
-  subShowInfo: z.boolean(),
   subSupportUrl: z.string(),
   subThemeDir: z.string(),
   subTitle: z.string(),
@@ -146,7 +144,7 @@ export const AllSettingViewSchema = z.object({
   ldapVlessField: z.string(),
   pageSize: z.number().int().min(0).max(1000),
   panelOutbound: z.string(),
-  remarkModel: z.string(),
+  remarkTemplate: z.string(),
   restartXrayOnClientDisable: z.boolean(),
   sessionMaxAge: z.number().int().min(1).max(525600),
   smtpCpu: z.number().int().min(0).max(100),
@@ -166,7 +164,6 @@ export const AllSettingViewSchema = z.object({
   subClashRules: z.string(),
   subClashURI: z.string(),
   subDomain: z.string(),
-  subEmailInRemark: z.boolean(),
   subEnable: z.boolean(),
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
@@ -182,7 +179,6 @@ export const AllSettingViewSchema = z.object({
   subPort: z.number().int().min(1).max(65535),
   subProfileUrl: z.string(),
   subRoutingRules: z.string(),
-  subShowInfo: z.boolean(),
   subSupportUrl: z.string(),
   subThemeDir: z.string(),
   subTitle: z.string(),
@@ -317,6 +313,43 @@ export const HistoryOfSeedersSchema = z.object({
 });
 export type HistoryOfSeeders = z.infer<typeof HistoryOfSeedersSchema>;
 
+export const HostSchema = z.object({
+  address: z.string(),
+  allowInsecure: z.boolean(),
+  alpn: z.array(z.string()),
+  createdAt: z.number().int(),
+  echConfigList: z.string(),
+  excludeFromSubTypes: z.array(z.string()),
+  finalMask: z.string(),
+  fingerprint: z.string(),
+  hostHeader: z.string(),
+  id: z.number().int(),
+  inboundId: z.number().int(),
+  isDisabled: z.boolean(),
+  isHidden: z.boolean(),
+  keepSniBlank: z.boolean(),
+  mihomoIpVersion: z.enum(['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer']),
+  mihomoX25519: z.boolean(),
+  muxParams: z.unknown(),
+  nodeGuids: z.array(z.string()).optional(),
+  overrideSniFromAddress: z.boolean(),
+  path: z.string(),
+  pinnedPeerCertSha256: z.array(z.string()),
+  port: z.number().int().min(0).max(65535),
+  remark: z.string().max(256),
+  security: z.enum(['same', 'tls', 'none', 'reality']),
+  serverDescription: z.string().max(64),
+  shuffleHost: z.boolean(),
+  sni: z.string(),
+  sockoptParams: z.unknown(),
+  sortOrder: z.number().int(),
+  tags: z.array(z.string()),
+  updatedAt: z.number().int(),
+  verifyPeerCertByName: z.boolean(),
+  vlessRoute: z.string(),
+});
+export type Host = z.infer<typeof HostSchema>;
+
 export const InboundSchema = z.object({
   clientStats: z.array(z.lazy(() => ClientTrafficSchema)),
   down: z.number().int(),

+ 6 - 3
frontend/src/layouts/AppSidebar.tsx

@@ -12,7 +12,9 @@ import {
   CodeOutlined,
   DashboardOutlined,
   DatabaseOutlined,
+  ExportOutlined,
   GithubOutlined,
+  GlobalOutlined,
   HeartOutlined,
   ImportOutlined,
   LogoutOutlined,
@@ -28,7 +30,6 @@ import {
   TagsOutlined,
   TeamOutlined,
   ToolOutlined,
-  UploadOutlined,
 } from '@ant-design/icons';
 
 import { HttpUtil } from '@/utils';
@@ -41,7 +42,7 @@ const DONATE_URL = 'https://donate.sanaei.dev/';
 const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
 const LOGOUT_KEY = '__logout__';
 
-type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs' | 'outbound';
+type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound';
 
 const iconByName: Record<IconName, ComponentType> = {
   dashboard: DashboardOutlined,
@@ -51,9 +52,10 @@ const iconByName: Record<IconName, ComponentType> = {
   setting: SettingOutlined,
   tool: ToolOutlined,
   cluster: ClusterOutlined,
+  hosts: GlobalOutlined,
   logout: LogoutOutlined,
   apidocs: ApiOutlined,
-  outbound: UploadOutlined,
+  outbound: ExportOutlined,
 };
 
 function readCollapsed(): boolean {
@@ -139,6 +141,7 @@ export default function AppSidebar() {
     { key: '/clients', icon: 'team', title: t('menu.clients') },
     { key: '/groups', icon: 'groups', title: t('menu.groups') },
     { key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
+    { key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
     { key: '/xray#outbound', icon: 'outbound', title: t('pages.xray.Outbounds') },
     { key: '/settings', icon: 'setting', title: t('menu.settings') },
     { key: '/xray', icon: 'tool', title: t('menu.xray') },

+ 50 - 0
frontend/src/lib/hosts/host-link.ts

@@ -0,0 +1,50 @@
+import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
+import type { HostFormValues } from '@/schemas/api/host';
+
+// The subset of a host that affects its share link. Mirrors the fields the
+// backend's hostToExternalProxyMap reads.
+export type HostLinkInput = Pick<
+  HostFormValues,
+  | 'security'
+  | 'address'
+  | 'port'
+  | 'remark'
+  | 'sni'
+  | 'alpn'
+  | 'fingerprint'
+  | 'pinnedPeerCertSha256'
+  | 'echConfigList'
+  | 'overrideSniFromAddress'
+  | 'keepSniBlank'
+>;
+
+// hostToExternalProxyEntry projects a host onto the ExternalProxyEntry shape the
+// share-link preview generators already understand — the frontend mirror of the
+// backend's hostToExternalProxyMap. security "reality"/"same" keep the inbound's
+// base TLS (forceTls "same"); the preview falls back to port 443 when the host
+// inherits the inbound port (port 0).
+export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntry {
+  const forceTls = host.security === 'tls' || host.security === 'none' ? host.security : 'same';
+
+  let sni: string | undefined;
+  if (host.keepSniBlank) {
+    sni = undefined;
+  } else if (host.overrideSniFromAddress) {
+    sni = host.address || undefined;
+  } else {
+    sni = host.sni || undefined;
+  }
+
+  return {
+    forceTls,
+    dest: host.address || '',
+    port: host.port && host.port > 0 ? host.port : 443,
+    remark: host.remark || '',
+    sni,
+    fingerprint: host.fingerprint,
+    alpn: host.alpn && host.alpn.length > 0 ? host.alpn : undefined,
+    pinnedPeerCertSha256:
+      host.pinnedPeerCertSha256 && host.pinnedPeerCertSha256.length > 0 ? host.pinnedPeerCertSha256 : undefined,
+    echConfigList: host.echConfigList || undefined,
+  };
+}

+ 70 - 0
frontend/src/lib/remark/remarkVariables.ts

@@ -0,0 +1,70 @@
+// Template variables an operator can embed in a Host's Remark. At subscription
+// time the backend (internal/sub/remark_vars.go) substitutes each {{TOKEN}}
+// per client. This file is the single frontend source of truth for the picker
+// UI and the live preview — keep the token list in sync with remark_vars.go.
+
+export type RemarkVarGroup = 'client' | 'traffic' | 'time';
+
+export interface RemarkVar {
+  /** Bare token name, e.g. "TRAFFIC_LEFT" (rendered as {{TRAFFIC_LEFT}}). */
+  token: string;
+  group: RemarkVarGroup;
+  /** Example value used only for the form's live preview. */
+  sample: string;
+}
+
+export const REMARK_VAR_GROUPS: RemarkVarGroup[] = ['client', 'traffic', 'time'];
+
+export const REMARK_VARIABLES: RemarkVar[] = [
+  // Client identity
+  { token: 'EMAIL', group: 'client', sample: 'john' },
+  { token: 'INBOUND', group: 'client', sample: 'Germany' },
+  { token: 'HOST', group: 'client', sample: 'CDN' },
+  { token: 'ID', group: 'client', sample: '3f2a9c1b-aaaa-bbbb-cccc-1234567890ab' },
+  { token: 'SHORT_ID', group: 'client', sample: '3f2a9c1b' },
+  { token: 'TELEGRAM_ID', group: 'client', sample: '123456789' },
+  { token: 'SUB_ID', group: 'client', sample: 'subABC' },
+  { token: 'COMMENT', group: 'client', sample: 'vip' },
+  // Traffic
+  { token: 'TRAFFIC_USED', group: 'traffic', sample: '8.40GB' },
+  { token: 'TRAFFIC_LEFT', group: 'traffic', sample: '41.60GB' },
+  { token: 'TRAFFIC_TOTAL', group: 'traffic', sample: '50.00GB' },
+  { token: 'TRAFFIC_USED_BYTES', group: 'traffic', sample: '9019431321' },
+  { token: 'TRAFFIC_LEFT_BYTES', group: 'traffic', sample: '44667656679' },
+  { token: 'TRAFFIC_TOTAL_BYTES', group: 'traffic', sample: '53687091200' },
+  { token: 'UP', group: 'traffic', sample: '5.20GB' },
+  { token: 'DOWN', group: 'traffic', sample: '3.20GB' },
+  // Time / status
+  { token: 'STATUS', group: 'time', sample: 'active' },
+  { token: 'DAYS_LEFT', group: 'time', sample: '12' },
+  { token: 'EXPIRE_DATE', group: 'time', sample: '2026-09-01' },
+  { token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' },
+  { token: 'CREATED_UNIX', group: 'time', sample: '1700000000' },
+  { token: 'RESET_DAYS', group: 'time', sample: '30' },
+];
+
+const SAMPLE_BY_TOKEN: Record<string, string> = Object.fromEntries(
+  REMARK_VARIABLES.map((v) => [v.token, v.sample]),
+);
+
+const TOKEN_RE = /\{\{([A-Z_]+)\}\}/g;
+
+/** wrapToken("EMAIL") → "{{EMAIL}}". */
+export function wrapToken(token: string): string {
+  return `{{${token}}}`;
+}
+
+/** Whether a remark string uses any {{VAR}} token at all. */
+export function hasRemarkTokens(template: string): boolean {
+  return template.includes('{{');
+}
+
+/**
+ * previewRemark renders a template against the sample values, mirroring the
+ * backend substitution closely enough for an at-a-glance preview. Unknown
+ * tokens collapse to empty, just like the server.
+ */
+export function previewRemark(template: string): string {
+  if (!hasRemarkTokens(template)) return template;
+  return template.replace(TOKEN_RE, (_m, tok: string) => SAMPLE_BY_TOKEN[tok] ?? '');
+}

+ 76 - 0
frontend/src/lib/xray/forms/transport/CustomSockoptList.tsx

@@ -0,0 +1,76 @@
+import { Button, Divider, Form, Input, Select } from 'antd';
+import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
+import { useTranslation } from 'react-i18next';
+import type { NamePath } from 'antd/es/form/interface';
+
+// Editor for sockopt.customSockopt — a list of raw setsockopt() options. Each
+// entry is rendered as a titled group of labeled fields (system / level / opt /
+// type / value) instead of one cramped inline row, so it reads like the rest of
+// the sockopt form. Shared by the inbound and outbound (and host) sockopt forms.
+// Ref: https://xtls.github.io/config/transports/sockopt.html#sockoptobject
+
+const SYSTEM_OPTIONS = [
+  { value: 'linux', label: 'linux' },
+  { value: 'windows', label: 'windows' },
+  { value: 'darwin', label: 'darwin' },
+];
+
+const TYPE_OPTIONS = [
+  { value: 'int', label: 'int' },
+  { value: 'str', label: 'str' },
+];
+
+interface CustomSockoptListProps {
+  name?: NamePath;
+}
+
+export default function CustomSockoptList({
+  name = ['streamSettings', 'sockopt', 'customSockopt'],
+}: CustomSockoptListProps) {
+  const { t } = useTranslation();
+  return (
+    <Form.List name={name}>
+      {(fields, { add, remove }) => (
+        <>
+          <Form.Item label={t('pages.inbounds.form.customSockopt')}>
+            <Button
+              type="dashed"
+              size="small"
+              icon={<PlusOutlined />}
+              onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
+            >
+              {t('pages.inbounds.form.addCustomOption')}
+            </Button>
+          </Form.Item>
+          {fields.map((field, idx) => (
+            <div key={field.key}>
+              <Divider plain style={{ margin: '4px 0 8px' }}>
+                {t('pages.inbounds.form.customSockopt')} {idx + 1}
+                <DeleteOutlined
+                  className="danger-icon"
+                  style={{ marginInlineStart: 8 }}
+                  onClick={() => remove(field.name)}
+                />
+              </Divider>
+              <Form.Item label="System" name={[field.name, 'system']}>
+                <Select placeholder="all" allowClear options={SYSTEM_OPTIONS} />
+              </Form.Item>
+              <Form.Item label="Level" name={[field.name, 'level']}>
+                <Input placeholder="6 (SOL_TCP)" />
+              </Form.Item>
+              <Form.Item label="Opt" name={[field.name, 'opt']}>
+                <Input placeholder="decimal, e.g. 19" />
+              </Form.Item>
+              <Form.Item label="Type" name={[field.name, 'type']}>
+                <Select options={TYPE_OPTIONS} />
+              </Form.Item>
+              <Form.Item label="Value" name={[field.name, 'value']}>
+                <Input placeholder="value" />
+              </Form.Item>
+            </div>
+          ))}
+        </>
+      )}
+    </Form.List>
+  );
+}

+ 1 - 1
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -71,7 +71,7 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
       return { packets: '1-3', length: '100-200', delay: '', maxSplit: '' };
     case 'sudoku':
       return {
-        password: '', ascii: '', customTable: '', customTables: [''],
+        password: '', ascii: '', customTable: '', customTables: [],
         paddingMin: 0, paddingMax: 0,
       };
     case 'header-custom':

+ 13 - 28
frontend/src/lib/xray/inbound-link.ts

@@ -983,23 +983,19 @@ export interface GenAllLinksEntry {
 export interface GenAllLinksInput {
   inbound: Inbound;
   remark?: string;
-  remarkModel?: string;
   client: ClientShape;
   hostOverride?: string;
   fallbackHostname: string;
 }
 
-// Fans out a single client's link per externalProxy entry, or just one
-// link when there are no external proxies. remarkModel is a 4-char
-// string: first char is the separator, remaining chars pick which
-// pieces to compose into the per-link remark — 'i' = inbound remark,
-// 'e' = client email, 'o' = externalProxy remark. Defaults to '-io'
-// (dash-separated, inbound + email + proxy).
+// Fans out a single client's link per externalProxy entry, or just one link
+// when there are no external proxies. The panel copy/QR remark is the inbound
+// remark plus the externalProxy remark, dash-joined (the configurable
+// subscription remark model was removed; subscription output uses the template).
 export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
   const {
     inbound,
     remark = '',
-    remarkModel = '-io',
     client,
     hostOverride = '',
     fallbackHostname,
@@ -1007,17 +1003,9 @@ export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
 
   const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
   const port = inbound.port;
-  const separationChar = remarkModel.charAt(0);
-  const orderChars = remarkModel.slice(1);
-  const email = client.email ?? '';
-
-  const composeRemark = (proxyRemark: string): string => {
-    const orders: Record<string, string> = { i: remark, e: email, o: proxyRemark };
-    return orderChars.split('')
-      .map((c) => orders[c] ?? '')
-      .filter((x) => x.length > 0)
-      .join(separationChar);
-  };
+
+  const composeRemark = (proxyRemark: string): string =>
+    [remark, proxyRemark].filter((x) => x.length > 0).join('-');
 
   const externals = inbound.streamSettings?.externalProxy;
   if (!externals || externals.length === 0) {
@@ -1044,7 +1032,6 @@ export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
 export interface GenInboundLinksInput {
   inbound: Inbound;
   remark?: string;
-  remarkModel?: string;
   hostOverride?: string;
   fallbackHostname: string;
 }
@@ -1058,7 +1045,6 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
   const {
     inbound,
     remark = '',
-    remarkModel = '-io',
     hostOverride = '',
     fallbackHostname,
   } = input;
@@ -1067,7 +1053,7 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
   if (clients) {
     const links: string[] = [];
     for (const client of clients) {
-      const entries = genAllLinks({ inbound, remark, remarkModel, client, hostOverride, fallbackHostname });
+      const entries = genAllLinks({ inbound, remark, client, hostOverride, fallbackHostname });
       for (const e of entries) links.push(e.link);
     }
     return links.join('\r\n');
@@ -1076,7 +1062,7 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
     return genShadowsocksLink({ inbound, address: addr, port: inbound.port, forceTls: 'same', remark });
   }
   if (inbound.protocol === 'wireguard') {
-    return genWireguardConfigs({ inbound, remark, remarkModel, hostOverride, fallbackHostname });
+    return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname });
   }
   return '';
 }
@@ -1087,16 +1073,15 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
 export interface GenWireguardFanoutInput {
   inbound: Inbound;
   remark?: string;
-  remarkModel?: string;
   hostOverride?: string;
   fallbackHostname: string;
 }
 
 export function genWireguardLinks(input: GenWireguardFanoutInput): string {
-  const { inbound, remark = '', remarkModel = '-io', hostOverride = '', fallbackHostname } = input;
+  const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
   if (inbound.protocol !== 'wireguard') return '';
   const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
-  const sep = remarkModel.charAt(0);
+  const sep = '-';
   return inbound.settings.peers
     .map((p, i) => genWireguardLink({
       settings: inbound.settings as WireguardInboundSettings,
@@ -1109,10 +1094,10 @@ export function genWireguardLinks(input: GenWireguardFanoutInput): string {
 }
 
 export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
-  const { inbound, remark = '', remarkModel = '-io', hostOverride = '', fallbackHostname } = input;
+  const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
   if (inbound.protocol !== 'wireguard') return '';
   const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
-  const sep = remarkModel.charAt(0);
+  const sep = '-';
   return inbound.settings.peers
     .map((p, i) => genWireguardConfig({
       settings: inbound.settings as WireguardInboundSettings,

+ 10 - 23
frontend/src/lib/xray/link-label.tsx

@@ -47,29 +47,16 @@ const TRANSPORT_COLOR = 'gold';
 
 const TAG_STYLE = { marginInlineEnd: 0, fontWeight: 600, letterSpacing: '0.3px' };
 
-/* Strip the client email and the optional traffic/expiry decorations the
-   panel appends to a remark (e.g. "5.23GB📊", "30D⏳", "⛔️N/A") together
-   with any separator chars left dangling, so the label shows just the
-   inbound remark. The email is known from the client record, so it can be
-   removed even though its position in the composed remark depends on the
-   panel's remark-model settings. */
-function cleanRemark(remark: string, email: string): string {
-  let r = remark
-    .replace(/⛔️?N\/A/gu, '')
-    .replace(/[0-9][0-9A-Za-z.,]*[📊⏳]/gu, '');
-  if (email) {
-    const esc = email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-    r = r.replace(new RegExp(`[\\s\\-_.|,@]*${esc}`, 'g'), '');
-  }
-  return r.replace(/^[\s\-_.|,@]+|[\s\-_.|,@]+$/gu, '').trim();
-}
+/* Pull protocol, transport, security plus the remark and port out of a share
+   link. vless/trojan carry network+security as `type`/`security` query params
+   and the remark in the URL hash; vmess packs them into the base64 JSON as
+   `net`/`tls`/`ps`/`port`. Returns null when the scheme is unknown or the
+   payload can't be parsed, so callers fall back to "Link N".
 
-/* Pull protocol, transport, security plus the inbound remark and port out
-   of a share link. vless/trojan carry network+security as `type`/`security`
-   query params and the remark in the URL hash; vmess packs them into the
-   base64 JSON as `net`/`tls`/`ps`/`port`. Returns null when the scheme is
-   unknown or the payload can't be parsed, so callers fall back to "Link N". */
-export function parseLinkParts(link: string, email = ''): LinkParts | null {
+   The remark is shown verbatim: the panel displays the subscription's clean
+   (name-only) remarks — the per-client traffic/expiry info is rendered only
+   into the body a client app imports, so there is nothing to strip here. */
+export function parseLinkParts(link: string): LinkParts | null {
   const trimmed = link.trim();
   const scheme = /^([a-z0-9]+):\/\//i.exec(trimmed)?.[1]?.toLowerCase() ?? '';
   if (!scheme) return null;
@@ -106,7 +93,7 @@ export function parseLinkParts(link: string, email = ''): LinkParts | null {
     protocol,
     network: network.toUpperCase(),
     security: security.toUpperCase(),
-    remark: cleanRemark(remark, email),
+    remark: remark.trim(),
     port,
   };
 }

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

@@ -13,7 +13,7 @@ export class AllSetting {
   pageSize = 25;
   expireDiff = 0;
   trafficDiff = 0;
-  remarkModel = '-io';
+  remarkTemplate = '{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
   datepicker: 'gregorian' | 'jalalian' = 'gregorian';
   tgBotEnable = false;
   tgBotToken = '';
@@ -48,8 +48,6 @@ export class AllSetting {
   subKeyFile = '';
   subUpdates = 12;
   subEncrypt = true;
-  subShowInfo = true;
-  subEmailInRemark = true;
   subURI = '';
   subJsonURI = '';
   subClashURI = '';

+ 93 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -907,6 +907,99 @@ export const sections: readonly Section[] = [
     ],
   },
 
+  {
+    id: 'hosts',
+    title: 'Hosts',
+    description:
+      'Per-inbound override endpoints. Each enabled host renders one extra subscription link/proxy with its own address/port/TLS, superseding the legacy externalProxy array. All endpoints under /panel/api/hosts.',
+    endpoints: [
+      {
+        method: 'GET',
+        path: '/panel/api/hosts/list',
+        summary: 'List every host across all inbounds, grouped by inbound then ordered by sort order.',
+        responseSchema: 'Host',
+        responseSchemaArray: true,
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/hosts/get/:id',
+        summary: 'Fetch a single host by ID.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
+        ],
+        responseSchema: 'Host',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/hosts/byInbound/:inboundId',
+        summary: "Fetch one inbound's hosts, ordered by sort order then id.",
+        params: [
+          { name: 'inboundId', in: 'path', type: 'number', desc: 'Inbound ID.' },
+        ],
+        responseSchema: 'Host',
+        responseSchemaArray: true,
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/hosts/tags',
+        summary: 'Distinct, sorted set of tags used across all hosts.',
+        response: '{\n  "success": true,\n  "obj": ["CDN", "EU", "FAST"]\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/hosts/add',
+        summary: 'Create a host on an inbound. inboundId and remark are required; security defaults to "same" (inherit the inbound).',
+        body: '{\n  "inboundId": 1,\n  "remark": "cdn-front",\n  "address": "cdn.example.com",\n  "port": 8443,\n  "security": "same",\n  "sni": "",\n  "tags": ["CDN"]\n}',
+        responseSchema: 'Host',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/hosts/update/:id',
+        summary: 'Replace a host’s content. The inbound and sort order are immutable here (use /reorder for ordering).',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
+        ],
+        body: '{\n  "inboundId": 1,\n  "remark": "cdn-front",\n  "address": "cdn.example.com",\n  "port": 8443,\n  "security": "same",\n  "sni": "",\n  "tags": ["CDN"]\n}',
+        responseSchema: 'Host',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/hosts/del/:id',
+        summary: 'Delete a host.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/hosts/setEnable/:id',
+        summary: 'Enable or disable a single host (disabled hosts are skipped in subscriptions).',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
+        ],
+        body: '{\n  "enable": true\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/hosts/reorder',
+        summary: 'Set host sort order by the position of each id in the array.',
+        body: '{\n  "ids": [3, 1, 2]\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/hosts/bulk/setEnable',
+        summary: 'Enable or disable many hosts in one call.',
+        body: '{\n  "ids": [1, 2, 3],\n  "enable": false\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/hosts/bulk/del',
+        summary: 'Delete many hosts in one call.',
+        body: '{\n  "ids": [1, 2, 3]\n}',
+      },
+    ],
+  },
+
   {
     id: 'backup',
     title: 'Backup',

+ 1 - 1
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -354,7 +354,7 @@ export default function ClientInfoModal({
               <>
                 <Divider>{t('pages.inbounds.copyLink')}</Divider>
                 {links.map((link, idx) => {
-                  const parts = parseLinkParts(link, client.email);
+                  const parts = parseLinkParts(link);
                   const fallback = `${t('pages.clients.link')} ${idx + 1}`;
                   const rowTitle = (parts && linkMetaText(parts)) || fallback;
                   const qrRemark = [parts?.remark, client.email].filter(Boolean).join('-') || rowTitle;

+ 1 - 1
frontend/src/pages/clients/ClientQrModal.tsx

@@ -92,7 +92,7 @@ export default function ClientQrModal({
       });
     }
     links.forEach((link, idx) => {
-      const parts = parseLinkParts(link, client?.email ?? '');
+      const parts = parseLinkParts(link);
       const meta = parts ? linkMetaText(parts) : '';
       const label: React.ReactNode = parts ? (
         <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>

+ 2 - 2
frontend/src/pages/clients/SubLinksModal.tsx

@@ -165,7 +165,7 @@ export default function SubLinksModal({
           <Alert
             type="warning"
             showIcon
-            message={t('pages.clients.subLinksDisabled')}
+            title={t('pages.clients.subLinksDisabled')}
             description={t('pages.clients.subLinksDisabledHint')}
             style={{ marginBottom: 12 }}
           />
@@ -174,7 +174,7 @@ export default function SubLinksModal({
           <Alert
             type="info"
             showIcon
-            message={t('pages.clients.subLinksEmpty')}
+            title={t('pages.clients.subLinksEmpty')}
             style={{ marginBottom: 12 }}
           />
         )}

+ 335 - 0
frontend/src/pages/hosts/HostFormModal.tsx

@@ -0,0 +1,335 @@
+import { useEffect, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd';
+import {
+  ProfileOutlined,
+  SafetyCertificateOutlined,
+  ControlOutlined,
+  NodeIndexOutlined,
+  SettingOutlined,
+  PartitionOutlined,
+  DeploymentUnitOutlined,
+  RocketOutlined,
+} from '@ant-design/icons';
+
+import type { HostRecord } from '@/api/queries/useHostsQuery';
+import type { HostFormValues } from '@/schemas/api/host';
+import type { InboundOption } from '@/schemas/client';
+import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
+import { useNodesQuery } from '@/api/queries/useNodesQuery';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { catTabLabel } from '@/pages/settings/catTabLabel';
+import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms';
+
+// inboundId is optional in the form so a new host starts unselected (the Select
+// shows its placeholder instead of 0); the required rule enforces it on submit.
+type FormShape = Omit<HostFormValues, 'isDisabled' | 'inboundId'> & { enable: boolean; inboundId?: number };
+
+interface HostFormModalProps {
+  open: boolean;
+  mode: 'add' | 'edit';
+  host: HostRecord | null;
+  inboundOptions: InboundOption[];
+  save: (payload: Partial<HostFormValues>) => Promise<{ success?: boolean; msg?: string } | undefined>;
+  onOpenChange: (open: boolean) => void;
+}
+
+const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
+
+function defaultsFor(host: HostRecord | null): FormShape {
+  return {
+    inboundId: host?.inboundId,
+    sortOrder: host?.sortOrder ?? 0,
+    remark: host?.remark ?? '',
+    serverDescription: host?.serverDescription ?? '',
+    enable: host ? !host.isDisabled : true,
+    isHidden: host?.isHidden ?? false,
+    tags: host?.tags ?? [],
+    address: host?.address ?? '',
+    port: host?.port ?? 0,
+    security: (host?.security as HostFormValues['security']) ?? 'same',
+    sni: host?.sni ?? '',
+    hostHeader: host?.hostHeader ?? '',
+    path: host?.path ?? '',
+    alpn: (host?.alpn as HostFormValues['alpn']) ?? [],
+    fingerprint: host?.fingerprint as HostFormValues['fingerprint'],
+    overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
+    keepSniBlank: host?.keepSniBlank ?? false,
+    pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
+    verifyPeerCertByName: host?.verifyPeerCertByName ?? false,
+    allowInsecure: host?.allowInsecure ?? false,
+    echConfigList: host?.echConfigList ?? '',
+    muxParams: asString(host?.muxParams),
+    sockoptParams: asString(host?.sockoptParams),
+    finalMask: host?.finalMask ?? '',
+    vlessRoute: host?.vlessRoute ?? '',
+    excludeFromSubTypes: (host?.excludeFromSubTypes as HostFormValues['excludeFromSubTypes']) ?? [],
+    nodeGuids: host?.nodeGuids ?? [],
+    mihomoIpVersion: host?.mihomoIpVersion as HostFormValues['mihomoIpVersion'],
+    mihomoX25519: host?.mihomoX25519 ?? false,
+    shuffleHost: host?.shuffleHost ?? false,
+  };
+}
+
+export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) {
+  const { t } = useTranslation();
+  const { isMobile } = useMediaQuery();
+  const [form] = Form.useForm<FormShape>();
+
+  // Drive conditional field visibility off the selected security, like the
+  // legacy externalProxy form: same/none inherit fully and hide every TLS/cert
+  // field; reality shows only the reality-relevant subset (its keys are
+  // inherited from the inbound); tls shows the full TLS override set.
+  const security = (Form.useWatch('security', form) ?? 'same') as string;
+  const showTls = security === 'tls' || security === 'reality';
+  const showTlsExtras = security === 'tls';
+
+  useEffect(() => {
+    if (open) form.setFieldsValue(defaultsFor(host));
+  }, [open, host, form]);
+
+  const { nodes } = useNodesQuery();
+
+  const inboundSelectOptions = useMemo(
+    () => inboundOptions.map((ib) => ({
+      value: ib.id,
+      label: ib.remark || ib.tag || `#${ib.id}`,
+    })),
+    [inboundOptions],
+  );
+
+  const nodeSelectOptions = useMemo(
+    () => nodes
+      .filter((n) => n.guid)
+      .map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })),
+    [nodes],
+  );
+
+  const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []);
+  const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
+
+  const onOk = async () => {
+    let values: FormShape;
+    try {
+      values = await form.validateFields();
+    } catch {
+      return;
+    }
+    const { enable, ...rest } = values;
+    const payload: Partial<HostFormValues> = { ...rest, isDisabled: !enable };
+    const res = await save(payload);
+    if (res?.success) {
+      message.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'));
+      onOpenChange(false);
+    } else if (res?.msg) {
+      message.error(res.msg);
+    }
+  };
+
+  return (
+    <Modal
+      open={open}
+      title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
+      onOk={onOk}
+      onCancel={() => onOpenChange(false)}
+      okText={t('save')}
+      cancelText={t('cancel')}
+      destroyOnHidden
+      width={isMobile ? '95vw' : 760}
+      styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
+    >
+      <Form
+        form={form}
+        colon={false}
+        labelCol={{ sm: { span: 8 } }}
+        wrapperCol={{ sm: { span: 14 } }}
+        labelWrap
+        initialValues={defaultsFor(host)}
+        preserve={false}
+      >
+        <Tabs
+          defaultActiveKey="basic"
+          items={[
+            {
+              key: 'basic',
+              forceRender: true,
+              label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
+              children: (
+                <>
+                  <Form.Item name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={[{ required: true, max: 256 }]}>
+                    <Input maxLength={256} />
+                  </Form.Item>
+                  <Form.Item name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
+                    <Input maxLength={64} />
+                  </Form.Item>
+                  <Form.Item name="inboundId" label={t('pages.hosts.fields.inbound')} rules={[{ required: true }]}>
+                    <Select
+                      options={inboundSelectOptions}
+                      showSearch
+                      optionFilterProp="label"
+                      disabled={mode === 'edit'}
+                      placeholder={t('pages.hosts.selectInbound')}
+                    />
+                  </Form.Item>
+                  <Form.Item name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}>
+                    <Input placeholder="cdn.example.com" />
+                  </Form.Item>
+                  <Form.Item name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
+                    <InputNumber min={0} max={65535} />
+                  </Form.Item>
+                  <Form.Item name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
+                    <Select mode="tags" allowClear tokenSeparators={[',']} />
+                  </Form.Item>
+                  <Form.Item name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
+                    <Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
+                  </Form.Item>
+                  <Form.Item name="enable" label={t('pages.hosts.fields.enable')} valuePropName="checked">
+                    <Switch />
+                  </Form.Item>
+                </>
+              ),
+            },
+            {
+              key: 'security',
+              forceRender: true,
+              label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
+              children: (
+                <>
+                  <Form.Item name="security" label={t('pages.hosts.fields.security')}>
+                    <Select
+                      options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
+                    />
+                  </Form.Item>
+                  {showTls && (
+                    <>
+                      <Form.Item name="sni" label={t('pages.hosts.fields.sni')}>
+                        <Input />
+                      </Form.Item>
+                      <Form.Item name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valuePropName="checked">
+                        <Switch />
+                      </Form.Item>
+                      <Form.Item name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valuePropName="checked">
+                        <Switch />
+                      </Form.Item>
+                      <Form.Item name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
+                        <Select allowClear options={fpOptions} />
+                      </Form.Item>
+                    </>
+                  )}
+                  {showTlsExtras && (
+                    <>
+                      <Form.Item name="alpn" label={t('pages.hosts.fields.alpn')}>
+                        <Select mode="multiple" allowClear options={alpnOptions} />
+                      </Form.Item>
+                      <Form.Item name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
+                        <Select mode="tags" allowClear tokenSeparators={[',']} />
+                      </Form.Item>
+                      <Form.Item name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} valuePropName="checked">
+                        <Switch />
+                      </Form.Item>
+                      <Form.Item name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valuePropName="checked">
+                        <Switch />
+                      </Form.Item>
+                      <Form.Item name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
+                        <Input.TextArea rows={2} />
+                      </Form.Item>
+                    </>
+                  )}
+                </>
+              ),
+            },
+            {
+              key: 'advanced',
+              forceRender: true,
+              label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
+              children: (
+                <Tabs
+                  size="small"
+                  defaultActiveKey="adv-general"
+                  items={[
+                    {
+                      key: 'adv-general',
+                      forceRender: true,
+                      label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
+                      children: (
+                        <>
+                          <Form.Item name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
+                            <Input />
+                          </Form.Item>
+                          <Form.Item name="path" label={t('pages.hosts.fields.path')}>
+                            <Input />
+                          </Form.Item>
+                          <Form.Item name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
+                            <Input placeholder="53,443,1000-2000" />
+                          </Form.Item>
+                          <Form.Item name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
+                            <Select
+                              mode="multiple"
+                              allowClear
+                              options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
+                            />
+                          </Form.Item>
+                        </>
+                      ),
+                    },
+                    {
+                      key: 'adv-mux',
+                      forceRender: true,
+                      label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
+                      children: (
+                        <Form.Item name="muxParams" noStyle>
+                          <HostMuxForm />
+                        </Form.Item>
+                      ),
+                    },
+                    {
+                      key: 'adv-sockopt',
+                      forceRender: true,
+                      label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
+                      children: (
+                        <Form.Item name="sockoptParams" noStyle>
+                          <HostSockoptForm />
+                        </Form.Item>
+                      ),
+                    },
+                    {
+                      key: 'adv-finalmask',
+                      forceRender: true,
+                      label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
+                      children: (
+                        <Form.Item name="finalMask" noStyle>
+                          <HostFinalMaskForm />
+                        </Form.Item>
+                      ),
+                    },
+                  ]}
+                />
+              ),
+            },
+            {
+              key: 'clash',
+              forceRender: true,
+              label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
+              children: (
+                <>
+                  <Form.Item name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
+                    <Select
+                      allowClear
+                      options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
+                    />
+                  </Form.Item>
+                  <Form.Item name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valuePropName="checked">
+                    <Switch />
+                  </Form.Item>
+                  <Form.Item name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valuePropName="checked">
+                    <Switch />
+                  </Form.Item>
+                </>
+              ),
+            },
+          ]}
+        />
+      </Form>
+    </Modal>
+  );
+}

+ 58 - 0
frontend/src/pages/hosts/HostList.css

@@ -0,0 +1,58 @@
+.hosts-card {
+  width: 100%;
+}
+
+.host-remark-cell {
+  display: flex;
+  flex-direction: column;
+  line-height: 1.3;
+}
+
+.host-remark {
+  font-weight: 500;
+}
+
+.host-desc {
+  font-size: 0.82em;
+  color: var(--ant-color-text-secondary);
+}
+
+.host-endpoint {
+  font-family: var(--font-mono, monospace);
+  font-size: 0.92em;
+}
+
+.host-muted {
+  color: var(--ant-color-text-quaternary);
+}
+
+/* card-toolbar is shared with the Clients/Inbounds list cards, but its rules
+   live in a lazily-loaded page stylesheet — re-declare here so the Hosts page
+   renders correctly when opened directly. */
+.hosts-card .card-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  padding: 6px 0;
+}
+
+@media (min-width: 769px) and (max-width: 920px) {
+  .hosts-card .card-toolbar {
+    gap: 6px;
+  }
+}
+
+/* Empty-table state. The shared .card-empty rule otherwise lives only in the
+   lazily-loaded Clients/Inbounds/Nodes stylesheets, so a direct /hosts refresh
+   would render it unstyled (faint + uncentered) until another page is visited.
+   Re-declare it here so it's correct on first load. */
+.card-empty {
+  text-align: center;
+  color: var(--ant-color-text-secondary);
+  padding: 24px 12px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+}

+ 195 - 0
frontend/src/pages/hosts/HostList.tsx

@@ -0,0 +1,195 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Card, Space, Switch, Table, Tag, Tooltip } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import {
+  ArrowDownOutlined,
+  ArrowUpOutlined,
+  DeleteOutlined,
+  EditOutlined,
+  GlobalOutlined,
+  PlusOutlined,
+} from '@ant-design/icons';
+
+import type { HostRecord } from '@/api/queries/useHostsQuery';
+import type { InboundOption } from '@/schemas/client';
+import './HostList.css';
+
+interface HostListProps {
+  hosts: HostRecord[];
+  inboundOptions: InboundOption[];
+  loading?: boolean;
+  isMobile?: boolean;
+  selectedIds: number[];
+  onSelectionChange: (ids: number[]) => void;
+  onAdd: () => void;
+  onEdit: (host: HostRecord) => void;
+  onDelete: (host: HostRecord) => void;
+  onToggleEnable: (host: HostRecord, next: boolean) => void;
+  onMove: (host: HostRecord, dir: 'up' | 'down') => void;
+  onBulkEnable: (enable: boolean) => void;
+  onBulkDelete: () => void;
+}
+
+// Sorted by inbound then sort_order then id — the same order the subscription
+// renderer uses, so the list mirrors the emitted link order.
+function sortHosts(hosts: HostRecord[]): HostRecord[] {
+  return [...hosts].sort((a, b) => {
+    if (a.inboundId !== b.inboundId) return a.inboundId - b.inboundId;
+    const sa = a.sortOrder ?? 0;
+    const sb = b.sortOrder ?? 0;
+    if (sa !== sb) return sa - sb;
+    return a.id - b.id;
+  });
+}
+
+export default function HostList(props: HostListProps) {
+  const { t } = useTranslation();
+  const {
+    hosts, inboundOptions, loading, isMobile, selectedIds, onSelectionChange,
+    onAdd, onEdit, onDelete, onToggleEnable, onMove, onBulkEnable, onBulkDelete,
+  } = props;
+
+  const inboundLabel = useMemo(() => {
+    const map = new Map<number, string>();
+    for (const ib of inboundOptions) map.set(ib.id, ib.remark || ib.tag || `#${ib.id}`);
+    return map;
+  }, [inboundOptions]);
+
+  const sorted = useMemo(() => sortHosts(hosts), [hosts]);
+
+  // Move is bounded to neighbours within the same inbound (sort_order is per-inbound).
+  const movable = useMemo(() => {
+    const byInbound = new Map<number, number>();
+    const idxInGroup = new Map<number, number>();
+    const counters = new Map<number, number>();
+    for (const h of sorted) byInbound.set(h.inboundId, (byInbound.get(h.inboundId) ?? 0) + 1);
+    for (const h of sorted) {
+      const c = counters.get(h.inboundId) ?? 0;
+      idxInGroup.set(h.id, c);
+      counters.set(h.inboundId, c + 1);
+    }
+    return { byInbound, idxInGroup };
+  }, [sorted]);
+
+  // Column order requested: Actions, Enable, then the rest.
+  const columns: ColumnsType<HostRecord> = [
+    {
+      title: t('pages.hosts.fields.actions'),
+      key: 'actions',
+      width: 168,
+      render: (_, h) => {
+        const idx = movable.idxInGroup.get(h.id) ?? 0;
+        const count = movable.byInbound.get(h.inboundId) ?? 1;
+        return (
+          <Space size={2}>
+            <Tooltip title={t('pages.hosts.moveUp')}>
+              <Button size="small" type="text" icon={<ArrowUpOutlined />} disabled={idx === 0} onClick={() => onMove(h, 'up')} />
+            </Tooltip>
+            <Tooltip title={t('pages.hosts.moveDown')}>
+              <Button size="small" type="text" icon={<ArrowDownOutlined />} disabled={idx >= count - 1} onClick={() => onMove(h, 'down')} />
+            </Tooltip>
+            <Tooltip title={t('edit')}>
+              <Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(h)} />
+            </Tooltip>
+            <Tooltip title={t('delete')}>
+              <Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(h)} />
+            </Tooltip>
+          </Space>
+        );
+      },
+    },
+    {
+      title: t('pages.hosts.fields.enable'),
+      key: 'enable',
+      width: 90,
+      render: (_, h) => (
+        <Switch size="small" checked={!h.isDisabled} onChange={(next) => onToggleEnable(h, next)} />
+      ),
+    },
+    {
+      title: t('pages.hosts.fields.remark'),
+      dataIndex: 'remark',
+      key: 'remark',
+      render: (_, h) => (
+        <div className="host-remark-cell">
+          <span className="host-remark">{h.remark}</span>
+          {h.serverDescription ? <span className="host-desc">{h.serverDescription}</span> : null}
+        </div>
+      ),
+    },
+    {
+      title: t('pages.hosts.fields.endpoint'),
+      key: 'endpoint',
+      render: (_, h) => <span className="host-endpoint">{`${h.address || '—'}${h.port ? `:${h.port}` : ''}`}</span>,
+    },
+    {
+      title: t('pages.hosts.fields.inbound'),
+      key: 'inbound',
+      render: (_, h) => inboundLabel.get(h.inboundId) ?? `#${h.inboundId}`,
+    },
+    {
+      title: t('pages.hosts.fields.security'),
+      dataIndex: 'security',
+      key: 'security',
+      render: (security: string) => <Tag>{security || 'same'}</Tag>,
+    },
+    {
+      title: t('pages.hosts.fields.tags'),
+      key: 'tags',
+      render: (_, h) => (h.tags && h.tags.length > 0
+        ? <Space size={[0, 4]} wrap>{h.tags.map((tag) => <Tag key={tag} color="blue">{tag}</Tag>)}</Space>
+        : <span className="host-muted">—</span>),
+    },
+  ];
+
+  const toolbar = (
+    <div className="card-toolbar">
+      {selectedIds.length === 0 ? (
+        <Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
+          {!isMobile && t('pages.hosts.addHost')}
+        </Button>
+      ) : (
+        <>
+          <Tag
+            color="blue"
+            closable
+            onClose={() => onSelectionChange([])}
+            style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
+          >
+            {t('pages.hosts.selectedCount', { count: selectedIds.length })}
+          </Tag>
+          <Button onClick={() => onBulkEnable(true)}>{t('pages.hosts.bulkEnable')}</Button>
+          <Button onClick={() => onBulkEnable(false)}>{t('pages.hosts.bulkDisable')}</Button>
+          <Button danger icon={<DeleteOutlined />} onClick={onBulkDelete}>{t('pages.hosts.bulkDelete')}</Button>
+        </>
+      )}
+    </div>
+  );
+
+  return (
+    <Card size="small" hoverable title={toolbar} className="hosts-card">
+      <Table<HostRecord>
+        rowKey="id"
+        size="small"
+        loading={loading}
+        columns={columns}
+        dataSource={sorted}
+        pagination={false}
+        scroll={{ x: 'max-content' }}
+        rowSelection={{
+          selectedRowKeys: selectedIds,
+          onChange: (keys) => onSelectionChange(keys as number[]),
+        }}
+        locale={{
+          emptyText: (
+            <div className="card-empty">
+              <GlobalOutlined style={{ fontSize: 32, marginBottom: 8 }} />
+              <div>{t('noData')}</div>
+            </div>
+          ),
+        }}
+      />
+    </Card>
+  );
+}

+ 210 - 0
frontend/src/pages/hosts/HostsPage.tsx

@@ -0,0 +1,210 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Card, Col, ConfigProvider, Layout, Modal, Result, Row, Spin, Statistic, message } from 'antd';
+import { CheckCircleOutlined, GlobalOutlined, StopOutlined } from '@ant-design/icons';
+
+import { useTheme } from '@/hooks/useTheme';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { useHostsQuery, type HostRecord } from '@/api/queries/useHostsQuery';
+import { useHostMutations } from '@/api/queries/useHostMutations';
+import { useInboundOptions } from '@/api/queries/useInboundOptions';
+import AppSidebar from '@/layouts/AppSidebar';
+import { setMessageInstance } from '@/utils/messageBus';
+import type { HostFormValues } from '@/schemas/api/host';
+import HostList from './HostList';
+import HostFormModal from './HostFormModal';
+
+// Hosts for one inbound in render order — used to compute a reorder payload.
+function inboundHostsInOrder(hosts: HostRecord[], inboundId: number): HostRecord[] {
+  return hosts
+    .filter((h) => h.inboundId === inboundId)
+    .sort((a, b) => {
+      const sa = a.sortOrder ?? 0;
+      const sb = b.sortOrder ?? 0;
+      if (sa !== sb) return sa - sb;
+      return a.id - b.id;
+    });
+}
+
+export default function HostsPage() {
+  const { t } = useTranslation();
+  const { isDark, isUltra, antdThemeConfig } = useTheme();
+  const { isMobile } = useMediaQuery();
+  const [modal, modalContextHolder] = Modal.useModal();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
+
+  const { hosts, loading, fetched, fetchError, refetch } = useHostsQuery();
+  const { create, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } = useHostMutations();
+  const { data: inboundOptions = [] } = useInboundOptions();
+
+  const [formOpen, setFormOpen] = useState(false);
+  const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
+  const [formHost, setFormHost] = useState<HostRecord | null>(null);
+  const [selectedIds, setSelectedIds] = useState<number[]>([]);
+
+  const onAdd = useCallback(() => {
+    setFormMode('add');
+    setFormHost(null);
+    setFormOpen(true);
+  }, []);
+
+  const onEdit = useCallback((host: HostRecord) => {
+    setFormMode('edit');
+    setFormHost({ ...host });
+    setFormOpen(true);
+  }, []);
+
+  const onSave = useCallback(async (payload: Partial<HostFormValues>) => {
+    if (formMode === 'edit' && formHost?.id) {
+      return update(formHost.id, payload);
+    }
+    return create(payload);
+  }, [formMode, formHost, update, create]);
+
+  const onDelete = useCallback((host: HostRecord) => {
+    modal.confirm({
+      title: t('pages.hosts.deleteConfirmTitle', { name: host.remark }),
+      okText: t('delete'),
+      okType: 'danger',
+      cancelText: t('cancel'),
+      onOk: async () => {
+        const msg = await remove(host.id);
+        if (msg?.success) messageApi.success(t('pages.hosts.toasts.delete'));
+      },
+    });
+  }, [modal, t, remove, messageApi]);
+
+  const onToggleEnable = useCallback(async (host: HostRecord, next: boolean) => {
+    await setEnable(host.id, next);
+  }, [setEnable]);
+
+  const onMove = useCallback(async (host: HostRecord, dir: 'up' | 'down') => {
+    const group = inboundHostsInOrder(hosts, host.inboundId);
+    const idx = group.findIndex((h) => h.id === host.id);
+    const swapWith = dir === 'up' ? idx - 1 : idx + 1;
+    if (idx < 0 || swapWith < 0 || swapWith >= group.length) return;
+    const ids = group.map((h) => h.id);
+    [ids[idx], ids[swapWith]] = [ids[swapWith], ids[idx]];
+    await reorder(ids);
+  }, [hosts, reorder]);
+
+  const onBulkEnable = useCallback(async (enable: boolean) => {
+    if (selectedIds.length === 0) return;
+    const msg = await bulkSetEnable(selectedIds, enable);
+    if (msg?.success) setSelectedIds([]);
+  }, [selectedIds, bulkSetEnable]);
+
+  const onBulkDelete = useCallback(() => {
+    if (selectedIds.length === 0) return;
+    modal.confirm({
+      title: t('pages.hosts.bulkDeleteConfirm', { count: selectedIds.length }),
+      okText: t('delete'),
+      okType: 'danger',
+      cancelText: t('cancel'),
+      onOk: async () => {
+        const msg = await bulkDel(selectedIds);
+        if (msg?.success) {
+          messageApi.success(t('pages.hosts.toasts.delete'));
+          setSelectedIds([]);
+        }
+      },
+    });
+  }, [selectedIds, modal, t, bulkDel, messageApi]);
+
+  const summary = useMemo(() => {
+    const total = hosts.length;
+    const enabled = hosts.filter((h) => !h.isDisabled).length;
+    return { total, enabled, disabled: total - enabled };
+  }, [hosts]);
+
+  const pageClass = useMemo(() => {
+    const classes = ['hosts-page'];
+    if (isDark) classes.push('is-dark');
+    if (isUltra) classes.push('is-ultra');
+    return classes.join(' ');
+  }, [isDark, isUltra]);
+
+  return (
+    <ConfigProvider theme={antdThemeConfig}>
+      {messageContextHolder}
+      {modalContextHolder}
+      <Layout className={pageClass}>
+        <AppSidebar />
+        <Layout className="content-shell">
+          <Layout.Content id="content-layout" className="content-area">
+            <Spin spinning={!fetched} delay={200} size="large">
+              {!fetched ? (
+                <div className="loading-spacer" />
+              ) : fetchError ? (
+                <Result
+                  status="error"
+                  title={t('somethingWentWrong')}
+                  subTitle={fetchError}
+                  extra={<Button type="primary" loading={loading} onClick={() => refetch()}>{t('refresh')}</Button>}
+                />
+              ) : (
+                <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
+                  <Col span={24}>
+                    <Card size="small" hoverable className="summary-card">
+                      <Row gutter={[16, 12]}>
+                        <Col xs={8} sm={8} md={8}>
+                          <Statistic
+                            title={t('pages.hosts.summary.total')}
+                            value={String(summary.total)}
+                            prefix={<GlobalOutlined />}
+                          />
+                        </Col>
+                        <Col xs={8} sm={8} md={8}>
+                          <Statistic
+                            title={t('pages.hosts.summary.enabled')}
+                            value={String(summary.enabled)}
+                            prefix={<CheckCircleOutlined style={{ color: 'var(--ant-color-success)' }} />}
+                          />
+                        </Col>
+                        <Col xs={8} sm={8} md={8}>
+                          <Statistic
+                            title={t('pages.hosts.summary.disabled')}
+                            value={String(summary.disabled)}
+                            prefix={<StopOutlined style={{ color: 'var(--ant-color-text-quaternary)' }} />}
+                          />
+                        </Col>
+                      </Row>
+                    </Card>
+                  </Col>
+
+                  <Col span={24}>
+                    <HostList
+                      hosts={hosts}
+                      inboundOptions={inboundOptions}
+                      loading={loading}
+                      isMobile={isMobile}
+                      selectedIds={selectedIds}
+                      onSelectionChange={setSelectedIds}
+                      onAdd={onAdd}
+                      onEdit={onEdit}
+                      onDelete={onDelete}
+                      onToggleEnable={onToggleEnable}
+                      onMove={onMove}
+                      onBulkEnable={onBulkEnable}
+                      onBulkDelete={onBulkDelete}
+                    />
+                  </Col>
+                </Row>
+              )}
+            </Spin>
+          </Layout.Content>
+        </Layout>
+
+        <HostFormModal
+          open={formOpen}
+          mode={formMode}
+          host={formHost}
+          inboundOptions={inboundOptions}
+          save={onSave}
+          onOpenChange={setFormOpen}
+        />
+      </Layout>
+    </ConfigProvider>
+  );
+}

+ 55 - 0
frontend/src/pages/hosts/json-forms/HostFinalMaskForm.tsx

@@ -0,0 +1,55 @@
+import { useEffect, useRef, useState } from 'react';
+import { Form } from 'antd';
+
+import { FinalMaskForm } from '@/lib/xray/forms/transport';
+import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
+
+// Per-host Final Mask editor — same shape as the sub-JSON settings one
+// (SubJsonFinalMaskForm) but reused for a host: reads/writes the host's
+// finalMask JSON string. The masks are merged into this host's JSON stream.
+
+function hasValue(v: unknown): boolean {
+  if (v == null) return false;
+  if (Array.isArray(v)) return v.some(hasValue);
+  if (typeof v === 'object') return Object.values(v as Record<string, unknown>).some(hasValue);
+  if (typeof v === 'string') return v.length > 0;
+  return true;
+}
+
+function parseFinalMask(raw: string): FinalMaskStreamSettings {
+  try {
+    if (raw) return JSON.parse(raw) as FinalMaskStreamSettings;
+  } catch {
+    return { tcp: [], udp: [] };
+  }
+  return { tcp: [], udp: [] };
+}
+
+export default function HostFinalMaskForm({ value = '', onChange }: { value?: string; onChange?: (next: string) => void }) {
+  const [form] = Form.useForm();
+  const [initial] = useState(() => parseFinalMask(value));
+  const onChangeRef = useRef(onChange);
+  onChangeRef.current = onChange;
+
+  const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
+
+  useEffect(() => {
+    if (finalmask === undefined) return;
+    const next = hasValue(finalmask) ? JSON.stringify(finalmask) : '';
+    if (next !== value) onChangeRef.current?.(next);
+  }, [finalmask, value]);
+
+  return (
+    <Form
+      form={form}
+      component={false}
+      colon={false}
+      labelCol={{ sm: { span: 8 } }}
+      wrapperCol={{ sm: { span: 14 } }}
+      labelWrap
+      initialValues={{ finalmask: initial }}
+    >
+      <FinalMaskForm name="finalmask" network="" protocol="" form={form} showAll />
+    </Form>
+  );
+}

+ 25 - 0
frontend/src/pages/hosts/json-forms/HostMuxForm.tsx

@@ -0,0 +1,25 @@
+import { MuxForm } from '@/pages/xray/outbounds/transport';
+
+import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
+import { serializeOverride } from './helpers';
+
+// Mux override editor — reuses the outbound MuxForm (same fields as the sub-JSON
+// settings editor). Stored in the host's muxParams JSON string. Defaults match
+// the sub-JSON editor; the host stores '' (= inherit the inbound/global mux)
+// when the toggle is off, an explicit mux object when on.
+const DEFAULT_MUX = { enabled: false, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' };
+
+export default function HostMuxForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
+  return (
+    <OutboundSubtreeJsonForm
+      value={value}
+      onChange={onChange}
+      path={['mux']}
+      defaultSubtree={DEFAULT_MUX}
+      serialize={(mux) => ((mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '')}
+      // protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate;
+      // a host's mux override is protocol-agnostic and should always be editable.
+      render={(form) => <MuxForm form={form} protocol="vmess" network="tcp" />}
+    />
+  );
+}

+ 43 - 0
frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx

@@ -0,0 +1,43 @@
+import { SockoptForm } from '@/pages/xray/outbounds/transport';
+import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
+
+import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
+import { serializeOverride } from './helpers';
+
+// Sockopt override editor — reuses the outbound SockoptForm (which carries its
+// own enable Switch and writes streamSettings.sockopt). Serialized to the host's
+// sockoptParams JSON string.
+//
+// A host is the client/dialer side, so the inbound-only sockopt keys are dropped
+// from the output. Verified against xray-core transport/internet/sockopt_*.go:
+// only V6Only and the handler-level acceptProxyProtocol / trustedXForwardedFor
+// are inbound-only — tproxy (IP_TRANSPARENT) and keepalive/interface ARE applied
+// on the outbound/dialer socket, so they stay. The outbound form no longer shows
+// the inbound-only keys, but its default object still seeds them, so strip here.
+const INBOUND_ONLY_SOCKOPT = ['acceptProxyProtocol', 'V6Only', 'trustedXForwardedFor'];
+
+function serializeClientSockopt(sockopt: unknown): string {
+  if (!sockopt || typeof sockopt !== 'object') return serializeOverride(sockopt);
+  const copy = { ...(sockopt as Record<string, unknown>) };
+  for (const key of INBOUND_ONLY_SOCKOPT) delete copy[key];
+  return serializeOverride(copy);
+}
+
+export default function HostSockoptForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
+  // Populate the dialerProxy dropdown with the panel's outbound tags (a host can
+  // chain through one of the subscription's outbounds by tag). dialerProxy chains
+  // through a single outbound, so balancers (routing targets) are excluded — only
+  // the outbound group is used; blackhole is dropped too (chaining to it just
+  // drops the traffic).
+  const { data: tagGroups } = useOutboundTagGroups({ excludeBlackhole: true });
+  const outboundTags = tagGroups?.outbounds ?? [];
+  return (
+    <OutboundSubtreeJsonForm
+      value={value}
+      onChange={onChange}
+      path={['streamSettings', 'sockopt']}
+      serialize={serializeClientSockopt}
+      render={(form) => <SockoptForm form={form} outboundTags={outboundTags} />}
+    />
+  );
+}

+ 68 - 0
frontend/src/pages/hosts/json-forms/OutboundSubtreeJsonForm.tsx

@@ -0,0 +1,68 @@
+import { useEffect, useRef, useState, type ReactNode } from 'react';
+import { Form, type FormInstance } from 'antd';
+
+import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
+
+import { nestAtPath, parseJsonObject, serializeOverride } from './helpers';
+
+interface OutboundSubtreeJsonFormProps {
+  value?: string;
+  onChange?: (next: string) => void;
+  // Form path the inner form edits, e.g. ['streamSettings', 'sockopt'] or ['mux'].
+  path: (string | number)[];
+  // Renders the reused outbound form given this wrapper's own form instance.
+  render: (form: FormInstance<OutboundFormValues>) => ReactNode;
+  // Seeds the form when the stored value is empty, so toggling a section on
+  // pre-fills sensible defaults instead of blanks (used by Mux).
+  defaultSubtree?: Record<string, unknown>;
+  // Turns the edited subtree into the stored JSON string (default: prune empties).
+  // Mux overrides this to store '' (= inherit) when its enable flag is off.
+  serialize?: (subtree: unknown) => string;
+}
+
+// Hosts the reused outbound transport forms (which bind to fixed form paths)
+// inside an isolated antd Form, mirroring SubJsonFinalMaskForm: seed the form
+// from the JSON string, watch the edited subtree, and report a JSON string back
+// to the parent host form. component={false} avoids a nested <form> DOM node.
+export default function OutboundSubtreeJsonForm({
+  value = '',
+  onChange,
+  path,
+  render,
+  defaultSubtree,
+  serialize = serializeOverride,
+}: OutboundSubtreeJsonFormProps) {
+  const [form] = Form.useForm();
+  const [initial] = useState<Record<string, unknown>>(() => {
+    const parsed = parseJsonObject(value);
+    return Object.keys(parsed).length ? parsed : (defaultSubtree ?? {});
+  });
+  const onChangeRef = useRef(onChange);
+  onChangeRef.current = onChange;
+
+  const subtree = Form.useWatch(path, form);
+
+  useEffect(() => {
+    const next = serialize(subtree);
+    if (next !== value) onChangeRef.current?.(next);
+    // serialize is logically stable; re-run only when the edited subtree changes.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [subtree, value]);
+
+  const hasInitial = Object.keys(initial).length > 0;
+  const initialValues = nestAtPath(path, hasInitial ? initial : undefined);
+
+  return (
+    <Form
+      form={form}
+      component={false}
+      colon={false}
+      labelCol={{ sm: { span: 8 } }}
+      wrapperCol={{ sm: { span: 14 } }}
+      labelWrap
+      initialValues={initialValues}
+    >
+      {render(form as unknown as FormInstance<OutboundFormValues>)}
+    </Form>
+  );
+}

+ 50 - 0
frontend/src/pages/hosts/json-forms/helpers.ts

@@ -0,0 +1,50 @@
+// Shared helpers for the host's structured JSON-override editors. Each host
+// override is persisted as a JSON string (muxParams / sockoptParams /
+// finalMask); these convert between that string and the object the reused
+// outbound/sub-JSON forms edit.
+
+export function parseJsonObject(raw: string): Record<string, unknown> {
+  if (!raw) return {};
+  try {
+    const v = JSON.parse(raw);
+    return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
+  } catch {
+    return {};
+  }
+}
+
+// Recursively drop '', null, undefined, and empty arrays/objects so an override
+// stays sparse — only the keys the operator actually set are emitted and merged
+// into the inbound stream. 0 and false are kept (meaningful sockopt/mux values).
+export function pruneEmptyDeep(value: unknown): unknown {
+  if (Array.isArray(value)) {
+    const arr = value.map(pruneEmptyDeep).filter((v) => v !== undefined);
+    return arr.length ? arr : undefined;
+  }
+  if (value && typeof value === 'object') {
+    const out: Record<string, unknown> = {};
+    for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
+      const pv = pruneEmptyDeep(v);
+      if (pv !== undefined) out[k] = pv;
+    }
+    return Object.keys(out).length ? out : undefined;
+  }
+  if (value === '' || value === null) return undefined;
+  return value;
+}
+
+// Prune then stringify; an all-empty override serializes to '' (= no override).
+export function serializeOverride(value: unknown): string {
+  const pruned = pruneEmptyDeep(value);
+  return pruned === undefined ? '' : JSON.stringify(pruned);
+}
+
+// Build a nested object { a: { b: leaf } } from a form path ['a','b'] so the
+// inner form can be seeded with initialValues at the exact path it edits.
+export function nestAtPath(path: (string | number)[], leaf: unknown): Record<string, unknown> {
+  let acc: unknown = leaf;
+  for (let i = path.length - 1; i >= 0; i -= 1) {
+    acc = { [path[i]]: acc };
+  }
+  return acc as Record<string, unknown>;
+}

+ 3 - 0
frontend/src/pages/hosts/json-forms/index.ts

@@ -0,0 +1,3 @@
+export { default as HostMuxForm } from './HostMuxForm';
+export { default as HostSockoptForm } from './HostSockoptForm';
+export { default as HostFinalMaskForm } from './HostFinalMaskForm';

+ 2 - 7
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -90,7 +90,6 @@ export default function InboundsPage() {
     subSettings,
     tgBotEnable,
     ipLimitEnable,
-    remarkModel,
     refresh,
     hydrateInbound,
     applyTrafficEvent,
@@ -265,13 +264,12 @@ export default function InboundsPage() {
       content: genInboundLinks({
         inbound: inboundFromDb(projected),
         remark: projected.remark,
-        remarkModel,
         hostOverride: hostOverrideFor(dbInbound),
         fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
       }),
       fileName: projected.remark || 'inbound',
     });
-  }, [checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
+  }, [checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
 
   const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
     openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2), json: true });
@@ -303,13 +301,12 @@ export default function InboundsPage() {
       out.push(genInboundLinks({
         inbound: inboundFromDb(projected),
         remark: projected.remark,
-        remarkModel,
         hostOverride: hostOverrideFor(ib),
         fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
       }));
     }
     openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') });
-  }, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
+  }, [dbInbounds, hydrateInbound, checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
 
   const exportAllSubs = useCallback(async () => {
     const hydrated = await Promise.all(
@@ -658,7 +655,6 @@ export default function InboundsPage() {
             onClose={() => setInfoOpen(false)}
             dbInbound={infoDbInbound}
             clientIndex={infoClientIndex}
-            remarkModel={remarkModel}
             expireDiff={expireDiff}
             trafficDiff={trafficDiff}
             ipLimitEnable={ipLimitEnable}
@@ -674,7 +670,6 @@ export default function InboundsPage() {
             onClose={() => setQrOpen(false)}
             dbInbound={qrDbInbound}
             client={null}
-            remarkModel={remarkModel}
             nodeAddress={qrNodeAddress}
             subSettings={subSettings}
           />

+ 4 - 25
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -65,7 +65,6 @@ import {
   WireguardFields,
 } from './protocols';
 import {
-  ExternalProxyForm,
   GrpcForm,
   HttpUpgradeForm,
   KcpForm,
@@ -251,23 +250,6 @@ export default function InboundFormModal({
     onSecurityChange,
   } = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null });
 
-  const toggleExternalProxy = (on: boolean) => {
-    if (on) {
-      const port = (form.getFieldValue('port') as number) ?? 443;
-      form.setFieldValue(['streamSettings', 'externalProxy'], [{
-        forceTls: 'same',
-        dest: typeof window !== 'undefined' ? window.location.hostname : '',
-        port,
-        remark: '',
-        sni: '',
-        fingerprint: '',
-        alpn: [],
-        pinnedPeerCertSha256: [],
-      }]);
-    } else {
-      form.setFieldValue(['streamSettings', 'externalProxy'], []);
-    }
-  };
 
   const toggleSockopt = (on: boolean) => {
     if (on) {
@@ -703,7 +685,7 @@ export default function InboundFormModal({
             className="mt-12"
             type="info"
             showIcon
-            message={t('pages.inbounds.fallbacks.needsTls')}
+            title={t('pages.inbounds.fallbacks.needsTls')}
           />
         )}
     </>
@@ -811,12 +793,9 @@ export default function InboundFormModal({
         </>
       )}
 
-      {/* externalProxy only feeds client share links. Wireguard's per-peer
-          .conf fanout resolves its host elsewhere, and tunnel (dokodemo-door)
-          has no clients at all — the section is dead weight on both. */}
-      {protocol !== Protocols.WIREGUARD && protocol !== Protocols.TUNNEL && (
-        <ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
-      )}
+      {/* The legacy externalProxy section is replaced by the Hosts page; the
+          field is still parsed/rendered for backward compatibility but is no
+          longer editable here. */}
 
       <SockoptForm toggleSockopt={toggleSockopt} network={network as string} />
 

+ 0 - 209
frontend/src/pages/inbounds/form/transport/external-proxy.tsx

@@ -1,209 +0,0 @@
-import type { ReactNode } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
-import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
-
-import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
-
-import './external-proxy.css';
-
-const newEntry = () => ({
-  forceTls: 'same',
-  dest: '',
-  port: 443,
-  remark: '',
-  sni: '',
-  fingerprint: '',
-  alpn: [],
-  pinnedPeerCertSha256: [],
-  echConfigList: '',
-});
-
-function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
-  return (
-    <div className="ext-proxy-field">
-      <span className="ext-proxy-flabel">{label}</span>
-      {children}
-    </div>
-  );
-}
-
-export default function ExternalProxyForm({
-  toggleExternalProxy,
-}: {
-  toggleExternalProxy: (on: boolean) => void;
-}) {
-  const { t } = useTranslation();
-  const form = Form.useFormInstance();
-
-  const generateRandomPin = (name: number) => {
-    const bytes = new Uint8Array(32);
-    crypto.getRandomValues(bytes);
-    const hash = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
-    const path = ['streamSettings', 'externalProxy', name, 'pinnedPeerCertSha256'];
-    const current = (form.getFieldValue(path) as string[] | undefined) ?? [];
-    form.setFieldValue(path, [...current, hash]);
-  };
-
-  return (
-    <Form.Item
-      noStyle
-      shouldUpdate={(prev, curr) => {
-        const a = (prev.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy;
-        const b = (curr.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy;
-        return (Array.isArray(a) ? a.length : 0) !== (Array.isArray(b) ? b.length : 0);
-      }}
-    >
-      {({ getFieldValue }) => {
-        const arr = getFieldValue(['streamSettings', 'externalProxy']);
-        const on = Array.isArray(arr) && arr.length > 0;
-        return (
-          <>
-            <Form.Item label={t('pages.inbounds.form.externalProxy')}>
-              <Switch checked={on} onChange={toggleExternalProxy} />
-            </Form.Item>
-            {on && (
-              <Form.Item wrapperCol={{ span: 24 }}>
-                <Form.List name={['streamSettings', 'externalProxy']}>
-                  {(fields, { add, remove }) => (
-                    <>
-                      <div className="ext-proxy-list">
-                        {fields.map((field, idx) => (
-                          <div key={field.key} className="ext-proxy-card">
-                            <div className="ext-proxy-card__head">
-                              <span className="ext-proxy-card__title">#{idx + 1}</span>
-                              <Button
-                                size="small"
-                                type="text"
-                                danger
-                                icon={<DeleteOutlined />}
-                                onClick={() => remove(field.name)}
-                              />
-                            </div>
-                            <div className="ext-proxy-grid ext-proxy-grid--dest">
-                              <Field label={t('pages.inbounds.form.forceTls')}>
-                                <Form.Item name={[field.name, 'forceTls']} noStyle>
-                                  <Select
-                                    style={{ width: '100%' }}
-                                    options={[
-                                      { value: 'same', label: t('pages.inbounds.same') },
-                                      { value: 'none', label: t('none') },
-                                      { value: 'tls', label: 'TLS' },
-                                    ]}
-                                  />
-                                </Form.Item>
-                              </Field>
-                              <Field label={t('pages.inbounds.address')}>
-                                <Form.Item name={[field.name, 'dest']} noStyle>
-                                  <Input placeholder={t('pages.inbounds.address')} />
-                                </Form.Item>
-                              </Field>
-                              <Field label={t('pages.inbounds.port')}>
-                                <Form.Item name={[field.name, 'port']} noStyle>
-                                  <InputNumber style={{ width: '100%' }} min={1} max={65535} />
-                                </Form.Item>
-                              </Field>
-                            </div>
-                            <Field label={t('pages.inbounds.remark')}>
-                              <Form.Item name={[field.name, 'remark']} noStyle>
-                                <Input placeholder={t('pages.inbounds.remark')} />
-                              </Form.Item>
-                            </Field>
-                            <Form.Item
-                              noStyle
-                              shouldUpdate={(prev, curr) =>
-                                prev.streamSettings?.externalProxy?.[field.name]?.forceTls
-                                !== curr.streamSettings?.externalProxy?.[field.name]?.forceTls
-                              }
-                            >
-                              {({ getFieldValue }) => {
-                                const ft = getFieldValue([
-                                  'streamSettings', 'externalProxy', field.name, 'forceTls',
-                                ]);
-                                if (ft !== 'tls') return null;
-                                return (
-                                  <div className="ext-proxy-tls">
-                                    <div className="ext-proxy-grid ext-proxy-grid--tls">
-                                      <Field label="SNI">
-                                        <Form.Item name={[field.name, 'sni']} noStyle>
-                                          <Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
-                                        </Form.Item>
-                                      </Field>
-                                      <Field label={t('pages.inbounds.form.fingerprint')}>
-                                        <Form.Item name={[field.name, 'fingerprint']} noStyle>
-                                          <Select
-                                            style={{ width: '100%' }}
-                                            placeholder={t('pages.inbounds.form.fingerprint')}
-                                            options={[
-                                              { value: '', label: t('pages.inbounds.form.defaultOption') },
-                                              ...Object.values(UTLS_FINGERPRINT).map((fp) => ({
-                                                value: fp,
-                                                label: fp,
-                                              })),
-                                            ]}
-                                          />
-                                        </Form.Item>
-                                      </Field>
-                                      <Field label="ALPN">
-                                        <Form.Item name={[field.name, 'alpn']} noStyle>
-                                          <Select
-                                            mode="multiple"
-                                            style={{ width: '100%' }}
-                                            placeholder="ALPN"
-                                            options={Object.values(ALPN_OPTION).map((a) => ({
-                                              value: a,
-                                              label: a,
-                                            }))}
-                                          />
-                                        </Form.Item>
-                                      </Field>
-                                    </div>
-                                    <Field label={t('pages.inbounds.form.echConfig')}>
-                                      <Form.Item name={[field.name, 'echConfigList']} noStyle>
-                                        <Input placeholder={t('pages.inbounds.form.echConfig')} />
-                                      </Form.Item>
-                                    </Field>
-                                    <Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}>
-                                      <Space.Compact block>
-                                        <Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>
-                                          <Select
-                                            mode="tags"
-                                            tokenSeparators={[',', ' ']}
-                                            placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
-                                            style={{ width: 'calc(100% - 32px)' }}
-                                          />
-                                        </Form.Item>
-                                        <Button
-                                          icon={<ReloadOutlined />}
-                                          onClick={() => generateRandomPin(field.name)}
-                                          title={t('pages.inbounds.form.generateRandomPin')}
-                                        />
-                                      </Space.Compact>
-                                    </Field>
-                                  </div>
-                                );
-                              }}
-                            </Form.Item>
-                          </div>
-                        ))}
-                      </div>
-                      <Button
-                        className="ext-proxy-add"
-                        block
-                        type="dashed"
-                        icon={<PlusOutlined />}
-                        onClick={() => add(newEntry())}
-                      >
-                        {t('add')}
-                      </Button>
-                    </>
-                  )}
-                </Form.List>
-              </Form.Item>
-            )}
-          </>
-        );
-      }}
-    </Form.Item>
-  );
-}

+ 0 - 1
frontend/src/pages/inbounds/form/transport/index.ts

@@ -4,5 +4,4 @@ export { default as GrpcForm } from './grpc';
 export { default as XhttpForm } from './xhttp';
 export { default as HttpUpgradeForm } from './httpupgrade';
 export { default as KcpForm } from './kcp';
-export { default as ExternalProxyForm } from './external-proxy';
 export { default as SockoptForm } from './sockopt';

+ 6 - 143
frontend/src/pages/inbounds/form/transport/sockopt.tsx

@@ -1,12 +1,8 @@
 import { useTranslation } from 'react-i18next';
-import { Alert, Button, Form, Input, InputNumber, Segmented, Select, Space, Switch } from 'antd';
+import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
 
-import {
-  Address_Port_Strategy,
-  DOMAIN_STRATEGY_OPTION,
-  TCP_CONGESTION_OPTION,
-} from '@/schemas/primitives';
-import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
+import { CustomSockoptList } from '@/components/form';
+import { TCP_CONGESTION_OPTION } from '@/schemas/primitives';
 
 // Transport key that carries its own acceptProxyProtocol field (mirrored
 // alongside the sockopt-level one so the PROXY preset never silently no-ops).
@@ -157,7 +153,7 @@ export default function SockoptForm({
                             type="warning"
                             showIcon
                             style={{ marginBottom: 16 }}
-                            message={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
+                            title={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
                           />
                         )}
                         {proxyMismatch && (
@@ -165,7 +161,7 @@ export default function SockoptForm({
                             type="warning"
                             showIcon
                             style={{ marginBottom: 16 }}
-                            message={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
+                            title={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
                           />
                         )}
                       </>
@@ -218,13 +214,6 @@ export default function SockoptForm({
                 >
                   <Switch />
                 </Form.Item>
-                <Form.Item
-                  name={['streamSettings', 'sockopt', 'tcpMptcp']}
-                  label={t('pages.inbounds.form.multipathTcp')}
-                  valuePropName="checked"
-                >
-                  <Switch />
-                </Form.Item>
                 <Form.Item
                   name={['streamSettings', 'sockopt', 'penetrate']}
                   label={t('pages.inbounds.form.penetrate')}
@@ -239,15 +228,6 @@ export default function SockoptForm({
                 >
                   <Switch />
                 </Form.Item>
-                <Form.Item
-                  name={['streamSettings', 'sockopt', 'domainStrategy']}
-                  label={t('pages.xray.wireguard.domainStrategy')}
-                >
-                  <Select
-                    style={{ width: '50%' }}
-                    options={Object.values(DOMAIN_STRATEGY_OPTION).map((d) => ({ value: d, label: d }))}
-                  />
-                </Form.Item>
                 <Form.Item
                   name={['streamSettings', 'sockopt', 'tcpcongestion']}
                   label={t('pages.inbounds.form.tcpCongestion')}
@@ -267,15 +247,6 @@ export default function SockoptForm({
                     ]}
                   />
                 </Form.Item>
-                <Form.Item name={['streamSettings', 'sockopt', 'dialerProxy']} label={t('pages.inbounds.form.dialerProxy')}>
-                  <Input />
-                </Form.Item>
-                <Form.Item
-                  name={['streamSettings', 'sockopt', 'interface']}
-                  label={t('pages.inbounds.info.interfaceName')}
-                >
-                  <Input />
-                </Form.Item>
                 <Form.Item
                   name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
                   label={t('pages.inbounds.form.trustedXForwardedFor')}
@@ -293,115 +264,7 @@ export default function SockoptForm({
                     ]}
                   />
                 </Form.Item>
-                <Form.Item
-                  name={['streamSettings', 'sockopt', 'addressPortStrategy']}
-                  label={t('pages.inbounds.form.addressPortStrategy')}
-                >
-                  <Select
-                    style={{ width: '50%' }}
-                    options={Object.values(Address_Port_Strategy).map((v) => ({ value: v, label: v }))}
-                  />
-                </Form.Item>
-                <Form.Item shouldUpdate noStyle>
-                  {({ getFieldValue, setFieldValue }) => {
-                    const he = getFieldValue(['streamSettings', 'sockopt', 'happyEyeballs']);
-                    const hasHe = he != null;
-                    return (
-                      <>
-                        <Form.Item label="Happy Eyeballs">
-                          <Switch
-                            checked={hasHe}
-                            onChange={(v) => {
-                              setFieldValue(
-                                ['streamSettings', 'sockopt', 'happyEyeballs'],
-                                v ? HappyEyeballsSchema.parse({}) : undefined,
-                              );
-                            }}
-                          />
-                        </Form.Item>
-                        {hasHe && (
-                          <>
-                            <Form.Item
-                              name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
-                              label={t('pages.inbounds.form.tryDelayMs')}
-                            >
-                              <InputNumber min={0} placeholder="0 disabled — 250 recommended" />
-                            </Form.Item>
-                            <Form.Item
-                              name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
-                              label={t('pages.inbounds.form.prioritizeIPv6')}
-                              valuePropName="checked"
-                            >
-                              <Switch />
-                            </Form.Item>
-                            <Form.Item
-                              name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
-                              label={t('pages.inbounds.form.interleave')}
-                            >
-                              <InputNumber min={1} />
-                            </Form.Item>
-                            <Form.Item
-                              name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
-                              label={t('pages.inbounds.form.maxConcurrentTry')}
-                            >
-                              <InputNumber min={0} />
-                            </Form.Item>
-                          </>
-                        )}
-                      </>
-                    );
-                  }}
-                </Form.Item>
-                <Form.List name={['streamSettings', 'sockopt', 'customSockopt']}>
-                  {(fields, { add, remove }) => (
-                    <>
-                      <Form.Item label={t('pages.inbounds.form.customSockopt')}>
-                        <Button
-                          type="dashed"
-                          size="small"
-                          onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
-                        >
-                          + {t('pages.inbounds.form.addCustomOption')}
-                        </Button>
-                      </Form.Item>
-                      {fields.map((field) => (
-                        <Space.Compact key={field.key} style={{ display: 'flex', marginBottom: 8 }}>
-                          <Form.Item name={[field.name, 'system']} noStyle>
-                            <Select
-                              placeholder="all"
-                              allowClear
-                              style={{ width: 100 }}
-                              options={[
-                                { value: 'linux', label: 'linux' },
-                                { value: 'windows', label: 'windows' },
-                                { value: 'darwin', label: 'darwin' },
-                              ]}
-                            />
-                          </Form.Item>
-                          <Form.Item name={[field.name, 'type']} noStyle>
-                            <Select
-                              style={{ width: 80 }}
-                              options={[
-                                { value: 'int', label: 'int' },
-                                { value: 'str', label: 'str' },
-                              ]}
-                            />
-                          </Form.Item>
-                          <Form.Item name={[field.name, 'level']} noStyle>
-                            <Input placeholder="level (6=TCP)" style={{ width: 100 }} />
-                          </Form.Item>
-                          <Form.Item name={[field.name, 'opt']} noStyle>
-                            <Input placeholder="opt" style={{ width: 120 }} />
-                          </Form.Item>
-                          <Form.Item name={[field.name, 'value']} noStyle>
-                            <Input placeholder="value" style={{ flex: 1 }} />
-                          </Form.Item>
-                          <Button danger onClick={() => remove(field.name)}>−</Button>
-                        </Space.Compact>
-                      ))}
-                    </>
-                  )}
-                </Form.List>
+                <CustomSockoptList />
               </>
             )}
           </>

+ 1 - 5
frontend/src/pages/inbounds/info/InboundInfoModal.tsx

@@ -31,7 +31,6 @@ export default function InboundInfoModal({
   onClose,
   dbInbound,
   clientIndex = 0,
-  remarkModel = '-io',
   expireDiff = 0,
   trafficDiff = 0,
   ipLimitEnable = false,
@@ -120,7 +119,6 @@ export default function InboundInfoModal({
         genWireguardConfigs({
           inbound: inboundForLinks,
           remark: dbInbound.remark,
-          remarkModel: '-io',
           hostOverride: nodeAddress,
           fallbackHostname,
         }).split('\r\n'),
@@ -129,7 +127,6 @@ export default function InboundInfoModal({
         genWireguardLinks({
           inbound: inboundForLinks,
           remark: dbInbound.remark,
-          remarkModel: '-io',
           hostOverride: nodeAddress,
           fallbackHostname,
         }).split('\r\n'),
@@ -140,7 +137,6 @@ export default function InboundInfoModal({
         genAllLinks({
           inbound: inboundForLinks,
           remark: dbInbound.remark,
-          remarkModel,
           client: (clientSet ?? {}) as Parameters<typeof genAllLinks>[0]['client'],
           hostOverride: nodeAddress,
           fallbackHostname,
@@ -189,7 +185,7 @@ export default function InboundInfoModal({
         }
       });
     }
-  }, [open, dbInbound, clientIndex, remarkModel, nodeAddress, subSettings, ipLimitEnable, t]);
+  }, [open, dbInbound, clientIndex, nodeAddress, subSettings, ipLimitEnable, t]);
 
   const isEnable = useMemo(() => {
     if (clientSettings) return !!clientSettings.enable;

+ 0 - 1
frontend/src/pages/inbounds/info/types.ts

@@ -76,7 +76,6 @@ export interface InboundInfoModalProps {
   onClose: () => void;
   dbInbound: DBInboundLike | null;
   clientIndex?: number;
-  remarkModel?: string;
   expireDiff?: number;
   trafficDiff?: number;
   ipLimitEnable?: boolean;

+ 1 - 6
frontend/src/pages/inbounds/qr/QrCodeModal.tsx

@@ -26,7 +26,6 @@ interface QrCodeModalProps {
   onClose: () => void;
   dbInbound: (DbInboundLike & { remark?: string }) | null;
   client?: ClientSetting | null;
-  remarkModel?: string;
   nodeAddress?: string;
   subSettings?: SubSettings;
 }
@@ -43,7 +42,6 @@ export default function QrCodeModal({
   onClose,
   dbInbound,
   client = null,
-  remarkModel = '-io',
   nodeAddress = '',
   subSettings,
 }: QrCodeModalProps) {
@@ -67,7 +65,6 @@ export default function QrCodeModal({
         genWireguardConfigs({
           inbound,
           remark: peerRemark,
-          remarkModel: '-io',
           hostOverride: nodeAddress,
           fallbackHostname,
         }).split('\r\n'),
@@ -76,7 +73,6 @@ export default function QrCodeModal({
         genWireguardLinks({
           inbound,
           remark: peerRemark,
-          remarkModel: '-io',
           hostOverride: nodeAddress,
           fallbackHostname,
         }).split('\r\n'),
@@ -87,7 +83,6 @@ export default function QrCodeModal({
         genAllLinks({
           inbound,
           remark: dbInbound.remark || '',
-          remarkModel,
           client: client ?? {},
           hostOverride: nodeAddress,
           fallbackHostname,
@@ -106,7 +101,7 @@ export default function QrCodeModal({
     }
     setSubLink(nextSub);
     setSubJsonLink(nextSubJson);
-  }, [open, dbInbound, client, remarkModel, nodeAddress, subSettings]);
+  }, [open, dbInbound, client, nodeAddress, subSettings]);
 
   const qrItems = useMemo<QrItem[]>(() => {
     const items: QrItem[] = [];

+ 0 - 2
frontend/src/pages/inbounds/useInbounds.ts

@@ -161,7 +161,6 @@ export function useInbounds() {
   const tgBotEnable = !!defaults.tgBotEnable;
   const ipLimitEnable = !!defaults.ipLimitEnable;
   const pageSize = defaults.pageSize ?? 0;
-  const remarkModel = defaults.remarkModel || '-io';
   const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
 
   const subSettings: SubSettings = useMemo(() => ({
@@ -528,7 +527,6 @@ export function useInbounds() {
     expireDiff,
     trafficDiff,
     subSettings,
-    remarkModel,
     datepicker,
     tgBotEnable,
     ipLimitEnable,

+ 1 - 1
frontend/src/pages/settings/EmailTab.tsx

@@ -102,7 +102,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
               {testResult && (
                 <Alert
                   type={testResult.success ? 'success' : 'error'}
-                  message={
+                  title={
                     testResult.success
                       ? t('pages.settings.' + testResult.msg)
                       : <span><b>{stageLabel[testResult.stage || ''] || testResult.stage}:</b> {t('pages.settings.' + testResult.msg)}</span>

+ 9 - 70
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -1,17 +1,13 @@
-import { useMemo } from 'react';
-import { Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
+import { Input, InputNumber, Switch, Tabs } from 'antd';
 import { BranchesOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
+import { RemarkTemplateField } from '@/components/form';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
 
-const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'External Proxy' };
-const REMARK_SAMPLES: Record<string, string> = { i: 'Germany', e: 'john', o: 'Relay' };
-const REMARK_SEPARATORS = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
-
 interface SubscriptionGeneralTabProps {
   allSetting: AllSetting;
   updateSetting: (patch: Partial<AllSetting>) => void;
@@ -21,30 +17,6 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();
 
-  const remarkModel = useMemo(() => {
-    const rm = allSetting.remarkModel || '';
-    return rm.length > 1 ? rm.substring(1).split('') : [];
-  }, [allSetting.remarkModel]);
-
-  const remarkSeparator = useMemo(() => {
-    const rm = allSetting.remarkModel || '-';
-    return rm.length > 1 ? rm.charAt(0) : '-';
-  }, [allSetting.remarkModel]);
-
-  const remarkSample = useMemo(() => {
-    const parts = remarkModel.map((k) => REMARK_SAMPLES[k]);
-    return parts.length === 0 ? '' : parts.join(remarkSeparator);
-  }, [remarkModel, remarkSeparator]);
-
-  function setRemarkModel(parts: string[]) {
-    updateSetting({ remarkModel: remarkSeparator + parts.join('') });
-  }
-
-  function setRemarkSeparator(sep: string) {
-    const tail = (allSetting.remarkModel || '-').substring(1);
-    updateSetting({ remarkModel: sep + tail });
-  }
-
   return (
     <Tabs defaultActiveKey="1" items={[
       {
@@ -94,49 +66,16 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
             <SettingListItem paddings="small" title={t('pages.settings.subEncrypt')} description={t('pages.settings.subEncryptDesc')}>
               <Switch checked={allSetting.subEncrypt} onChange={(v) => updateSetting({ subEncrypt: v })} />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.subShowInfo')} description={t('pages.settings.subShowInfoDesc')}>
-              <Switch checked={allSetting.subShowInfo} onChange={(v) => updateSetting({ subShowInfo: v })} />
-            </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.subEmailInRemark')} description={t('pages.settings.subEmailInRemarkDesc')}>
-              <Switch checked={allSetting.subEmailInRemark} onChange={(v) => updateSetting({ subEmailInRemark: v })} />
-            </SettingListItem>
-
             <SettingListItem
               paddings="small"
-              title={t('pages.settings.remarkModel')}
-              description={
-                <>
-                  {t('pages.settings.sampleRemark')}:{' '}
-                  <span
-                    style={{
-                      fontFamily: 'monospace',
-                      padding: '1px 6px',
-                      borderRadius: 4,
-                      border: '1px solid var(--ant-color-border)',
-                      background: 'var(--ant-color-fill-tertiary)',
-                      whiteSpace: 'pre',
-                    }}
-                  >
-                    {remarkSample ? `#${remarkSample}` : '—'}
-                  </span>
-                </>
-              }
+              title={t('pages.settings.remarkTemplate')}
+              description={t('pages.settings.remarkTemplateDesc')}
             >
-              <Space.Compact style={{ width: '100%' }}>
-                <Select
-                  mode="multiple"
-                  value={remarkModel}
-                  onChange={setRemarkModel}
-                  style={{ paddingRight: '.5rem', minWidth: '80%', width: 'auto' }}
-                  options={Object.entries(REMARK_MODELS).map(([k, l]) => ({ value: k, label: l }))}
-                />
-                <Select
-                  value={remarkSeparator}
-                  onChange={setRemarkSeparator}
-                  style={{ width: '20%' }}
-                  options={REMARK_SEPARATORS.map((s) => ({ value: s, label: s === ' ' ? '␣' : s }))}
-                />
-              </Space.Compact>
+              <RemarkTemplateField
+                value={allSetting.remarkTemplate}
+                onChange={(v) => updateSetting({ remarkTemplate: v })}
+                maxLength={256}
+              />
             </SettingListItem>
 
             <SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>

+ 1 - 1
frontend/src/pages/settings/TelegramTab.tsx

@@ -222,7 +222,7 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
               {testResult && (
                 <Alert
                   type={testResult.success ? 'success' : 'error'}
-                  message={testResult.msg}
+                  title={testResult.msg}
                   showIcon
                   closable
                   onClose={() => setTestResult(null)}

+ 3 - 1
frontend/src/pages/sub/SubPage.tsx

@@ -58,6 +58,7 @@ const subClashUrl = subData.subClashUrl || '';
 const subTitle = subData.subTitle || '';
 const links: string[] = Array.isArray(subData.links) ? subData.links : [];
 const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
+const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
 const datepicker = subData.datepicker || 'gregorian';
 
 const isUnlimited = totalByte <= 0 && expireMs === 0;
@@ -149,6 +150,7 @@ export default function SubPage() {
   const descriptionsItems = useMemo(() => {
     const items = [
       { key: 'subId', label: t('subscription.subId'), children: sId },
+      ...(subEmail ? [{ key: 'email', label: t('subscription.email'), children: subEmail }] : []),
       {
         key: 'status',
         label: t('subscription.status'),
@@ -413,7 +415,7 @@ export default function SubPage() {
                         </div>
                       </div>
                       {links.map((link, idx) => {
-                        const parts = parseLinkParts(link, linkEmails[idx] || '');
+                        const parts = parseLinkParts(link);
                         const fallback = `Link ${idx + 1}`;
                         const rowTitle = parts?.remark || fallback;
                         const qrLabel = [parts?.remark, linkEmails[idx]].filter(Boolean).join('-') || rowTitle;

+ 7 - 1
frontend/src/pages/xray/outbounds/OutboundCardList.tsx

@@ -8,6 +8,7 @@ import {
   VerticalAlignTopOutlined,
   ThunderboltOutlined,
   LoadingOutlined,
+  ExportOutlined,
 } from '@ant-design/icons';
 
 import { SizeFormatter } from '@/utils';
@@ -50,7 +51,12 @@ export default function OutboundCardList({
 }: OutboundCardListProps) {
   const { t } = useTranslation();
   if (rows.length === 0) {
-    return <div className="card-empty">—</div>;
+    return (
+      <div className="card-empty">
+        <ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
+        <div>{t('noData')}</div>
+      </div>
+    );
   }
   return (
     <>

+ 9 - 2
frontend/src/pages/xray/outbounds/OutboundsTab.css

@@ -3,10 +3,17 @@
   justify-content: flex-end;
 }
 
+/* Keep this in sync with the other pages' .card-empty (it's a global class):
+   the previous opacity:0.4 here leaked onto whichever page's empty state was
+   shown after the Outbounds CSS loaded, fading it. */
 .card-empty {
   text-align: center;
-  opacity: 0.4;
-  padding: 16px 0;
+  color: var(--ant-color-text-secondary);
+  padding: 24px 12px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
 }
 
 .outbound-card {

+ 9 - 0
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -33,6 +33,7 @@ import {
   ArrowDownOutlined,
   CheckCircleOutlined,
   WarningOutlined,
+  ExportOutlined,
 } from '@ant-design/icons';
 
 import { HttpUtil } from '@/utils';
@@ -469,6 +470,14 @@ export default function OutboundsTab({
             rowKey={(r) => r.key}
             pagination={false}
             size="small"
+            locale={{
+              emptyText: (
+                <div className="card-empty">
+                  <ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
+                  <div>{t('noData')}</div>
+                </div>
+              ),
+            }}
           />
         )}
 

+ 10 - 82
frontend/src/pages/xray/outbounds/transport/sockopt.tsx

@@ -1,6 +1,7 @@
 import { useTranslation } from 'react-i18next';
-import { Button, Form, Input, InputNumber, Select, Space, Switch, type FormInstance } from 'antd';
+import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
 
+import { CustomSockoptList } from '@/components/form';
 import { DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION } from '@/schemas/primitives';
 import { HappyEyeballsSchema, SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
 import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
@@ -136,54 +137,30 @@ export default function SockoptForm({
                     }))}
                   />
                 </Form.Item>
-                <Form.Item
-                  label={t('pages.xray.outboundForm.ipv6Only')}
-                  name={['streamSettings', 'sockopt', 'V6Only']}
-                  valuePropName="checked"
-                >
-                  <Switch />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.xray.outboundForm.acceptProxyProtocol')}
-                  name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
-                  valuePropName="checked"
-                >
-                  <Switch />
-                </Form.Item>
                 <Form.Item
                   label={t('pages.xray.outboundForm.tcpUserTimeoutMs')}
                   name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
                 >
-                  <InputNumber min={0} style={{ width: '100%' }} />
+                  <InputNumber min={0} />
                 </Form.Item>
                 <Form.Item
                   label={t('pages.xray.outboundForm.tcpKeepAliveIdleS')}
                   name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
                 >
-                  <InputNumber min={0} style={{ width: '100%' }} />
+                  <InputNumber min={0} />
                 </Form.Item>
                 <Form.Item
                   label={t('pages.inbounds.form.tcpMaxSeg')}
                   name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
                 >
-                  <InputNumber min={0} style={{ width: '100%' }} />
+                  <InputNumber min={0} />
                 </Form.Item>
                 <Form.Item
                   label={t('pages.inbounds.form.tcpWindowClamp')}
                   name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
                   tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
                 >
-                  <InputNumber min={0} style={{ width: '100%' }} />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.form.trustedXForwardedFor')}
-                  name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
-                >
-                  <Select
-                    mode="tags"
-                    tokenSeparators={[',', ' ']}
-                    placeholder="trusted-proxy.example,10.0.0.0/8"
-                  />
+                  <InputNumber min={0} />
                 </Form.Item>
                 <Form.Item shouldUpdate noStyle>
                   {() => {
@@ -210,7 +187,7 @@ export default function SockoptForm({
                               label={t('pages.inbounds.form.tryDelayMs')}
                               name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
                             >
-                              <InputNumber min={0} style={{ width: '100%' }} placeholder="0 (disabled) — 250 recommended" />
+                              <InputNumber min={0} placeholder="0 (disabled) — 250 recommended" />
                             </Form.Item>
                             <Form.Item
                               label={t('pages.inbounds.form.prioritizeIPv6')}
@@ -223,13 +200,13 @@ export default function SockoptForm({
                               label={t('pages.inbounds.form.interleave')}
                               name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
                             >
-                              <InputNumber min={1} style={{ width: '100%' }} />
+                              <InputNumber min={1} />
                             </Form.Item>
                             <Form.Item
                               label={t('pages.inbounds.form.maxConcurrentTry')}
                               name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
                             >
-                              <InputNumber min={0} style={{ width: '100%' }} />
+                              <InputNumber min={0} />
                             </Form.Item>
                           </>
                         )}
@@ -237,56 +214,7 @@ export default function SockoptForm({
                     );
                   }}
                 </Form.Item>
-                <Form.List name={['streamSettings', 'sockopt', 'customSockopt']}>
-                  {(fields, { add, remove }) => (
-                    <>
-                      <Form.Item label={t('pages.inbounds.form.customSockopt')}>
-                        <Button
-                          type="dashed"
-                          size="small"
-                          onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
-                        >
-                          + {t('pages.inbounds.form.addCustomOption')}
-                        </Button>
-                      </Form.Item>
-                      {fields.map((field) => (
-                        <Space.Compact key={field.key} style={{ display: 'flex', marginBottom: 8 }}>
-                          <Form.Item name={[field.name, 'system']} noStyle>
-                            <Select
-                              placeholder="all"
-                              allowClear
-                              style={{ width: 100 }}
-                              options={[
-                                { value: 'linux', label: 'linux' },
-                                { value: 'windows', label: 'windows' },
-                                { value: 'darwin', label: 'darwin' },
-                              ]}
-                            />
-                          </Form.Item>
-                          <Form.Item name={[field.name, 'type']} noStyle>
-                            <Select
-                              style={{ width: 80 }}
-                              options={[
-                                { value: 'int', label: 'int' },
-                                { value: 'str', label: 'str' },
-                              ]}
-                            />
-                          </Form.Item>
-                          <Form.Item name={[field.name, 'level']} noStyle>
-                            <Input placeholder="level (6=TCP)" style={{ width: 100 }} />
-                          </Form.Item>
-                          <Form.Item name={[field.name, 'opt']} noStyle>
-                            <Input placeholder="opt (decimal)" style={{ width: 120 }} />
-                          </Form.Item>
-                          <Form.Item name={[field.name, 'value']} noStyle>
-                            <Input placeholder="value" style={{ flex: 1 }} />
-                          </Form.Item>
-                          <Button danger onClick={() => remove(field.name)}>−</Button>
-                        </Space.Compact>
-                      ))}
-                    </>
-                  )}
-                </Form.List>
+                <CustomSockoptList />
               </>
             )}
           </>

+ 2 - 0
frontend/src/routes.tsx

@@ -8,6 +8,7 @@ const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage'));
 const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage'));
 const GroupsPage = lazy(() => import('@/pages/groups/GroupsPage'));
 const NodesPage = lazy(() => import('@/pages/nodes/NodesPage'));
+const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
 const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
 const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
 const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
@@ -26,6 +27,7 @@ const routes: RouteObject[] = [
       { path: 'clients', element: withSuspense(<ClientsPage />) },
       { path: 'groups', element: withSuspense(<GroupsPage />) },
       { path: 'nodes', element: withSuspense(<NodesPage />) },
+      { path: 'hosts', element: withSuspense(<HostsPage />) },
       { path: 'settings', element: withSuspense(<SettingsPage />) },
       { path: 'xray', element: withSuspense(<XrayPage />) },
       { path: 'api-docs', element: withSuspense(<ApiDocsPage />) },

+ 116 - 0
frontend/src/schemas/api/host.ts

@@ -0,0 +1,116 @@
+import { z } from 'zod';
+
+import { AlpnSchema, UtlsFingerprintSchema } from '@/schemas/protocols/security/tls';
+
+// A Host is a per-inbound override endpoint: at subscription time each enabled
+// host renders one extra share link/proxy with its own address/port/TLS, etc.,
+// superseding the legacy externalProxy array. The form schema mirrors the field
+// logic of schemas/protocols/stream/external-proxy.ts and reuses the shared
+// ALPN / uTLS primitives.
+
+export const HostSecuritySchema = z.enum(['same', 'tls', 'none', 'reality']);
+export type HostSecurity = z.infer<typeof HostSecuritySchema>;
+
+export const MihomoIpVersionSchema = z.enum(['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer']);
+export const SubTypeSchema = z.enum(['raw', 'json', 'clash']);
+
+// Tags are short uppercase identifiers (≤10 tags, each ≤36 chars). Enforced on
+// the frontend; the backend stores them verbatim.
+const HostTagSchema = z.string().regex(/^[A-Z0-9_:]+$/, 'pages.hosts.toasts.badTag').max(36);
+
+// HostFormValues is what the form edits and POSTs.
+export const HostFormSchema = z.object({
+  id: z.number().optional(),
+  inboundId: z.number().int().positive(),
+  sortOrder: z.number().int().default(0),
+  // Remark may contain {{VAR}} template tokens expanded per client at
+  // subscription time, so the stored template gets a generous cap.
+  remark: z.string().trim().min(1).max(256),
+  serverDescription: z.string().max(64).default(''),
+  isDisabled: z.boolean().default(false),
+  isHidden: z.boolean().default(false),
+  tags: z.array(HostTagSchema).max(10).default([]),
+
+  address: z.string().default(''),
+  port: z.number().int().min(0).max(65535).default(0),
+
+  security: HostSecuritySchema.default('same'),
+  sni: z.string().default(''),
+  hostHeader: z.string().default(''),
+  path: z.string().default(''),
+  alpn: z.array(AlpnSchema).default([]),
+  fingerprint: z.preprocess(
+    (val) => (val === '' ? undefined : val),
+    UtlsFingerprintSchema.optional(),
+  ),
+  overrideSniFromAddress: z.boolean().default(false),
+  keepSniBlank: z.boolean().default(false),
+  pinnedPeerCertSha256: z.array(z.string()).default([]),
+  verifyPeerCertByName: z.boolean().default(false),
+  allowInsecure: z.boolean().default(false),
+  echConfigList: z.string().default(''),
+
+  muxParams: z.string().default(''),
+  sockoptParams: z.string().default(''),
+  finalMask: z.string().default(''),
+  // A comma-separated list of ports/ranges (e.g. "53,443,1000-2000"). Empty = none.
+  vlessRoute: z
+    .string()
+    .trim()
+    .regex(/^(\d{1,5}(-\d{1,5})?)(\s*,\s*\d{1,5}(-\d{1,5})?)*$/, 'pages.hosts.toasts.badVlessRoute')
+    .or(z.literal(''))
+    .default(''),
+
+  excludeFromSubTypes: z.array(SubTypeSchema).default([]),
+
+  // Visual-only assignment of nodes that resolve from this host (stored, not yet
+  // wired into routing).
+  nodeGuids: z.array(z.string()).default([]),
+
+  mihomoIpVersion: z.preprocess(
+    (val) => (val === '' ? undefined : val),
+    MihomoIpVersionSchema.optional(),
+  ),
+  mihomoX25519: z.boolean().default(false),
+  shuffleHost: z.boolean().default(false),
+});
+export type HostFormValues = z.infer<typeof HostFormSchema>;
+
+// HostRecord is the loose list/read projection from /panel/api/hosts. Slice and
+// free-JSON fields tolerate the backend serializing nil as null.
+export const HostRecordSchema = z.object({
+  id: z.number(),
+  inboundId: z.number(),
+  sortOrder: z.number().optional(),
+  remark: z.string().optional(),
+  serverDescription: z.string().optional(),
+  isDisabled: z.boolean().optional(),
+  isHidden: z.boolean().optional(),
+  tags: z.array(z.string()).nullish(),
+  address: z.string().optional(),
+  port: z.number().optional(),
+  security: z.string().optional(),
+  sni: z.string().optional(),
+  hostHeader: z.string().optional(),
+  path: z.string().optional(),
+  alpn: z.array(z.string()).nullish(),
+  fingerprint: z.string().optional(),
+  overrideSniFromAddress: z.boolean().optional(),
+  keepSniBlank: z.boolean().optional(),
+  pinnedPeerCertSha256: z.array(z.string()).nullish(),
+  verifyPeerCertByName: z.boolean().optional(),
+  allowInsecure: z.boolean().optional(),
+  echConfigList: z.string().optional(),
+  muxParams: z.unknown().optional(),
+  sockoptParams: z.unknown().optional(),
+  finalMask: z.string().optional(),
+  vlessRoute: z.string().optional(),
+  excludeFromSubTypes: z.array(z.string()).nullish(),
+  nodeGuids: z.array(z.string()).nullish(),
+  mihomoIpVersion: z.string().optional(),
+  mihomoX25519: z.boolean().optional(),
+  shuffleHost: z.boolean().optional(),
+}).loose();
+export type HostRecord = z.infer<typeof HostRecordSchema>;
+
+export const HostListSchema = z.array(HostRecordSchema);

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

@@ -12,7 +12,6 @@ export const DefaultsPayloadSchema = z.object({
   subClashURI: z.string().optional(),
   subClashEnable: z.boolean().optional(),
   pageSize: z.number().optional(),
-  remarkModel: z.string().optional(),
   datepicker: z.enum(['gregorian', 'jalalian']).optional(),
   ipLimitEnable: z.boolean().optional(),
   accessLogEnable: z.boolean().optional(),

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

@@ -17,7 +17,7 @@ export const AllSettingSchema = z.object({
   pageSize: z.number().int().min(0).max(1000).optional(),
   expireDiff: nonNegativeInt.optional(),
   trafficDiff: nonNegativeInt.max(100).optional(),
-  remarkModel: z.string().optional(),
+  remarkTemplate: z.string().optional(),
   datepicker: z.enum(['gregorian', 'jalalian']).optional(),
   tgBotEnable: z.boolean().optional(),
   tgBotToken: z.string().optional(),
@@ -53,8 +53,6 @@ export const AllSettingSchema = z.object({
   subKeyFile: z.string().optional(),
   subUpdates: z.number().int().min(1).max(168).optional(),
   subEncrypt: z.boolean().optional(),
-  subShowInfo: z.boolean().optional(),
-  subEmailInRemark: z.boolean().optional(),
   subURI: z.string().optional(),
   subJsonURI: z.string().optional(),
   subClashURI: z.string().optional(),

+ 41 - 0
frontend/src/styles/page-cards.css

@@ -85,3 +85,44 @@
 .api-docs-page .ant-card .ant-card-actions {
   background: transparent;
 }
+
+/* Hosts page shares the same card styling + hover shadows (without the default
+   antd hoverable pointer/blur), matching Clients/Inbounds/etc. */
+.hosts-page .ant-card {
+  border-radius: 12px;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
+  transition: transform 0.2s ease, box-shadow 0.25s ease, border-color 0.2s ease;
+}
+
+.hosts-page.is-dark .ant-card {
+  box-shadow:
+    0 1px 2px rgba(0, 0, 0, 0.4),
+    inset 0 1px 0 rgba(255, 255, 255, 0.03);
+}
+
+.hosts-page.is-dark.is-ultra .ant-card {
+  box-shadow:
+    0 1px 2px rgba(0, 0, 0, 0.6),
+    inset 0 1px 0 rgba(255, 255, 255, 0.025);
+}
+
+.hosts-page .ant-card.ant-card-hoverable:hover {
+  cursor: default;
+  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
+}
+
+.hosts-page.is-dark .ant-card.ant-card-hoverable:hover {
+  box-shadow:
+    0 8px 24px rgba(0, 0, 0, 0.5),
+    inset 0 1px 0 rgba(255, 255, 255, 0.04);
+}
+
+.hosts-page.is-dark.is-ultra .ant-card.ant-card-hoverable:hover {
+  box-shadow:
+    0 8px 24px rgba(0, 0, 0, 0.75),
+    inset 0 1px 0 rgba(255, 255, 255, 0.03);
+}
+
+.hosts-page .ant-card .ant-card-actions {
+  background: transparent;
+}

+ 40 - 0
frontend/src/styles/page-shell.css

@@ -176,3 +176,43 @@ body.dark .ant-dropdown-menu-item-divider {
     padding: 8px;
   }
 }
+
+/* Hosts page shares the standard panel page shell (background, transparent
+   layout, content padding, summary-card padding). */
+.hosts-page {
+  --bg-page: #e6e8ec;
+  --bg-card: #ffffff;
+  min-height: 100vh;
+  background: var(--bg-page);
+}
+
+.hosts-page.is-dark {
+  --bg-page: #1a1b1f;
+  --bg-card: #23252b;
+}
+
+.hosts-page.is-dark.is-ultra {
+  --bg-page: #000;
+  --bg-card: #101013;
+}
+
+.hosts-page .ant-layout,
+.hosts-page .ant-layout-content,
+.hosts-page .content-shell {
+  background: transparent;
+}
+
+.hosts-page .content-area {
+  padding: 24px;
+}
+
+.hosts-page .summary-card {
+  padding: 16px;
+}
+
+@media (max-width: 768px) {
+  .hosts-page .content-area,
+  .hosts-page .summary-card {
+    padding: 8px;
+  }
+}

+ 1 - 17
frontend/src/test/__snapshots__/inbound-form-blocks.test.tsx.snap

@@ -36,12 +36,6 @@ exports[`inbound security forms > TlsForm field structure is stable 1`] = `
 ]
 `;
 
-exports[`inbound transport forms > ExternalProxyForm field structure is stable (one TLS entry) 1`] = `
-[
-  "External Proxy",
-]
-`;
-
 exports[`inbound transport forms > GrpcForm field structure is stable 1`] = `
 [
   "Service Name",
@@ -77,7 +71,7 @@ exports[`inbound transport forms > RawForm field structure is stable 1`] = `
 ]
 `;
 
-exports[`inbound transport forms > SockoptForm field structure is stable (enabled + happy eyeballs) 1`] = `
+exports[`inbound transport forms > SockoptForm field structure is stable (server-side fields only) 1`] = `
 [
   "Sockopt",
   "Real client IP",
@@ -89,21 +83,11 @@ exports[`inbound transport forms > SockoptForm field structure is stable (enable
   "TCP Window Clamp",
   "Proxy Protocol",
   "TCP Fast Open",
-  "Multipath TCP",
   "Penetrate",
   "V6 Only",
-  "Domain Strategy",
   "TCP Congestion",
   "TProxy",
-  "Dialer Proxy",
-  "Interface name",
   "Trusted X-Forwarded-For",
-  "Address+port strategy",
-  "Happy Eyeballs",
-  "Try delay (ms)",
-  "Prioritize IPv6",
-  "Interleave",
-  "Max concurrent try",
   "Custom sockopt",
 ]
 `;

+ 54 - 0
frontend/src/test/host-link.test.ts

@@ -0,0 +1,54 @@
+/// <reference types="vite/client" />
+import { describe, expect, it } from 'vitest';
+
+import { hostToExternalProxyEntry } from '@/lib/hosts/host-link';
+
+describe('hostToExternalProxyEntry', () => {
+  const base = {
+    security: 'tls' as const,
+    address: 'cdn.example.com',
+    port: 8443,
+    remark: 'R',
+    sni: 'sni.example.com',
+    alpn: ['h2'] as ('h2' | 'h3' | 'http/1.1')[],
+    fingerprint: 'chrome' as const,
+    pinnedPeerCertSha256: ['AAAA'],
+    echConfigList: 'ECH',
+    overrideSniFromAddress: false,
+    keepSniBlank: false,
+  };
+
+  it('maps the overlapping fields onto an external-proxy entry', () => {
+    const ep = hostToExternalProxyEntry(base);
+    expect(ep.forceTls).toBe('tls');
+    expect(ep.dest).toBe('cdn.example.com');
+    expect(ep.port).toBe(8443);
+    expect(ep.remark).toBe('R');
+    expect(ep.sni).toBe('sni.example.com');
+    expect(ep.alpn).toEqual(['h2']);
+    expect(ep.fingerprint).toBe('chrome');
+    expect(ep.pinnedPeerCertSha256).toEqual(['AAAA']);
+    expect(ep.echConfigList).toBe('ECH');
+  });
+
+  it('maps reality/same security to forceTls "same"', () => {
+    expect(hostToExternalProxyEntry({ ...base, security: 'reality' }).forceTls).toBe('same');
+    expect(hostToExternalProxyEntry({ ...base, security: 'same' }).forceTls).toBe('same');
+    expect(hostToExternalProxyEntry({ ...base, security: 'none' }).forceTls).toBe('none');
+  });
+
+  it('uses the address as sni when overrideSniFromAddress is set', () => {
+    const ep = hostToExternalProxyEntry({ ...base, overrideSniFromAddress: true });
+    expect(ep.sni).toBe('cdn.example.com');
+  });
+
+  it('omits sni when keepSniBlank is set', () => {
+    const ep = hostToExternalProxyEntry({ ...base, keepSniBlank: true });
+    expect(ep.sni).toBeUndefined();
+  });
+
+  it('falls back to port 443 when the host port is 0 (inherit)', () => {
+    const ep = hostToExternalProxyEntry({ ...base, port: 0 });
+    expect(ep.port).toBe(443);
+  });
+});

+ 67 - 0
frontend/src/test/host-schema.test.ts

@@ -0,0 +1,67 @@
+/// <reference types="vite/client" />
+import { describe, expect, it } from 'vitest';
+
+import { HostFormSchema } from '@/schemas/api/host';
+
+describe('HostFormSchema', () => {
+  const valid = {
+    inboundId: 1,
+    remark: 'cdn-front',
+    address: 'cdn.example.com',
+    port: 8443,
+    security: 'tls',
+    tags: ['CDN', 'EU'],
+    mihomoIpVersion: 'dual',
+    excludeFromSubTypes: ['clash'],
+  };
+
+  it('parses a valid host', () => {
+    const parsed = HostFormSchema.parse(valid);
+    expect(parsed.remark).toBe('cdn-front');
+    expect(parsed.security).toBe('tls');
+    expect(parsed.tags).toEqual(['CDN', 'EU']);
+    expect(parsed.excludeFromSubTypes).toEqual(['clash']);
+  });
+
+  it('rejects an empty remark', () => {
+    expect(() => HostFormSchema.parse({ ...valid, remark: '' })).toThrow();
+  });
+
+  it('accepts a templated remark up to 256 chars and rejects beyond', () => {
+    expect(() => HostFormSchema.parse({ ...valid, remark: 'x'.repeat(256) })).not.toThrow();
+    expect(() => HostFormSchema.parse({ ...valid, remark: 'x'.repeat(257) })).toThrow();
+  });
+
+  it('rejects an out-of-range port', () => {
+    expect(() => HostFormSchema.parse({ ...valid, port: 70000 })).toThrow();
+  });
+
+  it('rejects a bad security enum', () => {
+    expect(() => HostFormSchema.parse({ ...valid, security: 'bogus' })).toThrow();
+  });
+
+  it('rejects a tag with invalid characters', () => {
+    expect(() => HostFormSchema.parse({ ...valid, tags: ['lower-case'] })).toThrow();
+  });
+
+  it('rejects more than 10 tags', () => {
+    expect(() =>
+      HostFormSchema.parse({ ...valid, tags: Array.from({ length: 11 }, (_, i) => `T${i}`) }),
+    ).toThrow();
+  });
+
+  it('rejects a bad mihomoIpVersion enum', () => {
+    expect(() => HostFormSchema.parse({ ...valid, mihomoIpVersion: 'nope' })).toThrow();
+  });
+
+  it('rejects a bad excludeFromSubTypes value', () => {
+    expect(() => HostFormSchema.parse({ ...valid, excludeFromSubTypes: ['xml'] })).toThrow();
+  });
+
+  it('defaults security to "same" and port to 0', () => {
+    const parsed = HostFormSchema.parse({ inboundId: 1, remark: 'r' });
+    expect(parsed.security).toBe('same');
+    expect(parsed.port).toBe(0);
+    expect(parsed.tags).toEqual([]);
+  });
+});

+ 5 - 23
frontend/src/test/inbound-form-blocks.test.tsx

@@ -3,7 +3,6 @@ import { Form, type FormInstance } from 'antd';
 import type { ReactNode } from 'react';
 
 import {
-  ExternalProxyForm,
   GrpcForm,
   HttpUpgradeForm,
   KcpForm,
@@ -67,30 +66,13 @@ describe('inbound transport forms', () => {
     expect(fieldLabels()).toMatchSnapshot();
   });
 
-  it('ExternalProxyForm field structure is stable (one TLS entry)', () => {
-    renderInForm(
-      () => <ExternalProxyForm toggleExternalProxy={noop} />,
-      {
-        streamSettings: {
-          externalProxy: [{
-            forceTls: 'tls',
-            dest: '',
-            port: 443,
-            remark: '',
-            sni: '',
-            fingerprint: '',
-            alpn: [],
-          }],
-        },
-      },
-    );
-    expect(fieldLabels()).toMatchSnapshot();
-  });
-
-  it('SockoptForm field structure is stable (enabled + happy eyeballs)', () => {
+  it('SockoptForm field structure is stable (server-side fields only)', () => {
+    // The inbound sockopt form shows only server/listening-side fields;
+    // outbound-only fields (dialerProxy, domainStrategy, interface,
+    // addressPortStrategy, happyEyeballs, tcpMptcp) live in the outbound form.
     renderInForm(
       () => <SockoptForm toggleSockopt={noop} network="tcp" />,
-      { streamSettings: { sockopt: { happyEyeballs: {} } } },
+      { streamSettings: { sockopt: { mark: 0 } } },
     );
     expect(fieldLabels()).toMatchSnapshot();
   });

+ 29 - 0
frontend/src/test/link-label.test.ts

@@ -0,0 +1,29 @@
+import { describe, it, expect } from 'vitest';
+
+import { parseLinkParts, linkMetaText } from '@/lib/xray/link-label';
+
+// The panel shows the subscription's remark verbatim. Per-client traffic/expiry
+// info is rendered only into the body a client app imports (backend, first link
+// only), so the panel's display links are already clean — nothing is stripped.
+describe('link-label parseLinkParts', () => {
+  const linkWith = (remark: string) =>
+    `vless://[email protected]:443?type=tcp&security=tls#${encodeURIComponent(remark)}`;
+
+  it('parses protocol / network / security and keeps the remark verbatim', () => {
+    const parts = parseLinkParts(linkWith('[email protected]'));
+    expect(parts?.protocol).toBe('Vless');
+    expect(parts?.network).toBe('TCP');
+    expect(parts?.security).toBe('TLS');
+    expect(parts?.remark).toBe('[email protected]');
+    expect(parts?.port).toBe('443');
+  });
+
+  it('linkMetaText joins the remark with the port', () => {
+    const parts = parseLinkParts(linkWith('[email protected]'));
+    expect(parts && linkMetaText(parts)).toBe('[email protected]:443');
+  });
+
+  it('returns null for an unparseable scheme', () => {
+    expect(parseLinkParts('not-a-link')).toBeNull();
+  });
+});

+ 26 - 0
frontend/src/test/remark-template-field.test.tsx

@@ -0,0 +1,26 @@
+import { describe, it, expect, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+
+import RemarkTemplateField from '@/components/form/RemarkTemplateField';
+
+describe('RemarkTemplateField', () => {
+  it('inserts a {{TOKEN}} when a variable chip is clicked', async () => {
+    const onChange = vi.fn();
+    render(<RemarkTemplateField value="DE " onChange={onChange} maxLength={256} />);
+
+    // Open the variable picker (the only button is the addon trigger).
+    fireEvent.click(screen.getByRole('button'));
+    fireEvent.click(await screen.findByText('{{EMAIL}}'));
+
+    expect(onChange).toHaveBeenCalledTimes(1);
+    const inserted = onChange.mock.calls[0][0] as string;
+    expect(inserted).toContain('{{EMAIL}}');
+    expect(inserted).toContain('DE');
+  });
+
+  it('renders a live preview of the expanded remark', () => {
+    render(<RemarkTemplateField value="{{EMAIL}}" onChange={() => {}} />);
+    // Sample expansion of {{EMAIL}} is "john".
+    expect(screen.getByText('john')).toBeTruthy();
+  });
+});

+ 30 - 0
frontend/src/test/remark-variables.test.ts

@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest';
+
+import {
+  REMARK_VARIABLES,
+  hasRemarkTokens,
+  previewRemark,
+  wrapToken,
+} from '@/lib/remark/remarkVariables';
+
+describe('remark variables', () => {
+  it('wrapToken / hasRemarkTokens', () => {
+    expect(wrapToken('EMAIL')).toBe('{{EMAIL}}');
+    expect(hasRemarkTokens('hi {{EMAIL}}')).toBe(true);
+    expect(hasRemarkTokens('plain')).toBe(false);
+  });
+
+  it('previewRemark substitutes known tokens and drops unknown', () => {
+    expect(previewRemark('plain text')).toBe('plain text');
+    expect(previewRemark('{{EMAIL}}')).toBe('john');
+    expect(previewRemark('{{EMAIL}} · {{TRAFFIC_LEFT}} · {{DAYS_LEFT}}d')).toBe('john · 41.60GB · 12d');
+    expect(previewRemark('{{NOT_A_TOKEN}}')).toBe('');
+  });
+
+  it('every catalog token previews to its own sample', () => {
+    for (const v of REMARK_VARIABLES) {
+      expect(v.sample.length).toBeGreaterThan(0);
+      expect(previewRemark(wrapToken(v.token))).toBe(v.sample);
+    }
+  });
+});

+ 131 - 0
internal/database/db.go

@@ -72,6 +72,7 @@ func initModels() error {
 		&model.ClientExternalLink{},
 		&model.ClientGroup{},
 		&model.InboundFallback{},
+		&model.Host{},
 		&model.NodeClientTraffic{},
 		&model.NodeClientIp{},
 		&model.ClientGlobalTraffic{},
@@ -93,6 +94,9 @@ func initModels() error {
 	if err := pruneOrphanedClientInbounds(); err != nil {
 		return err
 	}
+	if err := pruneOrphanedHosts(); err != nil {
+		return err
+	}
 	if err := normalizeInboundSubSortIndex(); err != nil {
 		return err
 	}
@@ -116,6 +120,127 @@ func dropLegacyForeignKeys() error {
 	return nil
 }
 
+// seedHostsFromExternalProxy is a one-time, self-gated migration that creates a
+// Host row for every legacy externalProxy entry on every inbound. Additive: the
+// externalProxy arrays are left intact in StreamSettings.
+func seedHostsFromExternalProxy() error {
+	var history []string
+	if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
+		return err
+	}
+	if slices.Contains(history, "HostsFromExternalProxy") {
+		return nil
+	}
+
+	var inbounds []model.Inbound
+	if err := db.Find(&inbounds).Error; err != nil {
+		return err
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		for _, inbound := range inbounds {
+			if strings.TrimSpace(inbound.StreamSettings) == "" {
+				continue
+			}
+			var stream map[string]any
+			if err := json.Unmarshal([]byte(inbound.StreamSettings), &stream); err != nil {
+				log.Printf("HostsFromExternalProxy: skip inbound %d (invalid stream json): %v", inbound.Id, err)
+				continue
+			}
+			eps, ok := stream["externalProxy"].([]any)
+			if !ok || len(eps) == 0 {
+				continue
+			}
+			for i, raw := range eps {
+				ep, ok := raw.(map[string]any)
+				if !ok {
+					continue
+				}
+				if err := tx.Create(externalProxyEntryToHost(inbound.Id, i, ep)).Error; err != nil {
+					return err
+				}
+			}
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "HostsFromExternalProxy"}).Error
+	})
+}
+
+// externalProxyEntryToHost maps one legacy externalProxy entry onto a Host.
+// forceTls (same|tls|none) maps straight to Security; an unknown value falls back
+// to "same" (inherit). An empty remark gets a stable generated label so the row
+// stays valid/editable, and the remark is capped at the model's 256-char limit.
+func externalProxyEntryToHost(inboundId, index int, ep map[string]any) *model.Host {
+	security, _ := ep["forceTls"].(string)
+	switch security {
+	case "same", "tls", "none":
+	default:
+		security = "same"
+	}
+	dest, _ := ep["dest"].(string)
+	port := 0
+	if p, ok := ep["port"].(float64); ok {
+		port = int(p)
+	}
+	remark, _ := ep["remark"].(string)
+	if strings.TrimSpace(remark) == "" {
+		remark = "imported " + strconv.Itoa(index+1)
+	}
+	if len(remark) > 256 {
+		remark = remark[:256]
+	}
+	sni, _ := ep["sni"].(string)
+	fingerprint, _ := ep["fingerprint"].(string)
+	ech, _ := ep["echConfigList"].(string)
+	return &model.Host{
+		InboundId:            inboundId,
+		SortOrder:            index,
+		Remark:               remark,
+		Address:              dest,
+		Port:                 port,
+		Security:             security,
+		Sni:                  sni,
+		Fingerprint:          fingerprint,
+		Alpn:                 anyToNonEmptyStrings(ep["alpn"]),
+		PinnedPeerCertSha256: anyToNonEmptyStrings(ep["pinnedPeerCertSha256"]),
+		EchConfigList:        ech,
+	}
+}
+
+func anyToNonEmptyStrings(v any) []string {
+	switch t := v.(type) {
+	case []any:
+		out := make([]string, 0, len(t))
+		for _, e := range t {
+			if s, ok := e.(string); ok && s != "" {
+				out = append(out, s)
+			}
+		}
+		return out
+	case []string:
+		out := make([]string, 0, len(t))
+		for _, s := range t {
+			if s != "" {
+				out = append(out, s)
+			}
+		}
+		return out
+	default:
+		return nil
+	}
+}
+
+func pruneOrphanedHosts() error {
+	res := db.Exec("DELETE FROM hosts WHERE inbound_id NOT IN (SELECT id FROM inbounds)")
+	if res.Error != nil {
+		log.Printf("Error pruning orphaned hosts rows: %v", res.Error)
+		return res.Error
+	}
+	if res.RowsAffected > 0 {
+		log.Printf("Pruned %d orphaned hosts row(s)", res.RowsAffected)
+	}
+	return nil
+}
+
 func pruneOrphanedClientInbounds() error {
 	res := db.Exec("DELETE FROM client_inbounds WHERE inbound_id NOT IN (SELECT id FROM inbounds)")
 	if res.Error != nil {
@@ -294,6 +419,12 @@ func runSeeders(isUsersEmpty bool) error {
 			return err
 		}
 	}
+
+	// Self-gated on the "HostsFromExternalProxy" row, so it is safe to call
+	// unconditionally here.
+	if err := seedHostsFromExternalProxy(); err != nil {
+		return err
+	}
 	return nil
 }
 

+ 151 - 0
internal/database/host_migration_test.go

@@ -0,0 +1,151 @@
+package database
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func initMigrateDB(t *testing.T) {
+	t.Helper()
+	if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+}
+
+func seedInboundWithStream(t *testing.T, tag string, port int, stream string) *model.Inbound {
+	t.Helper()
+	ib := &model.Inbound{
+		UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS,
+		Remark: tag, Settings: `{"clients":[]}`, StreamSettings: stream,
+	}
+	if err := GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound %s: %v", tag, err)
+	}
+	return ib
+}
+
+const epMigrationStream = `{"network":"ws","security":"tls","externalProxy":[
+	{"forceTls":"tls","dest":"a.cdn.com","port":8443,"remark":"A","sni":"a.sni","fingerprint":"chrome","alpn":["h2","h3"],"pinnedPeerCertSha256":["AAAA"],"echConfigList":"ECHV"},
+	{"forceTls":"none","dest":"b.cdn.com","port":80,"remark":"B"}
+]}`
+
+// #1 — each externalProxy entry becomes one host row with the exact field
+// mapping; sort_order is the entry index; inbound_id is correct.
+func TestMigrate_ExternalProxyToHosts(t *testing.T) {
+	initMigrateDB(t)
+	ib := seedInboundWithStream(t, "m1", 5551, epMigrationStream)
+
+	if err := seedHostsFromExternalProxy(); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+
+	var hosts []model.Host
+	if err := GetDB().Where("inbound_id = ?", ib.Id).Order("sort_order asc").Find(&hosts).Error; err != nil {
+		t.Fatalf("load hosts: %v", err)
+	}
+	if len(hosts) != 2 {
+		t.Fatalf("hosts = %d, want 2", len(hosts))
+	}
+	a := hosts[0]
+	if a.InboundId != ib.Id || a.SortOrder != 0 || a.Security != "tls" || a.Address != "a.cdn.com" ||
+		a.Port != 8443 || a.Remark != "A" || a.Sni != "a.sni" || a.Fingerprint != "chrome" || a.EchConfigList != "ECHV" {
+		t.Fatalf("host A mapping wrong: %+v", a)
+	}
+	if len(a.Alpn) != 2 || a.Alpn[0] != "h2" || a.Alpn[1] != "h3" {
+		t.Fatalf("host A alpn = %v, want [h2 h3]", a.Alpn)
+	}
+	if len(a.PinnedPeerCertSha256) != 1 || a.PinnedPeerCertSha256[0] != "AAAA" {
+		t.Fatalf("host A pins = %v, want [AAAA]", a.PinnedPeerCertSha256)
+	}
+	b := hosts[1]
+	if b.InboundId != ib.Id || b.SortOrder != 1 || b.Security != "none" || b.Address != "b.cdn.com" ||
+		b.Port != 80 || b.Remark != "B" {
+		t.Fatalf("host B mapping wrong: %+v", b)
+	}
+}
+
+// #2 — a second run is a no-op (the HistoryOfSeeders gate).
+func TestMigrate_Idempotent(t *testing.T) {
+	initMigrateDB(t)
+	seedInboundWithStream(t, "m2", 5552, epMigrationStream)
+
+	if err := seedHostsFromExternalProxy(); err != nil {
+		t.Fatalf("first run: %v", err)
+	}
+	if err := seedHostsFromExternalProxy(); err != nil {
+		t.Fatalf("second run: %v", err)
+	}
+	var count int64
+	GetDB().Model(&model.Host{}).Count(&count)
+	if count != 2 {
+		t.Fatalf("host count = %d, want 2 (second run must be a no-op)", count)
+	}
+}
+
+// #3 — inbounds without externalProxy create no hosts.
+func TestMigrate_NoExternalProxy_NoHosts(t *testing.T) {
+	initMigrateDB(t)
+	seedInboundWithStream(t, "m3", 5553, `{"network":"tcp","security":"none"}`)
+
+	if err := seedHostsFromExternalProxy(); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+	var count int64
+	GetDB().Model(&model.Host{}).Count(&count)
+	if count != 0 {
+		t.Fatalf("host count = %d, want 0", count)
+	}
+}
+
+// #4 — externalProxy stays in StreamSettings (additive, rollback-safe).
+func TestMigrate_KeepsExternalProxyIntact(t *testing.T) {
+	initMigrateDB(t)
+	ib := seedInboundWithStream(t, "m4", 5554, epMigrationStream)
+
+	if err := seedHostsFromExternalProxy(); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+	var got model.Inbound
+	if err := GetDB().First(&got, ib.Id).Error; err != nil {
+		t.Fatalf("reload inbound: %v", err)
+	}
+	if !strings.Contains(got.StreamSettings, "externalProxy") || !strings.Contains(got.StreamSettings, "a.cdn.com") {
+		t.Fatalf("externalProxy must remain in StreamSettings: %s", got.StreamSettings)
+	}
+}
+
+// #5 — same against a real Postgres DSN (sequence resync); skips without a DSN.
+func TestMigrate_Postgres(t *testing.T) {
+	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres migration test")
+	}
+	if err := InitDB(""); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+	// Clean slate so this run owns the migration regardless of prior tests.
+	GetDB().Exec("TRUNCATE TABLE hosts, inbounds RESTART IDENTITY CASCADE")
+	GetDB().Where("seeder_name = ?", "HostsFromExternalProxy").Delete(&model.HistoryOfSeeders{})
+
+	seedInboundWithStream(t, "mpg", 5555, epMigrationStream)
+	if err := seedHostsFromExternalProxy(); err != nil {
+		t.Fatalf("migrate pg: %v", err)
+	}
+	var count int64
+	GetDB().Model(&model.Host{}).Count(&count)
+	if count != 2 {
+		t.Fatalf("pg host count = %d, want 2", count)
+	}
+	if err := seedHostsFromExternalProxy(); err != nil {
+		t.Fatalf("migrate pg (2nd): %v", err)
+	}
+	GetDB().Model(&model.Host{}).Count(&count)
+	if count != 2 {
+		t.Fatalf("pg host count after 2nd run = %d, want 2 (idempotent)", count)
+	}
+}

+ 84 - 0
internal/database/host_test.go

@@ -0,0 +1,84 @@
+package database
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// hostColumns is the set of columns initModels must create for the hosts table.
+func hostColumns() []string {
+	return []string{
+		"id", "inbound_id", "sort_order", "remark", "server_description", "is_disabled", "is_hidden", "tags",
+		"address", "port",
+		"security", "sni", "host_header", "path", "alpn", "fingerprint",
+		"override_sni_from_address", "keep_sni_blank", "pinned_peer_cert_sha256",
+		"verify_peer_cert_by_name", "allow_insecure", "ech_config_list",
+		"mux_params", "sockopt_params", "final_mask", "vless_route",
+		"exclude_from_sub_types", "mihomo_ip_version", "mihomo_x25519", "shuffle_host", "node_guids",
+		"created_at", "updated_at",
+	}
+}
+
+func assertHostSchema(t *testing.T) {
+	t.Helper()
+	m := GetDB().Migrator()
+	if !m.HasTable("hosts") {
+		t.Fatalf("hosts table not created by initModels")
+	}
+	for _, col := range hostColumns() {
+		if !m.HasColumn(&model.Host{}, col) {
+			t.Fatalf("hosts table missing column %q", col)
+		}
+	}
+}
+
+// TestHostAutoMigrateCreatesColumns verifies the hosts table and every expected
+// column exist after initModels (SQLite).
+func TestHostAutoMigrateCreatesColumns(t *testing.T) {
+	if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+	assertHostSchema(t)
+}
+
+// TestHostAutoMigrateCreatesColumns_Postgres is the dual-driver counterpart.
+func TestHostAutoMigrateCreatesColumns_Postgres(t *testing.T) {
+	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres schema test")
+	}
+	if err := InitDB(""); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+	assertHostSchema(t)
+}
+
+// TestPruneOrphanedHosts verifies a host whose inbound_id has no matching inbound
+// is removed by the prune step.
+func TestPruneOrphanedHosts(t *testing.T) {
+	if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+	db := GetDB()
+
+	orphan := &model.Host{InboundId: 99999, Remark: "orphan"}
+	if err := db.Create(orphan).Error; err != nil {
+		t.Fatalf("create orphan host: %v", err)
+	}
+	if err := pruneOrphanedHosts(); err != nil {
+		t.Fatalf("pruneOrphanedHosts: %v", err)
+	}
+	var cnt int64
+	if err := db.Model(&model.Host{}).Where("id = ?", orphan.Id).Count(&cnt).Error; err != nil {
+		t.Fatalf("count: %v", err)
+	}
+	if cnt != 0 {
+		t.Fatalf("orphan host not pruned, count=%d", cnt)
+	}
+}

+ 1 - 0
internal/database/migrate_data.go

@@ -49,6 +49,7 @@ func migrationModels() []any {
 		&model.ClientInbound{},
 		&model.ClientExternalLink{},
 		&model.InboundFallback{},
+		&model.Host{},
 		&model.NodeClientTraffic{},
 		&model.NodeClientIp{},
 		&model.OutboundSubscription{},

+ 45 - 0
internal/database/model/host_test.go

@@ -0,0 +1,45 @@
+package model
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/go-playground/validator/v10"
+)
+
+// TestHostTableName locks the table name the rest of the feature (queries,
+// prune, migration) keys off.
+func TestHostTableName(t *testing.T) {
+	if got := (Host{}).TableName(); got != "hosts" {
+		t.Fatalf("Host.TableName() = %q, want hosts", got)
+	}
+}
+
+// TestHostValidation locks the struct-tag constraints enforced by the request
+// binder (middleware.BindAndValidate -> validate.Struct).
+func TestHostValidation(t *testing.T) {
+	v := validator.New(validator.WithRequiredStructEnabled())
+
+	valid := Host{InboundId: 1, Remark: "cdn-front", Port: 8443, Security: "tls", MihomoIpVersion: "dual"}
+	if err := v.Struct(valid); err != nil {
+		t.Fatalf("valid host rejected: %v", err)
+	}
+
+	bad := []struct {
+		name string
+		h    Host
+	}{
+		{"missing inbound", Host{Remark: "ok"}},
+		{"empty remark", Host{InboundId: 1, Remark: ""}},
+		{"remark too long", Host{InboundId: 1, Remark: strings.Repeat("x", 257)}},
+		{"port too high", Host{InboundId: 1, Remark: "ok", Port: 70000}},
+		{"port negative", Host{InboundId: 1, Remark: "ok", Port: -1}},
+		{"bad security", Host{InboundId: 1, Remark: "ok", Security: "bogus"}},
+		{"bad mihomo ip version", Host{InboundId: 1, Remark: "ok", MihomoIpVersion: "nope"}},
+	}
+	for _, tc := range bad {
+		if err := v.Struct(tc.h); err == nil {
+			t.Fatalf("%s: expected validation error, got nil", tc.name)
+		}
+	}
+}

+ 54 - 0
internal/database/model/model.go

@@ -718,6 +718,60 @@ type InboundFallback struct {
 
 func (InboundFallback) TableName() string { return "inbound_fallbacks" }
 
+// Host is an override endpoint attached to an inbound: at subscription time each
+// enabled host renders one share link/proxy with its own address/port/TLS/etc.,
+// superseding the legacy externalProxy array. Free-JSON fields are stored as
+// text and parsed in the sub layer; slice fields use the json serializer.
+type Host struct {
+	Id                int      `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
+	InboundId         int      `json:"inboundId" form:"inboundId" gorm:"index;not null;column:inbound_id" validate:"required" example:"1"`
+	SortOrder         int      `json:"sortOrder" form:"sortOrder" gorm:"default:0;column:sort_order"`
+	Remark            string   `json:"remark" form:"remark" validate:"required,max=256" example:"cdn-front"`
+	ServerDescription string   `json:"serverDescription" form:"serverDescription" gorm:"column:server_description" validate:"omitempty,max=64"`
+	IsDisabled        bool     `json:"isDisabled" form:"isDisabled" gorm:"default:false;column:is_disabled"`
+	IsHidden          bool     `json:"isHidden" form:"isHidden" gorm:"default:false;column:is_hidden"`
+	Tags              []string `json:"tags" form:"tags" gorm:"serializer:json"`
+
+	Address string `json:"address" form:"address" example:"cdn.example.com"`
+	Port    int    `json:"port" form:"port" gorm:"default:0" validate:"gte=0,lte=65535" example:"8443"`
+
+	Security               string   `json:"security" form:"security" gorm:"default:same" validate:"omitempty,oneof=same tls none reality" example:"same"`
+	Sni                    string   `json:"sni" form:"sni"`
+	HostHeader             string   `json:"hostHeader" form:"hostHeader" gorm:"column:host_header"`
+	Path                   string   `json:"path" form:"path"`
+	Alpn                   []string `json:"alpn" form:"alpn" gorm:"serializer:json"`
+	Fingerprint            string   `json:"fingerprint" form:"fingerprint"`
+	OverrideSniFromAddress bool     `json:"overrideSniFromAddress" form:"overrideSniFromAddress" gorm:"column:override_sni_from_address"`
+	KeepSniBlank           bool     `json:"keepSniBlank" form:"keepSniBlank" gorm:"column:keep_sni_blank"`
+	PinnedPeerCertSha256   []string `json:"pinnedPeerCertSha256" form:"pinnedPeerCertSha256" gorm:"serializer:json;column:pinned_peer_cert_sha256"`
+	VerifyPeerCertByName   bool     `json:"verifyPeerCertByName" form:"verifyPeerCertByName" gorm:"column:verify_peer_cert_by_name"`
+	AllowInsecure          bool     `json:"allowInsecure" form:"allowInsecure" gorm:"column:allow_insecure"`
+	EchConfigList          string   `json:"echConfigList" form:"echConfigList" gorm:"column:ech_config_list"`
+
+	MuxParams     string `json:"muxParams" form:"muxParams" gorm:"type:text;column:mux_params"`
+	SockoptParams string `json:"sockoptParams" form:"sockoptParams" gorm:"type:text;column:sockopt_params"`
+	// FinalMask is a JSON object of xray finalmask masks (tcp/udp/quicParams),
+	// merged into this host's JSON-subscription stream. Empty = no override.
+	FinalMask string `json:"finalMask" form:"finalMask" gorm:"type:text;column:final_mask"`
+
+	// VlessRoute is a free-form port/range routing spec (e.g. "53,443,1000-2000");
+	// stored verbatim, format-validated on the frontend.
+	VlessRoute string `json:"vlessRoute" form:"vlessRoute" gorm:"column:vless_route"`
+
+	ExcludeFromSubTypes []string `json:"excludeFromSubTypes" form:"excludeFromSubTypes" gorm:"serializer:json;column:exclude_from_sub_types"`
+
+	MihomoIpVersion string `json:"mihomoIpVersion" form:"mihomoIpVersion" gorm:"column:mihomo_ip_version" validate:"omitempty,oneof=dual ipv4 ipv6 ipv4-prefer ipv6-prefer"`
+	MihomoX25519    bool   `json:"mihomoX25519" form:"mihomoX25519" gorm:"column:mihomo_x25519"`
+	ShuffleHost     bool   `json:"shuffleHost" form:"shuffleHost" gorm:"column:shuffle_host"`
+
+	NodeGuids []string `json:"nodeGuids,omitempty" form:"nodeGuids" gorm:"serializer:json;column:node_guids"`
+
+	CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
+	UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
+}
+
+func (Host) TableName() string { return "hosts" }
+
 func (c *Client) ToRecord() *ClientRecord {
 	rec := &ClientRecord{
 		Email:      c.Email,

+ 213 - 0
internal/sub/characterization_test.go

@@ -0,0 +1,213 @@
+package sub
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// Characterization snapshots (Phase 0 of the Hosts feature). These lock the
+// CURRENT subscription-link output for the externalProxy paths so the Phase-1
+// ShareEndpoint refactor can be proven behavior-preserving: they must pass on
+// unchanged code and stay green, unedited, through every later phase. Assertions
+// are exact (==) where output is deterministic and Contains where a value is
+// randomized (reality spx) or hex-derived.
+
+const charClient = `{"id":"11111111-2222-4333-8444-555555555555","email":"user"}`
+
+// charVlessInbound builds a VLESS inbound with one client "user".
+func charVlessInbound(stream string) *model.Inbound {
+	return &model.Inbound{
+		Listen:         "203.0.113.1",
+		Port:           443,
+		Protocol:       model.VLESS,
+		Remark:         "char",
+		Settings:       `{"clients":[` + charClient + `],"decryption":"none","encryption":"none"}`,
+		StreamSettings: stream,
+	}
+}
+
+// C1 — VLESS, TLS base, 2 externalProxy entries (forceTls tls + none). Locks
+// buildExternalProxyURLLinks, applyExternalProxyTLSParams, the none-strip path,
+// per-entry ordering, and the "\n" join.
+func TestChar_C1_VlessExternalProxy(t *testing.T) {
+	stream := `{
+		"network":"tcp","security":"tls",
+		"tcpSettings":{"header":{"type":"none"}},
+		"tlsSettings":{"serverName":"base.sni","alpn":["h2"],"settings":{"fingerprint":"chrome"}},
+		"externalProxy":[
+			{"forceTls":"tls","dest":"cdn1.example.com","port":8443,"remark":"R1","sni":"sni1.example.com","fingerprint":"firefox","alpn":["h3","h2"],"pinnedPeerCertSha256":["UElO"]},
+			{"forceTls":"none","dest":"cdn2.example.com","port":80,"remark":"R2"}
+		]
+	}`
+	s := &SubService{}
+	got := s.genVlessLink(charVlessInbound(stream), "user")
+	want := "vless://[email protected]:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1\n" +
+		"vless://[email protected]:80?encryption=none&security=none&type=tcp#char-R2"
+	if got != want {
+		t.Fatalf("C1 mismatch.\n got: %q\nwant: %q", got, want)
+	}
+}
+
+// C4 — VLESS reality base + 1 externalProxy with forceTls "same". Locks the
+// "same keeps the base security (reality)" passthrough. spx is randomized so the
+// fixed fields are asserted by Contains.
+func TestChar_C4_VlessRealitySame(t *testing.T) {
+	stream := `{
+		"network":"tcp","security":"reality",
+		"tcpSettings":{"header":{"type":"none"}},
+		"realitySettings":{"serverNames":["reality.example.com"],"shortIds":["ab12cd"],"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}},
+		"externalProxy":[{"forceTls":"same","dest":"cdn.example.com","port":2053,"remark":"RS"}]
+	}`
+	s := &SubService{}
+	got := s.genVlessLink(charVlessInbound(stream), "user")
+	wants := []string{
+		"vless://[email protected]:2053",
+		"security=reality",
+		"sni=reality.example.com",
+		"pbk=PBKvalue",
+		"sid=ab12cd",
+		"fp=firefox",
+		"#char-RS",
+	}
+	for _, w := range wants {
+		if !strings.Contains(got, w) {
+			t.Fatalf("C4 missing %q\n got: %s", w, got)
+		}
+	}
+	if strings.Count(got, "\n") != 0 {
+		t.Fatalf("C4 expected a single link, got: %s", got)
+	}
+}
+
+// C2 — VMess, TLS base, 2 externalProxy entries (forceTls same + none). Locks
+// buildVmessExternalProxyLinks, cloneVmessShareObj strip, the obj["tls"] rewrite,
+// and the int port. Asserts on the decoded JSON objects.
+func TestChar_C2_VmessExternalProxy(t *testing.T) {
+	stream := `{
+		"network":"tcp","security":"tls",
+		"tcpSettings":{"header":{"type":"none"}},
+		"tlsSettings":{"serverName":"base.sni","alpn":["h2"],"settings":{"fingerprint":"chrome"}},
+		"externalProxy":[
+			{"forceTls":"same","dest":"vm1.example.com","port":8443,"remark":"V1","sni":"sni1.example.com"},
+			{"forceTls":"none","dest":"vm2.example.com","port":80,"remark":"V2"}
+		]
+	}`
+	in := &model.Inbound{
+		Listen:         "203.0.113.1",
+		Port:           443,
+		Protocol:       model.VMESS,
+		Remark:         "char",
+		Settings:       `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user","security":"auto"}]}`,
+		StreamSettings: stream,
+	}
+	s := &SubService{}
+	got := s.genVmessLink(in, "user")
+	want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMSIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogInNuaTEuZXhhbXBsZS5jb20iLAogICJ0bHMiOiAidGxzIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9\n" +
+		"vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMiIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
+	if got != want {
+		t.Fatalf("C2 mismatch.\n got: %q\nwant: %q", got, want)
+	}
+	// Sanity: decode both objects so a structural change is visible too.
+	for i, part := range strings.Split(got, "\n") {
+		raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(part, "vmess://"))
+		if err != nil {
+			t.Fatalf("C2 link %d not base64: %v", i, err)
+		}
+		var obj map[string]any
+		if err := json.Unmarshal(raw, &obj); err != nil {
+			t.Fatalf("C2 link %d not json: %v", i, err)
+		}
+	}
+}
+
+// C3a — Trojan, TLS base, 1 externalProxy entry. Locks userinfo encoding through
+// the shared builder.
+func TestChar_C3_TrojanExternalProxy(t *testing.T) {
+	stream := `{
+		"network":"tcp","security":"tls",
+		"tcpSettings":{"header":{"type":"none"}},
+		"tlsSettings":{"serverName":"base.sni","settings":{"fingerprint":"chrome"}},
+		"externalProxy":[{"forceTls":"tls","dest":"tj.example.com","port":8443,"remark":"TJ","sni":"tj.sni"}]
+	}`
+	in := &model.Inbound{
+		Listen:         "203.0.113.1",
+		Port:           443,
+		Protocol:       model.Trojan,
+		Remark:         "char",
+		Settings:       `{"clients":[{"password":"p@ss/w+rd=","email":"user"}]}`,
+		StreamSettings: stream,
+	}
+	s := &SubService{}
+	got := s.genTrojanLink(in, "user")
+	want := "trojan://p%40ss%2Fw%2Brd%[email protected]:8443?fp=chrome&security=tls&sni=tj.sni&type=tcp#char-TJ"
+	if got != want {
+		t.Fatalf("C3-Trojan mismatch.\n got: %q\nwant: %q", got, want)
+	}
+}
+
+// C3b — Shadowsocks 2022 (method[0]=='2'), TLS base, 1 externalProxy entry.
+// Locks the ss-2022 triple-segment userinfo path through the shared builder.
+func TestChar_C3_ShadowsocksExternalProxy(t *testing.T) {
+	stream := `{
+		"network":"tcp","security":"tls",
+		"tcpSettings":{"header":{"type":"none"}},
+		"tlsSettings":{"serverName":"base.sni","settings":{"fingerprint":"chrome"}},
+		"externalProxy":[{"forceTls":"tls","dest":"ss.example.com","port":8443,"remark":"SS","sni":"ss.sni"}]
+	}`
+	in := &model.Inbound{
+		Listen:         "203.0.113.1",
+		Port:           443,
+		Protocol:       model.Shadowsocks,
+		Remark:         "char",
+		Settings:       `{"method":"2022-blake3-aes-256-gcm","password":"inboundpw","clients":[{"password":"clientpw","email":"user"}]}`,
+		StreamSettings: stream,
+	}
+	s := &SubService{}
+	got := s.genShadowsocksLink(in, "user")
+	want := "ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206aW5ib3VuZHB3OmNsaWVudHB3@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS"
+	if got != want {
+		t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want)
+	}
+}
+
+// C6 — Hysteria2, TLS, 1 externalProxy entry with a cert pin. Guards that the
+// Hysteria generator stays on its own path (hex pinSHA256, not pcs) and is NOT
+// folded into the unified builder. Pin hex is derived, so Contains is used.
+func TestChar_C6_HysteriaExternalProxy(t *testing.T) {
+	// base64 of 32 zero bytes -> a valid pin shape for hysteriaPinHex.
+	pin := base64.StdEncoding.EncodeToString(make([]byte, 32))
+	stream := `{
+		"security":"tls",
+		"tlsSettings":{"serverName":"hy.sni","alpn":["h3"],"settings":{"fingerprint":"chrome"}},
+		"externalProxy":[{"forceTls":"same","dest":"hop.example.com","port":9443,"remark":"H1","pinnedPeerCertSha256":["` + pin + `"]}]
+	}`
+	in := &model.Inbound{
+		Listen:         "203.0.113.1",
+		Port:           443,
+		Protocol:       model.Hysteria,
+		Remark:         "char",
+		Settings:       `{"version":2,"clients":[{"auth":"hyauth","email":"user"}]}`,
+		StreamSettings: stream,
+	}
+	s := &SubService{}
+	got := s.genHysteriaLink(in, "user")
+	wants := []string{
+		"hysteria2://[email protected]:9443",
+		"security=tls",
+		"sni=hy.sni",
+		"pinSHA256=",
+		"#char-H1",
+	}
+	for _, w := range wants {
+		if !strings.Contains(got, w) {
+			t.Fatalf("C6 missing %q\n got: %s", w, got)
+		}
+	}
+	if strings.Contains(got, "pcs=") {
+		t.Fatalf("C6 hysteria must not use pcs=, got: %s", got)
+	}
+}

+ 24 - 6
internal/sub/clash_service.go

@@ -23,6 +23,7 @@ func NewSubClashService(enableRouting bool, clashRules string, subService *SubSe
 
 func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
 	subReq := s.SubService.ForRequest(host)
+	subReq.subscriptionBody = true
 	inbounds, err := subReq.getInboundsBySubId(subId)
 	if err != nil {
 		return "", "", err
@@ -44,6 +45,9 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 			continue
 		}
 		subReq.projectThroughFallbackMaster(inbound)
+		if hostEps := subReq.hostEndpoints(inbound, "clash"); len(hostEps) > 0 {
+			injectExternalProxy(inbound, hostEps)
+		}
 		for _, client := range clients {
 			seenEmails[client.Email] = struct{}{}
 			proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
@@ -163,6 +167,9 @@ func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound,
 	proxies := make([]map[string]any, 0, len(externalProxies))
 	for _, ep := range externalProxies {
 		extPrxy := ep.(map[string]any)
+		// Expand the host's {{VAR}} remark template for this client (no-op for
+		// the synthetic/legacy entry) before it becomes the proxy name.
+		subReq.renderHostRemark(inbound, client, extPrxy)
 		workingInbound := *inbound
 		workingInbound.Listen = extPrxy["dest"].(string)
 		workingInbound.Port = int(extPrxy["port"].(float64))
@@ -185,24 +192,30 @@ func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound,
 		if hasExternalProxy {
 			applyExternalProxyTLSToStream(extPrxy, workingStream, security)
 		}
+		applyHostStreamOverrides(extPrxy, workingStream)
 
-		proxy := s.buildProxy(subReq, &workingInbound, client, workingStream, extPrxy["remark"].(string))
+		proxy := s.buildProxy(subReq, &workingInbound, client, workingStream, extPrxy)
 		if len(proxy) > 0 {
+			// Host-only mihomo knob: ip-version is a top-level proxy field, set
+			// last so it cannot be clobbered. Absent for legacy externalProxy.
+			if v, _ := extPrxy["mihomoIpVersion"].(string); v != "" {
+				proxy["ip-version"] = v
+			}
 			proxies = append(proxies, proxy)
 		}
 	}
 	return proxies
 }
 
-func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
+func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound, client model.Client, stream map[string]any, ep map[string]any) map[string]any {
 	// Hysteria has its own transport + TLS model, applyTransport /
 	// applySecurity don't fit.
 	if inbound.Protocol == model.Hysteria {
-		return s.buildHysteriaProxy(subReq, inbound, client, extraRemark)
+		return s.buildHysteriaProxy(subReq, inbound, client, ep)
 	}
 
 	proxy := map[string]any{
-		"name":   subReq.genRemark(inbound, client.Email, extraRemark),
+		"name":   subReq.endpointRemark(inbound, client.Email, ep),
 		"server": inbound.Listen,
 		"port":   inbound.Port,
 		"udp":    true,
@@ -273,7 +286,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
 // directly instead of going through streamData/tlsData, because those
 // helpers prune fields (like `allowInsecure` / the salamander obfs
 // block) that the hysteria proxy wants preserved.
-func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.Inbound, client model.Client, extraRemark string) map[string]any {
+func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
 	var inboundSettings map[string]any
 	_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
 
@@ -285,7 +298,7 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
 	}
 
 	proxy := map[string]any{
-		"name":   subReq.genRemark(inbound, client.Email, extraRemark),
+		"name":   subReq.endpointRemark(inbound, client.Email, ep),
 		"type":   proxyType,
 		"server": inbound.Listen,
 		"port":   inbound.Port,
@@ -482,6 +495,11 @@ func (s *SubClashService) applySecurity(proxy map[string]any, security string, s
 					proxy["alpn"] = out
 				}
 			}
+			if inner, ok := tlsSettings["settings"].(map[string]any); ok {
+				if insecure, ok := inner["allowInsecure"].(bool); ok && insecure {
+					proxy["skip-cert-verify"] = true
+				}
+			}
 		}
 		return true
 	case "reality":

+ 10 - 10
internal/sub/clash_service_test.go

@@ -46,7 +46,7 @@ func TestEnsureUniqueProxyNames(t *testing.T) {
 // public-key, short-id, or client-fingerprint would hand mihomo a broken reality
 // proxy. The existing clash tests don't assert any of these.
 func TestBuildProxy_VLESSRealityFieldsForClash(t *testing.T) {
-	svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
+	svc := &SubClashService{SubService: &SubService{}}
 	inbound := &model.Inbound{Listen: "203.0.113.1", Port: 443, Protocol: model.VLESS, Remark: "r", Settings: `{"encryption":"none"}`}
 	client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
 	stream := map[string]any{
@@ -56,7 +56,7 @@ func TestBuildProxy_VLESSRealityFieldsForClash(t *testing.T) {
 		"realitySettings": map[string]any{"serverName": "reality.example.com", "publicKey": "PBKvalue", "shortId": "ab12", "fingerprint": "chrome"},
 	}
 
-	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
+	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
 	if proxy == nil {
 		t.Fatal("buildProxy returned nil for a valid reality stream")
 	}
@@ -175,7 +175,7 @@ func TestApplyTransport_HTTPUpgrade(t *testing.T) {
 }
 
 func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testing.T) {
-	svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
+	svc := &SubClashService{SubService: &SubService{}}
 	encryption := "mlkem768x25519plus.native.0rtt.client"
 	inbound := &model.Inbound{
 		Listen:   "203.0.113.1",
@@ -199,7 +199,7 @@ func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testi
 		},
 	}
 
-	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
+	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
 
 	if proxy["encryption"] != encryption {
 		t.Fatalf("encryption = %v, want %q", proxy["encryption"], encryption)
@@ -207,7 +207,7 @@ func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testi
 }
 
 func TestBuildProxy_VLESSFlowXhttpRealityVlessenc(t *testing.T) {
-	svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
+	svc := &SubClashService{SubService: &SubService{}}
 	encryption := "mlkem768x25519plus.native.0rtt.client"
 	inbound := &model.Inbound{
 		Listen:   "203.0.113.1",
@@ -231,7 +231,7 @@ func TestBuildProxy_VLESSFlowXhttpRealityVlessenc(t *testing.T) {
 		},
 	}
 
-	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
+	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
 
 	if proxy["flow"] != "xtls-rprx-vision" {
 		t.Fatalf("xhttp+reality+vlessenc Clash proxy must carry the vision flow (#5232): %#v", proxy)
@@ -239,7 +239,7 @@ func TestBuildProxy_VLESSFlowXhttpRealityVlessenc(t *testing.T) {
 }
 
 func TestBuildProxy_VLESSFlowDroppedWithoutVisionSupport(t *testing.T) {
-	svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
+	svc := &SubClashService{SubService: &SubService{}}
 	inbound := &model.Inbound{
 		Listen:   "203.0.113.1",
 		Port:     443,
@@ -256,7 +256,7 @@ func TestBuildProxy_VLESSFlowDroppedWithoutVisionSupport(t *testing.T) {
 		},
 	}
 
-	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
+	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
 
 	if _, ok := proxy["flow"]; ok {
 		t.Fatalf("tcp without tls/reality must not carry a flow: %#v", proxy)
@@ -264,7 +264,7 @@ func TestBuildProxy_VLESSFlowDroppedWithoutVisionSupport(t *testing.T) {
 }
 
 func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
-	svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
+	svc := &SubClashService{SubService: &SubService{}}
 	inbound := &model.Inbound{
 		Listen:   "203.0.113.1",
 		Port:     443,
@@ -281,7 +281,7 @@ func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
 		},
 	}
 
-	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
+	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
 
 	if _, ok := proxy["encryption"]; ok {
 		t.Fatalf("plain vless encryption should be omitted for mihomo: %#v", proxy)

+ 9 - 5
internal/sub/controller.go

@@ -74,8 +74,7 @@ func NewSUBController(
 	jsonEnabled bool,
 	clashEnabled bool,
 	encrypt bool,
-	showInfo bool,
-	rModel string,
+	remarkTemplate string,
 	update string,
 	jsonMux string,
 	jsonRules string,
@@ -89,7 +88,7 @@ func NewSUBController(
 	subEnableRouting bool,
 	subRoutingRules string,
 ) *SUBController {
-	sub := NewSubService(showInfo, rModel)
+	sub := NewSubService(remarkTemplate)
 	a := &SUBController{
 		subTitle:         subTitle,
 		subSupportUrl:    subSupportUrl,
@@ -138,6 +137,12 @@ func (a *SUBController) subs(c *gin.Context) {
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
 	subReq := a.subService.ForRequest(host)
+	// The remark template's per-client info is for the content a client app
+	// imports — the raw subscription body. A browser viewing the HTML info page
+	// gets clean, name-only remarks (usage is shown in the page summary).
+	accept := c.GetHeader("Accept")
+	wantsHTML := strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html")
+	subReq.subscriptionBody = !wantsHTML
 	subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
 	if err != nil || len(subs) == 0 {
 		writeSubError(c, err)
@@ -148,8 +153,7 @@ func (a *SUBController) subs(c *gin.Context) {
 		}
 
 		// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
-		accept := c.GetHeader("Accept")
-		if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
+		if wantsHTML {
 			subURL, subJsonURL, subClashURL := subReq.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
 			if !a.jsonEnabled {
 				subJsonURL = ""

+ 128 - 0
internal/sub/endpoint.go

@@ -0,0 +1,128 @@
+package sub
+
+import (
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// ShareEndpoint is one render target for a subscription link: the address/port
+// to dial plus an optional set of TLS overrides. It unifies two sources behind
+// one type so the per-protocol link builders don't branch on where the override
+// came from:
+//
+//   - a legacy externalProxy entry (Phase 1): the source map is carried in `ep`
+//     and applied through the unchanged applyExternalProxyTLS* helpers, so the
+//     emitted link is byte-identical to the pre-refactor output;
+//   - a Host row (Phase 4): leaves `ep` nil and uses typed override fields.
+//
+// ForceTls is the verbatim "same"/"tls"/"none"/"" value — never pre-resolved,
+// because three behaviors branch on the raw string (keep-base, obj["tls"]
+// rewrite, none-strip).
+type ShareEndpoint struct {
+	Address  string
+	Port     int
+	Remark   string // extra remark slot fed to genRemark, not a rendered remark
+	ForceTls string
+
+	// ep is the source externalProxy entry. nil for host/default endpoints.
+	ep map[string]any
+}
+
+// externalProxyToEndpoint maps one externalProxy entry to an endpoint that
+// carries the entry for delegated, provably-identical TLS application.
+func externalProxyToEndpoint(ep map[string]any) ShareEndpoint {
+	e := ShareEndpoint{ep: ep}
+	e.Address, _ = ep["dest"].(string)
+	if p, ok := ep["port"].(float64); ok {
+		e.Port = int(p)
+	}
+	e.Remark, _ = ep["remark"].(string)
+	e.ForceTls, _ = ep["forceTls"].(string)
+	return e
+}
+
+// inboundDefaultEndpoint is the endpoint for an inbound's own resolved
+// address/port (the no-externalProxy default). forceTls "same" keeps the base
+// security; no per-endpoint TLS override.
+func (s *SubService) inboundDefaultEndpoint(inbound *model.Inbound) ShareEndpoint {
+	return ShareEndpoint{
+		Address:  s.resolveInboundAddress(inbound),
+		Port:     inbound.Port,
+		ForceTls: "same",
+	}
+}
+
+// applyEndpointTLSParams applies an endpoint's TLS overrides onto a URL-param
+// map. External-proxy endpoints delegate to the unchanged helper; host/default
+// endpoints carry no override yet (Phase 4).
+func applyEndpointTLSParams(e ShareEndpoint, params map[string]string, security string) {
+	if e.ep != nil {
+		applyExternalProxyTLSParams(e.ep, params, security)
+	}
+}
+
+// applyEndpointTLSObj is applyEndpointTLSParams for the VMess base64-JSON form.
+func applyEndpointTLSObj(e ShareEndpoint, obj map[string]any, security string) {
+	if e.ep != nil {
+		applyExternalProxyTLSObj(e.ep, obj, security)
+	}
+}
+
+// buildEndpointLinks renders one URL-param link per endpoint (vless/trojan/ss).
+// securityToApply mirrors the legacy externalProxy loop: "same" keeps the base
+// security, otherwise the endpoint's forceTls wins; "none" strips TLS hint
+// fields at emit time.
+func (s *SubService) buildEndpointLinks(
+	eps []ShareEndpoint,
+	params map[string]string,
+	baseSecurity string,
+	makeLink func(dest string, port int) string,
+	makeRemark func(e ShareEndpoint) string,
+) string {
+	links := make([]string, 0, len(eps))
+	for _, e := range eps {
+		securityToApply := baseSecurity
+		if e.ForceTls != "same" {
+			securityToApply = e.ForceTls
+		}
+		nextParams := cloneStringMap(params)
+		applyEndpointTLSParams(e, nextParams, securityToApply)
+		applyEndpointRealityParams(e, nextParams, securityToApply)
+		applyEndpointHostPath(e, nextParams)
+		applyEndpointAllowInsecure(e, nextParams, securityToApply)
+		links = append(links, buildLinkWithParamsAndSecurity(
+			makeLink(e.Address, e.Port),
+			nextParams,
+			makeRemark(e),
+			securityToApply,
+			e.ForceTls == "none",
+		))
+	}
+	return strings.Join(links, "\n")
+}
+
+// buildEndpointVmessLinks renders one VMess base64-JSON link per endpoint.
+func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[string]any, inbound *model.Inbound, email string) string {
+	var links strings.Builder
+	for index, e := range eps {
+		securityToApply, _ := baseObj["tls"].(string)
+		if e.ForceTls != "same" {
+			securityToApply = e.ForceTls
+		}
+		newObj := cloneVmessShareObj(baseObj, e.ForceTls)
+		newObj["ps"] = s.endpointRemark(inbound, email, e.ep)
+		newObj["add"] = e.Address
+		newObj["port"] = e.Port
+		if e.ForceTls != "same" {
+			newObj["tls"] = e.ForceTls
+		}
+		applyEndpointTLSObj(e, newObj, securityToApply)
+		applyEndpointHostPathObj(e, newObj)
+		if index > 0 {
+			links.WriteString("\n")
+		}
+		links.WriteString(buildVmessLink(newObj))
+	}
+	return links.String()
+}

+ 113 - 0
internal/sub/endpoint_test.go

@@ -0,0 +1,113 @@
+package sub
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// N1 — externalProxyToEndpoint maps the scalar fields and carries the source
+// entry so delegated TLS application reproduces the legacy presence-tracked
+// overrides (absent key never clobbers an upstream value).
+func TestExternalProxyToEndpoint(t *testing.T) {
+	ep := map[string]any{
+		"forceTls": "tls",
+		"dest":     "cdn.example.com",
+		"port":     float64(8443),
+		"remark":   "R",
+		"sni":      "s.example.com",
+	}
+	e := externalProxyToEndpoint(ep)
+	if e.Address != "cdn.example.com" {
+		t.Fatalf("Address = %q, want cdn.example.com", e.Address)
+	}
+	if e.Port != 8443 {
+		t.Fatalf("Port = %d, want 8443", e.Port)
+	}
+	if e.ForceTls != "tls" {
+		t.Fatalf("ForceTls = %q, want tls", e.ForceTls)
+	}
+	if e.Remark != "R" {
+		t.Fatalf("Remark = %q, want R", e.Remark)
+	}
+	if e.ep == nil {
+		t.Fatalf("ep not carried; delegated TLS application would lose the source entry")
+	}
+	// Delegation preserves the sni override and does not invent absent fields.
+	params := map[string]string{}
+	applyEndpointTLSParams(e, params, "tls")
+	if params["sni"] != "s.example.com" {
+		t.Fatalf("delegated sni = %q, want s.example.com", params["sni"])
+	}
+	if _, ok := params["fp"]; ok {
+		t.Fatalf("absent fingerprint must not be set, got fp=%q", params["fp"])
+	}
+}
+
+// N2 — inboundDefaultEndpoint reproduces the no-externalProxy default: resolved
+// address + inbound port, forceTls "same", empty remark, no source entry.
+func TestInboundDefaultEndpoint(t *testing.T) {
+	in := &model.Inbound{Listen: "198.51.100.7", Port: 8080}
+	s := &SubService{}
+	e := s.inboundDefaultEndpoint(in)
+	if e.Address != "198.51.100.7" {
+		t.Fatalf("Address = %q, want 198.51.100.7", e.Address)
+	}
+	if e.Port != 8080 {
+		t.Fatalf("Port = %d, want 8080", e.Port)
+	}
+	if e.ForceTls != "same" {
+		t.Fatalf("ForceTls = %q, want same", e.ForceTls)
+	}
+	if e.Remark != "" {
+		t.Fatalf("Remark = %q, want empty", e.Remark)
+	}
+	if e.ep != nil {
+		t.Fatalf("default endpoint must not carry a source externalProxy entry")
+	}
+}
+
+// N3 — buildEndpointLinks renders the param-form path: one link per endpoint,
+// TLS override applied for tls, fields stripped + security overridden for none,
+// joined by "\n", in order.
+func TestBuildEndpointLinks_ParamForm(t *testing.T) {
+	s := &SubService{}
+	in := &model.Inbound{Remark: "ib"}
+	params := map[string]string{"type": "tcp", "security": "tls", "sni": "base.sni", "fp": "chrome"}
+	eps := []ShareEndpoint{
+		externalProxyToEndpoint(map[string]any{"forceTls": "tls", "dest": "a.example.com", "port": float64(8443), "remark": "A", "sni": "a.sni"}),
+		externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
+	}
+	got := s.buildEndpointLinks(eps, params, "tls",
+		func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) },
+		func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark) },
+	)
+	want := "vless://[email protected]:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A\n" +
+		"vless://[email protected]:80?security=none&type=tcp#ib-B"
+	if got != want {
+		t.Fatalf("N3 mismatch.\n got: %q\nwant: %q", got, want)
+	}
+}
+
+// N4 — buildEndpointVmessLinks renders the object-form path: base obj cloned per
+// endpoint, add/port/tls rewritten, sni override applied, none-strip honored.
+func TestBuildEndpointVmessLinks(t *testing.T) {
+	s := &SubService{}
+	in := &model.Inbound{Remark: "ib"}
+	baseObj := map[string]any{
+		"v": "2", "add": "base.example.com", "port": 443, "type": "none",
+		"id": "uid", "scy": "auto", "net": "tcp",
+		"tls": "tls", "sni": "base.sni", "alpn": "h2", "fp": "chrome",
+	}
+	eps := []ShareEndpoint{
+		externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "sni": "a.sni"}),
+		externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
+	}
+	got := s.buildEndpointVmessLinks(eps, baseObj, in, "user")
+	want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEiLAogICJzY3kiOiAiYXV0byIsCiAgInNuaSI6ICJhLnNuaSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
+		"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9"
+	if got != want {
+		t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want)
+	}
+}

+ 1 - 1
internal/sub/external_config_test.go

@@ -100,7 +100,7 @@ func TestExpandEntryLinkAppliesRemark(t *testing.T) {
 
 func TestClashProxyFromExternalTrojanReality(t *testing.T) {
 	link := "trojan://[email protected]:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
-	svc := NewSubClashService(false, "", NewSubService(false, "-io"))
+	svc := NewSubClashService(false, "", NewSubService(""))
 	proxy := svc.clashProxyFromExternal(link, "DE-Provider")
 	if proxy == nil {
 		t.Fatal("expected a clash proxy, got nil")

+ 1 - 1
internal/sub/external_only_sub_test.go

@@ -25,7 +25,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
 		t.Fatalf("seed external link: %v", err)
 	}
 
-	base := NewSubService(false, "-io")
+	base := NewSubService("")
 
 	jsonOut, _, err := NewSubJsonService("", "", "", base).GetJson("ext-only", "sub.example.com")
 	if err != nil {

+ 315 - 0
internal/sub/host_sub.go

@@ -0,0 +1,315 @@
+package sub
+
+import (
+	"encoding/json"
+	"maps"
+	"slices"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// hostEndpoints loads an inbound's enabled hosts for the given subscription
+// format ("raw"|"json"|"clash") and returns them as externalProxy-shaped maps so
+// the existing per-format renderers can fan out one link/proxy per host. Returns
+// nil when the inbound has no applicable host — the caller then uses the legacy
+// inbound/externalProxy path, preserving byte-identical output for zero-host
+// inbounds.
+func (s *SubService) hostEndpoints(inbound *model.Inbound, format string) []map[string]any {
+	var hosts []*model.Host
+	if err := database.GetDB().
+		Where("inbound_id = ? AND is_disabled = ?", inbound.Id, false).
+		Order("sort_order asc, id asc").
+		Find(&hosts).Error; err != nil {
+		logger.Warning("SubService - hostEndpoints:", err)
+		return nil
+	}
+	if len(hosts) == 0 {
+		return nil
+	}
+	defaultDest := s.resolveInboundAddress(inbound)
+	eps := make([]map[string]any, 0, len(hosts))
+	for _, h := range hosts {
+		if slices.Contains(h.ExcludeFromSubTypes, format) {
+			continue
+		}
+		eps = append(eps, hostToExternalProxyMap(h, defaultDest, inbound.Port))
+	}
+	return eps
+}
+
+// hostToExternalProxyMap projects a Host onto the externalProxy entry shape the
+// raw/json/clash renderers already consume. Address/port fall back to the
+// inbound's own when the host leaves them blank (override-only host).
+func hostToExternalProxyMap(h *model.Host, defaultDest string, defaultPort int) map[string]any {
+	dest := h.Address
+	if dest == "" {
+		dest = defaultDest
+	}
+	port := h.Port
+	if port == 0 {
+		port = defaultPort
+	}
+	ep := map[string]any{
+		"forceTls": hostSecurityToForceTls(h.Security),
+		"dest":     dest,
+		"port":     float64(port),
+		"remark":   h.Remark,
+		// Marks this as a host (not a legacy externalProxy) entry so host-only
+		// behaviors (e.g. reality SNI/fp override) apply without touching the
+		// legacy externalProxy path. Not emitted into output.
+		"isHost": true,
+	}
+	sni := h.Sni
+	if h.OverrideSniFromAddress {
+		sni = dest
+	}
+	if !h.KeepSniBlank && sni != "" {
+		ep["sni"] = sni
+	}
+	if h.Fingerprint != "" {
+		ep["fingerprint"] = h.Fingerprint
+	}
+	if len(h.Alpn) > 0 {
+		ep["alpn"] = stringsToAnySlice(h.Alpn)
+	}
+	if len(h.PinnedPeerCertSha256) > 0 {
+		ep["pinnedPeerCertSha256"] = stringsToAnySlice(h.PinnedPeerCertSha256)
+	}
+	if h.EchConfigList != "" {
+		ep["echConfigList"] = h.EchConfigList
+	}
+	if h.AllowInsecure {
+		ep["allowInsecure"] = true
+	}
+	if h.HostHeader != "" {
+		ep["hostHeader"] = h.HostHeader
+	}
+	if h.Path != "" {
+		ep["path"] = h.Path
+	}
+	if h.MihomoIpVersion != "" {
+		ep["mihomoIpVersion"] = h.MihomoIpVersion
+	}
+	if h.SockoptParams != "" {
+		ep["sockoptParams"] = h.SockoptParams
+	}
+	if h.MuxParams != "" {
+		ep["muxParams"] = h.MuxParams
+	}
+	if h.FinalMask != "" {
+		ep["finalMask"] = h.FinalMask
+	}
+	return ep
+}
+
+// hostMuxOverride returns a host's muxParams when it is valid JSON, else "".
+// Used to override the JSON outbound's mux for that host.
+func hostMuxOverride(ep map[string]any) string {
+	mp, ok := ep["muxParams"].(string)
+	if ok && mp != "" && json.Valid([]byte(mp)) {
+		return mp
+	}
+	return ""
+}
+
+// applyHostStreamOverrides injects a host's free-JSON stream overrides into the
+// per-host stream the JSON/Clash renderers build: sockoptParams (re-added since
+// the base stream strips sockopt) and finalMask. No-op for legacy externalProxy
+// entries (which never carry these keys), so existing output is unchanged.
+func applyHostStreamOverrides(ep map[string]any, stream map[string]any) {
+	if sp, ok := ep["sockoptParams"].(string); ok && sp != "" {
+		var sockopt map[string]any
+		if json.Unmarshal([]byte(sp), &sockopt) == nil && len(sockopt) > 0 {
+			stream["sockopt"] = sockopt
+		}
+	}
+	// Host finalmask: merge the host's masks into the stream's finalmask (the
+	// JSON renderer consumes streamSettings["finalmask"]; clash ignores it).
+	if fm, ok := ep["finalMask"].(string); ok && fm != "" {
+		var masks map[string]any
+		if json.Unmarshal([]byte(fm), &masks) == nil && len(masks) > 0 {
+			merged := mergeFinalMask(stream["finalmask"], masks)
+			if len(merged) > 0 {
+				stream["finalmask"] = merged
+			}
+		}
+	}
+	// Reality SNI override (host only): JSON realityData reads serverNames and
+	// clash reads serverName, so set both forms.
+	if isHostEndpoint(ep) {
+		if sec, _ := stream["security"].(string); sec == "reality" {
+			if rs, ok := stream["realitySettings"].(map[string]any); ok && rs != nil {
+				if sni, ok := externalProxySNI(ep); ok {
+					rs["serverName"] = sni
+					rs["serverNames"] = []any{sni}
+				}
+			}
+		}
+	}
+}
+
+// hostSecurityToForceTls maps Host.Security onto the externalProxy forceTls
+// vocabulary. "reality"/"same"/"" all keep the inbound's base security ("same")
+// — reality parameters can only come from the inbound itself.
+func hostSecurityToForceTls(security string) string {
+	switch security {
+	case "tls", "none":
+		return security
+	default:
+		return "same"
+	}
+}
+
+func stringsToAnySlice(in []string) []any {
+	out := make([]any, 0, len(in))
+	for _, s := range in {
+		if s != "" {
+			out = append(out, s)
+		}
+	}
+	return out
+}
+
+// injectExternalProxy rewrites the inbound's StreamSettings so its externalProxy
+// array is exactly eps. Host endpoints win over any legacy externalProxy.
+func injectExternalProxy(inbound *model.Inbound, eps []map[string]any) {
+	stream := unmarshalStreamSettings(inbound.StreamSettings)
+	if stream == nil {
+		stream = map[string]any{}
+	}
+	arr := make([]any, len(eps))
+	for i := range eps {
+		arr[i] = eps[i]
+	}
+	stream["externalProxy"] = arr
+	if b, err := json.Marshal(stream); err == nil {
+		inbound.StreamSettings = string(b)
+	}
+}
+
+// linkFromHosts renders a (possibly multi-line) raw link for one client using
+// the given host endpoints. It renders ONLY the hosts: an empty eps yields ""
+// (no legacy fallback) — the caller decides when to take the legacy path. That
+// separation is what makes the zero-hosts fallback mutation-testable.
+func (s *SubService) linkFromHosts(inbound *model.Inbound, client model.Client, eps []map[string]any) string {
+	if len(eps) == 0 {
+		return ""
+	}
+	// Clone each ep before expanding its remark template: the eps slice is
+	// shared across all clients of this inbound, so the rendered (per-client)
+	// remark must not leak into the next client's links.
+	rendered := make([]map[string]any, len(eps))
+	for i, ep := range eps {
+		cp := maps.Clone(ep)
+		s.renderHostRemark(inbound, client, cp)
+		rendered[i] = cp
+	}
+	clone := *inbound
+	injectExternalProxy(&clone, rendered)
+	return s.GetLink(&clone, client.Email)
+}
+
+// renderHostRemark expands a host endpoint's {{VAR}} remark template for one
+// client in place and marks it final, so the downstream link/proxy/config
+// renderers emit it verbatim (via endpointRemark) instead of re-composing it.
+// No-op for non-host endpoints (legacy externalProxy / synthetic default), so
+// their output stays byte-identical.
+func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Client, ep map[string]any) {
+	if !isHostEndpoint(ep) {
+		return
+	}
+	tmpl, _ := ep["remark"].(string)
+	ep["remark"] = s.genHostRemark(inbound, client, tmpl)
+	ep["remarkFinal"] = true
+}
+
+// endpointRemark returns the remark to stamp on an endpoint's link/proxy/config
+// entry. A host endpoint whose template was pre-expanded by renderHostRemark
+// carries remarkFinal and is used verbatim; every other entry flows through the
+// standard genRemark composition unchanged.
+func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map[string]any) string {
+	if ep != nil {
+		if final, _ := ep["remarkFinal"].(bool); final {
+			r, _ := ep["remark"].(string)
+			return r
+		}
+	}
+	var extra string
+	if ep != nil {
+		extra, _ = ep["remark"].(string)
+	}
+	return s.genRemark(inbound, email, extra)
+}
+
+// applyEndpointHostPath overrides the transport host header / path for a host
+// endpoint. It is a no-op for legacy externalProxy entries (which never carry
+// hostHeader/path) and only replaces keys the transport already emits, so it
+// cannot add spurious params to e.g. a tcp link.
+func applyEndpointHostPath(e ShareEndpoint, params map[string]string) {
+	if e.ep == nil {
+		return
+	}
+	if h, ok := e.ep["hostHeader"].(string); ok && h != "" {
+		if _, exists := params["host"]; exists {
+			params["host"] = h
+		}
+	}
+	if p, ok := e.ep["path"].(string); ok && p != "" {
+		if _, exists := params["path"]; exists {
+			params["path"] = p
+		}
+	}
+}
+
+// isHostEndpoint reports whether ep was synthesized from a Host (vs a legacy
+// externalProxy entry), so host-only overrides stay off the legacy path.
+func isHostEndpoint(ep map[string]any) bool {
+	v, _ := ep["isHost"].(bool)
+	return v
+}
+
+// applyEndpointRealityParams overrides a reality link's SNI + fingerprint from a
+// host (reality's pbk/sid are inherited from the inbound, so they aren't touched).
+// Host-only: legacy externalProxy reality links are unchanged.
+func applyEndpointRealityParams(e ShareEndpoint, params map[string]string, security string) {
+	if security != "reality" || e.ep == nil || !isHostEndpoint(e.ep) {
+		return
+	}
+	if sni, ok := externalProxySNI(e.ep); ok {
+		params["sni"] = sni
+	}
+	if fp, ok := e.ep["fingerprint"].(string); ok && fp != "" {
+		params["fp"] = fp
+	}
+}
+
+// applyEndpointAllowInsecure adds allowInsecure=1 to a TLS/Reality link when the
+// host opts into skipping cert verification. No-op for legacy externalProxy
+// entries (which never carry the key) and for plaintext (none) endpoints.
+func applyEndpointAllowInsecure(e ShareEndpoint, params map[string]string, security string) {
+	if e.ep == nil || security == "none" {
+		return
+	}
+	if ai, ok := e.ep["allowInsecure"].(bool); ok && ai {
+		params["allowInsecure"] = "1"
+	}
+}
+
+// applyEndpointHostPathObj is applyEndpointHostPath for the VMess object form.
+func applyEndpointHostPathObj(e ShareEndpoint, obj map[string]any) {
+	if e.ep == nil {
+		return
+	}
+	if h, ok := e.ep["hostHeader"].(string); ok && h != "" {
+		if _, exists := obj["host"]; exists {
+			obj["host"] = h
+		}
+	}
+	if p, ok := e.ep["path"].(string); ok && p != "" {
+		if _, exists := obj["path"]; exists {
+			obj["path"] = p
+		}
+	}
+}

+ 364 - 0
internal/sub/host_sub_test.go

@@ -0,0 +1,364 @@
+package sub
+
+import (
+	"fmt"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func seedSubDB(t *testing.T) {
+	t.Helper()
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+}
+
+// seedSubInbound creates a VLESS inbound with one client wired into the
+// normalized clients/client_inbounds tables so getInboundsBySubId resolves it.
+func seedSubInbound(t *testing.T, subId, tag string, port, subSortIndex int, stream string) *model.Inbound {
+	t.Helper()
+	db := database.GetDB()
+	uuid := "11111111-2222-4333-8444-" + fmt.Sprintf("%012d", port)
+	email := tag + "@e"
+	settings := fmt.Sprintf(`{"clients":[{"id":%q,"email":%q,"subId":%q,"enable":true}],"decryption":"none"}`, uuid, email, subId)
+	ib := &model.Inbound{
+		UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
+		Protocol: model.VLESS, Remark: tag, Settings: settings, StreamSettings: stream,
+		SubSortIndex: subSortIndex,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound %s: %v", tag, err)
+	}
+	client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("seed client %s: %v", email, err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("seed client_inbound %s: %v", email, err)
+	}
+	return ib
+}
+
+func seedHost(t *testing.T, h *model.Host) *model.Host {
+	t.Helper()
+	if err := database.GetDB().Create(h).Error; err != nil {
+		t.Fatalf("seed host: %v", err)
+	}
+	return h
+}
+
+const wsTLSStream = `{"network":"ws","security":"tls","wsSettings":{"path":"/base","host":"base.host"},"tlsSettings":{"serverName":"base.sni"}}`
+
+// #1 — an inbound with no hosts renders identically to the legacy path: a single
+// link from the inbound's own address. Mutation-checks the zero-hosts fallback.
+func TestSub_ZeroHosts_IdenticalOutput(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "z", 4431, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+	links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1", len(links))
+	}
+	if !strings.Contains(links[0], "203.0.113.5:4431") {
+		t.Fatalf("zero-hosts link should use the inbound address: %s", links[0])
+	}
+	if strings.Contains(links[0], "\n") {
+		t.Fatalf("zero-hosts must be a single link: %s", links[0])
+	}
+}
+
+// #2 — N enabled hosts render N links, ordered by sort_order, each carrying its
+// own address/port/sni and host-header/path override.
+func TestSub_NHosts_EmitsNLinksOrdered(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "n", 4432, 1, wsTLSStream)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 8443, Security: "tls", Sni: "b.sni", HostHeader: "b.host", Path: "/b"})
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 2096, Security: "tls", Sni: "a.sni", HostHeader: "a.host", Path: "/a"})
+
+	links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	parts := strings.Split(strings.Join(links, "\n"), "\n")
+	if len(parts) != 2 {
+		t.Fatalf("want 2 host links, got %d: %v", len(parts), parts)
+	}
+	if !strings.Contains(parts[0], "a.cdn.com:2096") || !strings.Contains(parts[0], "sni=a.sni") ||
+		!strings.Contains(parts[0], "host=a.host") || !strings.Contains(parts[0], "path=%2Fa") {
+		t.Fatalf("host A link (sort_order 1) wrong: %s", parts[0])
+	}
+	if !strings.Contains(parts[1], "b.cdn.com:8443") || !strings.Contains(parts[1], "sni=b.sni") ||
+		!strings.Contains(parts[1], "host=b.host") || !strings.Contains(parts[1], "path=%2Fb") {
+		t.Fatalf("host B link (sort_order 2) wrong: %s", parts[1])
+	}
+}
+
+// #3 — a disabled host is omitted; the inbound falls back to its legacy link.
+func TestSub_DisabledHostSkipped(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "d", 4433, 1, wsTLSStream)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "OFF", Address: "off.cdn.com", Port: 8443, IsDisabled: true})
+
+	links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	joined := strings.Join(links, "\n")
+	if strings.Contains(joined, "off.cdn.com") {
+		t.Fatalf("disabled host must not render: %s", joined)
+	}
+	if !strings.Contains(joined, "203.0.113.5:4433") {
+		t.Fatalf("with only a disabled host, the inbound's own link should render: %s", joined)
+	}
+}
+
+// #4 — when both hosts and a legacy externalProxy are set, hosts win and the
+// externalProxy entry is ignored.
+func TestSub_HostAndExternalProxy_Precedence(t *testing.T) {
+	seedSubDB(t)
+	stream := `{"network":"ws","security":"tls","wsSettings":{"path":"/base","host":"base.host"},"tlsSettings":{"serverName":"base.sni"},"externalProxy":[{"forceTls":"tls","dest":"legacy.cdn.com","port":7443,"remark":"L"}]}`
+	ib := seedSubInbound(t, "s1", "p", 4434, 1, stream)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "host.cdn.com", Port: 8443, Security: "tls", Sni: "host.sni"})
+
+	links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	joined := strings.Join(links, "\n")
+	if !strings.Contains(joined, "host.cdn.com:8443") {
+		t.Fatalf("host should win: %s", joined)
+	}
+	if strings.Contains(joined, "legacy.cdn.com") {
+		t.Fatalf("externalProxy must be ignored when hosts exist: %s", joined)
+	}
+}
+
+// #5 — hosts that share a remark but differ in address/port are NOT deduped:
+// distinct hosts produce distinct links. Mutation-checks the (absent) dedup.
+func TestSub_NHosts_NoDedup(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "dd", 4435, 1, wsTLSStream)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "SAME", Address: "one.cdn.com", Port: 8443, Security: "tls"})
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "SAME", Address: "two.cdn.com", Port: 8443, Security: "tls"})
+
+	links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	joined := strings.Join(links, "\n")
+	parts := strings.Split(joined, "\n")
+	if len(parts) != 2 {
+		t.Fatalf("two distinct hosts must yield two links, got %d: %v", len(parts), parts)
+	}
+	if !strings.Contains(joined, "one.cdn.com") || !strings.Contains(joined, "two.cdn.com") {
+		t.Fatalf("both distinct host addresses must appear: %s", joined)
+	}
+}
+
+// #6 — host sort_order composes with inbound SubSortIndex: inbounds order by
+// SubSortIndex, hosts within an inbound by sort_order.
+func TestSub_HostSortComposesWithSubSortIndex(t *testing.T) {
+	seedSubDB(t)
+	// inbound "second" has a higher SubSortIndex so it must come after "first".
+	ibFirst := seedSubInbound(t, "s1", "first", 4436, 1, wsTLSStream)
+	ibSecond := seedSubInbound(t, "s1", "second", 4437, 2, wsTLSStream)
+	seedHost(t, &model.Host{InboundId: ibSecond.Id, SortOrder: 1, Remark: "S", Address: "second-host.com", Port: 8443, Security: "tls"})
+	seedHost(t, &model.Host{InboundId: ibFirst.Id, SortOrder: 1, Remark: "F", Address: "first-host.com", Port: 8443, Security: "tls"})
+
+	links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	joined := strings.Join(links, "\n")
+	firstAt := strings.Index(joined, "first-host.com")
+	secondAt := strings.Index(joined, "second-host.com")
+	if firstAt < 0 || secondAt < 0 {
+		t.Fatalf("both inbound hosts should render: %s", joined)
+	}
+	if firstAt > secondAt {
+		t.Fatalf("inbound order must follow SubSortIndex (first before second): %s", joined)
+	}
+}
+
+// #7 — host overrides apply AFTER projectThroughFallbackMaster: the host's
+// address/sni win over the projected master stream.
+func TestSub_HostOverFallback(t *testing.T) {
+	seedSubDB(t)
+	db := database.GetDB()
+	master := &model.Inbound{
+		UserId: 1, Tag: "master", Enable: true, Listen: "203.0.113.9", Port: 9443,
+		Protocol: model.VLESS, Remark: "master",
+		Settings:       `{"clients":[],"decryption":"none"}`,
+		StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"master.sni"}}`,
+	}
+	if err := db.Create(master).Error; err != nil {
+		t.Fatalf("seed master: %v", err)
+	}
+	// child listens internal-only so projection triggers.
+	child := seedSubInbound(t, "s1", "child", 4438, 1, `{"network":"tcp","security":"none"}`)
+	child.Listen = "127.0.0.1"
+	if err := db.Model(&model.Inbound{}).Where("id = ?", child.Id).Update("listen", "127.0.0.1").Error; err != nil {
+		t.Fatalf("set child listen: %v", err)
+	}
+	if err := db.Create(&model.InboundFallback{MasterId: master.Id, ChildId: child.Id}).Error; err != nil {
+		t.Fatalf("seed fallback: %v", err)
+	}
+	seedHost(t, &model.Host{InboundId: child.Id, SortOrder: 1, Remark: "H", Address: "host.cdn.com", Port: 8443, Security: "tls", Sni: "host.sni"})
+
+	links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	joined := strings.Join(links, "\n")
+	if !strings.Contains(joined, "host.cdn.com:8443") || !strings.Contains(joined, "sni=host.sni") {
+		t.Fatalf("host override must win over fallback master: %s", joined)
+	}
+	if strings.Contains(joined, "203.0.113.9") || strings.Contains(joined, "sni=master.sni") {
+		t.Fatalf("master endpoint/sni must be overridden by the host: %s", joined)
+	}
+}
+
+// #8 — a client only gets hosts for inbounds it is actually on (the
+// clients ⋈ client_inbounds ⋈ inbounds join), never arbitrary inbounds.
+func TestSub_HostsResolveViaClientInbounds(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "mine", 4439, 1, wsTLSStream)           // client on s1
+	other := seedSubInbound(t, "s2", "other", 4440, 1, wsTLSStream) // client on s2 only
+	seedHost(t, &model.Host{InboundId: other.Id, SortOrder: 1, Remark: "X", Address: "other-host.com", Port: 8443, Security: "tls"})
+
+	links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	joined := strings.Join(links, "\n")
+	if strings.Contains(joined, "other-host.com") {
+		t.Fatalf("host on an inbound the client is not on must not appear: %s", joined)
+	}
+}
+
+// allowInsecure renders as allowInsecure=1 in the raw link and
+// skip-cert-verify: true in the Clash proxy.
+func TestSub_HostAllowInsecure(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "ai", 4450, 1, wsTLSStream)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 0, Remark: "AI", Address: "ai.cdn.com", Port: 8443, Security: "tls", AllowInsecure: true})
+
+	links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if !strings.Contains(strings.Join(links, "\n"), "allowInsecure=1") {
+		t.Fatalf("raw link should carry allowInsecure=1: %s", strings.Join(links, "\n"))
+	}
+
+	clash := NewSubClashService(false, "", NewSubService(""))
+	yaml, _, err := clash.GetClash("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+	if !strings.Contains(yaml, "skip-cert-verify: true") {
+		t.Fatalf("clash proxy should carry skip-cert-verify: true:\n%s", yaml)
+	}
+}
+
+// A host's sockoptParams is injected into the JSON output stream (sockopt is
+// stripped from the base stream, re-added per host).
+func TestSub_HostSockoptJSON(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "so", 4460, 1,
+		`{"network":"xhttp","security":"tls","xhttpSettings":{"path":"/x","mode":"auto"},"tlsSettings":{"serverName":"base.sni"}}`)
+	seedHost(t, &model.Host{
+		InboundId: ib.Id, SortOrder: 0, Remark: "SO", Address: "so.cdn.com", Port: 8443, Security: "tls",
+		SockoptParams: `{"tcpFastOpen":true}`,
+	})
+	js := NewSubJsonService("", "", "", NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	if !strings.Contains(out, "sockopt") || !strings.Contains(out, "tcpFastOpen") {
+		t.Fatalf("json should include the host sockopt:\n%s", out)
+	}
+}
+
+// A host's muxParams override the JSON outbound's mux.
+func TestSub_HostMuxJSON(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "mx", 4470, 1, wsTLSStream)
+	seedHost(t, &model.Host{
+		InboundId: ib.Id, SortOrder: 0, Remark: "MX", Address: "mx.cdn.com", Port: 8443, Security: "tls",
+		MuxParams: `{"enabled":true,"concurrency":8}`,
+	})
+	js := NewSubJsonService("", "", "", NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	if !strings.Contains(out, "concurrency") {
+		t.Fatalf("json should include the host mux override:\n%s", out)
+	}
+}
+
+// A reality host overrides SNI + fingerprint while inheriting pbk/sid from the
+// inbound (reality keys can't be host-supplied).
+func TestSub_HostRealitySniOverride(t *testing.T) {
+	seedSubDB(t)
+	realityStream := `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}`
+	ib := seedSubInbound(t, "s1", "rl", 4490, 1, realityStream)
+	seedHost(t, &model.Host{
+		InboundId: ib.Id, SortOrder: 0, Remark: "RL", Address: "rl.cdn.com", Port: 8443,
+		Security: "reality", Sni: "host.reality.com", Fingerprint: "firefox",
+	})
+	links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	joined := strings.Join(links, "\n")
+	if !strings.Contains(joined, "rl.cdn.com:8443") || !strings.Contains(joined, "security=reality") {
+		t.Fatalf("reality host base wrong: %s", joined)
+	}
+	if !strings.Contains(joined, "sni=host.reality.com") || !strings.Contains(joined, "fp=firefox") {
+		t.Fatalf("reality host sni/fp override not applied: %s", joined)
+	}
+	if strings.Contains(joined, "sni=base.reality.com") {
+		t.Fatalf("base reality sni must be overridden: %s", joined)
+	}
+	if !strings.Contains(joined, "pbk=PBK") || !strings.Contains(joined, "sid=abcd") {
+		t.Fatalf("reality pbk/sid must be inherited from the inbound: %s", joined)
+	}
+}
+
+// #9 — ExcludeFromSubTypes is honored per format: a host excluded from clash is
+// absent from GetClash but present in the raw GetSubs output.
+func TestSub_ExcludeFromSubTypes(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "x", 4441, 1, wsTLSStream)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "clashless.cdn.com", Port: 8443, Security: "tls", ExcludeFromSubTypes: []string{"clash"}})
+
+	sub := NewSubService("")
+	links, _, _, _, err := sub.GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if !strings.Contains(strings.Join(links, "\n"), "clashless.cdn.com") {
+		t.Fatalf("host not excluded from raw should appear in GetSubs")
+	}
+
+	clash := NewSubClashService(false, "", NewSubService(""))
+	yaml, _, err := clash.GetClash("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+	if strings.Contains(yaml, "clashless.cdn.com") {
+		t.Fatalf("host excluded from clash must not appear in GetClash:\n%s", yaml)
+	}
+}

+ 30 - 13
internal/sub/json_service.go

@@ -59,6 +59,7 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
 // GetJson generates a JSON subscription configuration for the given subscription ID and host.
 func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) {
 	subReq := s.SubService.ForRequest(host)
+	subReq.subscriptionBody = true
 	inbounds, err := subReq.getInboundsBySubId(subId)
 	if err != nil {
 		return "", "", err
@@ -82,6 +83,9 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
 			continue
 		}
 		subReq.projectThroughFallbackMaster(inbound)
+		if hostEps := subReq.hostEndpoints(inbound, "json"); len(hostEps) > 0 {
+			injectExternalProxy(inbound, hostEps)
+		}
 
 		for _, client := range clients {
 			seenEmails[client.Email] = struct{}{}
@@ -163,6 +167,9 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 
 	for _, ep := range externalProxies {
 		extPrxy := ep.(map[string]any)
+		// Expand the host's {{VAR}} remark template for this client (no-op for
+		// the synthetic/legacy entry) before it's used as the config remark.
+		subReq.renderHostRemark(inbound, client, extPrxy)
 		inbound.Listen = extPrxy["dest"].(string)
 		inbound.Port = int(extPrxy["port"].(float64))
 		newStream := cloneStreamForExternalProxy(stream)
@@ -182,17 +189,19 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 		if hasExternalProxy {
 			applyExternalProxyTLSToStream(extPrxy, newStream, security)
 		}
+		applyHostStreamOverrides(extPrxy, newStream)
 		streamSettings, _ := json.MarshalIndent(newStream, "", "  ")
+		hostMux := hostMuxOverride(extPrxy)
 
 		var newOutbounds []json_util.RawMessage
 
 		switch inbound.Protocol {
 		case "vmess":
-			newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client))
+			newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client, hostMux))
 		case "vless":
-			newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client))
+			newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client, hostMux))
 		case "trojan", "shadowsocks":
-			newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client))
+			newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client, hostMux))
 		case "hysteria":
 			newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client))
 		}
@@ -202,7 +211,7 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 		maps.Copy(newConfigJson, s.configJson)
 
 		newConfigJson["outbounds"] = newOutbounds
-		newConfigJson["remarks"] = subReq.genRemark(inbound, client.Email, extPrxy["remark"].(string))
+		newConfigJson["remarks"] = subReq.endpointRemark(inbound, client.Email, extPrxy)
 
 		newConfig, _ := json.MarshalIndent(newConfigJson, "", "  ")
 		newJsonArray = append(newJsonArray, newConfig)
@@ -321,13 +330,21 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
 	return rltyData
 }
 
-func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
+// jsonMux picks the per-host mux override when present, else the global mux.
+func jsonMux(global, override string) string {
+	if override != "" {
+		return override
+	}
+	return global
+}
+
+func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, muxOverride string) json_util.RawMessage {
 	outbound := Outbound{}
 
 	outbound.Protocol = string(inbound.Protocol)
 	outbound.Tag = "proxy"
-	if s.mux != "" {
-		outbound.Mux = json_util.RawMessage(s.mux)
+	if mux := jsonMux(s.mux, muxOverride); mux != "" {
+		outbound.Mux = json_util.RawMessage(mux)
 	}
 	outbound.StreamSettings = streamSettings
 
@@ -347,12 +364,12 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
 	return result
 }
 
-func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
+func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, muxOverride string) json_util.RawMessage {
 	outbound := Outbound{}
 	outbound.Protocol = string(inbound.Protocol)
 	outbound.Tag = "proxy"
-	if s.mux != "" {
-		outbound.Mux = json_util.RawMessage(s.mux)
+	if mux := jsonMux(s.mux, muxOverride); mux != "" {
+		outbound.Mux = json_util.RawMessage(mux)
 	}
 	outbound.StreamSettings = streamSettings
 
@@ -376,7 +393,7 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
 	return result
 }
 
-func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
+func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, muxOverride string) json_util.RawMessage {
 	outbound := Outbound{}
 
 	serverData := make([]ServerSetting, 1)
@@ -403,8 +420,8 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
 
 	outbound.Protocol = string(inbound.Protocol)
 	outbound.Tag = "proxy"
-	if s.mux != "" {
-		outbound.Mux = json_util.RawMessage(s.mux)
+	if mux := jsonMux(s.mux, muxOverride); mux != "" {
+		outbound.Mux = json_util.RawMessage(mux)
 	}
 	outbound.StreamSettings = streamSettings
 

+ 4 - 4
internal/sub/json_service_test.go

@@ -106,7 +106,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(inbound, nil, client))
+	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client, ""))
 	if _, ok := settings["vnext"]; ok {
 		t.Fatal("vless outbound must not use vnext")
 	}
@@ -119,7 +119,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")
 	}
@@ -132,7 +132,7 @@ func TestSubJsonServiceServerFlattened(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(trojan, nil, client))
+	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client, ""))
 	if _, ok := settings["servers"]; ok {
 		t.Fatal("trojan outbound must not use servers array")
 	}
@@ -141,7 +141,7 @@ func TestSubJsonServiceServerFlattened(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(ss, nil, client))
+	ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client, ""))
 	if ssSettings["method"] != "aes-256-gcm" {
 		t.Fatalf("flat shadowsocks must carry method: %#v", ssSettings)
 	}

+ 2 - 6
internal/sub/links.go

@@ -16,12 +16,8 @@ func NewLinkProvider() *LinkProvider {
 }
 
 func (p *LinkProvider) build(host string) *SubService {
-	showInfo, _ := p.settingService.GetSubShowInfo()
-	rModel, err := p.settingService.GetRemarkModel()
-	if err != nil {
-		rModel = "-io"
-	}
-	svc := NewSubService(showInfo, rModel)
+	remarkTemplate, _ := p.settingService.GetRemarkTemplate()
+	svc := NewSubService(remarkTemplate)
 	svc.PrepareForRequest(host)
 	return svc
 }

+ 7 - 7
internal/sub/mutation_audit_test.go

@@ -66,12 +66,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), true, model.VMESS},
-		{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client), true, model.VLESS},
-		{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client), 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(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client), false, model.VLESS},
-		{"server no mux", NewSubJsonService("", "", "", nil).genServer(&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, ""), true, model.VMESS},
+		{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), true, model.VLESS},
+		{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), 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(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
+		{"server no mux", NewSubJsonService("", "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
 	}
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
@@ -225,7 +225,7 @@ func TestGenVlessLink_NoFlowWhenClientFlowEmpty(t *testing.T) {
 		Settings:       `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}],"encryption":"none"}`,
 		StreamSettings: stream,
 	}
-	s := &SubService{remarkModel: "-ieo"}
+	s := &SubService{}
 	if link := s.genVlessLink(inbound, "user"); strings.Contains(link, "flow=") {
 		t.Fatalf("empty client flow must not produce a flow param, got %q", link)
 	}

+ 322 - 0
internal/sub/remark_vars.go

@@ -0,0 +1,322 @@
+package sub
+
+import (
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+	"unicode"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// remarkContext carries the per-client data a remark template can interpolate.
+// stats holds the live traffic record when one exists; when it doesn't, the
+// caller synthesizes a minimal one from the client so expiry/total/status tokens
+// still resolve. hostRemark is the host endpoint's own remark: it takes priority
+// over the inbound's remark as the config name and backs the {{HOST}} token.
+type remarkContext struct {
+	client     model.Client
+	stats      xray.ClientTraffic
+	inbound    *model.Inbound
+	hostRemark string
+}
+
+// configName is the display name for a link: the host endpoint's own remark when
+// it has one, otherwise the inbound's remark.
+func (ctx remarkContext) configName() string {
+	if ctx.hostRemark != "" {
+		return ctx.hostRemark
+	}
+	if ctx.inbound != nil {
+		return ctx.inbound.Remark
+	}
+	return ""
+}
+
+// remarkVarRe matches a {{TOKEN}} placeholder. Tokens are uppercase letters and
+// underscores only, so ordinary braces in a remark are left untouched.
+var remarkVarRe = regexp.MustCompile(`\{\{([A-Z_]+)\}\}`)
+
+// unlimitedMark is the value the human-readable quota/expiry tokens render when
+// the client has no limit. A segment built only around such a token carries no
+// information, so it is dropped rather than printed as "∞" (see expandRemarkVars).
+const unlimitedMark = "∞"
+
+// unlimitedDropTokens are the tokens that render unlimitedMark for an unlimited
+// client. A "|"-separated segment whose only value comes from one of these is
+// dropped whole when unlimited, so the operator never sees "📊∞|⏳∞D".
+var unlimitedDropTokens = map[string]bool{
+	"TRAFFIC_LEFT":  true,
+	"TRAFFIC_TOTAL": true,
+	"DAYS_LEFT":     true,
+}
+
+// expandRemarkVars substitutes every {{TOKEN}} in template with its per-client
+// value. Unknown tokens resolve to "" (never the literal text). The template is
+// split on "|" into segments: a segment whose only value is an unlimited quota
+// or expiry (∞) drops out whole — decoration and separator included — so an
+// unlimited client gets "host" instead of "host|📊∞|⏳∞D".
+func expandRemarkVars(template string, ctx remarkContext) string {
+	if !strings.Contains(template, "{{") {
+		return template
+	}
+	segments := strings.Split(template, "|")
+	kept := make([]string, 0, len(segments))
+	for _, seg := range segments {
+		if out, drop := expandSegment(seg, ctx); !drop {
+			kept = append(kept, out)
+		}
+	}
+	return strings.Join(kept, "|")
+}
+
+// expandSegment expands one "|" segment and reports whether it should be dropped.
+// It drops only when the segment carries an unlimited (∞) quota/expiry token and
+// no other token in it resolves to a non-empty value — so a segment mixing, say,
+// {{EMAIL}} with {{TRAFFIC_LEFT}} is always kept.
+func expandSegment(seg string, ctx remarkContext) (string, bool) {
+	hasUnlimited, hasOtherValue := false, false
+	out := remarkVarRe.ReplaceAllStringFunc(seg, func(m string) string {
+		token := m[2 : len(m)-2]
+		val := remarkVarValue(token, ctx)
+		switch {
+		case unlimitedDropTokens[token] && val == unlimitedMark:
+			hasUnlimited = true
+		case val != "":
+			hasOtherValue = true
+		}
+		return val
+	})
+	return out, hasUnlimited && !hasOtherValue
+}
+
+func remarkVarValue(token string, ctx remarkContext) string {
+	c := ctx.client
+	st := ctx.stats
+	used := st.Up + st.Down
+	switch token {
+	case "EMAIL", "USERNAME":
+		return c.Email
+	case "INBOUND":
+		return ctx.configName()
+	case "HOST":
+		return ctx.hostRemark
+	case "ID":
+		return c.ID
+	case "SHORT_ID":
+		if len(c.ID) >= 8 {
+			return c.ID[:8]
+		}
+		return c.ID
+	case "TELEGRAM_ID":
+		if c.TgID != 0 {
+			return strconv.FormatInt(c.TgID, 10)
+		}
+		return ""
+	case "SUB_ID":
+		return c.SubID
+	case "COMMENT":
+		return c.Comment
+	case "STATUS":
+		return clientStatus(st)
+	case "DAYS_LEFT":
+		return daysLeftLabel(st.ExpiryTime)
+	case "EXPIRE_DATE":
+		return expireDateLabel(st.ExpiryTime)
+	case "EXPIRE_UNIX":
+		if st.ExpiryTime <= 0 {
+			return "0"
+		}
+		return strconv.FormatInt(st.ExpiryTime/1000, 10)
+	case "CREATED_UNIX":
+		if c.CreatedAt == 0 {
+			return ""
+		}
+		return strconv.FormatInt(c.CreatedAt/1000, 10)
+	case "TRAFFIC_USED":
+		return common.FormatTraffic(used)
+	case "TRAFFIC_LEFT":
+		if st.Total <= 0 {
+			return unlimitedMark
+		}
+		return common.FormatTraffic(max64(st.Total-used, 0))
+	case "TRAFFIC_TOTAL":
+		if st.Total <= 0 {
+			return unlimitedMark
+		}
+		return common.FormatTraffic(st.Total)
+	case "TRAFFIC_USED_BYTES":
+		return strconv.FormatInt(used, 10)
+	case "TRAFFIC_LEFT_BYTES":
+		if st.Total <= 0 {
+			return "0"
+		}
+		return strconv.FormatInt(max64(st.Total-used, 0), 10)
+	case "TRAFFIC_TOTAL_BYTES":
+		return strconv.FormatInt(st.Total, 10)
+	case "UP":
+		return common.FormatTraffic(st.Up)
+	case "DOWN":
+		return common.FormatTraffic(st.Down)
+	case "RESET_DAYS":
+		if c.Reset > 0 {
+			return strconv.Itoa(c.Reset)
+		}
+		return ""
+	}
+	return ""
+}
+
+// clientStatus collapses enable/expiry/quota into a single word.
+func clientStatus(st xray.ClientTraffic) string {
+	if !st.Enable {
+		return "disabled"
+	}
+	if st.ExpiryTime > 0 && st.ExpiryTime/1000 < time.Now().Unix() {
+		return "expired"
+	}
+	if st.Total > 0 && st.Up+st.Down >= st.Total {
+		return "depleted"
+	}
+	return "active"
+}
+
+// daysLeftLabel is the whole-days form of remainingTimeLabel: "∞" for unlimited,
+// "0" once past expiry.
+func daysLeftLabel(expiryMs int64) string {
+	if expiryMs == 0 {
+		return unlimitedMark
+	}
+	exp := expiryMs / 1000
+	var secs int64
+	if exp > 0 {
+		secs = exp - time.Now().Unix()
+	} else {
+		secs = -exp // delayed-start: value is the duration itself
+	}
+	days := secs / 86400
+	if days < 0 {
+		return "0"
+	}
+	return strconv.FormatInt(days, 10)
+}
+
+// expireDateLabel renders a fixed expiry as YYYY-MM-DD (UTC). Unlimited and
+// delayed-start (no fixed calendar date yet) expiries yield "".
+func expireDateLabel(expiryMs int64) string {
+	if expiryMs <= 0 {
+		return ""
+	}
+	return time.Unix(expiryMs/1000, 0).UTC().Format("2006-01-02")
+}
+
+func max64(a, b int64) int64 {
+	if a > b {
+		return a
+	}
+	return b
+}
+
+// statsForClient returns the client's live traffic record, or a minimal one
+// synthesized from the client (enable/expiry/total) when no live stats exist —
+// so expiry/total/status tokens still resolve on links that have no counters yet.
+func (s *SubService) statsForClient(inbound *model.Inbound, client model.Client) xray.ClientTraffic {
+	if stats, ok := s.findClientStats(inbound, client.Email); ok {
+		return stats
+	}
+	return xray.ClientTraffic{
+		Enable:     client.Enable,
+		ExpiryTime: client.ExpiryTime,
+		Total:      client.TotalGB,
+	}
+}
+
+// lookupClient resolves the full client (TgID, SubID, comment, …) for an email,
+// needed when a global remark template references client-only tokens. Falls back
+// to an email-only client if not found.
+func (s *SubService) lookupClient(inbound *model.Inbound, email string) model.Client {
+	clients, _ := s.inboundService.GetClients(inbound)
+	for _, c := range clients {
+		if c.Email == email {
+			return c
+		}
+	}
+	return model.Client{Email: email}
+}
+
+// usageInfoTokens are the per-client status tokens. On every link of a
+// subscription except the client's first, these (and the decoration leading
+// into them) are dropped, so the traffic/expiry info shows once instead of on
+// every server.
+var usageInfoTokens = []string{
+	"TRAFFIC_USED", "TRAFFIC_LEFT", "TRAFFIC_TOTAL",
+	"TRAFFIC_USED_BYTES", "TRAFFIC_LEFT_BYTES", "TRAFFIC_TOTAL_BYTES",
+	"UP", "DOWN", "DAYS_LEFT", "EXPIRE_DATE", "EXPIRE_UNIX", "STATUS",
+}
+
+// nameOnlyTemplate returns template with the trailing per-client info part
+// removed: everything from the first usage token (and the decoration — emojis,
+// spaces, separators — leading into it) onward is dropped, leaving the config
+// name. Returns "" when the template is info-only.
+func nameOnlyTemplate(template string) string {
+	idx := -1
+	for _, tok := range usageInfoTokens {
+		if i := strings.Index(template, "{{"+tok+"}}"); i >= 0 && (idx < 0 || i < idx) {
+			idx = i
+		}
+	}
+	if idx < 0 {
+		return template
+	}
+	return strings.TrimRightFunc(template[:idx], func(r rune) bool {
+		return r != '}' && !unicode.IsLetter(r) && !unicode.IsDigit(r)
+	})
+}
+
+// effectiveTemplate picks which template to expand for one body link: the full
+// template (with the per-client info) for a client's first link, and the
+// name-only template for every link thereafter — so the info shows once. Only
+// called in the subscription-body context (displays bypass the template).
+func (s *SubService) effectiveTemplate(email string) string {
+	if s.usageShown == nil {
+		s.usageShown = map[string]bool{}
+	}
+	if s.usageShown[email] {
+		return nameOnlyTemplate(s.remarkTemplate)
+	}
+	s.usageShown[email] = true
+	return s.remarkTemplate
+}
+
+// genTemplatedRemark expands the remark template for one client. hostRemark is
+// the host endpoint's remark (empty for a plain inbound); it takes priority over
+// the inbound remark for the config name and backs the {{HOST}} token.
+func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
+	ctx := remarkContext{
+		client:     client,
+		stats:      s.statsForClient(inbound, client),
+		inbound:    inbound,
+		hostRemark: hostRemark,
+	}
+	tmpl := s.effectiveTemplate(client.Email)
+	// Fall back to the config name when the template is empty or expands to
+	// nothing (e.g. an all-unlimited template whose only segments dropped out).
+	if out := expandRemarkVars(tmpl, ctx); strings.TrimSpace(out) != "" {
+		return out
+	}
+	return ctx.configName()
+}
+
+// genHostRemark builds one host endpoint's remark for a specific client. The
+// config name is the host endpoint's own remark when set, otherwise the inbound's
+// remark. In the subscription body the rest of the remark template still applies;
+// displays show just the config name.
+func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
+	if !s.subscriptionBody {
+		return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
+	}
+	return s.genTemplatedRemark(inbound, client, hostRemark)
+}

+ 282 - 0
internal/sub/remark_vars_test.go

@@ -0,0 +1,282 @@
+package sub
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+const gb = int64(1024 * 1024 * 1024)
+
+// expandCtx builds a remarkContext from explicit pieces for token tests.
+func expandCtx(client model.Client, stats xray.ClientTraffic, inbound *model.Inbound) remarkContext {
+	return remarkContext{client: client, stats: stats, inbound: inbound}
+}
+
+func TestExpandRemarkVars(t *testing.T) {
+	inbound := &model.Inbound{Remark: "Germany"}
+	client := model.Client{
+		Email:     "[email protected]",
+		ID:        "3f2a9c1b-aaaa-bbbb-cccc-1234567890ab",
+		TgID:      123456789,
+		SubID:     "subABC",
+		Comment:   "vip",
+		Reset:     30,
+		CreatedAt: 1_700_000_000_000,
+	}
+	// 50GB total, 8GB used (5 up + 3 down), enabled, no expiry.
+	stats := xray.ClientTraffic{
+		Enable: true,
+		Total:  50 * gb,
+		Up:     5 * gb,
+		Down:   3 * gb,
+	}
+	ctx := expandCtx(client, stats, inbound)
+
+	cases := []struct{ tmpl, want string }{
+		{"{{EMAIL}}", "[email protected]"},
+		{"{{USERNAME}}", "[email protected]"},
+		{"{{INBOUND}}", "Germany"}, // no host remark in ctx → inbound remark
+		{"{{HOST}}", ""},           // no host remark in ctx → empty
+		{"{{ID}}", client.ID},
+		{"{{SHORT_ID}}", "3f2a9c1b"},
+		{"{{TELEGRAM_ID}}", "123456789"},
+		{"{{SUB_ID}}", "subABC"},
+		{"{{COMMENT}}", "vip"},
+		{"{{RESET_DAYS}}", "30"},
+		{"{{CREATED_UNIX}}", "1700000000"},
+		{"{{TRAFFIC_USED}}", "8.00GB"},
+		{"{{TRAFFIC_LEFT}}", "42.00GB"},
+		{"{{TRAFFIC_TOTAL}}", "50.00GB"},
+		{"{{TRAFFIC_USED_BYTES}}", "8589934592"},
+		{"{{TRAFFIC_TOTAL_BYTES}}", "53687091200"},
+		{"{{UP}}", "5.00GB"},
+		{"{{DOWN}}", "3.00GB"},
+		{"{{STATUS}}", "active"},
+		{"{{EXPIRE_UNIX}}", "0"},  // no expiry
+		{"{{EXPIRE_DATE}}", ""},   // no fixed date
+		{"{{UNKNOWN_TOKEN}}", ""}, // unknown → empty, never literal
+		{"DE {{EMAIL}} ok", "DE [email protected] ok"},
+		{"{{EMAIL}}-{{SHORT_ID}}", "[email protected]"},
+		{"no tokens here", "no tokens here"},
+	}
+	for _, c := range cases {
+		if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
+			t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
+		}
+	}
+	// The unlimited tokens still render ∞ at the value layer; expandRemarkVars
+	// is what drops an all-unlimited segment (see TestExpandRemarkVars_DropUnlimitedSegments).
+	if got := remarkVarValue("DAYS_LEFT", ctx); got != "∞" {
+		t.Errorf("remarkVarValue(DAYS_LEFT) = %q, want ∞", got)
+	}
+}
+
+func TestExpandRemarkVars_EdgeCases(t *testing.T) {
+	// Unlimited total → ∞ for human forms, 0 bytes for *_BYTES left. Checked at
+	// the value layer: expandRemarkVars would drop a bare ∞ segment.
+	unlimited := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: 0, Up: gb}, nil)
+	if got := remarkVarValue("TRAFFIC_TOTAL", unlimited); got != "∞" {
+		t.Errorf("unlimited TRAFFIC_TOTAL = %q, want ∞", got)
+	}
+	if got := remarkVarValue("TRAFFIC_LEFT", unlimited); got != "∞" {
+		t.Errorf("unlimited TRAFFIC_LEFT = %q, want ∞", got)
+	}
+	if got := expandRemarkVars("{{TRAFFIC_LEFT_BYTES}}", unlimited); got != "0" {
+		t.Errorf("unlimited TRAFFIC_LEFT_BYTES = %q, want 0", got)
+	}
+	// TgID zero → empty.
+	if got := expandRemarkVars("{{TELEGRAM_ID}}", unlimited); got != "" {
+		t.Errorf("zero TgID = %q, want empty", got)
+	}
+	// Over-quota usage clamps left to 0, not negative.
+	over := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: gb, Up: 2 * gb}, nil)
+	if got := expandRemarkVars("{{TRAFFIC_LEFT_BYTES}}", over); got != "0" {
+		t.Errorf("over-quota TRAFFIC_LEFT_BYTES = %q, want 0", got)
+	}
+	// Delayed-start (negative expiry) gives deterministic whole days.
+	delayed := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, ExpiryTime: -864_000_000}, nil)
+	if got := expandRemarkVars("{{DAYS_LEFT}}", delayed); got != "10" {
+		t.Errorf("delayed-start DAYS_LEFT = %q, want 10", got)
+	}
+}
+
+// An unlimited client drops the quota/expiry segments whole — decoration and the
+// "|" separator included — instead of printing "📊∞|⏳∞D".
+func TestExpandRemarkVars_DropUnlimitedSegments(t *testing.T) {
+	const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
+	inbound := &model.Inbound{Remark: "host"}
+
+	// No limit at all → only the name segment survives.
+	unlimited := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true}, inbound)
+	if got := expandRemarkVars(tmpl, unlimited); got != "host" {
+		t.Errorf("fully unlimited = %q, want %q", got, "host")
+	}
+
+	// Limited traffic but no expiry → traffic stays, the expiry segment drops.
+	noExpiry := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: 50 * gb, Up: 8 * gb}, inbound)
+	if got := expandRemarkVars(tmpl, noExpiry); got != "host|📊42.00GB" {
+		t.Errorf("no-expiry = %q, want %q", got, "host|📊42.00GB")
+	}
+
+	// A segment mixing an unlimited token with another value is kept whole,
+	// decoration and ∞ included — only all-unlimited segments drop.
+	mixed := expandCtx(model.Client{Email: "john"}, xray.ClientTraffic{Enable: true}, inbound)
+	if got := expandRemarkVars("{{EMAIL}} 📊{{TRAFFIC_LEFT}}", mixed); got != "john 📊∞" {
+		t.Errorf("mixed segment = %q, want %q", got, "john 📊∞")
+	}
+}
+
+func TestClientStatus(t *testing.T) {
+	cases := []struct {
+		name string
+		st   xray.ClientTraffic
+		want string
+	}{
+		{"disabled", xray.ClientTraffic{Enable: false}, "disabled"},
+		{"active", xray.ClientTraffic{Enable: true}, "active"},
+		{"expired", xray.ClientTraffic{Enable: true, ExpiryTime: 1000}, "expired"}, // 1s past epoch
+		{"depleted", xray.ClientTraffic{Enable: true, Total: gb, Up: gb}, "depleted"},
+	}
+	for _, c := range cases {
+		if got := clientStatus(c.st); got != c.want {
+			t.Errorf("%s: clientStatus = %q, want %q", c.name, got, c.want)
+		}
+	}
+}
+
+// hostRemarkService builds a SubService + inbound + client/stats for remark tests.
+func hostRemarkService(template string) (*SubService, *model.Inbound, model.Client) {
+	s := &SubService{remarkTemplate: template, subscriptionBody: true}
+	inbound := &model.Inbound{
+		Remark: "DE",
+		ClientStats: []xray.ClientTraffic{{
+			Email:      "[email protected]",
+			Enable:     true,
+			Total:      100 * gb,
+			Up:         15 * gb,
+			Down:       5 * gb,
+			ExpiryTime: -864_000_000, // delayed-start: deterministic 10 days
+		}},
+	}
+	client := model.Client{Email: "[email protected]"}
+	return s, inbound, client
+}
+
+// The config name prefers the host endpoint's own remark; the inbound's remark is
+// the fallback, used only when the host has none.
+func TestGenHostRemark_ConfigNameHostWins(t *testing.T) {
+	s, inbound, client := hostRemarkService("") // no template → config name only
+	if got := s.genHostRemark(inbound, client, "Relay"); got != "Relay" {
+		t.Fatalf("genHostRemark = %q, want %q (host remark wins)", got, "Relay")
+	}
+	if got := s.genHostRemark(inbound, client, ""); got != "DE" {
+		t.Fatalf("genHostRemark (no host remark) = %q, want %q (inbound fallback)", got, "DE")
+	}
+}
+
+// In the body the template applies: {{INBOUND}} is the config name (host remark
+// first, inbound fallback) and {{HOST}} is always the host's own remark.
+func TestGenHostRemark_GlobalTemplate(t *testing.T) {
+	// Host remark set → {{INBOUND}} resolves to it (host wins over the inbound).
+	s, inbound, client := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}} | {{DAYS_LEFT}}d")
+	if got := s.genHostRemark(inbound, client, "CDN"); got != "CDN | 80.00GB | 10d" {
+		t.Fatalf("global template (host wins) = %q", got)
+	}
+	// No host remark → {{INBOUND}} falls back to the inbound's own remark.
+	s2, inbound2, client2 := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}}")
+	if got := s2.genHostRemark(inbound2, client2, ""); got != "DE | 80.00GB" {
+		t.Fatalf("global template (inbound fallback) = %q", got)
+	}
+	// {{HOST}} is the host's own remark even when the inbound has one of its own.
+	s3, inbound3, client3 := hostRemarkService("{{HOST}}")
+	if got := s3.genHostRemark(inbound3, client3, "CDN"); got != "CDN" {
+		t.Fatalf("{{HOST}} token = %q, want CDN", got)
+	}
+}
+
+// A global template also drives non-host links via genRemark; {{HOST}} = the
+// legacy externalProxy remark passed as extra.
+func TestGenRemark_GlobalTemplate(t *testing.T) {
+	s, inbound, _ := hostRemarkService("{{EMAIL}} | {{TRAFFIC_LEFT}}")
+	got := s.genRemark(inbound, "[email protected]", "")
+	if got != "[email protected] | 80.00GB" {
+		t.Fatalf("global template (non-host) = %q", got)
+	}
+}
+
+// With no template, genRemark composes the fallback model and adds no suffix.
+func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
+	s, inbound, _ := hostRemarkService("")
+	got := s.genRemark(inbound, "[email protected]", "Relay")
+	if got != "DE-Relay" {
+		t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay")
+	}
+}
+
+// The per-client info part of the template renders only on a client's first
+// link of the request; later links show the name-only template.
+func TestUsageOnFirstLinkOnly(t *testing.T) {
+	s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
+	first := s.genHostRemark(inbound, client, "")
+	second := s.genHostRemark(inbound, client, "")
+	if !strings.Contains(first, "📊") || !strings.Contains(first, "80.00GB") {
+		t.Fatalf("first link should carry usage: %q", first)
+	}
+	if strings.ContainsAny(second, "📊⏳") {
+		t.Fatalf("second link must not carry usage: %q", second)
+	}
+	if second != "DE" {
+		t.Fatalf("second link = %q, want name-only %q", second, "DE")
+	}
+}
+
+// Outside the subscription body (panel link/QR displays, sub info page) the
+// template is bypassed entirely — links show just the config name, with no
+// per-client email or usage info.
+func TestRemarkInDisplayContext(t *testing.T) {
+	s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
+	s.subscriptionBody = false
+	// A host link in a display shows only the config name — host remark wins, with
+	// no per-client email or usage info.
+	if got := s.genHostRemark(inbound, client, "CDN"); got != "CDN" {
+		t.Fatalf("display host link = %q, want config name %q (host wins)", got, "CDN")
+	}
+	// With no host remark, the config name is the inbound's own remark.
+	if got := s.genHostRemark(inbound, client, ""); got != "DE" {
+		t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
+	}
+	// genRemark (non-host) likewise drops the template in display context.
+	if got := s.genRemark(inbound, client.Email, ""); got != "DE" {
+		t.Fatalf("display genRemark = %q, want %q", got, "DE")
+	}
+}
+
+// nameOnlyTemplate drops the info part (and its leading decoration), keeping name.
+func TestNameOnlyTemplate(t *testing.T) {
+	cases := map[string]string{
+		"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}",           // the default → name only
+		"{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}":          "{{EMAIL}} {{INBOUND}}", // multi-token name survives the trim
+		"{{INBOUND}} | {{STATUS}}":                      "{{INBOUND}}",
+		"{{INBOUND}}-{{EMAIL}}":                         "{{INBOUND}}-{{EMAIL}}", // no info tokens → unchanged
+		"{{TRAFFIC_LEFT}}":                              "",                      // info only → empty
+	}
+	for tmpl, want := range cases {
+		if got := nameOnlyTemplate(tmpl); got != want {
+			t.Errorf("nameOnlyTemplate(%q) = %q, want %q", tmpl, got, want)
+		}
+	}
+}
+
+// Two clients through the same global template get distinct, per-client remarks.
+func TestGenHostRemark_PerClient(t *testing.T) {
+	s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true}
+	inbound := &model.Inbound{}
+	a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "")
+	b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "")
+	if a != "alice@x" || b != "bob@x" {
+		t.Fatalf("per-client expansion failed: a=%q b=%q", a, b)
+	}
+}

+ 85 - 143
internal/sub/service.go

@@ -28,10 +28,18 @@ import (
 // SubService provides business logic for generating subscription links and managing subscription data.
 type SubService struct {
 	address        string
-	showInfo       bool
-	remarkModel    string
+	remarkTemplate string
 	datepicker     string
-	emailInRemark  bool
+	// subscriptionBody is true only when rendering the actual subscription
+	// content a client app imports (raw /sub fetch, /json, /clash). The remark
+	// template's per-client info is emitted there (on the first link); every
+	// other context — the sub info page, the panel's link/QR displays — renders
+	// the name-only template, like Remnawave.
+	subscriptionBody bool
+	// usageShown tracks, per client email, whether the info part of the template
+	// has already been emitted this request, so it appears on the first body
+	// link only. Per-request state; reset in PrepareForRequest.
+	usageShown     map[string]bool
 	inboundService service.InboundService
 	settingService service.SettingService
 	// nodesByID is populated per request from the Node table so
@@ -42,10 +50,9 @@ type SubService struct {
 }
 
 // NewSubService creates a new subscription service with the given configuration.
-func NewSubService(showInfo bool, remarkModel string) *SubService {
+func NewSubService(remarkTemplate string) *SubService {
 	return &SubService{
-		showInfo:    showInfo,
-		remarkModel: remarkModel,
+		remarkTemplate: remarkTemplate,
 	}
 }
 
@@ -70,24 +77,21 @@ func (s *SubService) PrepareForRequest(host string) {
 		}
 	}
 	s.address = host
+	s.usageShown = map[string]bool{}
 	s.loadNodes()
 	s.loadRemarkSettings()
 }
 
 // loadRemarkSettings populates the per-request remark formatting state so
-// every subscription format — raw, JSON, Clash — renders remarks the same
-// way. genRemark reads emailInRemark and the date formatter reads datepicker;
-// loading these only in getSubs left JSON/Clash with the zero values.
+// every subscription format — raw, JSON, Clash — renders remarks the same way
+// (the date formatter reads datepicker). Loading it only in getSubs left
+// JSON/Clash with the zero value.
 func (s *SubService) loadRemarkSettings() {
 	var err error
 	s.datepicker, err = s.settingService.GetDatepicker()
 	if err != nil {
 		s.datepicker = "gregorian"
 	}
-	s.emailInRemark, err = s.settingService.GetSubEmailInRemark()
-	if err != nil {
-		s.emailInRemark = true
-	}
 }
 
 func (s *SubService) configuredPublicHost() string {
@@ -191,11 +195,20 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
 			continue
 		}
 		s.projectThroughFallbackMaster(inbound)
+		// Host overrides apply AFTER fallback projection so a host's
+		// address/TLS wins over the projected master stream.
+		hostEps := s.hostEndpoints(inbound, "raw")
 		for _, client := range clients {
 			if client.Enable {
 				hasEnabledClient = true
 			}
-			result = append(result, s.GetLink(inbound, client.Email))
+			var link string
+			if len(hostEps) > 0 {
+				link = s.linkFromHosts(inbound, client, hostEps)
+			} else {
+				link = s.GetLink(inbound, client.Email)
+			}
+			result = append(result, link)
 			emails = append(emails, client.Email)
 			seenEmails[client.Email] = struct{}{}
 		}
@@ -584,7 +597,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 				return fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
-				return s.genRemark(inbound, email, ep["remark"].(string))
+				return s.endpointRemark(inbound, email, ep)
 			},
 		)
 	}
@@ -635,7 +648,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
 				return fmt.Sprintf("trojan://%s@%s", password, joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
-				return s.genRemark(inbound, email, ep["remark"].(string))
+				return s.endpointRemark(inbound, email, ep)
 			},
 		)
 	}
@@ -709,7 +722,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 				return fmt.Sprintf("ss://%s@%s", base64.RawURLEncoding.EncodeToString([]byte(encPart)), joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
-				return s.genRemark(inbound, email, ep["remark"].(string))
+				return s.endpointRemark(inbound, email, ep)
 			},
 		)
 	}
@@ -814,13 +827,11 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 			if dest == "" || !okPort {
 				continue
 			}
-			epRemark, _ := ep["remark"].(string)
-
 			epParams := cloneStringMap(params)
 			applyExternalProxyHysteriaParams(ep, epParams)
 
 			link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(dest, int(portF)))
-			links = append(links, buildLinkWithParams(link, epParams, s.genRemark(inbound, email, epRemark)))
+			links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep)))
 		}
 		return strings.Join(links, "\n")
 	}
@@ -1326,6 +1337,14 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
 		}
 		settings["echConfigList"] = ech
 	}
+	if ai, ok := ep["allowInsecure"].(bool); ok && ai {
+		settings, _ := tlsSettings["settings"].(map[string]any)
+		if settings == nil {
+			settings = map[string]any{}
+			tlsSettings["settings"] = settings
+		}
+		settings["allowInsecure"] = true
+	}
 }
 
 func externalProxySNI(ep map[string]any) (string, bool) {
@@ -1432,30 +1451,16 @@ func joinAnyStrings(items []any) string {
 	return strings.Join(parts, ",")
 }
 
+// buildVmessExternalProxyLinks is a thin adapter: it maps the legacy
+// externalProxy entries to []ShareEndpoint and renders them through the unified
+// endpoint path. Kept so genVmessLink's call site is unchanged.
 func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string {
-	var links strings.Builder
-	for index, externalProxy := range externalProxies {
+	eps := make([]ShareEndpoint, 0, len(externalProxies))
+	for _, externalProxy := range externalProxies {
 		ep, _ := externalProxy.(map[string]any)
-		newSecurity, _ := ep["forceTls"].(string)
-		securityToApply := baseObj["tls"].(string)
-		if newSecurity != "same" {
-			securityToApply = newSecurity
-		}
-		newObj := cloneVmessShareObj(baseObj, newSecurity)
-		newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string))
-		newObj["add"] = ep["dest"].(string)
-		newObj["port"] = int(ep["port"].(float64))
-
-		if newSecurity != "same" {
-			newObj["tls"] = newSecurity
-		}
-		applyExternalProxyTLSObj(ep, newObj, securityToApply)
-		if index > 0 {
-			links.WriteString("\n")
-		}
-		links.WriteString(buildVmessLink(newObj))
+		eps = append(eps, externalProxyToEndpoint(ep))
 	}
-	return links.String()
+	return s.buildEndpointVmessLinks(eps, baseObj, inbound, email)
 }
 
 // buildLinkWithParams appends ?query and #fragment to a pre-built
@@ -1512,6 +1517,9 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec
 	return sb.String()
 }
 
+// buildExternalProxyURLLinks is a thin adapter: it maps the legacy externalProxy
+// entries to []ShareEndpoint and renders them through the unified endpoint path.
+// Kept so the genVless/genTrojan/genShadowsocks call sites are unchanged.
 func (s *SubService) buildExternalProxyURLLinks(
 	externalProxies []any,
 	params map[string]string,
@@ -1519,33 +1527,14 @@ func (s *SubService) buildExternalProxyURLLinks(
 	makeLink func(dest string, port int) string,
 	makeRemark func(ep map[string]any) string,
 ) string {
-	links := make([]string, 0, len(externalProxies))
+	eps := make([]ShareEndpoint, 0, len(externalProxies))
 	for _, externalProxy := range externalProxies {
 		ep, _ := externalProxy.(map[string]any)
-		newSecurity, _ := ep["forceTls"].(string)
-		dest, _ := ep["dest"].(string)
-		port := int(ep["port"].(float64))
-
-		securityToApply := baseSecurity
-		if newSecurity != "same" {
-			securityToApply = newSecurity
-		}
-
-		nextParams := cloneStringMap(params)
-		applyExternalProxyTLSParams(ep, nextParams, securityToApply)
-
-		links = append(
-			links,
-			buildLinkWithParamsAndSecurity(
-				makeLink(dest, port),
-				nextParams,
-				makeRemark(ep),
-				securityToApply,
-				newSecurity == "none",
-			),
-		)
+		eps = append(eps, externalProxyToEndpoint(ep))
 	}
-	return strings.Join(links, "\n")
+	return s.buildEndpointLinks(eps, params, baseSecurity, makeLink, func(e ShareEndpoint) string {
+		return makeRemark(e.ep)
+	})
 }
 
 func cloneStringMap(source map[string]string) map[string]string {
@@ -1554,89 +1543,42 @@ func cloneStringMap(source map[string]string) map[string]string {
 	return cloned
 }
 
+// genRemark builds the remark for a non-host link (raw default / legacy
+// externalProxy / synthetic JSON-Clash entry). In the subscription body a set
+// remark template takes over; otherwise (and in every display context) the
+// remark is just the config name (inbound remark, then extra).
 func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string {
-	separationChar := string(s.remarkModel[0])
-	orderChars := s.remarkModel[1:]
-	orders := map[byte]string{
-		'i': "",
-		'e': "",
-		'o': "",
-	}
-	if len(email) > 0 && s.emailInRemark {
-		orders['e'] = email
-	}
-	if len(inbound.Remark) > 0 {
-		orders['i'] = inbound.Remark
-	}
-	if len(extra) > 0 {
-		orders['o'] = extra
-	}
-
-	var remark []string
-	for i := 0; i < len(orderChars); i++ {
-		char := orderChars[i]
-		order, exists := orders[char]
-		if exists && order != "" {
-			remark = append(remark, order)
-		}
+	if s.remarkTemplate != "" && s.subscriptionBody {
+		return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra)
+	}
+	// Sub info page + panel link/QR displays: just the config name (no template,
+	// so no per-client email/usage leaks into the shown remark).
+	return fallbackRemark(inbound.Remark, extra)
+}
+
+// fallbackRemark is the minimal remark used only when no template is configured
+// (an operator explicitly cleared it): the inbound remark and the host/extra
+// remark joined by "-", skipping empties. The configurable remark model was
+// removed in favour of the template, whose default already includes the email.
+func fallbackRemark(inboundRemark, extra string) string {
+	switch {
+	case inboundRemark == "":
+		return extra
+	case extra == "":
+		return inboundRemark
+	default:
+		return inboundRemark + "-" + extra
 	}
+}
 
-	if s.showInfo {
-		statsExist := false
-		var stats xray.ClientTraffic
-		for _, clientStat := range inbound.ClientStats {
-			if clientStat.Email == email {
-				stats = clientStat
-				statsExist = true
-				break
-			}
-		}
-
-		// Get remained days
-		if statsExist {
-			if !stats.Enable {
-				return fmt.Sprintf("⛔️N/A%s%s", separationChar, strings.Join(remark, separationChar))
-			}
-			if vol := stats.Total - (stats.Up + stats.Down); vol > 0 {
-				remark = append(remark, fmt.Sprintf("%s%s", common.FormatTraffic(vol), "📊"))
-			}
-			now := time.Now().Unix()
-			switch exp := stats.ExpiryTime / 1000; {
-			case exp > 0:
-				remainingSeconds := exp - now
-				days := remainingSeconds / 86400
-				hours := (remainingSeconds % 86400) / 3600
-				minutes := (remainingSeconds % 3600) / 60
-				if days > 0 {
-					if hours > 0 {
-						remark = append(remark, fmt.Sprintf("%dD,%dH⏳", days, hours))
-					} else {
-						remark = append(remark, fmt.Sprintf("%dD⏳", days))
-					}
-				} else if hours > 0 {
-					remark = append(remark, fmt.Sprintf("%dH⏳", hours))
-				} else {
-					remark = append(remark, fmt.Sprintf("%dM⏳", minutes))
-				}
-			case exp < 0:
-				days := exp / -86400
-				hours := (exp % -86400) / 3600
-				minutes := (exp % -3600) / 60
-				if days > 0 {
-					if hours > 0 {
-						remark = append(remark, fmt.Sprintf("%dD,%dH⏳", days, hours))
-					} else {
-						remark = append(remark, fmt.Sprintf("%dD⏳", days))
-					}
-				} else if hours > 0 {
-					remark = append(remark, fmt.Sprintf("%dH⏳", hours))
-				} else {
-					remark = append(remark, fmt.Sprintf("%dM⏳", minutes))
-				}
-			}
+// findClientStats returns the inbound's traffic record for email, if present.
+func (s *SubService) findClientStats(inbound *model.Inbound, email string) (xray.ClientTraffic, bool) {
+	for _, clientStat := range inbound.ClientStats {
+		if clientStat.Email == email {
+			return clientStat, true
 		}
 	}
-	return strings.Join(remark, separationChar)
+	return xray.ClientTraffic{}, false
 }
 
 func searchKey(data any, key string) (any, bool) {

+ 1 - 1
internal/sub/service_dedup_test.go

@@ -52,7 +52,7 @@ func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) {
 		t.Fatalf("seed client_inbound: %v", err)
 	}
 
-	s := NewSubService(false, "-ieo")
+	s := NewSubService("")
 	links, emails, _, _, err := s.GetSubs(subId, "sub.example.com")
 	if err != nil {
 		t.Fatalf("GetSubs: %v", err)

+ 3 - 3
internal/sub/service_flow_test.go

@@ -66,7 +66,7 @@ const xhttpRealityStream = `{
 }`
 
 func TestGenVlessLink_FlowXhttpRealityVlessenc(t *testing.T) {
-	s := &SubService{remarkModel: "-ieo"}
+	s := &SubService{}
 	link := s.genVlessLink(flowTestInbound(xhttpRealityStream, testMlkemEncryption), "user")
 	if !strings.Contains(link, "flow=xtls-rprx-vision") {
 		t.Fatalf("xhttp+reality+vlessenc link must carry the vision flow (#5232), got %q", link)
@@ -74,7 +74,7 @@ func TestGenVlessLink_FlowXhttpRealityVlessenc(t *testing.T) {
 }
 
 func TestGenVlessLink_NoFlowXhttpRealityWithoutVlessenc(t *testing.T) {
-	s := &SubService{remarkModel: "-ieo"}
+	s := &SubService{}
 	link := s.genVlessLink(flowTestInbound(xhttpRealityStream, "none"), "user")
 	if strings.Contains(link, "flow=") {
 		t.Fatalf("xhttp+reality without vlessenc must not carry a flow, got %q", link)
@@ -92,7 +92,7 @@ func TestGenVlessLink_FlowTcpRealityStillWorks(t *testing.T) {
 			"settings": {"publicKey": "pub", "fingerprint": "chrome"}
 		}
 	}`
-	s := &SubService{remarkModel: "-ieo"}
+	s := &SubService{}
 	link := s.genVlessLink(flowTestInbound(stream, "none"), "user")
 	if !strings.Contains(link, "flow=xtls-rprx-vision") {
 		t.Fatalf("tcp+reality link must keep the vision flow, got %q", link)

+ 2 - 2
internal/sub/service_sharelink_test.go

@@ -34,7 +34,7 @@ func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
 			"settings":{"fingerprint":"chrome","pinnedPeerCertSha256":["YWJj"]}
 		}
 	}`
-	s := &SubService{remarkModel: "-ieo"}
+	s := &SubService{}
 	link := s.genVlessLink(shareLinkInbound(stream), "user")
 
 	// url.Values.Encode() percent-encodes values: "," -> %2C, "/" -> %2F.
@@ -66,7 +66,7 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
 			"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
 		}
 	}`
-	s := &SubService{remarkModel: "-ieo"}
+	s := &SubService{}
 	link := s.genVlessLink(shareLinkInbound(stream), "user")
 
 	wants := []string{

+ 1 - 1
internal/sub/service_sort_test.go

@@ -62,7 +62,7 @@ func TestGetSubs_OrdersBySubSortIndexThenId(t *testing.T) {
 		}
 	}
 
-	s := NewSubService(false, "-ieo")
+	s := NewSubService("")
 	links, emails, _, _, err := s.GetSubs(subId, "sub.example.com")
 	if err != nil {
 		t.Fatalf("GetSubs: %v", err)

+ 1 - 2
internal/sub/service_test.go

@@ -32,8 +32,7 @@ func TestSubscriptionExpiryFromClient(t *testing.T) {
 func TestGenRemarkOmitsNodeName(t *testing.T) {
 	nodeID := 7
 	s := &SubService{
-		remarkModel: "-ieo",
-		nodesByID:   map[int]*model.Node{7: {Id: 7, Name: "Berlin", Address: "node7.example.com"}},
+		nodesByID: map[int]*model.Node{7: {Id: 7, Name: "Berlin", Address: "node7.example.com"}},
 	}
 	ib := &model.Inbound{Remark: "vless-tcp", NodeID: &nodeID}
 	if got := s.genRemark(ib, "", ""); got != "vless-tcp" {

+ 3 - 8
internal/sub/sub.go

@@ -105,14 +105,9 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		return nil, err
 	}
 
-	ShowInfo, err := s.settingService.GetSubShowInfo()
+	RemarkTemplate, err := s.settingService.GetRemarkTemplate()
 	if err != nil {
-		return nil, err
-	}
-
-	RemarkModel, err := s.settingService.GetRemarkModel()
-	if err != nil {
-		RemarkModel = "-io"
+		RemarkTemplate = ""
 	}
 
 	SubUpdates, err := s.settingService.GetSubUpdates()
@@ -230,7 +225,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	g := engine.Group("/")
 
 	s.sub = NewSUBController(
-		g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
+		g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, RemarkTemplate, SubUpdates,
 		SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
 		SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
 

+ 5 - 0
internal/web/controller/api.go

@@ -19,6 +19,7 @@ type APIController struct {
 	inboundController     *InboundController
 	serverController      *ServerController
 	nodeController        *NodeController
+	hostController        *HostController
 	settingController     *SettingController
 	xraySettingController *XraySettingController
 	settingService        service.SettingService
@@ -95,6 +96,10 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
 	nodes := api.Group("/nodes")
 	a.nodeController = NewNodeController(nodes)
 
+	// Hosts API — per-inbound override endpoints for subscription links
+	hosts := api.Group("/hosts")
+	a.hostController = NewHostController(hosts)
+
 	// Settings + Xray config management live under the API surface too, so the
 	// same API token drives them. Paths are /panel/api/setting/* and
 	// /panel/api/xray/*.

+ 2 - 0
internal/web/controller/api_docs_test.go

@@ -95,6 +95,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
 			basePath = "/panel/api/server"
 		case "node.go":
 			basePath = "/panel/api/nodes"
+		case "host.go":
+			basePath = "/panel/api/hosts"
 		case "setting.go":
 			basePath = "/panel/api/setting"
 		case "xray_setting.go":

+ 194 - 0
internal/web/controller/host.go

@@ -0,0 +1,194 @@
+package controller
+
+import (
+	"strconv"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+
+	"github.com/gin-gonic/gin"
+)
+
+// HostController exposes CRUD + ordering for Host override endpoints under
+// /panel/api/hosts. Thin HTTP layer over HostService; mirrors NodeController.
+type HostController struct {
+	hostService service.HostService
+}
+
+func NewHostController(g *gin.RouterGroup) *HostController {
+	a := &HostController{}
+	a.initRouter(g)
+	return a
+}
+
+func (a *HostController) initRouter(g *gin.RouterGroup) {
+	g.GET("/list", a.list)
+	g.GET("/get/:id", a.get)
+	g.GET("/byInbound/:inboundId", a.byInbound)
+	g.GET("/tags", a.tags)
+
+	g.POST("/add", a.add)
+	g.POST("/update/:id", a.update)
+	g.POST("/del/:id", a.del)
+	g.POST("/setEnable/:id", a.setEnable)
+	g.POST("/reorder", a.reorder)
+	g.POST("/bulk/setEnable", a.bulkSetEnable)
+	g.POST("/bulk/del", a.bulkDel)
+}
+
+func (a *HostController) list(c *gin.Context) {
+	hosts, err := a.hostService.GetHosts()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.list"), err)
+		return
+	}
+	jsonObj(c, hosts, nil)
+}
+
+func (a *HostController) get(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
+	h, err := a.hostService.GetHost(id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, h, nil)
+}
+
+func (a *HostController) byInbound(c *gin.Context) {
+	inboundId, err := strconv.Atoi(c.Param("inboundId"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
+	hosts, err := a.hostService.GetHostsByInbound(inboundId)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.list"), err)
+		return
+	}
+	jsonObj(c, hosts, nil)
+}
+
+func (a *HostController) tags(c *gin.Context) {
+	tags, err := a.hostService.GetAllTags()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.list"), err)
+		return
+	}
+	jsonObj(c, tags, nil)
+}
+
+func (a *HostController) add(c *gin.Context) {
+	h, ok := middleware.BindAndValidate[model.Host](c)
+	if !ok {
+		return
+	}
+	created, err := a.hostService.AddHost(h)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.add"), err)
+		return
+	}
+	jsonMsgObj(c, I18nWeb(c, "pages.hosts.toasts.add"), created, nil)
+}
+
+func (a *HostController) update(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
+	h, ok := middleware.BindAndValidate[model.Host](c)
+	if !ok {
+		return
+	}
+	updated, err := a.hostService.UpdateHost(id, h)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
+		return
+	}
+	jsonMsgObj(c, I18nWeb(c, "pages.hosts.toasts.update"), updated, nil)
+}
+
+func (a *HostController) del(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
+	if err := a.hostService.DeleteHost(id); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), nil)
+}
+
+func (a *HostController) setEnable(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
+	body := struct {
+		Enable bool `json:"enable" form:"enable"`
+	}{}
+	if err := c.ShouldBind(&body); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
+		return
+	}
+	if err := a.hostService.SetHostEnable(id, body.Enable); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), nil)
+}
+
+func (a *HostController) reorder(c *gin.Context) {
+	var req struct {
+		Ids []int `json:"ids" form:"ids"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
+		return
+	}
+	if err := a.hostService.ReorderHosts(req.Ids); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), nil)
+}
+
+func (a *HostController) bulkSetEnable(c *gin.Context) {
+	var req struct {
+		Ids    []int `json:"ids" form:"ids"`
+		Enable bool  `json:"enable" form:"enable"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
+		return
+	}
+	if err := a.hostService.SetHostsEnable(req.Ids, req.Enable); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), nil)
+}
+
+func (a *HostController) bulkDel(c *gin.Context) {
+	var req struct {
+		Ids []int `json:"ids" form:"ids"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
+		return
+	}
+	if err := a.hostService.DeleteHosts(req.Ids); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), nil)
+}

+ 146 - 0
internal/web/controller/host_test.go

@@ -0,0 +1,146 @@
+package controller
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"strconv"
+	"testing"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-contrib/sessions/cookie"
+	"github.com/gin-gonic/gin"
+	"github.com/op/go-logging"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+func newHostTestDB(t *testing.T) {
+	t.Helper()
+	// I18nWeb logs a warning when the localizer is absent (as in tests); the
+	// logger must be initialised so that warning does not nil-panic.
+	xuilogger.InitLogger(logging.ERROR)
+	gin.SetMode(gin.TestMode)
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+}
+
+type hostEnvelope struct {
+	Success bool            `json:"success"`
+	Msg     string          `json:"msg"`
+	Obj     json.RawMessage `json:"obj"`
+}
+
+func doHostReq(t *testing.T, engine *gin.Engine, method, path string, body any) hostEnvelope {
+	t.Helper()
+	var rdr *bytes.Reader
+	if body != nil {
+		b, _ := json.Marshal(body)
+		rdr = bytes.NewReader(b)
+	} else {
+		rdr = bytes.NewReader(nil)
+	}
+	req := httptest.NewRequest(method, path, rdr)
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, req)
+	if w.Code != http.StatusOK {
+		t.Fatalf("%s %s: status %d, body=%s", method, path, w.Code, w.Body.String())
+	}
+	var env hostEnvelope
+	if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+		t.Fatalf("%s %s: decode envelope: %v body=%s", method, path, err, w.Body.String())
+	}
+	return env
+}
+
+// TestHostController_AddListGetDelete exercises the CRUD round-trip and asserts
+// the {success,msg,obj} envelope convention through the registered routes.
+func TestHostController_AddListGetDelete(t *testing.T) {
+	newHostTestDB(t)
+	engine := gin.New()
+	NewHostController(engine.Group("/panel/api/hosts"))
+
+	ib := &model.Inbound{Tag: "ctl", Enable: true, Port: 5443, Protocol: model.VLESS, Settings: `{"clients":[]}`}
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+
+	// add
+	add := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/add", map[string]any{
+		"inboundId": ib.Id, "remark": "h1", "address": "h1.example.com", "port": 8443,
+	})
+	if !add.Success {
+		t.Fatalf("add not successful: %s", add.Msg)
+	}
+	var created model.Host
+	if err := json.Unmarshal(add.Obj, &created); err != nil {
+		t.Fatalf("decode created host: %v", err)
+	}
+	if created.Id == 0 || created.Remark != "h1" {
+		t.Fatalf("created host = %+v", created)
+	}
+
+	// list
+	list := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/list", nil)
+	var hosts []model.Host
+	if err := json.Unmarshal(list.Obj, &hosts); err != nil {
+		t.Fatalf("decode list: %v", err)
+	}
+	if len(hosts) != 1 || hosts[0].Id != created.Id {
+		t.Fatalf("list = %+v, want one host id=%d", hosts, created.Id)
+	}
+
+	// get
+	get := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+itoa(created.Id), nil)
+	if !get.Success {
+		t.Fatalf("get not successful: %s", get.Msg)
+	}
+
+	// del
+	del := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/del/"+itoa(created.Id), nil)
+	if !del.Success {
+		t.Fatalf("del not successful: %s", del.Msg)
+	}
+	list2 := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/list", nil)
+	var hosts2 []model.Host
+	_ = json.Unmarshal(list2.Obj, &hosts2)
+	if len(hosts2) != 0 {
+		t.Fatalf("after delete, list = %+v, want empty", hosts2)
+	}
+}
+
+// TestHostController_AuthInherited mirrors production wiring: the hosts group is
+// nested under the api group guarded by checkAPIAuth, so an unauthenticated XHR
+// to a hosts route is rejected (401) — the auth is inherited, not re-declared.
+func TestHostController_AuthInherited(t *testing.T) {
+	newHostTestDB(t)
+	engine := gin.New()
+	store := cookie.NewStore([]byte("host-auth-test-secret"))
+	engine.Use(sessions.Sessions("3x-ui", store))
+
+	a := &APIController{}
+	api := engine.Group("/panel/api")
+	api.Use(a.checkAPIAuth)
+	NewHostController(api.Group("/hosts"))
+
+	req := httptest.NewRequest(http.MethodGet, "/panel/api/hosts/list", nil)
+	req.Header.Set("X-Requested-With", "XMLHttpRequest")
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, req)
+	if w.Code != http.StatusUnauthorized {
+		t.Fatalf("unauthenticated hosts/list = %d, want 401 (auth inherited)", w.Code)
+	}
+}
+
+func itoa(i int) string {
+	return strconv.Itoa(i)
+}

+ 5 - 7
internal/web/entity/entity.go

@@ -32,11 +32,11 @@ type AllSetting struct {
 	PanelOutbound     string `json:"panelOutbound" form:"panelOutbound"`                             // Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)
 
 	// UI settings
-	PageSize    int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`      // Number of items per page in lists (0 disables pagination)
-	ExpireDiff  int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`           // Expiration warning threshold in days
-	TrafficDiff int    `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"` // Traffic warning threshold percentage
-	RemarkModel string `json:"remarkModel" form:"remarkModel"`                          // Remark model pattern for inbounds
-	Datepicker  string `json:"datepicker" form:"datepicker"`                            // Date picker format
+	PageSize       int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`      // Number of items per page in lists (0 disables pagination)
+	ExpireDiff     int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`           // Expiration warning threshold in days
+	TrafficDiff    int    `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"` // Traffic warning threshold percentage
+	RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"`                    // Subscription remark template ({{VAR}} tokens) rendered per client
+	Datepicker     string `json:"datepicker" form:"datepicker"`                            // Date picker format
 
 	// Telegram bot settings
 	TgBotEnable     bool   `json:"tgBotEnable" form:"tgBotEnable"`              // Enable Telegram bot notifications
@@ -86,8 +86,6 @@ type AllSetting struct {
 	ExternalTrafficInformURI    string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"`       // URI for external traffic reporting
 	RestartXrayOnClientDisable  bool   `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"`   // Restart Xray when clients are auto-disabled by expiry/traffic limit
 	SubEncrypt                  bool   `json:"subEncrypt" form:"subEncrypt"`                                   // Encrypt subscription responses
-	SubShowInfo                 bool   `json:"subShowInfo" form:"subShowInfo"`                                 // Show client information in subscriptions
-	SubEmailInRemark            bool   `json:"subEmailInRemark" form:"subEmailInRemark"`                       // Include email in subscription remark/name
 	SubURI                      string `json:"subURI" form:"subURI"`                                           // Subscription server URI
 	SubJsonPath                 string `json:"subJsonPath" form:"subJsonPath"`                                 // Path for JSON subscription endpoint
 	SubJsonURI                  string `json:"subJsonURI" form:"subJsonURI"`                                   // JSON subscription server URI

+ 130 - 0
internal/web/service/host.go

@@ -0,0 +1,130 @@
+package service
+
+import (
+	"sort"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+)
+
+// HostService manages Host rows (override endpoints attached to an inbound).
+// Mirrors the empty-struct + database.GetDB() shape of ClientService.
+type HostService struct{}
+
+// GetHosts returns every host, grouped by inbound then ordered by sort_order.
+func (s *HostService) GetHosts() ([]*model.Host, error) {
+	var hosts []*model.Host
+	err := database.GetDB().Order("inbound_id asc, sort_order asc, id asc").Find(&hosts).Error
+	return hosts, err
+}
+
+// GetHostsByInbound returns one inbound's hosts ordered by sort_order then id.
+func (s *HostService) GetHostsByInbound(inboundId int) ([]*model.Host, error) {
+	var hosts []*model.Host
+	err := database.GetDB().Where("inbound_id = ?", inboundId).Order("sort_order asc, id asc").Find(&hosts).Error
+	return hosts, err
+}
+
+func (s *HostService) GetHost(id int) (*model.Host, error) {
+	host := &model.Host{}
+	if err := database.GetDB().First(host, id).Error; err != nil {
+		return nil, err
+	}
+	return host, nil
+}
+
+// AddHost creates a host after confirming its inbound exists (no hard FK).
+func (s *HostService) AddHost(host *model.Host) (*model.Host, error) {
+	db := database.GetDB()
+	var count int64
+	if err := db.Model(&model.Inbound{}).Where("id = ?", host.InboundId).Count(&count).Error; err != nil {
+		return nil, err
+	}
+	if count == 0 {
+		return nil, common.NewError("inbound not found")
+	}
+	host.Id = 0
+	if err := db.Create(host).Error; err != nil {
+		return nil, err
+	}
+	return host, nil
+}
+
+// UpdateHost overwrites a host's content. InboundId and SortOrder are immutable
+// here — the inbound is fixed at creation and ordering is owned by ReorderHosts.
+func (s *HostService) UpdateHost(id int, host *model.Host) (*model.Host, error) {
+	db := database.GetDB()
+	existing := &model.Host{}
+	if err := db.First(existing, id).Error; err != nil {
+		return nil, err
+	}
+	host.Id = id
+	host.InboundId = existing.InboundId
+	host.SortOrder = existing.SortOrder
+	host.CreatedAt = existing.CreatedAt
+	if err := db.Save(host).Error; err != nil {
+		return nil, err
+	}
+	return s.GetHost(id)
+}
+
+func (s *HostService) DeleteHost(id int) error {
+	return database.GetDB().Delete(&model.Host{}, id).Error
+}
+
+func (s *HostService) SetHostEnable(id int, enable bool) error {
+	return database.GetDB().Model(&model.Host{}).Where("id = ?", id).Update("is_disabled", !enable).Error
+}
+
+func (s *HostService) SetHostsEnable(ids []int, enable bool) error {
+	if len(ids) == 0 {
+		return nil
+	}
+	return database.GetDB().Model(&model.Host{}).Where("id IN ?", ids).Update("is_disabled", !enable).Error
+}
+
+func (s *HostService) DeleteHosts(ids []int) error {
+	if len(ids) == 0 {
+		return nil
+	}
+	return database.GetDB().Where("id IN ?", ids).Delete(&model.Host{}).Error
+}
+
+// ReorderHosts assigns sort_order by the position of each id in ids, in a single
+// transaction (driver-safe on SQLite and Postgres).
+func (s *HostService) ReorderHosts(ids []int) error {
+	if len(ids) == 0 {
+		return nil
+	}
+	tx := database.GetDB().Begin()
+	for i, id := range ids {
+		if err := tx.Model(&model.Host{}).Where("id = ?", id).Update("sort_order", i).Error; err != nil {
+			tx.Rollback()
+			return err
+		}
+	}
+	return tx.Commit().Error
+}
+
+// GetAllTags returns the distinct, sorted set of tags across all hosts.
+func (s *HostService) GetAllTags() ([]string, error) {
+	hosts, err := s.GetHosts()
+	if err != nil {
+		return nil, err
+	}
+	set := make(map[string]struct{})
+	for _, h := range hosts {
+		for _, tag := range h.Tags {
+			if tag != "" {
+				set[tag] = struct{}{}
+			}
+		}
+	}
+	out := make([]string, 0, len(set))
+	for tag := range set {
+		out = append(out, tag)
+	}
+	sort.Strings(out)
+	return out, nil
+}

+ 179 - 0
internal/web/service/host_test.go

@@ -0,0 +1,179 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func mkHost(t *testing.T, svc *HostService, inboundId int, remark string, order int) *model.Host {
+	t.Helper()
+	h, err := svc.AddHost(&model.Host{
+		InboundId: inboundId,
+		Remark:    remark,
+		SortOrder: order,
+		Address:   remark + ".example.com",
+		Port:      8443,
+	})
+	if err != nil {
+		t.Fatalf("AddHost %s: %v", remark, err)
+	}
+	return h
+}
+
+// TestAddHost_GetHostsByInbound: create persists; query returns by inbound,
+// ordered by sort_order then id.
+func TestAddHost_GetHostsByInbound(t *testing.T) {
+	setupBulkDB(t)
+	svc := &HostService{}
+	ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
+	h1 := mkHost(t, svc, ib.Id, "b", 2)
+	h2 := mkHost(t, svc, ib.Id, "a", 1)
+
+	got, err := svc.GetHostsByInbound(ib.Id)
+	if err != nil {
+		t.Fatalf("GetHostsByInbound: %v", err)
+	}
+	if len(got) != 2 {
+		t.Fatalf("len = %d, want 2", len(got))
+	}
+	if got[0].Id != h2.Id || got[1].Id != h1.Id {
+		t.Fatalf("order = [%d,%d], want [%d,%d] (sort_order asc)", got[0].Id, got[1].Id, h2.Id, h1.Id)
+	}
+	if got[0].Address != "a.example.com" {
+		t.Fatalf("address not persisted: %q", got[0].Address)
+	}
+}
+
+// TestAddHost_RejectsUnknownInbound: a host whose inbound does not exist is refused.
+func TestAddHost_RejectsUnknownInbound(t *testing.T) {
+	setupBulkDB(t)
+	svc := &HostService{}
+	if _, err := svc.AddHost(&model.Host{InboundId: 99999, Remark: "x"}); err == nil {
+		t.Fatalf("expected error adding host to unknown inbound")
+	}
+}
+
+// TestReorderHosts: reorder updates sort_order and re-query reflects new order.
+func TestReorderHosts(t *testing.T) {
+	setupBulkDB(t)
+	svc := &HostService{}
+	ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
+	h1 := mkHost(t, svc, ib.Id, "h1", 0)
+	h2 := mkHost(t, svc, ib.Id, "h2", 0)
+	h3 := mkHost(t, svc, ib.Id, "h3", 0)
+
+	want := []int{h3.Id, h1.Id, h2.Id}
+	if err := svc.ReorderHosts(want); err != nil {
+		t.Fatalf("ReorderHosts: %v", err)
+	}
+	got, _ := svc.GetHostsByInbound(ib.Id)
+	for i, h := range got {
+		if h.Id != want[i] {
+			t.Fatalf("position %d = %d, want %d", i, h.Id, want[i])
+		}
+		if h.SortOrder != i {
+			t.Fatalf("host %d sort_order = %d, want %d", h.Id, h.SortOrder, i)
+		}
+	}
+}
+
+// TestSetHostEnableAndBulk: per-row and bulk enable/disable toggles persist.
+func TestSetHostEnableAndBulk(t *testing.T) {
+	setupBulkDB(t)
+	svc := &HostService{}
+	ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
+	h1 := mkHost(t, svc, ib.Id, "h1", 0)
+	h2 := mkHost(t, svc, ib.Id, "h2", 1)
+
+	if err := svc.SetHostEnable(h1.Id, false); err != nil {
+		t.Fatalf("SetHostEnable: %v", err)
+	}
+	if g, _ := svc.GetHost(h1.Id); g == nil || !g.IsDisabled {
+		t.Fatalf("h1 should be disabled after SetHostEnable(false)")
+	}
+
+	if err := svc.SetHostsEnable([]int{h1.Id, h2.Id}, true); err != nil {
+		t.Fatalf("SetHostsEnable(true): %v", err)
+	}
+	for _, id := range []int{h1.Id, h2.Id} {
+		if g, _ := svc.GetHost(id); g == nil || g.IsDisabled {
+			t.Fatalf("host %d should be enabled", id)
+		}
+	}
+	if err := svc.SetHostsEnable([]int{h1.Id, h2.Id}, false); err != nil {
+		t.Fatalf("SetHostsEnable(false): %v", err)
+	}
+	for _, id := range []int{h1.Id, h2.Id} {
+		if g, _ := svc.GetHost(id); g == nil || !g.IsDisabled {
+			t.Fatalf("host %d should be disabled", id)
+		}
+	}
+}
+
+// TestDeleteHosts: bulk delete removes exactly the named rows.
+func TestDeleteHosts(t *testing.T) {
+	setupBulkDB(t)
+	svc := &HostService{}
+	ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
+	h1 := mkHost(t, svc, ib.Id, "h1", 0)
+	h2 := mkHost(t, svc, ib.Id, "h2", 1)
+	h3 := mkHost(t, svc, ib.Id, "h3", 2)
+
+	if err := svc.DeleteHosts([]int{h1.Id, h3.Id}); err != nil {
+		t.Fatalf("DeleteHosts: %v", err)
+	}
+	got, _ := svc.GetHostsByInbound(ib.Id)
+	if len(got) != 1 || got[0].Id != h2.Id {
+		t.Fatalf("remaining = %v, want only h2 (%d)", got, h2.Id)
+	}
+}
+
+// TestDeleteInboundCascadesHosts: deleting an inbound deletes its hosts.
+func TestDeleteInboundCascadesHosts(t *testing.T) {
+	setupBulkDB(t)
+	svc := &HostService{}
+	inboundSvc := &InboundService{}
+	// Disabled local inbound so DelInbound skips the runtime push.
+	ib := &model.Inbound{Tag: "casc", Enable: false, Port: 4443, Protocol: model.VLESS, Settings: `{"clients":[]}`}
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	mkHost(t, svc, ib.Id, "h1", 0)
+	mkHost(t, svc, ib.Id, "h2", 1)
+
+	if _, err := inboundSvc.DelInbound(ib.Id); err != nil {
+		t.Fatalf("DelInbound: %v", err)
+	}
+	got, _ := svc.GetHostsByInbound(ib.Id)
+	if len(got) != 0 {
+		t.Fatalf("hosts not cascaded on inbound delete, len = %d", len(got))
+	}
+}
+
+// TestGetAllTags: distinct, sorted tags across all hosts.
+func TestGetAllTags(t *testing.T) {
+	setupBulkDB(t)
+	svc := &HostService{}
+	ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
+	if _, err := svc.AddHost(&model.Host{InboundId: ib.Id, Remark: "h1", Tags: []string{"EU", "CDN"}}); err != nil {
+		t.Fatalf("AddHost: %v", err)
+	}
+	if _, err := svc.AddHost(&model.Host{InboundId: ib.Id, Remark: "h2", Tags: []string{"CDN", "FAST"}}); err != nil {
+		t.Fatalf("AddHost: %v", err)
+	}
+	tags, err := svc.GetAllTags()
+	if err != nil {
+		t.Fatalf("GetAllTags: %v", err)
+	}
+	want := []string{"CDN", "EU", "FAST"}
+	if len(tags) != len(want) {
+		t.Fatalf("tags = %v, want %v", tags, want)
+	}
+	for i := range want {
+		if tags[i] != want[i] {
+			t.Fatalf("tags = %v, want %v", tags, want)
+		}
+	}
+}

+ 4 - 0
internal/web/service/inbound.go

@@ -782,6 +782,10 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 	if err := db.Delete(model.Inbound{}, id).Error; err != nil {
 		return needRestart, err
 	}
+	// Hosts have no hard FK; drop the inbound's hosts alongside it.
+	if err := db.Where("inbound_id = ?", id).Delete(&model.Host{}).Error; err != nil {
+		return needRestart, err
+	}
 	if markDirty && ib.NodeID != nil {
 		if dErr := (&NodeService{}).MarkNodeDirty(*ib.NodeID); dErr != nil {
 			logger.Warning("mark node dirty failed:", dErr)

Неке датотеке нису приказане због велике количине промена