Compare commits

...

7 Commits

Author SHA1 Message Date
MHSanaei 5dbd5b1d12 fix(sub): restore client email in panel copy/QR link remark (#5532)
Display-context links (Clients page QR + Information modals and the sub info page) dropped the client email from the link fragment in 3.4.0, showing only the inbound remark. Append the email back so the imported profile keeps its per-client label: inbound-host-email when a host is set, inbound-email otherwise. The usage template stays bypassed in display context, so no traffic or expiry data leaks.
2026-06-24 15:25:41 +02:00
MHSanaei bd60e770f4 fix(outbound): preserve custom headers for HTTP outbounds (#5519)
The Outbounds form routed HTTP through the SOCKS-shared simpleAuth adapter, which only knew address/port/user/pass, so xray's top-level settings.headers was dropped on both load and save. Opening and re-saving an HTTP outbound destroyed its headers.

Add headers to the HTTP wire/form schemas, round-trip it via dedicated httpFromWire/httpToWire helpers, and expose a HeaderMapEditor in the form. Only settings-level headers round-trip; xray-core ignores per-server headers.
2026-06-24 14:22:25 +02:00
MHSanaei a5e865c109 fix(backup): name Telegram backups after webDomain/IP instead of x-ui
The bot's ServerService is a separate instance whose mutex-guarded LastStatus is never populated (only RefreshStatus fills it, which the bot never calls), so backupHost's public-IP fallback never fired and bot backups collapsed to x-ui when no webDomain was set.

Resolve the public IP directly via a new mutex-guarded resolvePublicIPs helper (extracted from GetStatus and shared with it) so the bot path gets a real address. Panel downloads keep using the browser request host; the Telegram bot falls back to webDomain then public IP.
2026-06-24 14:12:41 +02:00
Rouzbeh† 82600936d6 fix(flow): restore XTLS Vision when an inbound becomes flow-eligible (#5520)
* fix(flow): restore XTLS Vision when an inbound becomes flow-eligible

clientWithInboundFlow strips Vision from a VLESS client whenever the target
inbound is not flow-eligible at client-write time — e.g. an XHTTP inbound
before its vlessenc (ML-KEM) encryption is set, or a client attached to such
an inbound. Nothing restored the flow once the inbound later became eligible:
an inbound edit stores its settings verbatim and never re-gates the clients.
So enabling encryption on an existing XHTTP inbound left every client without
flow, and the generated configs, share links and subscriptions silently
dropped flow=xtls-rprx-vision — most visibly on node inbounds and on any
inbound where encryption was turned on after the clients existed.

Restore the flow at the two points where an inbound can become eligible:

- UpdateInbound: after the new stream/settings are final, re-add Vision to
  clients that currently carry no flow but whose intended flow (their
  flow_override on a sibling inbound, via EffectiveFlowByEmail) is Vision —
  only when the inbound is now flow-eligible.
- MigrationRestoreVisionFlow: a one-time, idempotent boot migration that
  applies the same repair to existing installs and refreshes flow_override
  via SyncInbound.

The repair is conservative: it never invents a flow for a client that has
none anywhere, never overwrites an explicit flow, and is a no-op on healthy
installs. Adds EffectiveFlowByEmail and a unit test covering keep/skip/no-op
cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(flow): serialize restored settings with MarshalIndent

Match the indented JSON used by the adjacent timestamp block in UpdateInbound
and the externalProxy migration, so a restored inbound's settings column keeps
the same multi-line format as everything else (review nit on #5520).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(flow): batch the intended-flow lookup and run it on the active tx

restoreVisionFlowForEligibleInbound resolved each empty-flow client's intended
flow with EffectiveFlowByEmail, which issued two queries per client
(GetRecordByEmail + EffectiveFlow). A client that genuinely uses no Vision keeps
an empty flow forever, so it was re-queried on every UpdateInbound and every
boot — O(clients) queries per save on a Reality/TCP or XHTTP+vlessenc inbound
carrying many non-Vision clients, executed inside the serialized writer
transaction.

Replace it with EffectiveFlowsByEmails: collect every empty-flow email first and
resolve them in a single batched join over client_inbounds + clients (lowest
inbound_id wins, same rule as before), chunked for the SQLite bind-var limit.

Also thread the active tx through restoreVisionFlowForEligibleInbound so the
read runs on the writer's own connection while it holds the lock instead of a
separate pooled connection (UpdateInbound passes its tx; the boot migration
passes nil → GetDB() as before).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:02:42 +02:00
Rouzbeh† 14de0557f9 feat(clients): bulk-set XTLS flow from the Adjust dialog (#5524)
* feat(clients): bulk-set XTLS flow from the Adjust dialog

Add a "Set flow" dropdown to the bulk Adjust dialog so an admin can set or
clear the XTLS flow on all selected clients at once, alongside the existing
days/traffic bumps. Empty by default (no effect on save); "Disable" clears
flow, and the two vision values mirror the per-client credential tab.

Flow rides the existing inbound-JSON -> SyncInbound path (ClientRecord.Flow +
client_inbounds.flow_override), so no new endpoint, DB column, or migration.
Setting a vision flow is gated by inboundCanEnableTlsFlow: ineligible inbounds
are left untouched and reported as skipped; clearing is always allowed. A real
flow change requests an xray restart (local) or a node reconcile (remote).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(clients): keep days/traffic write when bulk flow is ineligible

Address review on the bulk-flow-adjust PR:

- Blocking: a client adjusted with both a days/traffic delta and a flow
  directive on a flow-ineligible inbound had the flow-ineligibility recorded
  into the same skip set that gates the ClientTraffic write, so the inbound
  JSON / ClientRecord advanced but ClientTraffic did not — divergent stores,
  and the client misreported as skipped. Track flow ineligibility in its own
  map (bulkInboundAdjustResult.flowIneligible) so it only feeds the final
  Skipped report and never suppresses the expiry/total persistence.
- Drop the broad delete(skippedReasons, email): flow reasons no longer enter
  skippedReasons, so honoring a flow can no longer erase an unrelated skip
  reason (unlimited expiry, a real persistence error on another inbound).
- Drop the inline comment block from ClientBulkAdjustModal.tsx (file had none);
  move the whitelist-sync note next to bulkFlowAllowed, the source of truth.
- Document the optional flow field in the bulkAdjust API-docs example
  (endpoints.ts) and regenerate openapi.json.
- Add a regression test covering days+flow on an ineligible inbound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:55:08 +02:00
Rouzbeh† c93beef267 fix(inbounds): accept null rewritePort in tunnel settings (#5516) (#5525)
Clearing the Rewrite port field makes AntD InputNumber write null into the
form store. The tunnel schema declared rewritePort as PortSchema.optional(),
which accepts undefined but not null, so saving (or the JSON tab reflecting
null) failed validation with "settings.rewritePort — Invalid input".

Accept null and collapse it to undefined so the field is simply omitted from
the serialized payload, matching the behavior of deleting the key by hand.
The trailing .optional() keeps the key optional in the inferred type.

Closes #5516
2026-06-24 12:54:05 +02:00
MHSanaei 48c2fb27b8 feat(sub): add Incy client integration and routing tab
Add an Incy quick-import button (incy://add) to the Android and iOS app menus on the subscription page, and a new Incy settings tab with routing enable + rules. Incy routing is delivered by injecting an incy://routing/onadd line into the raw subscription body, avoiding a collision with Happ's Routing header. Includes backend settings, regenerated OpenAPI/zod schemas, and translations for all locales.
2026-06-24 12:51:22 +02:00
55 changed files with 1069 additions and 152 deletions
+23 -2
View File
@@ -232,6 +232,14 @@
"description": "Hide server settings in happ subscription (Only for Happ)", "description": "Hide server settings in happ subscription (Only for Happ)",
"type": "boolean" "type": "boolean"
}, },
"subIncyEnableRouting": {
"description": "Enable routing injection for the Incy client",
"type": "boolean"
},
"subIncyRoutingRules": {
"description": "Incy routing deep-link injected into the subscription body (Only for Incy)",
"type": "string"
},
"subJsonEnable": { "subJsonEnable": {
"description": "Enable JSON subscription endpoint", "description": "Enable JSON subscription endpoint",
"type": "boolean" "type": "boolean"
@@ -457,6 +465,8 @@
"subEnableRouting", "subEnableRouting",
"subEncrypt", "subEncrypt",
"subHideSettings", "subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonEnable", "subJsonEnable",
"subJsonFinalMask", "subJsonFinalMask",
"subJsonMux", "subJsonMux",
@@ -727,6 +737,14 @@
"description": "Hide server settings in happ subscription (Only for Happ)", "description": "Hide server settings in happ subscription (Only for Happ)",
"type": "boolean" "type": "boolean"
}, },
"subIncyEnableRouting": {
"description": "Enable routing injection for the Incy client",
"type": "boolean"
},
"subIncyRoutingRules": {
"description": "Incy routing deep-link injected into the subscription body (Only for Incy)",
"type": "string"
},
"subJsonEnable": { "subJsonEnable": {
"description": "Enable JSON subscription endpoint", "description": "Enable JSON subscription endpoint",
"type": "boolean" "type": "boolean"
@@ -959,6 +977,8 @@
"subEnableRouting", "subEnableRouting",
"subEncrypt", "subEncrypt",
"subHideSettings", "subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonEnable", "subJsonEnable",
"subJsonFinalMask", "subJsonFinalMask",
"subJsonMux", "subJsonMux",
@@ -5456,7 +5476,7 @@
"tags": [ "tags": [
"Clients" "Clients"
], ],
"summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. Returns the adjusted count and per-email skip reasons.", "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). Returns the adjusted count and per-email skip reasons.",
"operationId": "post_panel_api_clients_bulkAdjust", "operationId": "post_panel_api_clients_bulkAdjust",
"requestBody": { "requestBody": {
"required": true, "required": true,
@@ -5471,7 +5491,8 @@
"bob" "bob"
], ],
"addDays": 30, "addDays": 30,
"addBytes": 53687091200 "addBytes": 53687091200,
"flow": "xtls-rprx-vision"
} }
} }
} }
+4
View File
@@ -52,6 +52,8 @@ export const EXAMPLES: Record<string, unknown> = {
"subEnableRouting": false, "subEnableRouting": false,
"subEncrypt": false, "subEncrypt": false,
"subHideSettings": false, "subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonEnable": false, "subJsonEnable": false,
"subJsonFinalMask": "", "subJsonFinalMask": "",
"subJsonMux": "", "subJsonMux": "",
@@ -152,6 +154,8 @@ export const EXAMPLES: Record<string, unknown> = {
"subEnableRouting": false, "subEnableRouting": false,
"subEncrypt": false, "subEncrypt": false,
"subHideSettings": false, "subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonEnable": false, "subJsonEnable": false,
"subJsonFinalMask": "", "subJsonFinalMask": "",
"subJsonMux": "", "subJsonMux": "",
+20
View File
@@ -206,6 +206,14 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Hide server settings in happ subscription (Only for Happ)", "description": "Hide server settings in happ subscription (Only for Happ)",
"type": "boolean" "type": "boolean"
}, },
"subIncyEnableRouting": {
"description": "Enable routing injection for the Incy client",
"type": "boolean"
},
"subIncyRoutingRules": {
"description": "Incy routing deep-link injected into the subscription body (Only for Incy)",
"type": "string"
},
"subJsonEnable": { "subJsonEnable": {
"description": "Enable JSON subscription endpoint", "description": "Enable JSON subscription endpoint",
"type": "boolean" "type": "boolean"
@@ -431,6 +439,8 @@ export const SCHEMAS: Record<string, unknown> = {
"subEnableRouting", "subEnableRouting",
"subEncrypt", "subEncrypt",
"subHideSettings", "subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonEnable", "subJsonEnable",
"subJsonFinalMask", "subJsonFinalMask",
"subJsonMux", "subJsonMux",
@@ -701,6 +711,14 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Hide server settings in happ subscription (Only for Happ)", "description": "Hide server settings in happ subscription (Only for Happ)",
"type": "boolean" "type": "boolean"
}, },
"subIncyEnableRouting": {
"description": "Enable routing injection for the Incy client",
"type": "boolean"
},
"subIncyRoutingRules": {
"description": "Incy routing deep-link injected into the subscription body (Only for Incy)",
"type": "string"
},
"subJsonEnable": { "subJsonEnable": {
"description": "Enable JSON subscription endpoint", "description": "Enable JSON subscription endpoint",
"type": "boolean" "type": "boolean"
@@ -933,6 +951,8 @@ export const SCHEMAS: Record<string, unknown> = {
"subEnableRouting", "subEnableRouting",
"subEncrypt", "subEncrypt",
"subHideSettings", "subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonEnable", "subJsonEnable",
"subJsonFinalMask", "subJsonFinalMask",
"subJsonMux", "subJsonMux",
+4
View File
@@ -58,6 +58,8 @@ export interface AllSetting {
subEnableRouting: boolean; subEnableRouting: boolean;
subEncrypt: boolean; subEncrypt: boolean;
subHideSettings: boolean; subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonEnable: boolean; subJsonEnable: boolean;
subJsonFinalMask: string; subJsonFinalMask: string;
subJsonMux: string; subJsonMux: string;
@@ -159,6 +161,8 @@ export interface AllSettingView {
subEnableRouting: boolean; subEnableRouting: boolean;
subEncrypt: boolean; subEncrypt: boolean;
subHideSettings: boolean; subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonEnable: boolean; subJsonEnable: boolean;
subJsonFinalMask: string; subJsonFinalMask: string;
subJsonMux: string; subJsonMux: string;
+4
View File
@@ -70,6 +70,8 @@ export const AllSettingSchema = z.object({
subEnableRouting: z.boolean(), subEnableRouting: z.boolean(),
subEncrypt: z.boolean(), subEncrypt: z.boolean(),
subHideSettings: z.boolean(), subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonEnable: z.boolean(), subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(), subJsonFinalMask: z.string(),
subJsonMux: z.string(), subJsonMux: z.string(),
@@ -172,6 +174,8 @@ export const AllSettingViewSchema = z.object({
subEnableRouting: z.boolean(), subEnableRouting: z.boolean(),
subEncrypt: z.boolean(), subEncrypt: z.boolean(),
subHideSettings: z.boolean(), subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonEnable: z.boolean(), subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(), subJsonFinalMask: z.string(),
subJsonMux: z.string(), subJsonMux: z.string(),
+3 -3
View File
@@ -341,7 +341,7 @@ export function useClients() {
}); });
const bulkAdjustMut = useMutation({ const bulkAdjustMut = useMutation({
mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number }): Promise<Msg<BulkAdjustResult>> => { mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number; flow: string }): Promise<Msg<BulkAdjustResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS); const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust'); return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
}, },
@@ -435,9 +435,9 @@ export function useClients() {
if (!Array.isArray(payloads) || payloads.length === 0) return Promise.resolve(null as unknown as Msg<BulkCreateResult>); if (!Array.isArray(payloads) || payloads.length === 0) return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
return bulkCreateMut.mutateAsync(payloads); return bulkCreateMut.mutateAsync(payloads);
}, [bulkCreateMut]); }, [bulkCreateMut]);
const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => { const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number, flow = '') => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null); if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes }); return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow });
}, [bulkAdjustMut]); }, [bulkAdjustMut]);
const bulkAddToGroup = useCallback((emails: string[], group: string) => { const bulkAddToGroup = useCallback((emails: string[], group: string) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null); if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
+31 -2
View File
@@ -8,6 +8,7 @@ import type {
DnsRuleForm, DnsRuleForm,
FreedomFinalRuleForm, FreedomFinalRuleForm,
FreedomOutboundFormSettings, FreedomOutboundFormSettings,
HttpOutboundFormSettings,
HysteriaOutboundFormSettings, HysteriaOutboundFormSettings,
LoopbackOutboundFormSettings, LoopbackOutboundFormSettings,
MuxForm, MuxForm,
@@ -178,6 +179,26 @@ function simpleAuthFromWire(raw: Raw, defaultPort: number): SimpleAuthFormSettin
}; };
} }
function stringRecordFromWire(raw: unknown): Record<string, string> {
const obj = asObject(raw);
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(obj)) {
if (typeof v === 'string') out[k] = v;
}
return out;
}
// HTTP outbound reuses the SOCKS server/user shape but also carries xray's
// top-level `settings.headers` (HTTPClientConfig.Headers), the CONNECT
// headers sent to the upstream proxy. xray ignores per-server `headers`,
// so only the settings-level map round-trips (issue #5519).
function httpFromWire(raw: Raw): HttpOutboundFormSettings {
return {
...simpleAuthFromWire(raw, 8080),
headers: stringRecordFromWire(raw.headers),
};
}
function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings { function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
const secretKey = asString(raw.secretKey); const secretKey = asString(raw.secretKey);
const pubKey = secretKey.length > 0 const pubKey = secretKey.length > 0
@@ -395,7 +416,7 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
case 'trojan': typed = { protocol: 'trojan', settings: trojanFromWire(settings) }; break; case 'trojan': typed = { protocol: 'trojan', settings: trojanFromWire(settings) }; break;
case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break; case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
case 'socks': typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) }; break; case 'socks': typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) }; break;
case 'http': typed = { protocol: 'http', settings: simpleAuthFromWire(settings, 8080) }; break; case 'http': typed = { protocol: 'http', settings: httpFromWire(settings) }; break;
case 'wireguard': typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) }; break; case 'wireguard': typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) }; break;
case 'hysteria': typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) }; break; case 'hysteria': typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) }; break;
case 'freedom': typed = { protocol: 'freedom', settings: freedomFromWire(settings) }; break; case 'freedom': typed = { protocol: 'freedom', settings: freedomFromWire(settings) }; break;
@@ -489,6 +510,14 @@ function simpleAuthToWire(s: SimpleAuthFormSettings) {
}; };
} }
function httpToWire(s: HttpOutboundFormSettings): Raw {
const wire: Raw = simpleAuthToWire(s);
if (s.headers && Object.keys(s.headers).length > 0) {
wire.headers = s.headers;
}
return wire;
}
function wireguardToWire(s: WireguardOutboundFormSettings) { function wireguardToWire(s: WireguardOutboundFormSettings) {
return { return {
mtu: s.mtu || undefined, mtu: s.mtu || undefined,
@@ -629,7 +658,7 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
case 'trojan': settings = trojanToWire(values.settings); break; case 'trojan': settings = trojanToWire(values.settings); break;
case 'shadowsocks': settings = shadowsocksToWire(values.settings); break; case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
case 'socks': settings = simpleAuthToWire(values.settings); break; case 'socks': settings = simpleAuthToWire(values.settings); break;
case 'http': settings = simpleAuthToWire(values.settings); break; case 'http': settings = httpToWire(values.settings); break;
case 'wireguard': settings = wireguardToWire(values.settings); break; case 'wireguard': settings = wireguardToWire(values.settings); break;
case 'hysteria': settings = hysteriaToWire(values.settings); break; case 'hysteria': settings = hysteriaToWire(values.settings); break;
case 'freedom': settings = freedomToWire(values.settings); break; case 'freedom': settings = freedomToWire(values.settings); break;
+2
View File
@@ -35,6 +35,8 @@ export class AllSetting {
subAnnounce = ''; subAnnounce = '';
subEnableRouting = false; subEnableRouting = false;
subRoutingRules = ''; subRoutingRules = '';
subIncyEnableRouting = false;
subIncyRoutingRules = '';
subListen = ''; subListen = '';
subPort = 2096; subPort = 2096;
subPath = '/sub/'; subPath = '/sub/';
+2 -2
View File
@@ -635,8 +635,8 @@ export const sections: readonly Section[] = [
{ {
method: 'POST', method: 'POST',
path: '/panel/api/clients/bulkAdjust', path: '/panel/api/clients/bulkAdjust',
summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. Returns the adjusted count and per-email skip reasons.', summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. The optional flow directive sets the XTLS flow on every client: "none" clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound supports it (omit or "" to leave it unchanged). Returns the adjusted count and per-email skip reasons.',
body: '{\n "emails": ["alice", "bob"],\n "addDays": 30,\n "addBytes": 53687091200\n}', body: '{\n "emails": ["alice", "bob"],\n "addDays": 30,\n "addBytes": 53687091200,\n "flow": "xtls-rprx-vision"\n}',
response: '{\n "success": true,\n "obj": {\n "adjusted": 2,\n "skipped": [\n { "email": "carol", "reason": "unlimited expiry" }\n ]\n }\n}', response: '{\n "success": true,\n "obj": {\n "adjusted": 2,\n "skipped": [\n { "email": "carol", "reason": "unlimited expiry" }\n ]\n }\n}',
}, },
{ {
@@ -1,16 +1,19 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Modal, message } from 'antd'; import { Alert, Form, InputNumber, Modal, Select, message } from 'antd';
import { ClientBulkAdjustFormSchema } from '@/schemas/client'; import { ClientBulkAdjustFormSchema } from '@/schemas/client';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives/flow';
const GB = 1024 * 1024 * 1024; const GB = 1024 * 1024 * 1024;
const FLOW_CLEAR = 'none';
interface ClientBulkAdjustModalProps { interface ClientBulkAdjustModalProps {
open: boolean; open: boolean;
count: number; count: number;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSubmit: (addDays: number, addBytes: number) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>; onSubmit: (addDays: number, addBytes: number, flow: string) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>;
} }
export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSubmit }: ClientBulkAdjustModalProps) { export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSubmit }: ClientBulkAdjustModalProps) {
@@ -18,12 +21,14 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [addDays, setAddDays] = useState<number>(0); const [addDays, setAddDays] = useState<number>(0);
const [addGB, setAddGB] = useState<number>(0); const [addGB, setAddGB] = useState<number>(0);
const [flow, setFlow] = useState<string>('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setAddDays(0); setAddDays(0);
setAddGB(0); setAddGB(0);
setFlow('');
} }
}, [open]); }, [open]);
@@ -31,16 +36,17 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
const validated = ClientBulkAdjustFormSchema.safeParse({ const validated = ClientBulkAdjustFormSchema.safeParse({
addDays: Math.trunc(Number(addDays) || 0), addDays: Math.trunc(Number(addDays) || 0),
addGB: Number(addGB) || 0, addGB: Number(addGB) || 0,
flow,
}); });
if (!validated.success) { if (!validated.success) {
messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong')); messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
return; return;
} }
const { addDays: days, addGB: gb } = validated.data; const { addDays: days, addGB: gb, flow: flowValue } = validated.data;
setSubmitting(true); setSubmitting(true);
try { try {
const bytes = Math.trunc(gb * GB); const bytes = Math.trunc(gb * GB);
const result = await onSubmit(days, bytes); const result = await onSubmit(days, bytes, flowValue);
if (!result) return; if (!result) return;
const ok = result.adjusted ?? 0; const ok = result.adjusted ?? 0;
const skipped = result.skipped?.length ?? 0; const skipped = result.skipped?.length ?? 0;
@@ -95,6 +101,18 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
step={1} step={1}
/> />
</Form.Item> </Form.Item>
<Form.Item label={t('pages.clients.bulkFlow')}>
<Select
value={flow}
onChange={setFlow}
style={{ width: '100%' }}
options={[
{ value: '', label: t('pages.clients.bulkFlowNoChange') },
{ value: FLOW_CLEAR, label: t('pages.clients.bulkFlowDisable') },
...Object.values(TLS_FLOW_CONTROL).map((k) => ({ value: k, label: k })),
]}
/>
</Form.Item>
</Form> </Form>
</Modal> </Modal>
</> </>
@@ -357,7 +357,7 @@ export default function ClientInfoModal({
const parts = parseLinkParts(link); const parts = parseLinkParts(link);
const fallback = `${t('pages.clients.link')} ${idx + 1}`; const fallback = `${t('pages.clients.link')} ${idx + 1}`;
const rowTitle = (parts && linkMetaText(parts)) || fallback; const rowTitle = (parts && linkMetaText(parts)) || fallback;
const qrRemark = [parts?.remark, client.email].filter(Boolean).join('-') || rowTitle; const qrRemark = parts?.remark || rowTitle;
const canQr = !isPostQuantumLink(link); const canQr = !isPostQuantumLink(link);
return ( return (
<div key={idx} className="link-row"> <div key={idx} className="link-row">
+2 -2
View File
@@ -1418,8 +1418,8 @@ export default function ClientsPage() {
open={bulkAdjustOpen} open={bulkAdjustOpen}
count={selectedRowKeys.length} count={selectedRowKeys.length}
onOpenChange={setBulkAdjustOpen} onOpenChange={setBulkAdjustOpen}
onSubmit={async (addDays, addBytes) => { onSubmit={async (addDays, addBytes, flow) => {
const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes); const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes, flow);
if (msg?.success) { if (msg?.success) {
setSelectedRowKeys([]); setSelectedRowKeys([]);
return msg.obj ?? { adjusted: 0 }; return msg.obj ?? { adjusted: 0 };
@@ -1,5 +1,5 @@
import { Input, InputNumber, Switch, Tabs } from 'antd'; import { Input, InputNumber, Switch, Tabs } from 'antd';
import { BranchesOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons'; import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { AllSetting } from '@/models/setting'; import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
@@ -178,6 +178,21 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
</> </>
), ),
}, },
{
key: '7',
label: catTabLabel(<CompassOutlined />, 'Incy', isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subIncyEnableRouting')} description={t('pages.settings.subIncyEnableRoutingDesc')}>
<Switch checked={allSetting.subIncyEnableRouting} onChange={(v) => updateSetting({ subIncyEnableRouting: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subIncyRoutingRules')} description={t('pages.settings.subIncyRoutingRulesDesc')}>
<Input.TextArea value={allSetting.subIncyRoutingRules} placeholder="incy://routing/onadd/..."
onChange={(e) => updateSetting({ subIncyRoutingRules: e.target.value })} />
</SettingListItem>
</>
),
},
]} /> ]} />
); );
} }
+4 -1
View File
@@ -139,6 +139,7 @@ export default function SubPage() {
); );
const streisandUrl = useMemo(() => `streisand://import/${encodeURIComponent(subUrl)}`, []); const streisandUrl = useMemo(() => `streisand://import/${encodeURIComponent(subUrl)}`, []);
const happUrl = useMemo(() => `happ://add/${subUrl}`, []); const happUrl = useMemo(() => `happ://add/${subUrl}`, []);
const incyUrl = useMemo(() => `incy://add/${subUrl}`, []);
const pageClass = useMemo(() => { const pageClass = useMemo(() => {
const classes = ['subscription-page']; const classes = ['subscription-page'];
@@ -200,6 +201,7 @@ export default function SubPage() {
{ key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) }, { key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
{ key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) }, { key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
{ key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) }, { key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) },
{ key: 'android-incy', label: 'Incy', onClick: () => open(`incy://add/${subUrl}`) },
], [copy, open]); ], [copy, open]);
const iosMenuItems = useMemo(() => [ const iosMenuItems = useMemo(() => [
@@ -209,7 +211,8 @@ export default function SubPage() {
{ key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) }, { key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
{ key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) }, { key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
{ key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) }, { key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) },
], [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl]); { key: 'ios-incy', label: 'Incy', onClick: () => open(incyUrl) },
], [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl, incyUrl]);
const langMenuItems = useMemo( const langMenuItems = useMemo(
() => (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map((l) => ({ () => (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map((l) => ({
@@ -1,6 +1,8 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd'; import { Form, Input } from 'antd';
import { HeaderMapEditor } from '@/components/form';
export default function HttpFields() { export default function HttpFields() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -11,6 +13,9 @@ export default function HttpFields() {
<Form.Item label={t('password')} name={['settings', 'pass']}> <Form.Item label={t('password')} name={['settings', 'pass']}>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
<HeaderMapEditor mode="v1" />
</Form.Item>
</> </>
); );
} }
+2 -1
View File
@@ -188,8 +188,9 @@ export const ClientBulkAdjustFormSchema = z
.object({ .object({
addDays: z.number().int(), addDays: z.number().int(),
addGB: z.number(), addGB: z.number(),
flow: z.string().optional().default(''),
}) })
.refine((v) => v.addDays !== 0 || v.addGB !== 0, { .refine((v) => v.addDays !== 0 || v.addGB !== 0 || v.flow !== '', {
message: 'pages.clients.bulkAdjustNothing', message: 'pages.clients.bulkAdjustNothing',
}); });
@@ -80,6 +80,7 @@ export const HttpOutboundFormSettingsSchema = z.object({
port: PortSchema.default(8080), port: PortSchema.default(8080),
user: z.string().default(''), user: z.string().default(''),
pass: z.string().default(''), pass: z.string().default(''),
headers: z.record(z.string(), z.string()).default({}),
}); });
export type HttpOutboundFormSettings = z.infer<typeof HttpOutboundFormSettingsSchema>; export type HttpOutboundFormSettings = z.infer<typeof HttpOutboundFormSettingsSchema>;
@@ -11,7 +11,12 @@ export type TunnelNetwork = z.infer<typeof TunnelNetworkSchema>;
// with arr=false. // with arr=false.
export const TunnelInboundSettingsSchema = z.object({ export const TunnelInboundSettingsSchema = z.object({
rewriteAddress: z.string().optional(), rewriteAddress: z.string().optional(),
rewritePort: PortSchema.optional(), // AntD InputNumber writes null when cleared; accept it and collapse to
// undefined so the field is omitted from the payload instead of crashing
// validation with "Invalid input" (issue #5516). The trailing .optional()
// keeps the key optional in the inferred type (a bare .transform() would
// make it required).
rewritePort: PortSchema.nullable().transform((v) => v ?? undefined).optional(),
portMap: z.record(z.string(), z.string()).default({}), portMap: z.record(z.string(), z.string()).default({}),
allowedNetwork: TunnelNetworkSchema.default('tcp,udp'), allowedNetwork: TunnelNetworkSchema.default('tcp,udp'),
followRedirect: z.boolean().default(false), followRedirect: z.boolean().default(false),
@@ -21,5 +21,6 @@ export type HttpOutboundServer = z.infer<typeof HttpOutboundServerSchema>;
export const HttpOutboundSettingsSchema = z.object({ export const HttpOutboundSettingsSchema = z.object({
servers: z.array(HttpOutboundServerSchema).min(1), servers: z.array(HttpOutboundServerSchema).min(1),
headers: z.record(z.string(), z.string()).optional(),
}); });
export type HttpOutboundSettings = z.infer<typeof HttpOutboundSettingsSchema>; export type HttpOutboundSettings = z.infer<typeof HttpOutboundSettingsSchema>;
+2
View File
@@ -39,6 +39,8 @@ export const AllSettingSchema = z.object({
subAnnounce: z.string().optional(), subAnnounce: z.string().optional(),
subEnableRouting: z.boolean().optional(), subEnableRouting: z.boolean().optional(),
subRoutingRules: z.string().optional(), subRoutingRules: z.string().optional(),
subIncyEnableRouting: z.boolean().optional(),
subIncyRoutingRules: z.string().optional(),
subListen: z.string().optional(), subListen: z.string().optional(),
subPort: port.optional(), subPort: port.optional(),
subPath: absolutePath.optional(), subPath: absolutePath.optional(),
@@ -175,6 +175,29 @@ describe('outbound-form-adapter: round-trip', () => {
}); });
}); });
it('http preserves top-level settings.headers across wire → form → wire (#5519)', () => {
const headers = { 'X-T5-Auth': '683556433', Host: '153.3.236.22:443' };
const form = rawOutboundToFormValues({
protocol: 'http',
tag: 'h',
settings: { servers: [{ address: 'a', port: 443, users: [] }], headers },
});
expect(form.protocol).toBe('http');
if (form.protocol === 'http') {
expect(form.settings.headers).toEqual(headers);
}
const back = formValuesToWirePayload(form);
expect(back.settings).toMatchObject({ headers });
});
it('http omits headers when empty', () => {
const back = formValuesToWirePayload(rawOutboundToFormValues({
protocol: 'http',
settings: { servers: [{ address: 'a', port: 8080, users: [] }] },
}));
expect(back.settings).not.toHaveProperty('headers');
});
it('wireguard csv-joins address and reserved on read, splits on write', () => { it('wireguard csv-joins address and reserved on read, splits on write', () => {
const wire = { const wire = {
protocol: 'wireguard', protocol: 'wireguard',
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { TunnelInboundSettingsSchema } from '@/schemas/protocols/inbound/tunnel';
// Regression for issue #5516: AntD InputNumber writes null when the Rewrite
// port field is cleared, which used to crash validation with "Invalid input".
describe('TunnelInboundSettingsSchema rewritePort', () => {
it('accepts null (cleared field) and omits the port', () => {
const parsed = TunnelInboundSettingsSchema.parse({ rewritePort: null });
expect(parsed.rewritePort).toBeUndefined();
});
it('accepts a missing field', () => {
const parsed = TunnelInboundSettingsSchema.parse({});
expect(parsed.rewritePort).toBeUndefined();
});
it('preserves a valid port', () => {
const parsed = TunnelInboundSettingsSchema.parse({ rewritePort: 8443 });
expect(parsed.rewritePort).toBe(8443);
});
it('still rejects out-of-range ports', () => {
expect(() => TunnelInboundSettingsSchema.parse({ rewritePort: 70000 })).toThrow();
});
});
+6 -6
View File
@@ -45,8 +45,8 @@ func TestChar_C1_VlessExternalProxy(t *testing.T) {
}` }`
s := &SubService{} s := &SubService{}
got := s.genVlessLink(charVlessInbound(stream), "user") got := s.genVlessLink(charVlessInbound(stream), "user")
want := "vless://11111111-2222-4333-8444-555555555555@cdn1.example.com:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1\n" + want := "vless://11111111-2222-4333-8444-555555555555@cdn1.example.com:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1-user\n" +
"vless://11111111-2222-4333-8444-555555555555@cdn2.example.com:80?encryption=none&security=none&type=tcp#char-R2" "vless://11111111-2222-4333-8444-555555555555@cdn2.example.com:80?encryption=none&security=none&type=tcp#char-R2-user"
if got != want { if got != want {
t.Fatalf("C1 mismatch.\n got: %q\nwant: %q", got, want) t.Fatalf("C1 mismatch.\n got: %q\nwant: %q", got, want)
} }
@@ -106,8 +106,8 @@ func TestChar_C2_VmessExternalProxy(t *testing.T) {
} }
s := &SubService{} s := &SubService{}
got := s.genVmessLink(in, "user") got := s.genVmessLink(in, "user")
want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMSIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogInNuaTEuZXhhbXBsZS5jb20iLAogICJ0bHMiOiAidGxzIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9\n" + want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMS11c2VyIiwKICAic2N5IjogImF1dG8iLAogICJzbmkiOiAic25pMS5leGFtcGxlLmNvbSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
"vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMiIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=" "vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMi11c2VyIiwKICAic2N5IjogImF1dG8iLAogICJ0bHMiOiAibm9uZSIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ=="
if got != want { if got != want {
t.Fatalf("C2 mismatch.\n got: %q\nwant: %q", got, want) t.Fatalf("C2 mismatch.\n got: %q\nwant: %q", got, want)
} }
@@ -143,7 +143,7 @@ func TestChar_C3_TrojanExternalProxy(t *testing.T) {
} }
s := &SubService{} s := &SubService{}
got := s.genTrojanLink(in, "user") got := s.genTrojanLink(in, "user")
want := "trojan://p%40ss%2Fw%2Brd%3D@tj.example.com:8443?fp=chrome&security=tls&sni=tj.sni&type=tcp#char-TJ" want := "trojan://p%40ss%2Fw%2Brd%3D@tj.example.com:8443?fp=chrome&security=tls&sni=tj.sni&type=tcp#char-TJ-user"
if got != want { if got != want {
t.Fatalf("C3-Trojan mismatch.\n got: %q\nwant: %q", got, want) t.Fatalf("C3-Trojan mismatch.\n got: %q\nwant: %q", got, want)
} }
@@ -168,7 +168,7 @@ func TestChar_C3_ShadowsocksExternalProxy(t *testing.T) {
} }
s := &SubService{} s := &SubService{}
got := s.genShadowsocksLink(in, "user") got := s.genShadowsocksLink(in, "user")
want := "ss://2022-blake3-aes-256-gcm:inboundpw:clientpw@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS" want := "ss://2022-blake3-aes-256-gcm:inboundpw:clientpw@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS-user"
if got != want { if got != want {
t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want) t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want)
} }
+29 -14
View File
@@ -49,13 +49,17 @@ type SUBController struct {
subEnableRouting bool subEnableRouting bool
subRoutingRules string subRoutingRules string
subHideSettings bool subHideSettings bool
subPath string
subJsonPath string subIncyEnableRouting bool
subClashPath string subIncyRoutingRules string
jsonEnabled bool
clashEnabled bool subPath string
subEncrypt bool subJsonPath string
updateInterval string subClashPath string
jsonEnabled bool
clashEnabled bool
subEncrypt bool
updateInterval string
subService *SubService subService *SubService
subJsonService *SubJsonService subJsonService *SubJsonService
@@ -89,6 +93,8 @@ func NewSUBController(
subEnableRouting bool, subEnableRouting bool,
subRoutingRules string, subRoutingRules string,
subHideSettings bool, subHideSettings bool,
subIncyEnableRouting bool,
subIncyRoutingRules string,
) *SUBController { ) *SUBController {
sub := NewSubService(remarkTemplate) sub := NewSubService(remarkTemplate)
a := &SUBController{ a := &SUBController{
@@ -99,13 +105,17 @@ func NewSUBController(
subEnableRouting: subEnableRouting, subEnableRouting: subEnableRouting,
subRoutingRules: subRoutingRules, subRoutingRules: subRoutingRules,
subHideSettings: subHideSettings, subHideSettings: subHideSettings,
subPath: subPath,
subJsonPath: jsonPath, subIncyEnableRouting: subIncyEnableRouting,
subClashPath: clashPath, subIncyRoutingRules: subIncyRoutingRules,
jsonEnabled: jsonEnabled,
clashEnabled: clashEnabled, subPath: subPath,
subEncrypt: encrypt, subJsonPath: jsonPath,
updateInterval: update, subClashPath: clashPath,
jsonEnabled: jsonEnabled,
clashEnabled: clashEnabled,
subEncrypt: encrypt,
updateInterval: update,
subService: sub, subService: sub,
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub), subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
@@ -183,6 +193,11 @@ func (a *SUBController) subs(c *gin.Context) {
} }
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings) a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
result.WriteString(a.subIncyRoutingRules)
result.WriteString("\n")
}
if a.subEncrypt { if a.subEncrypt {
c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String()))) c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String())))
} else { } else {
+4 -4
View File
@@ -83,8 +83,8 @@ func TestBuildEndpointLinks_ParamForm(t *testing.T) {
func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) }, func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) },
func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") }, func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
) )
want := "vless://uid@a.example.com:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A\n" + want := "vless://uid@a.example.com:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A-user\n" +
"vless://uid@b.example.com:80?security=none&type=tcp#ib-B" "vless://uid@b.example.com:80?security=none&type=tcp#ib-B-user"
if got != want { if got != want {
t.Fatalf("N3 mismatch.\n got: %q\nwant: %q", got, want) t.Fatalf("N3 mismatch.\n got: %q\nwant: %q", got, want)
} }
@@ -105,8 +105,8 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}), externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
} }
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp") got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEiLAogICJzY3kiOiAiYXV0byIsCiAgInNuaSI6ICJhLnNuaSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" + want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEtdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogImEuc25pIiwKICAidGxzIjogInRscyIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ==\n" +
"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9" "vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUItdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
if got != want { if got != want {
t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want) t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want)
} }
+5 -5
View File
@@ -524,13 +524,13 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
return ctx.configName() return ctx.configName()
} }
// genHostRemark builds one host endpoint's remark for a specific client. The // genHostRemark builds one host endpoint's remark for a specific client. In the
// config name is always the inbound's own remark; the host's remark is surfaced // subscription body the {{HOST}} token carries the host's remark and the rest of
// only through the {{HOST}} token. In the subscription body the rest of the // the template still applies; displays show the config name, host and email.
// remark template still applies; displays show just the config name.
func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string { func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
if !s.subscriptionBody { if !s.subscriptionBody {
return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName() name := remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
return fallbackRemark(name, hostRemark, client.Email)
} }
return s.genTemplatedRemark(inbound, client, hostRemark, transport) return s.genTemplatedRemark(inbound, client, hostRemark, transport)
} }
+9 -17
View File
@@ -206,12 +206,11 @@ func TestGenRemark_GlobalTemplate(t *testing.T) {
} }
} }
// With no template, genRemark composes the fallback model and adds no suffix. func TestGenRemark_NoTemplate_AppendsEmail(t *testing.T) {
func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
s, inbound, _ := hostRemarkService("") s, inbound, _ := hostRemarkService("")
got := s.genRemark(inbound, "john@example.com", "Relay", "") got := s.genRemark(inbound, "john@example.com", "Relay", "")
if got != "DE-Relay" { if got != "DE-Relay-john@example.com" {
t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay") t.Fatalf("genRemark = %q, want %q", got, "DE-Relay-john@example.com")
} }
} }
@@ -232,24 +231,17 @@ func TestUsageOnFirstLinkOnly(t *testing.T) {
} }
} }
// Outside the subscription body (panel link/QR displays, sub info page) the
// template is bypassed entirely — links show just the config name, with no
// per-client email or usage info.
func TestRemarkInDisplayContext(t *testing.T) { func TestRemarkInDisplayContext(t *testing.T) {
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D") s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
s.subscriptionBody = false s.subscriptionBody = false
// A host link in a display shows only the config name — the inbound's remark, if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE-CDN-john@example.com" {
// with no per-client email or usage info and the host remark ignored. t.Fatalf("display host link = %q, want %q", got, "DE-CDN-john@example.com")
if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE" {
t.Fatalf("display host link = %q, want config name %q", got, "DE")
} }
// With no host remark, the config name is likewise the inbound's own remark. if got := s.genHostRemark(inbound, client, "", ""); got != "DE-john@example.com" {
if got := s.genHostRemark(inbound, client, "", ""); got != "DE" { t.Fatalf("display host link (no host) = %q, want %q", got, "DE-john@example.com")
t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
} }
// genRemark (non-host) likewise drops the template in display context. if got := s.genRemark(inbound, client.Email, "", ""); got != "DE-john@example.com" {
if got := s.genRemark(inbound, client.Email, "", ""); got != "DE" { t.Fatalf("display genRemark = %q, want %q", got, "DE-john@example.com")
t.Fatalf("display genRemark = %q, want %q", got, "DE")
} }
} }
+8 -15
View File
@@ -1641,24 +1641,17 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
if s.remarkTemplate != "" && s.subscriptionBody { if s.remarkTemplate != "" && s.subscriptionBody {
return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport) return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport)
} }
// Sub info page + panel link/QR displays: just the config name (no template, return fallbackRemark(inbound.Remark, extra, email)
// so no per-client email/usage leaks into the shown remark).
return fallbackRemark(inbound.Remark, extra)
} }
// fallbackRemark is the minimal remark used only when no template is configured func fallbackRemark(parts ...string) string {
// (an operator explicitly cleared it): the inbound remark and the host/extra out := make([]string, 0, len(parts))
// remark joined by "-", skipping empties. The configurable remark model was for _, p := range parts {
// removed in favour of the template, whose default already includes the email. if p != "" {
func fallbackRemark(inboundRemark, extra string) string { out = append(out, p)
switch { }
case inboundRemark == "":
return extra
case extra == "":
return inboundRemark
default:
return inboundRemark + "-" + extra
} }
return strings.Join(out, "-")
} }
// findClientStats returns the inbound's traffic record for email, if present. // findClientStats returns the inbound's traffic record for email, if present.
+12 -1
View File
@@ -175,6 +175,16 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubHideSettings = false SubHideSettings = false
} }
SubIncyEnableRouting, err := s.settingService.GetSubIncyEnableRouting()
if err != nil {
SubIncyEnableRouting = false
}
SubIncyRoutingRules, err := s.settingService.GetSubIncyRoutingRules()
if err != nil {
SubIncyRoutingRules = ""
}
// set per-request localizer from headers/cookies // set per-request localizer from headers/cookies
engine.Use(locale.LocalizerMiddleware()) engine.Use(locale.LocalizerMiddleware())
@@ -232,7 +242,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.sub = NewSUBController( s.sub = NewSUBController(
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, RemarkTemplate, SubUpdates, g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, RemarkTemplate, SubUpdates,
SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl, SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules, SubHideSettings) SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules, SubHideSettings,
SubIncyEnableRouting, SubIncyRoutingRules)
return engine, nil return engine, nil
} }
+2 -1
View File
@@ -248,6 +248,7 @@ type bulkAdjustRequest struct {
Emails []string `json:"emails"` Emails []string `json:"emails"`
AddDays int `json:"addDays"` AddDays int `json:"addDays"`
AddBytes int64 `json:"addBytes"` AddBytes int64 `json:"addBytes"`
Flow string `json:"flow"`
} }
func (a *ClientController) bulkAdjust(c *gin.Context) { func (a *ClientController) bulkAdjust(c *gin.Context) {
@@ -256,7 +257,7 @@ func (a *ClientController) bulkAdjust(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
} }
result, needRestart, err := a.clientService.BulkAdjust(&a.inboundService, req.Emails, req.AddDays, req.AddBytes) result, needRestart, err := a.clientService.BulkAdjust(&a.inboundService, req.Emails, req.AddDays, req.AddBytes, req.Flow)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
+2
View File
@@ -77,6 +77,8 @@ type AllSetting struct {
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"` // Subscription announce SubAnnounce string `json:"subAnnounce" form:"subAnnounce"` // Subscription announce
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` // Enable routing for subscription SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` // Enable routing for subscription
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"` // Subscription global routing rules (Only for Happ) SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"` // Subscription global routing rules (Only for Happ)
SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"` // Enable routing injection for the Incy client
SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"` // Incy routing deep-link injected into the subscription body (Only for Incy)
SubListen string `json:"subListen" form:"subListen"` // Subscription server listen IP SubListen string `json:"subListen" form:"subListen"` // Subscription server listen IP
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"` // Subscription server port SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"` // Subscription server port
SubPath string `json:"subPath" form:"subPath"` // Base path for subscription URLs SubPath string `json:"subPath" form:"subPath"` // Base path for subscription URLs
+102 -14
View File
@@ -242,6 +242,22 @@ type bulkAdjustEntry struct {
newTotal int64 newTotal int64
} }
// bulkFlowClear is the directive that strips the XTLS flow from every selected
// client. The vision values are the only positive flows xray accepts.
const bulkFlowClear = "none"
// bulkFlowAllowed whitelists the flow directives BulkAdjust accepts. Anything
// outside this set is treated as "" (leave flow untouched) so a malformed or
// hostile value can never be injected into a client's settings. The dropdown in
// ClientBulkAdjustModal.tsx offers the same set ("" / "none" / TLS_FLOW_CONTROL);
// keep the two in sync.
var bulkFlowAllowed = map[string]struct{}{
"": {},
bulkFlowClear: {},
"xtls-rprx-vision": {},
"xtls-rprx-vision-udp443": {},
}
// BulkAdjust shifts ExpiryTime by addDays (days) and TotalGB by addBytes // BulkAdjust shifts ExpiryTime by addDays (days) and TotalGB by addBytes
// for every email in the list. Clients whose corresponding field is // for every email in the list. Clients whose corresponding field is
// unlimited (0) are skipped — bulk extend should not accidentally // unlimited (0) are skipped — bulk extend should not accidentally
@@ -250,12 +266,17 @@ type bulkAdjustEntry struct {
// Like BulkDelete, the work is grouped by inbound so each inbound's // Like BulkDelete, the work is grouped by inbound so each inbound's
// settings JSON is parsed and written exactly once regardless of how // settings JSON is parsed and written exactly once regardless of how
// many target emails it contains. // many target emails it contains.
func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string, addDays int, addBytes int64) (BulkAdjustResult, bool, error) { func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string, addDays int, addBytes int64, flow string) (BulkAdjustResult, bool, error) {
result := BulkAdjustResult{} result := BulkAdjustResult{}
if len(emails) == 0 { if len(emails) == 0 {
return result, false, nil return result, false, nil
} }
if addDays == 0 && addBytes == 0 { flow = strings.TrimSpace(flow)
if _, ok := bulkFlowAllowed[flow]; !ok {
flow = "" // ignore unknown directives — "" means "leave flow untouched"
}
adjustFlow := flow != ""
if addDays == 0 && addBytes == 0 && !adjustFlow {
return result, false, common.NewError("no adjustment specified") return result, false, common.NewError("no adjustment specified")
} }
@@ -342,7 +363,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
entry.newTotal = next entry.newTotal = next
} }
} }
if entry.applyExpiry || entry.applyTotal { if entry.applyExpiry || entry.applyTotal || adjustFlow {
plan[email] = entry plan[email] = entry
} }
} }
@@ -379,11 +400,19 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
} }
needRestart := false needRestart := false
flowHonored := map[string]bool{}
flowIneligible := map[string]bool{}
for inboundId, ibEmails := range emailsByInbound { for inboundId, ibEmails := range emailsByInbound {
ibRes := s.bulkAdjustInboundClients(inboundSvc, inboundId, ibEmails, plan) ibRes := s.bulkAdjustInboundClients(inboundSvc, inboundId, ibEmails, plan, flow)
if ibRes.needRestart { if ibRes.needRestart {
needRestart = true needRestart = true
} }
for email := range ibRes.flowHonored {
flowHonored[email] = true
}
for email := range ibRes.flowIneligible {
flowIneligible[email] = true
}
for email, reason := range ibRes.perEmailSkipped { for email, reason := range ibRes.perEmailSkipped {
if _, already := skippedReasons[email]; !already { if _, already := skippedReasons[email]; !already {
skippedReasons[email] = reason skippedReasons[email] = reason
@@ -391,6 +420,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
} }
} }
adjusted := map[string]struct{}{}
for email, entry := range plan { for email, entry := range plan {
if _, skipped := skippedReasons[email]; skipped { if _, skipped := skippedReasons[email]; skipped {
continue continue
@@ -402,27 +432,49 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
if entry.applyTotal { if entry.applyTotal {
updates["total"] = entry.newTotal updates["total"] = entry.newTotal
} }
if len(updates) == 0 { if len(updates) > 0 {
continue if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Updates(updates).Error; err != nil {
} if _, already := skippedReasons[email]; !already {
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Updates(updates).Error; err != nil { skippedReasons[email] = err.Error()
if _, already := skippedReasons[email]; !already { }
skippedReasons[email] = err.Error() continue
} }
continue
} }
result.Adjusted++ // Counted when expiry/total changed, or a flow directive was honored
// for this client (flow lives in the inbound JSON, not ClientTraffic).
if len(updates) > 0 || flowHonored[email] {
adjusted[email] = struct{}{}
}
} }
result.Adjusted = len(adjusted)
for email, reason := range skippedReasons { for email, reason := range skippedReasons {
result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: reason}) result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: reason})
} }
// Report a flow directive that no inbound could carry — only when it was not
// honored anywhere and the client has no other (expiry/total) skip reason.
// The expiry/total part, if any, has already been applied and counted above.
for email := range flowIneligible {
if flowHonored[email] {
continue
}
if _, already := skippedReasons[email]; already {
continue
}
result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: "flow not supported on inbound"})
}
return result, needRestart, nil return result, needRestart, nil
} }
type bulkInboundAdjustResult struct { type bulkInboundAdjustResult struct {
perEmailSkipped map[string]string perEmailSkipped map[string]string
needRestart bool flowHonored map[string]bool
// flowIneligible is tracked apart from perEmailSkipped: a flow directive
// that an inbound cannot carry must not suppress the expiry/total write for
// the same client (which would diverge the inbound JSON / ClientRecord from
// ClientTraffic). It only feeds the final Skipped report.
flowIneligible map[string]bool
needRestart bool
} }
// bulkAdjustInboundClients applies expiry/total deltas to multiple clients // bulkAdjustInboundClients applies expiry/total deltas to multiple clients
@@ -436,8 +488,9 @@ func (s *ClientService) bulkAdjustInboundClients(
inboundId int, inboundId int,
emails []string, emails []string,
plan map[string]*bulkAdjustEntry, plan map[string]*bulkAdjustEntry,
flow string,
) bulkInboundAdjustResult { ) bulkInboundAdjustResult {
res := bulkInboundAdjustResult{perEmailSkipped: map[string]string{}} res := bulkInboundAdjustResult{perEmailSkipped: map[string]string{}, flowHonored: map[string]bool{}, flowIneligible: map[string]bool{}}
defer lockInbound(inboundId).Unlock() defer lockInbound(inboundId).Unlock()
@@ -469,8 +522,15 @@ func (s *ClientService) bulkAdjustInboundClients(
wantedEmails[email] = struct{}{} wantedEmails[email] = struct{}{}
} }
// Flow eligibility is a property of the inbound (protocol + transport), so
// resolve it once. Clearing flow is always allowed; setting a vision flow
// is only honored on an inbound that can carry it.
flowEligible := flow == bulkFlowClear ||
inboundCanEnableTlsFlow(string(oldInbound.Protocol), oldInbound.StreamSettings, oldInbound.Settings)
interfaceClients, _ := settings["clients"].([]any) interfaceClients, _ := settings["clients"].([]any)
foundEmails := map[string]bool{} foundEmails := map[string]bool{}
flowChanged := false
nowMs := time.Now().Unix() * 1000 nowMs := time.Now().Unix() * 1000
for i, client := range interfaceClients { for i, client := range interfaceClients {
c, ok := client.(map[string]any) c, ok := client.(map[string]any)
@@ -488,6 +548,23 @@ func (s *ClientService) bulkAdjustInboundClients(
if entry.applyTotal { if entry.applyTotal {
c["totalGB"] = entry.newTotal c["totalGB"] = entry.newTotal
} }
if flow != "" {
if flowEligible {
want := ""
if flow != bulkFlowClear {
want = flow
}
if cur, _ := c["flow"].(string); cur != want {
c["flow"] = want
flowChanged = true
}
res.flowHonored[targetEmail] = true
} else {
// Record separately so this never suppresses the expiry/total
// write for the same client (see flowIneligible doc).
res.flowIneligible[targetEmail] = true
}
}
c["updated_at"] = nowMs c["updated_at"] = nowMs
interfaceClients[i] = c interfaceClients[i] = c
foundEmails[targetEmail] = true foundEmails[targetEmail] = true
@@ -513,6 +590,13 @@ func (s *ClientService) bulkAdjustInboundClients(
} }
oldInbound.Settings = string(newSettings) oldInbound.Settings = string(newSettings)
// A flow change rewrites the user's xray config, which the lightweight
// UpdateUser push below does not carry. Local nodes reload via restart;
// remote nodes get a full reconcile (MarkNodeDirty) instead of a per-user push.
if flowChanged && oldInbound.NodeID == nil {
res.needRestart = true
}
markDirty := false markDirty := false
if oldInbound.NodeID != nil { if oldInbound.NodeID != nil {
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound) rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
@@ -525,6 +609,10 @@ func (s *ClientService) bulkAdjustInboundClients(
if dirty { if dirty {
markDirty = true markDirty = true
} }
if flowChanged {
markDirty = true
push = false
}
// Large batches collapse into one reconcile push rather than M updates. // Large batches collapse into one reconcile push rather than M updates.
if push && len(foundEmails) > nodeBulkPushThreshold { if push && len(foundEmails) > nodeBulkPushThreshold {
markDirty = true markDirty = true
@@ -0,0 +1,217 @@
package service
import (
"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/xray"
)
// mkInboundStream is mkInbound with explicit stream settings, needed to make an
// inbound flow-eligible (VLESS + tcp + reality/tls).
func mkInboundStream(t *testing.T, port int, proto model.Protocol, settings, stream string) *model.Inbound {
t.Helper()
ib := &model.Inbound{
Tag: string(proto) + "-stream-" + emailSafe(port),
Enable: true,
Port: port,
Protocol: proto,
Settings: settings,
StreamSettings: stream,
}
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound %d: %v", port, err)
}
return ib
}
func emailSafe(port int) string {
return string(rune('a'+port%26)) + string(rune('a'+(port/26)%26))
}
func flowOf(t *testing.T, svc *ClientService, email string) string {
t.Helper()
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail(%q): %v", email, err)
}
return rec.Flow
}
const realityStream = `{"network":"tcp","security":"reality"}`
const wsStream = `{"network":"ws","security":"none"}`
// TestBulkAdjust_FlowSetAndClear covers the happy path: a vision flow is applied
// on an eligible VLESS inbound and later cleared with the "none" directive. Both
// transitions are real config changes, so they must request a restart.
func TestBulkAdjust_FlowSetAndClear(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
clients := []model.Client{
{Email: "f1@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "f1", Enable: true},
{Email: "f2@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "f2", Enable: true},
}
ib := mkInboundStream(t, 30001, model.VLESS, clientsSettings(t, clients), realityStream)
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed: %v", err)
}
emails := emailsOf(clients)
// Set vision flow.
res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "xtls-rprx-vision-udp443")
if err != nil {
t.Fatalf("BulkAdjust set: %v", err)
}
if res.Adjusted != 2 {
t.Fatalf("expected 2 adjusted, got %d (skipped=%v)", res.Adjusted, res.Skipped)
}
if !restart {
t.Fatalf("setting flow should request a restart")
}
for _, e := range emails {
if got := flowOf(t, svc, e); got != "xtls-rprx-vision-udp443" {
t.Fatalf("%s flow = %q, want xtls-rprx-vision-udp443", e, got)
}
}
// Setting the same flow again is a no-op: honored (counted) but no restart.
if _, restart2, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "xtls-rprx-vision-udp443"); err != nil {
t.Fatalf("BulkAdjust idempotent: %v", err)
} else if restart2 {
t.Fatalf("re-setting identical flow should not request a restart")
}
// Clear flow.
cres, crestart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "none")
if err != nil {
t.Fatalf("BulkAdjust clear: %v", err)
}
if cres.Adjusted != 2 {
t.Fatalf("expected 2 cleared, got %d (skipped=%v)", cres.Adjusted, cres.Skipped)
}
if !crestart {
t.Fatalf("clearing flow should request a restart")
}
for _, e := range emails {
if got := flowOf(t, svc, e); got != "" {
t.Fatalf("%s flow = %q, want empty after clear", e, got)
}
}
}
// TestBulkAdjust_FlowIneligibleSkipped verifies a vision flow is refused on an
// inbound that cannot carry it (ws transport), reported as skipped, and the
// client's flow is left untouched.
func TestBulkAdjust_FlowIneligibleSkipped(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
clients := []model.Client{
{Email: "ws1@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "ws1", Enable: true},
}
ib := mkInboundStream(t, 30101, model.VLESS, clientsSettings(t, clients), wsStream)
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed: %v", err)
}
res, restart, err := svc.BulkAdjust(inboundSvc, []string{"ws1@x"}, 0, 0, "xtls-rprx-vision")
if err != nil {
t.Fatalf("BulkAdjust: %v", err)
}
if res.Adjusted != 0 {
t.Fatalf("ineligible inbound should adjust nothing, got %d", res.Adjusted)
}
if restart {
t.Fatalf("no change should not request a restart")
}
if len(res.Skipped) != 1 || res.Skipped[0].Email != "ws1@x" {
t.Fatalf("expected ws1@x in skipped, got %v", res.Skipped)
}
if got := flowOf(t, svc, "ws1@x"); got != "" {
t.Fatalf("flow should stay empty on ineligible inbound, got %q", got)
}
}
// TestBulkAdjust_NoDirectiveErrors guards the relaxed precondition: with no
// days, traffic, or flow set there is nothing to do.
func TestBulkAdjust_NoDirectiveErrors(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
if _, _, err := svc.BulkAdjust(inboundSvc, []string{"any@x"}, 0, 0, ""); err == nil {
t.Fatalf("expected error when no adjustment is specified")
}
// An unknown flow directive is ignored (treated as ""), so it also errors.
if _, _, err := svc.BulkAdjust(inboundSvc, []string{"any@x"}, 0, 0, "bogus-flow"); err == nil {
t.Fatalf("unknown flow should be ignored and error like an empty directive")
}
}
// TestBulkAdjust_DaysApplyDespiteIneligibleFlow is the regression for the review
// blocker: when a client on a flow-ineligible inbound is adjusted with BOTH a
// days/traffic delta AND a flow directive, the days/traffic change must still be
// persisted to ClientTraffic (not just the inbound JSON / ClientRecord) and the
// client must count as adjusted, while the unhonored flow is reported separately.
func TestBulkAdjust_DaysApplyDespiteIneligibleFlow(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
const day = int64(24 * 60 * 60 * 1000)
const gb = int64(1) << 30
baseExpiry := time.Now().UnixMilli() + 30*day
baseTotal := 10 * gb
clients := []model.Client{
{Email: "mix@x", ID: "44444444-4444-4444-4444-444444444444", SubID: "mix", Enable: true, ExpiryTime: baseExpiry, TotalGB: baseTotal},
}
ib := mkInboundStream(t, 30201, model.VLESS, clientsSettings(t, clients), wsStream)
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed: %v", err)
}
// ClientTraffic is the store the enforcement job reads; seed it to match.
if err := database.GetDB().Create(&xray.ClientTraffic{Email: "mix@x", Enable: true, ExpiryTime: baseExpiry, Total: baseTotal}).Error; err != nil {
t.Fatalf("seed traffic: %v", err)
}
res, _, err := svc.BulkAdjust(inboundSvc, []string{"mix@x"}, 7, gb, "xtls-rprx-vision")
if err != nil {
t.Fatalf("BulkAdjust: %v", err)
}
if res.Adjusted != 1 {
t.Fatalf("days/traffic should still be applied: Adjusted=%d skipped=%v", res.Adjusted, res.Skipped)
}
if len(res.Skipped) != 1 || res.Skipped[0].Email != "mix@x" {
t.Fatalf("expected mix@x reported for the unhonored flow, got %v", res.Skipped)
}
wantExpiry := baseExpiry + 7*day
wantTotal := baseTotal + gb
// ClientRecord (inbound-derived) advanced.
if rec, err := svc.GetRecordByEmail(nil, "mix@x"); err != nil {
t.Fatalf("record: %v", err)
} else if rec.ExpiryTime != wantExpiry || rec.TotalGB != wantTotal {
t.Fatalf("ClientRecord not advanced: expiry=%d total=%d", rec.ExpiryTime, rec.TotalGB)
}
// ClientTraffic advanced in lockstep — no divergence.
var ct xray.ClientTraffic
if err := database.GetDB().Where("email = ?", "mix@x").First(&ct).Error; err != nil {
t.Fatalf("traffic row: %v", err)
}
if ct.ExpiryTime != wantExpiry || ct.Total != wantTotal {
t.Fatalf("ClientTraffic diverged: expiry=%d total=%d, want expiry=%d total=%d", ct.ExpiryTime, ct.Total, wantExpiry, wantTotal)
}
// Flow left untouched on the ineligible inbound.
if got := flowOf(t, svc, "mix@x"); got != "" {
t.Fatalf("flow should stay empty on ineligible inbound, got %q", got)
}
}
@@ -0,0 +1,64 @@
package service
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// EffectiveFlowsByEmails resolves intended flow for many clients in one batched
// query, taking the flow_override of the lowest inbound_id and skipping emails
// with no non-empty flow anywhere.
func TestEffectiveFlowsByEmails(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
const vision = "xtls-rprx-vision"
// vis@x: attached to inbound 20 (empty flow) and 10 (Vision) — lowest
// inbound_id (10) wins, so the empty override on 20 must not mask it.
// plain@x: only an empty flow_override anywhere — absent from the result.
mkClient := func(id int, email string) {
if err := db.Create(&model.ClientRecord{Id: id, Email: email, Enable: true}).Error; err != nil {
t.Fatalf("create client %s: %v", email, err)
}
}
mkLink := func(clientID, inboundID int, flow string) {
if err := db.Create(&model.ClientInbound{ClientId: clientID, InboundId: inboundID, FlowOverride: flow}).Error; err != nil {
t.Fatalf("link %d/%d: %v", clientID, inboundID, err)
}
}
mkClient(1, "vis@x")
mkClient(2, "plain@x")
mkLink(1, 20, "") // higher inbound_id, empty
mkLink(1, 10, vision) // lower inbound_id, Vision
mkLink(2, 30, "") // only empty override
cs := &ClientService{}
got, err := cs.EffectiveFlowsByEmails(nil, []string{"vis@x", "plain@x", "missing@x"})
if err != nil {
t.Fatalf("EffectiveFlowsByEmails: %v", err)
}
if got["vis@x"] != vision {
t.Errorf("vis@x = %q, want %q (lowest inbound_id flow_override)", got["vis@x"], vision)
}
if v, ok := got["plain@x"]; ok {
t.Errorf("plain@x present (%q); want absent (no non-empty flow anywhere)", v)
}
if v, ok := got["missing@x"]; ok {
t.Errorf("missing@x present (%q); want absent (unknown client)", v)
}
// Empty input is a no-op (no query).
if m, err := cs.EffectiveFlowsByEmails(nil, nil); err != nil || len(m) != 0 {
t.Errorf("empty input: got %v err %v, want empty map", m, err)
}
}
+38
View File
@@ -49,6 +49,44 @@ func (s *ClientService) EffectiveFlow(tx *gorm.DB, recordId int) (string, error)
return flows[0], nil return flows[0], nil
} }
// EffectiveFlowsByEmails resolves the intended flow (non-empty flow_override,
// lowest inbound_id first — same rule as EffectiveFlow) for many clients in one
// query, keyed by email. Emails absent from the result carry no flow anywhere.
// Batched so flow restoration on an inbound with many clients is O(1) queries
// instead of O(clients). Used to restore a stripped flow onto an inbound that
// has just become flow-eligible.
func (s *ClientService) EffectiveFlowsByEmails(tx *gorm.DB, emails []string) (map[string]string, error) {
if tx == nil {
tx = database.GetDB()
}
out := make(map[string]string, len(emails))
if len(emails) == 0 {
return out, nil
}
type row struct {
Email string
Flow string `gorm:"column:flow_override"`
}
for _, batch := range chunkStrings(emails, sqlInChunk) {
var rows []row
err := tx.Table("client_inbounds").
Select("clients.email AS email, client_inbounds.flow_override AS flow_override").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("clients.email IN ? AND client_inbounds.flow_override <> ?", batch, "").
Order("client_inbounds.inbound_id ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, r := range rows {
if _, seen := out[r.Email]; !seen { // ordered by inbound_id ASC → first = lowest
out[r.Email] = r.Flow
}
}
}
return out, nil
}
func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, error) { func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, error) {
if tx == nil { if tx == nil {
tx = database.GetDB() tx = database.GetDB()
+8
View File
@@ -1065,6 +1065,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
logger.Warning("Shadowsocks inbound", inbound.Id, "method change resized keys; regenerated mismatched client PSK(s)") logger.Warning("Shadowsocks inbound", inbound.Id, "method change resized keys; regenerated mismatched client PSK(s)")
} }
// Re-gate Vision flow now that the new stream/encryption is known: if this
// VLESS inbound just became flow-eligible (e.g. vlessenc was enabled on an
// XHTTP inbound), restore Vision for clients whose intended flow is Vision
// but was stripped while the inbound was ineligible.
if restored, changed := s.restoreVisionFlowForEligibleInbound(tx, inbound.Settings, inbound.StreamSettings, inbound.Protocol); changed {
inbound.Settings = restored
}
oldInbound.Total = inbound.Total oldInbound.Total = inbound.Total
oldInbound.Remark = inbound.Remark oldInbound.Remark = inbound.Remark
oldInbound.SubSortIndex = inbound.SubSortIndex oldInbound.SubSortIndex = inbound.SubSortIndex
@@ -0,0 +1,90 @@
package service
import (
"encoding/json"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"gorm.io/gorm"
)
const visionFlow = "xtls-rprx-vision"
// restoreVisionFlowForEligibleInbound re-adds the XTLS Vision flow to a VLESS
// inbound's clients that lost it earlier.
//
// clientWithInboundFlow strips Vision from a client whenever the target inbound
// is not flow-eligible at write time (e.g. an XHTTP inbound before its vlessenc
// encryption is set). Nothing restored the flow when the inbound later became
// eligible — an inbound edit stores its settings verbatim and never re-gates the
// clients — so enabling encryption on an existing XHTTP inbound left every
// client without flow, and the share links/subscriptions dropped it.
//
// This runs on the now-final inbound settings: when the inbound IS flow-eligible
// it sets flow=Vision on each client that currently has no flow but whose
// intended flow (its flow_override on a sibling inbound, via EffectiveFlowsByEmails)
// is Vision. It never invents a flow for a client that has none anywhere, and it
// never overwrites an explicit non-empty flow. Returns the rewritten settings
// JSON and whether anything changed.
func (s *InboundService) restoreVisionFlowForEligibleInbound(tx *gorm.DB, settings, streamSettings string, protocol model.Protocol) (string, bool) {
if protocol != model.VLESS {
return settings, false
}
if !inboundCanEnableTlsFlow(string(protocol), streamSettings, settings) {
return settings, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return settings, false
}
clients, ok := parsed["clients"].([]any)
if !ok || len(clients) == 0 {
return settings, false
}
// Collect empty-flow clients, then resolve their intended flow in one query.
emails := make([]string, 0, len(clients))
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
if flow, _ := cm["flow"].(string); flow != "" {
continue // respect an explicit flow (Vision or otherwise)
}
if email, _ := cm["email"].(string); email != "" {
emails = append(emails, email)
}
}
if len(emails) == 0 {
return settings, false
}
intended, err := s.clientService.EffectiveFlowsByEmails(tx, emails)
if err != nil {
return settings, false
}
changed := false
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
if flow, _ := cm["flow"].(string); flow != "" {
continue
}
email, _ := cm["email"].(string)
if intended[email] != visionFlow {
continue
}
cm["flow"] = visionFlow
clients[i] = cm
changed = true
}
if !changed {
return settings, false
}
out, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(out), true
}
@@ -0,0 +1,96 @@
package service
import (
"encoding/json"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// restoreVisionFlowForEligibleInbound must re-add Vision to a client whose flow
// was stripped while the XHTTP inbound was not yet vlessenc-encrypted, but only
// when the client's intended flow (its flow_override on a sibling) is Vision,
// only on now-eligible inbounds, and never overwriting an explicit flow.
func TestRestoreVisionFlowForEligibleInbound(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
const vision = "xtls-rprx-vision"
const realityStream = `{"network":"tcp","security":"reality"}`
const xhttpEnc = `{"network":"xhttp","security":"reality"}`
const encSettings = `"decryption":"mlkem768x25519plus.native.0rtt.KEY","encryption":"mlkem768x25519plus.native.0rtt.KEY"`
cs := &ClientService{}
ibSvc := &InboundService{}
// Sibling reality inbound where the client legitimately has Vision.
sibling := &model.Inbound{
Tag: "sib", Enable: true, Port: 51001, Protocol: model.VLESS, StreamSettings: realityStream,
Settings: `{"clients":[{"id":"u1","email":"keep@x","flow":"` + vision + `","subId":"s1","enable":true}]}`,
}
if err := db.Create(sibling).Error; err != nil {
t.Fatalf("create sibling: %v", err)
}
keep, _ := ibSvc.GetClients(sibling)
if err := cs.SyncInbound(nil, sibling.Id, keep); err != nil {
t.Fatalf("sync sibling: %v", err)
}
// A client with no intended Vision anywhere — must NOT be touched.
other := &model.Inbound{
Tag: "oth", Enable: true, Port: 51002, Protocol: model.VLESS, StreamSettings: realityStream,
Settings: `{"clients":[{"id":"u2","email":"none@x","subId":"s2","enable":true}]}`,
}
if err := db.Create(other).Error; err != nil {
t.Fatalf("create other: %v", err)
}
oc, _ := ibSvc.GetClients(other)
if err := cs.SyncInbound(nil, other.Id, oc); err != nil {
t.Fatalf("sync other: %v", err)
}
// The now-eligible XHTTP inbound: keep@x has empty flow (was stripped),
// none@x has empty flow (no Vision anywhere), set@x has an explicit empty
// stays empty unless intended Vision.
target := `{` + encSettings + `,"clients":[` +
`{"id":"u1","email":"keep@x","flow":"","subId":"s1","enable":true},` +
`{"id":"u2","email":"none@x","flow":"","subId":"s2","enable":true}` +
`]}`
out, changed := ibSvc.restoreVisionFlowForEligibleInbound(nil, target, xhttpEnc, model.VLESS)
if !changed {
t.Fatal("expected changed=true")
}
var parsed map[string]any
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("parse out: %v", err)
}
flows := map[string]string{}
for _, c := range parsed["clients"].([]any) {
cm := c.(map[string]any)
flows[cm["email"].(string)], _ = cm["flow"].(string)
}
if flows["keep@x"] != vision {
t.Errorf("keep@x flow = %q, want Vision (intended on sibling)", flows["keep@x"])
}
if flows["none@x"] != "" {
t.Errorf("none@x flow = %q, want empty (no Vision intent)", flows["none@x"])
}
// Ineligible inbound (xhttp without encryption) must be a no-op.
noenc := `{"clients":[{"id":"u1","email":"keep@x","flow":"","subId":"s1","enable":true}]}`
if _, ch := ibSvc.restoreVisionFlowForEligibleInbound(nil, noenc, `{"network":"xhttp","security":"reality"}`, model.VLESS); ch {
t.Error("ineligible xhttp (no vlessenc) must not change")
}
// Non-VLESS must be a no-op.
if _, ch := ibSvc.restoreVisionFlowForEligibleInbound(nil, target, xhttpEnc, model.VMESS); ch {
t.Error("non-VLESS must not change")
}
}
+41
View File
@@ -253,4 +253,45 @@ func (s *InboundService) MigrationRequirements() {
func (s *InboundService) MigrateDB() { func (s *InboundService) MigrateDB() {
s.MigrationRequirements() s.MigrationRequirements()
s.MigrationRemoveOrphanedTraffics() s.MigrationRemoveOrphanedTraffics()
s.MigrationRestoreVisionFlow()
}
// MigrationRestoreVisionFlow repairs VLESS inbounds whose clients lost their
// XTLS Vision flow because the inbound was not flow-eligible when the client was
// written (e.g. an XHTTP inbound whose vlessenc encryption was enabled only
// later). For each now-eligible inbound it restores flow=xtls-rprx-vision on
// clients whose intended flow (their flow_override on a sibling inbound) is
// Vision. Idempotent: once a client carries the flow it is skipped, so this is a
// no-op on healthy installs and on subsequent boots.
func (s *InboundService) MigrationRestoreVisionFlow() {
db := database.GetDB()
var inbounds []*model.Inbound
if err := db.Model(&model.Inbound{}).
Where("protocol = ?", model.VLESS).
Find(&inbounds).Error; err != nil {
logger.Warning("MigrationRestoreVisionFlow: load inbounds failed:", err)
return
}
for _, ib := range inbounds {
restored, changed := s.restoreVisionFlowForEligibleInbound(nil, ib.Settings, ib.StreamSettings, ib.Protocol)
if !changed {
continue
}
clients, err := s.GetClients(&model.Inbound{Settings: restored})
if err != nil {
logger.Warning("MigrationRestoreVisionFlow: parse clients for inbound", ib.Id, "failed:", err)
continue
}
err = db.Transaction(func(tx *gorm.DB) error {
if e := tx.Model(&model.Inbound{}).Where("id = ?", ib.Id).Update("settings", restored).Error; e != nil {
return e
}
return s.clientService.SyncInbound(tx, ib.Id, clients)
})
if err != nil {
logger.Warning("MigrationRestoreVisionFlow: update inbound", ib.Id, "failed:", err)
continue
}
logger.Info("MigrationRestoreVisionFlow: restored XTLS Vision flow on inbound", ib.Id)
}
} }
+65 -53
View File
@@ -375,6 +375,53 @@ func getPublicIP(url string) string {
return ipString return ipString
} }
var publicIPv4Services = []string{
"https://api4.ipify.org",
"https://ipv4.icanhazip.com",
"https://v4.api.ipinfo.io/ip",
"https://ipv4.myexternalip.com/raw",
"https://4.ident.me",
"https://check-host.net/ip",
}
var publicIPv6Services = []string{
"https://api6.ipify.org",
"https://ipv6.icanhazip.com",
"https://v6.api.ipinfo.io/ip",
"https://ipv6.myexternalip.com/raw",
"https://6.ident.me",
}
// resolvePublicIPs caches the public IPv4/IPv6 addresses on first use. Guarded
// by s.mu because the bot's ServerService may call it from sendBackup while a
// status report runs concurrently.
func (s *ServerService) resolvePublicIPs() {
s.mu.Lock()
defer s.mu.Unlock()
if s.cachedIPv4 == "" {
for _, ip4Service := range publicIPv4Services {
s.cachedIPv4 = getPublicIP(ip4Service)
if s.cachedIPv4 != "N/A" {
break
}
}
}
if s.cachedIPv6 == "" && !s.noIPv6 {
for _, ip6Service := range publicIPv6Services {
s.cachedIPv6 = getPublicIP(ip6Service)
if s.cachedIPv6 != "N/A" {
break
}
}
}
if s.cachedIPv6 == "N/A" {
s.noIPv6 = true
}
}
func (s *ServerService) GetStatus(lastStatus *Status) *Status { func (s *ServerService) GetStatus(lastStatus *Status) *Status {
now := time.Now() now := time.Now()
status := &Status{ status := &Status{
@@ -536,45 +583,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
logger.Warning("get udp connections failed:", err) logger.Warning("get udp connections failed:", err)
} }
// IP fetching with caching s.resolvePublicIPs()
showIp4ServiceLists := []string{
"https://api4.ipify.org",
"https://ipv4.icanhazip.com",
"https://v4.api.ipinfo.io/ip",
"https://ipv4.myexternalip.com/raw",
"https://4.ident.me",
"https://check-host.net/ip",
}
showIp6ServiceLists := []string{
"https://api6.ipify.org",
"https://ipv6.icanhazip.com",
"https://v6.api.ipinfo.io/ip",
"https://ipv6.myexternalip.com/raw",
"https://6.ident.me",
}
if s.cachedIPv4 == "" {
for _, ip4Service := range showIp4ServiceLists {
s.cachedIPv4 = getPublicIP(ip4Service)
if s.cachedIPv4 != "N/A" {
break
}
}
}
if s.cachedIPv6 == "" && !s.noIPv6 {
for _, ip6Service := range showIp6ServiceLists {
s.cachedIPv6 = getPublicIP(ip6Service)
if s.cachedIPv6 != "N/A" {
break
}
}
}
if s.cachedIPv6 == "N/A" {
s.noIPv6 = true
}
status.PublicIP.IPv4 = s.cachedIPv4 status.PublicIP.IPv4 = s.cachedIPv4
status.PublicIP.IPv6 = s.cachedIPv6 status.PublicIP.IPv6 = s.cachedIPv6
@@ -1282,11 +1291,12 @@ func (s *ServerService) GetDb() ([]byte, error) {
// BackupFilename returns the filename for a database backup, named after the // BackupFilename returns the filename for a database backup, named after the
// panel's address so a downloaded or Telegram-sent backup identifies the server // panel's address so a downloaded or Telegram-sent backup identifies the server
// it came from. requestHost is the browser's address (the getDb handler passes // it came from. requestHost is the browser's address: the getDb handler passes
// c.Request.Host, matching the host shown in the panel title); it is preferred // c.Request.Host so a panel download is named after whatever address the user
// when present, otherwise the configured web domain and then the server's public // reached the panel with, no Listen Domain needed. The Telegram bot has no
// IP are used. The extension is .dump on PostgreSQL and .db on SQLite; the base // request and passes "", falling back to the configured Listen Domain (webDomain)
// falls back to "x-ui" when no address is known. // and then the public IP. The extension is .dump on PostgreSQL and .db on SQLite;
// the base falls back to "x-ui" when no address is known.
func (s *ServerService) BackupFilename(requestHost string) string { func (s *ServerService) BackupFilename(requestHost string) string {
ext := ".db" ext := ".db"
if database.IsPostgres() { if database.IsPostgres() {
@@ -1296,9 +1306,12 @@ func (s *ServerService) BackupFilename(requestHost string) string {
} }
// backupHost picks the address used to name backup files: the browser's request // backupHost picks the address used to name backup files: the browser's request
// host (port stripped, the same value the panel title shows) when available, // host (port stripped) when available, otherwise the configured Listen Domain
// otherwise the configured web domain and then the cached public IP (IPv4 before // (webDomain) and then the resolved public IP (IPv4 before IPv6), reduced to safe
// IPv6), reduced to safe filename characters. // filename characters. The public IP is resolved directly rather than read from
// LastStatus so callers whose ServerService never runs the status ticker —
// notably the Telegram bot — still get a real address instead of the "x-ui"
// fallback.
func (s *ServerService) backupHost(requestHost string) string { func (s *ServerService) backupHost(requestHost string) string {
host := extractHostname(strings.TrimSpace(requestHost)) host := extractHostname(strings.TrimSpace(requestHost))
if host == "" { if host == "" {
@@ -1307,12 +1320,11 @@ func (s *ServerService) backupHost(requestHost string) string {
} }
} }
if host == "" { if host == "" {
if st := s.LastStatus(); st != nil { s.resolvePublicIPs()
if ip := st.PublicIP.IPv4; ip != "" && ip != "N/A" { if ip := s.cachedIPv4; ip != "" && ip != "N/A" {
host = ip host = ip
} else if ip := st.PublicIP.IPv6; ip != "" && ip != "N/A" { } else if ip := s.cachedIPv6; ip != "" && ip != "N/A" {
host = ip host = ip
}
} }
} }
return sanitizeBackupHost(host) return sanitizeBackupHost(host)
+10
View File
@@ -78,6 +78,8 @@ var defaultValueMap = map[string]string{
"subEnableRouting": "false", "subEnableRouting": "false",
"subRoutingRules": "", "subRoutingRules": "",
"subHideSettings": "false", "subHideSettings": "false",
"subIncyEnableRouting": "false",
"subIncyRoutingRules": "",
"subListen": "", "subListen": "",
"subPort": "2096", "subPort": "2096",
"subPath": "/sub/", "subPath": "/sub/",
@@ -709,6 +711,14 @@ func (s *SettingService) GetSubHideSettings() (bool, error) {
return s.getBool("subHideSettings") return s.getBool("subHideSettings")
} }
func (s *SettingService) GetSubIncyEnableRouting() (bool, error) {
return s.getBool("subIncyEnableRouting")
}
func (s *SettingService) GetSubIncyRoutingRules() (string, error) {
return s.getString("subIncyRoutingRules")
}
func (s *SettingService) GetSubListen() (string, error) { func (s *SettingService) GetSubListen() (string, error) {
return s.getString("subListen") return s.getString("subListen")
} }
@@ -346,7 +346,7 @@ func TestBulkOpsPostgresScale(t *testing.T) {
} }
t0 := time.Now() t0 := time.Now()
if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30); err != nil { if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30, ""); err != nil {
t.Fatalf("BulkAdjust: %v", err) t.Fatalf("BulkAdjust: %v", err)
} }
adjustDur := time.Since(t0) adjustDur := time.Since(t0)
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)", "subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
"subHideSettings": "إخفاء إعدادات الخادم", "subHideSettings": "إخفاء إعدادات الخادم",
"subHideSettingsDesc": "إخفاء إمكانية عرض وتعديل إعدادات الخادم في عميل VPN. (فقط لـ Happ)", "subHideSettingsDesc": "إخفاء إمكانية عرض وتعديل إعدادات الخادم في عميل VPN. (فقط لـ Happ)",
"subIncyEnableRouting": "تفعيل التوجيه",
"subIncyEnableRoutingDesc": "حقن ملف تعريف التوجيه في محتوى الاشتراك لعميل Incy. (فقط لـ Incy)",
"subIncyRoutingRules": "قواعد التوجيه",
"subIncyRoutingRulesDesc": "رابط توجيه Incy المُضاف إلى محتوى الاشتراك، مثل incy://routing/onadd/<base64>. (فقط لـ Incy)",
"subClashEnableRouting": "تفعيل التوجيه", "subClashEnableRouting": "تفعيل التوجيه",
"subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.", "subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
"subClashRoutingRules": "قواعد التوجيه العامة", "subClashRoutingRules": "قواعد التوجيه العامة",
+8 -1
View File
@@ -831,9 +831,12 @@
"bulkDeleteConfirmContent": "Each selected client is removed from every attached inbound and its traffic record is dropped. This cannot be undone.", "bulkDeleteConfirmContent": "Each selected client is removed from every attached inbound and its traffic record is dropped. This cannot be undone.",
"bulkAdjustTitle": "Adjust {count} clients", "bulkAdjustTitle": "Adjust {count} clients",
"bulkAdjustHint": "Positive values extend, negative values reduce. Clients with unlimited expiry or traffic are skipped for that field.", "bulkAdjustHint": "Positive values extend, negative values reduce. Clients with unlimited expiry or traffic are skipped for that field.",
"bulkAdjustNothing": "Set days or traffic before applying.", "bulkAdjustNothing": "Set days, traffic, or flow before applying.",
"addDays": "Add days", "addDays": "Add days",
"addTrafficGB": "Add traffic (GB)", "addTrafficGB": "Add traffic (GB)",
"bulkFlow": "Set flow",
"bulkFlowNoChange": "No change",
"bulkFlowDisable": "Disable (clear flow)",
"delDepleted": "Delete depleted", "delDepleted": "Delete depleted",
"delDepletedConfirmTitle": "Delete depleted clients?", "delDepletedConfirmTitle": "Delete depleted clients?",
"delDepletedConfirmContent": "Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.", "delDepletedConfirmContent": "Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.",
@@ -1240,6 +1243,10 @@
"subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)", "subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)",
"subHideSettings": "Hide server settings", "subHideSettings": "Hide server settings",
"subHideSettingsDesc": "Hide the ability to view and edit server configurations in the VPN client. (Only for Happ)", "subHideSettingsDesc": "Hide the ability to view and edit server configurations in the VPN client. (Only for Happ)",
"subIncyEnableRouting": "Enable routing",
"subIncyEnableRoutingDesc": "Inject a routing profile into the subscription body for the Incy client. (Only for Incy)",
"subIncyRoutingRules": "Routing rules",
"subIncyRoutingRulesDesc": "Incy routing deep-link added to the subscription body, e.g. incy://routing/onadd/<base64>. (Only for Incy)",
"subClashEnableRouting": "Enable routing", "subClashEnableRouting": "Enable routing",
"subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.", "subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.",
"subClashRoutingRules": "Global routing rules", "subClashRoutingRules": "Global routing rules",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)", "subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)",
"subHideSettings": "Ocultar configuración del servidor", "subHideSettings": "Ocultar configuración del servidor",
"subHideSettingsDesc": "Ocultar la posibilidad de ver y editar las configuraciones del servidor en el cliente VPN. (Solo para Happ)", "subHideSettingsDesc": "Ocultar la posibilidad de ver y editar las configuraciones del servidor en el cliente VPN. (Solo para Happ)",
"subIncyEnableRouting": "Habilitar enrutamiento",
"subIncyEnableRoutingDesc": "Inyectar un perfil de enrutamiento en el cuerpo de la suscripción para el cliente Incy. (Solo para Incy)",
"subIncyRoutingRules": "Reglas de enrutamiento",
"subIncyRoutingRulesDesc": "Enlace de enrutamiento de Incy añadido al cuerpo de la suscripción, p. ej. incy://routing/onadd/<base64>. (Solo para Incy)",
"subClashEnableRouting": "Habilitar enrutamiento", "subClashEnableRouting": "Habilitar enrutamiento",
"subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.", "subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.",
"subClashRoutingRules": "Reglas globales de enrutamiento", "subClashRoutingRules": "Reglas globales de enrutamiento",
+4
View File
@@ -1132,6 +1132,10 @@
"subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)", "subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
"subHideSettings": "پنهان کردن تنظیمات سرور", "subHideSettings": "پنهان کردن تنظیمات سرور",
"subHideSettingsDesc": "پنهان کردن توانایی مشاهده و ویرایش پیکربندی سرور در کلاینت VPN. (فقط برای Happ)", "subHideSettingsDesc": "پنهان کردن توانایی مشاهده و ویرایش پیکربندی سرور در کلاینت VPN. (فقط برای Happ)",
"subIncyEnableRouting": "فعال‌سازی مسیریابی",
"subIncyEnableRoutingDesc": "تزریق پروفایل مسیریابی به بدنه اشتراک برای کلاینت Incy. (فقط برای Incy)",
"subIncyRoutingRules": "قوانین مسیریابی",
"subIncyRoutingRulesDesc": "لینک مسیریابی Incy که به بدنه اشتراک افزوده می‌شود، مثلاً incy://routing/onadd/<base64>. (فقط برای Incy)",
"subClashEnableRouting": "فعال‌سازی مسیریابی", "subClashEnableRouting": "فعال‌سازی مسیریابی",
"subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراک‌های YAML تولیدشده وارد کن.", "subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراک‌های YAML تولیدشده وارد کن.",
"subClashRoutingRules": "قوانین مسیریابی سراسری", "subClashRoutingRules": "قوانین مسیریابی سراسری",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)", "subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)",
"subHideSettings": "Sembunyikan pengaturan server", "subHideSettings": "Sembunyikan pengaturan server",
"subHideSettingsDesc": "Menyembunyikan kemampuan untuk melihat dan mengedit konfigurasi server di klien VPN. (Hanya untuk Happ)", "subHideSettingsDesc": "Menyembunyikan kemampuan untuk melihat dan mengedit konfigurasi server di klien VPN. (Hanya untuk Happ)",
"subIncyEnableRouting": "Aktifkan perutean",
"subIncyEnableRoutingDesc": "Menyuntikkan profil perutean ke dalam body langganan untuk klien Incy. (Hanya untuk Incy)",
"subIncyRoutingRules": "Aturan routing",
"subIncyRoutingRulesDesc": "Tautan perutean Incy yang ditambahkan ke body langganan, mis. incy://routing/onadd/<base64>. (Hanya untuk Incy)",
"subClashEnableRouting": "Aktifkan routing", "subClashEnableRouting": "Aktifkan routing",
"subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.", "subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.",
"subClashRoutingRules": "Aturan routing global", "subClashRoutingRules": "Aturan routing global",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)", "subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
"subHideSettings": "サーバー設定を非表示", "subHideSettings": "サーバー設定を非表示",
"subHideSettingsDesc": "VPNクライアントでサーバー設定の表示・編集機能を非表示にします。(Happのみ)", "subHideSettingsDesc": "VPNクライアントでサーバー設定の表示・編集機能を非表示にします。(Happのみ)",
"subIncyEnableRouting": "ルーティングを有効化",
"subIncyEnableRoutingDesc": "Incyクライアント用に、サブスクリプション本文へルーティングプロファイルを挿入します。(Incyのみ)",
"subIncyRoutingRules": "ルーティングルール",
"subIncyRoutingRulesDesc": "サブスクリプション本文に追加するIncyルーティングのディープリンク。例: incy://routing/onadd/<base64>。(Incyのみ)",
"subClashEnableRouting": "ルーティングを有効化", "subClashEnableRouting": "ルーティングを有効化",
"subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。", "subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
"subClashRoutingRules": "グローバルルーティングルール", "subClashRoutingRules": "グローバルルーティングルール",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)", "subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)",
"subHideSettings": "Ocultar configurações do servidor", "subHideSettings": "Ocultar configurações do servidor",
"subHideSettingsDesc": "Ocultar a capacidade de visualizar e editar as configurações do servidor no cliente VPN. (Apenas para Happ)", "subHideSettingsDesc": "Ocultar a capacidade de visualizar e editar as configurações do servidor no cliente VPN. (Apenas para Happ)",
"subIncyEnableRouting": "Ativar roteamento",
"subIncyEnableRoutingDesc": "Injetar um perfil de roteamento no corpo da assinatura para o cliente Incy. (Apenas para Incy)",
"subIncyRoutingRules": "Regras de roteamento",
"subIncyRoutingRulesDesc": "Link de roteamento do Incy adicionado ao corpo da assinatura, ex. incy://routing/onadd/<base64>. (Apenas para Incy)",
"subClashEnableRouting": "Ativar roteamento", "subClashEnableRouting": "Ativar roteamento",
"subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.", "subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.",
"subClashRoutingRules": "Regras globais de roteamento", "subClashRoutingRules": "Regras globais de roteamento",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)", "subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
"subHideSettings": "Скрыть настройки сервера", "subHideSettings": "Скрыть настройки сервера",
"subHideSettingsDesc": "Скрыть возможность просмотра и редактирования конфигурации сервера в VPN-клиенте. (Только для Happ)", "subHideSettingsDesc": "Скрыть возможность просмотра и редактирования конфигурации сервера в VPN-клиенте. (Только для Happ)",
"subIncyEnableRouting": "Включить маршрутизацию",
"subIncyEnableRoutingDesc": "Внедрять профиль маршрутизации в тело подписки для клиента Incy. (Только для Incy)",
"subIncyRoutingRules": "Правила маршрутизации",
"subIncyRoutingRulesDesc": "Ссылка маршрутизации Incy, добавляемая в тело подписки, напр. incy://routing/onadd/<base64>. (Только для Incy)",
"subClashEnableRouting": "Включить маршрутизацию", "subClashEnableRouting": "Включить маршрутизацию",
"subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.", "subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
"subClashRoutingRules": "Глобальные правила маршрутизации", "subClashRoutingRules": "Глобальные правила маршрутизации",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)", "subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)",
"subHideSettings": "Sunucu ayarlarını gizle", "subHideSettings": "Sunucu ayarlarını gizle",
"subHideSettingsDesc": "VPN istemcisinde sunucu yapılandırmalarını görüntüleme ve düzenleme özelliğini gizleyin. (Yalnızca Happ için)", "subHideSettingsDesc": "VPN istemcisinde sunucu yapılandırmalarını görüntüleme ve düzenleme özelliğini gizleyin. (Yalnızca Happ için)",
"subIncyEnableRouting": "Yönlendirmeyi etkinleştir",
"subIncyEnableRoutingDesc": "Incy istemcisi için abonelik gövdesine bir yönlendirme profili ekleyin. (Yalnızca Incy için)",
"subIncyRoutingRules": "Yönlendirme kuralları",
"subIncyRoutingRulesDesc": "Abonelik gövdesine eklenen Incy yönlendirme bağlantısı, örn. incy://routing/onadd/<base64>. (Yalnızca Incy için)",
"subClashEnableRouting": "Yönlendirmeyi Etkinleştir", "subClashEnableRouting": "Yönlendirmeyi Etkinleştir",
"subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekler.", "subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekler.",
"subClashRoutingRules": "Genel Yönlendirme Kuralları", "subClashRoutingRules": "Genel Yönlendirme Kuralları",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)", "subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
"subHideSettings": "Приховати налаштування сервера", "subHideSettings": "Приховати налаштування сервера",
"subHideSettingsDesc": "Приховати можливість перегляду та редагування конфігурації сервера у VPN-клієнті. (Тільки для Happ)", "subHideSettingsDesc": "Приховати можливість перегляду та редагування конфігурації сервера у VPN-клієнті. (Тільки для Happ)",
"subIncyEnableRouting": "Увімкнути маршрутизацію",
"subIncyEnableRoutingDesc": "Вставляти профіль маршрутизації в тіло підписки для клієнта Incy. (Тільки для Incy)",
"subIncyRoutingRules": "Правила маршрутизації",
"subIncyRoutingRulesDesc": "Посилання маршрутизації Incy, що додається в тіло підписки, напр. incy://routing/onadd/<base64>. (Тільки для Incy)",
"subClashEnableRouting": "Увімкнути маршрутизацію", "subClashEnableRouting": "Увімкнути маршрутизацію",
"subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.", "subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
"subClashRoutingRules": "Глобальні правила маршрутизації", "subClashRoutingRules": "Глобальні правила маршрутизації",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)", "subRoutingRulesDesc": "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)",
"subHideSettings": "Ẩn cài đặt máy chủ", "subHideSettings": "Ẩn cài đặt máy chủ",
"subHideSettingsDesc": "Ẩn khả năng xem và chỉnh sửa cấu hình máy chủ trong ứng dụng khách VPN. (Chỉ dành cho Happ)", "subHideSettingsDesc": "Ẩn khả năng xem và chỉnh sửa cấu hình máy chủ trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
"subIncyEnableRouting": "Bật định tuyến",
"subIncyEnableRoutingDesc": "Chèn hồ sơ định tuyến vào nội dung đăng ký cho ứng dụng Incy. (Chỉ dành cho Incy)",
"subIncyRoutingRules": "Quy tắc định tuyến",
"subIncyRoutingRulesDesc": "Liên kết định tuyến Incy được thêm vào nội dung đăng ký, ví dụ incy://routing/onadd/<base64>. (Chỉ dành cho Incy)",
"subClashEnableRouting": "Bật định tuyến", "subClashEnableRouting": "Bật định tuyến",
"subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.", "subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.",
"subClashRoutingRules": "Quy tắc định tuyến toàn cầu", "subClashRoutingRules": "Quy tắc định tuyến toàn cầu",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "VPN 用户端的全域路由规则。(仅限 Happ)", "subRoutingRulesDesc": "VPN 用户端的全域路由规则。(仅限 Happ)",
"subHideSettings": "隐藏服务器设置", "subHideSettings": "隐藏服务器设置",
"subHideSettingsDesc": "在 VPN 客户端中隐藏查看和编辑服务器配置的功能。(仅限 Happ)", "subHideSettingsDesc": "在 VPN 客户端中隐藏查看和编辑服务器配置的功能。(仅限 Happ)",
"subIncyEnableRouting": "启用路由",
"subIncyEnableRoutingDesc": "为 Incy 客户端将路由配置注入订阅内容中。(仅限 Incy)",
"subIncyRoutingRules": "路由规则",
"subIncyRoutingRulesDesc": "添加到订阅内容的 Incy 路由深层链接,例如 incy://routing/onadd/<base64>。(仅限 Incy)",
"subClashEnableRouting": "启用路由", "subClashEnableRouting": "启用路由",
"subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。", "subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
"subClashRoutingRules": "全局路由规则", "subClashRoutingRules": "全局路由规则",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)", "subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
"subHideSettings": "隱藏伺服器設定", "subHideSettings": "隱藏伺服器設定",
"subHideSettingsDesc": "在 VPN 用戶端中隱藏查看和編輯伺服器配置的功能。(僅限 Happ)", "subHideSettingsDesc": "在 VPN 用戶端中隱藏查看和編輯伺服器配置的功能。(僅限 Happ)",
"subIncyEnableRouting": "啟用路由",
"subIncyEnableRoutingDesc": "為 Incy 用戶端將路由設定檔注入訂閱內容中。(僅限 Incy)",
"subIncyRoutingRules": "路由規則",
"subIncyRoutingRulesDesc": "加入訂閱內容的 Incy 路由深層連結,例如 incy://routing/onadd/<base64>。(僅限 Incy)",
"subClashEnableRouting": "啟用路由", "subClashEnableRouting": "啟用路由",
"subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。", "subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
"subClashRoutingRules": "全域路由規則", "subClashRoutingRules": "全域路由規則",