mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-13 16:45:03 +03:00
Compare commits
7 Commits
v3.4.0
..
5dbd5b1d12
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dbd5b1d12 | |||
| bd60e770f4 | |||
| a5e865c109 | |||
| 82600936d6 | |||
| 14de0557f9 | |||
| c93beef267 | |||
| 48c2fb27b8 |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,6 +35,8 @@ export class AllSetting {
|
||||
subAnnounce = '';
|
||||
subEnableRouting = false;
|
||||
subRoutingRules = '';
|
||||
subIncyEnableRouting = false;
|
||||
subIncyRoutingRules = '';
|
||||
subListen = '';
|
||||
subPort = 2096;
|
||||
subPath = '/sub/';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": "قواعد التوجيه العامة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "قوانین مسیریابی سراسری",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "グローバルルーティングルール",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Глобальные правила маршрутизации",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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": "Глобальні правила маршрутизації",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "全局路由规则",
|
||||
|
||||
@@ -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": "全域路由規則",
|
||||
|
||||
Reference in New Issue
Block a user