mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-15 17:45:03 +03:00
Compare commits
14 Commits
16b2bcf9aa
...
2b1308ca29
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b1308ca29 | |||
| 9e117bbdd3 | |||
| 8cd71e07ea | |||
| 79e65f63df | |||
| c87649d9f2 | |||
| 123fac222b | |||
| d38c912dc1 | |||
| fde66ba820 | |||
| a0dec000b2 | |||
| 11f602fe04 | |||
| c80e5e276b | |||
| 22ff07b24b | |||
| d623410cf4 | |||
| a9d5d9afdb |
@@ -109,6 +109,11 @@
|
||||
"ldapVlessField": {
|
||||
"type": "string"
|
||||
},
|
||||
"outboundDownThreshold": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"pageSize": {
|
||||
"maximum": 1000,
|
||||
"minimum": 0,
|
||||
@@ -387,6 +392,7 @@
|
||||
"ldapUserAttr",
|
||||
"ldapUserFilter",
|
||||
"ldapVlessField",
|
||||
"outboundDownThreshold",
|
||||
"pageSize",
|
||||
"panelOutbound",
|
||||
"remarkTemplate",
|
||||
@@ -570,6 +576,11 @@
|
||||
"ldapVlessField": {
|
||||
"type": "string"
|
||||
},
|
||||
"outboundDownThreshold": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"pageSize": {
|
||||
"maximum": 1000,
|
||||
"minimum": 0,
|
||||
@@ -855,6 +866,7 @@
|
||||
"ldapUserAttr",
|
||||
"ldapUserFilter",
|
||||
"ldapVlessField",
|
||||
"outboundDownThreshold",
|
||||
"pageSize",
|
||||
"panelOutbound",
|
||||
"remarkTemplate",
|
||||
|
||||
@@ -10,7 +10,14 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
icon: <CloudServerOutlined />,
|
||||
title: 'eventGroupOutbound',
|
||||
events: [
|
||||
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
|
||||
{
|
||||
key: 'outbound.down',
|
||||
label: 'eventOutboundDown',
|
||||
settingKey: 'outboundDownThreshold',
|
||||
extra: ({ value, onChange, ariaLabel }) => (
|
||||
<InputNumber size="small" min={1} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||
),
|
||||
},
|
||||
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -10,7 +10,14 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
icon: <CloudServerOutlined />,
|
||||
title: 'eventGroupOutbound',
|
||||
events: [
|
||||
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
|
||||
{
|
||||
key: 'outbound.down',
|
||||
label: 'eventOutboundDown',
|
||||
settingKey: 'outboundDownThreshold',
|
||||
extra: ({ value, onChange, ariaLabel }) => (
|
||||
<InputNumber size="small" min={1} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||
),
|
||||
},
|
||||
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"ldapUserAttr": "",
|
||||
"ldapUserFilter": "",
|
||||
"ldapVlessField": "",
|
||||
"outboundDownThreshold": 1,
|
||||
"pageSize": 0,
|
||||
"panelOutbound": "",
|
||||
"remarkTemplate": "",
|
||||
@@ -136,6 +137,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"ldapUserAttr": "",
|
||||
"ldapUserFilter": "",
|
||||
"ldapVlessField": "",
|
||||
"outboundDownThreshold": 1,
|
||||
"pageSize": 0,
|
||||
"panelOutbound": "",
|
||||
"remarkTemplate": "",
|
||||
|
||||
@@ -83,6 +83,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ldapVlessField": {
|
||||
"type": "string"
|
||||
},
|
||||
"outboundDownThreshold": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"pageSize": {
|
||||
"maximum": 1000,
|
||||
"minimum": 0,
|
||||
@@ -361,6 +366,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ldapUserAttr",
|
||||
"ldapUserFilter",
|
||||
"ldapVlessField",
|
||||
"outboundDownThreshold",
|
||||
"pageSize",
|
||||
"panelOutbound",
|
||||
"remarkTemplate",
|
||||
@@ -544,6 +550,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ldapVlessField": {
|
||||
"type": "string"
|
||||
},
|
||||
"outboundDownThreshold": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"pageSize": {
|
||||
"maximum": 1000,
|
||||
"minimum": 0,
|
||||
@@ -829,6 +840,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ldapUserAttr",
|
||||
"ldapUserFilter",
|
||||
"ldapVlessField",
|
||||
"outboundDownThreshold",
|
||||
"pageSize",
|
||||
"panelOutbound",
|
||||
"remarkTemplate",
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface AllSetting {
|
||||
ldapUserAttr: string;
|
||||
ldapUserFilter: string;
|
||||
ldapVlessField: string;
|
||||
outboundDownThreshold: number;
|
||||
pageSize: number;
|
||||
panelOutbound: string;
|
||||
remarkTemplate: string;
|
||||
@@ -143,6 +144,7 @@ export interface AllSettingView {
|
||||
ldapUserAttr: string;
|
||||
ldapUserFilter: string;
|
||||
ldapVlessField: string;
|
||||
outboundDownThreshold: number;
|
||||
pageSize: number;
|
||||
panelOutbound: string;
|
||||
remarkTemplate: string;
|
||||
|
||||
@@ -44,6 +44,7 @@ export const AllSettingSchema = z.object({
|
||||
ldapUserAttr: z.string(),
|
||||
ldapUserFilter: z.string(),
|
||||
ldapVlessField: z.string(),
|
||||
outboundDownThreshold: z.number().int().min(1).max(100),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
panelOutbound: z.string(),
|
||||
remarkTemplate: z.string(),
|
||||
@@ -156,6 +157,7 @@ export const AllSettingViewSchema = z.object({
|
||||
ldapUserAttr: z.string(),
|
||||
ldapUserFilter: z.string(),
|
||||
ldapVlessField: z.string(),
|
||||
outboundDownThreshold: z.number().int().min(1).max(100),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
panelOutbound: z.string(),
|
||||
remarkTemplate: z.string(),
|
||||
|
||||
@@ -822,8 +822,8 @@ export function genWireguardLink(input: GenWireguardLinkInput): string {
|
||||
? Wireguard.generateKeypair(settings.secretKey).publicKey
|
||||
: '';
|
||||
if (pubKey.length > 0) url.searchParams.set('publickey', pubKey);
|
||||
if (peer.allowedIPs.length > 0 && peer.allowedIPs[0]) {
|
||||
url.searchParams.set('address', peer.allowedIPs[0]);
|
||||
if (peer.allowedIPs.length > 0) {
|
||||
url.searchParams.set('address', peer.allowedIPs.join(','));
|
||||
}
|
||||
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
|
||||
url.searchParams.set('mtu', String(settings.mtu));
|
||||
@@ -850,7 +850,7 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
|
||||
|
||||
let txt = `[Interface]\n`;
|
||||
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
|
||||
txt += `Address = ${peer.allowedIPs[0] ?? ''}\n`;
|
||||
txt += `Address = ${peer.allowedIPs.join(', ')}\n`;
|
||||
txt += `DNS = ${settings.dns || '1.1.1.1, 1.0.0.1'}\n`;
|
||||
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
|
||||
txt += `MTU = ${settings.mtu}\n`;
|
||||
|
||||
@@ -103,6 +103,7 @@ export class AllSetting {
|
||||
smtpEnabledEvents = '';
|
||||
smtpCpu = 80;
|
||||
smtpMemory = 80;
|
||||
outboundDownThreshold = 3;
|
||||
hasTgBotToken = false;
|
||||
hasTwoFactorToken = false;
|
||||
hasLdapPassword = false;
|
||||
@@ -120,6 +121,8 @@ export class AllSetting {
|
||||
}
|
||||
const cpu = Math.round(Number(this.tgCpu));
|
||||
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
||||
const threshold = Math.round(Number(this.outboundDownThreshold));
|
||||
this.outboundDownThreshold = Number.isFinite(threshold) ? Math.min(100, Math.max(1, threshold)) : 3;
|
||||
}
|
||||
|
||||
equals(other: AllSetting): boolean {
|
||||
|
||||
@@ -48,6 +48,7 @@ const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
|
||||
interface ExternalLinkRow {
|
||||
kind: 'link' | 'subscription';
|
||||
value: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
@@ -138,6 +139,7 @@ function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[
|
||||
return (links || []).map((l) => ({
|
||||
kind: l.kind === 'subscription' ? 'subscription' : 'link',
|
||||
value: l.value || '',
|
||||
remark: l.remark || '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -207,7 +209,7 @@ export default function ClientFormModal({
|
||||
const limitIpNotice = getLimitIpNotice(fail2ban, t);
|
||||
|
||||
function addExternalLinkRow(kind: 'link' | 'subscription') {
|
||||
appendExternalLink({ kind, value: '' });
|
||||
appendExternalLink({ kind, value: '', remark: '' });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -371,10 +373,15 @@ export default function ClientFormModal({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!showFlow && flow) {
|
||||
// Only clear the flow once we actually have inbound options to judge
|
||||
// capability from. While the options list is momentarily empty (e.g. the
|
||||
// options query is (re)loading and `inbounds` falls back to `[]`), showFlow
|
||||
// is a false negative, so clearing here would silently drop a valid
|
||||
// xtls-rprx-vision flow the user picked for a Reality/TLS inbound.
|
||||
if (inbounds.length > 0 && !showFlow && flow) {
|
||||
methods.setValue('flow', '');
|
||||
}
|
||||
}, [showFlow, flow, methods]);
|
||||
}, [inbounds, showFlow, flow, methods]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showReverseTag && reverseTag) {
|
||||
@@ -547,7 +554,7 @@ export default function ClientFormModal({
|
||||
}
|
||||
|
||||
const externalLinks: ExternalLinkInput[] = values.externalLinks
|
||||
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
|
||||
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: (r.remark || '').trim() }))
|
||||
.filter((r) => r.value !== '');
|
||||
|
||||
setSubmitting(true);
|
||||
@@ -917,6 +924,13 @@ export default function ClientFormModal({
|
||||
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name={`externalLinks.${index}.remark`} noStyle>
|
||||
<Input
|
||||
style={{ width: 140 }}
|
||||
aria-label={t('remark')}
|
||||
placeholder={t('remark')}
|
||||
/>
|
||||
</FormField>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
|
||||
import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { CopyOutlined, DownloadOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
|
||||
import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
@@ -64,6 +64,12 @@ const DEFAULT_SUB: SubSettings = {
|
||||
publicHost: '',
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_DOWNLOAD_NAMES = {
|
||||
standard: 'subscription-standard.txt',
|
||||
json: 'subscription-json.json',
|
||||
clash: 'subscription-clash.yaml',
|
||||
} as const;
|
||||
|
||||
export default function ClientInfoModal({
|
||||
open,
|
||||
client,
|
||||
@@ -89,6 +95,7 @@ export default function ClientInfoModal({
|
||||
const [ipsLoading, setIpsLoading] = useState(false);
|
||||
const [ipsClearing, setIpsClearing] = useState(false);
|
||||
const [ipsModalOpen, setIpsModalOpen] = useState(false);
|
||||
const [downloadingFormat, setDownloadingFormat] = useState<keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -148,6 +155,21 @@ export default function ClientInfoModal({
|
||||
if (ok) messageApi.success(t('copied'));
|
||||
}
|
||||
|
||||
async function downloadSubscription(url: string, format: keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES) {
|
||||
if (!url || downloadingFormat) return;
|
||||
setDownloadingFormat(format);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Subscription download failed');
|
||||
const content = await response.text();
|
||||
FileManager.downloadTextFile(content, SUBSCRIPTION_DOWNLOAD_NAMES[format]);
|
||||
} catch (_) {
|
||||
messageApi.error(t('somethingWentWrong'));
|
||||
} finally {
|
||||
setDownloadingFormat(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIps() {
|
||||
if (!client?.email) return;
|
||||
setIpsLoading(true);
|
||||
@@ -383,6 +405,9 @@ export default function ClientInfoModal({
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subLink)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('download')}>
|
||||
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'standard'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subLink, 'standard')} />
|
||||
</Tooltip>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
@@ -411,6 +436,9 @@ export default function ClientInfoModal({
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subJsonLink)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('download')}>
|
||||
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'json'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subJsonLink, 'json')} />
|
||||
</Tooltip>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
@@ -442,6 +470,9 @@ export default function ClientInfoModal({
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subClashLink)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('download')}>
|
||||
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'clash'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subClashLink, 'clash')} />
|
||||
</Tooltip>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
AppleOutlined,
|
||||
CopyOutlined,
|
||||
DownOutlined,
|
||||
DownloadOutlined,
|
||||
MoonFilled,
|
||||
MoonOutlined,
|
||||
QrcodeOutlined,
|
||||
@@ -64,6 +65,8 @@ const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
|
||||
const datepicker = subData.datepicker || 'gregorian';
|
||||
const announce = subData.announce || '';
|
||||
|
||||
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
|
||||
|
||||
const isUnlimited = totalByte <= 0 && expireMs === 0;
|
||||
const isActive = (() => {
|
||||
if (!enabled) return false;
|
||||
@@ -354,6 +357,15 @@ export default function SubPage() {
|
||||
{sId}
|
||||
</a>
|
||||
<div className="sub-link-actions">
|
||||
<Button
|
||||
size="small"
|
||||
href={appendRawView(subJsonUrl)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon={<DownloadOutlined />}
|
||||
aria-label={t('download')}
|
||||
title={t('download')}
|
||||
/>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(subJsonUrl)} aria-label={t('copy')} title={t('copy')} />
|
||||
<Popover
|
||||
trigger="click"
|
||||
@@ -386,6 +398,15 @@ export default function SubPage() {
|
||||
{sId}
|
||||
</a>
|
||||
<div className="sub-link-actions">
|
||||
<Button
|
||||
size="small"
|
||||
href={appendRawView(subClashUrl)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon={<DownloadOutlined />}
|
||||
aria-label={t('download')}
|
||||
title={t('download')}
|
||||
/>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(subClashUrl)} aria-label={t('copy')} title={t('copy')} />
|
||||
<Popover
|
||||
trigger="click"
|
||||
|
||||
@@ -52,6 +52,18 @@ function countLoopbackRefs(settings: XraySettingsValue, targetTag: string): numb
|
||||
return count;
|
||||
}
|
||||
|
||||
function isGeneralRoutingRule(rule: Record<string, unknown>): boolean {
|
||||
const inboundTag = rule.inboundTag;
|
||||
return !Array.isArray(inboundTag) || inboundTag.length === 0;
|
||||
}
|
||||
|
||||
function firstGeneralRoutingRuleIndex(rules: Array<Record<string, unknown>>): number {
|
||||
for (let i = 0; i < rules.length; i += 1) {
|
||||
if (isGeneralRoutingRule(rules[i])) return i;
|
||||
}
|
||||
return rules.length;
|
||||
}
|
||||
|
||||
export function ensureBalancerLoopback(
|
||||
settings: XraySettingsValue,
|
||||
targetBalancerTag: string,
|
||||
@@ -72,13 +84,19 @@ export function ensureBalancerLoopback(
|
||||
if (!settings.routing) settings.routing = { rules: [], balancers: [] };
|
||||
if (!Array.isArray(settings.routing.rules)) settings.routing.rules = [];
|
||||
|
||||
const existingRuleIdx = (settings.routing.rules as Array<{ inboundTag?: string[] }>).findIndex(
|
||||
(r) => Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag),
|
||||
const rules = settings.routing.rules as Array<Record<string, unknown>>;
|
||||
|
||||
const existingRuleIdx = rules.findIndex(
|
||||
(r) => Array.isArray(r.inboundTag) && (r.inboundTag as string[]).includes(lbTag),
|
||||
);
|
||||
|
||||
if (existingRuleIdx >= 0) {
|
||||
(settings.routing.rules as Record<string, unknown>[])[existingRuleIdx].balancerTag = targetBalancerTag;
|
||||
const existing = rules[existingRuleIdx];
|
||||
existing.balancerTag = targetBalancerTag;
|
||||
rules.splice(existingRuleIdx, 1);
|
||||
rules.splice(firstGeneralRoutingRuleIndex(rules), 0, existing);
|
||||
} else {
|
||||
(settings.routing.rules as Record<string, unknown>[]).push({
|
||||
rules.splice(firstGeneralRoutingRuleIndex(rules), 0, {
|
||||
type: 'field',
|
||||
inboundTag: [lbTag],
|
||||
balancerTag: targetBalancerTag,
|
||||
|
||||
@@ -75,6 +75,40 @@ function reservedFor(clientId?: string): number[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function mergeWarpRotation(
|
||||
existing: Record<string, unknown> | undefined,
|
||||
data: WarpData | null,
|
||||
config: WarpConfig | null,
|
||||
): Record<string, unknown> | null {
|
||||
const cfg = config?.config;
|
||||
const peer = cfg?.peers?.[0];
|
||||
if (!cfg || !peer) return null;
|
||||
const base: Record<string, unknown> =
|
||||
existing && typeof existing === 'object' ? { ...existing } : { tag: 'warp', protocol: 'wireguard' };
|
||||
const prevSettings =
|
||||
base.settings && typeof base.settings === 'object'
|
||||
? { ...(base.settings as Record<string, unknown>) }
|
||||
: {};
|
||||
const prevPeers = Array.isArray(prevSettings.peers)
|
||||
? [...(prevSettings.peers as Record<string, unknown>[])]
|
||||
: [];
|
||||
const prevFirstPeer =
|
||||
prevPeers[0] && typeof prevPeers[0] === 'object'
|
||||
? { ...(prevPeers[0] as Record<string, unknown>) }
|
||||
: {};
|
||||
prevFirstPeer.publicKey = peer.public_key;
|
||||
prevFirstPeer.endpoint = peer.endpoint?.host;
|
||||
prevPeers[0] = prevFirstPeer;
|
||||
prevSettings.secretKey = data?.private_key;
|
||||
prevSettings.address = addressesFor(cfg.interface?.addresses || {});
|
||||
prevSettings.reserved = reservedFor(cfg.client_id ?? data?.client_id);
|
||||
prevSettings.peers = prevPeers;
|
||||
base.settings = prevSettings;
|
||||
base.tag = 'warp';
|
||||
base.protocol = 'wireguard';
|
||||
return base;
|
||||
}
|
||||
|
||||
export default function WarpModal({
|
||||
open,
|
||||
templateSettings,
|
||||
@@ -194,12 +228,15 @@ export default function WarpModal({
|
||||
const parsed = JSON.parse(msg.obj);
|
||||
setWarpData(parsed.data);
|
||||
setWarpConfig(parsed.config);
|
||||
const built = collectConfig(parsed.data, parsed.config);
|
||||
// The backend already persisted the new keys into the saved Xray
|
||||
// template; keep the in-memory editor in sync so a later template
|
||||
// save doesn't revert them to the old keys.
|
||||
if (built && warpOutboundIndex >= 0) {
|
||||
onResetOutbound({ index: warpOutboundIndex, outbound: built });
|
||||
collectConfig(parsed.data, parsed.config);
|
||||
if (warpOutboundIndex >= 0) {
|
||||
const existing = templateSettings?.outbounds?.[warpOutboundIndex] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const merged = mergeWarpRotation(existing, parsed.data, parsed.config);
|
||||
if (merged) {
|
||||
onResetOutbound({ index: warpOutboundIndex, outbound: merged });
|
||||
}
|
||||
}
|
||||
messageApi.success(t('pages.xray.warp.changeIpSuccess', 'WARP IP changed successfully!'));
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const AllSettingSchema = z.object({
|
||||
tgRunTime: z.string().optional(),
|
||||
tgBotBackup: z.boolean().optional(),
|
||||
tgCpu: z.number().int().min(0).max(100).optional(),
|
||||
outboundDownThreshold: z.number().int().min(1).max(100).optional(),
|
||||
tgLang: z.string().optional(),
|
||||
twoFactorEnable: z.boolean().optional(),
|
||||
twoFactorToken: z.string().optional(),
|
||||
|
||||
@@ -24,6 +24,7 @@ interface RuleEntry {
|
||||
type?: string;
|
||||
inboundTag?: string[];
|
||||
balancerTag?: string;
|
||||
domain?: string[];
|
||||
}
|
||||
interface BalancerEntry {
|
||||
tag?: string;
|
||||
@@ -140,6 +141,105 @@ describe('ensureBalancerLoopback dedup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureBalancerLoopback rule ordering', () => {
|
||||
function loopbackRuleIndex(settings: XraySettingsValue, lbTag: string): number {
|
||||
return ruleEntries(settings).findIndex(
|
||||
(r) => Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag),
|
||||
);
|
||||
}
|
||||
function generalRuleIndex(settings: XraySettingsValue): number {
|
||||
return ruleEntries(settings).findIndex(
|
||||
(r) => !Array.isArray(r.inboundTag) || r.inboundTag.length === 0,
|
||||
);
|
||||
}
|
||||
|
||||
it('inserts a new loopback rule ahead of a general (no inboundTag) rule', () => {
|
||||
const settings = makeSettings({
|
||||
rules: [{ type: 'field', domain: ['example.com'], balancerTag: 'parent' }],
|
||||
balancers: [{ tag: 'parent', selector: [] }],
|
||||
});
|
||||
|
||||
ensureBalancerLoopback(settings, 'target');
|
||||
|
||||
expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(
|
||||
generalRuleIndex(settings),
|
||||
);
|
||||
});
|
||||
|
||||
it('repositions an existing loopback rule that landed after a general rule', () => {
|
||||
const settings = makeSettings({
|
||||
rules: [
|
||||
{ type: 'field', domain: ['example.com'], balancerTag: 'parent' },
|
||||
{ type: 'field', inboundTag: ['_bl_target'], balancerTag: 'stale' },
|
||||
],
|
||||
balancers: [{ tag: 'parent', selector: [] }],
|
||||
});
|
||||
|
||||
ensureBalancerLoopback(settings, 'target');
|
||||
|
||||
const lbIdx = loopbackRuleIndex(settings, '_bl_target');
|
||||
expect(lbIdx).toBeLessThan(generalRuleIndex(settings));
|
||||
expect(ruleEntries(settings)[lbIdx].balancerTag).toBe('target');
|
||||
});
|
||||
|
||||
it('leaves inboundTag-restricted rules in place and slots loopback ahead of general rules only', () => {
|
||||
const settings = makeSettings({
|
||||
rules: [
|
||||
{ type: 'field', inboundTag: ['api'], balancerTag: 'stats' },
|
||||
{ type: 'field', domain: ['example.com'], balancerTag: 'parent' },
|
||||
],
|
||||
balancers: [{ tag: 'parent', selector: [] }],
|
||||
});
|
||||
|
||||
ensureBalancerLoopback(settings, 'target');
|
||||
|
||||
const entries = ruleEntries(settings);
|
||||
expect(entries[0].inboundTag).toEqual(['api']);
|
||||
const lbIdx = loopbackRuleIndex(settings, '_bl_target');
|
||||
const generalIdx = generalRuleIndex(settings);
|
||||
expect(lbIdx).toBeLessThan(generalIdx);
|
||||
expect(lbIdx).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('ensureMissingBalancerLoopbacks repositions every mis-ordered loopback rule', () => {
|
||||
const settings = makeSettings({
|
||||
rules: [
|
||||
{ type: 'field', domain: ['example.com'], balancerTag: 'B1' },
|
||||
{ type: 'field', inboundTag: ['_bl_B2'], balancerTag: 'B2' },
|
||||
],
|
||||
balancers: [
|
||||
{ tag: 'B1', selector: [], fallbackTag: '_bl_B2' },
|
||||
{ tag: 'B2', selector: [] },
|
||||
],
|
||||
});
|
||||
|
||||
ensureMissingBalancerLoopbacks(settings);
|
||||
|
||||
expect(loopbackRuleIndex(settings, '_bl_B2')).toBeLessThan(
|
||||
generalRuleIndex(settings),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the loopback rule ahead of the general rule after a second ensureBalancerLoopback call', () => {
|
||||
const settings = makeSettings({
|
||||
rules: [{ type: 'field', domain: ['example.com'], balancerTag: 'parent' }],
|
||||
balancers: [{ tag: 'parent', selector: [] }],
|
||||
});
|
||||
|
||||
ensureBalancerLoopback(settings, 'target');
|
||||
ensureBalancerLoopback(settings, 'target');
|
||||
|
||||
expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(
|
||||
generalRuleIndex(settings),
|
||||
);
|
||||
expect(
|
||||
ruleEntries(settings).filter(
|
||||
(r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_target'),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectBalancerCycles', () => {
|
||||
const cases: Array<{ name: string; balancers: BalancerEntry[]; expected: string[][] }> = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import ClientFormModal from '@/pages/clients/ClientFormModal';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
|
||||
function makeQC() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
|
||||
const REALITY_INBOUND = {
|
||||
id: 4,
|
||||
port: 10443,
|
||||
protocol: 'vless',
|
||||
tag: 'in-10443-tcp',
|
||||
tlsFlowCapable: true,
|
||||
enable: true,
|
||||
} as unknown as InboundOption;
|
||||
|
||||
const CLIENT = {
|
||||
email: 'testuser',
|
||||
flow: 'xtls-rprx-vision',
|
||||
uuid: '11111111-1111-1111-1111-111111111111',
|
||||
subId: 'subid123',
|
||||
enable: true,
|
||||
} as unknown as ClientRecord;
|
||||
|
||||
function savedFlow(save: ReturnType<typeof vi.fn>): unknown {
|
||||
return (save.mock.calls[0][0] as Record<string, unknown>).flow;
|
||||
}
|
||||
|
||||
describe('ClientFormModal — Vision flow preservation', () => {
|
||||
it('keeps xtls-rprx-vision with a stable Reality inbound', async () => {
|
||||
const qc = makeQC();
|
||||
const save = vi.fn().mockResolvedValue({ success: true });
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={qc}>
|
||||
<ClientFormModal open mode="edit" client={CLIENT} inbounds={[REALITY_INBOUND]} attachedIds={[4]} save={save} onOpenChange={() => {}} />
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /save/i }));
|
||||
await waitFor(() => expect(save).toHaveBeenCalled());
|
||||
expect(savedFlow(save)).toBe('xtls-rprx-vision');
|
||||
});
|
||||
|
||||
it('does not drop a selected Vision flow while the inbound options momentarily reload', async () => {
|
||||
const qc = makeQC();
|
||||
const save = vi.fn().mockResolvedValue({ success: true });
|
||||
const tree = (inbounds: InboundOption[]) => (
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={qc}>
|
||||
<ClientFormModal open mode="edit" client={CLIENT} inbounds={inbounds} attachedIds={[4]} save={save} onOpenChange={() => {}} />
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
// Options loaded -> reloading (inboundOptionsQuery.data ?? [] === []) -> loaded again.
|
||||
const { rerender } = render(tree([REALITY_INBOUND]));
|
||||
await screen.findByRole('button', { name: /save/i });
|
||||
rerender(tree([]));
|
||||
rerender(tree([REALITY_INBOUND]));
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /save/i }));
|
||||
await waitFor(() => expect(save).toHaveBeenCalled());
|
||||
expect(savedFlow(save)).toBe('xtls-rprx-vision');
|
||||
});
|
||||
});
|
||||
@@ -308,6 +308,43 @@ describe('genWireguardLink + genWireguardConfig', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('genWireguardLink + genWireguardConfig multi allowedIPs', () => {
|
||||
const settings = {
|
||||
secretKey: '',
|
||||
mtu: 1280,
|
||||
dns: '',
|
||||
peers: [
|
||||
{
|
||||
privateKey: 'cLI',
|
||||
allowedIPs: ['10.0.0.2/32', 'fd00::2/128'],
|
||||
},
|
||||
],
|
||||
} as unknown as WireguardInboundSettings;
|
||||
|
||||
it('joins every allowed IP into the share-link address param', () => {
|
||||
const link = genWireguardLink({
|
||||
settings,
|
||||
address: 'wg.example.test',
|
||||
port: 51820,
|
||||
remark: 'dual-stack',
|
||||
peerIndex: 0,
|
||||
});
|
||||
const u = new URL(link);
|
||||
expect(u.searchParams.get('address')).toBe('10.0.0.2/32,fd00::2/128');
|
||||
});
|
||||
|
||||
it('joins every allowed IP into the .conf Address line', () => {
|
||||
const config = genWireguardConfig({
|
||||
settings,
|
||||
address: 'wg.example.test',
|
||||
port: 51820,
|
||||
remark: 'dual-stack',
|
||||
peerIndex: 0,
|
||||
});
|
||||
expect(config).toContain('Address = 10.0.0.2/32, fd00::2/128\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAddr precedence', () => {
|
||||
const baseInbound = {
|
||||
listen: '',
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { mergeWarpRotation } from '@/pages/xray/overrides/WarpModal';
|
||||
|
||||
const clientId = btoa(String.fromCharCode(1, 2, 3));
|
||||
|
||||
function rotatedConfig(overrides: { public_key?: string; host?: string; v4?: string; v6?: string } = {}) {
|
||||
return {
|
||||
config: {
|
||||
client_id: clientId,
|
||||
interface: { addresses: { v4: overrides.v4 ?? '172.16.0.2', v6: overrides.v6 ?? '2606:4700::2' } },
|
||||
peers: [{ public_key: overrides.public_key ?? 'newPub', endpoint: { host: overrides.host ?? 'engage.cloudflareclient.com:2408' } }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('mergeWarpRotation', () => {
|
||||
it('patches only rotated fields and preserves user customizations', () => {
|
||||
const existing = {
|
||||
tag: 'warp',
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
mtu: 1280,
|
||||
secretKey: 'oldSecret',
|
||||
address: ['172.16.0.2/32'],
|
||||
reserved: [9, 9, 9],
|
||||
domainStrategy: 'ForceIPv4',
|
||||
noKernelTun: false,
|
||||
peers: [
|
||||
{
|
||||
publicKey: 'oldPub',
|
||||
endpoint: 'engage.cloudflareclient.com:2408',
|
||||
keepAlive: 25,
|
||||
allowedIPs: ['10.0.0.0/24'],
|
||||
preSharedKey: 'psk',
|
||||
},
|
||||
{ publicKey: 'extraPeer', endpoint: 'extra.test:51820' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const merged = mergeWarpRotation(
|
||||
existing,
|
||||
{ private_key: 'newSecret' },
|
||||
rotatedConfig({ public_key: 'newPub', host: 'engage.cloudflareclient.com:2408', v4: '172.16.0.9' }),
|
||||
);
|
||||
|
||||
expect(merged).not.toBeNull();
|
||||
const settings = (merged as { settings: Record<string, unknown> }).settings;
|
||||
expect(settings.secretKey).toBe('newSecret');
|
||||
expect(settings.address).toEqual(['172.16.0.9/32', '2606:4700::2/128']);
|
||||
expect(settings.reserved).toEqual([1, 2, 3]);
|
||||
const peers = settings.peers as Array<Record<string, unknown>>;
|
||||
expect(peers[0].publicKey).toBe('newPub');
|
||||
expect(peers[0].endpoint).toBe('engage.cloudflareclient.com:2408');
|
||||
expect(peers[0].keepAlive).toBe(25);
|
||||
expect(peers[0].allowedIPs).toEqual(['10.0.0.0/24']);
|
||||
expect(peers[0].preSharedKey).toBe('psk');
|
||||
expect(peers[1]).toEqual({ publicKey: 'extraPeer', endpoint: 'extra.test:51820' });
|
||||
expect(settings.mtu).toBe(1280);
|
||||
expect(settings.domainStrategy).toBe('ForceIPv4');
|
||||
expect(settings.noKernelTun).toBe(false);
|
||||
});
|
||||
|
||||
it('does not mutate the existing outbound object', () => {
|
||||
const existing = {
|
||||
tag: 'warp',
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
secretKey: 'oldSecret',
|
||||
address: ['172.16.0.2/32'],
|
||||
reserved: [9, 9, 9],
|
||||
peers: [{ publicKey: 'oldPub', endpoint: 'old:1', keepAlive: 25 }],
|
||||
},
|
||||
};
|
||||
const snapshot = JSON.parse(JSON.stringify(existing));
|
||||
|
||||
mergeWarpRotation(existing, { private_key: 'newSecret' }, rotatedConfig());
|
||||
|
||||
expect(existing).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('returns null when the rotation response has no peer', () => {
|
||||
expect(mergeWarpRotation(undefined, null, { config: { peers: [] } })).toBeNull();
|
||||
expect(mergeWarpRotation(undefined, null, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('seeds a default warp outbound when none existed yet', () => {
|
||||
const merged = mergeWarpRotation(undefined, { private_key: 'newSecret' }, rotatedConfig());
|
||||
expect(merged).not.toBeNull();
|
||||
expect((merged as Record<string, unknown>).tag).toBe('warp');
|
||||
expect((merged as Record<string, unknown>).protocol).toBe('wireguard');
|
||||
const settings = (merged as { settings: Record<string, unknown> }).settings;
|
||||
expect(settings.secretKey).toBe('newSecret');
|
||||
expect(settings.peers).toEqual([
|
||||
{ publicKey: 'newPub', endpoint: 'engage.cloudflareclient.com:2408' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+29
-10
@@ -6,12 +6,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -340,11 +340,11 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
logSubscriptionRoute(userAgent, "html")
|
||||
return
|
||||
}
|
||||
if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c) {
|
||||
if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false) {
|
||||
logSubscriptionRoute(userAgent, "clash")
|
||||
return
|
||||
}
|
||||
if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8") {
|
||||
if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8", false) {
|
||||
logSubscriptionRoute(userAgent, "json")
|
||||
return
|
||||
}
|
||||
@@ -446,7 +446,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
|
||||
if diskBody, diskErr := os.ReadFile("internal/web/dist/subpage.html"); diskErr == nil {
|
||||
body = diskBody
|
||||
} else {
|
||||
readBody, err := distFS.ReadFile("dist/subpage.html")
|
||||
readBody, err := fs.ReadFile(distFS, "dist/subpage.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "missing embedded subpage")
|
||||
return
|
||||
@@ -596,6 +596,12 @@ func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, er
|
||||
|
||||
// subJsons handles HTTP requests for JSON subscription configurations.
|
||||
func (a *SUBController) subJsons(c *gin.Context) {
|
||||
if strings.EqualFold(c.Query("view"), "raw") {
|
||||
if !a.serveJsonBody(c, a.jsonAlwaysArray, "application/json; charset=utf-8", true) {
|
||||
writeSubError(c, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
if a.maybeServeSubPage(c) {
|
||||
return
|
||||
}
|
||||
@@ -603,12 +609,12 @@ func (a *SUBController) subJsons(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) {
|
||||
if !a.serveJsonBody(c, alwaysReturnArray, contentType) {
|
||||
if !a.serveJsonBody(c, alwaysReturnArray, contentType, false) {
|
||||
writeSubError(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string) bool {
|
||||
func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string, rawDownload bool) bool {
|
||||
subId := c.Param("subid")
|
||||
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
||||
jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray)
|
||||
@@ -624,21 +630,30 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
|
||||
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
}
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
if rawDownload {
|
||||
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
|
||||
}
|
||||
|
||||
c.Data(200, contentType, []byte(jsonSub))
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *SUBController) subClashs(c *gin.Context) {
|
||||
if strings.EqualFold(c.Query("view"), "raw") {
|
||||
if !a.serveClashBody(c, true) {
|
||||
writeSubError(c, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
if a.maybeServeSubPage(c) {
|
||||
return
|
||||
}
|
||||
if !a.serveClashBody(c) {
|
||||
if !a.serveClashBody(c, false) {
|
||||
writeSubError(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *SUBController) serveClashBody(c *gin.Context) bool {
|
||||
func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
|
||||
subId := c.Param("subid")
|
||||
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
||||
clashSub, header, err := a.subClashService.GetClash(subId, host)
|
||||
@@ -654,7 +669,9 @@ func (a *SUBController) serveClashBody(c *gin.Context) bool {
|
||||
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
}
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
if a.subTitle != "" {
|
||||
if rawDownload {
|
||||
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
|
||||
} else if a.subTitle != "" {
|
||||
// Clash clients commonly use Content-Disposition to choose the imported profile name.
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle)))
|
||||
}
|
||||
@@ -693,7 +710,9 @@ func (a *SUBController) ApplyCommonHeaders(
|
||||
}
|
||||
|
||||
// Advanced (Happ)
|
||||
c.Writer.Header().Set("Routing-Enable", strconv.FormatBool(profileEnableRouting))
|
||||
if profileEnableRouting {
|
||||
c.Writer.Header().Set("Routing-Enable", "true")
|
||||
}
|
||||
if profileRoutingRules != "" {
|
||||
c.Writer.Header().Set("Routing", profileRoutingRules)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -19,6 +20,10 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
var testDistFS = fstest.MapFS{
|
||||
"dist/subpage.html": {Data: []byte(`<!doctype html><html><head></head><body><div id="root"></div></body></html>`)},
|
||||
}
|
||||
|
||||
// newTestSUBController builds a controller with just the bits loadSubTemplate
|
||||
// needs, so the template tests don't require a database.
|
||||
func newTestSUBController() *SUBController {
|
||||
@@ -391,6 +396,63 @@ func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatEndpointsRawViewBypassesBrowserPage(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
seedSubInbound(t, "s1", "raw", 4481, 1, `{"network":"tcp","security":"none"}`)
|
||||
gin.SetMode(gin.TestMode)
|
||||
oldDistFS := distFS
|
||||
distFS = testDistFS
|
||||
t.Cleanup(func() { distFS = oldDistFS })
|
||||
router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
contentType string
|
||||
disposition string
|
||||
bodyContains string
|
||||
}{
|
||||
{name: "JSON", path: "/json/s1?view=raw", contentType: "application/json; charset=utf-8", disposition: `attachment; filename="subscription.json"`, bodyContains: "outbounds"},
|
||||
{name: "Clash", path: "/clash/s1?view=raw", contentType: "application/yaml; charset=utf-8", disposition: `attachment; filename="subscription.yaml"`, bodyContains: "proxies:"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+tt.path, nil)
|
||||
req.Header.Set("Accept", "text/html")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if got := resp.Header().Get("Content-Type"); got != tt.contentType {
|
||||
t.Fatalf("Content-Type = %q, want %q", got, tt.contentType)
|
||||
}
|
||||
if got := resp.Header().Get("Content-Disposition"); got != tt.disposition {
|
||||
t.Fatalf("Content-Disposition = %q, want %q", got, tt.disposition)
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), tt.bodyContains) {
|
||||
t.Fatalf("raw body does not contain %q: %s", tt.bodyContains, resp.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, path := range []string{"/json/s1", "/clash/s1"} {
|
||||
t.Run(path+" browser page", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil)
|
||||
req.Header.Set("Accept", "text/html")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if got := resp.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
|
||||
t.Fatalf("Content-Type = %q, want HTML", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
package sub
|
||||
|
||||
import "embed"
|
||||
import "io/fs"
|
||||
|
||||
// distFS holds the Vite-built frontend filesystem, injected from main at
|
||||
// startup. The `web` package owns the //go:embed directive (because dist/
|
||||
// is at internal/web/dist/), and hands the FS over via SetDistFS so the sub package
|
||||
// doesn't import web — that would create an import cycle once any
|
||||
// internal/web/controller handler reuses sub's link-building service.
|
||||
var distFS embed.FS
|
||||
var distFS fs.FS
|
||||
|
||||
// SetDistFS installs the embedded frontend filesystem the sub server uses
|
||||
// for its info page assets. Must be called before NewServer().Start().
|
||||
func SetDistFS(fs embed.FS) {
|
||||
distFS = fs
|
||||
func SetDistFS(frontendFS fs.FS) {
|
||||
distFS = frontendFS
|
||||
}
|
||||
|
||||
@@ -74,18 +74,59 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink
|
||||
}
|
||||
|
||||
// expandEntry turns one entry into the concrete share links it contributes. A
|
||||
// "subscription" entry is fetched (cached) and its links are kept with their own
|
||||
// names; a "link" entry yields the single link with the row's remark.
|
||||
// "subscription" entry is fetched (cached) and its links keep their own names
|
||||
// (URL #fragment / vmess ps). A "link" entry uses the row remark when set,
|
||||
// otherwise the link's original name — never blank, so Clash/JSON do not fall
|
||||
// back to the client email.
|
||||
func expandEntry(e externalLinkEntry) []expandedLink {
|
||||
if e.Kind == model.ExternalLinkKindSubscription {
|
||||
links := fetchSubscriptionLinks(e.Value)
|
||||
out := make([]expandedLink, 0, len(links))
|
||||
for _, l := range links {
|
||||
out = append(out, expandedLink{Link: l, Name: ""})
|
||||
out = append(out, expandedLink{Link: l, Name: linkDisplayName(l)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
return []expandedLink{{Link: e.Value, Name: e.Remark}}
|
||||
name := strings.TrimSpace(e.Remark)
|
||||
if name == "" {
|
||||
name = linkDisplayName(e.Value)
|
||||
}
|
||||
return []expandedLink{{Link: e.Value, Name: name}}
|
||||
}
|
||||
|
||||
// linkDisplayName extracts the human-readable name already carried by a share
|
||||
// link: vmess JSON `ps`, or the URL #fragment for every other scheme.
|
||||
func linkDisplayName(rawLink string) string {
|
||||
rawLink = strings.TrimSpace(rawLink)
|
||||
if rawLink == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(rawLink, "vmess://") {
|
||||
b64 := strings.TrimPrefix(rawLink, "vmess://")
|
||||
raw, err := base64.StdEncoding.DecodeString(padBase64Sub(b64))
|
||||
if err != nil {
|
||||
raw, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(b64, "="))
|
||||
}
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var j map[string]any
|
||||
if err := json.Unmarshal(raw, &j); err != nil {
|
||||
return ""
|
||||
}
|
||||
if ps, ok := j["ps"].(string); ok {
|
||||
return strings.TrimSpace(ps)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexByte(rawLink, '#'); i >= 0 && i+1 < len(rawLink) {
|
||||
frag := rawLink[i+1:]
|
||||
if decoded, err := url.PathUnescape(frag); err == nil {
|
||||
return strings.TrimSpace(decoded)
|
||||
}
|
||||
return strings.TrimSpace(frag)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// applyRemarkToLink rewrites a share link's display name to remark (when set),
|
||||
|
||||
@@ -98,6 +98,35 @@ func TestExpandEntryLinkAppliesRemark(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandEntryLinkFallsBackToOriginalName(t *testing.T) {
|
||||
got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindLink, Value: "trojan://pw@b.com:8443#orig", Remark: ""})
|
||||
if len(got) != 1 || got[0].Name != "orig" {
|
||||
t.Fatalf("expandEntry empty remark = %#v, want Name=orig", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkDisplayName(t *testing.T) {
|
||||
payload := map[string]any{"v": "2", "ps": "NL-Node", "add": "1.2.3.4", "port": "443", "id": "uuid"}
|
||||
b, _ := json.Marshal(payload)
|
||||
vmess := "vmess://" + base64.StdEncoding.EncodeToString(b)
|
||||
|
||||
cases := []struct {
|
||||
link string
|
||||
want string
|
||||
}{
|
||||
{"vless://uuid@a.com:443#one", "one"},
|
||||
{"trojan://pw@b.com:8443#" + url.PathEscape("DE Node"), "DE Node"},
|
||||
{vmess, "NL-Node"},
|
||||
{"ss://def", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := linkDisplayName(c.link); got != c.want {
|
||||
t.Errorf("linkDisplayName(%q) = %q, want %q", c.link, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClashProxyFromExternalTrojanReality(t *testing.T) {
|
||||
link := "trojan://provider-pass@37.27.201.56:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
|
||||
svc := NewSubClashService(false, "", NewSubService(""))
|
||||
|
||||
@@ -644,8 +644,8 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
|
||||
params["publickey"] = pub
|
||||
}
|
||||
}
|
||||
if len(client.AllowedIPs) > 0 && client.AllowedIPs[0] != "" {
|
||||
params["address"] = client.AllowedIPs[0]
|
||||
if joined := strings.Join(client.AllowedIPs, ","); joined != "" {
|
||||
params["address"] = joined
|
||||
}
|
||||
if mtu, ok := settings["mtu"].(float64); ok && mtu > 0 {
|
||||
params["mtu"] = strconv.Itoa(int(mtu))
|
||||
@@ -1025,11 +1025,10 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
||||
}
|
||||
}
|
||||
|
||||
// salamander obfs (Hysteria2). The panel-side link generator already
|
||||
// emits these; keep the subscription output in sync so a client has
|
||||
// the obfs password to match the server.
|
||||
// salamander obfs (Hysteria2). Emit only the standard URI fields;
|
||||
// the non-standard fm=<json> finalmask dump breaks mihomo and other
|
||||
// Hysteria2 clients that reject unknown query params.
|
||||
if finalmask, ok := stream["finalmask"].(map[string]any); ok {
|
||||
applyFinalMaskParams(finalmask, params)
|
||||
if udpMasks, ok := finalmask["udp"].([]any); ok {
|
||||
for _, m := range udpMasks {
|
||||
mask, _ := m.(map[string]any)
|
||||
|
||||
@@ -1110,3 +1110,32 @@ func TestHysteriaHopPorts(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenHysteriaLinkOmitsFinalMaskQueryParam(t *testing.T) {
|
||||
stream := `{
|
||||
"security":"tls",
|
||||
"tlsSettings":{"serverName":"hy.sni","alpn":["h3"],"settings":{"fingerprint":"chrome"}},
|
||||
"finalmask":{"udp":[{"type":"salamander","settings":{"password":"obfs-secret"}}]}
|
||||
}`
|
||||
in := &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
Port: 443,
|
||||
Protocol: model.Hysteria,
|
||||
Remark: "hy2",
|
||||
Settings: `{"version":2,"clients":[{"auth":"hyauth","email":"user"}]}`,
|
||||
StreamSettings: stream,
|
||||
}
|
||||
got := (&SubService{}).genHysteriaLink(in, "user")
|
||||
if got == "" {
|
||||
t.Fatal("expected hysteria2 link")
|
||||
}
|
||||
if strings.Contains(got, "fm=") {
|
||||
t.Fatalf("hysteria2 subscription URI must not include non-standard fm param: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "obfs=salamander") {
|
||||
t.Fatalf("missing standard obfs=salamander: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "obfs-password=obfs-secret") {
|
||||
t.Fatalf("missing standard obfs-password: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,36 @@ func TestGenWireguardLinkFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenWireguardLinkMultiAllowedIPs(t *testing.T) {
|
||||
serverPriv, _, err := wgutil.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("keypair: %v", err)
|
||||
}
|
||||
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("client keypair: %v", err)
|
||||
}
|
||||
|
||||
inbound := &model.Inbound{
|
||||
Listen: "203.0.113.7",
|
||||
Port: 51820,
|
||||
Protocol: model.WireGuard,
|
||||
Remark: "wg-sub",
|
||||
Settings: `{"secretKey":"` + serverPriv + `","clients":[{"email":"user","privateKey":"` + clientPriv + `","allowedIPs":["10.0.0.2/32","fd00::2/128"]}]}`,
|
||||
}
|
||||
|
||||
s := &SubService{}
|
||||
link := s.genWireguardLink(inbound, "user")
|
||||
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
t.Fatalf("link does not parse: %v\n got: %s", err, link)
|
||||
}
|
||||
if got, want := u.Query().Get("address"), "10.0.0.2/32,fd00::2/128"; got != want {
|
||||
t.Fatalf("address = %q, want %q (all allowed IPs joined, not just the first)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenWireguardLinkWrongProtocol(t *testing.T) {
|
||||
s := &SubService{}
|
||||
vless := &model.Inbound{Protocol: model.VLESS, Settings: `{"clients":[{"email":"user"}]}`}
|
||||
|
||||
@@ -59,6 +59,8 @@ type AllSetting struct {
|
||||
SmtpCpu int `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"`
|
||||
SmtpMemory int `json:"smtpMemory" form:"smtpMemory" validate:"gte=0,lte=100"`
|
||||
|
||||
OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"`
|
||||
|
||||
TimeLocation string `json:"timeLocation" form:"timeLocation"`
|
||||
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"`
|
||||
TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"`
|
||||
|
||||
@@ -433,6 +433,35 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||
}
|
||||
}
|
||||
|
||||
if len(inboundIds) == 0 {
|
||||
merged := *existing
|
||||
applyClientRecordMerge(&merged, updated.ToRecord())
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"sub_id": merged.SubID,
|
||||
"uuid": merged.UUID,
|
||||
"password": merged.Password,
|
||||
"auth": merged.Auth,
|
||||
"secret": merged.Secret,
|
||||
"flow": merged.Flow,
|
||||
"security": merged.Security,
|
||||
"wg_private_key": merged.PrivateKey,
|
||||
"wg_public_key": merged.PublicKey,
|
||||
"wg_allowed_ips": merged.AllowedIPs,
|
||||
"wg_pre_shared_key": merged.PreSharedKey,
|
||||
"wg_keep_alive": merged.KeepAlive,
|
||||
"limit_ip": merged.LimitIP,
|
||||
"total_gb": merged.TotalGB,
|
||||
"expiry_time": merged.ExpiryTime,
|
||||
"tg_id": merged.TgID,
|
||||
"comment": merged.Comment,
|
||||
"reset": merged.Reset,
|
||||
}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
}
|
||||
|
||||
reverseStr := ""
|
||||
if updated.Reverse != nil && strings.TrimSpace(updated.Reverse.Tag) != "" {
|
||||
if b, mErr := json.Marshal(updated.Reverse); mErr == nil {
|
||||
|
||||
@@ -807,7 +807,9 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
}
|
||||
// Rename the client record in the same transaction as the settings JSON
|
||||
// so no concurrent SyncInbound can see one renamed without the other.
|
||||
if len(oldEmail) > 0 && !strings.EqualFold(oldEmail, clients[0].Email) {
|
||||
// Byte-level compare (not EqualFold): case-only edits must rename too,
|
||||
// otherwise SyncInbound's case-sensitive lookup creates a duplicate row.
|
||||
if len(oldEmail) > 0 && oldEmail != clients[0].Email {
|
||||
var renameTaken int64
|
||||
if e := tx.Model(&model.ClientRecord{}).Where("email = ?", clients[0].Email).Count(&renameTaken).Error; e != nil {
|
||||
return e
|
||||
|
||||
@@ -9,6 +9,65 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// applyClientRecordMerge merges incoming client-record fields onto row using the
|
||||
// same rules everywhere a client record is persisted: scalar quota / lifecycle /
|
||||
// subscription fields are applied unconditionally (so clearing them takes
|
||||
// effect), while credentials and identifiers are only overwritten when the
|
||||
// incoming value is non-empty (so a partial update preserves the stored UUID /
|
||||
// password / keys). CreatedAt keeps the earliest known value. Email, UpdatedAt,
|
||||
// and the Id primary key are intentionally not touched here — callers handle
|
||||
// those separately. Shared by SyncInbound (per-inbound persistence) and Update
|
||||
// (the no-attached-inbound fallback) so the two paths cannot diverge.
|
||||
func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecord) {
|
||||
if incoming.UUID != "" {
|
||||
row.UUID = incoming.UUID
|
||||
}
|
||||
if incoming.Password != "" {
|
||||
row.Password = incoming.Password
|
||||
}
|
||||
if incoming.Auth != "" {
|
||||
row.Auth = incoming.Auth
|
||||
}
|
||||
if incoming.Secret != "" {
|
||||
row.Secret = incoming.Secret
|
||||
}
|
||||
if incoming.AdTag != "" {
|
||||
row.AdTag = incoming.AdTag
|
||||
}
|
||||
row.Flow = incoming.Flow
|
||||
if incoming.Security != "" {
|
||||
row.Security = incoming.Security
|
||||
}
|
||||
if incoming.Reverse != "" {
|
||||
row.Reverse = incoming.Reverse
|
||||
}
|
||||
if incoming.PrivateKey != "" {
|
||||
row.PrivateKey = incoming.PrivateKey
|
||||
}
|
||||
if incoming.PublicKey != "" {
|
||||
row.PublicKey = incoming.PublicKey
|
||||
}
|
||||
if incoming.AllowedIPs != "" {
|
||||
row.AllowedIPs = incoming.AllowedIPs
|
||||
}
|
||||
row.PreSharedKey = incoming.PreSharedKey
|
||||
row.KeepAlive = incoming.KeepAlive
|
||||
row.SubID = incoming.SubID
|
||||
row.LimitIP = incoming.LimitIP
|
||||
row.TotalGB = incoming.TotalGB
|
||||
row.ExpiryTime = incoming.ExpiryTime
|
||||
row.Enable = incoming.Enable
|
||||
row.TgID = incoming.TgID
|
||||
if incoming.Group != "" {
|
||||
row.Group = incoming.Group
|
||||
}
|
||||
row.Comment = incoming.Comment
|
||||
row.Reset = incoming.Reset
|
||||
if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
|
||||
row.CreatedAt = incoming.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error {
|
||||
if tx == nil {
|
||||
tx = database.GetDB()
|
||||
@@ -66,53 +125,7 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
|
||||
}
|
||||
|
||||
before := *row
|
||||
if incoming.UUID != "" {
|
||||
row.UUID = incoming.UUID
|
||||
}
|
||||
if incoming.Password != "" {
|
||||
row.Password = incoming.Password
|
||||
}
|
||||
if incoming.Auth != "" {
|
||||
row.Auth = incoming.Auth
|
||||
}
|
||||
if incoming.Secret != "" {
|
||||
row.Secret = incoming.Secret
|
||||
}
|
||||
if incoming.AdTag != "" {
|
||||
row.AdTag = incoming.AdTag
|
||||
}
|
||||
row.Flow = incoming.Flow
|
||||
if incoming.Security != "" {
|
||||
row.Security = incoming.Security
|
||||
}
|
||||
if incoming.Reverse != "" {
|
||||
row.Reverse = incoming.Reverse
|
||||
}
|
||||
if incoming.PrivateKey != "" {
|
||||
row.PrivateKey = incoming.PrivateKey
|
||||
}
|
||||
if incoming.PublicKey != "" {
|
||||
row.PublicKey = incoming.PublicKey
|
||||
}
|
||||
if incoming.AllowedIPs != "" {
|
||||
row.AllowedIPs = incoming.AllowedIPs
|
||||
}
|
||||
row.PreSharedKey = incoming.PreSharedKey
|
||||
row.KeepAlive = incoming.KeepAlive
|
||||
row.SubID = incoming.SubID
|
||||
row.LimitIP = incoming.LimitIP
|
||||
row.TotalGB = incoming.TotalGB
|
||||
row.ExpiryTime = incoming.ExpiryTime
|
||||
row.Enable = incoming.Enable
|
||||
row.TgID = incoming.TgID
|
||||
if incoming.Group != "" {
|
||||
row.Group = incoming.Group
|
||||
}
|
||||
row.Comment = incoming.Comment
|
||||
row.Reset = incoming.Reset
|
||||
if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
|
||||
row.CreatedAt = incoming.CreatedAt
|
||||
}
|
||||
applyClientRecordMerge(row, incoming)
|
||||
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
|
||||
row.UpdatedAt = preservedUpdatedAt
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestAddClientStat_RefreshesStaleRowOnInboundDeleteThenReuse covers #5958:
|
||||
// deleting a client's only inbound leaves its clients/client_traffics rows in
|
||||
// place (matching ClientService.Detach, which does the same on purpose so a
|
||||
// later Attach can resume a client with its accumulated traffic intact). If
|
||||
// that same email is instead reused for a freshly (re)created client via
|
||||
// ClientService.Create, the new enable/expiry/reset/total must win over
|
||||
// whatever the orphaned row still holds instead of being silently ignored by
|
||||
// AddClientStat's OnConflict.
|
||||
func TestAddClientStat_RefreshesStaleRowOnInboundDeleteThenReuse(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
const email = "reused@example.com"
|
||||
const subID = "sub-reused"
|
||||
|
||||
ibA := mkInbound(t, 22001, model.VLESS, `{"clients":[]}`)
|
||||
|
||||
// Create starts every client enabled (it forces Enable=true), so the
|
||||
// depleted/disabled shape a client has by the time it's naturally deleted
|
||||
// is reached the same way production reaches it: a follow-up Update, not
|
||||
// the initial Create.
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{
|
||||
Email: email, SubID: subID, Enable: true,
|
||||
TotalGB: 0, ExpiryTime: 1000, Reset: 0,
|
||||
},
|
||||
InboundIds: []int{ibA.Id},
|
||||
}); err != nil {
|
||||
t.Fatalf("initial Create: %v", err)
|
||||
}
|
||||
rec0 := lookupClientRecord(t, email)
|
||||
if _, err := svc.Update(inboundSvc, rec0.Id, model.Client{
|
||||
Email: email, SubID: subID, Enable: false,
|
||||
TotalGB: 0, ExpiryTime: 1000, Reset: 0,
|
||||
}); err != nil {
|
||||
t.Fatalf("Update to disabled: %v", err)
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
var before xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&before).Error; err != nil {
|
||||
t.Fatalf("lookup client_traffics before delete: %v", err)
|
||||
}
|
||||
if before.Enable || before.Reset != 0 || before.Total != 0 {
|
||||
t.Fatalf("unexpected initial client_traffics row: %+v", before)
|
||||
}
|
||||
|
||||
// Delete the client's only inbound. This must NOT delete the client or its
|
||||
// traffic row (that's the documented, intentional Detach-parity behavior) —
|
||||
// it only orphans them.
|
||||
if _, err := inboundSvc.DelInbound(ibA.Id); err != nil {
|
||||
t.Fatalf("DelInbound: %v", err)
|
||||
}
|
||||
|
||||
rec := lookupClientRecord(t, email)
|
||||
ids, err := svc.GetInboundIdsForRecord(rec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInboundIdsForRecord: %v", err)
|
||||
}
|
||||
if len(ids) != 0 {
|
||||
t.Fatalf("client should be fully detached after its only inbound was deleted, still attached to: %v", ids)
|
||||
}
|
||||
var stillThere xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&stillThere).Error; err != nil {
|
||||
t.Fatalf("client_traffics row should survive inbound deletion (Detach parity), lookup failed: %v", err)
|
||||
}
|
||||
|
||||
// Reuse the same email + subId (the only way ClientService.Create allows
|
||||
// re-adding under an already-used email) on a freshly created inbound, with
|
||||
// deliberately different, "fresh" settings.
|
||||
ibB := mkInbound(t, 22002, model.VLESS, `{"clients":[]}`)
|
||||
const wantExpiry = int64(9999999999000)
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{
|
||||
Email: email, SubID: subID, Enable: true,
|
||||
TotalGB: 10 << 30, ExpiryTime: wantExpiry, Reset: 5,
|
||||
},
|
||||
InboundIds: []int{ibB.Id},
|
||||
}); err != nil {
|
||||
t.Fatalf("reuse Create: %v", err)
|
||||
}
|
||||
|
||||
var after xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&after).Error; err != nil {
|
||||
t.Fatalf("lookup client_traffics after reuse: %v", err)
|
||||
}
|
||||
if !after.Enable {
|
||||
t.Errorf("client_traffics.enable still stale (false) after reuse, want true")
|
||||
}
|
||||
if after.Reset != 5 {
|
||||
t.Errorf("client_traffics.reset = %d, want 5 (stale value from before delete was 0)", after.Reset)
|
||||
}
|
||||
if after.Total != 10<<30 {
|
||||
t.Errorf("client_traffics.total = %d, want %d", after.Total, int64(10<<30))
|
||||
}
|
||||
if after.ExpiryTime != wantExpiry {
|
||||
t.Errorf("client_traffics.expiry_time = %d, want %d", after.ExpiryTime, wantExpiry)
|
||||
}
|
||||
if after.InboundId != ibB.Id {
|
||||
t.Errorf("client_traffics.inbound_id = %d, want refreshed to new inbound %d (was %d)", after.InboundId, ibB.Id, ibA.Id)
|
||||
}
|
||||
|
||||
// up/down are deliberately NOT refreshed by AddClientStat — confirm that
|
||||
// stays true (would matter if the original client had real usage).
|
||||
if after.Up != before.Up || after.Down != before.Down {
|
||||
t.Errorf("up/down should be left untouched by the conflict refresh: before up=%d down=%d, after up=%d down=%d",
|
||||
before.Up, before.Down, after.Up, after.Down)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddClientStat_MultiInboundReattachStaysIdempotent guards the legitimate
|
||||
// case AddClientStat's OnConflict is also responsible for: a client attached
|
||||
// to two inbounds at once shares one client_traffics row, and re-asserting
|
||||
// its own current settings for the second inbound must be a no-op in effect,
|
||||
// not a data loss. In particular it must not zero out real accumulated
|
||||
// traffic just because the client gained a second attachment.
|
||||
func TestAddClientStat_MultiInboundReattachStaysIdempotent(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
const email = "multi@example.com"
|
||||
const subID = "sub-multi"
|
||||
|
||||
ibA := mkInbound(t, 22003, model.VLESS, `{"clients":[]}`)
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{
|
||||
Email: email, SubID: subID, Enable: true,
|
||||
TotalGB: 5 << 30, ExpiryTime: 42, Reset: 3,
|
||||
},
|
||||
InboundIds: []int{ibA.Id},
|
||||
}); err != nil {
|
||||
t.Fatalf("first Create: %v", err)
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
// Simulate real accumulated usage before the second attachment.
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"up": int64(123), "down": int64(456)}).Error; err != nil {
|
||||
t.Fatalf("seed usage: %v", err)
|
||||
}
|
||||
|
||||
ibB := mkInbound(t, 22004, model.VLESS, `{"clients":[]}`)
|
||||
// Re-adding the same identity to a second inbound: same email/subId/settings,
|
||||
// exactly what the panel does when attaching an existing client elsewhere.
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{
|
||||
Email: email, SubID: subID, Enable: true,
|
||||
TotalGB: 5 << 30, ExpiryTime: 42, Reset: 3,
|
||||
},
|
||||
InboundIds: []int{ibB.Id},
|
||||
}); err != nil {
|
||||
t.Fatalf("second Create (attach to ibB): %v", err)
|
||||
}
|
||||
|
||||
var row xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&row).Error; err != nil {
|
||||
t.Fatalf("lookup after second attach: %v", err)
|
||||
}
|
||||
if row.Up != 123 || row.Down != 456 {
|
||||
t.Errorf("accumulated traffic was reset by re-attach: up=%d down=%d, want 123/456", row.Up, row.Down)
|
||||
}
|
||||
if !row.Enable || row.Reset != 3 || row.Total != 5<<30 || row.ExpiryTime != 42 {
|
||||
t.Errorf("config columns changed unexpectedly on idempotent re-assert: %+v", row)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestUpdate_PersistsFields_NoInbound(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(c *model.Client)
|
||||
readBack func(rec *model.ClientRecord) any
|
||||
want any
|
||||
}{
|
||||
{
|
||||
name: "subId",
|
||||
mutate: func(c *model.Client) { c.SubID = "new-sub-id" },
|
||||
readBack: func(rec *model.ClientRecord) any { return rec.SubID },
|
||||
want: "new-sub-id",
|
||||
},
|
||||
{
|
||||
name: "totalGB cleared to zero",
|
||||
mutate: func(c *model.Client) { c.TotalGB = 0 },
|
||||
readBack: func(rec *model.ClientRecord) any { return rec.TotalGB },
|
||||
want: int64(0),
|
||||
},
|
||||
{
|
||||
name: "expiryTime",
|
||||
mutate: func(c *model.Client) { c.ExpiryTime = 1700000000 },
|
||||
readBack: func(rec *model.ClientRecord) any { return rec.ExpiryTime },
|
||||
want: int64(1700000000),
|
||||
},
|
||||
{
|
||||
name: "limitIp",
|
||||
mutate: func(c *model.Client) { c.LimitIP = 7 },
|
||||
readBack: func(rec *model.ClientRecord) any { return rec.LimitIP },
|
||||
want: 7,
|
||||
},
|
||||
{
|
||||
name: "tgId",
|
||||
mutate: func(c *model.Client) { c.TgID = 9876543210 },
|
||||
readBack: func(rec *model.ClientRecord) any { return rec.TgID },
|
||||
want: int64(9876543210),
|
||||
},
|
||||
{
|
||||
name: "comment cleared to empty",
|
||||
mutate: func(c *model.Client) { c.Comment = "" },
|
||||
readBack: func(rec *model.ClientRecord) any { return rec.Comment },
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "reset",
|
||||
mutate: func(c *model.Client) { c.Reset = 30 },
|
||||
readBack: func(rec *model.ClientRecord) any { return rec.Reset },
|
||||
want: 30,
|
||||
},
|
||||
{
|
||||
name: "flow",
|
||||
mutate: func(c *model.Client) { c.Flow = "xtls-rprx-vision" },
|
||||
readBack: func(rec *model.ClientRecord) any { return rec.Flow },
|
||||
want: "xtls-rprx-vision",
|
||||
},
|
||||
{
|
||||
name: "security",
|
||||
mutate: func(c *model.Client) { c.Security = "aes-128-gcm" },
|
||||
readBack: func(rec *model.ClientRecord) any { return rec.Security },
|
||||
want: "aes-128-gcm",
|
||||
},
|
||||
{
|
||||
name: "uuid rotated",
|
||||
mutate: func(c *model.Client) { c.ID = "22222222-2222-2222-2222-222222222222" },
|
||||
readBack: func(rec *model.ClientRecord) any { return rec.UUID },
|
||||
want: "22222222-2222-2222-2222-222222222222",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
email := "noib-" + strings.ReplaceAll(strings.ToLower(tc.name), " ", "-") + "@x"
|
||||
rec := &model.ClientRecord{
|
||||
Email: email,
|
||||
UUID: "11111111-1111-1111-1111-111111111111",
|
||||
SubID: email,
|
||||
TotalGB: 5,
|
||||
ExpiryTime: 1000,
|
||||
LimitIP: 1,
|
||||
TgID: 1,
|
||||
Comment: "seeded",
|
||||
Reset: 1,
|
||||
Flow: "seeded-flow",
|
||||
Security: "seeded-sec",
|
||||
}
|
||||
if err := database.GetDB().Create(rec).Error; err != nil {
|
||||
t.Fatalf("create record: %v", err)
|
||||
}
|
||||
|
||||
updated := rec.ToClient()
|
||||
tc.mutate(updated)
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.GetByID(rec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if tc.readBack(got) != tc.want {
|
||||
t.Fatalf("%s: not persisted for no-inbound client, got %v, want %v", tc.name, tc.readBack(got), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_NoInbound_PreservesCredentialsWhenOmitted(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
email := "noib-preserve@x"
|
||||
rec := &model.ClientRecord{
|
||||
Email: email,
|
||||
UUID: "11111111-1111-1111-1111-111111111111",
|
||||
SubID: email,
|
||||
Password: "seeded-pw",
|
||||
Auth: "seeded-auth",
|
||||
Secret: "seeded-secret",
|
||||
}
|
||||
if err := database.GetDB().Create(rec).Error; err != nil {
|
||||
t.Fatalf("create record: %v", err)
|
||||
}
|
||||
|
||||
updated := rec.ToClient()
|
||||
updated.ID = ""
|
||||
updated.Password = ""
|
||||
updated.Auth = ""
|
||||
updated.Secret = ""
|
||||
updated.Comment = "only comment changed"
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.GetByID(rec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.UUID != "11111111-1111-1111-1111-111111111111" {
|
||||
t.Fatalf("uuid wiped on partial update, got %q", got.UUID)
|
||||
}
|
||||
if got.Password != "seeded-pw" {
|
||||
t.Fatalf("password wiped on partial update, got %q", got.Password)
|
||||
}
|
||||
if got.Auth != "seeded-auth" {
|
||||
t.Fatalf("auth wiped on partial update, got %q", got.Auth)
|
||||
}
|
||||
if got.Secret != "seeded-secret" {
|
||||
t.Fatalf("secret wiped on partial update, got %q", got.Secret)
|
||||
}
|
||||
if got.Comment != "only comment changed" {
|
||||
t.Fatalf("comment not persisted, got %q", got.Comment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyClientRecordMerge_MirrorsSyncInboundRules(t *testing.T) {
|
||||
row := &model.ClientRecord{
|
||||
UUID: "kept-uuid",
|
||||
Password: "kept-pw",
|
||||
Flow: "kept-flow",
|
||||
TotalGB: 9,
|
||||
Group: "kept-group",
|
||||
Comment: "kept-comment",
|
||||
}
|
||||
incoming := &model.ClientRecord{
|
||||
Password: "new-pw",
|
||||
TotalGB: 0,
|
||||
Comment: "new-comment",
|
||||
}
|
||||
|
||||
applyClientRecordMerge(row, incoming)
|
||||
|
||||
if row.UUID != "kept-uuid" {
|
||||
t.Fatalf("empty incoming UUID should preserve stored UUID, got %q", row.UUID)
|
||||
}
|
||||
if row.Password != "new-pw" {
|
||||
t.Fatalf("non-empty incoming Password should overwrite, got %q", row.Password)
|
||||
}
|
||||
if row.Flow != "" {
|
||||
t.Fatalf("incoming Flow is unconditional and should overwrite with empty, got %q", row.Flow)
|
||||
}
|
||||
if row.TotalGB != 0 {
|
||||
t.Fatalf("incoming TotalGB is unconditional and should overwrite with zero, got %v", row.TotalGB)
|
||||
}
|
||||
if row.Group != "kept-group" {
|
||||
t.Fatalf("empty incoming Group should preserve stored group, got %q", row.Group)
|
||||
}
|
||||
if row.Comment != "new-comment" {
|
||||
t.Fatalf("incoming Comment is unconditional and should overwrite, got %q", row.Comment)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,36 @@ func TestUpdateInboundClientRenameDoesNotDuplicateRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInboundClientCaseOnlyRenameDoesNotDuplicateRecord(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{{Email: "test", ID: "aaaaaaaa-0000-0000-0000-000000000002", SubID: "sub-case", Enable: true}}
|
||||
ib := mkInbound(t, 22002, model.VLESS, clientsSettings(t, source))
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
origId := lookupClientRecord(t, "test").Id
|
||||
|
||||
updated := source[0]
|
||||
updated.Email = "Test"
|
||||
if _, err := svc.Update(inboundSvc, origId, updated); err != nil {
|
||||
t.Fatalf("Update case-only email: %v", err)
|
||||
}
|
||||
|
||||
if n := countClientRecords(t); n != 1 {
|
||||
t.Fatalf("client records after case-only rename = %d, want 1", n)
|
||||
}
|
||||
rec := lookupClientRecord(t, "Test")
|
||||
if rec.Id != origId {
|
||||
t.Fatalf("record id after case-only rename = %d, want %d", rec.Id, origId)
|
||||
}
|
||||
if rec.Email != "Test" {
|
||||
t.Fatalf("email after case-only rename = %q, want %q", rec.Email, "Test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
@@ -830,10 +830,17 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
if err := tx.Omit("ClientStats").Save(inbound).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Emails seeded here (import's ClientStats, e.g. the controller's forced
|
||||
// Enable=true on every imported stat row) are authoritative for this call
|
||||
// and must not be clobbered by the AddClientStat loop below, which derives
|
||||
// its enable/total/expiry/reset from Settings.clients[] instead — a second,
|
||||
// possibly-stale source for the same columns on a plain (non-import) create.
|
||||
statEmails := make(map[string]bool, len(inbound.ClientStats))
|
||||
for i := range inbound.ClientStats {
|
||||
if inbound.ClientStats[i].Email == "" {
|
||||
continue
|
||||
}
|
||||
statEmails[inbound.ClientStats[i].Email] = true
|
||||
inbound.ClientStats[i].Id = 0
|
||||
inbound.ClientStats[i].InboundId = inbound.Id
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
@@ -844,6 +851,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
}
|
||||
}
|
||||
for _, client := range clients {
|
||||
if statEmails[client.Email] {
|
||||
continue
|
||||
}
|
||||
if err := s.AddClientStat(tx, inbound.Id, &client); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -125,3 +125,37 @@ func TestAddInbound_ImportStatsMissingClientStillGetsTrafficRow(t *testing.T) {
|
||||
t.Fatalf("erin Total = %d, want 2000 (quota taken from client settings)", erin.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddInbound_ImportForcedEnableSurvivesDisabledSettingsClient covers a
|
||||
// regression the AddClientStat OnConflict fix (#5958) could otherwise
|
||||
// introduce: controller.importInbound forces every ClientStats row to
|
||||
// Enable=true (imports are meant to bring every client back enabled), but
|
||||
// Settings.clients[].enable is untouched and can still say false for a client
|
||||
// that was disabled at export time. AddInbound runs the ClientStats loop
|
||||
// first (plain insert, Enable=true) and then calls AddClientStat once per
|
||||
// Settings-derived client on the same email — that second call must not let
|
||||
// the stale, disabled Settings value win over the forced-enabled row.
|
||||
func TestAddInbound_ImportForcedEnableSurvivesDisabledSettingsClient(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
settings := `{"clients":[` +
|
||||
`{"id":"66666666-6666-6666-6666-666666666666","email":"frank","subId":"s-frank","enable":false,"totalGB":5000}` +
|
||||
`],"decryption":"none","encryption":"none"}`
|
||||
// makeImportInbound forces Enable=true on every stats row, matching
|
||||
// controller.importInbound's behavior regardless of what's passed here.
|
||||
in := makeImportInbound("in-9104-tcp", 9104, settings, []xray.ClientTraffic{
|
||||
{Email: "frank", Up: 10, Down: 20, Total: 5000},
|
||||
})
|
||||
if _, _, err := svc.AddInbound(in); err != nil {
|
||||
t.Fatalf("import inbound: %v", err)
|
||||
}
|
||||
|
||||
var frank xray.ClientTraffic
|
||||
if err := database.GetDB().Where("email = ?", "frank").First(&frank).Error; err != nil {
|
||||
t.Fatalf("frank row: %v", err)
|
||||
}
|
||||
if !frank.Enable {
|
||||
t.Fatalf("frank.Enable = false, want true (import must force-enable even though Settings still says disabled)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,8 +116,9 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||
// attached to a local inbound. The old `inbound_id NOT IN (node inbounds)`
|
||||
// filter dropped the local traffic of a client attached to both a node and the
|
||||
// mother inbound whenever the node inbound happened to be attached first — its
|
||||
// shared row then carried the node inbound's id (AddClientStat uses OnConflict
|
||||
// DoNothing and never refreshes it), so the local poll skipped it entirely.
|
||||
// shared row then carried the node inbound's id (AddClientStat used to use
|
||||
// OnConflict DoNothing and never refreshed it; it now refreshes inbound_id on
|
||||
// conflict, but this filter was removed rather than relying on that ordering).
|
||||
err = tx.Model(xray.ClientTraffic{}).
|
||||
Where("email IN (?)", emails).
|
||||
Find(&dbClientTraffics).Error
|
||||
@@ -446,9 +447,28 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
|
||||
return needRestart, int64(len(traffics)), nil
|
||||
}
|
||||
|
||||
// AddClientStat inserts a per-client accounting row, no-op on email
|
||||
// conflict. Xray reports traffic per email, so the surviving row acts as
|
||||
// the shared accumulator for inbounds that re-use the same identity.
|
||||
// AddClientStat inserts a per-client accounting row, or refreshes the
|
||||
// config-derived columns on an email conflict. Xray reports traffic per
|
||||
// email, so the surviving row also acts as the shared accumulator for
|
||||
// inbounds that re-use the same identity — every call for that identity
|
||||
// (one per attached inbound) carries the same enable/expiry/reset/total,
|
||||
// so re-asserting them here is idempotent for that legitimate case.
|
||||
//
|
||||
// The conflict path matters on its own for a second reason: an inbound
|
||||
// delete detaches its clients (InboundService.DelInbound) without deleting
|
||||
// their client_traffics row, by design — mirroring ClientService.Detach,
|
||||
// which intentionally leaves a fully-detached client's row in place so a
|
||||
// later Attach can resume it with its accumulated traffic intact. If that
|
||||
// same email is instead reused for a freshly (re)created client, the new
|
||||
// config's enable/expiry/reset/total must win over whatever the orphaned
|
||||
// row still holds; DoNothing left them stale indefinitely (#5958).
|
||||
//
|
||||
// up/down are deliberately excluded from the refresh: they are the
|
||||
// accumulated traffic totals, and zeroing them here would erase real usage
|
||||
// every time an existing, actively-used client is attached to one more
|
||||
// inbound. One tradeoff this does not resolve: a genuinely new client that
|
||||
// happens to reuse an orphaned email still inherits that row's leftover
|
||||
// up/down, since nothing at this call site can tell the two cases apart.
|
||||
func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model.Client) error {
|
||||
clientTraffic := xray.ClientTraffic{
|
||||
InboundId: inboundId,
|
||||
@@ -458,8 +478,10 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model
|
||||
Enable: client.Enable,
|
||||
Reset: client.Reset,
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "email"}}, DoNothing: true}).
|
||||
Create(&clientTraffic).Error
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "email"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset"}),
|
||||
}).Create(&clientTraffic).Error
|
||||
}
|
||||
|
||||
func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *model.Client) error {
|
||||
|
||||
@@ -155,6 +155,9 @@ var defaultValueMap = map[string]string{
|
||||
"smtpCpu": "80",
|
||||
"smtpMemory": "80",
|
||||
|
||||
// Consecutive failed observatory probes before an outbound.down event fires
|
||||
"outboundDownThreshold": "3",
|
||||
|
||||
// Email (SMTP) notifications
|
||||
"smtpEnable": "false",
|
||||
"smtpHost": "",
|
||||
@@ -1136,6 +1139,17 @@ func (s *SettingService) SetSmtpMemory(value int) error {
|
||||
return s.setInt("smtpMemory", value)
|
||||
}
|
||||
|
||||
// GetOutboundDownThreshold returns how many consecutive failed observatory
|
||||
// probes an outbound must accumulate before an outbound.down notification is
|
||||
// emitted. 1 preserves the legacy "notify on the first failed probe" behaviour.
|
||||
func (s *SettingService) GetOutboundDownThreshold() (int, error) {
|
||||
return s.getInt("outboundDownThreshold")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetOutboundDownThreshold(value int) error {
|
||||
return s.setInt("outboundDownThreshold", value)
|
||||
}
|
||||
|
||||
// SecretClears marks redacted secrets the user explicitly emptied. Without a
|
||||
// flag, a blank submitted secret means "unchanged" (the field is always served
|
||||
// blank to the browser) and the stored value is preserved.
|
||||
|
||||
@@ -354,10 +354,10 @@ const PanelEgressInboundTag = "panel-egress"
|
||||
// already taken by other inbounds in the generated config are skipped.
|
||||
const panelEgressBasePort = 62790
|
||||
|
||||
// injectPanelEgress appends a loopback SOCKS inbound to the generated config
|
||||
// and prepends a routing rule sending it to outboundTag. Both live only in the
|
||||
// generated config — the stored template is never modified — and both are
|
||||
// hot-appliable, so changing the panel outbound never restarts the core.
|
||||
// injectPanelEgress appends a loopback SOCKS inbound and routing rule only when
|
||||
// outboundTag resolves in the final outbound or balancer set. Otherwise the
|
||||
// entire injection is skipped. Generated state is hot-appliable and never
|
||||
// modifies the stored template or restarts the core.
|
||||
func injectPanelEgress(cfg *xray.Config, outboundTag string) {
|
||||
for i := range cfg.InboundConfigs {
|
||||
if cfg.InboundConfigs[i].Tag == PanelEgressInboundTag {
|
||||
@@ -375,6 +375,10 @@ func injectPanelEgress(cfg *xray.Config, outboundTag string) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if !routingTargetExists(routing, cfg.OutboundConfigs, outboundTag) {
|
||||
logger.Warning("panel egress: target tag [", outboundTag, "] not found, skipping injection")
|
||||
return
|
||||
}
|
||||
rules, _ := routing["rules"].([]any)
|
||||
rule := map[string]any{
|
||||
"type": "field",
|
||||
@@ -418,6 +422,25 @@ func injectPanelEgress(cfg *xray.Config, outboundTag string) {
|
||||
})
|
||||
}
|
||||
|
||||
func outboundTagExists(outbounds json_util.RawMessage, tag string) bool {
|
||||
var parsed []struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if tag == "" || json.Unmarshal(outbounds, &parsed) != nil {
|
||||
return false
|
||||
}
|
||||
for _, outbound := range parsed {
|
||||
if outbound.Tag == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func routingTargetExists(routing map[string]any, outbounds json_util.RawMessage, tag string) bool {
|
||||
return routingTagIsBalancer(routing, tag) || outboundTagExists(outbounds, tag)
|
||||
}
|
||||
|
||||
// NodeEgressInboundTag returns the loopback SOCKS inbound tag for a given node.
|
||||
func NodeEgressInboundTag(nodeID int) string {
|
||||
return fmt.Sprintf("node-egress-%d", nodeID)
|
||||
@@ -452,6 +475,10 @@ func injectNodeEgresses(cfg *xray.Config, nodes []*model.Node) {
|
||||
if !n.Enable || n.OutboundTag == "" {
|
||||
continue
|
||||
}
|
||||
if !routingTargetExists(routing, cfg.OutboundConfigs, n.OutboundTag) {
|
||||
logger.Warning("node egress: target tag [", n.OutboundTag, "] not found, skipping node [", n.Id, "]")
|
||||
continue
|
||||
}
|
||||
tag := NodeEgressInboundTag(n.Id)
|
||||
if _, exists := usedTags[tag]; exists {
|
||||
logger.Warning("node egress: inbound tag [", tag, "] already exists, skipping")
|
||||
@@ -529,11 +556,11 @@ func routingTagIsBalancer(routing map[string]any, tag string) bool {
|
||||
const mtprotoEgressSocksSettings = `{"auth":"noauth","udp":false}`
|
||||
|
||||
// injectMtprotoEgress wires one routed mtproto inbound into the generated
|
||||
// config: it appends a loopback SOCKS inbound (tagged with the inbound's own tag,
|
||||
// on the egress port persisted in settings) and, when an outbound is selected,
|
||||
// prepends a routing rule sending that tag to it. Both live only in the generated
|
||||
// config — the stored template is untouched — and both are hot-appliable, so
|
||||
// toggling routing never forces a full Xray restart. Mirrors injectPanelEgress.
|
||||
// config after any selected outbound resolves in the final target set. Invalid
|
||||
// selected targets or routing data skip the entire injection; without a selected
|
||||
// outbound, the bridge retains default-route behavior. Generated state remains
|
||||
// hot-appliable, leaves the stored template untouched, and never forces a full
|
||||
// Xray restart. Mirrors injectPanelEgress.
|
||||
func injectMtprotoEgress(cfg *xray.Config, inbound *model.Inbound) {
|
||||
var parsed struct {
|
||||
RouteThroughXray bool `json:"routeThroughXray"`
|
||||
@@ -556,31 +583,33 @@ func injectMtprotoEgress(cfg *xray.Config, inbound *model.Inbound) {
|
||||
|
||||
if parsed.OutboundTag != "" {
|
||||
routing := map[string]any{}
|
||||
parseOK := true
|
||||
if len(cfg.RouterConfig) > 0 {
|
||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||
logger.Warning("mtproto egress: routing section is unparsable, skipping rule:", err)
|
||||
parseOK = false
|
||||
logger.Warning("mtproto egress: routing section is unparsable, skipping injection:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if parseOK {
|
||||
rules, _ := routing["rules"].([]any)
|
||||
rule := map[string]any{
|
||||
"type": "field",
|
||||
"inboundTag": []any{tag},
|
||||
}
|
||||
if routingTagIsBalancer(routing, parsed.OutboundTag) {
|
||||
rule["balancerTag"] = parsed.OutboundTag
|
||||
} else {
|
||||
rule["outboundTag"] = parsed.OutboundTag
|
||||
}
|
||||
routing["rules"] = append([]any{rule}, rules...)
|
||||
if newRouting, err := json.Marshal(routing); err == nil {
|
||||
cfg.RouterConfig = json_util.RawMessage(newRouting)
|
||||
} else {
|
||||
logger.Warning("mtproto egress: failed to rebuild routing section, skipping rule:", err)
|
||||
}
|
||||
if !routingTargetExists(routing, cfg.OutboundConfigs, parsed.OutboundTag) {
|
||||
logger.Warning("mtproto egress: target tag [", parsed.OutboundTag, "] not found, skipping injection")
|
||||
return
|
||||
}
|
||||
rules, _ := routing["rules"].([]any)
|
||||
rule := map[string]any{
|
||||
"type": "field",
|
||||
"inboundTag": []any{tag},
|
||||
}
|
||||
if routingTagIsBalancer(routing, parsed.OutboundTag) {
|
||||
rule["balancerTag"] = parsed.OutboundTag
|
||||
} else {
|
||||
rule["outboundTag"] = parsed.OutboundTag
|
||||
}
|
||||
routing["rules"] = append([]any{rule}, rules...)
|
||||
newRouting, err := json.Marshal(routing)
|
||||
if err != nil {
|
||||
logger.Warning("mtproto egress: failed to rebuild routing section, skipping injection:", err)
|
||||
return
|
||||
}
|
||||
cfg.RouterConfig = json_util.RawMessage(newRouting)
|
||||
}
|
||||
|
||||
cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
|
||||
|
||||
@@ -127,7 +127,8 @@ func TestEnsureStatsPolicy(t *testing.T) {
|
||||
|
||||
func egressTestConfig() *xray.Config {
|
||||
return &xray.Config{
|
||||
RouterConfig: json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),
|
||||
RouterConfig: json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),
|
||||
OutboundConfigs: json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"socks","tag":"warp"}]`),
|
||||
InboundConfigs: []xray.InboundConfig{
|
||||
{Port: 62789, Protocol: "tunnel", Tag: "api", Listen: json_util.RawMessage(`"127.0.0.1"`)},
|
||||
},
|
||||
@@ -278,6 +279,146 @@ func TestInjectPanelEgress_BadRoutingSkips(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPanelEgress_MissingTargetSkips(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
before := string(cfg.RouterConfig)
|
||||
injectPanelEgress(cfg, "removed-subscription-outbound")
|
||||
if len(cfg.InboundConfigs) != 1 {
|
||||
t.Fatalf("a missing target must not expose the panel bridge, got %+v", cfg.InboundConfigs)
|
||||
}
|
||||
if string(cfg.RouterConfig) != before {
|
||||
t.Fatalf("a missing target must leave routing untouched, got %s", cfg.RouterConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPanelEgress_BadOutboundsSkips(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
|
||||
before := string(cfg.RouterConfig)
|
||||
injectPanelEgress(cfg, "direct")
|
||||
if len(cfg.InboundConfigs) != 1 {
|
||||
t.Fatalf("unparsable outbounds must not expose the panel bridge, got %+v", cfg.InboundConfigs)
|
||||
}
|
||||
if string(cfg.RouterConfig) != before {
|
||||
t.Fatalf("unparsable outbounds must leave routing untouched, got %s", cfg.RouterConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectNodeEgresses_MissingTargetSkips(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
injectNodeEgresses(cfg, []*model.Node{
|
||||
{Id: 1, Enable: true, OutboundTag: "removed-subscription-outbound"},
|
||||
{Id: 2, Enable: true, OutboundTag: "warp"},
|
||||
})
|
||||
|
||||
if len(cfg.InboundConfigs) != 2 {
|
||||
t.Fatalf("only the node with a valid target should get a bridge, got %+v", cfg.InboundConfigs)
|
||||
}
|
||||
bridge := cfg.InboundConfigs[1]
|
||||
if bridge.Tag != NodeEgressInboundTag(2) || bridge.Port != nodeEgressBasePort+2 {
|
||||
t.Fatalf("unexpected node egress bridge: %+v", bridge)
|
||||
}
|
||||
|
||||
var routing egressRouting
|
||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(routing.Rules) != 2 || routing.Rules[0].OutboundTag != "warp" ||
|
||||
len(routing.Rules[0].InboundTag) != 1 || routing.Rules[0].InboundTag[0] != NodeEgressInboundTag(2) {
|
||||
t.Fatalf("only the valid node egress rule should be prepended, got %+v", routing.Rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectNodeEgresses_BadOutboundsSkips(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
|
||||
before := string(cfg.RouterConfig)
|
||||
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
|
||||
|
||||
if len(cfg.InboundConfigs) != 1 {
|
||||
t.Fatalf("unparsable outbounds must not expose a node bridge, got %+v", cfg.InboundConfigs)
|
||||
}
|
||||
if string(cfg.RouterConfig) != before {
|
||||
t.Fatalf("unparsable outbounds must leave routing untouched, got %s", cfg.RouterConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectNodeEgresses_BalancerTarget(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
cfg.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
|
||||
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "lb"}})
|
||||
|
||||
var routing struct {
|
||||
Rules []struct {
|
||||
OutboundTag string `json:"outboundTag"`
|
||||
BalancerTag string `json:"balancerTag"`
|
||||
} `json:"rules"`
|
||||
}
|
||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.InboundConfigs) != 2 || len(routing.Rules) != 1 ||
|
||||
routing.Rules[0].BalancerTag != "lb" || routing.Rules[0].OutboundTag != "" {
|
||||
t.Fatalf("a valid balancer target must create the node bridge and rule, got %+v", routing.Rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectNodeEgresses_TagCollisionSkips(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
||||
xray.InboundConfig{Port: 1234, Protocol: "socks", Tag: NodeEgressInboundTag(1)},
|
||||
)
|
||||
before := string(cfg.RouterConfig)
|
||||
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
|
||||
|
||||
if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
|
||||
t.Fatal("an existing node egress tag must make that node injection a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectNodeEgresses_PortCollision(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
||||
xray.InboundConfig{Port: nodeEgressBasePort + 1, Protocol: "vless", Tag: "in-1"},
|
||||
xray.InboundConfig{Port: nodeEgressBasePort + 2, Protocol: "vless", Tag: "in-2"},
|
||||
)
|
||||
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
|
||||
|
||||
bridge := cfg.InboundConfigs[len(cfg.InboundConfigs)-1]
|
||||
if bridge.Tag != NodeEgressInboundTag(1) || bridge.Port != nodeEgressBasePort+3 {
|
||||
t.Fatalf("node egress must skip taken ports, got %+v", bridge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectNodeEgresses_NoRoutingSection(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
cfg.RouterConfig = nil
|
||||
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
|
||||
|
||||
var routing egressRouting
|
||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.InboundConfigs) != 2 || len(routing.Rules) != 1 ||
|
||||
routing.Rules[0].OutboundTag != "direct" ||
|
||||
len(routing.Rules[0].InboundTag) != 1 || routing.Rules[0].InboundTag[0] != NodeEgressInboundTag(1) {
|
||||
t.Fatalf("a routing section must be created with the node egress rule, got %+v", routing.Rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectNodeEgresses_BadRoutingSkips(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
cfg.RouterConfig = json_util.RawMessage(`{not json`)
|
||||
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
|
||||
|
||||
if len(cfg.InboundConfigs) != 1 {
|
||||
t.Fatalf("unparsable routing must not expose a node bridge, got %+v", cfg.InboundConfigs)
|
||||
}
|
||||
if string(cfg.RouterConfig) != `{not json` {
|
||||
t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func mtprotoInbound(tag string, settings string) *model.Inbound {
|
||||
return &model.Inbound{Tag: tag, Protocol: model.MTProto, Enable: true, Settings: settings}
|
||||
}
|
||||
@@ -373,3 +514,46 @@ func TestInjectMtprotoEgress_TagCollisionSkips(t *testing.T) {
|
||||
t.Fatal("a real inbound already owning the tag must make the bridge a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectMtprotoEgress_MissingTargetSkips(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
before := string(cfg.RouterConfig)
|
||||
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
|
||||
`{"routeThroughXray":true,"routeXrayPort":50004,"outboundTag":"removed-subscription-outbound"}`))
|
||||
|
||||
if len(cfg.InboundConfigs) != 1 {
|
||||
t.Fatalf("a missing target must not expose the mtproto bridge, got %+v", cfg.InboundConfigs)
|
||||
}
|
||||
if string(cfg.RouterConfig) != before {
|
||||
t.Fatalf("a missing target must leave routing untouched, got %s", cfg.RouterConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectMtprotoEgress_BadOutboundsSkips(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
|
||||
before := string(cfg.RouterConfig)
|
||||
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
|
||||
`{"routeThroughXray":true,"routeXrayPort":50005,"outboundTag":"direct"}`))
|
||||
|
||||
if len(cfg.InboundConfigs) != 1 {
|
||||
t.Fatalf("unparsable outbounds must not expose the mtproto bridge, got %+v", cfg.InboundConfigs)
|
||||
}
|
||||
if string(cfg.RouterConfig) != before {
|
||||
t.Fatalf("unparsable outbounds must leave routing untouched, got %s", cfg.RouterConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
cfg.RouterConfig = json_util.RawMessage(`{not json`)
|
||||
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
|
||||
`{"routeThroughXray":true,"routeXrayPort":50006,"outboundTag":"direct"}`))
|
||||
|
||||
if len(cfg.InboundConfigs) != 1 {
|
||||
t.Fatalf("unparsable routing must not expose the mtproto bridge, got %+v", cfg.InboundConfigs)
|
||||
}
|
||||
if string(cfg.RouterConfig) != `{not json` {
|
||||
t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,19 @@ type XrayMetricsService struct {
|
||||
state xrayMetricsState
|
||||
client *http.Client
|
||||
obsByTag map[string]ObsTagSnapshot
|
||||
health map[string]outboundHealth
|
||||
}
|
||||
|
||||
// outboundHealth debounces observatory flapping. Xray flips an outbound's
|
||||
// alive flag on a single failed probe, so raw transitions produce a storm of
|
||||
// down/up notifications on a flaky link. We instead require failStreak to reach
|
||||
// the configured threshold (consecutive FAILED probes, tracked per new probe
|
||||
// via lastTry) before publishing outbound.down, and only publish outbound.up
|
||||
// once a down has actually been notified.
|
||||
type outboundHealth struct {
|
||||
lastTry int64
|
||||
failStreak int
|
||||
notified bool
|
||||
}
|
||||
|
||||
var validObsTag = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`)
|
||||
@@ -214,32 +227,57 @@ func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]ra
|
||||
xrayMetrics.append(obsHistoryKey(tag), t, float64(e.Delay))
|
||||
}
|
||||
|
||||
threshold := 3
|
||||
if v, err := s.settingService.GetOutboundDownThreshold(); err == nil && v > 0 {
|
||||
threshold = v
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
// Detect transitions and publish events
|
||||
// Debounce observatory flapping into stable down/up notifications.
|
||||
if eventBus != nil {
|
||||
// Check existing tags for state changes
|
||||
for tag, old := range s.obsByTag {
|
||||
cur, exists := next[tag]
|
||||
if !exists {
|
||||
// Tag disappeared from observatory — skip, not a real failure
|
||||
if s.health == nil {
|
||||
s.health = make(map[string]outboundHealth, len(next))
|
||||
}
|
||||
for tag, cur := range next {
|
||||
// React only to a genuinely new probe attempt (lastTry advanced).
|
||||
// The sampler polls far more often than xray probes, so counting
|
||||
// samples instead of probes would trip the threshold instantly.
|
||||
h := s.health[tag]
|
||||
if cur.LastTryTime == 0 || cur.LastTryTime == h.lastTry {
|
||||
continue
|
||||
}
|
||||
if old.Alive && !cur.Alive {
|
||||
errMsg := ""
|
||||
if cur.Delay < 0 {
|
||||
errMsg = "probe failed"
|
||||
h.lastTry = cur.LastTryTime
|
||||
if cur.Alive {
|
||||
if h.notified {
|
||||
eventBus.Publish(eventbus.Event{
|
||||
Type: eventbus.EventOutboundUp,
|
||||
Source: tag,
|
||||
Data: &eventbus.OutboundHealthData{Delay: cur.Delay},
|
||||
})
|
||||
}
|
||||
eventBus.Publish(eventbus.Event{
|
||||
Type: eventbus.EventOutboundDown,
|
||||
Source: tag,
|
||||
Data: &eventbus.OutboundHealthData{Delay: cur.Delay, Error: errMsg},
|
||||
})
|
||||
} else if !old.Alive && cur.Alive {
|
||||
eventBus.Publish(eventbus.Event{
|
||||
Type: eventbus.EventOutboundUp,
|
||||
Source: tag,
|
||||
Data: &eventbus.OutboundHealthData{Delay: cur.Delay},
|
||||
})
|
||||
h.failStreak = 0
|
||||
h.notified = false
|
||||
} else {
|
||||
h.failStreak++
|
||||
if h.failStreak >= threshold && !h.notified {
|
||||
errMsg := ""
|
||||
if cur.Delay < 0 {
|
||||
errMsg = "probe failed"
|
||||
}
|
||||
eventBus.Publish(eventbus.Event{
|
||||
Type: eventbus.EventOutboundDown,
|
||||
Source: tag,
|
||||
Data: &eventbus.OutboundHealthData{Delay: cur.Delay, Error: errMsg},
|
||||
})
|
||||
h.notified = true
|
||||
}
|
||||
}
|
||||
s.health[tag] = h
|
||||
}
|
||||
// Forget tags that vanished from the observatory.
|
||||
for tag := range s.health {
|
||||
if _, ok := next[tag]; !ok {
|
||||
delete(s.health, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
)
|
||||
|
||||
// probe is one observatory sample: whether the outbound is alive and the
|
||||
// last_try_time xray reports for it (a new probe advances lastTry).
|
||||
type probe struct {
|
||||
alive bool
|
||||
lastTry int64
|
||||
}
|
||||
|
||||
const testSentinel eventbus.EventType = "test.sentinel"
|
||||
|
||||
// runObservatory feeds a probe sequence through applyObservatory with the given
|
||||
// threshold and returns the outbound.* events it published, in order.
|
||||
func runObservatory(t *testing.T, threshold int, seq []probe) []eventbus.EventType {
|
||||
t.Helper()
|
||||
|
||||
ss := SettingService{}
|
||||
if err := ss.SetOutboundDownThreshold(threshold); err != nil {
|
||||
t.Fatalf("set threshold: %v", err)
|
||||
}
|
||||
|
||||
bus := eventbus.New(256)
|
||||
events := make(chan eventbus.Event, 256)
|
||||
bus.Subscribe("test", func(e eventbus.Event) { events <- e })
|
||||
SetEventBus(bus)
|
||||
t.Cleanup(func() {
|
||||
SetEventBus(nil)
|
||||
bus.Stop()
|
||||
})
|
||||
|
||||
s := &XrayMetricsService{settingService: ss}
|
||||
for _, p := range seq {
|
||||
s.applyObservatory(time.Unix(p.lastTry, 0), map[string]rawObsEntry{
|
||||
"proxy": {Alive: p.alive, Delay: 10, LastTryTime: p.lastTry, OutboundTag: "proxy"},
|
||||
})
|
||||
}
|
||||
|
||||
bus.Publish(eventbus.Event{Type: testSentinel, Source: "x"})
|
||||
var got []eventbus.EventType
|
||||
for {
|
||||
select {
|
||||
case e := <-events:
|
||||
if e.Type == testSentinel {
|
||||
return got
|
||||
}
|
||||
got = append(got, e.Type)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for events to drain")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyObservatoryDebounce(t *testing.T) {
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("init db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
threshold int
|
||||
seq []probe
|
||||
want []eventbus.EventType
|
||||
}{
|
||||
{
|
||||
name: "notifies only after threshold consecutive failed probes",
|
||||
threshold: 3,
|
||||
seq: []probe{
|
||||
{true, 1},
|
||||
{false, 2},
|
||||
{false, 3},
|
||||
{false, 4},
|
||||
{false, 5},
|
||||
{true, 6},
|
||||
{false, 7},
|
||||
{true, 8},
|
||||
},
|
||||
want: []eventbus.EventType{eventbus.EventOutboundDown, eventbus.EventOutboundUp},
|
||||
},
|
||||
{
|
||||
name: "repeated samples of the same probe do not advance the streak",
|
||||
threshold: 3,
|
||||
seq: []probe{{false, 2}, {false, 2}, {false, 2}, {false, 2}, {false, 2}},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single-probe blip never notifies",
|
||||
threshold: 3,
|
||||
seq: []probe{{true, 1}, {false, 2}, {true, 3}},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "threshold 1 keeps the legacy notify-on-first-failure behaviour",
|
||||
threshold: 1,
|
||||
seq: []probe{{true, 1}, {false, 2}},
|
||||
want: []eventbus.EventType{eventbus.EventOutboundDown},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := runObservatory(t, tt.threshold, tt.seq)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("events = %v, want %v", got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("event[%d] = %q, want %q (full: %v)", i, got[i], tt.want[i], got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
@@ -16,6 +18,11 @@ type XraySettingService struct {
|
||||
SettingService
|
||||
}
|
||||
|
||||
const (
|
||||
unencryptedOutboundProhibitedError = "without TLS or other encryption is prohibited unless the server address is a private IP or domain"
|
||||
unencryptedOutboundMinimumVersion = "26.7.11"
|
||||
)
|
||||
|
||||
func (s *XraySettingService) SaveXraySetting(newXraySettings string) error {
|
||||
// The frontend round-trips the whole getXraySetting response back
|
||||
// through the textarea, so if it has ever received a wrapped
|
||||
@@ -46,8 +53,15 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
||||
if err := json.Unmarshal(xrayConfig.OutboundConfigs, &outbounds); err != nil {
|
||||
return common.NewError("xray template config invalid: outbounds is not an array:", err)
|
||||
}
|
||||
coreVersion := "Unknown"
|
||||
if p != nil {
|
||||
coreVersion = p.GetXrayVersion()
|
||||
}
|
||||
for _, outbound := range outbounds {
|
||||
if err := xray.ValidateOutboundConfig(outbound); err != nil {
|
||||
if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, err) {
|
||||
continue
|
||||
}
|
||||
tagged := struct {
|
||||
Tag string `json:"tag"`
|
||||
}{}
|
||||
@@ -59,6 +73,51 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldSkipLegacyUnencryptedOutboundRejection lets an older running Xray
|
||||
// core accept an outbound that the newer embedded validator rejects solely
|
||||
// because it is unencrypted and targets a public address. Unknown or malformed
|
||||
// versions preserve the embedded validator's strict behavior.
|
||||
func shouldSkipLegacyUnencryptedOutboundRejection(coreVersion string, err error) bool {
|
||||
if err == nil || !strings.Contains(err.Error(), unencryptedOutboundProhibitedError) {
|
||||
return false
|
||||
}
|
||||
comparison, ok := compareXrayCoreVersions(coreVersion, unencryptedOutboundMinimumVersion)
|
||||
return ok && comparison < 0
|
||||
}
|
||||
|
||||
func compareXrayCoreVersions(a, b string) (int, bool) {
|
||||
aParts, okA := parseXrayCoreVersionParts(a)
|
||||
bParts, okB := parseXrayCoreVersionParts(b)
|
||||
if !okA || !okB {
|
||||
return 0, false
|
||||
}
|
||||
for i := range len(aParts) {
|
||||
if aParts[i] > bParts[i] {
|
||||
return 1, true
|
||||
}
|
||||
if aParts[i] < bParts[i] {
|
||||
return -1, true
|
||||
}
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
func parseXrayCoreVersionParts(version string) ([3]int, bool) {
|
||||
var result [3]int
|
||||
parts := strings.Split(strings.TrimPrefix(strings.TrimSpace(version), "v"), ".")
|
||||
if len(parts) != len(result) {
|
||||
return result, false
|
||||
}
|
||||
for i, part := range parts {
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return result, false
|
||||
}
|
||||
result[i] = n
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func (s *XraySettingService) UpdateWarpXraySetting(warpData map[string]string, warpConfig map[string]any) error {
|
||||
template, err := s.GetXrayConfigTemplate()
|
||||
if err != nil {
|
||||
|
||||
@@ -2,10 +2,58 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShouldSkipLegacyUnencryptedOutboundRejection(t *testing.T) {
|
||||
prohibited := errors.New("vless without TLS or other encryption is prohibited unless the server address is a private IP or domain")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "older core", version: "26.4.25", err: prohibited, want: true},
|
||||
{name: "boundary version", version: "26.7.11", err: prohibited, want: false},
|
||||
{name: "newer core", version: "26.10.0", err: prohibited, want: false},
|
||||
{name: "unknown version", version: "Unknown", err: prohibited, want: false},
|
||||
{name: "unparseable version", version: "26.7", err: prohibited, want: false},
|
||||
{name: "empty version", version: "", err: prohibited, want: false},
|
||||
{name: "unrelated validation error", version: "26.4.25", err: errors.New("invalid outbound"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldSkipLegacyUnencryptedOutboundRejection(tt.version, tt.err); got != tt.want {
|
||||
t.Fatalf("shouldSkipLegacyUnencryptedOutboundRejection(%q, %v) = %v, want %v", tt.version, tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareXrayCoreVersions(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{a: "26.7.11", b: "26.7.11", want: 0, ok: true},
|
||||
{a: "26.10.0", b: "26.7.11", want: 1, ok: true},
|
||||
{a: "v26.4.25", b: "26.7.11", want: -1, ok: true},
|
||||
{a: "Unknown", b: "26.7.11", want: 0, ok: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, ok := compareXrayCoreVersions(tt.a, tt.b)
|
||||
if got != tt.want || ok != tt.ok {
|
||||
t.Errorf("compareXrayCoreVersions(%q, %q) = (%d, %v), want (%d, %v)", tt.a, tt.b, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapXrayTemplateConfig(t *testing.T) {
|
||||
real := `{"log":{},"inbounds":[],"outbounds":[],"routing":{}}`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user