4 Commity 29b14dac59 ... 315ecc2588

Autor SHA1 Wiadomość Data
  MHSanaei 315ecc2588 fix(inbound): persist streamSettings for tunnel so sockopt saves 9 godzin temu
  wahh3b-lgtm 605e90dbf0 feat(sub): add dynamic remark variables with Jalali date, transport, and status tokens (#5430) 10 godzin temu
  IgorKha ce1d348ece feat(sub): add option to hide server settings in subscription (happ) (#5433) 11 godzin temu
  w3struk 1a4aef3353 feat(sub): full XHTTP field mapping for Clash/Mihomo subscriptions (#5417) 11 godzin temu
37 zmienionych plików z 1115 dodań i 81 usunięć
  1. 2 1
      .gitignore
  2. 10 0
      frontend/public/openapi.json
  3. 2 0
      frontend/src/generated/examples.ts
  4. 10 0
      frontend/src/generated/schemas.ts
  5. 2 0
      frontend/src/generated/types.ts
  6. 2 0
      frontend/src/generated/zod.ts
  7. 1 0
      frontend/src/models/setting.ts
  8. 3 0
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  9. 1 0
      frontend/src/schemas/setting.ts
  10. 130 24
      internal/sub/clash_service.go
  11. 470 0
      internal/sub/clash_service_test.go
  12. 10 3
      internal/sub/controller.go
  13. 2 2
      internal/sub/endpoint.go
  14. 2 2
      internal/sub/endpoint_test.go
  15. 7 5
      internal/sub/host_sub.go
  16. 4 2
      internal/sub/json_service.go
  17. 216 8
      internal/sub/remark_vars.go
  18. 181 15
      internal/sub/remark_vars_test.go
  19. 16 15
      internal/sub/service.go
  20. 1 1
      internal/sub/service_test.go
  21. 6 1
      internal/sub/sub.go
  22. 1 0
      internal/web/entity/entity.go
  23. 5 2
      internal/web/service/inbound.go
  24. 5 0
      internal/web/service/setting.go
  25. 2 0
      internal/web/translation/ar-EG.json
  26. 2 0
      internal/web/translation/en-US.json
  27. 2 0
      internal/web/translation/es-ES.json
  28. 2 0
      internal/web/translation/fa-IR.json
  29. 2 0
      internal/web/translation/id-ID.json
  30. 2 0
      internal/web/translation/ja-JP.json
  31. 2 0
      internal/web/translation/pt-BR.json
  32. 2 0
      internal/web/translation/ru-RU.json
  33. 2 0
      internal/web/translation/tr-TR.json
  34. 2 0
      internal/web/translation/uk-UA.json
  35. 2 0
      internal/web/translation/vi-VN.json
  36. 2 0
      internal/web/translation/zh-CN.json
  37. 2 0
      internal/web/translation/zh-TW.json

+ 2 - 1
.gitignore

@@ -43,4 +43,5 @@ system_metrics.gob
 docker-compose.override.yml
 
 # Ignore .env (Environment Variables) file
-.env
+.env
+

+ 10 - 0
frontend/public/openapi.json

@@ -222,6 +222,10 @@
             "description": "Encrypt subscription responses",
             "type": "boolean"
           },
+          "subHideSettings": {
+            "description": "Hide server settings in happ subscription (Only for Happ)",
+            "type": "boolean"
+          },
           "subJsonEnable": {
             "description": "Enable JSON subscription endpoint",
             "type": "boolean"
@@ -439,6 +443,7 @@
           "subEnable",
           "subEnableRouting",
           "subEncrypt",
+          "subHideSettings",
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonMux",
@@ -698,6 +703,10 @@
             "description": "Encrypt subscription responses",
             "type": "boolean"
           },
+          "subHideSettings": {
+            "description": "Hide server settings in happ subscription (Only for Happ)",
+            "type": "boolean"
+          },
           "subJsonEnable": {
             "description": "Enable JSON subscription endpoint",
             "type": "boolean"
@@ -922,6 +931,7 @@
           "subEnable",
           "subEnableRouting",
           "subEncrypt",
+          "subHideSettings",
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonMux",

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

@@ -50,6 +50,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subEnable": false,
     "subEnableRouting": false,
     "subEncrypt": false,
+    "subHideSettings": false,
     "subJsonEnable": false,
     "subJsonFinalMask": "",
     "subJsonMux": "",
@@ -147,6 +148,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subEnable": false,
     "subEnableRouting": false,
     "subEncrypt": false,
+    "subHideSettings": false,
     "subJsonEnable": false,
     "subJsonFinalMask": "",
     "subJsonMux": "",

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

@@ -196,6 +196,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Encrypt subscription responses",
         "type": "boolean"
       },
+      "subHideSettings": {
+        "description": "Hide server settings in happ subscription (Only for Happ)",
+        "type": "boolean"
+      },
       "subJsonEnable": {
         "description": "Enable JSON subscription endpoint",
         "type": "boolean"
@@ -413,6 +417,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEnable",
       "subEnableRouting",
       "subEncrypt",
+      "subHideSettings",
       "subJsonEnable",
       "subJsonFinalMask",
       "subJsonMux",
@@ -672,6 +677,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Encrypt subscription responses",
         "type": "boolean"
       },
+      "subHideSettings": {
+        "description": "Hide server settings in happ subscription (Only for Happ)",
+        "type": "boolean"
+      },
       "subJsonEnable": {
         "description": "Enable JSON subscription endpoint",
         "type": "boolean"
@@ -896,6 +905,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEnable",
       "subEnableRouting",
       "subEncrypt",
+      "subHideSettings",
       "subJsonEnable",
       "subJsonFinalMask",
       "subJsonMux",

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

@@ -56,6 +56,7 @@ export interface AllSetting {
   subEnable: boolean;
   subEnableRouting: boolean;
   subEncrypt: boolean;
+  subHideSettings: boolean;
   subJsonEnable: boolean;
   subJsonFinalMask: string;
   subJsonMux: string;
@@ -154,6 +155,7 @@ export interface AllSettingView {
   subEnable: boolean;
   subEnableRouting: boolean;
   subEncrypt: boolean;
+  subHideSettings: boolean;
   subJsonEnable: boolean;
   subJsonFinalMask: string;
   subJsonMux: string;

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

@@ -68,6 +68,7 @@ export const AllSettingSchema = z.object({
   subEnable: z.boolean(),
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
+  subHideSettings: z.boolean(),
   subJsonEnable: z.boolean(),
   subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
@@ -167,6 +168,7 @@ export const AllSettingViewSchema = z.object({
   subEnable: z.boolean(),
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
+  subHideSettings: z.boolean(),
   subJsonEnable: z.boolean(),
   subJsonFinalMask: z.string(),
   subJsonMux: z.string(),

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

@@ -57,6 +57,7 @@ export class AllSetting {
   subJsonRules = '';
   subJsonFinalMask = '';
   subThemeDir = '';
+  subHideSettings = false;
 
   timeLocation = 'Local';
 

+ 3 - 0
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -153,6 +153,9 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
               <Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
                 onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
             </SettingListItem>
+            <SettingListItem paddings="small" title={t('pages.settings.subHideSettings')} description={t('pages.settings.subHideSettingsDesc')}>
+              <Switch checked={allSetting.subHideSettings} onChange={(v) => updateSetting({ subHideSettings: v })} />
+            </SettingListItem>
           </>
         ),
       },

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

@@ -61,6 +61,7 @@ export const AllSettingSchema = z.object({
   subJsonMux: z.string().optional(),
   subJsonRules: z.string().optional(),
   subJsonFinalMask: z.string().optional(),
+  subHideSettings: z.boolean().optional(),
   timeLocation: z.string().optional(),
   ldapEnable: z.boolean().optional(),
   ldapHost: z.string().optional(),

+ 130 - 24
internal/sub/clash_service.go

@@ -163,13 +163,14 @@ func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound,
 		}}
 	}
 	delete(stream, "externalProxy")
+	network, _ := stream["network"].(string)
 
 	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)
+		subReq.renderHostRemark(inbound, client, extPrxy, network)
 		workingInbound := *inbound
 		workingInbound.Listen = extPrxy["dest"].(string)
 		workingInbound.Port = int(extPrxy["port"].(float64))
@@ -214,14 +215,14 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
 		return s.buildHysteriaProxy(subReq, inbound, client, ep)
 	}
 
+	network, _ := stream["network"].(string)
+
 	proxy := map[string]any{
-		"name":   subReq.endpointRemark(inbound, client.Email, ep),
+		"name":   subReq.endpointRemark(inbound, client.Email, ep, network),
 		"server": inbound.Listen,
 		"port":   inbound.Port,
 		"udp":    true,
 	}
-
-	network, _ := stream["network"].(string)
 	if !s.applyTransport(proxy, network, stream) {
 		return nil
 	}
@@ -298,7 +299,7 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
 	}
 
 	proxy := map[string]any{
-		"name":   subReq.endpointRemark(inbound, client.Email, ep),
+		"name":   subReq.endpointRemark(inbound, client.Email, ep, "quic"),
 		"type":   proxyType,
 		"server": inbound.Listen,
 		"port":   inbound.Port,
@@ -363,6 +364,128 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
 	return proxy
 }
 
+// buildXhttpClashOpts converts xhttpSettings from 3x-ui's camelCase JSON
+// storage into the kebab-case map that Mihomo expects under xhttp-opts.
+//
+// Only client-relevant fields are included (allowlist approach).
+// Server-only fields (noSSEHeader, scMaxBufferedPosts, scStreamUpServerSecs,
+// serverMaxHeaderBytes) are automatically excluded because they are not in
+// the mapping. This is intentional — when Mihomo adds new fields, the mapping
+// must be updated explicitly rather than leaking unverified fields to clients.
+//
+// Returns nil if no non-trivial fields are present.
+func buildXhttpClashOpts(xhttp map[string]any) map[string]any {
+	if xhttp == nil {
+		return nil
+	}
+	opts := map[string]any{}
+
+	// Direct fields: path, mode
+	if v, ok := xhttp["path"].(string); ok && v != "" {
+		opts["path"] = v
+	}
+	if v, ok := xhttp["mode"].(string); ok && v != "" {
+		opts["mode"] = v
+	}
+
+	// Host: explicit host field wins, then fall back to headers.Host
+	host := ""
+	if v, ok := xhttp["host"].(string); ok && v != "" {
+		host = v
+	} else if headers, ok := xhttp["headers"].(map[string]any); ok {
+		host = searchHost(headers)
+	}
+	if host != "" {
+		opts["host"] = host
+	}
+
+	type xhttpStringField struct{ src, dst, skipValue string }
+
+	stringFields := []xhttpStringField{
+		{"xPaddingBytes", "x-padding-bytes", ""},
+		{"uplinkHTTPMethod", "uplink-http-method", ""},
+		{"sessionPlacement", "session-placement", ""},
+		{"sessionKey", "session-key", ""},
+		{"seqPlacement", "seq-placement", ""},
+		{"seqKey", "seq-key", ""},
+		{"uplinkDataPlacement", "uplink-data-placement", ""},
+		{"uplinkDataKey", "uplink-data-key", ""},
+		{"scMaxEachPostBytes", "sc-max-each-post-bytes", "1000000"},
+		{"scMinPostsIntervalMs", "sc-min-posts-interval-ms", "30"},
+	}
+
+	for _, f := range stringFields {
+		if v, ok := xhttp[f.src].(string); ok && v != "" && (f.skipValue == "" || v != f.skipValue) {
+			opts[f.dst] = v
+		}
+	}
+
+	// Bool fields (truthy only)
+	if v, ok := xhttp["noGRPCHeader"].(bool); ok && v {
+		opts["no-grpc-header"] = true
+	}
+	if v, ok := xhttp["xPaddingObfsMode"].(bool); ok && v {
+		opts["x-padding-obfs-mode"] = true
+		// Padding obfs gated fields
+		for _, field := range []struct{ src, dst string }{
+			{"xPaddingKey", "x-padding-key"},
+			{"xPaddingHeader", "x-padding-header"},
+			{"xPaddingPlacement", "x-padding-placement"},
+			{"xPaddingMethod", "x-padding-method"},
+		} {
+			if v, ok := xhttp[field.src].(string); ok && v != "" {
+				opts[field.dst] = v
+			}
+		}
+	}
+
+	// Non-zero value fields
+	if v, ok := nonZeroShareValue(xhttp["uplinkChunkSize"]); ok {
+		opts["uplink-chunk-size"] = v
+	}
+
+	// Nested object: xmux → reuse-settings
+	if xmux, ok := xhttp["xmux"].(map[string]any); ok && len(xmux) > 0 {
+		reuse := map[string]any{}
+		for _, f := range []struct{ src, dst string }{
+			{"maxConcurrency", "max-concurrency"},
+			{"maxConnections", "max-connections"},
+			{"cMaxReuseTimes", "c-max-reuse-times"},
+			{"hMaxRequestTimes", "h-max-request-times"},
+			{"hMaxReusableSecs", "h-max-reusable-secs"},
+		} {
+			if v, ok := xmux[f.src].(string); ok && v != "" {
+				reuse[f.dst] = v
+			}
+		}
+		if v, ok := nonZeroShareValue(xmux["hKeepAlivePeriod"]); ok {
+			reuse["h-keep-alive-period"] = v
+		}
+		if len(reuse) > 0 {
+			opts["reuse-settings"] = reuse
+		}
+	}
+
+	// Headers (drop Host key)
+	if rawHeaders, ok := xhttp["headers"].(map[string]any); ok && len(rawHeaders) > 0 {
+		out := map[string]any{}
+		for k, v := range rawHeaders {
+			if strings.EqualFold(k, "host") {
+				continue
+			}
+			out[k] = v
+		}
+		if len(out) > 0 {
+			opts["headers"] = out
+		}
+	}
+
+	if len(opts) == 0 {
+		return nil
+	}
+	return opts
+}
+
 func (s *SubClashService) applyTransport(proxy map[string]any, network string, stream map[string]any) bool {
 	switch network {
 	case "", "tcp":
@@ -438,25 +561,8 @@ func (s *SubClashService) applyTransport(proxy map[string]any, network string, s
 	case "xhttp":
 		proxy["network"] = "xhttp"
 		xhttp, _ := stream["xhttpSettings"].(map[string]any)
-		opts := map[string]any{}
-		if xhttp != nil {
-			if path, ok := xhttp["path"].(string); ok && path != "" {
-				opts["path"] = path
-			}
-			host := ""
-			if v, ok := xhttp["host"].(string); ok && v != "" {
-				host = v
-			} else if headers, ok := xhttp["headers"].(map[string]any); ok {
-				host = searchHost(headers)
-			}
-			if host != "" {
-				opts["host"] = host
-			}
-			if mode, ok := xhttp["mode"].(string); ok && mode != "" {
-				opts["mode"] = mode
-			}
-		}
-		if len(opts) > 0 {
+		opts := buildXhttpClashOpts(xhttp)
+		if opts != nil {
 			proxy["xhttp-opts"] = opts
 		}
 		return true

+ 470 - 0
internal/sub/clash_service_test.go

@@ -145,6 +145,22 @@ func TestApplyTransport_XHTTP_HostFromHeaders(t *testing.T) {
 	}
 }
 
+func TestApplyTransport_XHTTP_NoSettings(t *testing.T) {
+	svc := &SubClashService{}
+	proxy := map[string]any{}
+	stream := map[string]any{}
+
+	if !svc.applyTransport(proxy, "xhttp", stream) {
+		t.Fatalf("applyTransport returned false for xhttp with no xhttpSettings")
+	}
+	if proxy["network"] != "xhttp" {
+		t.Fatalf("network = %v, want xhttp", proxy["network"])
+	}
+	if _, exists := proxy["xhttp-opts"]; exists {
+		t.Fatalf("xhttp-opts should be absent when xhttpSettings is missing, got %#v", proxy["xhttp-opts"])
+	}
+}
+
 func TestApplyTransport_HTTPUpgrade(t *testing.T) {
 	svc := &SubClashService{}
 	proxy := map[string]any{}
@@ -301,3 +317,457 @@ func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
 		t.Fatalf("uuid = %v, want %v", proxy["uuid"], client.ID)
 	}
 }
+
+func TestBuildXhttpClashOpts_FullFieldMapping(t *testing.T) {
+	xhttp := map[string]any{
+		"path":    "/api/v1",
+		"mode":    "stream-up",
+		"host":    "example.com",
+		"xPaddingBytes":    "100-1000",
+		"xPaddingObfsMode": true,
+		"xPaddingKey":       "mykey",
+		"xPaddingHeader":    "X-Trace-ID",
+		"xPaddingPlacement": "queryInHeader",
+		"xPaddingMethod":    "tokenish",
+		"uplinkHTTPMethod":  "POST",
+		"sessionPlacement":  "query",
+		"sessionKey":        "sess",
+		"seqPlacement":      "header",
+		"seqKey":            "seq",
+		"uplinkDataPlacement": "body",
+		"uplinkDataKey":      "udata",
+		"uplinkChunkSize":    "64-256",
+		"noGRPCHeader":       true,
+		"scMaxEachPostBytes": "500000",
+		"scMinPostsIntervalMs": "50",
+		"xmux": map[string]any{
+			"maxConcurrency":   "16-32",
+			"maxConnections":   "4",
+			"cMaxReuseTimes":   "8",
+			"hMaxRequestTimes":  "600-900",
+			"hMaxReusableSecs":  "1800-3000",
+			"hKeepAlivePeriod":  float64(60),
+		},
+		"headers": map[string]any{
+			"User-Agent": "chrome",
+			"Host":       "should-be-dropped.com",
+		},
+	}
+
+	opts := buildXhttpClashOpts(xhttp)
+	if opts == nil {
+		t.Fatal("expected non-nil opts for full field mapping")
+	}
+
+	// Direct fields
+	if opts["path"] != "/api/v1" {
+		t.Errorf("path = %v, want /api/v1", opts["path"])
+	}
+	if opts["mode"] != "stream-up" {
+		t.Errorf("mode = %v, want stream-up", opts["mode"])
+	}
+	if opts["host"] != "example.com" {
+		t.Errorf("host = %v, want example.com", opts["host"])
+	}
+
+	// String fields
+	if opts["x-padding-bytes"] != "100-1000" {
+		t.Errorf("x-padding-bytes = %v", opts["x-padding-bytes"])
+	}
+	if opts["uplink-http-method"] != "POST" {
+		t.Errorf("uplink-http-method = %v", opts["uplink-http-method"])
+	}
+	if opts["session-placement"] != "query" {
+		t.Errorf("session-placement = %v", opts["session-placement"])
+	}
+	if opts["session-key"] != "sess" {
+		t.Errorf("session-key = %v", opts["session-key"])
+	}
+	if opts["seq-placement"] != "header" {
+		t.Errorf("seq-placement = %v", opts["seq-placement"])
+	}
+	if opts["seq-key"] != "seq" {
+		t.Errorf("seq-key = %v", opts["seq-key"])
+	}
+	if opts["uplink-data-placement"] != "body" {
+		t.Errorf("uplink-data-placement = %v", opts["uplink-data-placement"])
+	}
+	if opts["uplink-data-key"] != "udata" {
+		t.Errorf("uplink-data-key = %v", opts["uplink-data-key"])
+	}
+
+	// DPI-filtered fields (non-default values should pass)
+	if opts["sc-max-each-post-bytes"] != "500000" {
+		t.Errorf("sc-max-each-post-bytes = %v", opts["sc-max-each-post-bytes"])
+	}
+	if opts["sc-min-posts-interval-ms"] != "50" {
+		t.Errorf("sc-min-posts-interval-ms = %v", opts["sc-min-posts-interval-ms"])
+	}
+
+	// Bool fields
+	if opts["no-grpc-header"] != true {
+		t.Errorf("no-grpc-header = %v, want true", opts["no-grpc-header"])
+	}
+	if opts["x-padding-obfs-mode"] != true {
+		t.Errorf("x-padding-obfs-mode = %v, want true", opts["x-padding-obfs-mode"])
+	}
+
+	// Padding obfs gated fields
+	if opts["x-padding-key"] != "mykey" {
+		t.Errorf("x-padding-key = %v", opts["x-padding-key"])
+	}
+	if opts["x-padding-header"] != "X-Trace-ID" {
+		t.Errorf("x-padding-header = %v", opts["x-padding-header"])
+	}
+	if opts["x-padding-placement"] != "queryInHeader" {
+		t.Errorf("x-padding-placement = %v", opts["x-padding-placement"])
+	}
+	if opts["x-padding-method"] != "tokenish" {
+		t.Errorf("x-padding-method = %v", opts["x-padding-method"])
+	}
+
+	// Non-zero value fields
+	if opts["uplink-chunk-size"] != "64-256" {
+		t.Errorf("uplink-chunk-size = %v", opts["uplink-chunk-size"])
+	}
+
+	// Reuse-settings (xmux)
+	reuse, ok := opts["reuse-settings"].(map[string]any)
+	if !ok {
+		t.Fatalf("reuse-settings missing or wrong type: %#v", opts["reuse-settings"])
+	}
+	if reuse["max-concurrency"] != "16-32" {
+		t.Errorf("max-concurrency = %v", reuse["max-concurrency"])
+	}
+	if reuse["max-connections"] != "4" {
+		t.Errorf("max-connections = %v", reuse["max-connections"])
+	}
+	if reuse["c-max-reuse-times"] != "8" {
+		t.Errorf("c-max-reuse-times = %v", reuse["c-max-reuse-times"])
+	}
+	if reuse["h-max-request-times"] != "600-900" {
+		t.Errorf("h-max-request-times = %v", reuse["h-max-request-times"])
+	}
+	if reuse["h-max-reusable-secs"] != "1800-3000" {
+		t.Errorf("h-max-reusable-secs = %v", reuse["h-max-reusable-secs"])
+	}
+	if reuse["h-keep-alive-period"] != float64(60) {
+		t.Errorf("h-keep-alive-period = %v, want 60", reuse["h-keep-alive-period"])
+	}
+
+	// Headers (Host should be dropped)
+	headers, ok := opts["headers"].(map[string]any)
+	if !ok {
+		t.Fatalf("headers missing or wrong type: %#v", opts["headers"])
+	}
+	if headers["User-Agent"] != "chrome" {
+		t.Errorf("headers[User-Agent] = %v", headers["User-Agent"])
+	}
+	if _, has := headers["Host"]; has {
+		t.Error("headers should not contain Host key")
+	}
+	if _, has := headers["host"]; has {
+		t.Error("headers should not contain host key (case-insensitive)")
+	}
+}
+
+func TestBuildXhttpClashOpts_DPIDefaultsFiltered(t *testing.T) {
+	xhttp := map[string]any{
+		"path":                "/",
+		"mode":                "stream-up",
+		"scMaxEachPostBytes":   "1000000",
+		"scMinPostsIntervalMs": "30",
+	}
+	opts := buildXhttpClashOpts(xhttp)
+	if opts == nil {
+		t.Fatal("expected non-nil opts (path and mode should be present)")
+	}
+	if _, has := opts["sc-max-each-post-bytes"]; has {
+		t.Error("sc-max-each-post-bytes should be filtered when value is 1000000")
+	}
+	if _, has := opts["sc-min-posts-interval-ms"]; has {
+		t.Error("sc-min-posts-interval-ms should be filtered when value is 30")
+	}
+}
+
+func TestBuildXhttpClashOpts_PaddingObfsGate(t *testing.T) {
+	// Sub-test 1: obfs mode false — gated fields should not appear
+	t.Run("ObfsModeFalse", func(t *testing.T) {
+		xhttp := map[string]any{
+			"path":            "/",
+			"xPaddingObfsMode": false,
+			"xPaddingKey":     "should-not-appear",
+		}
+		opts := buildXhttpClashOpts(xhttp)
+		if opts == nil {
+			t.Fatal("expected non-nil opts")
+		}
+		if _, has := opts["x-padding-obfs-mode"]; has {
+			t.Error("x-padding-obfs-mode should not appear when false")
+		}
+		if _, has := opts["x-padding-key"]; has {
+			t.Error("x-padding-key should not appear when obfs mode is false")
+		}
+	})
+
+	// Sub-test 2: obfs mode absent — gated fields should not appear
+	t.Run("ObfsModeAbsent", func(t *testing.T) {
+		xhttp := map[string]any{
+			"path":        "/",
+			"xPaddingKey": "should-not-appear",
+		}
+		opts := buildXhttpClashOpts(xhttp)
+		if opts == nil {
+			t.Fatal("expected non-nil opts")
+		}
+		if _, has := opts["x-padding-key"]; has {
+			t.Error("x-padding-key should not appear when obfs mode is absent")
+		}
+	})
+
+	// Sub-test 3: obfs mode true with no gated fields — only x-padding-obfs-mode appears
+	t.Run("ObfsModeTrueNoGatedFields", func(t *testing.T) {
+		xhttp := map[string]any{
+			"path":             "/",
+			"xPaddingObfsMode": true,
+		}
+		opts := buildXhttpClashOpts(xhttp)
+		if opts == nil {
+			t.Fatal("expected non-nil opts")
+		}
+		if opts["x-padding-obfs-mode"] != true {
+			t.Errorf("x-padding-obfs-mode = %v, want true", opts["x-padding-obfs-mode"])
+		}
+		if _, has := opts["x-padding-key"]; has {
+			t.Error("x-padding-key should not appear when not set")
+		}
+	})
+}
+
+func TestBuildXhttpClashOpts_XmuxMapsToReuseSettings(t *testing.T) {
+	// Sub-test 1: full xmux mapping
+	t.Run("FullXmux", func(t *testing.T) {
+		xhttp := map[string]any{
+			"path": "/",
+			"xmux": map[string]any{
+				"maxConcurrency":   "16-32",
+				"maxConnections":   "4",
+				"cMaxReuseTimes":   "8",
+				"hMaxRequestTimes":  "600-900",
+				"hMaxReusableSecs":  "1800-3000",
+				"hKeepAlivePeriod":  float64(60),
+			},
+		}
+		opts := buildXhttpClashOpts(xhttp)
+		if opts == nil {
+			t.Fatal("expected non-nil opts")
+		}
+		reuse, ok := opts["reuse-settings"].(map[string]any)
+		if !ok {
+			t.Fatalf("reuse-settings missing or wrong type: %#v", opts["reuse-settings"])
+		}
+		if reuse["max-concurrency"] != "16-32" {
+			t.Errorf("max-concurrency = %v", reuse["max-concurrency"])
+		}
+		if reuse["max-connections"] != "4" {
+			t.Errorf("max-connections = %v", reuse["max-connections"])
+		}
+		if reuse["c-max-reuse-times"] != "8" {
+			t.Errorf("c-max-reuse-times = %v", reuse["c-max-reuse-times"])
+		}
+		if reuse["h-max-request-times"] != "600-900" {
+			t.Errorf("h-max-request-times = %v", reuse["h-max-request-times"])
+		}
+		if reuse["h-max-reusable-secs"] != "1800-3000" {
+			t.Errorf("h-max-reusable-secs = %v", reuse["h-max-reusable-secs"])
+		}
+		if reuse["h-keep-alive-period"] != float64(60) {
+			t.Errorf("h-keep-alive-period = %v, want 60", reuse["h-keep-alive-period"])
+		}
+	})
+
+	// Sub-test 2: empty xmux map — no reuse-settings key
+	t.Run("EmptyXmux", func(t *testing.T) {
+		xhttp := map[string]any{
+			"path": "/",
+			"xmux": map[string]any{},
+		}
+		opts := buildXhttpClashOpts(xhttp)
+		if opts == nil {
+			t.Fatal("expected non-nil opts (path is present)")
+		}
+		if _, has := opts["reuse-settings"]; has {
+			t.Error("reuse-settings should not appear for empty xmux")
+		}
+	})
+
+	// Sub-test 3: hKeepAlivePeriod as int (not float64)
+	t.Run("IntKeepAlivePeriod", func(t *testing.T) {
+		xhttp := map[string]any{
+			"path": "/",
+			"xmux": map[string]any{
+				"hKeepAlivePeriod": int(60),
+			},
+		}
+		opts := buildXhttpClashOpts(xhttp)
+		if opts == nil {
+			t.Fatal("expected non-nil opts")
+		}
+		reuse, ok := opts["reuse-settings"].(map[string]any)
+		if !ok {
+			t.Fatalf("reuse-settings missing: %#v", opts["reuse-settings"])
+		}
+		if reuse["h-keep-alive-period"] != int(60) {
+			t.Errorf("h-keep-alive-period = %v (%T), want 60 (int)", reuse["h-keep-alive-period"], reuse["h-keep-alive-period"])
+		}
+	})
+
+	// Sub-test 4: hKeepAlivePeriod=0 should be filtered
+	t.Run("ZeroKeepAlivePeriod", func(t *testing.T) {
+		xhttp := map[string]any{
+			"path": "/",
+			"xmux": map[string]any{
+				"hKeepAlivePeriod": float64(0),
+			},
+		}
+		opts := buildXhttpClashOpts(xhttp)
+		if opts == nil {
+			t.Fatal("expected non-nil opts")
+		}
+		if _, has := opts["reuse-settings"]; has {
+			t.Error("reuse-settings should not appear when only hKeepAlivePeriod=0")
+		}
+	})
+}
+
+func TestBuildXhttpClashOpts_ServerOnlyFieldsExcluded(t *testing.T) {
+	xhttp := map[string]any{
+		"path":                 "/",
+		"noSSEHeader":          true,
+		"scMaxBufferedPosts":   "100",
+		"scStreamUpServerSecs": "5",
+		"serverMaxHeaderBytes": "4096",
+	}
+	opts := buildXhttpClashOpts(xhttp)
+	if opts == nil {
+		t.Fatal("expected non-nil opts (path is present)")
+	}
+	if _, has := opts["no-sse-header"]; has {
+		t.Error("noSSEHeader should not appear in Clash output (server-only)")
+	}
+	if _, has := opts["sc-max-buffered-posts"]; has {
+		t.Error("scMaxBufferedPosts should not appear in Clash output (server-only)")
+	}
+	if _, has := opts["sc-stream-up-server-secs"]; has {
+		t.Error("scStreamUpServerSecs should not appear in Clash output (server-only)")
+	}
+	if _, has := opts["server-max-header-bytes"]; has {
+		t.Error("serverMaxHeaderBytes should not appear in Clash output (not in Mihomo)")
+	}
+}
+
+func TestBuildXhttpClashOpts_NilInput(t *testing.T) {
+	opts := buildXhttpClashOpts(nil)
+	if opts != nil {
+		t.Fatalf("expected nil for nil input, got %#v", opts)
+	}
+}
+
+func TestBuildXhttpClashOpts_EmptyInput(t *testing.T) {
+	opts := buildXhttpClashOpts(map[string]any{})
+	if opts != nil {
+		t.Fatalf("expected nil for empty input, got %#v", opts)
+	}
+}
+
+func TestBuildXhttpClashOpts_HostFallbackFromHeaders(t *testing.T) {
+	// Sub-test 1: host from headers.Host
+	t.Run("HostFromHeaders", func(t *testing.T) {
+		xhttp := map[string]any{
+			"path":    "/",
+			"headers": map[string]any{"Host": "via-header.example.com"},
+		}
+		opts := buildXhttpClashOpts(xhttp)
+		if opts == nil {
+			t.Fatal("expected non-nil opts")
+		}
+		if opts["host"] != "via-header.example.com" {
+			t.Errorf("host = %v, want via-header.example.com", opts["host"])
+		}
+	})
+
+	// Sub-test 2: headers only contains Host — no headers key in output
+	t.Run("HeadersOnlyHost", func(t *testing.T) {
+		xhttp := map[string]any{
+			"path":    "/",
+			"headers": map[string]any{"Host": "only-host.example.com"},
+		}
+		opts := buildXhttpClashOpts(xhttp)
+		if opts == nil {
+			t.Fatal("expected non-nil opts")
+		}
+		if _, has := opts["headers"]; has {
+			t.Error("headers key should not appear when only Host is present (Host is extracted to top-level)")
+		}
+	})
+
+	// Sub-test 3: case-insensitive Host drop
+	t.Run("CaseInsensitiveHostDrop", func(t *testing.T) {
+		xhttp := map[string]any{
+			"path": "/",
+			"host": "explicit.example.com",
+			"headers": map[string]any{
+				"host":     "lowercase-host.example.com",
+				"X-Custom": "value",
+			},
+		}
+		opts := buildXhttpClashOpts(xhttp)
+		if opts == nil {
+			t.Fatal("expected non-nil opts")
+		}
+		if opts["host"] != "explicit.example.com" {
+			t.Errorf("host = %v, want explicit.example.com (explicit host wins)", opts["host"])
+		}
+		headers, ok := opts["headers"].(map[string]any)
+		if !ok {
+			t.Fatal("headers should be present (X-Custom remains)")
+		}
+		if _, has := headers["host"]; has {
+			t.Error("lowercase 'host' should be dropped from headers")
+		}
+		if headers["X-Custom"] != "value" {
+			t.Errorf("X-Custom = %v, want value", headers["X-Custom"])
+		}
+	})
+}
+
+func TestBuildXhttpClashOpts_NoGRPCHeaderFalsey(t *testing.T) {
+	// Sub-test 1: noGRPCHeader: false
+	t.Run("ExplicitFalse", func(t *testing.T) {
+		xhttp := map[string]any{
+			"path":         "/",
+			"noGRPCHeader": false,
+		}
+		opts := buildXhttpClashOpts(xhttp)
+		if opts == nil {
+			t.Fatal("expected non-nil opts (path is present)")
+		}
+		if _, has := opts["no-grpc-header"]; has {
+			t.Error("no-grpc-header should not appear when noGRPCHeader is false")
+		}
+	})
+
+	// Sub-test 2: noGRPCHeader absent
+	t.Run("Absent", func(t *testing.T) {
+		xhttp := map[string]any{
+			"path": "/",
+		}
+		opts := buildXhttpClashOpts(xhttp)
+		if opts == nil {
+			t.Fatal("expected non-nil opts")
+		}
+		if _, has := opts["no-grpc-header"]; has {
+			t.Error("no-grpc-header should not appear when absent")
+		}
+	})
+}

+ 10 - 3
internal/sub/controller.go

@@ -48,6 +48,7 @@ type SUBController struct {
 	subAnnounce      string
 	subEnableRouting bool
 	subRoutingRules  string
+	subHideSettings  bool
 	subPath          string
 	subJsonPath      string
 	subClashPath     string
@@ -87,6 +88,7 @@ func NewSUBController(
 	subAnnounce string,
 	subEnableRouting bool,
 	subRoutingRules string,
+	subHideSettings bool,
 ) *SUBController {
 	sub := NewSubService(remarkTemplate)
 	a := &SUBController{
@@ -96,6 +98,7 @@ func NewSUBController(
 		subAnnounce:      subAnnounce,
 		subEnableRouting: subEnableRouting,
 		subRoutingRules:  subRoutingRules,
+		subHideSettings:  subHideSettings,
 		subPath:          subPath,
 		subJsonPath:      jsonPath,
 		subClashPath:     clashPath,
@@ -178,7 +181,7 @@ func (a *SUBController) subs(c *gin.Context) {
 		if profileUrl == "" {
 			profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
 		}
-		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
+		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
 
 		if a.subEncrypt {
 			c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String())))
@@ -357,7 +360,7 @@ func (a *SUBController) subJsons(c *gin.Context) {
 		if profileUrl == "" {
 			profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
 		}
-		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
+		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
 
 		c.String(200, jsonSub)
 	}
@@ -374,7 +377,7 @@ func (a *SUBController) subClashs(c *gin.Context) {
 		if profileUrl == "" {
 			profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
 		}
-		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
+		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
 		if a.subTitle != "" {
 			// Clash clients commonly use Content-Disposition to choose the imported profile name.
 			c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle)))
@@ -394,6 +397,7 @@ func (a *SUBController) ApplyCommonHeaders(
 	profileAnnounce string,
 	profileEnableRouting bool,
 	profileRoutingRules string,
+	profileHideSettings bool,
 ) {
 	c.Writer.Header().Set("Subscription-Userinfo", header)
 	c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
@@ -417,4 +421,7 @@ func (a *SUBController) ApplyCommonHeaders(
 	if profileRoutingRules != "" {
 		c.Writer.Header().Set("Routing", profileRoutingRules)
 	}
+	if profileHideSettings {
+		c.Writer.Header().Set("Hide-Settings", "1")
+	}
 }

+ 2 - 2
internal/sub/endpoint.go

@@ -103,7 +103,7 @@ func (s *SubService) buildEndpointLinks(
 }
 
 // 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 {
+func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[string]any, inbound *model.Inbound, email string, transport string) string {
 	var links strings.Builder
 	for index, e := range eps {
 		securityToApply, _ := baseObj["tls"].(string)
@@ -111,7 +111,7 @@ func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[st
 			securityToApply = e.ForceTls
 		}
 		newObj := cloneVmessShareObj(baseObj, e.ForceTls)
-		newObj["ps"] = s.endpointRemark(inbound, email, e.ep)
+		newObj["ps"] = s.endpointRemark(inbound, email, e.ep, transport)
 		newObj["add"] = e.Address
 		newObj["port"] = e.Port
 		if e.ForceTls != "same" {

+ 2 - 2
internal/sub/endpoint_test.go

@@ -81,7 +81,7 @@ func TestBuildEndpointLinks_ParamForm(t *testing.T) {
 	}
 	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) },
+		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"
@@ -104,7 +104,7 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
 		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")
+	got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
 	want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEiLAogICJzY3kiOiAiYXV0byIsCiAgInNuaSI6ICJhLnNuaSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
 		"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9"
 	if got != want {

+ 7 - 5
internal/sub/host_sub.go

@@ -197,13 +197,15 @@ func (s *SubService) linkFromHosts(inbound *model.Inbound, client model.Client,
 	if len(eps) == 0 {
 		return ""
 	}
+	stream := unmarshalStreamSettings(inbound.StreamSettings)
+	transport, _ := stream["network"].(string)
 	// 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)
+		s.renderHostRemark(inbound, client, cp, transport)
 		rendered[i] = cp
 	}
 	clone := *inbound
@@ -216,12 +218,12 @@ func (s *SubService) linkFromHosts(inbound *model.Inbound, client model.Client,
 // 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) {
+func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Client, ep map[string]any, transport string) {
 	if !isHostEndpoint(ep) {
 		return
 	}
 	tmpl, _ := ep["remark"].(string)
-	ep["remark"] = s.genHostRemark(inbound, client, tmpl)
+	ep["remark"] = s.genHostRemark(inbound, client, tmpl, transport)
 	ep["remarkFinal"] = true
 }
 
@@ -229,7 +231,7 @@ func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Clien
 // 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 {
+func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map[string]any, transport string) string {
 	if ep != nil {
 		if final, _ := ep["remarkFinal"].(bool); final {
 			r, _ := ep["remark"].(string)
@@ -240,7 +242,7 @@ func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map
 	if ep != nil {
 		extra, _ = ep["remark"].(string)
 	}
-	return s.genRemark(inbound, email, extra)
+	return s.genRemark(inbound, email, extra, transport)
 }
 
 // applyEndpointHostPath overrides the transport host header / path for a host

+ 4 - 2
internal/sub/json_service.go

@@ -174,12 +174,13 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 	}
 
 	delete(stream, "externalProxy")
+	network, _ := stream["network"].(string)
 
 	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)
+		subReq.renderHostRemark(inbound, client, extPrxy, network)
 		inbound.Listen = extPrxy["dest"].(string)
 		inbound.Port = int(extPrxy["port"].(float64))
 		newStream := cloneStreamForExternalProxy(stream)
@@ -220,8 +221,9 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 		newConfigJson := make(map[string]any)
 		maps.Copy(newConfigJson, s.configJson)
 
+		transport, _ := newStream["network"].(string)
 		newConfigJson["outbounds"] = newOutbounds
-		newConfigJson["remarks"] = subReq.endpointRemark(inbound, client.Email, extPrxy)
+		newConfigJson["remarks"] = subReq.endpointRemark(inbound, client.Email, extPrxy, transport)
 
 		newConfig, _ := json.MarshalIndent(newConfigJson, "", "  ")
 		newJsonArray = append(newJsonArray, newConfig)

+ 216 - 8
internal/sub/remark_vars.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"fmt"
 	"regexp"
 	"strconv"
 	"strings"
@@ -23,6 +24,7 @@ type remarkContext struct {
 	stats      xray.ClientTraffic
 	inbound    *model.Inbound
 	hostRemark string
+	transport  string
 }
 
 // configName is the display name for a link: always the inbound's own remark.
@@ -50,6 +52,54 @@ var unlimitedDropTokens = map[string]bool{
 	"TRAFFIC_LEFT":  true,
 	"TRAFFIC_TOTAL": true,
 	"DAYS_LEFT":     true,
+	"TIME_LEFT":     true,
+}
+
+// uiTokenMap translates user-friendly single-brace tokens (used in the frontend
+// Remark/Host Name fields) to their internal double-brace equivalents. Tokens
+// not present in this map are left untouched.
+var uiTokenMap = map[string]string{
+	"EMAIL":              "EMAIL",
+	"DATA_USAGE":         "TRAFFIC_USED",
+	"DATA_LEFT":          "TRAFFIC_LEFT",
+	"DATA_LIMIT":         "TRAFFIC_TOTAL",
+	"DAYS_LEFT":          "DAYS_LEFT",
+	"EXPIRE_DATE":        "EXPIRE_DATE",
+	"JALALI_EXPIRE_DATE": "JALALI_EXPIRE_DATE",
+	"TIME_LEFT":          "TIME_LEFT",
+	"STATUS_EMOJI":       "STATUS_EMOJI",
+	"USAGE_PERCENTAGE":   "USAGE_PERCENTAGE",
+	"PROTOCOL":           "PROTOCOL",
+	"TRANSPORT":          "TRANSPORT",
+}
+
+// translateUISingleBrackets converts user-friendly single-brace tokens to the
+// internal double-brace format before regex expansion. Only {TOKEN} patterns
+// that are NOT part of {{TOKEN}} are translated. Unknown tokens stay as-is.
+func translateUISingleBrackets(template string) string {
+	var result strings.Builder
+	i := 0
+	for i < len(template) {
+		if template[i] == '{' && (i == 0 || template[i-1] != '{') {
+			j := i + 1
+			for j < len(template) && template[j] != '}' {
+				j++
+			}
+			if j < len(template) && template[j] == '}' {
+				token := template[i+1 : j]
+				if internal, ok := uiTokenMap[token]; ok {
+					result.WriteString("{{")
+					result.WriteString(internal)
+					result.WriteString("}}")
+					i = j + 1
+					continue
+				}
+			}
+		}
+		result.WriteByte(template[i])
+		i++
+	}
+	return result.String()
 }
 
 // expandRemarkVars substitutes every {{TOKEN}} in template with its per-client
@@ -58,6 +108,7 @@ var unlimitedDropTokens = map[string]bool{
 // 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 {
+	template = translateUISingleBrackets(template)
 	if !strings.Contains(template, "{{") {
 		return template
 	}
@@ -164,6 +215,21 @@ func remarkVarValue(token string, ctx remarkContext) string {
 			return strconv.Itoa(c.Reset)
 		}
 		return ""
+	case "STATUS_EMOJI":
+		return statusEmoji(st)
+	case "USAGE_PERCENTAGE":
+		return usagePercentage(st)
+	case "PROTOCOL":
+		if ctx.inbound != nil {
+			return strings.ToUpper(string(ctx.inbound.Protocol))
+		}
+		return ""
+	case "TRANSPORT":
+		return ctx.transport
+	case "TIME_LEFT":
+		return timeLeftLabel(st.ExpiryTime)
+	case "JALALI_EXPIRE_DATE":
+		return jalaliExpireDateLabel(st.ExpiryTime)
 	}
 	return ""
 }
@@ -202,13 +268,13 @@ func daysLeftLabel(expiryMs int64) string {
 	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 "".
+// expireDateLabel renders a fixed expiry as YYYY-MM-DD (local time). 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")
+	return time.Unix(expiryMs/1000, 0).In(time.Local).Format("2006-01-02")
 }
 
 func max64(a, b int64) int64 {
@@ -218,6 +284,145 @@ func max64(a, b int64) int64 {
 	return b
 }
 
+// statusEmoji maps clientStatus to a single emoji character.
+func statusEmoji(st xray.ClientTraffic) string {
+	switch clientStatus(st) {
+	case "active":
+		return "✅"
+	case "expired":
+		return "⏳"
+	case "depleted":
+		return "🚫"
+	case "disabled":
+		return "🚫"
+	default:
+		return ""
+	}
+}
+
+// usagePercentage computes the traffic usage as a percentage string (e.g. "52.3%").
+// Returns "" when the client has no traffic limit.
+func usagePercentage(st xray.ClientTraffic) string {
+	if st.Total <= 0 {
+		return ""
+	}
+	used := st.Up + st.Down
+	pct := float64(used) / float64(st.Total) * 100
+	if pct > 100 {
+		pct = 100 // clamp over-quota usage, consistent with TRAFFIC_LEFT
+	}
+	return fmt.Sprintf("%.1f%%", pct)
+}
+
+// timeLeftLabel renders remaining time as "Xd Xh Xm" (or shorter when days/hours
+// are zero). Returns "∞" for unlimited and "0" when past expiry.
+func timeLeftLabel(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
+	}
+	if secs <= 0 {
+		return "0"
+	}
+	days := secs / 86400
+	hours := (secs % 86400) / 3600
+	mins := (secs % 3600) / 60
+	if days > 0 {
+		return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
+	}
+	if hours > 0 {
+		return fmt.Sprintf("%dh %dm", hours, mins)
+	}
+	return fmt.Sprintf("%dm", mins)
+}
+
+// jalaliExpireDateLabel converts a Gregorian expiry timestamp to Jalali
+// (Persian/Solar Hijri) date format "YYYY/MM/DD". Returns "" for unlimited
+// or delayed-start expiries.
+func jalaliExpireDateLabel(expiryMs int64) string {
+	if expiryMs <= 0 {
+		return ""
+	}
+	t := time.Unix(expiryMs/1000, 0).In(time.Local)
+	y, m, d := gregorianToJalali(t.Year(), int(t.Month()), t.Day())
+	return fmt.Sprintf("%d/%02d/%02d", y, m, d)
+}
+
+// gregorianToJalali converts a Gregorian date to Jalali (Solar Hijri) date.
+// Uses a reference-date approach: counts days from a known reference point
+// (2024-01-01 = 1402-10-11 JAL) and walks the Jalali calendar forward/backward.
+func gregorianToJalali(gy, gm, gd int) (jy, jm, jd int) {
+	// Compute Julian Day Number for the input Gregorian date
+	a := (14 - gm) / 12
+	y := gy + 4800 - a
+	m := gm + 12*a - 3
+	jdn := gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045
+
+	// Reference: 2024-01-01 = JDN 2460311 = 1402-10-11 JAL
+	refJDN := 2460311
+	days := int64(jdn - refJDN)
+	jy, jm, jd = 1402, 10, 11
+
+	// Walk forward
+	for days > 0 {
+		remaining := int64(jalaliMonthDays(jy, jm) - jd + 1)
+		if days < remaining {
+			jd += int(days)
+			return
+		}
+		days -= remaining
+		jm++
+		if jm > 12 {
+			jm = 1
+			jy++
+		}
+		jd = 1
+	}
+	// Walk backward
+	for days < 0 {
+		jd += int(days)
+		for jd < 1 {
+			jm--
+			if jm < 1 {
+				jm = 12
+				jy--
+			}
+			jd += jalaliMonthDays(jy, jm)
+		}
+		days = 0
+	}
+	return
+}
+
+func jalaliMonthDays(y, m int) int {
+	if m <= 6 {
+		return 31
+	}
+	if m <= 11 {
+		return 30
+	}
+	if isJalaliLeap(y) {
+		return 30
+	}
+	return 29
+}
+
+// isJalaliLeap reports whether the given Jalali year is a leap year.
+// The leap pattern repeats every 33 years with 8 leap years.
+func isJalaliLeap(y int) bool {
+	switch y % 33 {
+	case 1, 5, 9, 13, 17, 22, 26, 30:
+		return true
+	}
+	return false
+}
+
 // 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.
@@ -261,6 +466,7 @@ 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",
+	"STATUS_EMOJI", "USAGE_PERCENTAGE", "TIME_LEFT", "JALALI_EXPIRE_DATE",
 }
 
 // nameOnlyTemplate returns template with the trailing per-client info part
@@ -287,25 +493,27 @@ func nameOnlyTemplate(template string) string {
 // 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 {
+	translated := translateUISingleBrackets(s.remarkTemplate)
 	if s.usageShown == nil {
 		s.usageShown = map[string]bool{}
 	}
 	if s.usageShown[email] {
-		return nameOnlyTemplate(s.remarkTemplate)
+		return nameOnlyTemplate(translated)
 	}
 	s.usageShown[email] = true
-	return s.remarkTemplate
+	return translated
 }
 
 // genTemplatedRemark expands the remark template for one client. hostRemark is
 // the host endpoint's remark (empty for a plain inbound); it backs the {{HOST}}
 // token only and never substitutes the inbound remark as the config name.
-func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
+func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
 	ctx := remarkContext{
 		client:     client,
 		stats:      s.statsForClient(inbound, client),
 		inbound:    inbound,
 		hostRemark: hostRemark,
+		transport:  transport,
 	}
 	tmpl := s.effectiveTemplate(client.Email)
 	// Fall back to the config name when the template is empty or expands to
@@ -320,9 +528,9 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
 // config name is always the inbound's own remark; the host's remark is surfaced
 // only through the {{HOST}} token. 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 {
+func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
 	if !s.subscriptionBody {
 		return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
 	}
-	return s.genTemplatedRemark(inbound, client, hostRemark)
+	return s.genTemplatedRemark(inbound, client, hostRemark, transport)
 }

+ 181 - 15
internal/sub/remark_vars_test.go

@@ -37,7 +37,6 @@ func TestExpandRemarkVars(t *testing.T) {
 
 	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},
@@ -169,10 +168,10 @@ func hostRemarkService(template string) (*SubService, *model.Inbound, model.Clie
 // never substitutes it (it is reachable only through {{HOST}}).
 func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
 	s, inbound, client := hostRemarkService("") // no template → config name only
-	if got := s.genHostRemark(inbound, client, "Relay"); got != "DE" {
+	if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE" {
 		t.Fatalf("genHostRemark = %q, want %q (inbound remark, host ignored)", got, "DE")
 	}
-	if got := s.genHostRemark(inbound, client, ""); got != "DE" {
+	if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
 		t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE")
 	}
 }
@@ -182,17 +181,17 @@ func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
 func TestGenHostRemark_GlobalTemplate(t *testing.T) {
 	// {{INBOUND}} resolves to the inbound remark regardless of the host remark.
 	s, inbound, client := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}} | {{DAYS_LEFT}}d")
-	if got := s.genHostRemark(inbound, client, "CDN"); got != "DE | 80.00GB | 10d" {
+	if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE | 80.00GB | 10d" {
 		t.Fatalf("global template ({{INBOUND}} = inbound) = %q", got)
 	}
 	// {{INBOUND}} and {{HOST}} side by side show both, distinctly (#5443).
 	s2, inbound2, client2 := hostRemarkService("{{INBOUND}}|{{HOST}}|{{TRAFFIC_LEFT}}")
-	if got := s2.genHostRemark(inbound2, client2, "CDN"); got != "DE|CDN|80.00GB" {
+	if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE|CDN|80.00GB" {
 		t.Fatalf("global template (inbound + host) = %q, want %q", got, "DE|CDN|80.00GB")
 	}
 	// {{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" {
+	if got := s3.genHostRemark(inbound3, client3, "CDN", ""); got != "CDN" {
 		t.Fatalf("{{HOST}} token = %q, want CDN", got)
 	}
 }
@@ -201,7 +200,7 @@ func TestGenHostRemark_GlobalTemplate(t *testing.T) {
 // legacy externalProxy remark passed as extra.
 func TestGenRemark_GlobalTemplate(t *testing.T) {
 	s, inbound, _ := hostRemarkService("{{EMAIL}} | {{TRAFFIC_LEFT}}")
-	got := s.genRemark(inbound, "[email protected]", "")
+	got := s.genRemark(inbound, "[email protected]", "", "")
 	if got != "[email protected] | 80.00GB" {
 		t.Fatalf("global template (non-host) = %q", got)
 	}
@@ -210,7 +209,7 @@ func TestGenRemark_GlobalTemplate(t *testing.T) {
 // 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")
+	got := s.genRemark(inbound, "[email protected]", "Relay", "")
 	if got != "DE-Relay" {
 		t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay")
 	}
@@ -220,8 +219,8 @@ func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
 // 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, "")
+	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)
 	}
@@ -241,15 +240,15 @@ func TestRemarkInDisplayContext(t *testing.T) {
 	s.subscriptionBody = false
 	// A host link in a display shows only the config name — the inbound's remark,
 	// with no per-client email or usage info and the host remark ignored.
-	if got := s.genHostRemark(inbound, client, "CDN"); got != "DE" {
+	if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE" {
 		t.Fatalf("display host link = %q, want config name %q", got, "DE")
 	}
 	// With no host remark, the config name is likewise the inbound's own remark.
-	if got := s.genHostRemark(inbound, client, ""); got != "DE" {
+	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" {
+	if got := s.genRemark(inbound, client.Email, "", ""); got != "DE" {
 		t.Fatalf("display genRemark = %q, want %q", got, "DE")
 	}
 }
@@ -294,9 +293,176 @@ func TestStatsForClient_CrossInboundFallback(t *testing.T) {
 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"}, "")
+	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)
 	}
 }
+
+func TestStatusEmoji(t *testing.T) {
+	cases := []struct {
+		stats xray.ClientTraffic
+		want  string
+	}{
+		{xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: gb}, "✅"},
+		{xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: 10 * gb, Down: 1}, "🚫"},
+		{xray.ClientTraffic{Enable: false}, "🚫"},
+		{xray.ClientTraffic{Enable: true, ExpiryTime: 1000}, "⏳"},
+	}
+	for _, c := range cases {
+		if got := statusEmoji(c.stats); got != c.want {
+			t.Errorf("statusEmoji(%+v) = %q, want %q", c.stats, got, c.want)
+		}
+	}
+}
+
+func TestUsagePercentage(t *testing.T) {
+	if got := usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 25 * gb, Down: 25 * gb}); got != "50.0%" {
+		t.Errorf("usagePercentage 50%% = %q", got)
+	}
+	if got := usagePercentage(xray.ClientTraffic{Total: 0}); got != "" {
+		t.Errorf("usagePercentage unlimited = %q, want empty", got)
+	}
+	if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 10 * gb}); got != "100.0%" {
+		t.Errorf("usagePercentage 100%% = %q", got)
+	}
+	// Over-quota usage clamps to 100%, consistent with TRAFFIC_LEFT.
+	if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 25 * gb}); got != "100.0%" {
+		t.Errorf("usagePercentage over-quota = %q, want 100.0%%", got)
+	}
+}
+
+func TestTimeLeftLabel(t *testing.T) {
+	if got := timeLeftLabel(0); got != "∞" {
+		t.Errorf("timeLeftLabel(0) = %q, want ∞", got)
+	}
+	// Delayed-start: negative expiry = duration in ms. 1000ms = 1 second = "0m".
+	if got := timeLeftLabel(-1000); got != "0m" {
+		t.Errorf("timeLeftLabel(-1000) = %q, want 0m", got)
+	}
+}
+
+func TestGregorianToJalali(t *testing.T) {
+	cases := []struct {
+		gy, gm, gd int
+		jy, jm, jd int
+	}{
+		{2024, 1, 1, 1402, 10, 11},
+		{2000, 3, 20, 1379, 1, 1},
+		{1979, 2, 11, 1357, 11, 22},
+	}
+	for _, c := range cases {
+		jy, jm, jd := gregorianToJalali(c.gy, c.gm, c.gd)
+		if jy != c.jy || jm != c.jm || jd != c.jd {
+			t.Errorf("gregorianToJalali(%d,%d,%d) = (%d,%d,%d), want (%d,%d,%d)",
+				c.gy, c.gm, c.gd, jy, jm, jd, c.jy, c.jm, c.jd)
+		}
+	}
+}
+
+func TestJalaliExpireDateLabel(t *testing.T) {
+	if got := jalaliExpireDateLabel(0); got != "" {
+		t.Errorf("jalaliExpireDateLabel(0) = %q, want empty", got)
+	}
+	if got := jalaliExpireDateLabel(-1000); got != "" {
+		t.Errorf("jalaliExpireDateLabel(-1000) = %q, want empty", got)
+	}
+}
+
+func TestExpandNewTokensInTemplate(t *testing.T) {
+	inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
+	client := model.Client{Email: "[email protected]", ID: "abc-123"}
+	stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
+	ctx := remarkContext{
+		client:    client,
+		stats:     stats,
+		inbound:   inbound,
+		transport: "ws",
+	}
+
+	cases := []struct{ tmpl, want string }{
+		{"{{STATUS_EMOJI}}", "✅"},
+		{"{{USAGE_PERCENTAGE}}", "50.0%"},
+		{"{{PROTOCOL}}", "VLESS"},
+		{"{{TRANSPORT}}", "ws"},
+		{"{{STATUS_EMOJI}} {{INBOUND}}", "✅ DE"},
+	}
+	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)
+		}
+	}
+}
+
+func TestTranslateUISingleBrackets(t *testing.T) {
+	cases := []struct{ in, want string }{
+		{"{EMAIL}", "{{EMAIL}}"},
+		{"{DATA_LEFT}", "{{TRAFFIC_LEFT}}"},
+		{"{DATA_LEFT} of {DATA_LIMIT}", "{{TRAFFIC_LEFT}} of {{TRAFFIC_TOTAL}}"},
+		{"{STATUS_EMOJI} {INBOUND}", "{{STATUS_EMOJI}} {INBOUND}"},
+		{"{UNKNOWN_TOKEN}", "{UNKNOWN_TOKEN}"},
+		{"no braces", "no braces"},
+		{"{{TRAFFIC_LEFT}}", "{{TRAFFIC_LEFT}}"},
+		{"{username}", "{username}"},
+	}
+	for _, c := range cases {
+		if got := translateUISingleBrackets(c.in); got != c.want {
+			t.Errorf("translateUISingleBrackets(%q) = %q, want %q", c.in, got, c.want)
+		}
+	}
+}
+
+func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
+	inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
+	stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
+	ctx := remarkContext{
+		client:    model.Client{Email: "[email protected]"},
+		stats:     stats,
+		inbound:   inbound,
+		transport: "ws",
+	}
+	cases := []struct{ tmpl, want string }{
+		{"{EMAIL}", "[email protected]"},
+		{"{DATA_LEFT}", "50.00GB"},
+		{"{DATA_USAGE}", "50.00GB"},
+		{"{DATA_LIMIT}", "100.00GB"},
+		{"{STATUS_EMOJI}", "✅"},
+		{"{USAGE_PERCENTAGE}", "50.0%"},
+		{"{PROTOCOL}", "VLESS"},
+		{"{TRANSPORT}", "ws"},
+	}
+	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)
+		}
+	}
+}
+
+func TestUsageOnFirstLinkOnly_SingleBracket(t *testing.T) {
+	s := &SubService{
+		remarkTemplate:   "{STATUS_EMOJI} {{INBOUND}}|📊{{TRAFFIC_LEFT}}",
+		subscriptionBody: true,
+		usageShown:       map[string]bool{},
+	}
+	inbound := &model.Inbound{
+		Remark: "DE",
+		ClientStats: []xray.ClientTraffic{{
+			Email:  "alice@x",
+			Enable: true,
+			Total:  100 * gb,
+			Up:     20 * gb,
+			Down:   10 * gb,
+		}},
+	}
+	client := model.Client{Email: "alice@x"}
+	first := s.genTemplatedRemark(inbound, client, "", "ws")
+	s.usageShown["alice@x"] = true
+	second := s.genTemplatedRemark(inbound, client, "", "ws")
+	if !strings.Contains(first, "📊") {
+		t.Fatalf("first link should carry usage: %q", first)
+	}
+	if strings.Contains(second, "📊") {
+		t.Fatalf("second link must not carry usage: %q", second)
+	}
+}

+ 16 - 15
internal/sub/service.go

@@ -532,10 +532,10 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
 	externalProxies, _ := stream["externalProxy"].([]any)
 
 	if len(externalProxies) > 0 {
-		return s.buildVmessExternalProxyLinks(externalProxies, obj, inbound, email)
+		return s.buildVmessExternalProxyLinks(externalProxies, obj, inbound, email, network)
 	}
 
-	obj["ps"] = s.genRemark(inbound, email, "")
+	obj["ps"] = s.genRemark(inbound, email, "", network)
 	return buildVmessLink(obj)
 }
 
@@ -619,13 +619,13 @@ 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.endpointRemark(inbound, email, ep)
+				return s.endpointRemark(inbound, email, ep, streamNetwork)
 			},
 		)
 	}
 
 	link := fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(address, port))
-	return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
+	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork))
 }
 
 func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string {
@@ -670,13 +670,13 @@ 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.endpointRemark(inbound, email, ep)
+				return s.endpointRemark(inbound, email, ep, streamNetwork)
 			},
 		)
 	}
 
 	link := fmt.Sprintf("trojan://%s@%s", password, joinHostPort(address, port))
-	return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
+	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork))
 }
 
 // encodeUserinfo percent-encodes a userinfo (password/auth) value so it
@@ -763,13 +763,13 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 				return fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
-				return s.endpointRemark(inbound, email, ep)
+				return s.endpointRemark(inbound, email, ep, streamNetwork)
 			},
 		)
 	}
 
 	link := fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(address, inbound.Port))
-	return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
+	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork))
 }
 
 func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) string {
@@ -872,7 +872,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 			applyExternalProxyHysteriaParams(ep, epParams)
 
 			link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(dest, int(portF)))
-			links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep)))
+			links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep, "quic")))
 		}
 		return strings.Join(links, "\n")
 	}
@@ -883,7 +883,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 		params["mport"] = hopPorts
 	}
 	link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
-	return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
+	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "quic"))
 }
 
 // hysteriaHopPorts returns the configured Hysteria2 UDP port-hopping range
@@ -1494,14 +1494,15 @@ func joinAnyStrings(items []any) string {
 
 // 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 {
+// endpoint path. Kept as a thin shim over the unified endpoint builder so
+// genVmessLink keeps calling one helper (now threading transport through).
+func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string, transport string) string {
 	eps := make([]ShareEndpoint, 0, len(externalProxies))
 	for _, externalProxy := range externalProxies {
 		ep, _ := externalProxy.(map[string]any)
 		eps = append(eps, externalProxyToEndpoint(ep))
 	}
-	return s.buildEndpointVmessLinks(eps, baseObj, inbound, email)
+	return s.buildEndpointVmessLinks(eps, baseObj, inbound, email, transport)
 }
 
 // buildLinkWithParams appends ?query and #fragment to a pre-built
@@ -1588,9 +1589,9 @@ func cloneStringMap(source map[string]string) map[string]string {
 // 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 {
+func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string, transport string) string {
 	if s.remarkTemplate != "" && s.subscriptionBody {
-		return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra)
+		return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport)
 	}
 	// Sub info page + panel link/QR displays: just the config name (no template,
 	// so no per-client email/usage leaks into the shown remark).

+ 1 - 1
internal/sub/service_test.go

@@ -35,7 +35,7 @@ func TestGenRemarkOmitsNodeName(t *testing.T) {
 		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" {
+	if got := s.genRemark(ib, "", "", ""); got != "vless-tcp" {
 		t.Fatalf("remark = %q, want %q (node name must not leak into client-visible remarks)", got, "vless-tcp")
 	}
 }

+ 6 - 1
internal/sub/sub.go

@@ -170,6 +170,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		SubRoutingRules = ""
 	}
 
+	SubHideSettings, err := s.settingService.GetSubHideSettings()
+	if err != nil {
+		SubHideSettings = false
+	}
+
 	// set per-request localizer from headers/cookies
 	engine.Use(locale.LocalizerMiddleware())
 
@@ -227,7 +232,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	s.sub = NewSUBController(
 		g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, RemarkTemplate, SubUpdates,
 		SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
-		SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
+		SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules, SubHideSettings)
 
 	return engine, nil
 }

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

@@ -98,6 +98,7 @@ type AllSetting struct {
 	SubJsonRules                string `json:"subJsonRules" form:"subJsonRules"`
 	SubJsonFinalMask            string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
 	SubThemeDir                 string `json:"subThemeDir" form:"subThemeDir"`           // Absolute path to a folder containing a custom subscription page template
+	SubHideSettings             bool   `json:"subHideSettings" form:"subHideSettings"`   // Hide server settings in happ subscription (Only for Happ)
 
 	// LDAP settings
 	LdapEnable     bool   `json:"ldapEnable" form:"ldapEnable"`

+ 5 - 2
internal/web/service/inbound.go

@@ -424,8 +424,10 @@ func (s *InboundService) getAllEmailSubIDs() (map[string]string, error) {
 }
 
 // normalizeStreamSettings clears StreamSettings for protocols that don't use it.
-// Only vmess, vless, trojan, shadowsocks, hysteria, and wireguard protocols use
-// streamSettings (wireguard for finalmask UDP masks and sockopt on its listener).
+// Only vmess, vless, trojan, shadowsocks, hysteria, wireguard, and tunnel
+// protocols use streamSettings (wireguard for finalmask UDP masks and sockopt on
+// its listener; tunnel for sockopt, notably sockopt.tproxy for its TProxy/redirect
+// mode).
 func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
 	protocolsWithStream := map[model.Protocol]bool{
 		model.VMESS:       true,
@@ -434,6 +436,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
 		model.Shadowsocks: true,
 		model.Hysteria:    true,
 		model.WireGuard:   true,
+		model.Tunnel:      true,
 	}
 
 	if !protocolsWithStream[inbound.Protocol] {

+ 5 - 0
internal/web/service/setting.go

@@ -74,6 +74,7 @@ var defaultValueMap = map[string]string{
 	"subAnnounce":                 "",
 	"subEnableRouting":            "false",
 	"subRoutingRules":             "",
+	"subHideSettings":             "false",
 	"subListen":                   "",
 	"subPort":                     "2096",
 	"subPath":                     "/sub/",
@@ -692,6 +693,10 @@ func (s *SettingService) GetSubRoutingRules() (string, error) {
 	return s.getString("subRoutingRules")
 }
 
+func (s *SettingService) GetSubHideSettings() (bool, error) {
+	return s.getBool("subHideSettings")
+}
+
 func (s *SettingService) GetSubListen() (string, error) {
 	return s.getString("subListen")
 }

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

@@ -1090,6 +1090,8 @@
       "subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
       "subRoutingRules": "قواعد التوجيه",
       "subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
+      "subHideSettings": "إخفاء إعدادات الخادم",
+      "subHideSettingsDesc": "إخفاء إمكانية عرض وتعديل إعدادات الخادم في عميل VPN. (فقط لـ Happ)",
       "subClashEnableRouting": "تفعيل التوجيه",
       "subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
       "subClashRoutingRules": "قواعد التوجيه العامة",

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

@@ -1200,6 +1200,8 @@
       "subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
       "subRoutingRules": "Routing rules",
       "subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)",
+      "subHideSettings": "Hide server settings",
+      "subHideSettingsDesc": "Hide the ability to view and edit server configurations in the VPN client. (Only for Happ)",
       "subClashEnableRouting": "Enable routing",
       "subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.",
       "subClashRoutingRules": "Global routing rules",

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

@@ -1090,6 +1090,8 @@
       "subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
       "subRoutingRules": "Reglas de enrutamiento",
       "subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)",
+      "subHideSettings": "Ocultar configuración del servidor",
+      "subHideSettingsDesc": "Ocultar la posibilidad de ver y editar las configuraciones del servidor en el cliente VPN. (Solo para Happ)",
       "subClashEnableRouting": "Habilitar enrutamiento",
       "subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.",
       "subClashRoutingRules": "Reglas globales de enrutamiento",

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

@@ -1092,6 +1092,8 @@
       "subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
       "subRoutingRules": "قوانین مسیریابی",
       "subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
+      "subHideSettings": "پنهان کردن تنظیمات سرور",
+      "subHideSettingsDesc": "پنهان کردن توانایی مشاهده و ویرایش پیکربندی سرور در کلاینت VPN. (فقط برای Happ)",
       "subClashEnableRouting": "فعال‌سازی مسیریابی",
       "subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراک‌های YAML تولیدشده وارد کن.",
       "subClashRoutingRules": "قوانین مسیریابی سراسری",

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

@@ -1090,6 +1090,8 @@
       "subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
       "subRoutingRules": "Aturan routing",
       "subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)",
+      "subHideSettings": "Sembunyikan pengaturan server",
+      "subHideSettingsDesc": "Menyembunyikan kemampuan untuk melihat dan mengedit konfigurasi server di klien VPN. (Hanya untuk Happ)",
       "subClashEnableRouting": "Aktifkan routing",
       "subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.",
       "subClashRoutingRules": "Aturan routing global",

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

@@ -1090,6 +1090,8 @@
       "subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
       "subRoutingRules": "ルーティングルール",
       "subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
+      "subHideSettings": "サーバー設定を非表示",
+      "subHideSettingsDesc": "VPNクライアントでサーバー設定の表示・編集機能を非表示にします。(Happのみ)",
       "subClashEnableRouting": "ルーティングを有効化",
       "subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
       "subClashRoutingRules": "グローバルルーティングルール",

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

@@ -1090,6 +1090,8 @@
       "subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
       "subRoutingRules": "Regras de roteamento",
       "subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)",
+      "subHideSettings": "Ocultar configurações do servidor",
+      "subHideSettingsDesc": "Ocultar a capacidade de visualizar e editar as configurações do servidor no cliente VPN. (Apenas para Happ)",
       "subClashEnableRouting": "Ativar roteamento",
       "subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.",
       "subClashRoutingRules": "Regras globais de roteamento",

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

@@ -1090,6 +1090,8 @@
       "subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
       "subRoutingRules": "Правила маршрутизации",
       "subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
+      "subHideSettings": "Скрыть настройки сервера",
+      "subHideSettingsDesc": "Скрыть возможность просмотра и редактирования конфигурации сервера в VPN-клиенте. (Только для Happ)",
       "subClashEnableRouting": "Включить маршрутизацию",
       "subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
       "subClashRoutingRules": "Глобальные правила маршрутизации",

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

@@ -1090,6 +1090,8 @@
       "subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
       "subRoutingRules": "Yönlendirme kuralları",
       "subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)",
+      "subHideSettings": "Sunucu ayarlarını gizle",
+      "subHideSettingsDesc": "VPN istemcisinde sunucu yapılandırmalarını görüntüleme ve düzenleme özelliğini gizleyin. (Yalnızca Happ için)",
       "subClashEnableRouting": "Yönlendirmeyi Etkinleştir",
       "subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekler.",
       "subClashRoutingRules": "Genel Yönlendirme Kuralları",

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

@@ -1090,6 +1090,8 @@
       "subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
       "subRoutingRules": "Правила маршрутизації",
       "subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
+      "subHideSettings": "Приховати налаштування сервера",
+      "subHideSettingsDesc": "Приховати можливість перегляду та редагування конфігурації сервера у VPN-клієнті. (Тільки для Happ)",
       "subClashEnableRouting": "Увімкнути маршрутизацію",
       "subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
       "subClashRoutingRules": "Глобальні правила маршрутизації",

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

@@ -1090,6 +1090,8 @@
       "subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
       "subRoutingRules": "Quy tắc định tuyến",
       "subRoutingRulesDesc": "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)",
+      "subHideSettings": "Ẩn cài đặt máy chủ",
+      "subHideSettingsDesc": "Ẩn khả năng xem và chỉnh sửa cấu hình máy chủ trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
       "subClashEnableRouting": "Bật định tuyến",
       "subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.",
       "subClashRoutingRules": "Quy tắc định tuyến toàn cầu",

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

@@ -1090,6 +1090,8 @@
       "subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)",
       "subRoutingRules": "路由規則",
       "subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
+      "subHideSettings": "隐藏服务器设置",
+      "subHideSettingsDesc": "在 VPN 客户端中隐藏查看和编辑服务器配置的功能。(僅限 Happ)",
       "subClashEnableRouting": "启用路由",
       "subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
       "subClashRoutingRules": "全局路由规则",

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

@@ -1090,6 +1090,8 @@
       "subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
       "subRoutingRules": "路由規則",
       "subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
+      "subHideSettings": "隱藏伺服器設定",
+      "subHideSettingsDesc": "在 VPN 用戶端中隱藏查看和編輯伺服器配置的功能。(僅限 Happ)",
       "subClashEnableRouting": "啟用路由",
       "subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
       "subClashRoutingRules": "全域路由規則",