Compare commits

..

12 Commits

Author SHA1 Message Date
MHSanaei 814cda3fb4 feat(xray): update xray-core to v26.7.11 and adapt panel
Bump xtls/xray-core to 50231eaf (v26.7.11) and the three binary pins
(DockerInit.sh, release.yml x2) in lockstep.

Adapt the panel to the upstream changes:

- Shadowsocks "none"/"plain" and VMess "none"/"zero" were removed from
  the core. A migration rewrites stored none/plain SS methods to a
  supported cipher and none/zero VMess security to "auto" (on both the
  clients column and inbound settings JSON); the SS build-time heal does
  the same so a row injected after boot cannot brick startup. The removed
  values are dropped from every frontend option list, schema and adapter,
  and coerced to "auto" at the Go link/sub/Clash emit sites and both link
  importers. Fix the CipherType_NONE sentinel that no longer compiles.

- Unencrypted vless/trojan outbounds to a public address are now refused
  by the core. Validate outbounds through the vendored config loader when
  saving the xray template and when storing/merging outbound
  subscriptions, so one such outbound cannot keep the core from starting.

- New TCP finalmask type "xmc" (Minecraft mimicry): add it to the sub
  link allowlist, the frontend enum and the FinalMask form (hostname,
  usernames, required password), and document it.

- streamSettings gained a "method" alias for "network"; canonicalize it
  to "network" at inbound save time and in the form adapters/schema so a
  method-keyed config keeps its transport.

- New root "env" config key is passed through xray.Config, compared in
  Equals, and forces a restart in the hot diff.

- REALITY now defaults minClientVer to 26.3.27; update the form
  placeholder.
2026-07-12 00:30:47 +02:00
Dmitrii Ignatov affcf6c422 fix(link): strip query and trailing slash when parsing ss:// port (#5895)
* fix(link): strip query and trailing slash when parsing ss:// port

Subscription-provided Shadowsocks links use the SIP002 form
ss://userinfo@host:port[/][?plugin=...]#tag. parseShadowsocks only
stripped the #fragment, so a "?plugin=" / "?type=" query and the
optional trailing slash leaked into the host:port split, strconv.Atoi
failed, and the port was silently set to 0 (the error was discarded).
Direct link import was unaffected because it runs through the frontend
parser, which already handles this.

Strip the query and the trailing slash before splitting host:port,
mirroring the frontend outbound-link-parser and the SIP002 grammar.
This complements #5432, which fixed the SS2022 generation side.

Add table-driven parseShadowsocks tests covering modern, legacy,
base64url userinfo, the SIP002 slash+plugin form, and SIP022
percent-encoded userinfo with a dual-key password.

* fix(link): surface ss:// port parse errors instead of defaulting to 0

  The modern and legacy Shadowsocks branches discarded the strconv.Atoi
  error when reading the port, silently yielding port 0 for any malformed
  host:port. Return a parse error instead, matching defaultPort's existing
  pattern in this file, so a bad link is skipped by ParseSubscriptionBody
  rather than injected as an unusable port-0 outbound.
2026-07-11 23:34:09 +02:00
MHSanaei cbd2940a63 fix(node): adopt a node inbound's host overrides into the master
Per-inbound Host overrides (Security/SNI/Fingerprint/ALPN and friends)
are looked up by the local inbound id when subscriptions render, but
nothing in the node sync ever fetched the node's hosts table: an
inbound adopted from a managed node got zero Host rows on the master,
so its subscription configs fell back to a bare TLS block without the
fingerprint/SNI the node was configured with.

When a traffic snapshot carries a tag with no central row yet - the
only moment adoption can happen - the sync job now also pulls the
node's existing hosts/list endpoint (best-effort, so old nodes just
skip it) and the adoption branch materializes that inbound's groups
against the new central id inside the same transaction, reusing the
group-to-rows projection the hosts API already uses. Master stays
authoritative afterwards: this is a one-time import, not a continuous
sync, matching how the inbound's own settings are adopted.

Closes #5890
2026-07-11 23:17:57 +02:00
MHSanaei e6bef229ae fix(web): opt panel pages out of Cloudflare Rocket Loader
Behind Cloudflare with Rocket Loader enabled, the panel's entry bundles
were rewritten and executed through Rocket Loader's own loader instead
of as native ES modules (a reporter's network capture shows the main
bundle initiated by rocket-loader.min.js). That breaks module semantics
and script ordering, leaving a blank page after login even though every
asset returns 200 - most visibly with a custom URI path, where the
injected base path must be set before the bundle boots.

Stamp data-cfasync="false" - Cloudflare's documented per-script opt-out
- on the built entry script tags via a build-time transformIndexHtml
hook (Vite regenerates entry tags, so a source-HTML attribute would be
stripped), and on the runtime-injected base-path/version inline script
in serveDistPage.

Closes #5868
2026-07-11 22:48:59 +02:00
MHSanaei 975b1f1acc fix(iplimit): ban a dead connection once instead of every scan
When a client's connection drops without a clean TCP close, xray-core
keeps its online-map entry until the session context ends (idle policy),
minutes after the kernel socket is gone. The 10s IP-limit scan kept
seeing that stale IP as the oldest live one and re-emitted the same
[LIMIT_IP] Disconnecting OLD IP line plus a RemoveUser/AddUser cycle
every scan - operators measured 100+ repeats over ~1000s for a single
network switch, forcing absurd fail2ban maxretry values to avoid
banning legitimate mobile users.

The core refreshes an entry's lastSeen only when a new connection from
that IP is dispatched, never on traffic, so a frozen lastSeen across
scans is a dead connection, not a reconnect. Track the lastSeen of each
banned (email, ip) pair and skip the log line and disconnect until it
advances; a real reconnect moves lastSeen and is enforced exactly as
before, and an age cutoff that could misclassify long-lived active
tunnels is deliberately avoided.

Closes #5893
2026-07-11 22:48:58 +02:00
MHSanaei 6aa87f4e57 fix(clients): finish deleting from every inbound when one fails
Delete aborted its per-inbound loop on the first error, so a client
attached to inbounds across several nodes lost at most one per attempt:
the loop never reached the remaining nodes, the record cleanup after
the loop never ran, and each retry started over with whatever was left.
Operators with many nodes had to delete the same client once per node.

Collect per-inbound failures and keep going so every reachable inbound
and node is cleaned in a single pass, then keep the client record only
when something failed - its settings JSON still holds the client there,
so the next delete retries exactly the leftovers - and return the
joined failures instead of silently reporting success. DeleteByEmail's
legacy fallback loop gets the same treatment.

Closes #5845
2026-07-11 22:48:57 +02:00
MHSanaei 200ea09157 fix(node): never sweep a node's inbounds before their first adoption
Adding a node imports nothing; its pre-existing inbounds only become
central rows on the first clean traffic-sync tick. But any save of the
node (switching sync mode, picking tags after "Load inbounds from
node") marks it config-dirty, and the next tick then ran ReconcileNode
before that first adoption: with zero central rows the delete sweep saw
every remote tag as undesired and destroyed the node's real inbounds -
in "all" mode all of them - disconnecting live clients with no
confirmation, and the master then reported "record not found".

Track the first completed clean sync in nodes.inbounds_adopted_at and
skip the sweep (pushes still run) until it is set, so "absent locally"
can no longer be conflated with "deleted on the master". A node that
has synced before still sweeps normally, including the offline
last-inbound-deleted case. Existing nodes are seeded as adopted on
upgrade to keep their behavior unchanged.

Closes #5898
2026-07-11 22:48:57 +02:00
MHSanaei fc625d8f66 fix(database): drop the legacy UNIQUE constraint on inbounds.port
Inbound.Port carried a unique gorm tag before multi-node existed; the
tag was removed from the model long ago, but AutoMigrate never drops a
constraint, so SQLite databases created in that era still physically
enforce global port uniqueness. On such installs the node-scoped port
conflict logic is correct yet the raw insert fails - manual "Deploy to
node" saves and setRemoteTraffic's central-inbound adoption both die
with "UNIQUE constraint failed: inbounds.port" when two nodes use the
same port.

Add a SQLite-only migration that drops an explicit unique index on port
directly, and rebuilds the table (create from the current model, copy
shared columns, swap) when the constraint is inline, since SQLite can't
ALTER it away. Fresh databases and Postgres are untouched.

Closes #5894
2026-07-11 22:48:56 +02:00
MHSanaei c4448f4ea8 fix(clients): rename client record atomically with inbound settings
Update committed an email rename to the clients table with a standalone
write before the per-inbound loop rewrote each inbound's settings JSON.
In that window the record held the new email while the JSON still held
the old one, so any concurrent SyncInbound (traffic poll, another edit)
found no record for the old email and inserted a duplicate seeded from
the stale JSON - carrying the same subId, which then failed every later
edit with "Duplicate subId". The subId collision check also ran after
that write, so even a rejected update permanently renamed the email.

Move the rename inside UpdateInboundClient's serialized transaction,
next to the settings save and SyncInbound, so no other writer can see
one without the other; skip it when a record already owns the target
email (the merge case). Update now only runs collision checks before
the loop and falls back to a direct rename solely for records with no
attached inbound. This covers both the REST API and the web UI editor,
which share this path.

Closes #5870
2026-07-11 22:48:54 +02:00
MHSanaei 3b731cd657 fix(clients): reuse stored credentials when re-adding an existing identity
Create permits a repeat add for an email that already exists when the
payload subId matches the stored one (the documented way to attach an
identity to more inbounds), but it never seeded the payload from the
existing record, so an omitted id minted a fresh UUID via
fillProtocolDefaults. SyncInbound then overwrote the shared clients.uuid
row by email while previously-attached inbounds kept the original UUID
in their settings JSON, silently desyncing panel credentials from
subscription links. BulkCreate had the identical gap.

Seed ID/Password/Auth/Secret from the existing record in both paths
(mirroring what Update, Attach and BulkAttach already do), and preserve
Secret in Update too so partial edits of MTProto clients cannot rotate
the stored secret.

Closes #5903
2026-07-11 22:48:51 +02:00
MHSanaei 1c789c3e4d fix(script): confirm auto-detected public IPv4 before issuing IP certificate
On networks with asymmetric routing (or proxies/multi-WAN), external
IP-echo services can return a transit or gateway address instead of the
server's real incoming IP, and every IP-certificate flow silently
issued for that wrong address. The x-ui.sh menu flow (option 20 -> 6)
and the install.sh/update.sh SSL menus now show the detected IPv4 for
confirmation (Enter keeps the old behavior), and declining falls into
the same validated manual-entry loop already used when every provider
fails. Non-interactive installs are untouched - XUI_SERVER_IP already
pins the address there.

Closes #5867
2026-07-11 22:35:31 +02:00
MHSanaei 201d4731de fix(xhttp): stop XMUX maxConcurrency from reverting on save
XHttpXmuxSchema defaulted maxConnections to 6 (added to mirror xray-core
v26.6.27's anti-RKN client default), so load-time hydration backfilled a
non-zero maxConnections onto every config whose saved xmux lacked the
key. Since maxConnections and maxConcurrency are mutually exclusive on
the wire, the save-time exclusivity rule then saw both fields set and
silently deleted the user's maxConcurrency; the missing key came back as
the '16-32' schema default on the next load, so edits appeared to never
save.

Revert the bare schema default to 0 and seed the anti-RKN
maxConnections=6 only when XMUX is freshly toggled on
(XMUX_FRESH_DEFAULTS, with maxConcurrency left blank — xray-core parses
an empty range string as 0), so the two strategies never start out
conflicting. The inbound and outbound XMUX forms now also clear the
opposing field live as soon as the user sets one, so whichever strategy
was actually typed is the one persisted.

Closes #5864
2026-07-11 20:43:47 +02:00
69 changed files with 1831 additions and 123 deletions
+2 -2
View File
@@ -118,7 +118,7 @@ jobs:
cd x-ui/bin
# Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.6.27/"
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
if [ "${{ matrix.platform }}" == "amd64" ]; then
wget -q ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip
@@ -273,7 +273,7 @@ jobs:
cd x-ui\bin
# Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.6.27/"
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip"
+1 -1
View File
@@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
fi
mkdir -p build/bin
cd build/bin
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.27/Xray-linux-${ARCH}.zip"
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/Xray-linux-${ARCH}.zip"
unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}"
+3 -1
View File
@@ -122,7 +122,9 @@ Only valid when the protocol is **Hysteria2**.
disguise transports that don't carry TLS (like mKCP) or add a second skin on top of
TLS. Masks are configured per direction:
- **TCP masks** — `fragment`, `sudoku`, `header-custom`.
- **TCP masks** — `fragment`, `sudoku`, `header-custom`, `xmc` (disguises the
stream as Minecraft protocol traffic; requires a password, with optional
hostname and player usernames).
- **UDP masks** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
`noise`, `sudoku`, `realm`. (`mkcp-legacy` reproduces the old mKCP header
obfuscation.)
+3 -1
View File
@@ -123,7 +123,9 @@ icon: Network
انتقال‌هایی را که TLS حمل نمی‌کنند (مانند mKCP) استتار کند یا پوسته‌ای دوم روی TLS
بیفزاید. ماسک‌ها برای هر جهت پیکربندی می‌شوند:
- **ماسک‌های TCP** — `fragment`، `sudoku`، `header-custom`.
- **ماسک‌های TCP** — `fragment`، `sudoku`، `header-custom`، `xmc` (ترافیک را به شکل
پروتکل Minecraft استتار می‌کند؛ گذرواژه الزامی است و نام میزبان و نام‌های بازیکن
اختیاری‌اند).
- **ماسک‌های UDP** — `salamander`، `mkcp-legacy`، `header-custom`، `xdns`، `xicmp`،
`noise`، `sudoku`، `realm`. (`mkcp-legacy` همان مبهم‌سازی قدیمی هدر mKCP را
بازتولید می‌کند.)
+3 -1
View File
@@ -125,7 +125,9 @@ HTTPUpgrade — это одноразовый HTTP/1.1 `Upgrade` без фрей
поэтому он может замаскировать транспорты, не несущие TLS (например, mKCP), или
добавить вторую оболочку поверх TLS. Маски настраиваются по направлениям:
- **TCP-маски** — `fragment`, `sudoku`, `header-custom`.
- **TCP-маски** — `fragment`, `sudoku`, `header-custom`, `xmc` (маскирует поток
под трафик протокола Minecraft; требуется пароль, имя хоста и имена игроков
опциональны).
- **UDP-маски** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
`noise`, `sudoku`, `realm`. (`mkcp-legacy` воспроизводит старую обфускацию
заголовка mKCP.)
+2 -1
View File
@@ -117,7 +117,8 @@ Session-ID 字段(`sessionIDPlacement`、`sessionIDKey`、`sessionIDTable`、
**FinalMask** 在传输方式和安全层**之后**包裹流量,因此它既能伪装那些承载不了 TLS 的
传输(如 mKCP),也能在 TLS 之上再加一层外壳。掩码按方向分别配置:
- **TCP 掩码** — `fragment`、`sudoku`、`header-custom`。
- **TCP 掩码** — `fragment`、`sudoku`、`header-custom`、`xmc`(把流量伪装成
Minecraft 协议;密码必填,主机名和玩家用户名可选)。
- **UDP 掩码** — `salamander`、`mkcp-legacy`、`header-custom`、`xdns`、`xicmp`、
`noise`、`sudoku`、`realm`。(`mkcp-legacy` 重现旧版 mKCP 的头部混淆。)
- **QUIC 参数** — 拥塞控制(`reno`、`bbr`、`brutal`、`force-brutal`)、Brutal 上/下行
@@ -81,6 +81,8 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
};
case 'header-custom':
return { clients: [], servers: [] };
case 'xmc':
return { hostname: '', usernames: [], password: RandomUtil.randomLowerAndNum(16) };
default:
return {};
}
@@ -294,6 +296,7 @@ function TcpMaskItem({
{ value: 'fragment', label: 'Fragment' },
{ value: 'header-custom', label: 'Header Custom' },
{ value: 'sudoku', label: 'Sudoku' },
{ value: 'xmc', label: 'XMC (Minecraft)' },
]}
/>
</Form.Item>
@@ -371,6 +374,41 @@ function TcpMaskItem({
/>
);
}
if (type === 'xmc') {
return (
<>
<Form.Item label="Hostname" name={[fieldName, 'settings', 'hostname']}>
<Input placeholder="Server address mimicked in the handshake" />
</Form.Item>
<Form.Item
label="Usernames"
name={[fieldName, 'settings', 'usernames']}
extra="Player names offered to probes; core defaults to Dream when empty."
>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
</Form.Item>
<Form.Item label="Password" required>
<Space.Compact block>
<Form.Item
name={[fieldName, 'settings', 'password']}
noStyle
rules={[{ required: true, message: 'Password is required' }]}
>
<Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
aria-label={t('regenerate')}
onClick={() => form.setFieldValue(
[...absolutePath, 'settings', 'password'],
RandomUtil.randomLowerAndNum(16),
)}
/>
</Space.Compact>
</Form.Item>
</>
);
}
return null;
}}
</Form.Item>
@@ -124,6 +124,10 @@ const NETWORK_SETTINGS_KEY: Record<string, string> = {
};
function healStreamNetworkKey(stream: Record<string, unknown>): void {
if (typeof stream.method === 'string' && stream.method !== '') {
stream.network = stream.method;
}
delete stream.method;
const network = typeof stream.network === 'string' ? stream.network : '';
const key = NETWORK_SETTINGS_KEY[network];
if (!key) return;
@@ -107,7 +107,7 @@ function vmessFromWire(raw: Raw): VmessOutboundFormSettings {
id: asString(u.id),
security: ((): VmessOutboundFormSettings['security'] => {
const s = asString(u.security);
const allowed = ['aes-128-gcm', 'chacha20-poly1305', 'auto', 'none', 'zero'];
const allowed = ['aes-128-gcm', 'chacha20-poly1305', 'auto'];
return (allowed.includes(s) ? s : 'auto') as VmessOutboundFormSettings['security'];
})(),
};
@@ -387,10 +387,14 @@ export interface RawOutboundRow {
mux?: unknown;
}
export const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
function hydrateStreamForm(stream: Raw): OutboundStreamFormValues {
const next = { ...stream };
if (typeof next.method === 'string' && next.method !== '') {
next.network = next.method;
}
delete next.method;
const xh = next.xhttpSettings;
if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
const xhttp = { ...(xh as Raw) };
@@ -360,6 +360,8 @@ export function parseVmessLink(link: string): Raw | null {
}
const port = Number(json.port) || 443;
const rawScy = (json.scy as string) || 'auto';
const userSecurity = rawScy === 'none' || rawScy === 'zero' ? 'auto' : rawScy;
return {
protocol: 'vmess',
tag: typeof json.ps === 'string' ? json.ps : '',
@@ -367,7 +369,7 @@ export function parseVmessLink(link: string): Raw | null {
vnext: [{
address: json.add ?? '',
port,
users: [{ id: json.id ?? '', security: (json.scy as string) || 'auto' }],
users: [{ id: json.id ?? '', security: userSecurity }],
}],
},
streamSettings: stream,
@@ -46,7 +46,7 @@ function hasMeaningfulHeaders(headers: unknown): boolean {
// Upper bound of an xray-core Int32Range value: "16-32" -> 32, "4" -> 4,
// 4 -> 4, "" / null -> 0. xmux fields are ranges, and xray-core keys its
// mutual-exclusivity check on the `.To` (upper) side.
function int32RangeUpper(v: unknown): number {
export function int32RangeUpper(v: unknown): number {
if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
if (typeof v !== 'string') return 0;
const trimmed = v.trim();
@@ -57,13 +57,9 @@ function int32RangeUpper(v: unknown): number {
}
// xray-core's XmuxConfig rejects a config that sets BOTH maxConnections
// and maxConcurrency ("maxConnections cannot be specified together with
// maxConcurrency"). The panel pre-fills maxConcurrency ("16-32") whenever
// XMUX is enabled, so any explicit maxConnections would otherwise always
// collide and make xray refuse the config. maxConnections defaults to 0
// (off), so a positive value is an explicit opt-in to connection-pool
// mode — honor it and drop the leftover default maxConcurrency, matching
// core's "one strategy at a time" semantics.
// and maxConcurrency. A positive maxConnections is an explicit opt-in to
// connection-pool mode — honor it and drop the leftover maxConcurrency
// default that load-time hydration backfills onto older saved configs.
function resolveXmuxExclusivity(xmux: Record<string, unknown>): Record<string, unknown> {
if (int32RangeUpper(xmux.maxConnections) > 0 && int32RangeUpper(xmux.maxConcurrency) > 0) {
const out = { ...xmux };
@@ -36,7 +36,7 @@ import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const;
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', 'mtproto',
@@ -217,7 +217,9 @@ export default function ClientFormModal({
password: client.password || '',
auth: client.auth || '',
flow: client.flow || '',
security: client.security || 'auto',
security: !client.security || client.security === 'none' || client.security === 'zero'
? 'auto'
: client.security,
reverseTag: client.reverse?.tag || '',
totalGB: bytesToGB(client.totalGB || 0),
reset: Number(client.reset) || 0,
@@ -128,7 +128,7 @@ export default function RealityForm({
name={['streamSettings', 'realitySettings', 'minClientVer']}
label={t('pages.inbounds.form.minClientVer')}
>
<Input placeholder="25.9.11" />
<Input placeholder="26.3.27" />
</FormField>
<FormField
name={['streamSettings', 'realitySettings', 'maxClientVer']}
@@ -4,10 +4,9 @@ import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { XHTTP_SESSION_ID_TABLES, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { XHTTP_SESSION_ID_TABLES, XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
import { int32RangeUpper } from '@/lib/xray/stream-wire-normalize';
function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise<void>) {
return async (value: unknown): Promise<true | string> => {
@@ -36,7 +35,21 @@ export default function XhttpForm() {
const existing = getValues('streamSettings.xhttpSettings.xmux');
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
if (hasValues) return;
setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_FRESH_DEFAULTS });
}
function onXmuxMaxConcurrencyChange(value: unknown) {
if (int32RangeUpper(value) <= 0) return;
if (int32RangeUpper(getValues('streamSettings.xhttpSettings.xmux.maxConnections')) > 0) {
setValue('streamSettings.xhttpSettings.xmux.maxConnections', 0);
}
}
function onXmuxMaxConnectionsChange(value: unknown) {
if (int32RangeUpper(value) <= 0) return;
if (int32RangeUpper(getValues('streamSettings.xhttpSettings.xmux.maxConcurrency')) > 0) {
setValue('streamSettings.xhttpSettings.xmux.maxConcurrency', '');
}
}
return (
@@ -295,12 +308,14 @@ export default function XhttpForm() {
<FormField
label={t('pages.xray.outboundForm.maxConcurrency')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
onAfterChange={onXmuxMaxConcurrencyChange}
>
<Input placeholder="16-32" />
</FormField>
<FormField
label={t('pages.xray.outboundForm.maxConnections')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
onAfterChange={onXmuxMaxConnectionsChange}
>
<Input placeholder="0" />
</FormField>
@@ -17,11 +17,11 @@ import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { JsonEditor } from '@/components/form';
import { Wireguard } from '@/utils';
import {
XMUX_DEFAULTS,
formValuesToWirePayload,
rawOutboundToFormValues,
} from '@/lib/xray/outbound-form-adapter';
import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
import { XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
import {
OutboundFormBaseSchema,
type OutboundFormValues,
@@ -255,7 +255,7 @@ export default function OutboundFormModal({
const existing = methods.getValues('streamSettings.xhttpSettings.xmux');
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
if (hasValues) return;
methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_FRESH_DEFAULTS });
}
const duplicateTag = useMemo(() => {
@@ -5,6 +5,7 @@ import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
import { int32RangeUpper } from '@/lib/xray/stream-wire-normalize';
import { XHTTP_SESSION_ID_TABLES } from '@/schemas/protocols/stream/xhttp';
import { MODE_OPTIONS } from '../outbound-form-constants';
@@ -28,7 +29,7 @@ const XH = 'streamSettings.xhttpSettings';
export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
const { t } = useTranslation();
const { control } = useFormContext();
const { control, getValues, setValue } = useFormContext();
const mode = useWatch({ control, name: `${XH}.mode` }) as string | undefined;
const obfs = !!useWatch({ control, name: `${XH}.xPaddingObfsMode` });
const sessionPlacement = useWatch({ control, name: `${XH}.sessionIDPlacement` }) as string | undefined;
@@ -37,6 +38,20 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as string | undefined;
const enableXmux = !!useWatch({ control, name: `${XH}.enableXmux` });
function onXmuxMaxConcurrencyChange(value: unknown) {
if (int32RangeUpper(value) <= 0) return;
if (int32RangeUpper(getValues(`${XH}.xmux.maxConnections`)) > 0) {
setValue(`${XH}.xmux.maxConnections`, 0);
}
}
function onXmuxMaxConnectionsChange(value: unknown) {
if (int32RangeUpper(value) <= 0) return;
if (int32RangeUpper(getValues(`${XH}.xmux.maxConcurrency`)) > 0) {
setValue(`${XH}.xmux.maxConcurrency`, '');
}
}
return (
<>
<FormField label={t('host')} name={['streamSettings', 'xhttpSettings', 'host']}>
@@ -283,12 +298,14 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
<FormField
label={t('pages.xray.outboundForm.maxConcurrency')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
onAfterChange={onXmuxMaxConcurrencyChange}
>
<Input placeholder="16-32" />
</FormField>
<FormField
label={t('pages.xray.outboundForm.maxConnections')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
onAfterChange={onXmuxMaxConnectionsChange}
>
<Input placeholder="0" />
</FormField>
@@ -30,8 +30,6 @@ export const USERS_SECURITY = Object.freeze({
AES_128_GCM: 'aes-128-gcm',
CHACHA20_POLY1305: 'chacha20-poly1305',
AUTO: 'auto',
NONE: 'none',
ZERO: 'zero',
});
export const MODE_OPTION = Object.freeze({
@@ -4,16 +4,16 @@ const VmessSecurityEnum = z.enum([
'aes-128-gcm',
'chacha20-poly1305',
'auto',
'none',
'zero',
]);
// Legacy rows persisted `security: ""` (especially on VMess inbounds
// created before the enum was nailed down). Preprocess maps the empty
// string back to the documented default so existing data parses cleanly
// — subsequent writes serialize the normalized value.
// created before the enum was nailed down), and rows predating xray-core
// v26.7.11 may still hold the removed "none"/"zero" values that the core
// now treats as "auto". Preprocess maps all of them to the documented
// default so existing data parses cleanly — subsequent writes serialize
// the normalized value.
export const VmessSecuritySchema = z.preprocess(
(val) => (val === '' ? 'auto' : val),
(val) => (val === '' || val === 'none' || val === 'zero' ? 'auto' : val),
VmessSecurityEnum,
);
export type VmessSecurity = z.infer<typeof VmessSecurityEnum>;
@@ -5,12 +5,12 @@ import { z } from 'zod';
// plus optional QUIC tuning. The `settings` sub-object is polymorphic on
// `type`; we model the wire-faithful shape with a permissive
// record-of-unknown for `settings` and leave per-type tightening to
// Step 6 — there are 8 UDP mask types plus 3 TCP mask types, each with
// Step 6 — there are 8 UDP mask types plus 4 TCP mask types, each with
// distinct setting fields, and modeling them all as discriminated unions
// here would dwarf the rest of the stream module without buying anything
// the safety net doesn't already cover.
export const TcpMaskTypeSchema = z.enum(['fragment', 'sudoku', 'header-custom']);
export const TcpMaskTypeSchema = z.enum(['fragment', 'sudoku', 'header-custom', 'xmc']);
export type TcpMaskType = z.infer<typeof TcpMaskTypeSchema>;
export const TcpMaskSchema = z.object({
+22 -4
View File
@@ -52,10 +52,28 @@ const TransportNetworkSettingsSchema = z.discriminatedUnion('network', [
// mode. The transportless branch accepts that shape (network absent), while a
// present-but-invalid network still fails both branches so a typo can't slip
// through. `network: never().optional()` reads as "this key must be absent".
export const NetworkSettingsSchema = z.union([
TransportNetworkSettingsSchema,
z.object({ network: z.never().optional() }),
]);
//
// The preprocess folds `method` — xray-core v26.7.11's preferred alias for
// `network`, which wins over `network` when both are present — back into the
// panel-canonical `network` key, so imported/pasted configs keyed on the
// alias don't silently match the transportless branch and lose their
// transport.
export const NetworkSettingsSchema = z.preprocess(
(val) => {
if (val && typeof val === 'object' && 'method' in val) {
const { method, ...rest } = val as Record<string, unknown>;
if (typeof method === 'string' && method !== '') {
return { ...rest, network: method };
}
return rest;
}
return val;
},
z.union([
TransportNetworkSettingsSchema,
z.object({ network: z.never().optional() }),
]),
);
export type NetworkSettings = z.infer<typeof NetworkSettingsSchema>;
// Orthogonal extras that ride alongside the network and security branches.
+15 -1
View File
@@ -15,9 +15,14 @@ export type XHttpMode = z.infer<typeof XHttpModeSchema>;
// XMUX is the connection-multiplexing layer xHTTP uses to fan out
// parallel requests over a small pool of upstream connections. Fields
// are strings because they accept dash-range values like '16-32'.
// maxConcurrency and maxConnections are mutually exclusive strategies
// (xray-core rejects a config that sets both), so the bare schema
// default keeps only one of them non-zero — a non-zero maxConnections
// default resurrected on load made every re-save silently delete the
// user's maxConcurrency.
export const XHttpXmuxSchema = z.object({
maxConcurrency: z.string().default('16-32'),
maxConnections: z.union([z.string(), z.number()]).default(6),
maxConnections: z.union([z.string(), z.number()]).default(0),
cMaxReuseTimes: z.union([z.string(), z.number()]).default(0),
hMaxRequestTimes: z.string().default('600-900'),
hMaxReusableSecs: z.string().default('1800-3000'),
@@ -25,6 +30,15 @@ export const XHttpXmuxSchema = z.object({
});
export type XHttpXmux = z.infer<typeof XHttpXmuxSchema>;
// Seed for freshly enabling XMUX on a config that had no xmux block:
// mirrors xray-core v26.6.27's own anti-RKN maxConnections=6 fallback
// rather than the concurrency strategy.
export const XMUX_FRESH_DEFAULTS: XHttpXmux = {
...XHttpXmuxSchema.parse({}),
maxConcurrency: '',
maxConnections: 6,
};
// Predefined sessionIDTable names xray-core accepts as a shorthand for a
// charset (splithttp.PredefinedTable, xray-core #6258). A literal ASCII
// charset string is also accepted.
@@ -167,6 +167,16 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
},
"type": "header-custom",
},
{
"settings": {
"hostname": "mc.example.com",
"password": "s3cr3t",
"usernames": [
"Dream",
],
},
"type": "xmc",
},
],
"udp": [],
}
@@ -25,6 +25,14 @@
],
"errors": []
}
},
{
"type": "xmc",
"settings": {
"hostname": "mc.example.com",
"usernames": ["Dream"],
"password": "s3cr3t"
}
}
]
}
@@ -353,3 +353,35 @@ describe('legacy xhttp session keys on edit (#5621)', () => {
expect(out.sessionKey).toBeUndefined();
});
});
describe('xhttp xmux maxConcurrency survives a load/re-save round-trip', () => {
const xmuxRow: RawInboundRow = {
...vlessRow,
streamSettings: {
network: 'xhttp',
security: 'none',
xhttpSettings: {
path: '/xh',
mode: 'auto',
xmux: { maxConcurrency: '1-2' },
},
},
};
it('rawInboundToFormValues does not resurrect a non-zero maxConnections', () => {
const values = rawInboundToFormValues(xmuxRow);
const xhttp = (values.streamSettings as unknown as Record<string, Record<string, unknown>>).xhttpSettings;
expect(xhttp.enableXmux).toBe(true);
const xmux = xhttp.xmux as Record<string, unknown>;
expect(xmux.maxConcurrency).toBe('1-2');
expect(xmux.maxConnections).toBe(0);
});
it('formValuesToWirePayload keeps maxConcurrency on an unedited re-save', () => {
const values = rawInboundToFormValues(xmuxRow);
const payload = formValuesToWirePayload(values);
const stream = JSON.parse(payload.streamSettings) as Record<string, Record<string, unknown>>;
const xmux = stream.xhttpSettings.xmux as Record<string, unknown>;
expect(xmux.maxConcurrency).toBe('1-2');
});
});
@@ -521,6 +521,29 @@ describe('outbound-form-adapter: xhttp xmux toggle', () => {
const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
expect(wireXhttp).not.toHaveProperty('xmux');
});
it('keeps a saved maxConcurrency through a load/re-save round-trip', () => {
const wire = {
...xmuxWire,
streamSettings: {
...xmuxWire.streamSettings,
xhttpSettings: {
...xmuxWire.streamSettings.xhttpSettings,
xmux: { maxConcurrency: '1-2' },
},
},
};
const form = rawOutboundToFormValues(wire);
const xhttp = (form.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
const xmux = xhttp.xmux as Record<string, unknown>;
expect(xmux.maxConcurrency).toBe('1-2');
expect(xmux.maxConnections).toBe(0);
const back = formValuesToWirePayload(form);
const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
const wireXmux = wireXhttp.xmux as Record<string, unknown>;
expect(wireXmux.maxConcurrency).toBe('1-2');
});
});
describe('outbound-form-adapter: full optional-block round-trip', () => {
@@ -12,7 +12,7 @@ import {
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { XHttpXmuxSchema, XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
describe('validateRealityTarget', () => {
it('accepts host:port and bare port', () => {
@@ -153,19 +153,25 @@ describe('normalizeXhttpForWire stream-one', () => {
expect(xmux.maxConnections).toBe('8');
});
it('defaults xmux maxConnections to 6 (xray-core anti-RKN default) and drops maxConcurrency on the wire', () => {
expect(XHttpXmuxSchema.parse({}).maxConnections).toBe(6);
it('bare schema defaults keep only one exclusive xmux strategy non-zero', () => {
expect(XHttpXmuxSchema.parse({}).maxConnections).toBe(0);
expect(XHttpXmuxSchema.parse({}).maxConcurrency).toBe('16-32');
});
it('XMUX_FRESH_DEFAULTS seeds the anti-RKN maxConnections=6 without a competing maxConcurrency', () => {
expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(6);
expect(XMUX_FRESH_DEFAULTS.maxConcurrency).toBe('');
const out = normalizeXhttpForWire({
path: '/app',
mode: 'stream-one',
enableXmux: true,
xmux: XHttpXmuxSchema.parse({}),
xmux: XMUX_FRESH_DEFAULTS,
}, 'outbound');
const xmux = out.xmux as Record<string, unknown>;
expect(xmux.maxConnections).toBe(6);
expect(xmux).not.toHaveProperty('maxConcurrency');
expect(xmux.maxConcurrency).toBe('');
});
});
+13
View File
@@ -24,3 +24,16 @@ describe('NetworkSettingsSchema fixtures', () => {
});
}
});
describe('NetworkSettingsSchema method alias', () => {
it('folds xray-core v26.7.11 method alias back into network', () => {
const parsed = NetworkSettingsSchema.parse({ method: 'ws', wsSettings: {} });
expect((parsed as { network?: string }).network).toBe('ws');
expect((parsed as Record<string, unknown>).method).toBeUndefined();
});
it('prefers method over network when both are present', () => {
const parsed = NetworkSettingsSchema.parse({ method: 'grpc', network: 'tcp', grpcSettings: {} });
expect((parsed as { network?: string }).network).toBe('grpc');
});
});
+13 -1
View File
@@ -77,6 +77,18 @@ function injectBasePathPlugin() {
};
}
// Cloudflare Rocket Loader rewrites script tags and runs bundles through its
// own loader, breaking ES-module semantics; data-cfasync="false" opts out.
function rocketLoaderOptOutPlugin() {
return {
name: 'xui-rocket-loader-opt-out',
apply: 'build',
transformIndexHtml(html) {
return html.replaceAll('<script ', '<script data-cfasync="false" ');
},
};
}
function bypassMigratedRoute(req) {
if (req.method !== 'GET') return undefined;
const url = req.url.split('?')[0];
@@ -140,7 +152,7 @@ function makeBackendProxy(target) {
}
export default defineConfig({
plugins: [react(), injectBasePathPlugin()],
plugins: [react(), injectBasePathPlugin(), rocketLoaderOptOutPlugin()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
+6 -6
View File
@@ -21,10 +21,10 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/valyala/fasthttp v1.72.0
github.com/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12
github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c
go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.53.0
golang.org/x/sys v0.46.0
golang.org/x/crypto v0.54.0
golang.org/x/sys v0.47.0
golang.org/x/text v0.40.0
google.golang.org/grpc v1.82.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
@@ -64,7 +64,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/juju/ratelimit v1.0.2 // indirect
github.com/klauspost/compress v1.19.0
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
@@ -77,7 +77,7 @@ require (
github.com/pion/logging v0.2.4 // indirect
github.com/pion/stun/v3 v3.1.6 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/pires/go-proxyproto v0.12.0 // indirect
github.com/pires/go-proxyproto v0.15.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
@@ -101,7 +101,7 @@ require (
golang.org/x/arch v0.28.0 // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0
golang.org/x/net v0.57.0
golang.org/x/sync v0.22.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.47.0 // indirect
+12 -12
View File
@@ -117,8 +117,8 @@ github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -156,8 +156,8 @@ github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI=
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
@@ -218,8 +218,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12 h1:0RZBYuLmkxxlMrSsCNRrdNWHhW5zHTfkC2vAknzZ5P4=
github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12/go.mod h1:5J11wfaLGhI6HNOi90zVI6E87c1ykA8grPfUeHZYbhA=
github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c h1:SbB1ez0bqZllbzaVj0PC+Vje3dRA8m/7jW1ussjDSgM=
github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c/go.mod h1:Jts8yHqPCpvsdL5CW5xMd8H9d2fkg1cILeBNqEwRXNw=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -248,14 +248,14 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBs
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -263,8 +263,8 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+18
View File
@@ -873,6 +873,24 @@ prompt_and_setup_ssl() {
# User chose Let's Encrypt IP certificate option
echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
# Confirm the auto-detected IP before issuing for it: with asymmetric
# routing / multi-WAN the echo services can return a transit address.
if [[ "$NONINTERACTIVE" != "1" ]]; then
local ip_confirm=""
read -rp "Is ${server_ip} the correct incoming public IPv4 address for this server? [Default y]: " ip_confirm
if [[ -n "$ip_confirm" && "$ip_confirm" != "y" && "$ip_confirm" != "Y" ]]; then
server_ip=""
while [[ -z "$server_ip" ]]; do
read -rp "Please enter your server's public IPv4 address: " server_ip
server_ip="${server_ip// /}"
if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
server_ip=""
fi
done
fi
fi
# Ask for optional IPv6
local ipv6_addr=""
prompt_or_default ipv6_addr "Do you have an IPv6 address to include? (leave empty to skip): " "" XUI_SSL_IPV6
+261 -1
View File
@@ -91,6 +91,9 @@ func initModels() error {
return err
}
}
if err := dropLegacyInboundPortUnique(); err != nil {
return err
}
if err := migrateHostVerifyPeerCertByNameColumn(); err != nil {
return err
}
@@ -118,6 +121,12 @@ func initModels() error {
if err := migrateLegacySocksInboundsToMixed(); err != nil {
return err
}
if err := migrateShadowsocksRemovedCiphers(); err != nil {
return err
}
if err := migrateVmessRemovedSecurities(); err != nil {
return err
}
if IsPostgres() {
if err := resyncPostgresSequences(db, models); err != nil {
log.Printf("Error resyncing postgres sequences: %v", err)
@@ -160,6 +169,121 @@ func dropLegacyForeignKeys() error {
return nil
}
type sqliteIndexListRow struct {
Name string `gorm:"column:name"`
Unique int `gorm:"column:unique"`
Origin string `gorm:"column:origin"`
}
func sqliteUniquePortIndexes() (autoIndexes, explicitIndexes []string, err error) {
var list []sqliteIndexListRow
if err = db.Raw(`PRAGMA index_list('inbounds')`).Scan(&list).Error; err != nil {
return nil, nil, err
}
for _, idx := range list {
if idx.Unique != 1 {
continue
}
var cols []struct {
Name string `gorm:"column:name"`
}
if err = db.Raw(`PRAGMA index_info("` + idx.Name + `")`).Scan(&cols).Error; err != nil {
return nil, nil, err
}
if len(cols) != 1 || cols[0].Name != "port" {
continue
}
if idx.Origin == "c" {
explicitIndexes = append(explicitIndexes, idx.Name)
} else {
autoIndexes = append(autoIndexes, idx.Name)
}
}
return autoIndexes, explicitIndexes, nil
}
// dropLegacyInboundPortUnique removes the pre-multi-node UNIQUE on inbounds.port,
// which AutoMigrate never drops and which blocks cross-node port reuse on old SQLite DBs.
func dropLegacyInboundPortUnique() error {
if IsPostgres() {
return nil
}
autoIndexes, explicitIndexes, err := sqliteUniquePortIndexes()
if err != nil {
return err
}
for _, name := range explicitIndexes {
if err := db.Exec(`DROP INDEX IF EXISTS "` + name + `"`).Error; err != nil {
return err
}
}
if len(autoIndexes) == 0 {
return nil
}
log.Printf("Rebuilding inbounds table to drop the legacy UNIQUE constraint on port")
return rebuildInboundsWithoutInlineUniquePort()
}
func sqliteTableColumns(tx *gorm.DB, table string) ([]string, error) {
var rows []struct {
Name string `gorm:"column:name"`
}
if err := tx.Raw(`PRAGMA table_info("` + table + `")`).Scan(&rows).Error; err != nil {
return nil, err
}
cols := make([]string, 0, len(rows))
for _, r := range rows {
cols = append(cols, r.Name)
}
return cols, nil
}
func rebuildInboundsWithoutInlineUniquePort() error {
return db.Transaction(func(tx *gorm.DB) error {
var list []sqliteIndexListRow
if err := tx.Raw(`PRAGMA index_list('inbounds')`).Scan(&list).Error; err != nil {
return err
}
for _, idx := range list {
if idx.Origin != "c" {
continue
}
if err := tx.Exec(`DROP INDEX IF EXISTS "` + idx.Name + `"`).Error; err != nil {
return err
}
}
if err := tx.Exec(`ALTER TABLE inbounds RENAME TO inbounds_legacy_rebuild`).Error; err != nil {
return err
}
if err := tx.Migrator().CreateTable(&model.Inbound{}); err != nil {
return err
}
newCols, err := sqliteTableColumns(tx, "inbounds")
if err != nil {
return err
}
oldCols, err := sqliteTableColumns(tx, "inbounds_legacy_rebuild")
if err != nil {
return err
}
oldSet := make(map[string]struct{}, len(oldCols))
for _, c := range oldCols {
oldSet[c] = struct{}{}
}
shared := make([]string, 0, len(newCols))
for _, c := range newCols {
if _, ok := oldSet[c]; ok {
shared = append(shared, `"`+c+`"`)
}
}
colList := strings.Join(shared, ", ")
if err := tx.Exec(`INSERT INTO inbounds (` + colList + `) SELECT ` + colList + ` FROM inbounds_legacy_rebuild`).Error; err != nil {
return err
}
return tx.Exec(`DROP TABLE inbounds_legacy_rebuild`).Error
})
}
func migrateHostVerifyPeerCertByNameColumn() error {
if !db.Migrator().HasColumn(&model.Host{}, "verify_peer_cert_by_name") {
return nil
@@ -636,6 +760,125 @@ func migrateLegacySocksInboundsToMixed() error {
return nil
}
// migrateShadowsocksRemovedCiphers rewrites shadowsocks inbounds still using
// the "none"/"plain" ciphers that xray-core v26.7.11 removed; one such row
// makes the whole generated config unbuildable and keeps xray from starting.
func migrateShadowsocksRemovedCiphers() error {
var inbounds []model.Inbound
if err := db.Where("protocol = ?", model.Shadowsocks).Find(&inbounds).Error; err != nil {
return err
}
migrated := int64(0)
for _, inbound := range inbounds {
if strings.TrimSpace(inbound.Settings) == "" {
continue
}
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
continue
}
changed := false
if method, _ := settings["method"].(string); method != "" {
if replacement, removed := model.ReplaceRemovedShadowsocksCipher(method); removed {
settings["method"] = replacement
changed = true
}
}
if clients, ok := settings["clients"].([]any); ok {
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
method, _ := cm["method"].(string)
if replacement, removed := model.ReplaceRemovedShadowsocksCipher(method); removed {
cm["method"] = replacement
clients[i] = cm
changed = true
}
}
}
if !changed {
continue
}
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
log.Printf("migrateShadowsocksRemovedCiphers: skip inbound %d (marshal failed): %v", inbound.Id, err)
continue
}
if err := db.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
Update("settings", string(newSettings)).Error; err != nil {
return err
}
migrated++
}
if migrated > 0 {
log.Printf("Rewrote removed shadowsocks cipher on %d inbound(s)", migrated)
}
return nil
}
// migrateVmessRemovedSecurities rewrites the vmess "none"/"zero" security
// values that xray-core v26.7.11 removed to "auto" (what the core now treats
// them as), on both the clients column and each vmess inbound's settings.
func migrateVmessRemovedSecurities() error {
res := db.Exec("UPDATE clients SET security = 'auto' WHERE security IN ('none', 'zero')")
if res.Error != nil {
log.Printf("Error migrating removed vmess security values on clients: %v", res.Error)
return res.Error
}
if res.RowsAffected > 0 {
log.Printf("Migrated %d client(s) off removed vmess security values", res.RowsAffected)
}
var inbounds []model.Inbound
if err := db.Where("protocol = ?", model.VMESS).Find(&inbounds).Error; err != nil {
return err
}
migrated := int64(0)
for _, inbound := range inbounds {
if strings.TrimSpace(inbound.Settings) == "" {
continue
}
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
continue
}
clients, ok := settings["clients"].([]any)
if !ok {
continue
}
changed := false
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
if security, _ := cm["security"].(string); security == "none" || security == "zero" {
cm["security"] = "auto"
clients[i] = cm
changed = true
}
}
if !changed {
continue
}
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
log.Printf("migrateVmessRemovedSecurities: skip inbound %d (marshal failed): %v", inbound.Id, err)
continue
}
if err := db.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
Update("settings", string(newSettings)).Error; err != nil {
return err
}
migrated++
}
if migrated > 0 {
log.Printf("Rewrote removed vmess security values on %d inbound(s)", migrated)
}
return nil
}
// normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
// minimum (rows written by builds that defaulted the column to 0, or by nodes
// predating the field) so they cannot sort ahead of explicitly ranked inbounds.
@@ -810,7 +1053,7 @@ func runSeeders(isUsersEmpty bool) error {
}
if empty && isUsersEmpty {
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients"}
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted"}
for _, name := range seeders {
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
return err
@@ -903,6 +1146,12 @@ func runSeeders(isUsersEmpty bool) error {
}
}
if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
if err := seedNodeInboundsAdopted(); err != nil {
return err
}
}
if err := seedHostsFromExternalProxy(); err != nil {
return err
}
@@ -937,6 +1186,17 @@ func runSeeders(isUsersEmpty bool) error {
return normalizeSettingPaths()
}
// seedNodeInboundsAdopted keeps the pre-existing reconcile behavior for nodes
// that were already syncing before the inbounds_adopted_at gate was introduced.
func seedNodeInboundsAdopted() error {
if err := db.Model(&model.Node{}).
Where("inbounds_adopted_at = 0").
Update("inbounds_adopted_at", time.Now().Unix()).Error; err != nil {
return err
}
return db.Create(&model.HistoryOfSeeders{SeederName: "NodeInboundsAdopted"}).Error
}
func seedHostGroupIds() error {
var history []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
@@ -0,0 +1,76 @@
package database
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
const legacyInboundsDDL = "CREATE TABLE `inbounds` (`id` integer PRIMARY KEY AUTOINCREMENT,`user_id` integer,`up` integer,`down` integer,`total` integer,`remark` text,`enable` numeric,`expiry_time` integer,`listen` text,`port` integer UNIQUE,`protocol` text,`settings` text,`stream_settings` text,`tag` text UNIQUE,`sniffing` text)"
func openLegacyPortUniqueDB(t *testing.T) string {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
legacy, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
t.Fatalf("open legacy db: %v", err)
}
if err := legacy.Exec(legacyInboundsDDL).Error; err != nil {
t.Fatalf("create legacy inbounds: %v", err)
}
if err := legacy.Exec(
`INSERT INTO inbounds (user_id, remark, enable, port, protocol, settings, stream_settings, tag, sniffing)
VALUES (1, 'preexisting', 1, 80, 'vless', '{"clients":[]}', '{}', 'in-80-tcp', '{}')`,
).Error; err != nil {
t.Fatalf("seed legacy inbound: %v", err)
}
sqlDB, err := legacy.DB()
if err != nil {
t.Fatalf("legacy db handle: %v", err)
}
if err := sqlDB.Close(); err != nil {
t.Fatalf("close legacy db: %v", err)
}
return dbPath
}
func TestDropLegacyInboundPortUnique(t *testing.T) {
dbPath := openLegacyPortUniqueDB(t)
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB over legacy schema: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
var preexisting model.Inbound
if err := GetDB().Where("tag = ?", "in-80-tcp").First(&preexisting).Error; err != nil {
t.Fatalf("preexisting inbound lost in rebuild: %v", err)
}
if preexisting.Remark != "preexisting" || preexisting.Port != 80 {
t.Fatalf("preexisting inbound corrupted: remark=%q port=%d", preexisting.Remark, preexisting.Port)
}
nodeA, nodeB := 1, 2
samePortA := &model.Inbound{UserId: 1, Tag: "node-a-80", Enable: true, Port: 80, Protocol: model.VLESS, Remark: "a", Settings: `{"clients":[]}`, NodeID: &nodeA}
samePortB := &model.Inbound{UserId: 1, Tag: "node-b-80", Enable: true, Port: 80, Protocol: model.VLESS, Remark: "b", Settings: `{"clients":[]}`, NodeID: &nodeB}
if err := GetDB().Create(samePortA).Error; err != nil {
t.Fatalf("create node-a inbound on port 80: %v", err)
}
if err := GetDB().Create(samePortB).Error; err != nil {
t.Fatalf("create node-b inbound on port 80: %v", err)
}
var dupTag model.Inbound
dupErr := GetDB().Create(&model.Inbound{UserId: 1, Tag: "in-80-tcp", Enable: true, Port: 90, Protocol: model.VLESS, Settings: `{"clients":[]}`}).Error
if dupErr == nil {
t.Fatalf("duplicate tag insert succeeded, want unique violation; row=%v", dupTag)
}
if err := dropLegacyInboundPortUnique(); err != nil {
t.Fatalf("second dropLegacyInboundPortUnique run: %v", err)
}
}
+35 -4
View File
@@ -442,8 +442,23 @@ func StripVlessInboundEncryption(settings string) (string, bool) {
return string(out), true
}
// HealShadowsocksClientMethods normalises the per-client `method` field
// on a shadowsocks inbound's settings JSON before it leaves for xray-core:
// ReplaceRemovedShadowsocksCipher maps ciphers that xray-core v26.7.11
// deleted ("none"/"plain" make the whole config fail with "unknown cipher
// method") to a still-supported replacement. Returns the replacement and
// true when the given method is one of the removed ciphers.
func ReplaceRemovedShadowsocksCipher(method string) (string, bool) {
switch method {
case "none", "plain":
return "chacha20-ietf-poly1305", true
}
return method, false
}
// HealShadowsocksClientMethods normalises the `method` fields on a
// shadowsocks inbound's settings JSON before it leaves for xray-core:
// - Ciphers removed upstream (none/plain): rewritten via
// ReplaceRemovedShadowsocksCipher so one legacy row cannot prevent
// xray from starting.
// - Legacy ciphers (aes-*, chacha20-*): every client must carry a
// per-user `method` matching the inbound's top-level method, otherwise
// xray fails with "unsupported cipher method:".
@@ -462,12 +477,24 @@ func HealShadowsocksClientMethods(settings string) (string, bool) {
return settings, false
}
method, _ := parsed["method"].(string)
changed := false
if replacement, removed := ReplaceRemovedShadowsocksCipher(method); removed {
method = replacement
parsed["method"] = method
changed = true
}
clients, ok := parsed["clients"].([]any)
if !ok {
return settings, false
if !changed {
return settings, false
}
out, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(out), true
}
is2022 := strings.HasPrefix(method, "2022-blake3-")
changed := false
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
@@ -719,6 +746,10 @@ type Node struct {
ConfigDirty bool `json:"configDirty" gorm:"default:false"`
ConfigDirtyAt int64 `json:"configDirtyAt"`
// InboundsAdoptedAt records the first clean traffic sync that imported the
// node's pre-existing inbounds; reconcile must not sweep remote tags before it.
InboundsAdoptedAt int64 `json:"-" gorm:"column:inbounds_adopted_at;default:0"`
InboundCount int `json:"inboundCount" gorm:"-" example:"5"`
ClientCount int `json:"clientCount" gorm:"-" example:"27"`
OnlineCount int `json:"onlineCount" gorm:"-" example:"3"`
@@ -0,0 +1,43 @@
package model
import (
"encoding/json"
"testing"
)
// TestHealShadowsocksClientMethods_RewritesRemovedCipher covers the last-gate
// build-time heal for xray-core v26.7.11's removed "none"/"plain" ciphers: a
// row that survives to config generation (restored backup, direct DB edit)
// must be rewritten to a supported cipher on both the inbound method and its
// clients so one such inbound cannot keep xray from starting.
func TestHealShadowsocksClientMethods_RewritesRemovedCipher(t *testing.T) {
settings := `{"method": "plain", "clients": [{"email": "a@x", "password": "p", "method": "plain"}]}`
healed, ok := HealShadowsocksClientMethods(settings)
if !ok {
t.Fatal("expected heal to report a change for a removed cipher")
}
var parsed struct {
Method string `json:"method"`
Clients []map[string]any `json:"clients"`
}
if err := json.Unmarshal([]byte(healed), &parsed); err != nil {
t.Fatalf("parse healed settings: %v", err)
}
if parsed.Method != "chacha20-ietf-poly1305" {
t.Fatalf("expected inbound method rewritten to a supported cipher, got %q", parsed.Method)
}
if parsed.Clients[0]["method"] != "chacha20-ietf-poly1305" {
t.Fatalf("expected client method to match the healed cipher, got %v", parsed.Clients[0]["method"])
}
}
func TestReplaceRemovedShadowsocksCipher(t *testing.T) {
for _, method := range []string{"none", "plain"} {
if got, removed := ReplaceRemovedShadowsocksCipher(method); !removed || got != "chacha20-ietf-poly1305" {
t.Fatalf("ReplaceRemovedShadowsocksCipher(%q) = (%q, %v), want a supported replacement", method, got, removed)
}
}
if got, removed := ReplaceRemovedShadowsocksCipher("aes-256-gcm"); removed || got != "aes-256-gcm" {
t.Fatalf("ReplaceRemovedShadowsocksCipher(aes-256-gcm) = (%q, %v), want it left untouched", got, removed)
}
}
@@ -0,0 +1,117 @@
package database
import (
"encoding/json"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// TestMigrateShadowsocksRemovedCiphers_RewritesNoneAndPlain covers the
// xray-core v26.7.11 removal of the shadowsocks "none"/"plain" ciphers: one
// such row makes the generated config unbuildable, so startup must rewrite
// both the inbound method and any per-client method to a supported cipher and
// leave a valid inbound untouched.
func TestMigrateShadowsocksRemovedCiphers_RewritesNoneAndPlain(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
removed := `{"method": "none", "clients": [{"email": "a@x", "password": "p", "method": "plain"}]}`
dirty := model.Inbound{UserId: 1, Port: 31001, Protocol: model.Shadowsocks, Tag: "ss-removed", Settings: removed}
if err := db.Create(&dirty).Error; err != nil {
t.Fatalf("create dirty inbound: %v", err)
}
valid := `{"method": "aes-256-gcm", "clients": [{"email": "b@x", "password": "p"}]}`
clean := model.Inbound{UserId: 1, Port: 31002, Protocol: model.Shadowsocks, Tag: "ss-valid", Settings: valid}
if err := db.Create(&clean).Error; err != nil {
t.Fatalf("create clean inbound: %v", err)
}
if err := migrateShadowsocksRemovedCiphers(); err != nil {
t.Fatalf("migrateShadowsocksRemovedCiphers: %v", err)
}
var gotDirty model.Inbound
if err := db.First(&gotDirty, dirty.Id).Error; err != nil {
t.Fatalf("reload dirty inbound: %v", err)
}
var parsed struct {
Method string `json:"method"`
Clients []map[string]any `json:"clients"`
}
if err := json.Unmarshal([]byte(gotDirty.Settings), &parsed); err != nil {
t.Fatalf("parse repaired settings: %v", err)
}
if parsed.Method != "chacha20-ietf-poly1305" {
t.Fatalf("expected inbound method rewritten, got %q", parsed.Method)
}
if parsed.Clients[0]["method"] != "chacha20-ietf-poly1305" {
t.Fatalf("expected client method rewritten, got %v", parsed.Clients[0]["method"])
}
var gotClean model.Inbound
if err := db.First(&gotClean, clean.Id).Error; err != nil {
t.Fatalf("reload clean inbound: %v", err)
}
if gotClean.Settings != valid {
t.Fatalf("valid inbound was rewritten:\nbefore: %s\nafter: %s", valid, gotClean.Settings)
}
}
// TestMigrateVmessRemovedSecurities_RewritesNoneAndZero covers the v26.7.11
// removal of vmess "none"/"zero" security values: startup rewrites them to
// "auto" on both the clients column and each vmess inbound's settings JSON.
func TestMigrateVmessRemovedSecurities_RewritesNoneAndZero(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
settings := `{"clients": [{"id": "u1", "email": "a@x", "security": "none"},` +
`{"id": "u2", "email": "b@x", "security": "zero"},` +
`{"id": "u3", "email": "c@x", "security": "aes-128-gcm"}]}`
inbound := model.Inbound{UserId: 1, Port: 32001, Protocol: model.VMESS, Tag: "vmess-removed", Settings: settings}
if err := db.Create(&inbound).Error; err != nil {
t.Fatalf("create vmess inbound: %v", err)
}
if err := db.Create(&model.ClientRecord{Email: "a@x", Security: "zero", Enable: true}).Error; err != nil {
t.Fatalf("create client record: %v", err)
}
if err := migrateVmessRemovedSecurities(); err != nil {
t.Fatalf("migrateVmessRemovedSecurities: %v", err)
}
var got model.Inbound
if err := db.First(&got, inbound.Id).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
var parsed struct {
Clients []map[string]any `json:"clients"`
}
if err := json.Unmarshal([]byte(got.Settings), &parsed); err != nil {
t.Fatalf("parse settings: %v", err)
}
if parsed.Clients[0]["security"] != "auto" || parsed.Clients[1]["security"] != "auto" {
t.Fatalf("expected removed securities rewritten to auto, got %v", parsed.Clients)
}
if parsed.Clients[2]["security"] != "aes-128-gcm" {
t.Fatalf("expected valid security untouched, got %v", parsed.Clients[2]["security"])
}
var rec model.ClientRecord
if err := db.Where("email = ?", "a@x").First(&rec).Error; err != nil {
t.Fatalf("reload client record: %v", err)
}
if rec.Security != "auto" {
t.Fatalf("expected client record security rewritten to auto, got %q", rec.Security)
}
}
+1 -5
View File
@@ -236,11 +236,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
proxy["type"] = "vmess"
proxy["uuid"] = client.ID
proxy["alterId"] = 0
cipher := client.Security
if cipher == "" {
cipher = "auto"
}
proxy["cipher"] = cipher
proxy["cipher"] = normalizeVmessSecurity(client.Security)
case model.VLESS:
proxy["type"] = "vless"
proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep))
+1 -4
View File
@@ -384,10 +384,7 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
}
outbound.StreamSettings = streamSettings
security := client.Security
if security == "" {
security = "auto"
}
security := normalizeVmessSecurity(client.Security)
outbound.Settings = map[string]any{
"address": inbound.Listen,
"port": inbound.Port,
+14 -1
View File
@@ -714,7 +714,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
return ""
}
obj["id"] = client.ID
obj["scy"] = client.Security
obj["scy"] = normalizeVmessSecurity(client.Security)
externalProxies, _ := stream["externalProxy"].([]any)
@@ -726,6 +726,18 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
return buildVmessLink(obj)
}
// normalizeVmessSecurity maps the vmess security values xray-core v26.7.11
// removed ("none"/"zero"), plus the legacy empty string, to "auto" so links
// and subscriptions stop advertising values the upgraded server rejects on
// the wire.
func normalizeVmessSecurity(security string) string {
switch security {
case "", "none", "zero":
return "auto"
}
return security
}
// vlessEncryptionEnabled reports whether the VLESS inbound settings enable
// VLESS-level encryption (vlessenc / ML-KEM). When on, the encryption/decryption
// fields hold a generated dotted string (e.g. "mlkem768x25519plus.native.0rtt.<key>");
@@ -2131,6 +2143,7 @@ var validFinalMaskTCPTypes = map[string]struct{}{
"header-custom": {},
"fragment": {},
"sudoku": {},
"xmc": {},
}
// applyKcpShareParams reconstructs legacy KCP share-link fields from either
+15
View File
@@ -1016,6 +1016,21 @@ func TestMarshalFinalMask_UnknownTypeIsDropped(t *testing.T) {
}
}
func TestMarshalFinalMask_KeepsXmcTcpMask(t *testing.T) {
fm := map[string]any{
"tcp": []any{
map[string]any{"type": "xmc", "settings": map[string]any{"password": "p"}},
},
}
out, ok := marshalFinalMask(fm)
if !ok {
t.Fatal("expected ok=true for an xmc tcp mask")
}
if !strings.Contains(out, "xmc") {
t.Fatalf("marshaled finalmask dropped the xmc mask: %s", out)
}
}
func TestHasFinalMaskContent(t *testing.T) {
if hasFinalMaskContent(nil) {
t.Fatal("nil should not count as content")
+17 -4
View File
@@ -208,6 +208,10 @@ func parseVmess(link string) (*ParseResult, error) {
}
port := num(j["port"])
scy := getString(j, "scy", "auto")
if scy == "none" || scy == "zero" {
scy = "auto"
}
ob := Outbound{
"protocol": "vmess",
"tag": getString(j, "ps", ""),
@@ -219,7 +223,7 @@ func parseVmess(link string) (*ParseResult, error) {
"users": []any{
map[string]any{
"id": getString(j, "id", ""),
"security": getString(j, "scy", "auto"),
"security": scy,
},
},
},
@@ -338,12 +342,15 @@ func parseShadowsocks(link string) (*ParseResult, error) {
remark, _ = url.QueryUnescape(link[i+1:])
link = link[:i]
}
if i := strings.Index(link, "?"); i >= 0 {
link = link[:i]
}
core := strings.TrimPrefix(link, "ss://")
at := strings.Index(core, "@")
if at >= 0 {
// modern
userB64 := core[:at]
hp := core[at+1:]
hp := strings.TrimRight(core[at+1:], "/")
userInfo, err := base64DecodeFlexible(userB64)
if err != nil {
// SIP022 (2022-blake3-*) userinfo is percent-encoded, not base64.
@@ -358,7 +365,10 @@ func parseShadowsocks(link string) (*ParseResult, error) {
return nil, fmt.Errorf("bad ss host:port")
}
host := hp[:colon]
port, _ := strconv.Atoi(hp[colon+1:])
port, err := strconv.Atoi(hp[colon+1:])
if err != nil {
return nil, fmt.Errorf("bad ss port %q: %w", hp[colon+1:], err)
}
method, pass := splitMethodPass(userInfo)
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
ob := Outbound{
@@ -388,7 +398,10 @@ func parseShadowsocks(link string) (*ParseResult, error) {
return nil, fmt.Errorf("bad legacy ss hp")
}
host := hp[:colon]
port, _ := strconv.Atoi(hp[colon+1:])
port, err := strconv.Atoi(hp[colon+1:])
if err != nil {
return nil, fmt.Errorf("bad legacy ss port %q: %w", hp[colon+1:], err)
}
method, pass := splitMethodPass(userInfo)
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
ob := Outbound{
+110
View File
@@ -1,6 +1,7 @@
package link
import (
"encoding/base64"
"net/url"
"strings"
"testing"
@@ -113,6 +114,115 @@ func TestSanitizeFinalMaskQuicParams_ClampsAndRejects(t *testing.T) {
}
}
func TestParseShadowsocks(t *testing.T) {
modernUser := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
legacyBody := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass@1.2.3.4:8388"))
cases := []struct {
name string
link string
host string
port int
method string
pass string
}{
{
name: "modern",
link: "ss://" + modernUser + "@1.2.3.4:8388#node",
host: "1.2.3.4",
port: 8388,
method: "aes-256-gcm",
pass: "secretpass",
},
{
name: "modern with plugin query",
link: "ss://" + modernUser + "@1.2.3.4:8388?plugin=v2ray-plugin#node",
host: "1.2.3.4",
port: 8388,
method: "aes-256-gcm",
pass: "secretpass",
},
{
name: "modern sip002 slash query",
link: "ss://" + modernUser + "@1.2.3.4:8388/?plugin=obfs-local%3Bobfs%3Dhttp#node",
host: "1.2.3.4",
port: 8388,
method: "aes-256-gcm",
pass: "secretpass",
},
{
name: "legacy",
link: "ss://" + legacyBody + "#node",
host: "1.2.3.4",
port: 8388,
method: "aes-256-gcm",
pass: "secretpass",
},
{
name: "base64url userinfo with plugin and trailing slash",
link: "ss://" + base64.RawURLEncoding.EncodeToString([]byte("aes-128-gcm:pa+ss/word")) + "@1.2.3.4:8388/?plugin=obfs-local%3Bobfs%3Dhttp#node",
host: "1.2.3.4",
port: 8388,
method: "aes-128-gcm",
pass: "pa+ss/word",
},
{
name: "sip022 percent-encoded userinfo",
link: "ss://2022-blake3-aes-256-gcm:YctPZ6U7xPPcU%2Bgp3u%2B0tx%2FtRizJN9K8y%2BuKlW2qjlI%3D@example.com:8888#Example3",
host: "example.com",
port: 8888,
method: "2022-blake3-aes-256-gcm",
pass: "YctPZ6U7xPPcU+gp3u+0tx/tRizJN9K8y+uKlW2qjlI=",
},
{
name: "sip022 dual-key password with type query preserves inner colon",
link: "ss://2022-blake3-aes-256-gcm:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%3D:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB%3D@1.2.3.4:9999?type=tcp#node",
host: "1.2.3.4",
port: 9999,
method: "2022-blake3-aes-256-gcm",
pass: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
res, err := ParseLink(c.link)
if err != nil {
t.Fatalf("parse ss: %v", err)
}
if res.Outbound["protocol"] != "shadowsocks" {
t.Fatalf("protocol = %v, want shadowsocks", res.Outbound["protocol"])
}
srv := res.Outbound["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
if srv["address"] != c.host {
t.Errorf("address = %v, want %v", srv["address"], c.host)
}
if srv["port"] != c.port {
t.Errorf("port = %v, want %v", srv["port"], c.port)
}
if srv["method"] != c.method {
t.Errorf("method = %v, want %v", srv["method"], c.method)
}
if srv["password"] != c.pass {
t.Errorf("password = %v, want %v", srv["password"], c.pass)
}
})
}
}
func TestParseShadowsocksBadPort(t *testing.T) {
user := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
cases := map[string]string{
"modern": "ss://" + user + "@1.2.3.4:notaport#node",
"legacy": "ss://" + base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass@1.2.3.4:notaport")) + "#node",
}
for name, link := range cases {
t.Run(name, func(t *testing.T) {
if _, err := ParseLink(link); err == nil {
t.Errorf("expected parse error for non-numeric port, got nil")
}
})
}
}
func TestParseSubscriptionBody_Base64(t *testing.T) {
// base64 of the two joined links:
// vless://u@h:443?type=tcp#A\nvless://u2@h2:443?type=tcp#B
+1 -1
View File
@@ -110,7 +110,7 @@ func serveDistPage(c *gin.Context, name string) {
if nonce := c.GetString("csp_nonce"); nonce != "" {
nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
}
script := `<script` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
script := `<script data-cfasync="false"` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
if name != "login.html" {
escapedVer := jsEscape.Replace(config.GetPanelVersion())
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
@@ -0,0 +1,68 @@
package job
import (
"os"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
)
func banLineCount(t *testing.T, email string) int {
t.Helper()
body, err := os.ReadFile(readIpLimitLogPath())
if err != nil {
if os.IsNotExist(err) {
return 0
}
t.Fatalf("read 3xipl.log: %v", err)
}
return strings.Count(string(body), "[LIMIT_IP] Email = "+email)
}
func TestUpdateInboundClientIps_FrozenLastSeenBannedOnce(t *testing.T) {
setupIntegrationDB(t)
const email = "frozen-ban"
seedInboundWithClient(t, "inbound-frozen-ban", email, 1)
now := time.Now().Unix()
deadStart := now - 300
live := []IPWithTimestamp{
{IP: "10.2.0.1", Timestamp: deadStart},
{IP: "192.0.2.7", Timestamp: now},
}
j := NewCheckClientIpJob()
inbound, err := j.getInboundByEmail(email)
if err != nil {
t.Fatalf("getInboundByEmail: %v", err)
}
row := seedClientIps(t, email, nil)
if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, true); !banned {
t.Fatalf("first scan: the over-limit stale IP must be banned")
}
if got := banLineCount(t, email); got != 1 {
t.Fatalf("ban lines after first scan = %d, want 1", got)
}
if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, true); banned {
t.Fatalf("second scan with a frozen lastSeen must not re-ban a dead connection")
}
if got := banLineCount(t, email); got != 1 {
t.Fatalf("ban lines after frozen rescan = %d, want still 1", got)
}
reconnected := []IPWithTimestamp{
{IP: "10.2.0.1", Timestamp: now + 30},
{IP: "192.0.2.7", Timestamp: now + 60},
}
if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, reconnected, true, true); !banned {
t.Fatalf("a reconnect (advanced lastSeen) must be banned again")
}
if got := banLineCount(t, email); got != 2 {
t.Fatalf("ban lines after reconnect = %d, want 2", got)
}
}
+34 -2
View File
@@ -9,6 +9,7 @@ import (
"os/exec"
"runtime"
"sort"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -32,6 +33,7 @@ type IPWithTimestamp struct {
// simply skips the run (the bundled core always supports it).
type CheckClientIpJob struct {
disAllowedIps []string
bannedSeen map[string]int64
xrayService service.XrayService
}
@@ -509,7 +511,8 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
// historical db-only ips are excluded from this count on purpose.
keptLive, bannedLive := selectIpsToBan(liveIps, limitIp)
if len(bannedLive) > 0 {
actionable := j.filterAdvancedSinceLastBan(clientEmail, bannedLive)
if len(actionable) > 0 {
shouldCleanLog = true
banned = true
@@ -525,7 +528,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
// filter.d/3x-ipl.conf with
// failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
// don't change the wording.
for _, ipTime := range bannedLive {
for _, ipTime := range actionable {
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
}
@@ -552,6 +555,35 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
return shouldCleanLog, banned
}
// filterAdvancedSinceLastBan keeps only banned pairs whose lastSeen advanced since
// the previous ban: the core refreshes lastSeen solely on a new dispatch, so a
// frozen value is a dead connection it hasn't reaped yet, not a reconnect.
func (j *CheckClientIpJob) filterAdvancedSinceLastBan(email string, banned []IPWithTimestamp) []IPWithTimestamp {
if j.bannedSeen == nil {
j.bannedSeen = make(map[string]int64)
}
current := make(map[string]struct{}, len(banned))
actionable := make([]IPWithTimestamp, 0, len(banned))
for _, ipTime := range banned {
key := email + "|" + ipTime.IP
current[key] = struct{}{}
if last, ok := j.bannedSeen[key]; ok && ipTime.Timestamp <= last {
continue
}
j.bannedSeen[key] = ipTime.Timestamp
actionable = append(actionable, ipTime)
}
prefix := email + "|"
for key := range j.bannedSeen {
if strings.HasPrefix(key, prefix) {
if _, still := current[key]; !still {
delete(j.bannedSeen, key)
}
}
}
return actionable
}
// disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
var xrayAPI xray.XrayAPI
+19
View File
@@ -391,6 +391,20 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
}
service.FilterNodeSnapshot(n, snap)
_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
if !dirty {
if pending, checkErr := j.inboundService.SnapshotHasUnadoptedInbounds(n.Id, snap); checkErr != nil {
logger.Warningf("node traffic sync: unadopted-inbound check for %s failed: %v", n.Name, checkErr)
} else if pending {
hostCtx, hostCancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
groups, hgErr := rt.FetchHostGroups(hostCtx)
hostCancel()
if hgErr != nil {
logger.Debugf("node traffic sync: fetch host groups from %s failed: %v", n.Name, hgErr)
} else {
snap.HostGroups = groups
}
}
}
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
if err != nil {
logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err)
@@ -399,6 +413,11 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
if changed {
j.structural.set()
}
if !dirty && n.InboundsAdoptedAt == 0 {
if markErr := j.nodeService.MarkNodeInboundsAdopted(n.Id); markErr != nil {
logger.Warningf("node traffic sync: mark inbounds adopted for %s failed: %v", n.Name, markErr)
}
}
active := make([]string, 0, len(snap.OnlineEmails))
active = append(active, snap.OnlineEmails...)
+20
View File
@@ -21,6 +21,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
@@ -669,6 +670,25 @@ type TrafficSnapshot struct {
// the per-GUID endpoint — OnlineEmails is the fallback then.
OnlineTree map[string][]string
LastOnlineMap map[string]int64
// HostGroups carries the node's per-inbound host overrides (TLS/SNI/
// fingerprint), fetched only when the snapshot holds a not-yet-adopted tag.
HostGroups []*entity.HostGroup
}
// FetchHostGroups pulls the node's host overrides so a freshly adopted inbound
// keeps its subscription TLS/SNI/fingerprint settings on the master.
func (r *Remote) FetchHostGroups(ctx context.Context) ([]*entity.HostGroup, error) {
env, err := r.do(ctx, http.MethodGet, "panel/api/hosts/list", nil)
if err != nil {
return nil, err
}
var groups []*entity.HostGroup
if len(env.Obj) > 0 {
if err := json.Unmarshal(env.Obj, &groups); err != nil {
return nil, fmt.Errorf("decode host groups: %w", err)
}
}
return groups, nil
}
func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, error) {
+20 -6
View File
@@ -1208,7 +1208,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
db := database.GetDB()
const lookupChunk = 400
existingEmailSub := make(map[string]string, len(emails))
existingByEmail := make(map[string]model.ClientRecord, len(emails))
for start := 0; start < len(emails); start += lookupChunk {
end := min(start+lookupChunk, len(emails))
var rows []model.ClientRecord
@@ -1216,7 +1216,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
return result, false, e
}
for i := range rows {
existingEmailSub[strings.ToLower(rows[i].Email)] = rows[i].SubID
existingByEmail[strings.ToLower(rows[i].Email)] = rows[i]
}
}
existingSubOwner := make(map[string]string, len(subIDs))
@@ -1252,10 +1252,24 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
for idx := range prep {
le := strings.ToLower(prep[idx].client.Email)
if existSub, ok := existingEmailSub[le]; ok && existSub != prep[idx].client.SubID {
failed[idx] = true
reason[idx] = "email already in use: " + prep[idx].client.Email
continue
if rec, ok := existingByEmail[le]; ok {
if rec.SubID != prep[idx].client.SubID {
failed[idx] = true
reason[idx] = "email already in use: " + prep[idx].client.Email
continue
}
if prep[idx].client.ID == "" {
prep[idx].client.ID = rec.UUID
}
if prep[idx].client.Password == "" {
prep[idx].client.Password = rec.Password
}
if prep[idx].client.Auth == "" {
prep[idx].client.Auth = rec.Auth
}
if prep[idx].client.Secret == "" {
prep[idx].client.Secret = rec.Secret
}
}
if owner, ok := existingSubOwner[prep[idx].client.SubID]; ok && owner != le {
failed[idx] = true
@@ -0,0 +1,54 @@
package service
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func settingsHoldUUID(t *testing.T, inboundSvc *InboundService, inboundId int, uuid string) bool {
t.Helper()
ib, err := inboundSvc.GetInbound(inboundId)
if err != nil {
t.Fatalf("GetInbound %d: %v", inboundId, err)
}
return strings.Contains(ib.Settings, uuid)
}
func TestCreateRepeatKeepsExistingUUID(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
ibA := mkInbound(t, 21001, model.VLESS, `{"clients":[]}`)
ibB := mkInbound(t, 21002, model.VLESS, `{"clients":[]}`)
const originalUUID = "aaaaaaaa-1111-2222-3333-444444444444"
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
Client: model.Client{Email: "repeat@x", ID: originalUUID, SubID: "sub-repeat", Enable: true},
InboundIds: []int{ibA.Id},
}); err != nil {
t.Fatalf("first Create: %v", err)
}
if rec := lookupClientRecord(t, "repeat@x"); rec.UUID != originalUUID {
t.Fatalf("record UUID after first Create = %q, want %q", rec.UUID, originalUUID)
}
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
Client: model.Client{Email: "repeat@x", SubID: "sub-repeat", Enable: true},
InboundIds: []int{ibB.Id},
}); err != nil {
t.Fatalf("repeat Create: %v", err)
}
if rec := lookupClientRecord(t, "repeat@x"); rec.UUID != originalUUID {
t.Fatalf("record UUID after repeat Create = %q, want %q", rec.UUID, originalUUID)
}
if !settingsHoldUUID(t, inboundSvc, ibA.Id, originalUUID) {
t.Fatalf("inbound A settings lost the original UUID")
}
if !settingsHoldUUID(t, inboundSvc, ibB.Id, originalUUID) {
t.Fatalf("inbound B settings did not reuse the original UUID")
}
}
+43 -8
View File
@@ -82,6 +82,20 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
if existing.SubID == "" || existing.SubID != client.SubID {
return false, common.NewError("email already in use:", client.Email)
}
// Reuse stored credentials when re-adding an existing identity, or
// fillProtocolDefaults mints a fresh UUID that desyncs other inbounds.
if client.ID == "" {
client.ID = existing.UUID
}
if client.Password == "" {
client.Password = existing.Password
}
if client.Auth == "" {
client.Auth = existing.Auth
}
if client.Secret == "" {
client.Secret = existing.Secret
}
}
if client.SubID != "" {
@@ -345,6 +359,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
if updated.Auth == "" {
updated.Auth = existing.Auth
}
if updated.Secret == "" {
updated.Secret = existing.Secret
}
if updated.Email != existing.Email {
var collisionCount int64
@@ -356,11 +373,6 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
if collisionCount > 0 {
return false, common.NewError("Duplicate email:", updated.Email)
}
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
Update("email", updated.Email).Error; err != nil {
return false, err
}
}
if updated.SubID != "" {
@@ -411,6 +423,16 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
}
}
// UpdateInboundClient renames the record atomically with each inbound's
// settings JSON; this direct write only covers records with no inbound left.
if updated.Email != existing.Email {
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ? AND email = ?", id, existing.Email).
Update("email", updated.Email).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 {
@@ -471,12 +493,14 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
}
needRestart := false
var delErrs []error
for _, ibId := range inboundIds {
if _, getErr := inboundSvc.GetInbound(ibId); getErr != nil {
if errors.Is(getErr, gorm.ErrRecordNotFound) {
continue
}
return needRestart, getErr
delErrs = append(delErrs, fmt.Errorf("inbound %d: %w", ibId, getErr))
continue
}
// Always delete by email — the client's stable identity. This removes
@@ -493,12 +517,18 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
if errors.Is(delErr, ErrClientNotInInbound) {
continue
}
return needRestart, delErr
delErrs = append(delErrs, fmt.Errorf("inbound %d: %w", ibId, delErr))
continue
}
if nr {
needRestart = true
}
}
// A failed inbound still holds the client in its settings JSON: keep the
// record so the next delete retries exactly the leftovers, and report it.
if len(delErrs) > 0 {
return needRestart, errors.Join(delErrs...)
}
db := database.GetDB()
if err := db.Transaction(func(tx *gorm.DB) error {
@@ -646,18 +676,23 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
return false, common.NewError(fmt.Sprintf("client %q not found in any inbound or client record", email))
}
needRestart := false
var delErrs []error
for _, ibId := range inboundIds {
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, false, true)
if delErr != nil {
if errors.Is(delErr, ErrClientNotInInbound) {
continue
}
return needRestart, delErr
delErrs = append(delErrs, fmt.Errorf("inbound %d: %w", ibId, delErr))
continue
}
if nr {
needRestart = true
}
}
if len(delErrs) > 0 {
return needRestart, errors.Join(delErrs...)
}
if !keepTraffic {
db := database.GetDB()
if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
@@ -0,0 +1,64 @@
package service
import (
"strconv"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestDeleteContinuesPastFailedInbound(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{{Email: "spread@x", ID: "cccccccc-0000-0000-0000-000000000001", SubID: "sub-spread", Enable: true}}
ib1 := mkInbound(t, 23001, model.VLESS, clientsSettings(t, source))
ib2 := mkInbound(t, 23002, model.VLESS, clientsSettings(t, source))
ib3 := mkInbound(t, 23003, model.VLESS, clientsSettings(t, source))
for _, ib := range []*model.Inbound{ib1, ib2, ib3} {
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage for %d: %v", ib.Id, err)
}
}
rec := lookupClientRecord(t, "spread@x")
missingNode := 9999
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", ib2.Id).
Update("node_id", missingNode).Error; err != nil {
t.Fatalf("point inbound 2 at a missing node: %v", err)
}
_, err := svc.Delete(inboundSvc, rec.Id, false)
if err == nil {
t.Fatalf("Delete with a failing inbound succeeded, want error")
}
if !strings.Contains(err.Error(), "inbound "+strconv.Itoa(ib2.Id)) {
t.Fatalf("Delete error = %q, want it to name inbound %d", err, ib2.Id)
}
for _, ib := range []*model.Inbound{ib1, ib3} {
if settingsHoldUUID(t, inboundSvc, ib.Id, "spread@x") {
t.Fatalf("inbound %d still holds the client after Delete", ib.Id)
}
}
if _, err := svc.GetByID(rec.Id); err != nil {
t.Fatalf("record removed despite a failed inbound: %v", err)
}
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", ib2.Id).
Update("node_id", nil).Error; err != nil {
t.Fatalf("repair inbound 2: %v", err)
}
if _, err := svc.Delete(inboundSvc, rec.Id, false); err != nil {
t.Fatalf("retry Delete: %v", err)
}
if _, err := svc.GetByID(rec.Id); err == nil {
t.Fatalf("record still present after successful retry")
}
if settingsHoldUUID(t, inboundSvc, ib2.Id, "spread@x") {
t.Fatalf("inbound 2 still holds the client after retry")
}
}
@@ -805,6 +805,19 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
if e := tx.Save(oldInbound).Error; e != nil {
return e
}
// 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) {
var renameTaken int64
if e := tx.Model(&model.ClientRecord{}).Where("email = ?", clients[0].Email).Count(&renameTaken).Error; e != nil {
return e
}
if renameTaken == 0 {
if e := tx.Model(&model.ClientRecord{}).Where("email = ?", oldEmail).Update("email", clients[0].Email).Error; e != nil {
return e
}
}
}
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
if gcErr != nil {
return gcErr
@@ -0,0 +1,88 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func countClientRecords(t *testing.T) int64 {
t.Helper()
var n int64
if err := database.GetDB().Model(&model.ClientRecord{}).Count(&n).Error; err != nil {
t.Fatalf("count client records: %v", err)
}
return n
}
func TestUpdateInboundClientRenameDoesNotDuplicateRecord(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{{Email: "old@x", ID: "aaaaaaaa-0000-0000-0000-000000000001", SubID: "sub-old", Enable: true}}
ib := mkInbound(t, 22001, model.VLESS, clientsSettings(t, source))
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err)
}
origId := lookupClientRecord(t, "old@x").Id
renamed := source
renamed[0].Email = "new@x"
if _, err := svc.UpdateInboundClient(inboundSvc, &model.Inbound{
Id: ib.Id,
Settings: clientsSettings(t, renamed),
}, "old@x"); err != nil {
t.Fatalf("UpdateInboundClient: %v", err)
}
if n := countClientRecords(t); n != 1 {
t.Fatalf("client records after rename = %d, want 1", n)
}
rec := lookupClientRecord(t, "new@x")
if rec.Id != origId {
t.Fatalf("record id after rename = %d, want %d", rec.Id, origId)
}
}
func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "keep@x", ID: "aaaaaaaa-0000-0000-0000-000000000003", SubID: "sub-keep", Enable: true},
{Email: "other@x", ID: "aaaaaaaa-0000-0000-0000-000000000004", SubID: "sub-other", Enable: true},
}
ib := mkInbound(t, 22003, model.VLESS, clientsSettings(t, source))
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err)
}
origId := lookupClientRecord(t, "keep@x").Id
origSettings := mustInboundSettings(t, inboundSvc, ib.Id)
updated := source[0]
updated.Email = "kept@x"
updated.SubID = "sub-other"
if _, err := svc.Update(inboundSvc, origId, updated); err == nil {
t.Fatalf("Update with colliding subId succeeded, want error")
}
rec := lookupClientRecord(t, "keep@x")
if rec.Id != origId {
t.Fatalf("record id changed after rejected update")
}
if got := mustInboundSettings(t, inboundSvc, ib.Id); got != origSettings {
t.Fatalf("inbound settings changed after rejected update")
}
}
func mustInboundSettings(t *testing.T, inboundSvc *InboundService, id int) string {
t.Helper()
ib, err := inboundSvc.GetInbound(id)
if err != nil {
t.Fatalf("GetInbound %d: %v", id, err)
}
return ib.Settings
}
+15
View File
@@ -154,6 +154,21 @@ func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
return rows
}
// adoptedHostRows projects a node's host groups onto a freshly adopted central
// inbound so TLS/SNI/fingerprint overrides survive the node-to-master import.
func adoptedHostRows(groups []*entity.HostGroup, nodeInboundId, centralInboundId int) []*model.Host {
var rows []*model.Host
for _, g := range groups {
if g == nil || !slices.Contains(g.InboundIds, nodeInboundId) {
continue
}
scoped := *g
scoped.InboundIds = []int{centralInboundId}
rows = append(rows, buildHostRows(g.GroupId, &scoped)...)
}
return rows
}
func validateInboundsExist(tx *gorm.DB, inboundIds []int) error {
for _, inboundId := range inboundIds {
var count int64
+29 -1
View File
@@ -503,7 +503,9 @@ func (s *InboundService) getAllEmailSubIDs() (map[string]string, error) {
// Only vmess, vless, trojan, shadowsocks, hysteria, wireguard, and tunnel
// protocols use streamSettings (wireguard for finalmask UDP masks and sockopt on
// its listener; tunnel for sockopt, notably sockopt.tproxy for its TProxy/redirect
// mode).
// mode). Streams keyed on "method" — xray-core v26.7.11's preferred alias for
// "network" — are canonicalized to "network", which every panel reader (link
// generation, port-conflict detection, flow eligibility) keys on.
func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
protocolsWithStream := map[model.Protocol]bool{
model.VMESS: true,
@@ -517,7 +519,33 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
if !protocolsWithStream[inbound.Protocol] {
inbound.StreamSettings = ""
return
}
inbound.StreamSettings = canonicalizeStreamNetworkKey(inbound.StreamSettings)
}
// canonicalizeStreamNetworkKey rewrites a streamSettings JSON that names its
// transport under "method" to the panel-canonical "network" key. When both
// keys are present, "method" wins — matching xray-core's own precedence.
func canonicalizeStreamNetworkKey(streamSettings string) string {
if streamSettings == "" {
return streamSettings
}
var stream map[string]any
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
return streamSettings
}
method, ok := stream["method"].(string)
if !ok || method == "" {
return streamSettings
}
stream["network"] = method
delete(stream, "method")
out, err := json.MarshalIndent(stream, "", " ")
if err != nil {
return streamSettings
}
return string(out)
}
// finalMaskRealityTcpMasks returns the stream's finalmask.tcp masks when the
+45
View File
@@ -129,6 +129,11 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err))
}
}
// Before the first clean sync adopts the node's inbounds, "absent locally"
// means "not imported yet" — sweeping now would wipe the node at onboarding.
if n.InboundsAdoptedAt == 0 {
return errors.Join(errs...)
}
// In "selected" sync mode the panel only manages the selected tags: the
// rest were never imported, so their absence from the local DB must not
// delete them from the node. Only a selected tag missing locally (the
@@ -219,6 +224,41 @@ func liftActivatedClientRecordExpiries(tx *gorm.DB) error {
).Error
}
// SnapshotHasUnadoptedInbounds reports whether the snapshot carries a tag with
// no central row yet, i.e. the next merge would adopt a new inbound.
func (s *InboundService) SnapshotHasUnadoptedInbounds(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
if snap == nil || len(snap.Inbounds) == 0 {
return false, nil
}
var tags []string
if err := database.GetDB().Model(model.Inbound{}).
Where("node_id = ?", nodeID).
Pluck("tag", &tags).Error; err != nil {
return false, err
}
prefix := nodeTagPrefix(&nodeID)
known := make(map[string]struct{}, len(tags)*2)
for _, tag := range tags {
known[tag] = struct{}{}
if prefix != "" {
if stripped, found := strings.CutPrefix(tag, prefix); found {
known[stripped] = struct{}{}
} else {
known[prefix+tag] = struct{}{}
}
}
}
for _, ib := range snap.Inbounds {
if ib == nil {
continue
}
if _, ok := known[ib.Tag]; !ok {
return true, nil
}
}
return false, nil
}
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
var structuralChange bool
err := submitTrafficWrite(func() error {
@@ -532,6 +572,11 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if newIb.Tag != snapIb.Tag {
tagToCentral[newIb.Tag] = &newIb
}
if rows := adoptedHostRows(snap.HostGroups, snapIb.Id, newIb.Id); len(rows) > 0 {
if err := tx.Create(&rows).Error; err != nil {
logger.Warningf("setRemoteTraffic: adopt host rows for tag %q failed: %v", newIb.Tag, err)
}
}
newInboundIDs[newIb.Id] = struct{}{}
structuralChange = true
continue
@@ -87,6 +87,7 @@ func reconcileTestNode(t *testing.T, ts *httptest.Server, name, mode string, tag
Status: "online",
InboundSyncMode: mode,
InboundTags: tags,
InboundsAdoptedAt: 1,
}
if err := database.GetDB().Create(n).Error; err != nil {
t.Fatalf("create node: %v", err)
@@ -143,6 +144,31 @@ func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
}
}
// A node whose pre-existing inbounds were never adopted into the central DB
// has zero local rows for legitimate reasons: reconcile before that first
// adoption must not sweep — it would delete every real inbound on the node
// right after onboarding (add node, save it again, watch it get wiped).
func TestReconcileNode_SkipsSweepBeforeFirstAdoption(t *testing.T) {
setupConflictDB(t)
ts, deletedIDs := fakeNodePanel(t, map[string]int{
"real-a": 1,
"real-b": 2,
"real-c": 3,
})
node := reconcileTestNode(t, ts, "fresh-node", "all", nil)
node.InboundsAdoptedAt = 0
svc := InboundService{}
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
t.Fatalf("ReconcileNode: %v", err)
}
if got := deletedIDs(); len(got) != 0 {
t.Fatalf("deleted remote ids = %v, want none before first adoption", got)
}
}
// One inbound the node rejects (e.g. a legacy protocol failing the node's
// request validation, #5685) must not abort the reconcile: the healthy inbound
// is still pushed, the delete sweep still runs, and the returned error names
+9
View File
@@ -771,6 +771,15 @@ func (s *NodeService) ClearNodeDirty(id int, dirtyAt int64) error {
Update("config_dirty", false).Error
}
func (s *NodeService) MarkNodeInboundsAdopted(id int) error {
if id <= 0 {
return nil
}
return database.GetDB().Model(model.Node{}).
Where("id = ? AND inbounds_adopted_at = 0", id).
Update("inbounds_adopted_at", time.Now().Unix()).Error
}
func (s *NodeService) NodeSyncState(id int) (enabled bool, status string, dirty bool, dirtyAt int64, err error) {
if id <= 0 {
return false, "", false, 0, errors.New("invalid node id")
@@ -0,0 +1,77 @@
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/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
)
func TestSetRemoteTraffic_AdoptsNodeHostRows(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
const nodeID = 6
if err := db.Create(&model.Node{
Id: nodeID,
Name: "host-node",
Address: "10.0.0.6",
Port: 2053,
ApiToken: "t",
Guid: "host-node-guid",
}).Error; err != nil {
t.Fatalf("create node: %v", err)
}
snap := &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{{
Id: 77,
Tag: "host-adopt-443",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
}},
HostGroups: []*entity.HostGroup{{
GroupId: "g-node",
InboundIds: []int{77, 99},
Hosts: []string{"cdn.example.com:8443"},
Remark: "cdn",
Security: "tls",
Sni: "sni.example.com",
Fingerprint: "firefox",
}},
}
svc := InboundService{}
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
t.Fatalf("setRemoteTrafficLocked: %v", err)
}
var central model.Inbound
if err := db.Where("tag = ?", "host-adopt-443").First(&central).Error; err != nil {
t.Fatalf("load adopted inbound: %v", err)
}
var hosts []model.Host
if err := db.Where("inbound_id = ?", central.Id).Find(&hosts).Error; err != nil {
t.Fatalf("load adopted hosts: %v", err)
}
if len(hosts) != 1 {
t.Fatalf("adopted host rows = %d, want 1", len(hosts))
}
h := hosts[0]
if h.GroupId != "g-node" || h.Address != "cdn.example.com" || h.Port != 8443 ||
h.Security != "tls" || h.Sni != "sni.example.com" || h.Fingerprint != "firefox" || h.Remark != "cdn" {
t.Fatalf("adopted host mismatch: %+v", h)
}
var total int64
if err := db.Model(&model.Host{}).Count(&total).Error; err != nil {
t.Fatalf("count hosts: %v", err)
}
if total != 1 {
t.Fatalf("total host rows = %d, want 1 (group member for un-adopted node inbound 99 must not materialize)", total)
}
}
+38 -7
View File
@@ -17,8 +17,34 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// filterOutboundsRejectedByCore drops outbounds the vendored xray-core config
// loader refuses to build — since v26.7.11 that includes unencrypted
// vless/trojan outbounds to public addresses — because one such outbound in
// the merged config would keep the whole core from starting.
func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
kept := make([]any, 0, len(outbounds))
var dropped []string
for _, ob := range outbounds {
raw, err := json.Marshal(ob)
if err == nil {
if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil {
tag := ""
if m, ok := ob.(map[string]any); ok {
tag, _ = m["tag"].(string)
}
logger.Warningf("%s: dropping outbound %q rejected by xray-core: %v", label, tag, buildErr)
dropped = append(dropped, fmt.Sprintf("%s: %v", tag, buildErr))
continue
}
}
kept = append(kept, ob)
}
return kept, dropped
}
// maxOutboundSubscriptionBytes caps a single outbound subscription response.
// It is larger than the 2 MiB user-facing subscription cap because an outbound
// subscription may aggregate many upstream outbounds into one document.
@@ -347,24 +373,28 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
}
identJSON, _ := json.Marshal(newIdent)
asAny := make([]any, len(parsed))
for i := range parsed {
asAny[i] = map[string]any(parsed[i])
}
kept, droppedByCore := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), asAny)
// Persist the outbounds (as compact JSON array)
obsJSON, _ := json.Marshal(parsed)
obsJSON, _ := json.Marshal(kept)
sub.LastFetchedOutbounds = string(obsJSON)
sub.LinkIdentities = string(identJSON)
sub.LastUpdated = time.Now().Unix()
sub.LastError = ""
if len(droppedByCore) > 0 {
sub.LastError = fmt.Sprintf("dropped %d outbound(s) the xray core rejects: %s", len(droppedByCore), droppedByCore[0])
}
if err := database.GetDB().Save(sub).Error; err != nil {
return nil, err
}
// Return as []any for the config merger
result := make([]any, len(parsed))
for i := range parsed {
result[i] = parsed[i]
}
return result, nil
return kept, nil
}
func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscription, err error) {
@@ -456,6 +486,7 @@ func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, app
logger.Warningf("outbound sub %d has corrupt LastFetchedOutbounds: %v", sub.Id, err)
continue
}
arr, _ = filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), arr)
if sub.Prepend {
prepend = append(prepend, arr...)
} else {
@@ -0,0 +1,57 @@
package service
import (
"encoding/json"
"testing"
)
// TestCanonicalizeStreamNetworkKey covers xray-core v26.7.11's "method" alias
// for streamSettings "network": a config keyed on "method" (from an imported
// or API-authored inbound) must be folded back to the panel-canonical
// "network" key that every downstream reader — link generation, port-conflict
// detection, flow eligibility — depends on.
func TestCanonicalizeStreamNetworkKey(t *testing.T) {
tests := []struct {
name string
in string
wantNetwork string
wantMethod bool
}{
{
name: "method alias becomes network",
in: `{"method": "ws", "security": "tls"}`,
wantNetwork: "ws",
},
{
name: "method wins when both present",
in: `{"method": "grpc", "network": "tcp"}`,
wantNetwork: "grpc",
},
{
name: "plain network untouched",
in: `{"network": "tcp"}`,
wantNetwork: "tcp",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := canonicalizeStreamNetworkKey(tc.in)
var stream map[string]any
if err := json.Unmarshal([]byte(got), &stream); err != nil {
t.Fatalf("result is not valid JSON: %v", err)
}
if stream["network"] != tc.wantNetwork {
t.Fatalf("network = %v, want %q", stream["network"], tc.wantNetwork)
}
if _, hasMethod := stream["method"]; hasMethod {
t.Fatalf("method key must be removed, got %s", got)
}
})
}
}
func TestCanonicalizeStreamNetworkKey_EmptyPassthrough(t *testing.T) {
if got := canonicalizeStreamNetworkKey(""); got != "" {
t.Fatalf("empty stream must round-trip, got %q", got)
}
}
+15
View File
@@ -41,6 +41,21 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
if err != nil {
return common.NewError("xray template config invalid:", err)
}
if len(xrayConfig.OutboundConfigs) > 0 {
var outbounds []json.RawMessage
if err := json.Unmarshal(xrayConfig.OutboundConfigs, &outbounds); err != nil {
return common.NewError("xray template config invalid: outbounds is not an array:", err)
}
for _, outbound := range outbounds {
if err := xray.ValidateOutboundConfig(outbound); err != nil {
tagged := struct {
Tag string `json:"tag"`
}{}
_ = json.Unmarshal(outbound, &tagged)
return common.NewError("xray core rejects outbound \""+tagged.Tag+"\":", err)
}
}
}
return nil
}
+17 -2
View File
@@ -171,6 +171,19 @@ func (x *XrayAPI) DelInbound(tag string) error {
return err
}
// ValidateOutboundConfig builds an outbound JSON object through the vendored
// xray-core config loader, surfacing the exact error the core would raise at
// startup — notably v26.7.11's refusal of unencrypted vless/trojan outbounds
// whose server address is a public IP or domain.
func ValidateOutboundConfig(outbound []byte) error {
detour := new(conf.OutboundDetourConfig)
if err := json.Unmarshal(outbound, detour); err != nil {
return err
}
_, err := detour.Build()
return err
}
// AddOutbound adds a new outbound configuration to the Xray core via gRPC.
func (x *XrayAPI) AddOutbound(outbound []byte) error {
if x.HandlerServiceClient == nil {
@@ -518,6 +531,8 @@ func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMe
var ssCipherType shadowsocks.CipherType
switch cipher {
case "aes-128-gcm":
ssCipherType = shadowsocks.CipherType_AES_128_GCM
case "aes-256-gcm":
ssCipherType = shadowsocks.CipherType_AES_256_GCM
case "chacha20-poly1305", "chacha20-ietf-poly1305":
@@ -525,10 +540,10 @@ func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMe
case "xchacha20-poly1305", "xchacha20-ietf-poly1305":
ssCipherType = shadowsocks.CipherType_XCHACHA20_POLY1305
default:
ssCipherType = shadowsocks.CipherType_NONE
ssCipherType = shadowsocks.CipherType_UNKNOWN
}
if ssCipherType != shadowsocks.CipherType_NONE {
if ssCipherType != shadowsocks.CipherType_UNKNOWN {
return serial.ToTypedMessage(&shadowsocks.Account{
Password: password,
CipherType: ssCipherType,
+4
View File
@@ -24,6 +24,7 @@ type Config struct {
BurstObservatory json_util.RawMessage `json:"burstObservatory,omitempty"`
Metrics json_util.RawMessage `json:"metrics"`
Geodata json_util.RawMessage `json:"geodata,omitempty"`
Env json_util.RawMessage `json:"env,omitempty"`
}
// Equals compares two Config instances for deep equality.
@@ -78,5 +79,8 @@ func (c *Config) Equals(other *Config) bool {
if !bytes.Equal(c.Geodata, other.Geodata) {
return false
}
if !bytes.Equal(c.Env, other.Env) {
return false
}
return true
}
+1
View File
@@ -70,6 +70,7 @@ func ComputeHotDiff(oldCfg, newCfg *Config) (*HotDiff, bool) {
{"burstObservatory", oldCfg.BurstObservatory, newCfg.BurstObservatory},
{"metrics", oldCfg.Metrics, newCfg.Metrics},
{"geodata", oldCfg.Geodata, newCfg.Geodata},
{"env", oldCfg.Env, newCfg.Env},
}
for _, section := range static {
if !rawEqualNormalized(section.old, section.new) {
+6
View File
@@ -143,6 +143,12 @@ func TestComputeHotDiff_StaticSectionChangeNeedsRestart(t *testing.T) {
if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
t.Fatal("observatory change must force a restart")
}
newCfg = makeHotConfig()
newCfg.Env = json_util.RawMessage(`{"XRAY_DNS_PATH":"/tmp/dns"}`)
if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
t.Fatal("env change must force a restart: env vars are read only at process start")
}
}
func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) {
+42
View File
@@ -0,0 +1,42 @@
package xray
import (
"strings"
"testing"
)
// TestValidateOutboundConfig_RejectsUnencryptedPublicVless covers xray-core
// v26.7.11's refusal to build an unencrypted vless outbound to a public
// address — the check now runs in-process, so the panel can surface it before
// a config reaches the core and bricks startup. A private-address outbound and
// a TLS outbound stay valid.
func TestValidateOutboundConfig_RejectsUnencryptedPublicVless(t *testing.T) {
publicPlaintext := `{
"protocol": "vless",
"settings": {"address": "1.2.3.4", "port": 443, "id": "b831381d-6324-4d53-ad4f-8cda48b30811", "encryption": "none"},
"streamSettings": {"network": "tcp", "security": "none"}
}`
if err := ValidateOutboundConfig([]byte(publicPlaintext)); err == nil {
t.Fatal("expected a public unencrypted vless outbound to be rejected")
} else if !strings.Contains(err.Error(), "prohibited") {
t.Fatalf("expected a prohibition error, got: %v", err)
}
privatePlaintext := `{
"protocol": "vless",
"settings": {"address": "10.0.0.1", "port": 443, "id": "b831381d-6324-4d53-ad4f-8cda48b30811", "encryption": "none"},
"streamSettings": {"network": "tcp", "security": "none"}
}`
if err := ValidateOutboundConfig([]byte(privatePlaintext)); err != nil {
t.Fatalf("a private-address plaintext vless outbound must stay valid, got: %v", err)
}
publicTLS := `{
"protocol": "vless",
"settings": {"address": "1.2.3.4", "port": 443, "id": "b831381d-6324-4d53-ad4f-8cda48b30811", "encryption": "none"},
"streamSettings": {"network": "tcp", "security": "tls", "tlsSettings": {"serverName": "example.com"}}
}`
if err := ValidateOutboundConfig([]byte(publicTLS)); err != nil {
t.Fatalf("a TLS-secured public vless outbound must stay valid, got: %v", err)
}
}
+16
View File
@@ -667,6 +667,22 @@ prompt_and_setup_ssl() {
# User chose Let's Encrypt IP certificate option
echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
# Confirm the auto-detected IP before issuing for it: with asymmetric
# routing / multi-WAN the echo services can return a transit address.
local ip_confirm=""
read -rp "Is ${server_ip} the correct incoming public IPv4 address for this server? [Default y]: " ip_confirm
if [[ -n "$ip_confirm" && "$ip_confirm" != "y" && "$ip_confirm" != "Y" ]]; then
server_ip=""
while [[ -z "$server_ip" ]]; do
read -rp "Please enter your server's public IPv4 address: " server_ip
server_ip="${server_ip// /}"
if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
server_ip=""
fi
done
fi
# Ask for optional IPv6
local ipv6_addr=""
read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr
+16 -10
View File
@@ -1548,19 +1548,25 @@ ssl_cert_issue_for_ip() {
fi
done
if [[ -z "$server_ip" ]]; then
if [[ -n "$server_ip" ]]; then
LOGI "Server IP detected: ${server_ip}"
if ! confirm "Is ${server_ip} the correct incoming public IPv4 address for this server?" "y"; then
server_ip=""
fi
else
LOGI "Could not auto-detect server IP from any provider."
while [[ -z "$server_ip" ]]; do
read -rp "Please enter your server's public IPv4 address: " server_ip
server_ip="${server_ip// /}"
if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
LOGE "Invalid IPv4 address. Please try again."
server_ip=""
fi
done
fi
LOGI "Server IP detected: ${server_ip}"
while [[ -z "$server_ip" ]]; do
read -rp "Please enter your server's public IPv4 address: " server_ip
server_ip="${server_ip// /}"
if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
LOGE "Invalid IPv4 address. Please try again."
server_ip=""
fi
done
LOGI "Issuing certificate for server IP: ${server_ip}"
# Ask for optional IPv6
local ipv6_addr=""