Просмотр исходного кода

feat(sub): add dummy info node and status configs for subscriptions (#6412)

* feat(settings): add subInfoNodeEnable and status template settings

* feat(sub): add dummy info node and status configs for raw links

* feat(sub): support dummy info node in clash and json subscriptions

* feat(ui): add subscription info node switch and status templates to settings

* style: apply gofumpt formatting

* fix(sub): address review feedback on subscription info node

- Restore GetSubs contract to avoid unintended remark expansions on non-subscription-body calls.
- Exclude dummy info node from Clash PROXY select group when active server nodes exist.
- Track hasEnabledClient and set traffic.Enable in JSON and Clash paths so status tokens evaluate correctly.
- Deterministically sort client emails across subscriptions before selecting primaryEmail.
- Consolidate duplicated info-node evaluation logic into resolveInfoNodeRemark helper.
- Remove redundant pure-getter test from setting_sub_info_node_test.go.

Co-Authored-By: Claude Code <[email protected]>

---------

Co-authored-by: Claude Code <[email protected]>
Rouzbeh† 4 часов назад
Родитель
Сommit
9f76a66dcf
33 измененных файлов с 1374 добавлено и 35 удалено
  1. 24 0
      docs/public/openapi.json
  2. 24 0
      frontend/public/openapi.json
  3. 6 0
      frontend/src/generated/examples.ts
  4. 24 0
      frontend/src/generated/schemas.ts
  5. 6 0
      frontend/src/generated/types.ts
  6. 6 0
      frontend/src/generated/zod.ts
  7. 4 0
      frontend/src/models/setting.ts
  8. 35 0
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  9. 3 0
      frontend/src/schemas/setting.ts
  10. 27 0
      frontend/src/test/setting-sub-info-node.test.ts
  11. 248 0
      internal/sub/clash_info_node_test.go
  12. 41 0
      internal/sub/clash_service.go
  13. 247 0
      internal/sub/json_info_node_test.go
  14. 45 0
      internal/sub/json_service.go
  15. 1 1
      internal/sub/remark_vars.go
  16. 102 10
      internal/sub/service.go
  17. 365 0
      internal/sub/service_info_node_test.go
  18. 9 6
      internal/web/entity/entity.go
  19. 22 5
      internal/web/service/setting.go
  20. 44 0
      internal/web/service/setting_sub_info_node_test.go
  21. 7 1
      internal/web/translation/ar-EG.json
  22. 7 1
      internal/web/translation/en-US.json
  23. 7 1
      internal/web/translation/es-ES.json
  24. 7 1
      internal/web/translation/fa-IR.json
  25. 7 1
      internal/web/translation/id-ID.json
  26. 7 1
      internal/web/translation/ja-JP.json
  27. 7 1
      internal/web/translation/pt-BR.json
  28. 7 1
      internal/web/translation/ru-RU.json
  29. 7 1
      internal/web/translation/tr-TR.json
  30. 7 1
      internal/web/translation/uk-UA.json
  31. 7 1
      internal/web/translation/vi-VN.json
  32. 7 1
      internal/web/translation/zh-CN.json
  33. 7 1
      internal/web/translation/zh-TW.json

+ 24 - 0
docs/public/openapi.json

@@ -217,6 +217,9 @@
           "subEncrypt": {
             "type": "boolean"
           },
+          "subExpiredTemplate": {
+            "type": "string"
+          },
           "subHideSettings": {
             "type": "boolean"
           },
@@ -226,6 +229,9 @@
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subInfoNodeEnable": {
+            "type": "boolean"
+          },
           "subJsonAlwaysArray": {
             "type": "boolean"
           },
@@ -288,6 +294,9 @@
           "subTitle": {
             "type": "string"
           },
+          "subTrafficDepletedTemplate": {
+            "type": "string"
+          },
           "subURI": {
             "type": "string"
           },
@@ -433,9 +442,11 @@
           "subEnable",
           "subEnableRouting",
           "subEncrypt",
+          "subExpiredTemplate",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
+          "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
           "subJsonEnable",
@@ -456,6 +467,7 @@
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
+          "subTrafficDepletedTemplate",
           "subURI",
           "subUpdates",
           "tgBotAPIServer",
@@ -696,6 +708,9 @@
           "subEncrypt": {
             "type": "boolean"
           },
+          "subExpiredTemplate": {
+            "type": "string"
+          },
           "subHideSettings": {
             "type": "boolean"
           },
@@ -705,6 +720,9 @@
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subInfoNodeEnable": {
+            "type": "boolean"
+          },
           "subJsonAlwaysArray": {
             "type": "boolean"
           },
@@ -767,6 +785,9 @@
           "subTitle": {
             "type": "string"
           },
+          "subTrafficDepletedTemplate": {
+            "type": "string"
+          },
           "subURI": {
             "type": "string"
           },
@@ -919,9 +940,11 @@
           "subEnable",
           "subEnableRouting",
           "subEncrypt",
+          "subExpiredTemplate",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
+          "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
           "subJsonEnable",
@@ -942,6 +965,7 @@
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
+          "subTrafficDepletedTemplate",
           "subURI",
           "subUpdates",
           "tgBotAPIServer",

+ 24 - 0
frontend/public/openapi.json

@@ -217,6 +217,9 @@
           "subEncrypt": {
             "type": "boolean"
           },
+          "subExpiredTemplate": {
+            "type": "string"
+          },
           "subHideSettings": {
             "type": "boolean"
           },
@@ -226,6 +229,9 @@
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subInfoNodeEnable": {
+            "type": "boolean"
+          },
           "subJsonAlwaysArray": {
             "type": "boolean"
           },
@@ -288,6 +294,9 @@
           "subTitle": {
             "type": "string"
           },
+          "subTrafficDepletedTemplate": {
+            "type": "string"
+          },
           "subURI": {
             "type": "string"
           },
@@ -433,9 +442,11 @@
           "subEnable",
           "subEnableRouting",
           "subEncrypt",
+          "subExpiredTemplate",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
+          "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
           "subJsonEnable",
@@ -456,6 +467,7 @@
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
+          "subTrafficDepletedTemplate",
           "subURI",
           "subUpdates",
           "tgBotAPIServer",
@@ -696,6 +708,9 @@
           "subEncrypt": {
             "type": "boolean"
           },
+          "subExpiredTemplate": {
+            "type": "string"
+          },
           "subHideSettings": {
             "type": "boolean"
           },
@@ -705,6 +720,9 @@
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subInfoNodeEnable": {
+            "type": "boolean"
+          },
           "subJsonAlwaysArray": {
             "type": "boolean"
           },
@@ -767,6 +785,9 @@
           "subTitle": {
             "type": "string"
           },
+          "subTrafficDepletedTemplate": {
+            "type": "string"
+          },
           "subURI": {
             "type": "string"
           },
@@ -919,9 +940,11 @@
           "subEnable",
           "subEnableRouting",
           "subEncrypt",
+          "subExpiredTemplate",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
+          "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
           "subJsonEnable",
@@ -942,6 +965,7 @@
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
+          "subTrafficDepletedTemplate",
           "subURI",
           "subUpdates",
           "tgBotAPIServer",

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

@@ -58,9 +58,11 @@ export const EXAMPLES: Record<string, unknown> = {
     "subEnable": false,
     "subEnableRouting": false,
     "subEncrypt": false,
+    "subExpiredTemplate": "",
     "subHideSettings": false,
     "subIncyEnableRouting": false,
     "subIncyRoutingRules": "",
+    "subInfoNodeEnable": false,
     "subJsonAlwaysArray": false,
     "subJsonAutoDetect": false,
     "subJsonEnable": false,
@@ -81,6 +83,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subSupportUrl": "",
     "subThemeDir": "",
     "subTitle": "",
+    "subTrafficDepletedTemplate": "",
     "subURI": "",
     "subUpdates": 0,
     "tgBotAPIServer": "",
@@ -172,9 +175,11 @@ export const EXAMPLES: Record<string, unknown> = {
     "subEnable": false,
     "subEnableRouting": false,
     "subEncrypt": false,
+    "subExpiredTemplate": "",
     "subHideSettings": false,
     "subIncyEnableRouting": false,
     "subIncyRoutingRules": "",
+    "subInfoNodeEnable": false,
     "subJsonAlwaysArray": false,
     "subJsonAutoDetect": false,
     "subJsonEnable": false,
@@ -195,6 +200,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subSupportUrl": "",
     "subThemeDir": "",
     "subTitle": "",
+    "subTrafficDepletedTemplate": "",
     "subURI": "",
     "subUpdates": 0,
     "tgBotAPIServer": "",

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

@@ -191,6 +191,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEncrypt": {
         "type": "boolean"
       },
+      "subExpiredTemplate": {
+        "type": "string"
+      },
       "subHideSettings": {
         "type": "boolean"
       },
@@ -200,6 +203,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subIncyRoutingRules": {
         "type": "string"
       },
+      "subInfoNodeEnable": {
+        "type": "boolean"
+      },
       "subJsonAlwaysArray": {
         "type": "boolean"
       },
@@ -262,6 +268,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subTitle": {
         "type": "string"
       },
+      "subTrafficDepletedTemplate": {
+        "type": "string"
+      },
       "subURI": {
         "type": "string"
       },
@@ -407,9 +416,11 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEnable",
       "subEnableRouting",
       "subEncrypt",
+      "subExpiredTemplate",
       "subHideSettings",
       "subIncyEnableRouting",
       "subIncyRoutingRules",
+      "subInfoNodeEnable",
       "subJsonAlwaysArray",
       "subJsonAutoDetect",
       "subJsonEnable",
@@ -430,6 +441,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subSupportUrl",
       "subThemeDir",
       "subTitle",
+      "subTrafficDepletedTemplate",
       "subURI",
       "subUpdates",
       "tgBotAPIServer",
@@ -670,6 +682,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEncrypt": {
         "type": "boolean"
       },
+      "subExpiredTemplate": {
+        "type": "string"
+      },
       "subHideSettings": {
         "type": "boolean"
       },
@@ -679,6 +694,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subIncyRoutingRules": {
         "type": "string"
       },
+      "subInfoNodeEnable": {
+        "type": "boolean"
+      },
       "subJsonAlwaysArray": {
         "type": "boolean"
       },
@@ -741,6 +759,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subTitle": {
         "type": "string"
       },
+      "subTrafficDepletedTemplate": {
+        "type": "string"
+      },
       "subURI": {
         "type": "string"
       },
@@ -893,9 +914,11 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEnable",
       "subEnableRouting",
       "subEncrypt",
+      "subExpiredTemplate",
       "subHideSettings",
       "subIncyEnableRouting",
       "subIncyRoutingRules",
+      "subInfoNodeEnable",
       "subJsonAlwaysArray",
       "subJsonAutoDetect",
       "subJsonEnable",
@@ -916,6 +939,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subSupportUrl",
       "subThemeDir",
       "subTitle",
+      "subTrafficDepletedTemplate",
       "subURI",
       "subUpdates",
       "tgBotAPIServer",

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

@@ -66,9 +66,11 @@ export interface AllSetting {
   subEnable: boolean;
   subEnableRouting: boolean;
   subEncrypt: boolean;
+  subExpiredTemplate: string;
   subHideSettings: boolean;
   subIncyEnableRouting: boolean;
   subIncyRoutingRules: string;
+  subInfoNodeEnable: boolean;
   subJsonAlwaysArray: boolean;
   subJsonAutoDetect: boolean;
   subJsonEnable: boolean;
@@ -89,6 +91,7 @@ export interface AllSetting {
   subSupportUrl: string;
   subThemeDir: string;
   subTitle: string;
+  subTrafficDepletedTemplate: string;
   subURI: string;
   subUpdates: number;
   tgBotAPIServer: string;
@@ -181,9 +184,11 @@ export interface AllSettingView {
   subEnable: boolean;
   subEnableRouting: boolean;
   subEncrypt: boolean;
+  subExpiredTemplate: string;
   subHideSettings: boolean;
   subIncyEnableRouting: boolean;
   subIncyRoutingRules: string;
+  subInfoNodeEnable: boolean;
   subJsonAlwaysArray: boolean;
   subJsonAutoDetect: boolean;
   subJsonEnable: boolean;
@@ -204,6 +209,7 @@ export interface AllSettingView {
   subSupportUrl: string;
   subThemeDir: string;
   subTitle: string;
+  subTrafficDepletedTemplate: string;
   subURI: string;
   subUpdates: number;
   tgBotAPIServer: string;

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

@@ -82,9 +82,11 @@ export const AllSettingSchema = z.object({
   subEnable: z.boolean(),
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
+  subExpiredTemplate: z.string(),
   subHideSettings: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyRoutingRules: z.string(),
+  subInfoNodeEnable: z.boolean(),
   subJsonAlwaysArray: z.boolean(),
   subJsonAutoDetect: z.boolean(),
   subJsonEnable: z.boolean(),
@@ -105,6 +107,7 @@ export const AllSettingSchema = z.object({
   subSupportUrl: z.string(),
   subThemeDir: z.string(),
   subTitle: z.string(),
+  subTrafficDepletedTemplate: z.string(),
   subURI: z.string(),
   subUpdates: z.number().int().min(0).max(525600),
   tgBotAPIServer: z.string(),
@@ -198,9 +201,11 @@ export const AllSettingViewSchema = z.object({
   subEnable: z.boolean(),
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
+  subExpiredTemplate: z.string(),
   subHideSettings: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyRoutingRules: z.string(),
+  subInfoNodeEnable: z.boolean(),
   subJsonAlwaysArray: z.boolean(),
   subJsonAutoDetect: z.boolean(),
   subJsonEnable: z.boolean(),
@@ -221,6 +226,7 @@ export const AllSettingViewSchema = z.object({
   subSupportUrl: z.string(),
   subThemeDir: z.string(),
   subTitle: z.string(),
+  subTrafficDepletedTemplate: z.string(),
   subURI: z.string(),
   subUpdates: z.number().int().min(0).max(525600),
   tgBotAPIServer: z.string(),

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

@@ -16,6 +16,10 @@ export class AllSetting {
   trafficDiff = 0;
   remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
   subShowIdentityOnAllLinks = false;
+  subInfoNodeEnable = false;
+  subExpiredTemplate = '⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}';
+  subTrafficDepletedTemplate =
+    '🚫 {{EMAIL}} | Traffic Depleted | {{TRAFFIC_USED}}/{{TRAFFIC_TOTAL}}';
   datepicker: 'gregorian' | 'jalalian' = 'gregorian';
   tgBotEnable = false;
   tgBotToken = '';

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

@@ -182,6 +182,41 @@ export default function SubscriptionGeneralTab({
                 />
               </SettingListItem>
 
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.subInfoNodeEnable')}
+                description={t('pages.settings.subInfoNodeEnableDesc')}
+              >
+                <Switch
+                  checked={allSetting.subInfoNodeEnable}
+                  onChange={(v) => updateSetting({ subInfoNodeEnable: v })}
+                />
+              </SettingListItem>
+
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.subExpiredTemplate')}
+                description={t('pages.settings.subExpiredTemplateDesc')}
+              >
+                <RemarkTemplateField
+                  value={allSetting.subExpiredTemplate}
+                  onChange={(v) => updateSetting({ subExpiredTemplate: v })}
+                  maxLength={256}
+                />
+              </SettingListItem>
+
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.subTrafficDepletedTemplate')}
+                description={t('pages.settings.subTrafficDepletedTemplateDesc')}
+              >
+                <RemarkTemplateField
+                  value={allSetting.subTrafficDepletedTemplate}
+                  onChange={(v) => updateSetting({ subTrafficDepletedTemplate: v })}
+                  maxLength={256}
+                />
+              </SettingListItem>
+
               <SettingListItem
                 paddings="small"
                 title={t('pages.settings.subUpdates')}

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

@@ -21,6 +21,9 @@ export const AllSettingSchema = z
     trafficDiff: nonNegativeInt.max(100).optional(),
     remarkTemplate: z.string().optional(),
     subShowIdentityOnAllLinks: z.boolean().optional(),
+    subInfoNodeEnable: z.boolean().optional(),
+    subExpiredTemplate: z.string().optional(),
+    subTrafficDepletedTemplate: z.string().optional(),
     datepicker: z.enum(['gregorian', 'jalalian']).optional(),
     tgBotEnable: z.boolean().optional(),
     tgBotToken: z.string().optional(),

+ 27 - 0
frontend/src/test/setting-sub-info-node.test.ts

@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest';
+import { AllSettingSchema } from '@/schemas/setting';
+import { AllSetting } from '@/models/setting';
+
+describe('subInfoNode settings', () => {
+  it('defaults on AllSetting', () => {
+    const s = new AllSetting();
+    expect(s.subInfoNodeEnable).toBe(false);
+    expect(s.subExpiredTemplate).toBe('⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}');
+    expect(s.subTrafficDepletedTemplate).toBe(
+      '🚫 {{EMAIL}} | Traffic Depleted | {{TRAFFIC_USED}}/{{TRAFFIC_TOTAL}}',
+    );
+  });
+
+  it('accepts valid values in the settings schema', () => {
+    const r = AllSettingSchema.safeParse({
+      subInfoNodeEnable: true,
+      subExpiredTemplate: 'custom expired',
+      subTrafficDepletedTemplate: 'custom depleted',
+    });
+    expect(r.success).toBe(true);
+  });
+
+  it('rejects invalid types', () => {
+    expect(AllSettingSchema.safeParse({ subInfoNodeEnable: 'true' }).success).toBe(false);
+  });
+});

+ 248 - 0
internal/sub/clash_info_node_test.go

@@ -0,0 +1,248 @@
+package sub
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestSubClash_InfoNode_Active(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-clash","enable":true,"totalGB":10737418240}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-clash",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 10737418240,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        1073741824,
+		Down:      1073741824,
+		Total:     10737418240,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
+	sub.subInfoNodeEnable = true
+	clash := NewSubClashService(false, "", sub)
+
+	out, _, err := clash.GetClash("sub-clash", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+
+	if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") {
+		t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out)
+	}
+	if !strings.Contains(out, "[email protected]|📊8.00GB") {
+		t.Fatalf("expected expanded remark on dummy proxy, got:\n%s", out)
+	}
+}
+
+func TestSubClash_InfoNode_Expired(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	expiredTime := time.Now().Add(-24 * time.Hour).UnixMilli()
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       fmt.Sprintf(`{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-clash-exp","enable":true,"expiryTime":%d}]}`, expiredTime),
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:         1,
+		Email:      "[email protected]",
+		SubID:      "sub-clash-exp",
+		UUID:       "c1-uuid",
+		Enable:     true,
+		ExpiryTime: expiredTime,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId:  1,
+		Email:      "[email protected]",
+		ExpiryTime: expiredTime,
+		Enable:     true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{INBOUND}}")
+	sub.subInfoNodeEnable = true
+	sub.subExpiredTemplate = service.DefaultSubExpiredTemplate
+	clash := NewSubClashService(false, "", sub)
+
+	out, _, err := clash.GetClash("sub-clash-exp", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+
+	if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") {
+		t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out)
+	}
+	if !strings.Contains(out, "Expired") || !strings.Contains(out, "[email protected]") {
+		t.Fatalf("expected expired remark in clash YAML, got:\n%s", out)
+	}
+	if strings.Contains(out, "Germany-VLESS") {
+		t.Fatalf("expired subscription must NOT contain working inbound, got:\n%s", out)
+	}
+}
+
+func TestSubClash_InfoNode_Depleted(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-clash-dep","enable":true,"totalGB":5368709120}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-clash-dep",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 5368709120,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        3221225472,
+		Down:      2147483648,
+		Total:     5368709120,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{INBOUND}}")
+	sub.subInfoNodeEnable = true
+	sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
+	clash := NewSubClashService(false, "", sub)
+
+	out, _, err := clash.GetClash("sub-clash-dep", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+
+	if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") {
+		t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out)
+	}
+	if !strings.Contains(out, "Traffic Depleted") || !strings.Contains(out, "[email protected]") {
+		t.Fatalf("expected depleted remark in clash YAML, got:\n%s", out)
+	}
+	if strings.Contains(out, "Germany-VLESS") {
+		t.Fatalf("depleted subscription must NOT contain working inbound, got:\n%s", out)
+	}
+}
+
+func TestSubClash_InfoNode_ProxyGroupOrder_DoesNotDefaultToDummy(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-clash","enable":true,"totalGB":10737418240}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientRecord{Id: 1, Email: "[email protected]", SubID: "sub-clash", UUID: "c1-uuid", Enable: true}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: "[email protected]", Enable: true}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}|{{STATUS}}")
+	sub.subInfoNodeEnable = true
+	clash := NewSubClashService(false, "", sub)
+
+	out, _, err := clash.GetClash("sub-clash", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+
+	// Status token must be rendered as active
+	if !strings.Contains(out, "active") {
+		t.Fatalf("expected active status in clash dummy remark, got:\n%s", out)
+	}
+
+	// In proxy-groups, PROXY select group must NOT have the dummy node as first member
+	// PROXY group proxies should begin with real proxy
+	if strings.Contains(out, "[email protected]|active") && strings.Contains(out, "proxies:\n  - [email protected]|active") {
+		t.Fatalf("PROXY group must NOT contain dummy info node as member, got:\n%s", out)
+	}
+	if !strings.Contains(out, "proxies:\n  - Germany-VLESS\n  - DIRECT") {
+		t.Fatalf("expected real proxy Germany-VLESS in PROXY group, got:\n%s", out)
+	}
+}

+ 41 - 0
internal/sub/clash_service.go

@@ -4,6 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"maps"
+	"slices"
 	"strings"
 
 	"github.com/goccy/go-json"
@@ -40,6 +41,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 
 	var proxies []map[string]any
 	var hasInactiveExternal bool
+	var hasEnabledClient bool
 
 	seenEmails := make(map[string]struct{})
 	for _, inbound := range inbounds {
@@ -52,11 +54,17 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 			injectExternalProxy(inbound, hostEps)
 		}
 		for _, client := range clients {
+			if client.Enable {
+				hasEnabledClient = true
+			}
 			seenEmails[client.Email] = struct{}{}
 			proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
 		}
 	}
 	for _, ext := range externalLinks {
+		if ext.Enable {
+			hasEnabledClient = true
+		}
 		if !ext.Active {
 			seenEmails[ext.Email] = struct{}{}
 			hasInactiveExternal = true
@@ -82,8 +90,25 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 	for e := range seenEmails {
 		emails = append(emails, e)
 	}
+	slices.Sort(emails)
 	traffic, _ := subReq.AggregateTrafficByEmails(emails)
+	traffic.Enable = hasEnabledClient
 	header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+
+	if mode, remark := subReq.resolveInfoNodeRemark(subId, emails, traffic, len(proxies) > 0); mode != infoNodeNone {
+		dummyProxy := map[string]any{
+			"name":   remark,
+			"type":   "socks5",
+			"server": "127.0.0.1",
+			"port":   1080,
+		}
+		if mode == infoNodeExpired || mode == infoNodeDepleted {
+			proxies = []map[string]any{dummyProxy}
+		} else {
+			proxies = append([]map[string]any{dummyProxy}, proxies...)
+		}
+	}
+
 	if len(proxies) == 0 {
 		return "", header, nil
 	}
@@ -92,6 +117,9 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 
 	proxyNames := make([]string, 0, len(proxies)+1)
 	for _, proxy := range proxies {
+		if isDummyProxy(proxy) && len(proxies) > 1 {
+			continue
+		}
 		if name, ok := proxy["name"].(string); ok && name != "" {
 			proxyNames = append(proxyNames, name)
 		}
@@ -152,6 +180,19 @@ func ensureUniqueProxyNames(proxies []map[string]any) {
 	}
 }
 
+func isDummyProxy(proxy map[string]any) bool {
+	typ, _ := proxy["type"].(string)
+	server, _ := proxy["server"].(string)
+	var port int
+	switch p := proxy["port"].(type) {
+	case int:
+		port = p
+	case float64:
+		port = int(p)
+	}
+	return typ == "socks5" && server == "127.0.0.1" && port == 1080
+}
+
 func fallbackProxyName(proxy map[string]any, idx int) string {
 	typ, _ := proxy["type"].(string)
 	server, _ := proxy["server"].(string)

+ 247 - 0
internal/sub/json_info_node_test.go

@@ -0,0 +1,247 @@
+package sub
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestSubJson_InfoNode_Active(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-json","enable":true,"totalGB":10737418240}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-json",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 10737418240,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        1073741824,
+		Down:      1073741824,
+		Total:     10737418240,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
+	sub.subInfoNodeEnable = true
+	jsonSvc := NewSubJsonService("", "", "", sub)
+
+	out, _, err := jsonSvc.GetJson("sub-json", "sub.example.com", false)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+
+	if !strings.Contains(out, "[email protected]|📊8.00GB") {
+		t.Fatalf("expected dummy remark in JSON remarks, got:\n%s", out)
+	}
+	if !strings.Contains(out, `"protocol": "socks"`) && !strings.Contains(out, `"protocol":"socks"`) {
+		t.Fatalf("expected socks outbound in JSON, got:\n%s", out)
+	}
+}
+
+func TestSubJson_InfoNode_Expired(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	expiredTime := time.Now().Add(-24 * time.Hour).UnixMilli()
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       fmt.Sprintf(`{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-json-exp","enable":true,"expiryTime":%d}]}`, expiredTime),
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:         1,
+		Email:      "[email protected]",
+		SubID:      "sub-json-exp",
+		UUID:       "c1-uuid",
+		Enable:     true,
+		ExpiryTime: expiredTime,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId:  1,
+		Email:      "[email protected]",
+		ExpiryTime: expiredTime,
+		Enable:     true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{INBOUND}}")
+	sub.subInfoNodeEnable = true
+	sub.subExpiredTemplate = service.DefaultSubExpiredTemplate
+	jsonSvc := NewSubJsonService("", "", "", sub)
+
+	out, _, err := jsonSvc.GetJson("sub-json-exp", "sub.example.com", false)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+
+	if !strings.Contains(out, "Expired") || !strings.Contains(out, "[email protected]") {
+		t.Fatalf("expected expired remark in JSON remarks, got:\n%s", out)
+	}
+	if strings.Contains(out, "Germany-VLESS") {
+		t.Fatalf("expired subscription must NOT contain working inbound in JSON, got:\n%s", out)
+	}
+}
+
+func TestSubJson_InfoNode_Depleted(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-json-dep","enable":true,"totalGB":5368709120}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-json-dep",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 5368709120,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        3221225472,
+		Down:      2147483648,
+		Total:     5368709120,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{INBOUND}}")
+	sub.subInfoNodeEnable = true
+	sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
+	jsonSvc := NewSubJsonService("", "", "", sub)
+
+	out, _, err := jsonSvc.GetJson("sub-json-dep", "sub.example.com", false)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+
+	if !strings.Contains(out, "Traffic Depleted") || !strings.Contains(out, "[email protected]") {
+		t.Fatalf("expected depleted remark in JSON remarks, got:\n%s", out)
+	}
+	if strings.Contains(out, "Germany-VLESS") {
+		t.Fatalf("depleted subscription must NOT contain working inbound in JSON, got:\n%s", out)
+	}
+}
+
+func TestSubJson_InfoNode_StatusActive(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-json-status","enable":true,"totalGB":10737418240}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-json-status",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 10737418240,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        100,
+		Down:      100,
+		Total:     10737418240,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{EMAIL}}|{{STATUS_EMOJI}} {{STATUS}}")
+	sub.subInfoNodeEnable = true
+	jsonSvc := NewSubJsonService("", "", "", sub)
+
+	out, _, err := jsonSvc.GetJson("sub-json-status", "sub.example.com", false)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+
+	if !strings.Contains(out, "[email protected]|✅ active") {
+		t.Fatalf("expected '[email protected]|✅ active' in JSON remarks, got:\n%s", out)
+	}
+}

+ 45 - 0
internal/sub/json_service.go

@@ -83,6 +83,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 
 	var header string
 	var hasInactiveExternal bool
+	var hasEnabledClient bool
 
 	seenEmails := make(map[string]struct{})
 	entries := make([]subConfigEntry, 0, len(inbounds))
@@ -99,6 +100,9 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 
 		var inboundConfigs []json_util.RawMessage
 		for _, client := range clients {
+			if client.Enable {
+				hasEnabledClient = true
+			}
 			seenEmails[client.Email] = struct{}{}
 			inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...)
 		}
@@ -128,6 +132,9 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 		configArray = append(configArray, entry.configs...)
 	}
 	for _, ext := range externalLinks {
+		if ext.Enable {
+			hasEnabledClient = true
+		}
 		if !ext.Active {
 			seenEmails[ext.Email] = struct{}{}
 			hasInactiveExternal = true
@@ -162,8 +169,20 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 	for e := range seenEmails {
 		emails = append(emails, e)
 	}
+	slices.Sort(emails)
 	traffic, _ := subReq.AggregateTrafficByEmails(emails)
+	traffic.Enable = hasEnabledClient
 	header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+
+	if mode, remark := subReq.resolveInfoNodeRemark(subId, emails, traffic, len(configArray) > 0); mode != infoNodeNone {
+		dummyConfig := s.genDummySocksConfig(remark)
+		if mode == infoNodeExpired || mode == infoNodeDepleted {
+			configArray = []json_util.RawMessage{dummyConfig}
+		} else {
+			configArray = append([]json_util.RawMessage{dummyConfig}, configArray...)
+		}
+	}
+
 	if len(configArray) == 0 {
 		return "", header, nil
 	}
@@ -962,6 +981,32 @@ func (s *SubJsonService) genWireguard(inbound *model.Inbound, client model.Clien
 	return result
 }
 
+func (s *SubJsonService) genDummySocksConfig(remark string) json_util.RawMessage {
+	outbound := map[string]any{
+		"protocol": "socks",
+		"tag":      "proxy",
+		"settings": map[string]any{
+			"servers": []any{
+				map[string]any{
+					"address": "127.0.0.1",
+					"port":    1080,
+				},
+			},
+		},
+	}
+	rawOutbound, _ := json.Marshal(outbound)
+	newOutbounds := []json_util.RawMessage{rawOutbound}
+	newOutbounds = append(newOutbounds, s.defaultOutbounds...)
+
+	newConfigJson := make(map[string]any)
+	maps.Copy(newConfigJson, s.configJson)
+	newConfigJson["outbounds"] = newOutbounds
+	newConfigJson["remarks"] = remark
+
+	newConfig, _ := json.MarshalIndent(newConfigJson, "", "  ")
+	return newConfig
+}
+
 func mergeFinalMask(base any, extra map[string]any) map[string]any {
 	merged := map[string]any{}
 	if baseMap, ok := base.(map[string]any); ok {

+ 1 - 1
internal/sub/remark_vars.go

@@ -625,7 +625,7 @@ func (s *SubService) effectiveTemplate(client model.Client) string {
 		s.usageShown = map[string]bool{}
 	}
 	key := templateInfoKey(client)
-	if s.usageShown[key] {
+	if s.subInfoNodeEnable || s.usageShown[key] {
 		remove := firstLinkOnlyBodyTokens
 		if s.showIdentityOnAllLinks {
 			remove = usageInfoTokens

+ 102 - 10
internal/sub/service.go

@@ -44,10 +44,13 @@ type SubService struct {
 	subscriptionBody bool
 	// usageShown emits info once per subscription identity, including twins.
 	// PrepareForRequest resets this per-request state.
-	usageShown             map[string]bool
-	showIdentityOnAllLinks bool
-	inboundService         service.InboundService
-	settingService         service.SettingService
+	usageShown                 map[string]bool
+	showIdentityOnAllLinks     bool
+	subInfoNodeEnable          bool
+	subExpiredTemplate         string
+	subTrafficDepletedTemplate string
+	inboundService             service.InboundService
+	settingService             service.SettingService
 	// nodesByID is populated per request from the Node table so
 	// resolveInboundAddress can return the node's address for any
 	// inbound whose NodeID is set. Keeps the per-link host derivation
@@ -197,14 +200,29 @@ func (s *SubService) linkSettings(inbound *model.Inbound) map[string]any {
 // (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 {
+	if s.datepicker == "" {
 		s.datepicker = "gregorian"
 	}
-	s.showIdentityOnAllLinks, err = s.settingService.GetSubShowIdentityOnAllLinks()
-	if err != nil {
-		s.showIdentityOnAllLinks = false
+	if s.subExpiredTemplate == "" {
+		s.subExpiredTemplate = service.DefaultSubExpiredTemplate
+	}
+	if s.subTrafficDepletedTemplate == "" {
+		s.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
+	}
+	if datepicker, err := s.settingService.GetDatepicker(); err == nil && datepicker != "" {
+		s.datepicker = datepicker
+	}
+	if enabled, err := s.settingService.GetSubShowIdentityOnAllLinks(); err == nil && enabled {
+		s.showIdentityOnAllLinks = enabled
+	}
+	if enabled, err := s.settingService.GetSubInfoNodeEnable(); err == nil && enabled {
+		s.subInfoNodeEnable = enabled
+	}
+	if tmpl, err := s.settingService.GetSubExpiredTemplate(); err == nil && tmpl != "" {
+		s.subExpiredTemplate = tmpl
+	}
+	if tmpl, err := s.settingService.GetSubTrafficDepletedTemplate(); err == nil && tmpl != "" {
+		s.subTrafficDepletedTemplate = tmpl
 	}
 }
 
@@ -293,6 +311,70 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
 	return s.ForRequest(host).getSubs(subId)
 }
 
+type infoNodeMode int
+
+const (
+	infoNodeNone infoNodeMode = iota
+	infoNodeActive
+	infoNodeExpired
+	infoNodeDepleted
+)
+
+func (s *SubService) resolveInfoNodeRemark(subId string, uniqueEmails []string, traffic xray.ClientTraffic, hasEntries bool) (infoNodeMode, string) {
+	if !s.subInfoNodeEnable || !s.subscriptionBody {
+		return infoNodeNone, ""
+	}
+	nowSec := time.Now().Unix()
+	isExpired := traffic.ExpiryTime > 0 && traffic.ExpiryTime/1000 <= nowSec
+	isDepleted := traffic.Total > 0 && (traffic.Up+traffic.Down) >= traffic.Total
+
+	primaryEmail := ""
+	if len(uniqueEmails) > 0 {
+		primaryEmail = uniqueEmails[0]
+	}
+	ctx := remarkContext{
+		client: model.Client{Email: primaryEmail, SubID: subId},
+		stats:  traffic,
+	}
+
+	if isExpired {
+		tmpl := s.subExpiredTemplate
+		if tmpl == "" {
+			tmpl = service.DefaultSubExpiredTemplate
+		}
+		remark := expandRemarkVars(tmpl, ctx)
+		if strings.TrimSpace(remark) == "" {
+			remark = "Expired"
+		}
+		return infoNodeExpired, remark
+	}
+
+	if isDepleted {
+		tmpl := s.subTrafficDepletedTemplate
+		if tmpl == "" {
+			tmpl = service.DefaultSubTrafficDepletedTemplate
+		}
+		remark := expandRemarkVars(tmpl, ctx)
+		if strings.TrimSpace(remark) == "" {
+			remark = "Traffic Depleted"
+		}
+		return infoNodeDepleted, remark
+	}
+
+	if hasEntries {
+		tmpl := s.remarkTemplate
+		if tmpl == "" {
+			tmpl = service.DefaultRemarkTemplate
+		}
+		remark := expandRemarkVars(tmpl, ctx)
+		if strings.TrimSpace(remark) != "" {
+			return infoNodeActive, remark
+		}
+	}
+
+	return infoNodeNone, ""
+}
+
 func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.ClientTraffic, error) {
 	var result []string
 	var emails []string
@@ -360,8 +442,18 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
 	for e := range seenEmails {
 		uniqueEmails = append(uniqueEmails, e)
 	}
+	slices.Sort(uniqueEmails)
 	traffic, lastOnline := s.AggregateTrafficByEmails(uniqueEmails)
 	traffic.Enable = hasEnabledClient
+
+	if mode, remark := s.resolveInfoNodeRemark(subId, uniqueEmails, traffic, len(result) > 0); mode != infoNodeNone {
+		dummyLink := fmt.Sprintf("socks://127.0.0.1:1080#%s", strings.ReplaceAll(url.QueryEscape(remark), "+", "%20"))
+		if mode == infoNodeExpired || mode == infoNodeDepleted {
+			return []string{dummyLink}, emails, lastOnline, traffic, nil
+		}
+		result = append([]string{dummyLink}, result...)
+	}
+
 	return result, emails, lastOnline, traffic, nil
 }
 

+ 365 - 0
internal/sub/service_info_node_test.go

@@ -0,0 +1,365 @@
+package sub
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func setupInfoNodeTestDB(t *testing.T) {
+	t.Helper()
+	if err := database.InitDB(t.TempDir() + "/test_infonode.db"); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() {
+		_ = database.CloseDB()
+	})
+	db := database.GetDB()
+	if err := db.AutoMigrate(
+		&model.Inbound{},
+		&model.ClientRecord{},
+		&model.ClientInbound{},
+		&xray.ClientTraffic{},
+		&model.Setting{},
+	); err != nil {
+		t.Fatalf("AutoMigrate: %v", err)
+	}
+}
+
+func TestSubService_InfoNode_Active(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Up:             0,
+		Down:           0,
+		Total:          0,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-active","enable":true,"totalGB":10737418240,"expiryTime":0}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-active",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 10737418240, // 10 GB
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        1073741824, // 1 GB
+		Down:      1073741824, // 1 GB
+		Total:     10737418240,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	svc := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
+	svc.subInfoNodeEnable = true
+	svc.subscriptionBody = true
+
+	links, emails, _, traffic, err := svc.GetSubs("sub-active", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs error: %v", err)
+	}
+	if len(links) != 2 {
+		t.Fatalf("expected 2 links (dummy info node + 1 vless link), got %d: %v", len(links), links)
+	}
+	if len(emails) != 1 || emails[0] != "[email protected]" {
+		t.Fatalf("emails = %v, want [[email protected]]", emails)
+	}
+	if !traffic.Enable {
+		t.Fatalf("traffic should be enabled")
+	}
+
+	// First link must be the dummy socks node
+	if !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
+		t.Fatalf("first link must be dummy socks node, got: %q", links[0])
+	}
+	decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#"))
+	if !strings.Contains(decodedRemark, "[email protected]") || !strings.Contains(decodedRemark, "8.00GB") {
+		t.Fatalf("expected dummy remark to contain [email protected] and 8.00GB, got: %q", decodedRemark)
+	}
+
+	// Second link must be the clean vless link without traffic left tokens
+	if !strings.HasPrefix(links[1], "vless://") {
+		t.Fatalf("second link must be vless, got: %q", links[1])
+	}
+	if strings.Contains(links[1], "8.00GB") {
+		t.Fatalf("server link must have clean remark without usage stats, got: %q", links[1])
+	}
+}
+
+func TestSubService_InfoNode_Expired(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	expiredTime := time.Now().Add(-24 * time.Hour).UnixMilli()
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       fmt.Sprintf(`{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-exp","enable":true,"expiryTime":%d}]}`, expiredTime),
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:         1,
+		Email:      "[email protected]",
+		SubID:      "sub-exp",
+		UUID:       "c1-uuid",
+		Enable:     true,
+		ExpiryTime: expiredTime,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId:  1,
+		Email:      "[email protected]",
+		ExpiryTime: expiredTime,
+		Enable:     true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	svc := NewSubService("{{INBOUND}}|{{EMAIL}}")
+	svc.subInfoNodeEnable = true
+	svc.subExpiredTemplate = service.DefaultSubExpiredTemplate
+	svc.subscriptionBody = true
+
+	links, _, _, _, err := svc.GetSubs("sub-exp", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs error: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("expected ONLY 1 link (dummy expired node), got %d: %v", len(links), links)
+	}
+	if !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
+		t.Fatalf("expired link must be dummy socks node, got: %q", links[0])
+	}
+	decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#"))
+	if !strings.Contains(decodedRemark, "Expired") || !strings.Contains(decodedRemark, "[email protected]") {
+		t.Fatalf("expected expired remark, got: %q", decodedRemark)
+	}
+}
+
+func TestSubService_InfoNode_TrafficDepleted(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-dep","enable":true,"totalGB":5368709120}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-dep",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 5368709120, // 5 GB
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        3221225472, // 3 GB
+		Down:      2147483648, // 2 GB (total 5 GB used = depleted)
+		Total:     5368709120,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	svc := NewSubService("{{INBOUND}}|{{EMAIL}}")
+	svc.subInfoNodeEnable = true
+	svc.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
+	svc.subscriptionBody = true
+
+	links, _, _, _, err := svc.GetSubs("sub-dep", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs error: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("expected ONLY 1 link (dummy depleted node), got %d: %v", len(links), links)
+	}
+	if !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
+		t.Fatalf("depleted link must be dummy socks node, got: %q", links[0])
+	}
+	decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#"))
+	if !strings.Contains(decodedRemark, "Traffic Depleted") || !strings.Contains(decodedRemark, "[email protected]") {
+		t.Fatalf("expected depleted remark, got: %q", decodedRemark)
+	}
+}
+
+func TestSubService_GetSubs_NonSubscriptionBody_NoInfoNode(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "US-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-panel","enable":true,"totalGB":10737418240}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-panel",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 10737418240,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        1073741824,
+		Down:      1073741824,
+		Total:     10737418240,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	// When called via GetSubs (e.g. from LinkProvider for admin QR / copy modals),
+	// subscriptionBody is false, so it should render the clean link and not add dummy nodes.
+	svc := NewSubService("{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
+	svc.subInfoNodeEnable = true
+
+	links, _, _, _, err := svc.GetSubs("sub-panel", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs error: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("expected 1 clean link, got %d: %v", len(links), links)
+	}
+	if strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
+		t.Fatalf("GetSubs must NOT return dummy socks node when subscriptionBody is false: %v", links[0])
+	}
+	if strings.Contains(links[0], "8.00GB") {
+		t.Fatalf("GetSubs must NOT contain snapshot usage stats in non-subscriptionBody context: %v", links[0])
+	}
+}
+
+func TestSubService_InfoNode_MultiClient_DeterministicPrimaryEmail(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-multi","enable":true,"totalGB":10737418240},{"id":"c2-uuid","email":"[email protected]","subId":"sub-multi","enable":true,"totalGB":10737418240}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientRecord{
+		Id: 1, Email: "[email protected]", SubID: "sub-multi", UUID: "c1-uuid", Enable: true, TotalGB: 10737418240,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientRecord{
+		Id: 2, Email: "[email protected]", SubID: "sub-multi", UUID: "c2-uuid", Enable: true, TotalGB: 10737418240,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 2}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1, Email: "[email protected]", Up: 100, Down: 100, Total: 10737418240, Enable: true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1, Email: "[email protected]", Up: 100, Down: 100, Total: 10737418240, Enable: true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	svc := NewSubService("{{EMAIL}}")
+	svc.subInfoNodeEnable = true
+	svc.subscriptionBody = true
+
+	for i := 0; i < 5; i++ {
+		links, _, _, _, err := svc.GetSubs("sub-multi", "sub.example.com")
+		if err != nil {
+			t.Fatalf("GetSubs error: %v", err)
+		}
+		if len(links) < 1 || !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
+			t.Fatalf("expected dummy socks node, got: %v", links)
+		}
+		decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#"))
+		if decodedRemark != "[email protected]" {
+			t.Fatalf("expected primaryEmail '[email protected]' (sorted alphabetically), got %q", decodedRemark)
+		}
+	}
+}

+ 9 - 6
internal/web/entity/entity.go

@@ -30,12 +30,15 @@ type AllSetting struct {
 	IpLimitAllowlist  string `json:"ipLimitAllowlist" form:"ipLimitAllowlist"`
 	PanelOutbound     string `json:"panelOutbound" form:"panelOutbound"`
 
-	PageSize                  int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
-	ExpireDiff                int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
-	TrafficDiff               int    `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
-	RemarkTemplate            string `json:"remarkTemplate" form:"remarkTemplate"`
-	SubShowIdentityOnAllLinks bool   `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
-	Datepicker                string `json:"datepicker" form:"datepicker"`
+	PageSize                   int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
+	ExpireDiff                 int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
+	TrafficDiff                int    `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
+	RemarkTemplate             string `json:"remarkTemplate" form:"remarkTemplate"`
+	SubShowIdentityOnAllLinks  bool   `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
+	SubInfoNodeEnable          bool   `json:"subInfoNodeEnable" form:"subInfoNodeEnable"`
+	SubExpiredTemplate         string `json:"subExpiredTemplate" form:"subExpiredTemplate"`
+	SubTrafficDepletedTemplate string `json:"subTrafficDepletedTemplate" form:"subTrafficDepletedTemplate"`
+	Datepicker                 string `json:"datepicker" form:"datepicker"`
 
 	TgBotEnable     bool   `json:"tgBotEnable" form:"tgBotEnable"`
 	TgBotToken      string `json:"tgBotToken" form:"tgBotToken"`

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

@@ -34,11 +34,13 @@ import (
 var xrayTemplateConfig string
 
 const (
-	DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)`
-	DefaultSubJsonUserAgentRegex  = ``
-	DefaultRemarkTemplate         = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
-	DefaultTrustedProxyCIDRs      = "127.0.0.1/32,::1/128"
-	maxRegexLength                = 2048
+	DefaultSubClashUserAgentRegex     = `(?i)(clash|mihomo)`
+	DefaultSubJsonUserAgentRegex      = ``
+	DefaultRemarkTemplate             = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
+	DefaultSubExpiredTemplate         = "⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}"
+	DefaultSubTrafficDepletedTemplate = "🚫 {{EMAIL}} | Traffic Depleted | {{TRAFFIC_USED}}/{{TRAFFIC_TOTAL}}"
+	DefaultTrustedProxyCIDRs          = "127.0.0.1/32,::1/128"
+	maxRegexLength                    = 2048
 )
 
 var defaultValueMap = map[string]string{
@@ -70,6 +72,9 @@ var defaultValueMap = map[string]string{
 	"trafficDiff":                 "0",
 	"remarkTemplate":              DefaultRemarkTemplate,
 	"subShowIdentityOnAllLinks":   "false",
+	"subInfoNodeEnable":           "false",
+	"subExpiredTemplate":          DefaultSubExpiredTemplate,
+	"subTrafficDepletedTemplate":  DefaultSubTrafficDepletedTemplate,
 	"timeLocation":                "Local",
 	"tgBotEnable":                 "false",
 	"tgBotToken":                  "",
@@ -676,6 +681,18 @@ func (s *SettingService) GetSubShowIdentityOnAllLinks() (bool, error) {
 	return s.getBool("subShowIdentityOnAllLinks")
 }
 
+func (s *SettingService) GetSubInfoNodeEnable() (bool, error) {
+	return s.getBool("subInfoNodeEnable")
+}
+
+func (s *SettingService) GetSubExpiredTemplate() (string, error) {
+	return s.getString("subExpiredTemplate")
+}
+
+func (s *SettingService) GetSubTrafficDepletedTemplate() (string, error) {
+	return s.getString("subTrafficDepletedTemplate")
+}
+
 func (s *SettingService) GetSecret() ([]byte, error) {
 	secret, err := s.getString("secret")
 	if secret == "" || secret == defaultValueMap["secret"] {

+ 44 - 0
internal/web/service/setting_sub_info_node_test.go

@@ -0,0 +1,44 @@
+package service
+
+import (
+	"testing"
+)
+
+func TestSubInfoNodeSettingsDefaultsAndPersists(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if settings.SubInfoNodeEnable {
+		t.Fatal("expected default SubInfoNodeEnable false")
+	}
+	if settings.SubExpiredTemplate != DefaultSubExpiredTemplate {
+		t.Fatalf("expected default SubExpiredTemplate %q, got %q", DefaultSubExpiredTemplate, settings.SubExpiredTemplate)
+	}
+	if settings.SubTrafficDepletedTemplate != DefaultSubTrafficDepletedTemplate {
+		t.Fatalf("expected default SubTrafficDepletedTemplate %q, got %q", DefaultSubTrafficDepletedTemplate, settings.SubTrafficDepletedTemplate)
+	}
+
+	settings.SubInfoNodeEnable = true
+	settings.SubExpiredTemplate = "custom expired"
+	settings.SubTrafficDepletedTemplate = "custom depleted"
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
+		t.Fatal(err)
+	}
+
+	gotEnabled, err := s.GetSubInfoNodeEnable()
+	if err != nil || !gotEnabled {
+		t.Fatalf("expected true, got %v, err %v", gotEnabled, err)
+	}
+	gotExp, err := s.GetSubExpiredTemplate()
+	if err != nil || gotExp != "custom expired" {
+		t.Fatalf("expected 'custom expired', got %q, err %v", gotExp, err)
+	}
+	gotDep, err := s.GetSubTrafficDepletedTemplate()
+	if err != nil || gotDep != "custom depleted" {
+		t.Fatalf("expected 'custom depleted', got %q, err %v", gotDep, err)
+	}
+}

+ 7 - 1
internal/web/translation/ar-EG.json

@@ -1456,7 +1456,13 @@
           "httpMethodDesc": "الأسلوب المستخدم في طلبات probe.",
           "note": "تحمل موزّعات leastPing/leastLoad دائمًا burstObservatory. يخصّص هذا المفتاح معاملات probe — أوقفه لاستخدام الإعدادات الافتراضية المدمجة. تُطبَّق التغييرات بعد إعادة تشغيل اللوحة."
         }
-      }
+      },
+      "subInfoNodeEnable": "تكوين معلومات منفصل / عقدة وهمية",
+      "subInfoNodeEnableDesc": "عند التفعيل، يتم عرض الملاحظات وحركة المرور/الأيام المتبقية كتكوين SOCKS منفصل في الأعلى، وعند انتهاء الصلاحية أو نفاد البيانات يتم إرجاع تكوين الحالة فقط.",
+      "subExpiredTemplate": "قالب انتهاء الصلاحية",
+      "subExpiredTemplateDesc": "قالب التكوين الوهمي عند انتهاء صلاحية اشتراك المستخدم.",
+      "subTrafficDepletedTemplate": "قالب نفاد البيانات",
+      "subTrafficDepletedTemplateDesc": "قالب التكوين الوهمي عند استهلاك حصة بيانات المستخدم بالكامل."
     },
     "xray": {
       "save": "احفظ",

+ 7 - 1
internal/web/translation/en-US.json

@@ -1574,7 +1574,13 @@
           "httpMethodDesc": "Method used for probe requests.",
           "note": "leastPing/leastLoad balancers always carry a burst observatory. This switch customises its probe parameters — turn it off to use the built-in defaults. Changes apply after a panel restart."
         }
-      }
+      },
+      "subInfoNodeEnable": "Separate Info Config / Dummy Node",
+      "subInfoNodeEnableDesc": "When enabled, remarks and remaining traffic/days are served as a separate dummy SOCKS config at the top, and expired/depleted subscriptions return only the status config.",
+      "subExpiredTemplate": "Expired Template",
+      "subExpiredTemplateDesc": "Template for the dummy config when the subscriber account has expired.",
+      "subTrafficDepletedTemplate": "Traffic Depleted Template",
+      "subTrafficDepletedTemplateDesc": "Template for the dummy config when subscriber traffic quota is exhausted."
     },
     "xray": {
       "save": "Save",

+ 7 - 1
internal/web/translation/es-ES.json

@@ -1456,7 +1456,13 @@
           "httpMethodDesc": "Método usado para las solicitudes de probe.",
           "note": "Los balanceadores leastPing/leastLoad siempre llevan un burstObservatory. Este interruptor personaliza sus parámetros de probe — apágalo para usar los valores predeterminados integrados. Los cambios se aplican tras reiniciar el panel."
         }
-      }
+      },
+      "subInfoNodeEnable": "Configuración de información separada / Nodo ficticio",
+      "subInfoNodeEnableDesc": "Si está activado, la información de tráfico y días restantes se muestra en un nodo SOCKS ficticio arriba, y las suscripciones caducadas o agotadas devuelven solo el estado.",
+      "subExpiredTemplate": "Plantilla de expirado",
+      "subExpiredTemplateDesc": "Plantilla para el nodo ficticio cuando la suscripción ha expirado.",
+      "subTrafficDepletedTemplate": "Plantilla de tráfico agotado",
+      "subTrafficDepletedTemplateDesc": "Plantilla para el nodo ficticio cuando el límite de tráfico se ha agotado."
     },
     "xray": {
       "save": "Guardar configuración",

+ 7 - 1
internal/web/translation/fa-IR.json

@@ -1456,7 +1456,13 @@
           "httpMethodDesc": "متد استفاده‌شده برای درخواست‌های probe.",
           "note": "موزان‌کننده‌های leastPing/leastLoad همیشه burstObservatory دارند. این کلید پارامترهای probe آن را سفارشی می‌کند — آن را خاموش کنید تا از پیش‌فرض‌های داخلی استفاده شود. تغییرات پس از راه‌اندازی مجدد پنل اعمال می‌شوند."
         }
-      }
+      },
+      "subInfoNodeEnable": "کانفیگ جداگانه اطلاعات / نود نمایشی",
+      "subInfoNodeEnableDesc": "در صورت فعال بودن، مشخصات و حجم/روزهای باقیمانده به عنوان یک کانفیگ مجزای ساکس در بالای لیست نمایش داده می‌شود و در صورت انقضا یا اتمام حجم فقط پیام وضعیت ارسال می‌شود.",
+      "subExpiredTemplate": "قالب پیام انقضا",
+      "subExpiredTemplateDesc": "قالب کانفیگ نمایشی زمانی که اشتراک کاربر منقضی شده است.",
+      "subTrafficDepletedTemplate": "قالب پیام اتمام حجم",
+      "subTrafficDepletedTemplateDesc": "قالب کانفیگ نمایشی زمانی که حجم اشتراک کاربر به پایان رسیده است."
     },
     "xray": {
       "save": "ذخیره",

+ 7 - 1
internal/web/translation/id-ID.json

@@ -1456,7 +1456,13 @@
           "httpMethodDesc": "Metode yang dipakai untuk permintaan probe.",
           "note": "Penyeimbang leastPing/leastLoad selalu membawa burstObservatory. Sakelar ini menyesuaikan parameter probe-nya — matikan untuk memakai bawaan default. Perubahan berlaku setelah panel dimulai ulang."
         }
-      }
+      },
+      "subInfoNodeEnable": "Konfigurasi Info Terpisah / Node Dummy",
+      "subInfoNodeEnableDesc": "Saat diaktifkan, catatan dan sisa kuota/hari disajikan sebagai konfigurasi SOCKS dummy terpisah di bagian atas, dan langganan kedaluwarsa/habis hanya mengembalikan status.",
+      "subExpiredTemplate": "Templat Kedaluwarsa",
+      "subExpiredTemplateDesc": "Templat untuk konfigurasi dummy saat akun langganan telah kedaluwarsa.",
+      "subTrafficDepletedTemplate": "Templat Kuota Habis",
+      "subTrafficDepletedTemplateDesc": "Templat untuk konfigurasi dummy saat kuota data langganan telah habis."
     },
     "xray": {
       "save": "Simpan",

+ 7 - 1
internal/web/translation/ja-JP.json

@@ -1456,7 +1456,13 @@
           "httpMethodDesc": "プローブ要求に使う HTTP メソッド。",
           "note": "leastPing/leastLoad バランサーは常に burstObservatory を持ちます。このスイッチはプローブパラメータをカスタマイズします — オフにすると組み込みのデフォルトを使います。変更はパネルの再起動後に反映されます。"
         }
-      }
+      },
+      "subInfoNodeEnable": "個別情報設定 / ダミーノード",
+      "subInfoNodeEnableDesc": "有効にすると、備考と残りの通信量/日数が上部の独立したSOCKSダミー構成として表示され、期限切れや通信量超過時はステータスのみが返されます。",
+      "subExpiredTemplate": "期限切れテンプレート",
+      "subExpiredTemplateDesc": "サブスクリプションの有効期限が切れた際のダミー構成用テンプレート。",
+      "subTrafficDepletedTemplate": "通信量超過テンプレート",
+      "subTrafficDepletedTemplateDesc": "サブスクリプションの通信量が上限に達した際のダミー構成用テンプレート。"
     },
     "xray": {
       "importRules": "ルールをインポート",

+ 7 - 1
internal/web/translation/pt-BR.json

@@ -1456,7 +1456,13 @@
           "httpMethodDesc": "Método usado nas requisições de probe.",
           "note": "Balanceadores leastPing/leastLoad sempre carregam um burstObservatory. Esta opção personaliza seus parâmetros de probe — desligue-a para usar os padrões integrados. As alterações se aplicam após reiniciar o painel."
         }
-      }
+      },
+      "subInfoNodeEnable": "Configuração de informações separada / Nó fictício",
+      "subInfoNodeEnableDesc": "Quando ativado, observações e tráfego/dias restantes são exibidos em uma configuração SOCKS fictícia no topo, e assinaturas expiradas/esgotadas retornam apenas o status.",
+      "subExpiredTemplate": "Modelo expirado",
+      "subExpiredTemplateDesc": "Modelo para a configuração fictícia quando a assinatura expirou.",
+      "subTrafficDepletedTemplate": "Modelo de tráfego esgotado",
+      "subTrafficDepletedTemplateDesc": "Modelo para a configuração fictícia quando a cota de tráfego foi esgotada."
     },
     "xray": {
       "importRules": "Importar regras",

+ 7 - 1
internal/web/translation/ru-RU.json

@@ -1456,7 +1456,13 @@
           "httpMethodDesc": "Метод запросов при проверках.",
           "note": "Балансировщики leastPing/leastLoad всегда содержат burst-обсерваторию. Этот переключатель настраивает её параметры проб — выключите, чтобы использовать встроенные значения по умолчанию. Изменения применяются после перезапуска панели."
         }
-      }
+      },
+      "subInfoNodeEnable": "Отдельный инфо-конфиг / фиктивный узел",
+      "subInfoNodeEnableDesc": "Если включено, примечание и остаток трафика/дней отображаются отдельным SOCKS-конфигом вверху, а при истечении срока/трафика возвращается только статус.",
+      "subExpiredTemplate": "Шаблон истекшей подписки",
+      "subExpiredTemplateDesc": "Шаблон для фиктивного узла, когда срок действия подписки истек.",
+      "subTrafficDepletedTemplate": "Шаблон исчерпания трафика",
+      "subTrafficDepletedTemplateDesc": "Шаблон для фиктивного узла, когда лимит трафика исчерпан."
     },
     "xray": {
       "importRules": "Импорт правил",

+ 7 - 1
internal/web/translation/tr-TR.json

@@ -1456,7 +1456,13 @@
           "httpMethodDesc": "Probe isteklerinde kullanılan HTTP yöntemi.",
           "note": "leastPing/leastLoad dengeleyicileri her zaman bir burstObservatory taşır. Bu anahtar probe parametrelerini özelleştirir — yerleşik varsayılanları kullanmak için kapatın. Değişiklikler panel yeniden başlatıldıktan sonra uygulanır."
         }
-      }
+      },
+      "subInfoNodeEnable": "Ayrı Bilgi Yapılandırması / Sahte Düğüm",
+      "subInfoNodeEnableDesc": "Etkinleştirildiğinde, notlar ve kalan trafik/günler en üstte ayrı bir sahte SOCKS yapılandırması olarak sunulur; süresi dolmuş veya kotası bitmiş abonelikler yalnızca durum yapılandırmasını alır.",
+      "subExpiredTemplate": "Süresi Dolmuş Şablonu",
+      "subExpiredTemplateDesc": "Abonelik süresi dolduğunda sahte yapılandırma için kullanılacak şablon.",
+      "subTrafficDepletedTemplate": "Trafik Tükendi Şablonu",
+      "subTrafficDepletedTemplateDesc": "Abonelik trafik kotası bittiğinde sahte yapılandırma için kullanılacak şablon."
     },
     "xray": {
       "save": "Kaydet",

+ 7 - 1
internal/web/translation/uk-UA.json

@@ -1456,7 +1456,13 @@
           "httpMethodDesc": "Метод запитів під час перевірок.",
           "note": "Балансувальники leastPing/leastLoad завжди мають burstObservatory. Цей перемикач налаштовує її параметри probe — вимкніть, щоб використовувати вбудовані значення за замовчуванням. Зміни застосовуються після перезапуску панелі."
         }
-      }
+      },
+      "subInfoNodeEnable": "Окремий інфо-конфіг / фіктивний вузол",
+      "subInfoNodeEnableDesc": "Якщо увімкнено, примітка та залишок трафіку/днів відображаються окремим SOCKS-конфігом угорі, а після закінчення терміну/трафіку повертається лише статус.",
+      "subExpiredTemplate": "Шаблон закінчення терміну",
+      "subExpiredTemplateDesc": "Шаблон для фіктивного вузла, коли термін дії підписки закінчився.",
+      "subTrafficDepletedTemplate": "Шаблон вичерпання трафіку",
+      "subTrafficDepletedTemplateDesc": "Шаблон для фіктивного вузла, коли ліміт трафіку вичерпано."
     },
     "xray": {
       "save": "Зберегти",

+ 7 - 1
internal/web/translation/vi-VN.json

@@ -1456,7 +1456,13 @@
           "httpMethodDesc": "Phương thức dùng cho yêu cầu probe.",
           "note": "Các bộ cân bằng leastPing/leastLoad luôn mang một burstObservatory. Công tắc này tùy chỉnh các tham số probe — tắt nó để dùng mặc định tích hợp. Các thay đổi áp dụng sau khi khởi động lại bảng điều khiển."
         }
-      }
+      },
+      "subInfoNodeEnable": "Cấu hình thông tin riêng / Nút ảo",
+      "subInfoNodeEnableDesc": "Khi bật, ghi chú và lưu lượng/ngày còn lại sẽ hiển thị dưới dạng cấu hình SOCKS ảo riêng ở trên cùng, và đăng ký hết hạn/hết dung lượng chỉ trả về cấu hình trạng thái.",
+      "subExpiredTemplate": "Mẫu hết hạn",
+      "subExpiredTemplateDesc": "Mẫu cho cấu hình ảo khi tài khoản đăng ký đã hết hạn.",
+      "subTrafficDepletedTemplate": "Mẫu hết dung lượng",
+      "subTrafficDepletedTemplateDesc": "Mẫu cho cấu hình ảo khi tài khoản đăng ký đã dùng hết dung lượng."
     },
     "xray": {
       "importRules": "Nhập quy tắc",

+ 7 - 1
internal/web/translation/zh-CN.json

@@ -1456,7 +1456,13 @@
           "httpMethodDesc": "探活请求使用的 HTTP 方法。",
           "note": "leastPing/leastLoad 均衡器始终带有 burstObservatory。此开关自定义其探活参数 — 关闭以使用内置默认值。更改在面板重启后生效。"
         }
-      }
+      },
+      "subInfoNodeEnable": "独立信息配置 / 提示节点",
+      "subInfoNodeEnableDesc": "启用后,备注及剩余流量/天数将作为单独的 SOCKS 提示配置显示在顶部;过期或流量耗尽时仅返回状态提示。",
+      "subExpiredTemplate": "过期提示模板",
+      "subExpiredTemplateDesc": "用户订阅过期时提示配置的备注模板。",
+      "subTrafficDepletedTemplate": "流量耗尽模板",
+      "subTrafficDepletedTemplateDesc": "用户订阅流量用尽时提示配置的备注模板。"
     },
     "xray": {
       "importRules": "导入规则",

+ 7 - 1
internal/web/translation/zh-TW.json

@@ -1456,7 +1456,13 @@
           "httpMethodDesc": "探活請求使用的 HTTP 方法。",
           "note": "leastPing/leastLoad 平衡器始終帶有 burstObservatory。此開關自訂其探活參數 — 關閉以使用內建預設值。變更在面板重啟後生效。"
         }
-      }
+      },
+      "subInfoNodeEnable": "獨立資訊配置 / 提示節點",
+      "subInfoNodeEnableDesc": "啟用後,備註及剩餘流量/天數將作為單獨的 SOCKS 提示配置顯示在頂部;過期或流量耗盡時僅返回狀態提示。",
+      "subExpiredTemplate": "過期提示範本",
+      "subExpiredTemplateDesc": "使用者訂閱過期時提示配置的備註範本。",
+      "subTrafficDepletedTemplate": "流量耗盡範本",
+      "subTrafficDepletedTemplateDesc": "使用者訂閱流量用盡時提示配置的備註範本。"
     },
     "xray": {
       "save": "儲存",