mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-08 19:27:14 +00:00
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 <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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":"user1@test.com","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: "user1@test.com",
|
||||
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: "user1@test.com",
|
||||
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, "user1@test.com|📊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":"exp@test.com","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: "exp@test.com",
|
||||
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: "exp@test.com",
|
||||
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, "exp@test.com") {
|
||||
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":"dep@test.com","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: "dep@test.com",
|
||||
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: "dep@test.com",
|
||||
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, "dep@test.com") {
|
||||
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":"user1@test.com","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: "user1@test.com", 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: "user1@test.com", 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, "user1@test.com|active") && strings.Contains(out, "proxies:\n - user1@test.com|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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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":"user1@test.com","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: "user1@test.com",
|
||||
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: "user1@test.com",
|
||||
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, "user1@test.com|📊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":"exp@test.com","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: "exp@test.com",
|
||||
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: "exp@test.com",
|
||||
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, "exp@test.com") {
|
||||
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":"dep@test.com","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: "dep@test.com",
|
||||
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: "dep@test.com",
|
||||
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, "dep@test.com") {
|
||||
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":"user1@test.com","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: "user1@test.com",
|
||||
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: "user1@test.com",
|
||||
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, "user1@test.com|✅ active") {
|
||||
t.Fatalf("expected 'user1@test.com|✅ active' in JSON remarks, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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":"user1@test.com","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: "user1@test.com",
|
||||
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: "user1@test.com",
|
||||
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] != "user1@test.com" {
|
||||
t.Fatalf("emails = %v, want [user1@test.com]", 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, "user1@test.com") || !strings.Contains(decodedRemark, "8.00GB") {
|
||||
t.Fatalf("expected dummy remark to contain user1@test.com 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":"exp@test.com","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: "exp@test.com",
|
||||
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: "exp@test.com",
|
||||
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, "exp@test.com") {
|
||||
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":"dep@test.com","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: "dep@test.com",
|
||||
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: "dep@test.com",
|
||||
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, "dep@test.com") {
|
||||
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":"user1@test.com","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: "user1@test.com",
|
||||
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: "user1@test.com",
|
||||
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":"zeta@test.com","subId":"sub-multi","enable":true,"totalGB":10737418240},{"id":"c2-uuid","email":"alpha@test.com","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: "zeta@test.com", 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: "alpha@test.com", 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: "zeta@test.com", Up: 100, Down: 100, Total: 10737418240, Enable: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{
|
||||
InboundId: 1, Email: "alpha@test.com", 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 != "alpha@test.com" {
|
||||
t.Fatalf("expected primaryEmail 'alpha@test.com' (sorted alphabetically), got %q", decodedRemark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1456,7 +1456,13 @@
|
||||
"httpMethodDesc": "الأسلوب المستخدم في طلبات probe.",
|
||||
"note": "تحمل موزّعات leastPing/leastLoad دائمًا burstObservatory. يخصّص هذا المفتاح معاملات probe — أوقفه لاستخدام الإعدادات الافتراضية المدمجة. تُطبَّق التغييرات بعد إعادة تشغيل اللوحة."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subInfoNodeEnable": "تكوين معلومات منفصل / عقدة وهمية",
|
||||
"subInfoNodeEnableDesc": "عند التفعيل، يتم عرض الملاحظات وحركة المرور/الأيام المتبقية كتكوين SOCKS منفصل في الأعلى، وعند انتهاء الصلاحية أو نفاد البيانات يتم إرجاع تكوين الحالة فقط.",
|
||||
"subExpiredTemplate": "قالب انتهاء الصلاحية",
|
||||
"subExpiredTemplateDesc": "قالب التكوين الوهمي عند انتهاء صلاحية اشتراك المستخدم.",
|
||||
"subTrafficDepletedTemplate": "قالب نفاد البيانات",
|
||||
"subTrafficDepletedTemplateDesc": "قالب التكوين الوهمي عند استهلاك حصة بيانات المستخدم بالكامل."
|
||||
},
|
||||
"xray": {
|
||||
"save": "احفظ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1456,7 +1456,13 @@
|
||||
"httpMethodDesc": "متد استفادهشده برای درخواستهای probe.",
|
||||
"note": "موزانکنندههای leastPing/leastLoad همیشه burstObservatory دارند. این کلید پارامترهای probe آن را سفارشی میکند — آن را خاموش کنید تا از پیشفرضهای داخلی استفاده شود. تغییرات پس از راهاندازی مجدد پنل اعمال میشوند."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subInfoNodeEnable": "کانفیگ جداگانه اطلاعات / نود نمایشی",
|
||||
"subInfoNodeEnableDesc": "در صورت فعال بودن، مشخصات و حجم/روزهای باقیمانده به عنوان یک کانفیگ مجزای ساکس در بالای لیست نمایش داده میشود و در صورت انقضا یا اتمام حجم فقط پیام وضعیت ارسال میشود.",
|
||||
"subExpiredTemplate": "قالب پیام انقضا",
|
||||
"subExpiredTemplateDesc": "قالب کانفیگ نمایشی زمانی که اشتراک کاربر منقضی شده است.",
|
||||
"subTrafficDepletedTemplate": "قالب پیام اتمام حجم",
|
||||
"subTrafficDepletedTemplateDesc": "قالب کانفیگ نمایشی زمانی که حجم اشتراک کاربر به پایان رسیده است."
|
||||
},
|
||||
"xray": {
|
||||
"save": "ذخیره",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1456,7 +1456,13 @@
|
||||
"httpMethodDesc": "プローブ要求に使う HTTP メソッド。",
|
||||
"note": "leastPing/leastLoad バランサーは常に burstObservatory を持ちます。このスイッチはプローブパラメータをカスタマイズします — オフにすると組み込みのデフォルトを使います。変更はパネルの再起動後に反映されます。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subInfoNodeEnable": "個別情報設定 / ダミーノード",
|
||||
"subInfoNodeEnableDesc": "有効にすると、備考と残りの通信量/日数が上部の独立したSOCKSダミー構成として表示され、期限切れや通信量超過時はステータスのみが返されます。",
|
||||
"subExpiredTemplate": "期限切れテンプレート",
|
||||
"subExpiredTemplateDesc": "サブスクリプションの有効期限が切れた際のダミー構成用テンプレート。",
|
||||
"subTrafficDepletedTemplate": "通信量超過テンプレート",
|
||||
"subTrafficDepletedTemplateDesc": "サブスクリプションの通信量が上限に達した際のダミー構成用テンプレート。"
|
||||
},
|
||||
"xray": {
|
||||
"importRules": "ルールをインポート",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1456,7 +1456,13 @@
|
||||
"httpMethodDesc": "Метод запросов при проверках.",
|
||||
"note": "Балансировщики leastPing/leastLoad всегда содержат burst-обсерваторию. Этот переключатель настраивает её параметры проб — выключите, чтобы использовать встроенные значения по умолчанию. Изменения применяются после перезапуска панели."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subInfoNodeEnable": "Отдельный инфо-конфиг / фиктивный узел",
|
||||
"subInfoNodeEnableDesc": "Если включено, примечание и остаток трафика/дней отображаются отдельным SOCKS-конфигом вверху, а при истечении срока/трафика возвращается только статус.",
|
||||
"subExpiredTemplate": "Шаблон истекшей подписки",
|
||||
"subExpiredTemplateDesc": "Шаблон для фиктивного узла, когда срок действия подписки истек.",
|
||||
"subTrafficDepletedTemplate": "Шаблон исчерпания трафика",
|
||||
"subTrafficDepletedTemplateDesc": "Шаблон для фиктивного узла, когда лимит трафика исчерпан."
|
||||
},
|
||||
"xray": {
|
||||
"importRules": "Импорт правил",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1456,7 +1456,13 @@
|
||||
"httpMethodDesc": "Метод запитів під час перевірок.",
|
||||
"note": "Балансувальники leastPing/leastLoad завжди мають burstObservatory. Цей перемикач налаштовує її параметри probe — вимкніть, щоб використовувати вбудовані значення за замовчуванням. Зміни застосовуються після перезапуску панелі."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subInfoNodeEnable": "Окремий інфо-конфіг / фіктивний вузол",
|
||||
"subInfoNodeEnableDesc": "Якщо увімкнено, примітка та залишок трафіку/днів відображаються окремим SOCKS-конфігом угорі, а після закінчення терміну/трафіку повертається лише статус.",
|
||||
"subExpiredTemplate": "Шаблон закінчення терміну",
|
||||
"subExpiredTemplateDesc": "Шаблон для фіктивного вузла, коли термін дії підписки закінчився.",
|
||||
"subTrafficDepletedTemplate": "Шаблон вичерпання трафіку",
|
||||
"subTrafficDepletedTemplateDesc": "Шаблон для фіктивного вузла, коли ліміт трафіку вичерпано."
|
||||
},
|
||||
"xray": {
|
||||
"save": "Зберегти",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1456,7 +1456,13 @@
|
||||
"httpMethodDesc": "探活请求使用的 HTTP 方法。",
|
||||
"note": "leastPing/leastLoad 均衡器始终带有 burstObservatory。此开关自定义其探活参数 — 关闭以使用内置默认值。更改在面板重启后生效。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subInfoNodeEnable": "独立信息配置 / 提示节点",
|
||||
"subInfoNodeEnableDesc": "启用后,备注及剩余流量/天数将作为单独的 SOCKS 提示配置显示在顶部;过期或流量耗尽时仅返回状态提示。",
|
||||
"subExpiredTemplate": "过期提示模板",
|
||||
"subExpiredTemplateDesc": "用户订阅过期时提示配置的备注模板。",
|
||||
"subTrafficDepletedTemplate": "流量耗尽模板",
|
||||
"subTrafficDepletedTemplateDesc": "用户订阅流量用尽时提示配置的备注模板。"
|
||||
},
|
||||
"xray": {
|
||||
"importRules": "导入规则",
|
||||
|
||||
@@ -1456,7 +1456,13 @@
|
||||
"httpMethodDesc": "探活請求使用的 HTTP 方法。",
|
||||
"note": "leastPing/leastLoad 平衡器始終帶有 burstObservatory。此開關自訂其探活參數 — 關閉以使用內建預設值。變更在面板重啟後生效。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subInfoNodeEnable": "獨立資訊配置 / 提示節點",
|
||||
"subInfoNodeEnableDesc": "啟用後,備註及剩餘流量/天數將作為單獨的 SOCKS 提示配置顯示在頂部;過期或流量耗盡時僅返回狀態提示。",
|
||||
"subExpiredTemplate": "過期提示範本",
|
||||
"subExpiredTemplateDesc": "使用者訂閱過期時提示配置的備註範本。",
|
||||
"subTrafficDepletedTemplate": "流量耗盡範本",
|
||||
"subTrafficDepletedTemplateDesc": "使用者訂閱流量用盡時提示配置的備註範本。"
|
||||
},
|
||||
"xray": {
|
||||
"save": "儲存",
|
||||
|
||||
Reference in New Issue
Block a user