Compare commits

...

14 Commits

Author SHA1 Message Date
Yuri Khachaturyan 2b1308ca29 feat(notifications): add a consecutive-failure threshold for outbound.down alerts (#5968)
Problem: a flaky outbound produces hundreds of false-positive "outbound down"
notifications overnight — each fires the moment xray's observatory reports a
single failed probe, and the next successful probe fires an "up".

applyObservatory forwarded every raw alive:true->false transition straight to
EventOutboundDown; xray's observatory has effectively no hysteresis, and nothing
on the panel side debounced it (the email/Telegram subscribers are pure
formatters).

Fix: debounce per outbound. outbound.down now fires only after
outboundDownThreshold consecutive FAILED probes (new setting, default 3);
outbound.up fires immediately on the first successful probe and only when a down
was actually notified. The threshold gates the event itself, so email and
Telegram share one knob (exposed next to the outbound.down toggle).

The streak counts genuinely new probes (last_try_time advancing), not sampler
polls — the sampler runs every 2s but the observatory re-probes per its
probeInterval, so counting samples would trip the threshold instantly.
outboundDownThreshold=1 reproduces the legacy notify-on-first-failure behaviour.

Tuning the observatory's probe interval/timeout is not a workaround: those
probes also drive the load balancer's outbound selection, so loosening them to
quiet notifications would slow real failover away from a genuinely dead
outbound. Notifications don't need observatory-grade latency, so the tolerance
belongs at the notification layer, leaving the observatory (and balancer)
untouched.

Adds TestApplyObservatoryDebounce covering the threshold, probe-vs-sample
counting, single-blip suppression and the legacy path.

Co-authored-by: Yuriy Khachaturian <y.khachaturian@souzmult.ru>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:59:07 +02:00
Blazethan 9e117bbdd3 fix(clients): keep VLESS xtls-rprx-vision flow when inbound options reload (#5971)
The client form cleared the `flow` field whenever `showFlow` was false, but
`showFlow` is derived from the inbound options list, which is transiently empty
while the options query (re)loads (`inboundOptionsQuery.data ?? []`). During
that window `showFlow` is a false negative, so the effect silently dropped a
valid `xtls-rprx-vision` flow the user had picked for a Reality/TLS inbound and
never restored it — the client was then saved with an empty flow and could not
connect with XTLS Vision.

Guard the clear so it only runs once the inbound options are actually available.

Adds a regression test that reproduces the drop across an options reload.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:58:25 +02:00
Mr. Nickson 8cd71e07ea fix: refresh stale client_traffics row when an inbound-deleted client's email is reused (#6003)
* fix: refresh stale client_traffics row when an inbound-deleted client's email is reused

AddClientStat's OnConflict was DoNothing on email, so once an inbound is
deleted (DelInbound only removes the client_inbounds link, matching
ClientService.Detach's intentional Detach-then-later-Attach behavior) the
orphaned client_traffics row for that email survives untouched. Re-creating
a client under the same email on a new inbound silently kept the old
enable/expiry_time/reset/total/inbound_id instead of adopting the new
client's config.

Switch the conflict path to DoUpdates on inbound_id/total/expiry_time/
enable/reset. up/down stay excluded on purpose: every call for an
already-attached identity carries the same config values (one call per
inbound), so the refresh is a no-op for that legitimate multi-inbound
share, while zeroing usage counters on each additional attach would erase
real traffic.

Fixes #5958

* fix: don't let AddClientStat clobber import's forced-enabled ClientStats rows

github-actions[bot] review on #6003 found that AddInbound writes client_traffics
twice for the same import payload: first inserting each ClientStats row
(DoNothing, with Enable forced true by controller.importInbound), then calling
AddClientStat once per Settings-derived client. With AddClientStat's OnConflict
now DoUpdates, that second call was unconditionally overwriting enable (and
total/expiry_time/reset/inbound_id) with the Settings.clients[].enable value —
which still holds whatever the client had at export time, silently undoing the
controller's "always import as enabled" behavior for any client disabled at
export.

Fix: track which emails were already seeded by the ClientStats loop and skip
the AddClientStat call for those emails, leaving the import path's forced
values as authoritative. Plain (non-import) creates are unaffected since
ClientStats is empty there, so every client still goes through AddClientStat's
refresh as before.

Also updated a stale comment in addClientTraffic that still described
AddClientStat as DoNothing.

Added TestAddInbound_ImportForcedEnableSurvivesDisabledSettingsClient, which
reproduces the exact regression (verified it fails without this fix) and
passes with it.
2026-07-21 15:57:55 +02:00
Seyed Ali Shahrokhi 79e65f63df fix(xray): validate generated egress targets (#5989)
* fix(xray): validate panel egress target

Avoid generating a loopback panel bridge and routing rule when a saved panel outbound disappears after an outbound subscription refresh. Preserve routing unchanged and log the missing target instead.

* fix(xray): guard node and mtproto egress

Apply the fail-closed target check to every generated egress bridge. Skip node and MTProto bridge injection when a selected outbound disappears or the relevant JSON cannot be parsed.

* test(xray): complete node egress coverage

Cover tag and port collisions plus absent and malformed routing. Clarify the fail-closed bridge behavior in the panel and MTProto egress documentation.
2026-07-21 15:57:34 +02:00
Alexey c87649d9f2 fix(sub) Happ profileEnableRouting button (#6008) 2026-07-21 15:56:54 +02:00
w3struk 123fac222b feat(sub): add raw subscription download actions (#6017)
* feat(sub): add raw subscription downloads

* fix(sub): address review feedback

* feat(sub): add download buttons to client subscriptions

* fix(sub): fetch subscription before download

---------

Co-authored-by: w3struk <w3struk@gmail.com>
2026-07-21 15:52:33 +02:00
Matt Van Horn d38c912dc1 fix: gate embedded unencrypted-outbound rejection on running Xray core version (#6028)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-07-21 15:51:46 +02:00
H-TTTTT fde66ba820 fix(sub): omit non-standard fm param from Hysteria2 URI (#6048)
Hysteria2 subscription links included a v2rayN-specific fm=<json>
finalmask dump alongside standard obfs/obfs-password. Clients such as
mihomo reject the unknown query param and fail to update the provider.

Emit only the standard salamander fields in genHysteriaLink, matching
the Clash generator.

Fixes #5982
2026-07-21 15:51:06 +02:00
H-TTTTT a0dec000b2 fix(clients): allow case-only email updates without duplicates (#6050)
Case-only email edits (test → Test) skipped the ClientRecord rename
because the gate used strings.EqualFold. SyncInbound then failed its
case-sensitive lookup and inserted a second row; the later fallback
rename hit UNIQUE constraint failed: clients.email.

Rename on any byte-level email difference so the same client is updated
in place.

Fixes #5951
2026-07-21 15:50:50 +02:00
H-TTTTT 11f602fe04 fix(sub): preserve external link names in Clash/JSON (#6049)
expandEntry cleared Name for external subscriptions and single links
with empty remark, so Clash/JSON fell back to the client email. Keep
each link's original name (#fragment / vmess ps); use the row remark
when set. Pass remark from the client form for single external links.

Fixes #6032
2026-07-21 15:50:33 +02:00
H-TTTTT c80e5e276b fix(wireguard): preserve all Allowed IPs in share link, .conf, and subscription (#6051)
The WireGuard client address is stored end-to-end as a string slice
(model.AllowedIPs []string, comma-split/joined in the DB), and the UI
hint documents comma-separated multi-value input. Three export paths
only read index [0], silently dropping every address after the first
(e.g. a dual-stack IPv4+IPv6 client never receives its second address):

- genWireguardLink share link address param (inbound-link.ts)
- genWireguardConfig .conf Address line (inbound-link.ts)
- SubService.genWireguardLink raw subscription address (service.go)

The sibling JSON and Clash subscriptions already emit the full slice,
and WireguardPeerFromClient passes the whole slice into the real Xray
peer config, so only the exported text was wrong. Join all entries,
matching the model's strings.Join(..., ",") convention for the link
params and the ', '-joined AllowedIPs line already used a few lines
below in genWireguardConfig.

Fixes #6031
2026-07-21 15:50:16 +02:00
H-TTTTT 22ff07b24b fix(warp): preserve outbound customization when rotating IP (#6052)
changeIp() rebuilt the warp outbound from a fixed default template via
collectConfig and replaced the whole in-memory object through
onResetOutbound, so only secretKey/address/reserved and the first peer's
publicKey/endpoint survived — a user's custom mtu, domainStrategy,
noKernelTun, the first peer's keepAlive / allowedIPs / preSharedKey, and
any extra peers were silently dropped from the editor. Because the page
keeps rendering that in-memory copy and a later page Save persists it to
/panel/api/xray/update, the loss could become permanent, even though the
backend ChangeWarpIP already patches only the rotated fields and leaves
everything else intact in the stored template.

Mirror the server-side UpdateWarpXraySetting: merge only the rotated
fields (secretKey, address, reserved, first peer publicKey/endpoint)
into the existing outbound, preserving the rest. register() (first-time
creation, nothing to preserve) still uses collectConfig's full build.

Extracted as a pure module-level mergeWarpRotation so it is unit-testable
without rendering the modal.

Fixes #6019
2026-07-21 15:50:00 +02:00
H-TTTTT d623410cf4 fix(clients): persist all editable fields for clients with no inbound (#6053)
ClientService.Update only wrote most editable fields to the clients table
inside the per-inbound loop (UpdateInboundClient -> SyncInbound), so a
client with no attached inbound — the external-links / remote-
subscription client — silently dropped subId, totalGB, expiryTime,
limitIp, tgId, comment, reset, flow, security and the credential fields
on edit. Update still returned success, so the panel showed a saved
toast while the row was untouched. email/enable/group_name/ad_tag/reverse
already had dedicated unconditional direct writes that covered the
no-inbound case; the rest did not.

This was a known failure shape: commit c4448f4e added the no-inbound
fallback for email only, and TestUpdate_PersistsRecordEnable_NoInbound
covered enable only — both stayed scoped to a single field.

Extract SyncInbound's per-row field merge into applyClientRecordMerge
(unconditional for scalar quota/lifecycle/subscription fields,
preserve-when-empty for credentials/identifiers, earliest CreatedAt)
and reuse it from Update. Update's new branch runs only when the client
has no inbound, so it fixes the gap without altering the inbound-attached
path (where flow is per-inbound gated via clientWithInboundFlow and
SyncInbound already persists every field). email/reverse/group/ad_tag/
enable keep their existing dedicated writes; the new write covers the
remaining columns.

Fixes #5981
2026-07-21 15:49:43 +02:00
H-TTTTT a9d5d9afdb fix(balancer): pin loopback routing rules ahead of general rules (#6054)
* fix(balancer): pin loopback routing rules ahead of general rules

ensureBalancerLoopback appended a balancer's fallback loopback rule
({ inboundTag: ['_bl_<target>'], balancerTag: <target> }) to the END of
routing.rules via Array.push, and only updated balancerTag in place when
the rule already existed. Xray evaluates routing rules top-to-bottom,
first match wins, and a general domain rule (no inboundTag restriction)
matches every inbound including the loopback one. So once such a general
rule targeting the parent balancer ended up earlier in the array — added
before the fallback was configured, or recreated later by an edit or by
ensureMissingBalancerLoopbacks — the fallback re-entered routing through
the _bl_<target> loopback outbound, the general rule matched again, and
traffic routed straight back into the parent balancer: an unbounded loop
and the CPU spike in the report. detectBalancerCycles only catches
mutual fallback cycles, so the plain acyclic Balancer1 -> Balancer2
chain passed silently.

Insert the loopback rule before the first general (no-inboundTag) rule
instead of pushing to the end, and reposition any existing loopback rule
that already landed after a general rule (preserving its other fields,
mirroring the EnsureStatsRouting hoist used for the same class of bug).
Rules with an inboundTag restriction (api, real inbounds) are left in
place — they can never match loopback traffic, so only the unrestricted
rules are dangerous. ensureMissingBalancerLoopbacks runs on every Save
All and calls ensureBalancerLoopback per fallback, so this also repairs
configs loaded from the DB with the wrong order.

Fixes #5960

* ci: re-trigger checks after flaky FuzzDecodeCertPin timeout

---------

Co-authored-by: x06579 <x06579@ai-dashboard>
2026-07-21 15:48:34 +02:00
44 changed files with 1853 additions and 151 deletions
+12
View File
@@ -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: '' },
],
},
+2
View File
@@ -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": "",
+12
View File
@@ -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",
+2
View File
@@ -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;
+2
View File
@@ -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(),
+3 -3
View File
@@ -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`;
+3
View File
@@ -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 {
+18 -4
View File
@@ -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>
+33 -2
View File
@@ -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"
+21
View File
@@ -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!'));
}
+1
View File
@@ -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(),
+100
View File
@@ -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');
});
});
+37
View File
@@ -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: '',
+99
View File
@@ -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
View File
@@ -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)
}
+62
View File
@@ -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 {
+4 -4
View File
@@ -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
}
+45 -4
View File
@@ -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),
+29
View File
@@ -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(""))
+5 -6
View File
@@ -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)
+29
View File
@@ -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)
}
}
+30
View File
@@ -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"}]}`}
+2
View File
@@ -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"`
+29
View File
@@ -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 {
+3 -1
View File
@@ -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
+60 -47
View File
@@ -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{}
+10
View File
@@ -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)")
}
}
+29 -7
View File
@@ -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 {
+14
View File
@@ -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.
+58 -29
View File
@@ -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{
+185 -1
View File
@@ -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)
}
}
+59 -21
View File
@@ -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)
}
}
}
+122
View File
@@ -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)
}
}
})
}
}
+59
View File
@@ -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 {
+48
View File
@@ -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":{}}`