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)",
"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": {
"description": "Enable JSON subscription endpoint",
"type": "boolean"
@@ -457,6 +465,8 @@
"subEnableRouting",
"subEncrypt",
"subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
@@ -727,6 +737,14 @@
"description": "Hide server settings in happ subscription (Only for Happ)",
"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": {
"description": "Enable JSON subscription endpoint",
"type": "boolean"
@@ -959,6 +977,8 @@
"subEnableRouting",
"subEncrypt",
"subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
@@ -5456,7 +5476,7 @@
"tags": [
"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",
"requestBody": {
"required": true,
@@ -5471,7 +5491,8 @@
"bob"
],
"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,
"subEncrypt": false,
"subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
@@ -152,6 +154,8 @@ export const EXAMPLES: Record<string, unknown> = {
"subEnableRouting": false,
"subEncrypt": false,
"subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
+20
View File
@@ -206,6 +206,14 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Hide server settings in happ subscription (Only for Happ)",
"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": {
"description": "Enable JSON subscription endpoint",
"type": "boolean"
@@ -431,6 +439,8 @@ export const SCHEMAS: Record<string, unknown> = {
"subEnableRouting",
"subEncrypt",
"subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
@@ -701,6 +711,14 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Hide server settings in happ subscription (Only for Happ)",
"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": {
"description": "Enable JSON subscription endpoint",
"type": "boolean"
@@ -933,6 +951,8 @@ export const SCHEMAS: Record<string, unknown> = {
"subEnableRouting",
"subEncrypt",
"subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
+4
View File
@@ -58,6 +58,8 @@ export interface AllSetting {
subEnableRouting: boolean;
subEncrypt: boolean;
subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonEnable: boolean;
subJsonFinalMask: string;
subJsonMux: string;
@@ -159,6 +161,8 @@ export interface AllSettingView {
subEnableRouting: boolean;
subEncrypt: boolean;
subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonEnable: boolean;
subJsonFinalMask: string;
subJsonMux: string;
+4
View File
@@ -70,6 +70,8 @@ export const AllSettingSchema = z.object({
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(),
subJsonMux: z.string(),
@@ -172,6 +174,8 @@ export const AllSettingViewSchema = z.object({
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(),
subJsonMux: z.string(),
+3 -3
View File
@@ -341,7 +341,7 @@ export function useClients() {
});
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);
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>);
return bulkCreateMut.mutateAsync(payloads);
}, [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);
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow });
}, [bulkAdjustMut]);
const bulkAddToGroup = useCallback((emails: string[], group: string) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
+31 -2
View File
@@ -8,6 +8,7 @@ import type {
DnsRuleForm,
FreedomFinalRuleForm,
FreedomOutboundFormSettings,
HttpOutboundFormSettings,
HysteriaOutboundFormSettings,
LoopbackOutboundFormSettings,
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 {
const secretKey = asString(raw.secretKey);
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 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; 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 'hysteria': typed = { protocol: 'hysteria', settings: hysteriaFromWire(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) {
return {
mtu: s.mtu || undefined,
@@ -629,7 +658,7 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
case 'trojan': settings = trojanToWire(values.settings); break;
case 'shadowsocks': settings = shadowsocksToWire(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 'hysteria': settings = hysteriaToWire(values.settings); break;
case 'freedom': settings = freedomToWire(values.settings); break;
+2
View File
@@ -35,6 +35,8 @@ export class AllSetting {
subAnnounce = '';
subEnableRouting = false;
subRoutingRules = '';
subIncyEnableRouting = false;
subIncyRoutingRules = '';
subListen = '';
subPort = 2096;
subPath = '/sub/';
+2 -2
View File
@@ -635,8 +635,8 @@ export const sections: readonly Section[] = [
{
method: 'POST',
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.',
body: '{\n "emails": ["alice", "bob"],\n "addDays": 30,\n "addBytes": 53687091200\n}',
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 "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}',
},
{
@@ -1,16 +1,19 @@
import { useEffect, useState } from 'react';
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 { TLS_FLOW_CONTROL } from '@/schemas/primitives/flow';
const GB = 1024 * 1024 * 1024;
const FLOW_CLEAR = 'none';
interface ClientBulkAdjustModalProps {
open: boolean;
count: number;
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) {
@@ -18,12 +21,14 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
const [messageApi, messageContextHolder] = message.useMessage();
const [addDays, setAddDays] = useState<number>(0);
const [addGB, setAddGB] = useState<number>(0);
const [flow, setFlow] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) {
setAddDays(0);
setAddGB(0);
setFlow('');
}
}, [open]);
@@ -31,16 +36,17 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
const validated = ClientBulkAdjustFormSchema.safeParse({
addDays: Math.trunc(Number(addDays) || 0),
addGB: Number(addGB) || 0,
flow,
});
if (!validated.success) {
messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
return;
}
const { addDays: days, addGB: gb } = validated.data;
const { addDays: days, addGB: gb, flow: flowValue } = validated.data;
setSubmitting(true);
try {
const bytes = Math.trunc(gb * GB);
const result = await onSubmit(days, bytes);
const result = await onSubmit(days, bytes, flowValue);
if (!result) return;
const ok = result.adjusted ?? 0;
const skipped = result.skipped?.length ?? 0;
@@ -95,6 +101,18 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
step={1}
/>
</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>
</Modal>
</>
@@ -357,7 +357,7 @@ export default function ClientInfoModal({
const parts = parseLinkParts(link);
const fallback = `${t('pages.clients.link')} ${idx + 1}`;
const rowTitle = (parts && linkMetaText(parts)) || fallback;
const qrRemark = [parts?.remark, client.email].filter(Boolean).join('-') || rowTitle;
const qrRemark = parts?.remark || rowTitle;
const canQr = !isPostQuantumLink(link);
return (
<div key={idx} className="link-row">
+2 -2
View File
@@ -1418,8 +1418,8 @@ export default function ClientsPage() {
open={bulkAdjustOpen}
count={selectedRowKeys.length}
onOpenChange={setBulkAdjustOpen}
onSubmit={async (addDays, addBytes) => {
const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes);
onSubmit={async (addDays, addBytes, flow) => {
const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes, flow);
if (msg?.success) {
setSelectedRowKeys([]);
return msg.obj ?? { adjusted: 0 };
@@ -1,5 +1,5 @@
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 type { AllSetting } from '@/models/setting';
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 happUrl = useMemo(() => `happ://add/${subUrl}`, []);
const incyUrl = useMemo(() => `incy://add/${subUrl}`, []);
const pageClass = useMemo(() => {
const classes = ['subscription-page'];
@@ -200,6 +201,7 @@ export default function SubPage() {
{ key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
{ key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
{ key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) },
{ key: 'android-incy', label: 'Incy', onClick: () => open(`incy://add/${subUrl}`) },
], [copy, open]);
const iosMenuItems = useMemo(() => [
@@ -209,7 +211,8 @@ export default function SubPage() {
{ key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
{ key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
{ 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(
() => (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map((l) => ({
@@ -1,6 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { HeaderMapEditor } from '@/components/form';
export default function HttpFields() {
const { t } = useTranslation();
return (
@@ -11,6 +13,9 @@ export default function HttpFields() {
<Form.Item label={t('password')} name={['settings', 'pass']}>
<Input />
</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({
addDays: z.number().int(),
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',
});
@@ -80,6 +80,7 @@ export const HttpOutboundFormSettingsSchema = z.object({
port: PortSchema.default(8080),
user: z.string().default(''),
pass: z.string().default(''),
headers: z.record(z.string(), z.string()).default({}),
});
export type HttpOutboundFormSettings = z.infer<typeof HttpOutboundFormSettingsSchema>;
@@ -11,7 +11,12 @@ export type TunnelNetwork = z.infer<typeof TunnelNetworkSchema>;
// with arr=false.
export const TunnelInboundSettingsSchema = z.object({
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({}),
allowedNetwork: TunnelNetworkSchema.default('tcp,udp'),
followRedirect: z.boolean().default(false),
@@ -21,5 +21,6 @@ export type HttpOutboundServer = z.infer<typeof HttpOutboundServerSchema>;
export const HttpOutboundSettingsSchema = z.object({
servers: z.array(HttpOutboundServerSchema).min(1),
headers: z.record(z.string(), z.string()).optional(),
});
export type HttpOutboundSettings = z.infer<typeof HttpOutboundSettingsSchema>;
+2
View File
@@ -39,6 +39,8 @@ export const AllSettingSchema = z.object({
subAnnounce: z.string().optional(),
subEnableRouting: z.boolean().optional(),
subRoutingRules: z.string().optional(),
subIncyEnableRouting: z.boolean().optional(),
subIncyRoutingRules: z.string().optional(),
subListen: z.string().optional(),
subPort: port.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', () => {
const wire = {
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{}
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" +
"vless://11111111-2222-4333-8444-555555555555@cdn2.example.com:80?encryption=none&security=none&type=tcp#char-R2"
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-user"
if 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{}
got := s.genVmessLink(in, "user")
want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMSIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogInNuaTEuZXhhbXBsZS5jb20iLAogICJ0bHMiOiAidGxzIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9\n" +
"vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMiIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMS11c2VyIiwKICAic2N5IjogImF1dG8iLAogICJzbmkiOiAic25pMS5leGFtcGxlLmNvbSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
"vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMi11c2VyIiwKICAic2N5IjogImF1dG8iLAogICJ0bHMiOiAibm9uZSIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ=="
if 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{}
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 {
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{}
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 {
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
subRoutingRules string
subHideSettings bool
subPath string
subJsonPath string
subClashPath string
jsonEnabled bool
clashEnabled bool
subEncrypt bool
updateInterval string
subIncyEnableRouting bool
subIncyRoutingRules string
subPath string
subJsonPath string
subClashPath string
jsonEnabled bool
clashEnabled bool
subEncrypt bool
updateInterval string
subService *SubService
subJsonService *SubJsonService
@@ -89,6 +93,8 @@ func NewSUBController(
subEnableRouting bool,
subRoutingRules string,
subHideSettings bool,
subIncyEnableRouting bool,
subIncyRoutingRules string,
) *SUBController {
sub := NewSubService(remarkTemplate)
a := &SUBController{
@@ -99,13 +105,17 @@ func NewSUBController(
subEnableRouting: subEnableRouting,
subRoutingRules: subRoutingRules,
subHideSettings: subHideSettings,
subPath: subPath,
subJsonPath: jsonPath,
subClashPath: clashPath,
jsonEnabled: jsonEnabled,
clashEnabled: clashEnabled,
subEncrypt: encrypt,
updateInterval: update,
subIncyEnableRouting: subIncyEnableRouting,
subIncyRoutingRules: subIncyRoutingRules,
subPath: subPath,
subJsonPath: jsonPath,
subClashPath: clashPath,
jsonEnabled: jsonEnabled,
clashEnabled: clashEnabled,
subEncrypt: encrypt,
updateInterval: update,
subService: 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)
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
result.WriteString(a.subIncyRoutingRules)
result.WriteString("\n")
}
if a.subEncrypt {
c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String())))
} 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(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" +
"vless://uid@b.example.com:80?security=none&type=tcp#ib-B"
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-user"
if 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"}),
}
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEiLAogICJzY3kiOiAiYXV0byIsCiAgInNuaSI6ICJhLnNuaSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9"
want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEtdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogImEuc25pIiwKICAidGxzIjogInRscyIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ==\n" +
"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUItdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
if 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()
}
// genHostRemark builds one host endpoint's remark for a specific client. The
// config name is always the inbound's own remark; the host's remark is surfaced
// only through the {{HOST}} token. In the subscription body the rest of the
// remark template still applies; displays show just the config name.
// genHostRemark builds one host endpoint's remark for a specific client. In the
// subscription body the {{HOST}} token carries the host's remark and the rest of
// the template still applies; displays show the config name, host and email.
func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
if !s.subscriptionBody {
return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
name := remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
return fallbackRemark(name, hostRemark, client.Email)
}
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_NoSuffix(t *testing.T) {
func TestGenRemark_NoTemplate_AppendsEmail(t *testing.T) {
s, inbound, _ := hostRemarkService("")
got := s.genRemark(inbound, "john@example.com", "Relay", "")
if got != "DE-Relay" {
t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay")
if got != "DE-Relay-john@example.com" {
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) {
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
s.subscriptionBody = false
// A host link in a display shows only the config name — the inbound's remark,
// with no per-client email or usage info and the host remark ignored.
if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE" {
t.Fatalf("display host link = %q, want config name %q", got, "DE")
if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE-CDN-john@example.com" {
t.Fatalf("display host link = %q, want %q", got, "DE-CDN-john@example.com")
}
// With no host remark, the config name is likewise the inbound's own remark.
if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
if got := s.genHostRemark(inbound, client, "", ""); got != "DE-john@example.com" {
t.Fatalf("display host link (no host) = %q, want %q", got, "DE-john@example.com")
}
// genRemark (non-host) likewise drops the template in display context.
if got := s.genRemark(inbound, client.Email, "", ""); got != "DE" {
t.Fatalf("display genRemark = %q, want %q", got, "DE")
if got := s.genRemark(inbound, client.Email, "", ""); got != "DE-john@example.com" {
t.Fatalf("display genRemark = %q, want %q", got, "DE-john@example.com")
}
}
+8 -15
View File
@@ -1641,24 +1641,17 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
if s.remarkTemplate != "" && s.subscriptionBody {
return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport)
}
// Sub info page + panel link/QR displays: just the config name (no template,
// so no per-client email/usage leaks into the shown remark).
return fallbackRemark(inbound.Remark, extra)
return fallbackRemark(inbound.Remark, extra, email)
}
// fallbackRemark is the minimal remark used only when no template is configured
// (an operator explicitly cleared it): the inbound remark and the host/extra
// remark joined by "-", skipping empties. The configurable remark model was
// removed in favour of the template, whose default already includes the email.
func fallbackRemark(inboundRemark, extra string) string {
switch {
case inboundRemark == "":
return extra
case extra == "":
return inboundRemark
default:
return inboundRemark + "-" + extra
func fallbackRemark(parts ...string) string {
out := make([]string, 0, len(parts))
for _, p := range parts {
if p != "" {
out = append(out, p)
}
}
return strings.Join(out, "-")
}
// 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
}
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
engine.Use(locale.LocalizerMiddleware())
@@ -232,7 +242,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.sub = NewSUBController(
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, RemarkTemplate, SubUpdates,
SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules, SubHideSettings)
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules, SubHideSettings,
SubIncyEnableRouting, SubIncyRoutingRules)
return engine, nil
}
+2 -1
View File
@@ -248,6 +248,7 @@ type bulkAdjustRequest struct {
Emails []string `json:"emails"`
AddDays int `json:"addDays"`
AddBytes int64 `json:"addBytes"`
Flow string `json:"flow"`
}
func (a *ClientController) bulkAdjust(c *gin.Context) {
@@ -256,7 +257,7 @@ func (a *ClientController) bulkAdjust(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
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 {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
+2
View File
@@ -77,6 +77,8 @@ type AllSetting struct {
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"` // Subscription announce
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` // Enable routing for subscription
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
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
+102 -14
View File
@@ -242,6 +242,22 @@ type bulkAdjustEntry struct {
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
// for every email in the list. Clients whose corresponding field is
// 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
// settings JSON is parsed and written exactly once regardless of how
// 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{}
if len(emails) == 0 {
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")
}
@@ -342,7 +363,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
entry.newTotal = next
}
}
if entry.applyExpiry || entry.applyTotal {
if entry.applyExpiry || entry.applyTotal || adjustFlow {
plan[email] = entry
}
}
@@ -379,11 +400,19 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
}
needRestart := false
flowHonored := map[string]bool{}
flowIneligible := map[string]bool{}
for inboundId, ibEmails := range emailsByInbound {
ibRes := s.bulkAdjustInboundClients(inboundSvc, inboundId, ibEmails, plan)
ibRes := s.bulkAdjustInboundClients(inboundSvc, inboundId, ibEmails, plan, flow)
if ibRes.needRestart {
needRestart = true
}
for email := range ibRes.flowHonored {
flowHonored[email] = true
}
for email := range ibRes.flowIneligible {
flowIneligible[email] = true
}
for email, reason := range ibRes.perEmailSkipped {
if _, already := skippedReasons[email]; !already {
skippedReasons[email] = reason
@@ -391,6 +420,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
}
}
adjusted := map[string]struct{}{}
for email, entry := range plan {
if _, skipped := skippedReasons[email]; skipped {
continue
@@ -402,27 +432,49 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
if entry.applyTotal {
updates["total"] = entry.newTotal
}
if len(updates) == 0 {
continue
}
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Updates(updates).Error; err != nil {
if _, already := skippedReasons[email]; !already {
skippedReasons[email] = err.Error()
if len(updates) > 0 {
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Updates(updates).Error; err != nil {
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 {
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
}
type bulkInboundAdjustResult struct {
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
@@ -436,8 +488,9 @@ func (s *ClientService) bulkAdjustInboundClients(
inboundId int,
emails []string,
plan map[string]*bulkAdjustEntry,
flow string,
) 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()
@@ -469,8 +522,15 @@ func (s *ClientService) bulkAdjustInboundClients(
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)
foundEmails := map[string]bool{}
flowChanged := false
nowMs := time.Now().Unix() * 1000
for i, client := range interfaceClients {
c, ok := client.(map[string]any)
@@ -488,6 +548,23 @@ func (s *ClientService) bulkAdjustInboundClients(
if entry.applyTotal {
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
interfaceClients[i] = c
foundEmails[targetEmail] = true
@@ -513,6 +590,13 @@ func (s *ClientService) bulkAdjustInboundClients(
}
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
if oldInbound.NodeID != nil {
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
@@ -525,6 +609,10 @@ func (s *ClientService) bulkAdjustInboundClients(
if dirty {
markDirty = true
}
if flowChanged {
markDirty = true
push = false
}
// Large batches collapse into one reconcile push rather than M updates.
if push && len(foundEmails) > nodeBulkPushThreshold {
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
}
// 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) {
if tx == nil {
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)")
}
// 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.Remark = inbound.Remark
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() {
s.MigrationRequirements()
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
}
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 {
now := time.Now()
status := &Status{
@@ -536,45 +583,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
logger.Warning("get udp connections failed:", err)
}
// IP fetching with caching
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
}
s.resolvePublicIPs()
status.PublicIP.IPv4 = s.cachedIPv4
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
// 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
// c.Request.Host, matching the host shown in the panel title); it is preferred
// when present, otherwise the configured web domain and then the server's public
// IP are used. The extension is .dump on PostgreSQL and .db on SQLite; the base
// falls back to "x-ui" when no address is known.
// it came from. requestHost is the browser's address: the getDb handler passes
// c.Request.Host so a panel download is named after whatever address the user
// reached the panel with, no Listen Domain needed. The Telegram bot has no
// request and passes "", falling back to the configured Listen Domain (webDomain)
// 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 {
ext := ".db"
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
// host (port stripped, the same value the panel title shows) when available,
// otherwise the configured web domain and then the cached public IP (IPv4 before
// IPv6), reduced to safe filename characters.
// host (port stripped) when available, otherwise the configured Listen Domain
// (webDomain) and then the resolved public IP (IPv4 before IPv6), reduced to safe
// 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 {
host := extractHostname(strings.TrimSpace(requestHost))
if host == "" {
@@ -1307,12 +1320,11 @@ func (s *ServerService) backupHost(requestHost string) string {
}
}
if host == "" {
if st := s.LastStatus(); st != nil {
if ip := st.PublicIP.IPv4; ip != "" && ip != "N/A" {
host = ip
} else if ip := st.PublicIP.IPv6; ip != "" && ip != "N/A" {
host = ip
}
s.resolvePublicIPs()
if ip := s.cachedIPv4; ip != "" && ip != "N/A" {
host = ip
} else if ip := s.cachedIPv6; ip != "" && ip != "N/A" {
host = ip
}
}
return sanitizeBackupHost(host)
+10
View File
@@ -78,6 +78,8 @@ var defaultValueMap = map[string]string{
"subEnableRouting": "false",
"subRoutingRules": "",
"subHideSettings": "false",
"subIncyEnableRouting": "false",
"subIncyRoutingRules": "",
"subListen": "",
"subPort": "2096",
"subPath": "/sub/",
@@ -709,6 +711,14 @@ func (s *SettingService) GetSubHideSettings() (bool, error) {
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) {
return s.getString("subListen")
}
@@ -346,7 +346,7 @@ func TestBulkOpsPostgresScale(t *testing.T) {
}
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)
}
adjustDur := time.Since(t0)
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
"subHideSettings": "إخفاء إعدادات الخادم",
"subHideSettingsDesc": "إخفاء إمكانية عرض وتعديل إعدادات الخادم في عميل VPN. (فقط لـ Happ)",
"subIncyEnableRouting": "تفعيل التوجيه",
"subIncyEnableRoutingDesc": "حقن ملف تعريف التوجيه في محتوى الاشتراك لعميل Incy. (فقط لـ Incy)",
"subIncyRoutingRules": "قواعد التوجيه",
"subIncyRoutingRulesDesc": "رابط توجيه Incy المُضاف إلى محتوى الاشتراك، مثل incy://routing/onadd/<base64>. (فقط لـ Incy)",
"subClashEnableRouting": "تفعيل التوجيه",
"subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
"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.",
"bulkAdjustTitle": "Adjust {count} clients",
"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",
"addTrafficGB": "Add traffic (GB)",
"bulkFlow": "Set flow",
"bulkFlowNoChange": "No change",
"bulkFlowDisable": "Disable (clear flow)",
"delDepleted": "Delete depleted",
"delDepletedConfirmTitle": "Delete depleted clients?",
"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)",
"subHideSettings": "Hide server settings",
"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",
"subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.",
"subClashRoutingRules": "Global routing rules",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)",
"subHideSettings": "Ocultar configuración del servidor",
"subHideSettingsDesc": "Ocultar la posibilidad de ver y editar las configuraciones del servidor en el cliente VPN. (Solo para Happ)",
"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",
"subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.",
"subClashRoutingRules": "Reglas globales de enrutamiento",
+4
View File
@@ -1132,6 +1132,10 @@
"subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
"subHideSettings": "پنهان کردن تنظیمات سرور",
"subHideSettingsDesc": "پنهان کردن توانایی مشاهده و ویرایش پیکربندی سرور در کلاینت VPN. (فقط برای Happ)",
"subIncyEnableRouting": "فعال‌سازی مسیریابی",
"subIncyEnableRoutingDesc": "تزریق پروفایل مسیریابی به بدنه اشتراک برای کلاینت Incy. (فقط برای Incy)",
"subIncyRoutingRules": "قوانین مسیریابی",
"subIncyRoutingRulesDesc": "لینک مسیریابی Incy که به بدنه اشتراک افزوده می‌شود، مثلاً incy://routing/onadd/<base64>. (فقط برای Incy)",
"subClashEnableRouting": "فعال‌سازی مسیریابی",
"subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراک‌های YAML تولیدشده وارد کن.",
"subClashRoutingRules": "قوانین مسیریابی سراسری",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)",
"subHideSettings": "Sembunyikan pengaturan server",
"subHideSettingsDesc": "Menyembunyikan kemampuan untuk melihat dan mengedit konfigurasi server di klien VPN. (Hanya untuk Happ)",
"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",
"subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.",
"subClashRoutingRules": "Aturan routing global",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
"subHideSettings": "サーバー設定を非表示",
"subHideSettingsDesc": "VPNクライアントでサーバー設定の表示・編集機能を非表示にします。(Happのみ)",
"subIncyEnableRouting": "ルーティングを有効化",
"subIncyEnableRoutingDesc": "Incyクライアント用に、サブスクリプション本文へルーティングプロファイルを挿入します。(Incyのみ)",
"subIncyRoutingRules": "ルーティングルール",
"subIncyRoutingRulesDesc": "サブスクリプション本文に追加するIncyルーティングのディープリンク。例: incy://routing/onadd/<base64>。(Incyのみ)",
"subClashEnableRouting": "ルーティングを有効化",
"subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
"subClashRoutingRules": "グローバルルーティングルール",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)",
"subHideSettings": "Ocultar configurações do servidor",
"subHideSettingsDesc": "Ocultar a capacidade de visualizar e editar as configurações do servidor no cliente VPN. (Apenas para Happ)",
"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",
"subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.",
"subClashRoutingRules": "Regras globais de roteamento",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
"subHideSettings": "Скрыть настройки сервера",
"subHideSettingsDesc": "Скрыть возможность просмотра и редактирования конфигурации сервера в VPN-клиенте. (Только для Happ)",
"subIncyEnableRouting": "Включить маршрутизацию",
"subIncyEnableRoutingDesc": "Внедрять профиль маршрутизации в тело подписки для клиента Incy. (Только для Incy)",
"subIncyRoutingRules": "Правила маршрутизации",
"subIncyRoutingRulesDesc": "Ссылка маршрутизации Incy, добавляемая в тело подписки, напр. incy://routing/onadd/<base64>. (Только для Incy)",
"subClashEnableRouting": "Включить маршрутизацию",
"subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
"subClashRoutingRules": "Глобальные правила маршрутизации",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)",
"subHideSettings": "Sunucu ayarlarını gizle",
"subHideSettingsDesc": "VPN istemcisinde sunucu yapılandırmalarını görüntüleme ve düzenleme özelliğini gizleyin. (Yalnızca Happ için)",
"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",
"subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekler.",
"subClashRoutingRules": "Genel Yönlendirme Kuralları",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
"subHideSettings": "Приховати налаштування сервера",
"subHideSettingsDesc": "Приховати можливість перегляду та редагування конфігурації сервера у VPN-клієнті. (Тільки для Happ)",
"subIncyEnableRouting": "Увімкнути маршрутизацію",
"subIncyEnableRoutingDesc": "Вставляти профіль маршрутизації в тіло підписки для клієнта Incy. (Тільки для Incy)",
"subIncyRoutingRules": "Правила маршрутизації",
"subIncyRoutingRulesDesc": "Посилання маршрутизації Incy, що додається в тіло підписки, напр. incy://routing/onadd/<base64>. (Тільки для Incy)",
"subClashEnableRouting": "Увімкнути маршрутизацію",
"subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
"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)",
"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)",
"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",
"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",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "VPN 用户端的全域路由规则。(仅限 Happ)",
"subHideSettings": "隐藏服务器设置",
"subHideSettingsDesc": "在 VPN 客户端中隐藏查看和编辑服务器配置的功能。(仅限 Happ)",
"subIncyEnableRouting": "启用路由",
"subIncyEnableRoutingDesc": "为 Incy 客户端将路由配置注入订阅内容中。(仅限 Incy)",
"subIncyRoutingRules": "路由规则",
"subIncyRoutingRulesDesc": "添加到订阅内容的 Incy 路由深层链接,例如 incy://routing/onadd/<base64>。(仅限 Incy)",
"subClashEnableRouting": "启用路由",
"subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
"subClashRoutingRules": "全局路由规则",
+4
View File
@@ -1130,6 +1130,10 @@
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
"subHideSettings": "隱藏伺服器設定",
"subHideSettingsDesc": "在 VPN 用戶端中隱藏查看和編輯伺服器配置的功能。(僅限 Happ)",
"subIncyEnableRouting": "啟用路由",
"subIncyEnableRoutingDesc": "為 Incy 用戶端將路由設定檔注入訂閱內容中。(僅限 Incy)",
"subIncyRoutingRules": "路由規則",
"subIncyRoutingRulesDesc": "加入訂閱內容的 Incy 路由深層連結,例如 incy://routing/onadd/<base64>。(僅限 Incy)",
"subClashEnableRouting": "啟用路由",
"subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
"subClashRoutingRules": "全域路由規則",