Compare commits

..

15 Commits

Author SHA1 Message Date
dependabot[bot] ec2c189071 chore(deps): bump antd from 6.6.5 to 6.6.6 in /frontend
Bumps [antd](https://github.com/ant-design/ant-design) from 6.6.5 to 6.6.6.
- [Release notes](https://github.com/ant-design/ant-design/releases)
- [Changelog](https://github.com/ant-design/ant-design/blob/master/CHANGELOG.en-US.md)
- [Commits](https://github.com/ant-design/ant-design/compare/6.6.5...6.6.6)

---
updated-dependencies:
- dependency-name: antd
  dependency-version: 6.6.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-15 09:06:59 +00:00
MHSanaei 608ef4e401 v3.10.2 2026-10-14 17:18:43 +02:00
MHSanaei 35b5e0989a fix(sponsor): fetch sponsor data from raw.githubusercontent.com
The server-side fetch used sponsors.sanaei.dev, a hostname unique to
3x-ui. Without ECH it travels in plaintext SNI, so an on-path observer
could tag a server as running the panel from that one connection.
raw.githubusercontent.com is shared by countless tools and reveals
nothing. The path stays inside TLS. The data source is unchanged: the
same MHSanaei/sponsors repo.

Refs #6824
2026-10-14 17:10:02 +02:00
MHSanaei b177b98ce8 feat(mtproto): expose mtg-multi's per-IP pending-handshake limit
mtg-multi v1.16.0 adds [defense.pending-handshakes] max-per-ip, which
closes new connections from an IP already holding that many
unauthenticated handshakes, so one address cannot pin the workers other
clients need. The MTProto inbound form now carries it as
pendingHandshakesMaxPerIp; the key is written only when set above 0, so
existing inbounds render a byte-identical config and are not restarted.
A change to it moves the structural fingerprint and restarts the sidecar.

dry-run is left out on purpose: it only feeds the pending_handshake_limit
Prometheus metric, which the panel does not surface. The hint warns that
clients behind carrier NAT share the limit.
2026-10-14 16:46:20 +02:00
Jay Hemnani 6bd1b8676a fix(link): read the XHTTP fields a vmess link carries inline (#6808)
A vmess link has no extra blob: the sub emitter writes the XHTTP extra
fields (session/seq/uplink placement, padding obfs, xmux, ...) at the
top level of the vmess JSON. The Go importer only read xPaddingBytes,
scMaxEachPostBytes and scMinPostsIntervalMs from there, so a vmess
XHTTP inbound with, for example, sessionIDPlacement=header or
xPaddingObfsMode came back from an outbound subscription or a client
external link as an outbound that cannot connect.

Read the same keys the TS importer already reads from vmess JSON,
including the pre-rename sessionPlacement/sessionKey aliases.
2026-10-14 15:34:35 +02:00
Jay Hemnani ff3f42ee2a fix(link): read fm and the mKCP fields from vmess links (#6809)
#6480 taught the vless/trojan importers to rebuild the mKCP header and
seed and left the vmess importers ignoring fm. The vmess emitter writes
the finalmask as fm, the mKCP header as type and the seed as path, plus
mtu/tti. Both importers dropped all of them, so a vmess mKCP inbound
with a seed, a header or any finalmask UDP mask (salamander, ...) came
back from an outbound subscription, a client external link or the
outbound form's link import as an outbound that cannot connect.

Feed those fields through the same finalmask and kcp helpers the
url-param protocols use, in the Go and TS importers.
2026-10-14 15:34:06 +02:00
MHSanaei 760f428c41 fix(xray): capture whole core crash reports instead of the first pipe chunk
LogWriter inspected each pipe chunk on its own. The Go runtime writes
"panic: " with a separate write before the message and traceback, so the
crash report was written from that first chunk alone and the panel left
7-byte core_crash_*.log files holding only "panic: ". Any ordinary log
line mentioning panic/exception/stack trace was also treated as a crash
and dropped from the panel log.

The writer now splits output into lines, starts a crash only on a line
beginning with the Go runtime's "panic: " / "fatal error: " header, keeps
everything after it, and writes one report from Flush, which
waitForCommand calls after cmd.Wait has delivered all output. The header
line becomes LastLine so GetResult shows the cause.

The report filename format "_000000000" is not a Go fractional-seconds
layout and always produced literal zeros, so reports from the same second
overwrote each other; os.CreateTemp now gives every report a unique name.
2026-10-14 15:31:09 +02:00
MHSanaei c45ffcdafa chore(windows): update OpenSSL installer
Replace the OpenSSL Light 3.6.0 executable with the 4.0.3 MSI installer.
2026-10-14 14:49:28 +02:00
MHSanaei cb4f2ec92f fix(sub): export Hysteria2 Gecko obfs in Clash/Mihomo subscriptions
A salamander finalmask carrying a packetSize range is Gecko obfs. The
hysteria2:// link generators already export it, but buildHysteriaProxy
copied only the password, so Clash/Mihomo received plain salamander and
could never complete the handshake against a Gecko server.

Emit mihomo's gecko form instead: obfs: gecko plus integer
obfs-min-packet-size / obfs-max-packet-size, validated by the same
parseHysteriaPacketSize bounds the URI export uses. A password-only mask
keeps the plain salamander output.

Closes #6822
2026-10-14 14:42:00 +02:00
MHSanaei db8b01b8ed feat(release): ship the Windows build as 3x-ui.exe with version info and icon
Kaspersky (Trojan.Win64.Agent.sb / .smhqgz) and Defender flag the
Windows build heuristically. A stripped, unsigned Go binary with no
VERSIONINFO, icon or manifest scores high on those heuristics, so the
release job now generates a resource .syso with go-winres from
winres/winres.json, stamped with internal/config/version, before
go build. The .syso is a build artifact and is gitignored.

The Windows zip now carries 3x-ui.exe instead of x-ui.exe, matching the
project name, and the executable embeds the 3X logo as its icon. The
icon is a square 256px crop of media/3x-ui-dark.png, since the source
logo is 400x200 and Windows icons must be square.
2026-10-14 14:16:49 +02:00
MHSanaei a66b360c09 fix(xray): apply shadowsocks client changes in place instead of rebuilding
The per-user hot path only covered vless, vmess, trojan, hysteria and
masque, so every client change on a multi-user shadowsocks inbound - a
node push for an auto-disable, renew, reset or add, a local Edit Inbound
save, or a config-diff hot apply - fell through to DelInbound +
AddInbound, closing the listener and dropping every live connection on
the port.

Shadowsocks joins userDiffableProtocols. The emitted user ops also carry
the inbound's method as "cipher": 2022 clients have no method of their
own, and without it AddUser cannot build the account, which would leave
2022 inbounds rebuilding anyway. Changes outside settings.clients
(method, 2022 server key, network) still differ in the rest of the
settings and still rebuild.

Verified against the pinned core: TestXrayAPI_E2E_Users and the
shadowsocks e2e tests pass with XRAY_E2E_BINARY set.

Closes #6820
2026-10-14 14:16:48 +02:00
MHSanaei f8b72de995 fix(node-sync): write only changed client rows in the node traffic merge
Every 5s node merge rewrote one client_traffics row and one
node_client_traffics baseline per (inbound, client) pair the node
reported, even when nothing had moved: a client is reported once per
inbound it is on, and idle clients every tick. On top of that each
inbound's SyncInbound wrote its own per-inbound values onto the shared
clients record, so a client on a flow and a non-flow inbound (or on a
WireGuard tunnel plus any other inbound) had its record flipped back
and forth twice per merge. With 7 nodes and ~20k client links that is
tens of thousands of statements per tick, all inside transactions on
the single serialized writer, holding row locks for seconds. A renew
of a client on 38 inbounds takes one writer turn per inbound, queued
behind those merges each time, and outlived the 30s HTTP write timeout
even though it committed; last_sub_fetch updates waited on the same
locks.

The merge now computes the row its UPDATE would leave, mirroring the
SQL merge expressions, and skips the write when nothing changes; the
baseline upsert is skipped when the node counter has not moved. The
record's per-inbound fields get one owner each, so repeated syncs
agree: flow is settled to the effective flow (lowest-id link carrying
one) for clients whose links changed, and WireGuard/AmneziaWG peer
fields come from the client's lowest-id tunnel. A repeated unchanged
snapshot now writes no client row; one active client writes two.

Verified beyond make verify: the CI Postgres lane, the merge and flow
tests run against PostgreSQL 16, and make node-e2e.

Closes #6815
2026-10-14 00:37:00 +02:00
MHSanaei 6f1dd0d757 feat(sponsors): drop the sidebar slot and simplify the Sponsors page
The sidebar no longer carries a sponsor card. The backend stops serving
the "sidebar" slot, so a sponsors.json entry that still lists it keeps
only its other slots and one listing nothing else is dropped. Its
rotation, three-sponsor cap and collapsed-rail logo rendering go with it.

The Sponsors page now lists only real sponsors: the "Your brand here"
card shows only as the empty state instead of sitting in the grid and
repeating the header's Become a sponsor button, and the Available
placements section and its slot-occupancy helper are removed. Cards are
larger, two per row on wide screens. Unused i18n keys are dropped from
every locale.
2026-10-13 23:32:56 +02:00
MHSanaei 319e693276 fix(sponsor): keep long sponsor copy inside every slot
The Sponsors page card sized its body to max-content because the title
never wrapped, so a long title spilled out of the card onto the next one.
The compact card (sidebar, login) put logo, copy and close button in one
row, leaving the copy ~110px of the 220px sidebar and clamping it to two
lines; the dashboard banner cut its title to a single line.

Titles and descriptions now wrap, breaking unbroken strings too. The
compact card stacks logo and tag above copy that spans the full card
width, keeping a bounded height in the sidebar with a 2-line title and
4-line description clamp and the full copy in the link tooltip.
2026-10-13 22:42:59 +02:00
Matt Van Horn 61885c9126 fix(sub): isolate Incy subscription routing from Happ headers (#6810)
* sub: isolate Incy subscription routing from Happ headers

The resolved Incy line is written to the body only when IsIncyClient
matches the subscription User-Agent, Incy routing is enabled, and the
resolved text is non-blank. ApplyCommonHeaders computes that same Incy
check once and, for a matching client, skips the Happ routing-header
block, leaving Routing-Enable, the Happ Routing value, and the JSON
routing header unset. ApplyIncyHeaders is called with that incyClient
flag. The new test requests the subscription as INCY/1.0.0/ios and as
Happ/1.2.0 and expects the Incy response to end with the incy:// line
and to have empty Routing headers, and the Happ response to keep the
happ:// Routing header with Routing-Enable true and no incy:// line in
the body.

Fixes #6062

* fix(sub): keep the shared JSON routing profile for INCY clients

The INCY isolation wrapped the whole Routing block, JSON fallback
included, in !incyClient. INCY therefore lost the subJsonRoutingRules
profile that #6402 sends as the Routing header so Happ and INCY fetch
the geo files the baked rules reference, on the raw, JSON and Clash
endpoints alike, while every other client kept it, incy:// deeplinks
included.

INCY accepts a routing link of any scheme in that header
(docs.incy.cc/en/routing), so only the Happ tab is Happ-only: an INCY
request now reads it as blank and falls back to the shared profile,
as the setting text promises. Where the raw body carries INCY's own
profile the header is dropped, because INCY ranks a Routing header
above a base64 body line (docs.incy.cc/en/app-management) and the
shared profile would override it, which is #6062 again.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-10-13 16:01:32 +02:00
58 changed files with 1400 additions and 780 deletions
+6 -1
View File
@@ -261,6 +261,11 @@ jobs:
go version
gcc --version
# Version info + manifest resource: a bare stripped Go exe scores high on AV heuristics.
PANEL_VERSION=$(tr -d '[:space:]' < internal/config/version)
go run github.com/tc-hib/go-winres@v0.3.3 make --arch amd64 \
--product-version "$PANEL_VERSION" --file-version "$PANEL_VERSION"
# Stamp the commit into per-commit (dev channel) builds only.
LDFLAGS="-w -s"
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
@@ -273,7 +278,7 @@ jobs:
run: |
$retry = @{ MaximumRetryCount = 5; RetryIntervalSec = 10 }
mkdir x-ui
Copy-Item xui-release.exe x-ui\x-ui.exe
Copy-Item xui-release.exe x-ui\3x-ui.exe
mkdir x-ui\bin
cd x-ui\bin
+1
View File
@@ -32,6 +32,7 @@ Thumbs.db
# Ignore Go build files
*.exe
*.syso
x-ui.db
x-ui.db-shm
x-ui.db-wal
+8 -8
View File
@@ -15,7 +15,7 @@
"@noble/hashes": "^2.4.0",
"@tanstack/react-query": "^5.104.2",
"@tanstack/react-query-devtools": "^5.104.2",
"antd": "^6.6.5",
"antd": "^6.6.6",
"codemirror": "^6.0.2",
"dayjs": "^1.11.23",
"i18next": "^26.4.3",
@@ -3503,9 +3503,9 @@
}
},
"node_modules/@rc-component/notification": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-2.0.9.tgz",
"integrity": "sha512-jQT5IVSWSHPTNFXQM2YQONPptjiKXZa4A4Kzwh2xkh3MkL31gRzyjb98N3QHBzLxsyGGJK1aNpn9j9aSO/O0dg==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-2.1.1.tgz",
"integrity": "sha512-HD+Gx/HUjivBI6GZtfPVLjvOJZybSEIoE2upnmoVg4w9vZQHMaIPWP6B52cHWOGg+u48HBbO+HnyVNN8AKQxgQ==",
"license": "MIT",
"dependencies": {
"@rc-component/motion": "^1.1.4",
@@ -6431,9 +6431,9 @@
}
},
"node_modules/antd": {
"version": "6.6.5",
"resolved": "https://registry.npmjs.org/antd/-/antd-6.6.5.tgz",
"integrity": "sha512-biJB/aORNU2lvZZCX783S/g8atfE8akrEKRjRrsXVttoMtJbSHLf/Z29niv0blzt6H3BgKvhkQgHoUn5MgCx7w==",
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/antd/-/antd-6.6.6.tgz",
"integrity": "sha512-+x4M755a6kyZyxkN2vpfm3HbYkyytPBPSncXHKPHU2ZPs3Z1ctrWwWApe/4ek4O0eUGKNFCpb461yficetktYQ==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^8.0.1",
@@ -6459,7 +6459,7 @@
"@rc-component/menu": "~1.5.0",
"@rc-component/motion": "^1.3.3",
"@rc-component/mutate-observer": "^2.0.1",
"@rc-component/notification": "~2.0.8",
"@rc-component/notification": "~2.1.1",
"@rc-component/pagination": "~1.4.0",
"@rc-component/picker": "~1.12.2",
"@rc-component/progress": "~1.0.3",
+1 -1
View File
@@ -41,7 +41,7 @@
"@noble/hashes": "^2.4.0",
"@tanstack/react-query": "^5.104.2",
"@tanstack/react-query-devtools": "^5.104.2",
"antd": "^6.6.5",
"antd": "^6.6.6",
"codemirror": "^6.0.2",
"dayjs": "^1.11.23",
"i18next": "^26.4.3",
+54 -36
View File
@@ -61,7 +61,7 @@
.sponsor-head {
display: flex;
align-items: center;
align-items: baseline;
gap: 8px;
min-width: 0;
}
@@ -77,15 +77,14 @@
}
.sponsor-title {
overflow: hidden;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
overflow-wrap: anywhere;
}
.sponsor-text {
font-size: 13px;
color: var(--ant-color-text-secondary);
overflow-wrap: anywhere;
}
.sponsor-visit {
@@ -129,42 +128,52 @@
}
.sponsor-card-compact .sponsor-main {
gap: 10px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 4px 8px;
padding: 8px 10px;
}
.sponsor-card-compact .sponsor-logo {
width: 32px;
height: 32px;
.sponsor-card-compact .sponsor-body,
.sponsor-card-compact .sponsor-head {
display: contents;
}
.sponsor-card-compact .sponsor-head {
flex-direction: column;
align-items: flex-start;
gap: 2px;
.sponsor-card-compact .sponsor-logo {
width: 28px;
height: 28px;
}
.sponsor-card-compact .sponsor-tag {
justify-self: start;
}
.sponsor-card-compact .sponsor-title,
.sponsor-card-compact .sponsor-text {
display: -webkit-box;
grid-column: 1 / -1;
overflow: hidden;
-webkit-box-orient: vertical;
}
.sponsor-card-compact .sponsor-title {
display: -webkit-box;
max-width: 100%;
font-size: 13px;
white-space: normal;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.sponsor-card-compact .sponsor-text {
display: -webkit-box;
overflow: hidden;
font-size: 12px;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.sponsor-card-compact .sponsor-close {
position: absolute;
top: 4px;
inset-inline-end: 4px;
width: 22px;
height: 22px;
margin: 4px 4px 0 0;
margin: 0;
font-size: 11px;
}
@@ -175,28 +184,37 @@
.sponsor-card-card .sponsor-main {
flex-direction: column;
align-items: flex-start;
padding: 16px;
gap: 16px;
padding: 24px;
}
.sponsor-card-card .sponsor-logo {
width: 56px;
height: 56px;
width: 72px;
height: 72px;
border-radius: 12px;
}
.sponsor-card-card .sponsor-logo-fallback {
font-size: 28px;
}
.sponsor-card-card .sponsor-body {
gap: 6px;
}
.sponsor-card-card .sponsor-title {
font-size: 17px;
line-height: 1.4;
}
.sponsor-card-card .sponsor-text {
font-size: 14px;
line-height: 1.6;
}
.sponsor-card-card .sponsor-visit {
margin-top: auto;
}
.sponsor-card-icon {
justify-content: center;
padding: 6px;
border-color: transparent;
background: transparent;
}
.sponsor-card-icon .sponsor-logo {
width: 32px;
height: 32px;
font-size: 14px;
}
@media (max-width: 576px) {
@@ -7,13 +7,41 @@ import SponsorCard from './SponsorCard';
const sponsor: Sponsor = {
id: 'acme-2026-10',
name: 'Acme VPS',
slots: ['dashboard', 'sidebar', 'page', 'login'],
slots: ['dashboard', 'page', 'login'],
until: '2099-01-01T00:00:00Z',
title: { en: 'Acme VPS — fast NVMe servers', fa: 'سرورهای سریع Acme' },
text: { en: 'Deploy 3X-UI in 60 seconds. 20% off for panel users.' },
link: 'https://acme.example/?utm_source=3x-ui',
};
// Copy as long as the real sponsors.json entries; each story renders it at its slot's width.
const longSponsor: Sponsor = {
...sponsor,
title: { en: 'Advertise here — reach 3X-UI server admins' },
text: {
en: 'Put your VPS, hosting or domain brand in front of the admins who use 3X-UI every day. Message us for slots and pricing.',
},
};
function part(root: HTMLElement, selector: string): HTMLElement {
const el = root.querySelector<HTMLElement>(selector);
if (!el) throw new Error(`missing ${selector}`);
return el;
}
async function expectInsideCard(root: HTMLElement, selector: string) {
const card = part(root, '.sponsor-card').getBoundingClientRect();
const box = part(root, selector).getBoundingClientRect();
await expect(box.left).toBeGreaterThanOrEqual(card.left - 0.5);
await expect(box.right).toBeLessThanOrEqual(card.right + 0.5);
}
async function expectNotClipped(root: HTMLElement, selector: string) {
const el = part(root, selector);
await expect(el.scrollWidth).toBeLessThanOrEqual(el.clientWidth + 1);
await expect(el.scrollHeight).toBeLessThanOrEqual(el.clientHeight + 1);
}
const meta = {
title: 'Sponsor/SponsorCard',
component: SponsorCard,
@@ -29,9 +57,8 @@ const meta = {
argTypes: {
sponsor: { description: 'Sponsor entry from GET /sponsors.' },
variant: {
description: 'banner (dashboard), compact (sidebar/login) or card (Sponsors page).',
description: 'banner (dashboard), compact (login) or card (Sponsors page).',
},
iconOnly: { description: 'Logo-only rendering for the collapsed sidebar rail.' },
onClose: { description: 'When set, shows a close button (temporary dismiss).' },
},
args: { sponsor },
@@ -53,7 +80,7 @@ export const Banner: Story = {
export const Compact: Story = {
args: { variant: 'compact', onClose: () => {} },
render: (args) => (
<div style={{ width: 204 }}>
<div style={{ width: 334 }}>
<SponsorCard {...args} />
</div>
),
@@ -68,6 +95,45 @@ export const Card: Story = {
),
};
export const IconOnly: Story = {
args: { iconOnly: true },
export const BannerLongCopy: Story = {
args: { variant: 'banner', sponsor: longSponsor, onClose: () => {} },
render: (args) => (
<div style={{ width: 360 }}>
<SponsorCard {...args} />
</div>
),
play: async ({ canvasElement }) => {
await expectNotClipped(canvasElement, '.sponsor-title');
},
};
export const LoginLongCopy: Story = {
args: { variant: 'compact', sponsor: longSponsor, onClose: () => {} },
render: (args) => (
<div style={{ width: 334 }}>
<SponsorCard {...args} />
</div>
),
play: async ({ canvasElement }) => {
await expectNotClipped(canvasElement, '.sponsor-title');
await expectNotClipped(canvasElement, '.sponsor-text');
await expect(part(canvasElement, '.sponsor-main')).toHaveAttribute(
'title',
`${longSponsor.title.en}\n${longSponsor.text.en}`,
);
},
};
export const PageLongCopy: Story = {
args: { variant: 'card', sponsor: longSponsor },
render: (args) => (
<div style={{ width: 320 }}>
<SponsorCard {...args} />
</div>
),
play: async ({ canvasElement }) => {
await expectInsideCard(canvasElement, '.sponsor-title');
await expectInsideCard(canvasElement, '.sponsor-text');
await expectNotClipped(canvasElement, '.sponsor-title');
},
};
@@ -12,7 +12,6 @@ export type SponsorCardVariant = 'banner' | 'compact' | 'card';
export interface SponsorCardProps {
sponsor: Sponsor;
variant?: SponsorCardVariant;
iconOnly?: boolean;
onClose?: () => void;
}
@@ -36,33 +35,13 @@ function SponsorLogo({ sponsor }: { sponsor: Sponsor }) {
);
}
export default function SponsorCard({
sponsor,
variant = 'banner',
iconOnly = false,
onClose,
}: SponsorCardProps) {
export default function SponsorCard({ sponsor, variant = 'banner', onClose }: SponsorCardProps) {
const { t, i18n } = useTranslation();
const lang = i18n.resolvedLanguage || i18n.language || 'en';
const title = pickLocale(sponsor.title, lang) || sponsor.name;
const text = pickLocale(sponsor.text, lang);
const tag = t('pages.sponsors.tag');
if (iconOnly) {
return (
<a
className="sponsor-card sponsor-card-icon"
href={sponsor.link}
target="_blank"
rel="noopener noreferrer sponsored"
title={`${tag} · ${title}`}
aria-label={`${tag}: ${title}`}
>
<SponsorLogo sponsor={sponsor} />
</a>
);
}
return (
<div className={`sponsor-card sponsor-card-${variant}`}>
<a
@@ -70,6 +49,7 @@ export default function SponsorCard({
href={sponsor.link}
target="_blank"
rel="noopener noreferrer sponsored"
title={variant === 'compact' ? [title, text].filter(Boolean).join('\n') : undefined}
>
<SponsorLogo sponsor={sponsor} />
<span className="sponsor-body" dir="auto">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
import {
@@ -9,47 +9,29 @@ import {
} from '@/lib/sponsors';
import SponsorCard, { type SponsorCardVariant } from './SponsorCard';
const ROTATE_MS = 30_000;
interface SponsorSlotProps {
slot: Slot;
variant?: SponsorCardVariant;
iconOnly?: boolean;
rotate?: boolean;
className?: string;
}
export default function SponsorSlot({
slot,
variant = 'banner',
iconOnly,
rotate,
className,
}: SponsorSlotProps) {
export default function SponsorSlot({ slot, variant = 'banner', className }: SponsorSlotProps) {
const { data } = useSponsorsQuery();
const [, setDismissTick] = useState(0);
const [index, setIndex] = useState(0);
// Re-filtered each render so a close (dismissTick bump) re-reads localStorage.
const visible = sponsorsForSlot(data.sponsors, slot).filter(
(s) => !isSponsorDismissed(s.id, slot),
);
useEffect(() => {
if (!rotate || visible.length < 2) return;
const timer = window.setInterval(() => setIndex((i) => i + 1), ROTATE_MS);
return () => window.clearInterval(timer);
}, [rotate, visible.length]);
if (visible.length === 0) return null;
const sponsor = visible[(rotate ? index : 0) % visible.length];
const sponsor = visible[0];
return (
<div className={className}>
<SponsorCard
sponsor={sponsor}
variant={variant}
iconOnly={iconOnly}
onClose={() => {
dismissSponsor(sponsor.id, slot);
setDismissTick((n) => n + 1);
-4
View File
@@ -247,10 +247,6 @@
padding: 8px 8px 12px;
}
.sider-sponsor {
margin-bottom: 6px;
}
.sidebar-pin {
display: inline-flex;
align-items: center;
-9
View File
@@ -44,7 +44,6 @@ import { formatPanelVersion } from '@/lib/panel-version';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { useAllSettings } from '@/api/queries/useAllSettings';
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
import SponsorSlot from '@/components/sponsor/SponsorSlot';
import './AppSidebar.css';
const DONATE_URL = 'https://donate.sanaei.dev/';
@@ -452,13 +451,6 @@ export default function AppSidebar() {
onClick={onMenuClick}
/>
<div className="sider-footer">
<SponsorSlot
slot="sidebar"
variant="compact"
iconOnly={railCollapsed}
rotate
className="sider-sponsor"
/>
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
</div>
</Layout.Sider>
@@ -544,7 +536,6 @@ export default function AppSidebar() {
}}
/>
<div className="drawer-footer">
<SponsorSlot slot="sidebar" variant="compact" rotate className="sider-sponsor" />
<VersionBadge version={panelVersion} />
</div>
</Drawer>
+1 -23
View File
@@ -1,6 +1,6 @@
import type { Sponsor } from '@/generated/types';
export type SponsorSlot = 'dashboard' | 'sidebar' | 'page' | 'login';
export type SponsorSlot = 'dashboard' | 'page' | 'login';
const DISMISS_KEY = 'xui.sponsor.dismissed';
export const SPONSOR_DISMISS_MS = 24 * 60 * 60 * 1000;
@@ -38,25 +38,3 @@ export function dismissSponsor(id: string, slot: SponsorSlot, now = Date.now())
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
} catch {}
}
// Mirrors the backend: dashboard/login show one sponsor, the sidebar rotates up to three.
export const SLOT_CAPACITY: Partial<Record<SponsorSlot, number>> = {
dashboard: 1,
login: 1,
sidebar: 3,
};
export interface PlacementStatus {
count: number;
capacity?: number;
takenUntil?: string;
}
// When full, a place frees up once enough bookings end to drop below capacity.
export function placementStatus(sponsors: Sponsor[], slot: SponsorSlot): PlacementStatus {
const booked = sponsorsForSlot(sponsors, slot);
const capacity = SLOT_CAPACITY[slot];
if (!capacity || booked.length < capacity) return { count: booked.length, capacity };
const ends = booked.map((s) => s.until).sort((a, b) => Date.parse(a) - Date.parse(b));
return { count: booked.length, capacity, takenUntil: ends[booked.length - capacity] };
}
@@ -543,7 +543,20 @@ export function parseVmessLink(link: string): Raw | null {
xhttp.path = json.path ?? '/';
if (json.mode) xhttp.mode = json.mode;
applyXhttpStringFromJson(xhttp, json);
} else if (network === 'kcp') {
applyTransportParams(
stream,
new URLSearchParams({ mtu: String(json.mtu ?? ''), tti: String(json.tti ?? '') }),
);
}
// The vmess object carries fm as-is and the mKCP header and seed as type and path.
const maskParams = new URLSearchParams();
if (typeof json.fm === 'string') maskParams.set('fm', json.fm);
if (network === 'kcp') {
if (typeof json.type === 'string') maskParams.set('headerType', json.type);
if (typeof json.path === 'string') maskParams.set('seed', json.path);
}
applyFinalMaskParam(stream, maskParams);
if (security === 'tls') {
const tls = stream.tlsSettings as Raw;
tls.serverName = json.sni ?? '';
@@ -74,6 +74,13 @@ export default function MtprotoFields() {
>
<InputNumber min={0} placeholder="0" style={{ width: '100%' }} />
</FormField>
<FormField
name={['settings', 'pendingHandshakesMaxPerIp']}
label={t('pages.inbounds.form.mtgPendingHandshakesMaxPerIp')}
tooltip={t('pages.inbounds.form.mtgPendingHandshakesMaxPerIpHint')}
>
<InputNumber min={0} placeholder="0" style={{ width: '100%' }} />
</FormField>
<FormField
name={['settings', 'routeThroughXray']}
label={t('pages.inbounds.form.mtgRouteThroughXray')}
+2 -70
View File
@@ -29,30 +29,14 @@
color: #faad14;
}
.sponsors-page .sponsors-section-title {
margin: 32px 0 12px;
color: var(--ant-color-text-secondary);
font-weight: 600;
letter-spacing: 0.3px;
}
.sponsors-yourbrand {
display: flex;
flex-direction: column;
align-items: flex-start;
align-items: center;
gap: 8px;
height: 100%;
min-height: 170px;
padding: 16px;
padding: 40px 24px;
border: 1.5px dashed var(--ant-color-border);
border-radius: var(--ant-border-radius-lg, 8px);
background: transparent;
}
.sponsors-yourbrand.is-large {
align-items: center;
min-height: 0;
padding: 40px 24px;
text-align: center;
background: var(--bg-card, transparent);
}
@@ -82,58 +66,6 @@
color: var(--ant-color-text-secondary);
}
.sponsors-yourbrand:not(.is-large) .ant-btn {
margin-top: auto;
}
.sponsors-placement {
display: flex;
gap: 12px;
height: 100%;
padding: 14px 16px;
border: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg, 8px);
background: var(--bg-card, transparent);
}
.sponsors-placement-icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
font-size: 17px;
color: var(--ant-color-primary);
background: var(--ant-color-primary-bg);
}
.sponsors-placement-title {
font-weight: 600;
color: var(--ant-color-text);
}
.sponsors-placement-body {
min-width: 0;
}
.sponsors-placement-status {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.sponsors-placement-status .ant-tag {
margin: 0;
}
.sponsors-placement-desc {
font-size: 13px;
color: var(--ant-color-text-secondary);
}
@media (max-width: 768px) {
.sponsors-page .content-area {
padding: 12px;
+7 -90
View File
@@ -1,22 +1,13 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Col, ConfigProvider, Layout, Row, Spin, Tag, Typography } from 'antd';
import {
CrownOutlined,
DashboardOutlined,
LoginOutlined,
MenuUnfoldOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { Button, Col, ConfigProvider, Layout, Row, Spin, Typography } from 'antd';
import { CrownOutlined, PlusOutlined } from '@ant-design/icons';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/layouts/AppSidebar';
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
import SponsorCard from '@/components/sponsor/SponsorCard';
import { IntlUtil } from '@/utils';
import { useDatepicker } from '@/hooks/useDatepicker';
import { placementStatus, sponsorsForSlot, type SponsorSlot } from '@/lib/sponsors';
import { sponsorsForSlot } from '@/lib/sponsors';
import './SponsorsPage.css';
function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) {
@@ -36,10 +27,10 @@ function BecomeButton({ contact, block }: { contact?: string; block?: boolean })
);
}
function YourBrandCard({ contact, large }: { contact?: string; large?: boolean }) {
function YourBrandCard({ contact }: { contact?: string }) {
const { t } = useTranslation();
return (
<div className={`sponsors-yourbrand${large ? ' is-large' : ''}`}>
<div className="sponsors-yourbrand">
<span className="sponsors-yourbrand-icon">
<PlusOutlined />
</span>
@@ -56,34 +47,6 @@ export default function SponsorsPage() {
const { data, fetched } = useSponsorsQuery();
const sponsors = sponsorsForSlot(data.sponsors, 'page');
const { datepicker } = useDatepicker();
const placements: { slot: SponsorSlot; icon: ReactNode; title: string; desc: string }[] = [
{
slot: 'dashboard',
icon: <DashboardOutlined />,
title: t('pages.sponsors.placementDashboard'),
desc: t('pages.sponsors.placementDashboardDesc'),
},
{
slot: 'sidebar',
icon: <MenuUnfoldOutlined />,
title: t('pages.sponsors.placementSidebar'),
desc: t('pages.sponsors.placementSidebarDesc'),
},
{
slot: 'login',
icon: <LoginOutlined />,
title: t('pages.sponsors.placementLogin'),
desc: t('pages.sponsors.placementLoginDesc'),
},
{
slot: 'page',
icon: <CrownOutlined />,
title: t('pages.sponsors.placementPage'),
desc: t('pages.sponsors.placementPageDesc'),
},
];
const pageClass = useMemo(() => {
const classes = ['sponsors-page'];
if (isDark) classes.push('is-dark');
@@ -110,63 +73,17 @@ export default function SponsorsPage() {
<Spin spinning={!fetched} delay={200}>
{sponsors.length === 0 ? (
fetched && <YourBrandCard contact={data.contact} large />
fetched && <YourBrandCard contact={data.contact} />
) : (
<Row gutter={[16, 16]}>
{sponsors.map((sponsor) => (
<Col key={sponsor.id} xs={24} sm={12} xl={8}>
<Col key={sponsor.id} xs={24} lg={12}>
<SponsorCard sponsor={sponsor} variant="card" />
</Col>
))}
{data.contact && (
<Col xs={24} sm={12} xl={8}>
<YourBrandCard contact={data.contact} />
</Col>
)}
</Row>
)}
</Spin>
<Typography.Title level={5} className="sponsors-section-title">
{t('pages.sponsors.placements')}
</Typography.Title>
<Row gutter={[16, 16]}>
{placements.map((p) => {
const status = placementStatus(data.sponsors, p.slot);
return (
<Col key={p.slot} xs={24} sm={12} xl={6}>
<div className="sponsors-placement">
<span className="sponsors-placement-icon">{p.icon}</span>
<div className="sponsors-placement-body">
<div className="sponsors-placement-title">{p.title}</div>
<div className="sponsors-placement-desc">{p.desc}</div>
<div className="sponsors-placement-status">
{status.takenUntil ? (
<Tag color="orange">
{t('pages.sponsors.takenUntil', {
date: IntlUtil.formatDate(status.takenUntil, datepicker),
})}
</Tag>
) : (
<Tag color="green">{t('pages.sponsors.available')}</Tag>
)}
{status.count > 0 && !status.takenUntil && (
<Tag>
{t('pages.sponsors.activeCount', {
count:
status.capacity && status.capacity > 1
? `${status.count}/${status.capacity}`
: status.count,
})}
</Tag>
)}
</div>
</div>
</div>
</Col>
);
})}
</Row>
</div>
</Layout.Content>
</Layout>
@@ -53,6 +53,8 @@ export const MtprotoInboundSettingsSchema = z.object({
// Caps concurrent connections across all users with a fair-share algorithm;
// 0 or unset disables throttling.
throttleMaxConnections: z.number().int().min(0).optional(),
// mtg's [defense.pending-handshakes] max-per-ip; 0 or unset leaves it off.
pendingHandshakesMaxPerIp: z.number().int().min(0).optional(),
// When set, the mtg sidecar dials Telegram through a loopback SOCKS bridge in
// the Xray config so the egress obeys routing rules. `outboundTag` optionally
// forces that traffic out a specific outbound/balancer. `routeXrayPort` is the
+51 -1
View File
@@ -305,8 +305,25 @@ describe('mKCP share params', () => {
// The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
// mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
type Mask = { type: string; settings: { header: string; value: string } };
const vmessKcp = (fields: Record<string, unknown>) =>
'vmess://' +
Base64.encode(
JSON.stringify({
v: '2',
add: 'h.com',
port: 443,
id: '11111111-2222-4333-8444-555555555555',
net: 'kcp',
tls: 'none',
...fields,
}),
);
const parse = (link: string) => {
const out = link.startsWith('trojan://') ? parseTrojanLink(link) : parseVlessLink(link);
const out = link.startsWith('trojan://')
? parseTrojanLink(link)
: link.startsWith('vmess://')
? parseVmessLink(link)
: parseVlessLink(link);
expect(out).not.toBeNull();
const stream = out!.streamSettings as Record<string, unknown>;
const kcp = stream.kcpSettings as { mtu: number; tti: number };
@@ -357,9 +374,42 @@ describe('mKCP share params', () => {
{ mtu: 1350, tti: 20 },
[],
],
[
'vmess header and seed from type and path',
vmessKcp({ type: 'wechat-video', path: 'secret-seed', mtu: 1400, tti: 50 }),
{ mtu: 1400, tti: 50 },
[
['mkcp-legacy', '', 'secret-seed'],
['mkcp-legacy', 'wechat', ''],
],
],
[
'vmess fm mkcp-legacy mask wins over path',
vmessKcp({
type: 'none',
path: 's3cr3tSeed',
mtu: 1350,
tti: 20,
fm: JSON.stringify({
udp: [{ settings: { header: '', value: 's3cr3tSeed' }, type: 'mkcp-legacy' }],
}),
}),
{ mtu: 1350, tti: 20 },
[['mkcp-legacy', '', 's3cr3tSeed']],
],
])('%s', (_name, link, kcp, masks) => {
expect(parse(link)).toEqual({ kcp, masks });
});
it('keeps a vmess fm udp mask', () => {
const fm = JSON.stringify({
udp: [{ type: 'salamander', settings: { password: 'obfspw123' } }],
});
const out = parseVmessLink(vmessKcp({ type: 'none', fm }));
expect((out!.streamSettings as Record<string, unknown>).finalmask).toEqual({
udp: [{ type: 'salamander', settings: { password: 'obfspw123' } }],
});
});
});
describe('parseTrojanLink', () => {
+5 -56
View File
@@ -1,12 +1,6 @@
import { afterEach, describe, expect, test } from 'vitest';
import {
SPONSOR_DISMISS_MS,
dismissSponsor,
isSponsorDismissed,
pickLocale,
placementStatus,
} from '@/lib/sponsors';
import { SPONSOR_DISMISS_MS, dismissSponsor, isSponsorDismissed, pickLocale } from '@/lib/sponsors';
afterEach(() => {
localStorage.clear();
@@ -33,10 +27,10 @@ describe('sponsor dismissal', () => {
const t0 = 1_800_000_000_000;
test('hides only the dismissed slot until the window elapses', () => {
dismissSponsor('acme', 'sidebar', t0);
expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS - 1)).toBe(true);
dismissSponsor('acme', 'login', t0);
expect(isSponsorDismissed('acme', 'login', t0 + SPONSOR_DISMISS_MS - 1)).toBe(true);
expect(isSponsorDismissed('acme', 'dashboard', t0)).toBe(false);
expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS)).toBe(false);
expect(isSponsorDismissed('acme', 'login', t0 + SPONSOR_DISMISS_MS)).toBe(false);
});
test('prunes expired entries on the next dismiss', () => {
@@ -48,51 +42,6 @@ describe('sponsor dismissal', () => {
test('corrupt storage is treated as nothing dismissed', () => {
localStorage.setItem('xui.sponsor.dismissed', 'not json');
expect(isSponsorDismissed('acme', 'sidebar', t0)).toBe(false);
});
});
describe('placementStatus', () => {
const sp = (id: string, slots: string[], until: string) => ({
id,
name: id,
slots,
until,
title: {},
text: {},
link: 'https://x.example/',
});
test('exclusive slot is taken until the latest booking ends', () => {
const sponsors = [
sp('a', ['dashboard'], '2026-11-01T00:00:00Z'),
sp('b', ['dashboard'], '2026-12-15T00:00:00Z'),
];
expect(placementStatus(sponsors, 'dashboard')).toEqual({
count: 2,
capacity: 1,
takenUntil: '2026-12-15T00:00:00Z',
});
});
test('sidebar is full at three and frees up when the earliest ends', () => {
const sponsors = [
sp('a', ['sidebar'], '2026-12-01T00:00:00Z'),
sp('b', ['sidebar'], '2026-10-20T00:00:00Z'),
sp('c', ['sidebar'], '2026-11-10T00:00:00Z'),
];
expect(placementStatus(sponsors, 'sidebar')).toEqual({
count: 3,
capacity: 3,
takenUntil: '2026-10-20T00:00:00Z',
});
expect(placementStatus(sponsors.slice(0, 2), 'sidebar')).toEqual({ count: 2, capacity: 3 });
});
test('uncapped page slot reports only a count', () => {
expect(placementStatus([sp('a', ['page'], '2026-12-01T00:00:00Z')], 'page')).toEqual({
count: 1,
capacity: undefined,
});
expect(isSponsorDismissed('acme', 'login', t0)).toBe(false);
});
});
+1 -1
View File
@@ -1 +1 @@
3.10.0
3.10.2
+34 -24
View File
@@ -58,6 +58,10 @@ type Instance struct {
// fair-share algorithm; zero disables throttling.
ThrottleMaxConnections int
// PendingHandshakesMaxPerIP caps unauthenticated handshakes held open by one
// client IP (mtg-multi >= 1.16.0); zero leaves the limit off.
PendingHandshakesMaxPerIP int
// PublicIPv4/PublicIPv6 pin the proxy's reachable address the Telegram
// middle proxy needs when clients carry advertising tags; they are omitted
// when empty so mtg auto-detects, and a change forces a restart.
@@ -93,6 +97,7 @@ func (inst Instance) structuralFingerprint() string {
strconv.Itoa(inst.FrontingPort),
strconv.FormatBool(inst.FrontingProxyProtocol),
strconv.Itoa(inst.ThrottleMaxConnections),
strconv.Itoa(inst.PendingHandshakesMaxPerIP),
strconv.FormatBool(inst.RouteThroughXray),
strconv.Itoa(inst.XrayRoutePort),
inst.PublicIPv4,
@@ -179,13 +184,14 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
Port int `json:"port"`
ProxyProtocol bool `json:"proxyProtocol"`
} `json:"domainFronting"`
PreferIP string `json:"preferIp"`
ThrottleMaxConnections int `json:"throttleMaxConnections"`
RouteThroughXray bool `json:"routeThroughXray"`
RouteXrayPort int `json:"routeXrayPort"`
PublicIPv4 string `json:"publicIpv4"`
PublicIPv6 string `json:"publicIpv6"`
Clients []struct {
PreferIP string `json:"preferIp"`
ThrottleMaxConnections int `json:"throttleMaxConnections"`
PendingHandshakesMaxPerIP int `json:"pendingHandshakesMaxPerIp"`
RouteThroughXray bool `json:"routeThroughXray"`
RouteXrayPort int `json:"routeXrayPort"`
PublicIPv4 string `json:"publicIpv4"`
PublicIPv6 string `json:"publicIpv6"`
Clients []struct {
Email string `json:"email"`
Secret string `json:"secret"`
AdTag string `json:"adTag"`
@@ -215,22 +221,23 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
return Instance{}, false
}
return Instance{
Id: ib.Id,
Tag: ib.Tag,
Listen: ib.Listen,
Port: ib.Port,
Secrets: secrets,
Debug: parsed.Debug,
ProxyProtocolListener: parsed.ProxyProtocolListener,
PreferIP: parsed.PreferIP,
FrontingIP: parsed.DomainFronting.IP,
FrontingPort: parsed.DomainFronting.Port,
FrontingProxyProtocol: parsed.DomainFronting.ProxyProtocol,
ThrottleMaxConnections: parsed.ThrottleMaxConnections,
RouteThroughXray: parsed.RouteThroughXray,
XrayRoutePort: parsed.RouteXrayPort,
PublicIPv4: strings.TrimSpace(parsed.PublicIPv4),
PublicIPv6: strings.TrimSpace(parsed.PublicIPv6),
Id: ib.Id,
Tag: ib.Tag,
Listen: ib.Listen,
Port: ib.Port,
Secrets: secrets,
Debug: parsed.Debug,
ProxyProtocolListener: parsed.ProxyProtocolListener,
PreferIP: parsed.PreferIP,
FrontingIP: parsed.DomainFronting.IP,
FrontingPort: parsed.DomainFronting.Port,
FrontingProxyProtocol: parsed.DomainFronting.ProxyProtocol,
ThrottleMaxConnections: parsed.ThrottleMaxConnections,
PendingHandshakesMaxPerIP: parsed.PendingHandshakesMaxPerIP,
RouteThroughXray: parsed.RouteThroughXray,
XrayRoutePort: parsed.RouteXrayPort,
PublicIPv4: strings.TrimSpace(parsed.PublicIPv4),
PublicIPv6: strings.TrimSpace(parsed.PublicIPv6),
}, true
}
@@ -523,7 +530,7 @@ func FreeLocalPort() (int, error) {
// precede any [section] header in TOML, and [secrets] must be the final section
// so trailing keys are not swallowed by another table. The layout is therefore:
// top-level scalars (incl. api-bind-to and api-token), then [domain-fronting],
// [network] and [throttle], then [secret-ad-tags] for clients overriding the
// [network], [throttle] and [defense.pending-handshakes], then [secret-ad-tags] for clients overriding the
// global advertising tag, and finally [secrets] with one named secret per
// active client.
func renderConfig(inst Instance, apiPort int, apiToken string) string {
@@ -569,6 +576,9 @@ func renderConfig(inst Instance, apiPort int, apiToken string) string {
if inst.ThrottleMaxConnections > 0 {
fmt.Fprintf(&b, "\n[throttle]\nmax-connections = %d\n", inst.ThrottleMaxConnections)
}
if inst.PendingHandshakesMaxPerIP > 0 {
fmt.Fprintf(&b, "\n[defense.pending-handshakes]\nmax-per-ip = %d\n", inst.PendingHandshakesMaxPerIP)
}
// Only clients present in [secrets] may appear here: mtg rejects a config
// whose [secret-ad-tags] names an unknown secret, so a disabled client's
// override must vanish together with its secret.
+27 -18
View File
@@ -18,7 +18,7 @@ func TestInstanceFromInbound(t *testing.T) {
Settings: `{"fakeTlsDomain":"example.com",` +
`"debug":true,"proxyProtocolListener":true,"preferIp":"prefer-ipv4",` +
`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true},` +
`"throttleMaxConnections":5000,` +
`"throttleMaxConnections":5000,"pendingHandshakesMaxPerIp":64,` +
`"routeThroughXray":true,"routeXrayPort":50000,` +
`"adTag":" 0123456789abcdef0123456789abcdef ",` +
`"clients":[` +
@@ -60,6 +60,9 @@ func TestInstanceFromInbound(t *testing.T) {
if inst.ThrottleMaxConnections != 5000 {
t.Fatalf("throttle not parsed: %+v", inst)
}
if inst.PendingHandshakesMaxPerIP != 64 {
t.Fatalf("pending-handshake limit not parsed: %+v", inst)
}
if !inst.RouteThroughXray || inst.XrayRoutePort != 50000 {
t.Fatalf("xray routing not parsed: %+v", inst)
}
@@ -90,7 +93,7 @@ func TestRenderConfig(t *testing.T) {
Secrets: []SecretEntry{{Name: "alice", Secret: "ee00"}},
Listen: "0.0.0.0", Port: 8443,
}, 5000, "")
for _, unwanted := range []string{"debug", "proxy-protocol-listener", "prefer-ip", "[domain-fronting]", "[stats.prometheus]", "[throttle]", "[secret-ad-tags]", "api-token"} {
for _, unwanted := range []string{"debug", "proxy-protocol-listener", "prefer-ip", "[domain-fronting]", "[stats.prometheus]", "[throttle]", "[defense.pending-handshakes]", "[secret-ad-tags]", "api-token"} {
if strings.Contains(bare, unwanted) {
t.Fatalf("bare config should not contain %q:\n%s", unwanted, bare)
}
@@ -116,9 +119,10 @@ func TestRenderConfig(t *testing.T) {
Listen: "0.0.0.0", Port: 443,
Debug: true, ProxyProtocolListener: true, PreferIP: "only-ipv6",
FrontingIP: "127.0.0.1", FrontingPort: 9443, FrontingProxyProtocol: true,
ThrottleMaxConnections: 5000,
PublicIPv4: "1.2.3.4",
PublicIPv6: "2001:db8::1",
ThrottleMaxConnections: 5000,
PendingHandshakesMaxPerIP: 64,
PublicIPv4: "1.2.3.4",
PublicIPv6: "2001:db8::1",
}, 6000, "sesame")
for _, want := range []string{
"debug = true\n",
@@ -133,6 +137,7 @@ func TestRenderConfig(t *testing.T) {
"proxy-protocol = true\n",
"[throttle]",
"max-connections = 5000",
"[defense.pending-handshakes]\nmax-per-ip = 64\n",
"[secret-ad-tags]",
`"bob" = "fedcba9876543210fedcba9876543210"`,
} {
@@ -160,6 +165,9 @@ func TestRenderConfig(t *testing.T) {
if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[throttle]") {
t.Fatalf("[throttle] must precede [secrets]:\n%s", full)
}
if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[defense.pending-handshakes]") {
t.Fatalf("[defense.pending-handshakes] must precede [secrets]:\n%s", full)
}
if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[secret-ad-tags]") {
t.Fatalf("[secret-ad-tags] must precede [secrets]:\n%s", full)
}
@@ -244,19 +252,20 @@ func TestFingerprintSplit(t *testing.T) {
base := Instance{Secrets: []SecretEntry{{Name: "a", Secret: "ee"}}, Listen: "0.0.0.0", Port: 443}
for name, mutate := range map[string]func(*Instance){
"debug": func(i *Instance) { i.Debug = true },
"listener": func(i *Instance) { i.ProxyProtocolListener = true },
"preferIp": func(i *Instance) { i.PreferIP = "only-ipv4" },
"frontingIP": func(i *Instance) { i.FrontingIP = "127.0.0.1" },
"frontingPort": func(i *Instance) { i.FrontingPort = 9443 },
"frontingProxy": func(i *Instance) { i.FrontingProxyProtocol = true },
"throttle": func(i *Instance) { i.ThrottleMaxConnections = 5000 },
"routeXray": func(i *Instance) { i.RouteThroughXray = true },
"routeXrayPort": func(i *Instance) { i.XrayRoutePort = 50000 },
"port": func(i *Instance) { i.Port = 8443 },
"listen": func(i *Instance) { i.Listen = "127.0.0.1" },
"publicIpv4": func(i *Instance) { i.PublicIPv4 = "1.2.3.4" },
"publicIpv6": func(i *Instance) { i.PublicIPv6 = "2001:db8::1" },
"debug": func(i *Instance) { i.Debug = true },
"listener": func(i *Instance) { i.ProxyProtocolListener = true },
"preferIp": func(i *Instance) { i.PreferIP = "only-ipv4" },
"frontingIP": func(i *Instance) { i.FrontingIP = "127.0.0.1" },
"frontingPort": func(i *Instance) { i.FrontingPort = 9443 },
"frontingProxy": func(i *Instance) { i.FrontingProxyProtocol = true },
"throttle": func(i *Instance) { i.ThrottleMaxConnections = 5000 },
"pendingHandshakes": func(i *Instance) { i.PendingHandshakesMaxPerIP = 64 },
"routeXray": func(i *Instance) { i.RouteThroughXray = true },
"routeXrayPort": func(i *Instance) { i.XrayRoutePort = 50000 },
"port": func(i *Instance) { i.Port = 8443 },
"listen": func(i *Instance) { i.Listen = "127.0.0.1" },
"publicIpv4": func(i *Instance) { i.PublicIPv4 = "1.2.3.4" },
"publicIpv6": func(i *Instance) { i.PublicIPv6 = "2001:db8::1" },
} {
t.Run("structural/"+name, func(t *testing.T) {
changed := base
+10 -2
View File
@@ -7,6 +7,7 @@ import (
"maps"
"net/netip"
"slices"
"strconv"
"strings"
"github.com/goccy/go-json"
@@ -540,8 +541,8 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
proxy["fingerprint"] = certFingerprint
}
// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
// block the subscription link generator uses.
// Salamander obfs (Hysteria2), read from the same finalmask.udp[salamander]
// block as the link generator; a packetSize range is mihomo's gecko obfs.
if finalmask, ok := rawStream["finalmask"].(map[string]any); ok {
if udpMasks, ok := finalmask["udp"].([]any); ok {
for _, m := range udpMasks {
@@ -553,6 +554,13 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
if pw, ok := settings["password"].(string); ok && pw != "" {
proxy["obfs"] = "salamander"
proxy["obfs-password"] = pw
packetSize, _ := settings["packetSize"].(string)
if gecko := parseHysteriaPacketSize(packetSize); gecko != "" {
minStr, maxStr := splitHysteriaPacketSize(gecko)
proxy["obfs"] = "gecko"
proxy["obfs-min-packet-size"], _ = strconv.Atoi(minStr)
proxy["obfs-max-packet-size"], _ = strconv.Atoi(maxStr)
}
break
}
}
+12 -2
View File
@@ -504,11 +504,15 @@ func (a *SUBController) subs(c *gin.Context) {
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, builtinProfileURL(c, scheme, hostWithPort))
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
// Incy's profile rides the raw body; other clients must not see that line (#6062).
if IsIncyClient(userAgent) && a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
incyRules, _, err := resolveIncyRoutingSource(a.subIncyRoutingRules)
if err == nil && strings.TrimSpace(incyRules) != "" {
result.WriteString(incyRules)
result.WriteString("\n")
// INCY ranks a Routing header above a body line, so the shared
// profile must not override INCY's own one here.
c.Writer.Header().Del("Routing")
}
}
@@ -957,6 +961,12 @@ func (a *SUBController) ApplyCommonHeaders(
c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
}
// The Happ tab is Happ-only, so INCY reads it as blank and gets the shared
// JSON profile instead; INCY takes a routing link of any scheme (#6062).
incyClient := c.Request != nil && IsIncyClient(c.GetHeader("User-Agent"))
if incyClient {
profileEnableRouting, profileRoutingRules = false, ""
}
rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
if strings.TrimSpace(profileRoutingRules) == "" {
// Happ/INCY fetch the geo files the baked rules reference through this
@@ -981,5 +991,5 @@ func (a *SUBController) ApplyCommonHeaders(
}
ApplyHappHeaders(c, a.happConfig, happManaged)
ApplyIncyHeaders(c, a.incyConfig, a.incyConfig.AutoDetect && c.Request != nil && IsIncyClient(c.GetHeader("User-Agent")))
ApplyIncyHeaders(c, a.incyConfig, a.incyConfig.AutoDetect && incyClient)
}
+55
View File
@@ -802,3 +802,58 @@ func TestLegacyClashEndpointNormalizesShadowsocksCipher(t *testing.T) {
})
}
}
func TestIncyRoutingIsolatedFromHappHeaders(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "vl", 4492, 1, `{"network":"tcp","security":"none"}`)
gin.SetMode(gin.TestMode)
const happRules = "happ://routing/onadd/existing-rules"
const incyRules = "incy://routing/onadd/AbC+/="
sharedRules := "incy://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(`{"Name":"shared","DirectSites":["geosite:category-ru"]}`))
cases := []struct {
name string
path string
userAgent string
incyRouting bool
wantRouting string
wantRoutingEnable string
wantIncyLine bool
}{
{name: "happ keeps its own tab and never sees the incy line", path: "/sub/s1", userAgent: "Happ/1.2.0", incyRouting: true, wantRouting: happRules, wantRoutingEnable: "true"},
{name: "incy without its own tab gets the shared json profile", path: "/sub/s1", userAgent: "INCY/1.0.0/ios", wantRouting: sharedRules},
{name: "incy body profile is not outranked by a routing header", path: "/sub/s1", userAgent: "INCY/1.0.0/ios", incyRouting: true, wantIncyLine: true},
{name: "incy json documents keep the shared profile header", path: "/json/s1", userAgent: "INCY/1.0.0/ios", incyRouting: true, wantRouting: sharedRules},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
router := gin.New()
NewSUBController(router.Group("/"),
WithSUBEncryption(false),
WithSUBJsonEnabled(true),
WithSUBEnableRouting(true),
WithSUBRoutingRules(happRules),
WithSUBJsonRoutingRules(sharedRules),
WithSUBIncyEnableRouting(tc.incyRouting),
WithSUBIncyRoutingRules(incyRules),
)
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+tc.path, nil)
req.Header.Set("User-Agent", tc.userAgent)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
body := resp.Body.String()
if resp.Code != http.StatusOK || !strings.Contains(body, "vless") {
t.Fatalf("status=%d, body=%s", resp.Code, body)
}
if got := resp.Header().Get("Routing"); got != tc.wantRouting {
t.Fatalf("Routing = %q, want %q", got, tc.wantRouting)
}
if got := resp.Header().Get("Routing-Enable"); got != tc.wantRoutingEnable {
t.Fatalf("Routing-Enable = %q, want %q", got, tc.wantRoutingEnable)
}
if got := strings.HasSuffix(body, incyRules+"\n"); got != tc.wantIncyLine || (!got && strings.Contains(body, incyRules)) {
t.Fatalf("incy line at body end = %v, want %v; body=%s", got, tc.wantIncyLine, body)
}
})
}
}
+40
View File
@@ -150,3 +150,43 @@ func TestParseHysteriaPacketSizeBounds(t *testing.T) {
t.Fatalf("valid range = %q", got)
}
}
// Mihomo reads Gecko only from obfs: gecko plus integer packet-size keys; plain
// salamander on a Gecko server never completes the handshake.
func TestBuildHysteriaProxyExportsGeckoObfs(t *testing.T) {
cases := []struct {
name string
settings string
want map[string]any
}{
{
name: "packetSize exports gecko",
settings: `{"password":"pw","packetSize":"512-1200"}`,
want: map[string]any{
"obfs": "gecko", "obfs-password": "pw",
"obfs-min-packet-size": 512, "obfs-max-packet-size": 1200,
},
},
{
name: "password only stays salamander",
settings: `{"password":"pw"}`,
want: map[string]any{"obfs": "salamander", "obfs-password": "pw"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
in := &model.Inbound{
Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
Settings: `{"version":2}`,
StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":` +
tc.settings + `}]}}`,
}
proxy := (&SubClashService{}).buildHysteriaProxy(&SubService{}, in, model.Client{Email: "user", Auth: "secret"}, nil)
for _, key := range []string{"obfs", "obfs-password", "obfs-min-packet-size", "obfs-max-packet-size"} {
if got, want := proxy[key], tc.want[key]; got != want {
t.Errorf("%s = %#v, want %#v", key, got, want)
}
}
})
}
}
+33 -2
View File
@@ -185,12 +185,18 @@ func parseVmess(link string) (*ParseResult, error) {
if m := getString(j, "mode", ""); m != "" {
xh["mode"] = m
}
// xhttp advanced keys are passed through if present in the json
for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs"} {
for _, k := range vmessXhttpKeys {
if v, ok := j[k]; ok {
xh[k] = v
}
}
for legacy, renamed := range xhttpLegacyAliases {
if _, ok := xh[renamed]; !ok {
if v, ok := j[legacy].(string); ok && v != "" {
xh[renamed] = v
}
}
}
case "tcp":
if getString(j, "type", "") == "http" {
stream["tcpSettings"] = map[string]any{
@@ -205,8 +211,18 @@ func parseVmess(link string) (*ParseResult, error) {
},
}
}
case "kcp":
applyTransport(stream, url.Values{"mtu": {strconv.Itoa(num(j["mtu"]))}, "tti": {strconv.Itoa(num(j["tti"]))}})
}
// The vmess object carries fm as-is and the mKCP header and seed as type and path.
maskParams := url.Values{"fm": {getString(j, "fm", "")}}
if network == "kcp" {
maskParams.Set("headerType", getString(j, "type", ""))
maskParams.Set("seed", getString(j, "path", ""))
}
applyFinalMask(stream, maskParams)
if security == "tls" {
tls := stream["tlsSettings"].(map[string]any)
tls["serverName"] = getString(j, "sni", "")
@@ -248,6 +264,21 @@ func parseVmess(link string) (*ParseResult, error) {
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
// vmessXhttpKeys are the XHTTP fields a vmess link carries inline, where vless/trojan put them in extra.
var vmessXhttpKeys = []string{
"xPaddingBytes", "xPaddingObfsMode", "xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod",
"sessionIDPlacement", "sessionIDKey", "sessionIDTable", "sessionIDLength",
"seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", "uplinkHTTPMethod", "uplinkChunkSize",
"scMaxEachPostBytes", "scMinPostsIntervalMs", "scMaxBufferedPosts", "scStreamUpServerSecs",
"serverMaxHeaderBytes", "noSSEHeader", "noGRPCHeader", "xmux", "downloadSettings", "headers",
}
// xhttpLegacyAliases maps the pre-rename session keys older links carry onto the current ones.
var xhttpLegacyAliases = map[string]string{
"sessionPlacement": "sessionIDPlacement",
"sessionKey": "sessionIDKey",
}
func vmessIdentity(j map[string]any) string {
// Remove ps (remark) for identity
core := map[string]any{}
@@ -200,6 +200,52 @@ func TestParse_XhttpExtraAndSnakeCaseFields(t *testing.T) {
}
}
func TestParse_VmessXhttpInlineFields(t *testing.T) {
// The vmess emitter writes the XHTTP extra fields at the top level of the link JSON.
cases := []struct {
name string
extra string
want map[string]any
}{
{
name: "session placement and padding obfs",
extra: `"sessionIDPlacement":"header","sessionIDKey":"X-Sid","sessionPlacement":"header","sessionKey":"X-Sid","xPaddingObfsMode":true,"xPaddingKey":"pk","xPaddingHeader":"X-Pad","xPaddingPlacement":"header","xPaddingMethod":"tokenish"`,
want: map[string]any{
"sessionIDPlacement": "header", "sessionIDKey": "X-Sid", "xPaddingObfsMode": true,
"xPaddingKey": "pk", "xPaddingHeader": "X-Pad", "xPaddingPlacement": "header", "xPaddingMethod": "tokenish",
},
},
{
name: "uplink and xmux",
extra: `"uplinkDataPlacement":"cookie","uplinkDataKey":"d","uplinkHTTPMethod":"PUT","uplinkChunkSize":4096,"xmux":{"maxConcurrency":"16-32"}`,
want: map[string]any{
"uplinkDataPlacement": "cookie", "uplinkDataKey": "d", "uplinkHTTPMethod": "PUT",
"uplinkChunkSize": float64(4096), "xmux": map[string]any{"maxConcurrency": "16-32"},
},
},
{
name: "legacy session keys",
extra: `"sessionPlacement":"query","sessionKey":"sid"`,
want: map[string]any{"sessionIDPlacement": "query", "sessionIDKey": "sid"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
inner := `{"v":"2","add":"h","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"xhttp","path":"/xh","mode":"packet-up","tls":"none",` + c.extra + `}`
res, err := ParseLink("vmess://" + base64.StdEncoding.EncodeToString([]byte(inner)))
if err != nil {
t.Fatalf("parse: %v", err)
}
xh := streamSub(t, res, "xhttpSettings")
for k, want := range c.want {
if !reflect.DeepEqual(xh[k], want) {
t.Errorf("xhttpSettings.%s = %#v, want %#v", k, xh[k], want)
}
}
})
}
}
func TestParse_VmessWSPathWithoutHostKey(t *testing.T) {
inner := `{"v":"2","add":"h","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"ws","path":"/api","tls":"tls"}`
link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(inner))
@@ -286,6 +332,11 @@ func mkcpLegacyMasks(t *testing.T, res *ParseResult) []mkcpMask {
return out
}
func vmessKcpLink(fields string) string {
inner := `{"v":"2","add":"h.com","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"kcp","tls":"none",` + fields + `}`
return "vmess://" + base64.StdEncoding.EncodeToString([]byte(inner))
}
func TestParse_KcpShareParams(t *testing.T) {
// The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
// mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
@@ -323,6 +374,20 @@ func TestParse_KcpShareParams(t *testing.T) {
wantMTU: 1350,
wantTTI: 20,
},
{
name: "vmess header and seed from type and path",
link: vmessKcpLink(`"type":"wechat-video","path":"secret-seed","mtu":1400,"tti":50`),
wantMTU: 1400,
wantTTI: 50,
wantMasks: []mkcpMask{{"", "secret-seed"}, {"wechat", ""}},
},
{
name: "vmess fm mkcp-legacy mask wins over path",
link: vmessKcpLink(`"type":"none","path":"s3cr3tSeed","mtu":1350,"tti":20,"fm":"{\"udp\":[{\"settings\":{\"header\":\"\",\"value\":\"s3cr3tSeed\"},\"type\":\"mkcp-legacy\"}]}"`),
wantMTU: 1350,
wantTTI: 20,
wantMasks: []mkcpMask{{"", "s3cr3tSeed"}},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -340,3 +405,15 @@ func TestParse_KcpShareParams(t *testing.T) {
})
}
}
func TestParse_VmessFinalMask(t *testing.T) {
res, err := ParseLink(vmessKcpLink(`"type":"none","fm":"{\"udp\":[{\"settings\":{\"password\":\"obfspw123\"},\"type\":\"salamander\"}]}"`))
if err != nil {
t.Fatalf("parse: %v", err)
}
udp := finalmaskUDP(t, res)
want := []any{map[string]any{"type": "salamander", "settings": map[string]any{"password": "obfspw123"}}}
if !reflect.DeepEqual(udp, want) {
t.Fatalf("finalmask.udp = %#v, want %#v", udp, want)
}
}
@@ -324,3 +324,35 @@ func TestBulkAttach_PreservesVisionFlowWhenCanonicalColumnZeroed(t *testing.T) {
t.Errorf("bulk-attached non-flow inbound must not receive Vision flow, got %#v", wsList)
}
}
// A client detached from its last inbound has no effective flow left to settle
// its record to, so the record keeps the flow a later attach falls back on.
func TestRecordFlow_DetachedFromLastInboundKeepsFlow(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
reality := &model.Inbound{Tag: "vless-reality", Enable: true, Port: 43001, Protocol: model.VLESS, StreamSettings: `{"network":"tcp","security":"reality"}`}
if err := database.GetDB().Create(reality).Error; err != nil {
t.Fatalf("create reality inbound: %v", err)
}
svc := ClientService{}
const email = "detached@example.com"
const vision = "xtls-rprx-vision"
client := model.Client{Email: email, ID: "ce8d33df-3a64-4f10-8f9b-91c3a8e0c333", Enable: true, Flow: vision}
if err := svc.SyncInbound(nil, reality.Id, []model.Client{client}); err != nil {
t.Fatalf("SyncInbound(attach): %v", err)
}
if err := svc.SyncInbound(nil, reality.Id, nil); err != nil {
t.Fatalf("SyncInbound(detach): %v", err)
}
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
if rec.Flow != vision {
t.Errorf("record flow after detaching its last inbound = %q, want %q", rec.Flow, vision)
}
}
+114 -16
View File
@@ -105,7 +105,11 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
tx = database.GetDB()
}
if err := s.validateProtocolClients(tx, inboundId, clients, detachEmails, prune); err != nil {
var inbound model.Inbound
if err := tx.Select("protocol").Where("id = ?", inboundId).Take(&inbound).Error; err != nil {
return err
}
if err := s.validateProtocolClients(tx, inbound.Protocol, inboundId, clients, detachEmails, prune); err != nil {
return err
}
@@ -137,6 +141,19 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
}
}
isTunnel := inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG
var onLowerTunnel map[int]struct{}
if isTunnel && len(existing) > 0 {
ids := make([]int, 0, len(existing))
for _, row := range existing {
ids = append(ids, row.Id)
}
var err error
if onLowerTunnel, err = clientsOnLowerTunnel(tx, inboundId, ids); err != nil {
return err
}
}
idByEmail := make(map[string]int, len(emails))
pending := make(map[string]*model.ClientRecord, len(emails))
toCreate := make([]*model.ClientRecord, 0, len(emails))
@@ -161,6 +178,8 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
before := *row
applyClientRecordMerge(row, incoming)
_, outranked := onLowerTunnel[row.Id]
keepLinkOwnedFields(row, &before, isTunnel && !outranked)
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
row.UpdatedAt = preservedUpdatedAt
@@ -222,7 +241,11 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
wantedIds = append(wantedIds, id)
}
if err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune); err != nil {
relinked, err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune)
if err != nil {
return err
}
if err := settleRecordFlows(tx, relinked); err != nil {
return err
}
@@ -260,19 +283,19 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
return nil
}
// reconcileInboundLinks writes only the client_inbounds rows that differ. prune
// also removes links absent from wantedFlow, which only a full sync may do.
func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wantedFlow map[int]string, wantedIds []int, detachEmails []string, prune bool) error {
// reconcileInboundLinks writes only the client_inbounds rows that differ and returns
// their clients. prune also removes links absent from wantedFlow, as only a full sync may.
func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wantedFlow map[int]string, wantedIds []int, detachEmails []string, prune bool) ([]int, error) {
var current []model.ClientInbound
if prune {
if err := tx.Where("inbound_id = ?", inboundId).Find(&current).Error; err != nil {
return err
return nil, err
}
} else {
for _, batch := range chunkInts(wantedIds, sqlInChunk) {
var rows []model.ClientInbound
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).Find(&rows).Error; err != nil {
return err
return nil, err
}
current = append(current, rows...)
}
@@ -300,7 +323,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
for _, batch := range chunkStrings(detachEmails, sqlInChunk) {
var ids []int
if err := tx.Model(&model.ClientRecord{}).Where("email IN ?", batch).Pluck("id", &ids).Error; err != nil {
return err
return nil, err
}
for _, id := range ids {
if _, keep := wantedFlow[id]; !keep {
@@ -325,7 +348,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
for _, batch := range chunkInts(toDelete, sqlInChunk) {
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
Delete(&model.ClientInbound{}).Error; err != nil {
return err
return nil, err
}
}
for flow, ids := range toUpdate {
@@ -333,7 +356,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
if err := tx.Model(&model.ClientInbound{}).
Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
Update("flow_override", flow).Error; err != nil {
return err
return nil, err
}
}
}
@@ -344,8 +367,87 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
Columns: []clause.Column{{Name: "client_id"}, {Name: "inbound_id"}},
DoUpdates: clause.AssignmentColumns([]string{"flow_override"}),
}).CreateInBatches(toInsert, 200).Error; err != nil {
return nil, err
}
}
relinked := append([]int(nil), toDelete...)
for _, ids := range toUpdate {
relinked = append(relinked, ids...)
}
for _, link := range toInsert {
relinked = append(relinked, link.ClientId)
}
return relinked, nil
}
// keepLinkOwnedFields undoes the merge of the record fields one inbound cannot decide
// alone, or syncing a client's inbounds in turn rewrites the record back and forth.
func keepLinkOwnedFields(row, before *model.ClientRecord, ownsTunnelPeer bool) {
row.Flow = before.Flow
if ownsTunnelPeer {
return
}
row.PrivateKey = before.PrivateKey
row.PublicKey = before.PublicKey
row.AllowedIPs = before.AllowedIPs
row.PreSharedKey = before.PreSharedKey
row.KeepAlive = before.KeepAlive
}
// clientsOnLowerTunnel returns the clients also attached to a WireGuard/AmneziaWG
// inbound with a lower id than inboundId: that tunnel owns their record's peer fields.
func clientsOnLowerTunnel(tx *gorm.DB, inboundId int, clientIds []int) (map[int]struct{}, error) {
out := make(map[int]struct{})
for _, batch := range chunkInts(clientIds, sqlInChunk) {
var ids []int
if err := tx.Table("client_inbounds").
Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id").
Where("client_inbounds.client_id IN ? AND client_inbounds.inbound_id < ? AND inbounds.protocol IN ?",
batch, inboundId, []model.Protocol{model.WireGuard, model.AmneziaWG}).
Distinct().Pluck("client_inbounds.client_id", &ids).Error; err != nil {
return nil, err
}
for _, id := range ids {
out[id] = struct{}{}
}
}
return out, nil
}
// settleRecordFlows sets the record flow of each still-linked client to its
// effective flow (EffectiveFlow's rule); an unlinked record keeps its own.
func settleRecordFlows(tx *gorm.DB, clientIds []int) error {
for _, batch := range chunkInts(clientIds, sqlInChunk) {
var links []model.ClientInbound
if err := tx.Select("client_id", "flow_override").
Where("client_id IN ?", batch).
Order("inbound_id ASC").
Find(&links).Error; err != nil {
return err
}
effective := make(map[int]string, len(batch))
for _, link := range links {
if flow, seen := effective[link.ClientId]; !seen || (flow == "" && link.FlowOverride != "") {
effective[link.ClientId] = link.FlowOverride
}
}
var records []model.ClientRecord
if err := tx.Select("id", "flow").Where("id IN ?", batch).Find(&records).Error; err != nil {
return err
}
idsByFlow := make(map[string][]int)
for _, rec := range records {
if flow, linked := effective[rec.Id]; linked && flow != rec.Flow {
idsByFlow[flow] = append(idsByFlow[flow], rec.Id)
}
}
for flow, ids := range idsByFlow {
if err := tx.Model(&model.ClientRecord{}).Where("id IN ?", ids).
UpdateColumn("flow", flow).Error; err != nil {
return err
}
}
}
return nil
}
@@ -418,12 +520,8 @@ func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId
// validateProtocolClients runs inside the write every path linking clients to an inbound
// shares, so a client the protocol's core config would refuse never reaches it.
func (s *ClientService) validateProtocolClients(tx *gorm.DB, inboundID int, changed []model.Client, detached []string, prune bool) error {
var inbound model.Inbound
if err := tx.Select("protocol").Where("id = ?", inboundID).Take(&inbound).Error; err != nil {
return err
}
switch inbound.Protocol {
func (s *ClientService) validateProtocolClients(tx *gorm.DB, protocol model.Protocol, inboundID int, changed []model.Client, detached []string, prune bool) error {
switch protocol {
case model.TUIC:
return s.validateTuicIdentities(tx, inboundID, changed, detached, prune)
case model.MASQUE:
+76 -63
View File
@@ -224,6 +224,43 @@ func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email strin
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
}
// mergeNodeClientTraffic folds one node-reported client into its central row.
// While frozen (a push pending or just landed) only the counters may move.
func (s *InboundService) mergeNodeClientTraffic(tx *gorm.DB, cs xray.ClientTraffic, frozen bool, deltaUp, deltaDown, now int64) error {
if frozen {
return tx.Exec(
fmt.Sprintf(
`UPDATE client_traffics
SET up = %s, down = %s, last_online = %s
WHERE email = ?`,
database.ClampedAddExpr("up"),
database.ClampedAddExpr("down"),
database.GreatestExpr("last_online", "?"),
),
deltaUp, deltaDown, cs.LastOnline, cs.Email,
).Error
}
return tx.Exec(
fmt.Sprintf(
`UPDATE client_traffics
SET up = %s, down = %s, enable = %s, total = ?,
expiry_time = %s,
reset = ?, reset_day = ?, reset_weekday = ?, last_online = %s
WHERE email = ?`,
database.ClampedAddExpr("up"),
database.ClampedAddExpr("down"),
database.ClientTrafficEnableMergeExpr(),
database.ClientTrafficExpiryMergeExpr(),
database.GreatestExpr("last_online", "?"),
),
deltaUp, deltaDown,
cs.Enable, cs.ExpiryTime, cs.Total, now, deltaUp, deltaDown,
cs.Total,
cs.ExpiryTime, cs.Reset, cs.ResetDay, cs.ResetWeekday,
cs.LastOnline, cs.Email,
).Error
}
// mergeActivationExpiry: master absolute wins; node may only activate when
// master is unset/duration. Node auto-renew goes through nodeClientRenewed.
func mergeActivationExpiry(existing, node int64) int64 {
@@ -634,22 +671,25 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
}
}
// Membership set for the rowExists checks below. Only the snapshot's emails
// are ever probed, so scope the lookup to those instead of plucking the whole
// client_traffics table (50k+ rows) on every node poll.
existingEmails := make(map[string]struct{}, len(snapEmailsAll))
// The stored row of every reported email, wherever it is homed, so the merge
// can skip a write that would leave it unchanged. Homed rows share centralCS's pointer.
statByEmail := make(map[string]*xray.ClientTraffic, len(snapEmailsAll))
if len(snapEmailsAll) > 0 {
snapEmailList := make([]string, 0, len(snapEmailsAll))
for email := range snapEmailsAll {
snapEmailList = append(snapEmailList, email)
}
for _, batch := range chunkStrings(snapEmailList, sqliteMaxVars) {
var found []string
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Pluck("email", &found).Error; err != nil {
var found []xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&found).Error; err != nil {
return false, err
}
for _, e := range found {
existingEmails[e] = struct{}{}
for i := range found {
if homed, ok := centralCSByEmail[found[i].Email]; ok {
statByEmail[found[i].Email] = homed
continue
}
statByEmail[found[i].Email] = &found[i]
}
}
}
@@ -1024,7 +1064,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
}
}
if _, rowExists := existingEmails[cs.Email]; !rowExists {
if _, rowExists := statByEmail[cs.Email]; !rowExists {
if dirty {
continue
}
@@ -1062,7 +1102,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
}
centralCS[csKey{c.Id, cs.Email}] = row
centralCSByEmail[cs.Email] = row
existingEmails[cs.Email] = struct{}{}
statByEmail[cs.Email] = row
structuralChange = true
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
return false, err
@@ -1121,64 +1161,34 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
existing.Total = cs.Total
existing.ExpiryTime = cs.ExpiryTime
existing.Reset = cs.Reset
existing.ResetDay = cs.ResetDay
existing.ResetWeekday = cs.ResetWeekday
existing.ResetCount = cs.ResetCount
existing.LastOnline = max(existing.LastOnline, cs.LastOnline)
structuralChange = true
} else if clientFrozen {
// Push pending or just landed: only counters may move, the master
// keeps expiry/enable/total/reset.
if err := tx.Exec(
fmt.Sprintf(
`UPDATE client_traffics
SET up = %s, down = %s, last_online = %s
WHERE email = ?`,
database.ClampedAddExpr("up"),
database.ClampedAddExpr("down"),
database.GreatestExpr("last_online", "?"),
),
deltaUp, deltaDown, cs.LastOnline, cs.Email,
).Error; err != nil {
return false, err
}
if existing != nil {
existing.Up = clampTrafficCounter(existing.Up + deltaUp)
existing.Down = clampTrafficCounter(existing.Down + deltaDown)
}
} else {
enableExpr := database.ClientTrafficEnableMergeExpr()
expiryExpr := database.ClientTrafficExpiryMergeExpr()
if err := tx.Exec(
fmt.Sprintf(
`UPDATE client_traffics
SET up = %s, down = %s, enable = %s, total = ?,
expiry_time = %s,
reset = ?, reset_day = ?, reset_weekday = ?, last_online = %s
WHERE email = ?`,
database.ClampedAddExpr("up"),
database.ClampedAddExpr("down"),
enableExpr,
expiryExpr,
database.GreatestExpr("last_online", "?"),
),
deltaUp, deltaDown,
cs.Enable, cs.ExpiryTime, cs.Total, now, deltaUp, deltaDown,
cs.Total,
cs.ExpiryTime, cs.Reset, cs.ResetDay, cs.ResetWeekday,
cs.LastOnline, cs.Email,
).Error; err != nil {
return false, err
}
if existing != nil {
priorExpiry := existing.ExpiryTime
if !cs.Enable && !nodeDisableIsStale(existing, cs, now, deltaUp, deltaDown) {
existing.Enable = false
// The row as the UPDATE below would leave it, computed from its
// pre-update values exactly as the SQL reads them.
row := statByEmail[cs.Email]
next := *row
next.Up = clampTrafficCounter(row.Up + deltaUp)
next.Down = clampTrafficCounter(row.Down + deltaDown)
next.LastOnline = max(row.LastOnline, cs.LastOnline)
if !clientFrozen {
if !cs.Enable && !nodeDisableIsStale(row, cs, now, deltaUp, deltaDown) {
next.Enable = false
}
existing.ExpiryTime = mergeActivationExpiry(priorExpiry, cs.ExpiryTime)
existing.Up = clampTrafficCounter(existing.Up + deltaUp)
existing.Down = clampTrafficCounter(existing.Down + deltaDown)
existing.Total = cs.Total
existing.Reset = cs.Reset
existing.ResetWeekday = cs.ResetWeekday
next.ExpiryTime = mergeActivationExpiry(row.ExpiryTime, cs.ExpiryTime)
next.Total = cs.Total
next.Reset, next.ResetDay, next.ResetWeekday = cs.Reset, cs.ResetDay, cs.ResetWeekday
}
// A multi-attached client is reported once per inbound and an idle one
// every tick: rewriting an unchanged row only holds its lock longer.
if next != *row {
if err := s.mergeNodeClientTraffic(tx, cs, clientFrozen, deltaUp, deltaDown, now); err != nil {
return false, err
}
*row = next
}
}
// A dip plus a lagging longer expiry mimics nodeClientRenewed and would
@@ -1186,6 +1196,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if clientFrozen && seen && (canon.Up < base.Up || canon.Down < base.Down) {
continue
}
if seen && base == canon {
continue
}
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
return false, err
}
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"slices"
"strings"
@@ -207,3 +208,76 @@ func TestNodeInboundUnchangedPushLeavesTheCoreAlone(t *testing.T) {
t.Fatalf("core operations = %q, want none for an unchanged inbound", got)
}
}
func ssHotSettings(t *testing.T, method, serverKey string, clients []model.Client) string {
t.Helper()
var settings map[string]any
if err := json.Unmarshal([]byte(clientsSettings(t, clients)), &settings); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
settings["method"] = method
settings["network"] = "tcp,udp"
if serverKey != "" {
settings["password"] = serverKey
} else {
for _, c := range settings["clients"].([]any) {
c.(map[string]any)["method"] = method
}
}
out, err := json.Marshal(settings)
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
return string(out)
}
// Multi-user shadowsocks has the same per-user API as vless, so a pushed client
// change must not rebuild the handler and drop every connection on the port.
func TestNodeShadowsocksPushChangingOnlyClientsKeepsTheHandler(t *testing.T) {
ciphers := []struct{ name, method, serverKey, keyA, keyB string }{
{"legacy aead", "aes-256-gcm", "", "a-pass", "b-pass"},
{"2022", "2022-blake3-aes-128-gcm", "AAECAwQFBgcICQoLDA0ODw==", "EBESExQVFhcYGRobHB0eHw==", "ICEiIyQlJicoKSorLC0uLw=="},
}
for _, cipher := range ciphers {
a := model.Client{Email: "ss-a", Password: cipher.keyA, Enable: true, CreatedAt: 1, UpdatedAt: 1}
b := model.Client{Email: "ss-b", Password: cipher.keyB, Enable: true, CreatedAt: 1, UpdatedAt: 1}
bOff := b
bOff.Enable = false
cases := []struct {
name string
stored, pushed []model.Client
wantOps []string
}{
{"disabling one client removes only that user", []model.Client{a, b}, []model.Client{a, bOff}, []string{"remove-user ss-b"}},
{"renewing a disabled client adds only that user", []model.Client{a, bOff}, []model.Client{a, b}, []string{"add-user ss-b"}},
}
for _, tc := range cases {
t.Run(cipher.name+"/"+tc.name, func(t *testing.T) {
setupConflictDB(t)
setRestartOnClientDisable(t, true)
probe := &inboundOpsProbe{}
useTestRuntimeManager(t).SetLocalRuntimeOverride(probe)
seedInboundConflict(t, "hot-ss", "0.0.0.0", 50083, model.Shadowsocks, `{"network":"tcp"}`,
ssHotSettings(t, cipher.method, cipher.serverKey, tc.stored))
stored := loadInboundByTag(t, "hot-ss")
if err := (&ClientService{}).SyncInbound(nil, stored.Id, tc.stored); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
pushed := *stored
pushed.Settings = ssHotSettings(t, cipher.method, cipher.serverKey, tc.pushed)
_, needRestart, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed)
if err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
if got := probe.recorded(); !slices.Equal(got, tc.wantOps) {
t.Fatalf("core operations = %q, want %q", got, tc.wantOps)
}
if needRestart {
t.Fatal("needRestart = true, want the push applied without restarting the core")
}
})
}
}
}
@@ -1,9 +1,14 @@
package service
import (
"encoding/json"
"fmt"
"maps"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"testing"
"gorm.io/gorm"
@@ -471,6 +476,163 @@ func TestMultiAttach_SameNode_DivergentSiblings(t *testing.T) {
assertUpDown(t, readTraffic(t, db, email), 100, 100, "equal siblings accrue the single increment")
}
var clientWriteSQL = regexp.MustCompile(`(?i)^\s*(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+["` + "`" + `]?(clients|client_inbounds|client_traffics|node_client_traffics)\b`)
// countClientRowWrites returns, per client table, how many rows fn's
// INSERT/UPDATE/DELETE statements touched.
func countClientRowWrites(t *testing.T, fn func()) map[string]int64 {
t.Helper()
db := database.GetDB()
var mu sync.Mutex
rows := map[string]int64{}
record := func(tx *gorm.DB) {
if tx.Error != nil || tx.RowsAffected == 0 || tx.Statement == nil {
return
}
if m := clientWriteSQL.FindStringSubmatch(tx.Statement.SQL.String()); m != nil {
mu.Lock()
rows[m[1]] += tx.RowsAffected
mu.Unlock()
}
}
const name = "test:count_client_row_writes"
cb := db.Callback()
for _, err := range []error{
cb.Create().After("gorm:create").Register(name, record),
cb.Update().After("gorm:update").Register(name, record),
cb.Delete().After("gorm:delete").Register(name, record),
cb.Raw().After("gorm:raw").Register(name, record),
} {
if err != nil {
t.Fatalf("register write callback: %v", err)
}
}
defer func() {
for _, err := range []error{
cb.Create().Remove(name), cb.Update().Remove(name),
cb.Delete().Remove(name), cb.Raw().Remove(name),
} {
if err != nil {
t.Errorf("remove write callback: %v", err)
}
}
}()
fn()
return rows
}
func TestNodeMerge_WritesOnlyChangedClients(t *testing.T) {
const clientCount = 20
type snapInbound struct {
tag string
protocol model.Protocol
entry func(i int, email string) map[string]any
}
vlessEntry := func(flow string) func(int, string) map[string]any {
return func(i int, email string) map[string]any {
e := map[string]any{"email": email, "id": fmt.Sprintf("%08d-0000-4000-8000-000000000000", i), "subId": email, "enable": true}
if flow != "" {
e["flow"] = flow
}
return e
}
}
wgEntry := func(tunnel int) func(int, string) map[string]any {
return func(i int, email string) map[string]any {
return map[string]any{
"email": email, "subId": email, "enable": true,
"privateKey": fmt.Sprintf("priv-%d-%d", tunnel, i),
"publicKey": fmt.Sprintf("pub-%d-%d", tunnel, i),
"allowedIPs": []string{fmt.Sprintf("10.%d.0.%d/32", tunnel, i+2)},
"preSharedKey": fmt.Sprintf("psk-%d-%d", tunnel, i),
"keepAlive": 25 * tunnel,
}
}
}
cases := []struct {
name string
inbounds []snapInbound
}{
{"vless with and without flow", []snapInbound{
{"n1-reality", model.VLESS, vlessEntry("xtls-rprx-vision")},
{"n1-ws", model.VLESS, vlessEntry("")},
}},
{"wireguard and vless", []snapInbound{
{"n1-wg", model.WireGuard, wgEntry(1)},
{"n1-vless", model.VLESS, vlessEntry("")},
}},
{"two wireguard tunnels", []snapInbound{
{"n1-wg-a", model.WireGuard, wgEntry(1)},
{"n1-wg-b", model.WireGuard, wgEntry(2)},
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
emails := make([]string, clientCount)
stats := make([]xray.ClientTraffic, clientCount)
for i := range emails {
emails[i] = fmt.Sprintf("multi-%d", i)
stats[i] = xray.ClientTraffic{Email: emails[i], Up: 1000, Down: 2000, Enable: true, LastOnline: 5000}
}
settings := make([]string, len(tc.inbounds))
for n, ib := range tc.inbounds {
entries := make([]map[string]any, clientCount)
for i, email := range emails {
entries[i] = ib.entry(i, email)
}
raw, err := json.Marshal(map[string]any{"clients": entries})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
settings[n] = string(raw)
nodeID := 1
if err := db.Create(&model.Inbound{
UserId: 1, NodeID: &nodeID, Tag: ib.tag, Enable: true,
Port: 42000 + n, Protocol: ib.protocol, Settings: settings[n],
}).Error; err != nil {
t.Fatalf("create inbound %q: %v", ib.tag, err)
}
}
merge := func(stats []xray.ClientTraffic) {
t.Helper()
// A node copies a multi-attached client's one counter onto every inbound it is on.
snap := &runtime.TrafficSnapshot{}
for n, ib := range tc.inbounds {
snap.Inbounds = append(snap.Inbounds, &model.Inbound{
Tag: ib.tag, Enable: true, Port: 42000 + n, Protocol: ib.protocol,
Settings: settings[n], ClientStats: slices.Clone(stats),
})
}
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
t.Fatalf("setRemoteTrafficLocked: %v", err)
}
}
merge(stats)
if got := countClientRowWrites(t, func() { merge(stats) }); len(got) != 0 {
t.Errorf("repeating an unchanged snapshot wrote client rows %v, want none", got)
}
active := slices.Clone(stats)
active[0].Up, active[0].Down, active[0].LastOnline = 1300, 2600, 6000
got := countClientRowWrites(t, func() { merge(active) })
want := map[string]int64{"client_traffics": 1, "node_client_traffics": 1}
if !maps.Equal(got, want) {
t.Errorf("one client's traffic tick wrote client rows %v, want %v", got, want)
}
ct := readTraffic(t, db, emails[0])
assertUpDown(t, ct, 300, 600, "active client after its tick")
if ct.LastOnline != 6000 {
t.Errorf("last_online = %d, want 6000", ct.LastOnline)
}
})
}
}
func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
+5 -12
View File
@@ -45,7 +45,6 @@ const (
sponsorsErrTTL = 10 * time.Minute
maxSponsorsBytes = 256 << 10
maxLogoBytes = 256 << 10
maxSidebarSlots = 3
localSponsorsDir = "../sponsors/3X"
sponsorLogoPath = "/sponsors/logo/"
)
@@ -61,13 +60,13 @@ type sponsorLogo struct {
}
var (
sponsorsURL = "https://sponsors.sanaei.dev/3X/sponsors.json"
sponsorLogoBase = "https://sponsors.sanaei.dev/3X/logos/"
sponsorsURL = "https://raw.githubusercontent.com/MHSanaei/sponsors/refs/heads/main/3X/sponsors.json"
sponsorLogoBase = "https://raw.githubusercontent.com/MHSanaei/sponsors/refs/heads/main/3X/logos/"
sponsorNow = time.Now
// The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs.
sponsorLogoRe = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*\.(png|webp|jpg)$`)
sponsorSlots = map[string]bool{"dashboard": true, "sidebar": true, "page": true, "login": true}
sponsorSlots = map[string]bool{"dashboard": true, "page": true, "login": true}
sponsorsMu sync.Mutex
sponsorsRaw *SponsorList
@@ -217,7 +216,6 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
if strings.HasPrefix(raw.Contact, "https://") {
out.Contact = raw.Contact
}
sidebarTaken := 0
for _, sp := range raw.Sponsors {
// A missing enable counts as on, so a forgotten field never hides a paid slot.
disabled := sp.Enable != nil && !*sp.Enable
@@ -233,18 +231,13 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
}
slots := make([]string, 0, len(sp.Slots))
for _, slot := range sp.Slots {
// The sidebar rotates, so capping it keeps each paid card on screen long enough.
if !sponsorSlots[slot] || (slot == "sidebar" && sidebarTaken >= maxSidebarSlots) {
continue
if sponsorSlots[slot] {
slots = append(slots, slot)
}
slots = append(slots, slot)
}
if len(slots) == 0 {
continue
}
if slices.Contains(slots, "sidebar") {
sidebarTaken++
}
sp.Slots = slots
out.Sponsors = append(out.Sponsors, sp)
}
+2 -31
View File
@@ -6,7 +6,6 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"sync/atomic"
"testing"
@@ -22,7 +21,7 @@ func validSponsor() Sponsor {
return Sponsor{
ID: "acme",
Name: "Acme",
Slots: []string{"dashboard", "sidebar"},
Slots: []string{"dashboard"},
Until: sponsorTestNow.Add(24 * time.Hour),
Logo: "acme.png",
Link: "https://acme.example/",
@@ -59,34 +58,6 @@ func TestActiveSponsorsFilters(t *testing.T) {
}
}
func TestActiveSponsorsCapsSidebarAtThree(t *testing.T) {
raw := &SponsorList{}
for _, id := range []string{"expired", "a", "b", "c", "d", "e"} {
sp := validSponsor()
sp.ID = id
sp.Slots = []string{"sidebar", "page"}
if id == "expired" {
sp.Until = sponsorTestNow
}
if id == "e" {
sp.Slots = []string{"sidebar"}
}
raw.Sponsors = append(raw.Sponsors, sp)
}
got := activeSponsors(raw, sponsorTestNow)
want := map[string][]string{
"a": {"sidebar", "page"}, "b": {"sidebar", "page"}, "c": {"sidebar", "page"}, "d": {"page"},
}
if len(got.Sponsors) != len(want) {
t.Fatalf("got %d sponsors, want %d (e has only sidebar and must drop)", len(got.Sponsors), len(want))
}
for _, sp := range got.Sponsors {
if !slices.Equal(sp.Slots, want[sp.ID]) {
t.Errorf("%s slots = %v, want %v", sp.ID, sp.Slots, want[sp.ID])
}
}
}
func TestActiveSponsorsLogoName(t *testing.T) {
cases := []struct{ logo, want string }{
{"acme.png", "/sponsors/logo/acme.png"},
@@ -114,7 +85,7 @@ func TestActiveSponsorsLogoName(t *testing.T) {
func TestActiveSponsorsResolvesLogoAndSlots(t *testing.T) {
sp := validSponsor()
sp.Slots = []string{"subpage", "login"}
sp.Slots = []string{"subpage", "sidebar", "login"}
got := activeSponsors(&SponsorList{Contact: "javascript:x", Sponsors: []Sponsor{sp}}, sponsorTestNow)
if len(got.Sponsors) != 1 {
t.Fatalf("got %d sponsors, want 1", len(got.Sponsors))
@@ -94,12 +94,13 @@ func seedDualTunnelClient(t *testing.T, enabled bool, wgKeepAlive *int) string {
wgInbound := &model.Inbound{Tag: "wg-dual", Enable: true, Port: 51823, Protocol: model.WireGuard, Settings: string(wgSettings)}
awgInbound := &model.Inbound{Tag: "awg-dual", Enable: true, Port: 51824, Protocol: model.AmneziaWG, Settings: string(awgSettings)}
if err := db.Create(wgInbound).Error; err != nil {
t.Fatalf("create wg inbound: %v", err)
}
// The lower-id tunnel owns the shared record's peer fields: AmneziaWG first.
if err := db.Create(awgInbound).Error; err != nil {
t.Fatalf("create awg inbound: %v", err)
}
if err := db.Create(wgInbound).Error; err != nil {
t.Fatalf("create wg inbound: %v", err)
}
svc := ClientService{}
if err := svc.SyncInbound(nil, wgInbound.Id, []model.Client{wgClient}); err != nil {
@@ -202,7 +203,7 @@ func TestGetXrayConfigWireGuardUsesInboundLocalTunnelFields(t *testing.T) {
t.Fatalf("read shared client: %v", err)
}
if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" || shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 {
t.Fatalf("test setup did not persist AmneziaWG last: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive)
t.Fatalf("test setup did not persist AmneziaWG identity: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive)
}
peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
+3 -13
View File
@@ -505,6 +505,8 @@
"mtprotoFakeTlsDomainHint": "نطاق FakeTLS الافتراضي المستخدم لإنشاء سر عميل جديد. يمكن لكل عميل استخدام نطاقه الخاص.",
"mtgThrottleMaxConnections": "الحد الأقصى للاتصالات",
"mtgThrottleMaxConnectionsHint": "تحديد الاتصالات المتزامنة لجميع المستخدمين بتوزيع عادل. القيمة 0 تعطّل الميزة.",
"mtgPendingHandshakesMaxPerIp": "الحد الأقصى للمصافحات المعلقة لكل IP",
"mtgPendingHandshakesMaxPerIpHint": "تُغلق الاتصالات الجديدة من عنوان IP لديه هذا العدد من المصافحات غير المكتملة. يتشارك المستخدمون خلف عنوان IP مشترك (NAT لدى المشغّل) هذا الحد. القيمة 0 تعطّله.",
"mtgAdTagInvalid": "يجب أن تتكوّن العلامة الإعلانية من 32 حرفًا ست عشريًا بالضبط.",
"mtgPublicIpv4": "عنوان IPv4 العام",
"mtgPublicIpv6": "عنوان IPv6 العام",
@@ -2423,19 +2425,7 @@
"tag": "راعٍ",
"visit": "زيارة",
"yourBrand": "علامتك التجارية هنا",
"yourBrandText": "ضع شركتك أمام آلاف مسؤولي الخوادم الذين يفتحون 3X-UI كل يوم.",
"placements": "المواضع المتاحة",
"placementDashboard": "لافتة لوحة المعلومات",
"placementDashboardDesc": "موضع مميز أعلى لوحة المعلومات، راعٍ واحد في كل مرة.",
"placementSidebar": "بطاقة الشريط الجانبي",
"placementSidebarDesc": "تظهر في كل صفحات اللوحة وتتناوب بين الرعاة.",
"placementLogin": "صفحة تسجيل الدخول",
"placementLoginDesc": "يراها المسؤول عند كل تسجيل دخول.",
"placementPage": "صفحة الرعاة",
"placementPageDesc": "مدرجة هنا مع الشعار والوصف والرابط.",
"available": "متاح",
"takenUntil": "محجوز حتى {date}",
"activeCount": "{count} نشط"
"yourBrandText": "ضع شركتك أمام آلاف مسؤولي الخوادم الذين يفتحون 3X-UI كل يوم."
}
},
"tgbot": {
+3 -13
View File
@@ -507,6 +507,8 @@
"mtprotoFakeTlsDomainHint": "Default FakeTLS domain used to generate a new client's secret. Each client can front its own domain.",
"mtgThrottleMaxConnections": "Max connections",
"mtgThrottleMaxConnectionsHint": "Cap concurrent connections across all users with a fair-share limit. 0 disables throttling.",
"mtgPendingHandshakesMaxPerIp": "Max pending handshakes per IP",
"mtgPendingHandshakesMaxPerIpHint": "Close new connections from an IP that already has this many unfinished handshakes. Clients behind a shared IP (carrier NAT) share the limit. 0 disables it.",
"mtgAdTagInvalid": "Ad-tag must be exactly 32 hexadecimal characters.",
"mtgPublicIpv4": "Public IPv4",
"mtgPublicIpv6": "Public IPv6",
@@ -2423,19 +2425,7 @@
"tag": "Sponsor",
"visit": "Visit",
"yourBrand": "Your brand here",
"yourBrandText": "Put your company in front of thousands of server admins who open 3X-UI every day.",
"placements": "Available placements",
"placementDashboard": "Overview banner",
"placementDashboardDesc": "Premium spot at the top of the dashboard, one sponsor at a time.",
"placementSidebar": "Sidebar card",
"placementSidebarDesc": "Shown on every panel page, rotating between sponsors.",
"placementLogin": "Login page",
"placementLoginDesc": "Seen by the admin at every sign-in.",
"placementPage": "Sponsors page",
"placementPageDesc": "Listed here with logo, description and link.",
"available": "Available",
"takenUntil": "Taken until {date}",
"activeCount": "{count} active"
"yourBrandText": "Put your company in front of thousands of server admins who open 3X-UI every day."
}
},
"tgbot": {
+3 -13
View File
@@ -505,6 +505,8 @@
"mtprotoFakeTlsDomainHint": "Dominio FakeTLS predeterminado para generar el secreto de un nuevo cliente. Cada cliente puede usar su propio dominio.",
"mtgThrottleMaxConnections": "Conexiones máximas",
"mtgThrottleMaxConnectionsHint": "Limita las conexiones simultáneas de todos los usuarios con reparto equitativo. 0 desactiva el límite.",
"mtgPendingHandshakesMaxPerIp": "Máx. handshakes pendientes por IP",
"mtgPendingHandshakesMaxPerIpHint": "Cierra las conexiones nuevas de una IP que ya tiene esta cantidad de handshakes sin completar. Los clientes detrás de una IP compartida (NAT del operador) comparten el límite. 0 lo desactiva.",
"mtgAdTagInvalid": "El ad-tag debe tener exactamente 32 caracteres hexadecimales.",
"mtgPublicIpv4": "IPv4 pública",
"mtgPublicIpv6": "IPv6 pública",
@@ -2423,19 +2425,7 @@
"tag": "Patrocinador",
"visit": "Visitar",
"yourBrand": "Tu marca aquí",
"yourBrandText": "Muestra tu empresa a miles de administradores de servidores que abren 3X-UI cada día.",
"placements": "Espacios disponibles",
"placementDashboard": "Banner del panel",
"placementDashboardDesc": "Espacio premium en la parte superior del panel, un patrocinador a la vez.",
"placementSidebar": "Tarjeta lateral",
"placementSidebarDesc": "Se muestra en todas las páginas del panel, rotando entre patrocinadores.",
"placementLogin": "Página de inicio de sesión",
"placementLoginDesc": "La ve el administrador en cada inicio de sesión.",
"placementPage": "Página de patrocinadores",
"placementPageDesc": "Aparece aquí con logo, descripción y enlace.",
"available": "Disponible",
"takenUntil": "Ocupado hasta {date}",
"activeCount": "{count} activos"
"yourBrandText": "Muestra tu empresa a miles de administradores de servidores que abren 3X-UI cada día."
}
},
"tgbot": {
+3 -13
View File
@@ -505,6 +505,8 @@
"mtprotoFakeTlsDomainHint": "دامنه پیش‌فرض FakeTLS برای ساخت سکرت کلاینت جدید. هر کلاینت می‌تواند دامنه مخصوص خود را داشته باشد.",
"mtgThrottleMaxConnections": "حداکثر اتصالات",
"mtgThrottleMaxConnectionsHint": "محدود کردن اتصالات همزمان همه کاربران با تقسیم منصفانه. مقدار ۰ غیرفعال است.",
"mtgPendingHandshakesMaxPerIp": "حداکثر handshake معلق برای هر IP",
"mtgPendingHandshakesMaxPerIpHint": "اتصال‌های جدید از IPای که این تعداد handshake ناتمام دارد بسته می‌شوند. کاربرانی که پشت یک IP مشترک (NAT اپراتور) هستند این سقف را با هم شریک‌اند. مقدار ۰ غیرفعال است.",
"mtgAdTagInvalid": "برچسب تبلیغاتی باید دقیقاً ۳۲ کاراکتر هگزادسیمال باشد.",
"mtgPublicIpv4": "IPv4 عمومی",
"mtgPublicIpv6": "IPv6 عمومی",
@@ -2423,19 +2425,7 @@
"tag": "اسپانسر",
"visit": "مشاهده",
"yourBrand": "جای برند شما",
"yourBrandText": "شرکت خود را جلوی چشم هزاران ادمین سروری بگذارید که هر روز 3X-UI را باز می‌کنند.",
"placements": "جایگاه‌های موجود",
"placementDashboard": "بنر داشبورد",
"placementDashboardDesc": "جایگاه ویژه بالای صفحه‌ی داشبورد، هر بار فقط یک اسپانسر.",
"placementSidebar": "کارت سایدبار",
"placementSidebarDesc": "در همه‌ی صفحات پنل نمایش داده می‌شود و بین اسپانسرها چرخش دارد.",
"placementLogin": "صفحه‌ی ورود",
"placementLoginDesc": "ادمین در هر بار ورود آن را می‌بیند.",
"placementPage": "صفحه‌ی اسپانسرها",
"placementPageDesc": "همراه با لوگو، توضیحات و لینک در همین صفحه.",
"available": "خالی",
"takenUntil": "پر شده تا {date}",
"activeCount": "{count} فعال"
"yourBrandText": "شرکت خود را جلوی چشم هزاران ادمین سروری بگذارید که هر روز 3X-UI را باز می‌کنند."
}
},
"tgbot": {
+3 -13
View File
@@ -505,6 +505,8 @@
"mtprotoFakeTlsDomainHint": "Domain FakeTLS default untuk membuat secret klien baru. Setiap klien bisa memakai domainnya sendiri.",
"mtgThrottleMaxConnections": "Koneksi maksimum",
"mtgThrottleMaxConnectionsHint": "Batasi koneksi bersamaan semua pengguna dengan pembagian adil. 0 menonaktifkan.",
"mtgPendingHandshakesMaxPerIp": "Maks. handshake tertunda per IP",
"mtgPendingHandshakesMaxPerIpHint": "Tutup koneksi baru dari IP yang sudah memiliki handshake belum selesai sebanyak ini. Klien di balik IP bersama (NAT operator) berbagi batas ini. 0 menonaktifkan.",
"mtgAdTagInvalid": "Ad-tag harus tepat 32 karakter heksadesimal.",
"mtgPublicIpv4": "IPv4 Publik",
"mtgPublicIpv6": "IPv6 Publik",
@@ -2423,19 +2425,7 @@
"tag": "Sponsor",
"visit": "Kunjungi",
"yourBrand": "Merek Anda di sini",
"yourBrandText": "Tampilkan perusahaan Anda kepada ribuan admin server yang membuka 3X-UI setiap hari.",
"placements": "Slot yang tersedia",
"placementDashboard": "Banner dasbor",
"placementDashboardDesc": "Tempat premium di atas dasbor, satu sponsor dalam satu waktu.",
"placementSidebar": "Kartu sidebar",
"placementSidebarDesc": "Tampil di setiap halaman panel, bergantian antar sponsor.",
"placementLogin": "Halaman login",
"placementLoginDesc": "Dilihat admin setiap kali masuk.",
"placementPage": "Halaman sponsor",
"placementPageDesc": "Tercantum di sini dengan logo, deskripsi, dan tautan.",
"available": "Tersedia",
"takenUntil": "Terisi hingga {date}",
"activeCount": "{count} aktif"
"yourBrandText": "Tampilkan perusahaan Anda kepada ribuan admin server yang membuka 3X-UI setiap hari."
}
},
"tgbot": {
+3 -13
View File
@@ -526,6 +526,8 @@
"mtprotoFakeTlsDomainHint": "新しいクライアントのシークレット生成に使う既定の FakeTLS ドメイン。クライアントごとに別のドメインを使用できます。",
"mtgThrottleMaxConnections": "最大接続数",
"mtgThrottleMaxConnectionsHint": "全ユーザーの同時接続数を公平配分で制限します。0 で無効。",
"mtgPendingHandshakesMaxPerIp": "IP あたりの未完了ハンドシェイク上限",
"mtgPendingHandshakesMaxPerIpHint": "未完了のハンドシェイクをこの数だけ保持している IP からの新規接続を閉じます。共有 IP(キャリア NAT)配下のクライアントはこの上限を共有します。0 で無効。",
"mtgAdTagInvalid": "広告タグは正確に 32 文字の 16 進数である必要があります。",
"mtgPublicIpv4": "パブリック IPv4",
"mtgPublicIpv6": "パブリック IPv6",
@@ -2423,19 +2425,7 @@
"tag": "スポンサー",
"visit": "訪問",
"yourBrand": "ここにあなたのブランドを",
"yourBrandText": "毎日 3X-UI を開く何千人ものサーバー管理者に御社をアピールできます。",
"placements": "掲載枠",
"placementDashboard": "ダッシュボードバナー",
"placementDashboardDesc": "ダッシュボード上部のプレミアム枠。一度に 1 社のみ。",
"placementSidebar": "サイドバーカード",
"placementSidebarDesc": "パネルの全ページに表示され、スポンサー間でローテーションします。",
"placementLogin": "ログインページ",
"placementLoginDesc": "管理者がログインするたびに表示されます。",
"placementPage": "スポンサーページ",
"placementPageDesc": "ロゴ、説明、リンク付きでここに掲載されます。",
"available": "空きあり",
"takenUntil": "{date} まで予約済み",
"activeCount": "{count} 件掲載中"
"yourBrandText": "毎日 3X-UI を開く何千人ものサーバー管理者に御社をアピールできます。"
}
},
"tgbot": {
+3 -13
View File
@@ -526,6 +526,8 @@
"mtprotoFakeTlsDomainHint": "Domínio FakeTLS padrão usado para gerar o segredo de um novo cliente. Cada cliente pode usar seu próprio domínio.",
"mtgThrottleMaxConnections": "Conexões máximas",
"mtgThrottleMaxConnectionsHint": "Limita conexões simultâneas de todos os usuários com distribuição justa. 0 desativa o limite.",
"mtgPendingHandshakesMaxPerIp": "Máx. handshakes pendentes por IP",
"mtgPendingHandshakesMaxPerIpHint": "Fecha novas conexões de um IP que já tem esta quantidade de handshakes não concluídos. Clientes atrás de um IP compartilhado (NAT da operadora) dividem o limite. 0 desativa.",
"mtgAdTagInvalid": "A ad-tag deve ter exatamente 32 caracteres hexadecimais.",
"mtgPublicIpv4": "IPv4 público",
"mtgPublicIpv6": "IPv6 público",
@@ -2423,19 +2425,7 @@
"tag": "Patrocinador",
"visit": "Visitar",
"yourBrand": "Sua marca aqui",
"yourBrandText": "Coloque sua empresa diante de milhares de administradores de servidores que abrem o 3X-UI todos os dias.",
"placements": "Espaços disponíveis",
"placementDashboard": "Banner do painel",
"placementDashboardDesc": "Espaço premium no topo do painel, um patrocinador por vez.",
"placementSidebar": "Cartão lateral",
"placementSidebarDesc": "Exibido em todas as páginas do painel, alternando entre patrocinadores.",
"placementLogin": "Página de login",
"placementLoginDesc": "Visto pelo administrador a cada login.",
"placementPage": "Página de patrocinadores",
"placementPageDesc": "Listado aqui com logo, descrição e link.",
"available": "Disponível",
"takenUntil": "Ocupado até {date}",
"activeCount": "{count} ativos"
"yourBrandText": "Coloque sua empresa diante de milhares de administradores de servidores que abrem o 3X-UI todos os dias."
}
},
"tgbot": {
+3 -13
View File
@@ -528,6 +528,8 @@
"mtprotoFakeTlsDomainHint": "Домен FakeTLS по умолчанию для генерации секрета нового клиента. Каждый клиент может использовать свой домен.",
"mtgThrottleMaxConnections": "Макс. подключений",
"mtgThrottleMaxConnectionsHint": "Ограничение одновременных подключений всех пользователей по справедливому распределению. 0 — отключено.",
"mtgPendingHandshakesMaxPerIp": "Макс. незавершённых рукопожатий на IP",
"mtgPendingHandshakesMaxPerIpHint": "Новые подключения с IP, у которого уже столько незавершённых рукопожатий, закрываются. Клиенты за общим IP (NAT оператора) делят этот лимит. 0 — отключено.",
"mtgAdTagInvalid": "Рекламный тег должен содержать ровно 32 шестнадцатеричных символа.",
"mtgPublicIpv4": "Публичный IPv4",
"mtgPublicIpv6": "Публичный IPv6",
@@ -2423,19 +2425,7 @@
"tag": "Спонсор",
"visit": "Перейти",
"yourBrand": "Здесь может быть ваш бренд",
"yourBrandText": "Покажите свою компанию тысячам администраторов серверов, которые каждый день открывают 3X-UI.",
"placements": "Доступные места",
"placementDashboard": "Баннер на главной",
"placementDashboardDesc": "Премиум-место вверху панели, один спонсор за раз.",
"placementSidebar": "Карточка в боковой панели",
"placementSidebarDesc": "Показывается на всех страницах панели, спонсоры чередуются.",
"placementLogin": "Страница входа",
"placementLoginDesc": "Администратор видит её при каждом входе.",
"placementPage": "Страница спонсоров",
"placementPageDesc": "Размещение здесь с логотипом, описанием и ссылкой.",
"available": "Свободно",
"takenUntil": "Занято до {date}",
"activeCount": "Активных: {count}"
"yourBrandText": "Покажите свою компанию тысячам администраторов серверов, которые каждый день открывают 3X-UI."
}
},
"tgbot": {
+3 -13
View File
@@ -505,6 +505,8 @@
"mtprotoFakeTlsDomainHint": "Yeni bir istemcinin sırrını oluştururken kullanılan varsayılan FakeTLS alan adı. Her istemci kendi alan adını kullanabilir.",
"mtgThrottleMaxConnections": "Maks. bağlantı",
"mtgThrottleMaxConnectionsHint": "Tüm kullanıcıların eşzamanlı bağlantılarını adil paylaşımla sınırlar. 0 devre dışı bırakır.",
"mtgPendingHandshakesMaxPerIp": "IP başına maks. bekleyen el sıkışma",
"mtgPendingHandshakesMaxPerIpHint": "Bu sayıda tamamlanmamış el sıkışması olan bir IP'den gelen yeni bağlantıları kapatır. Paylaşılan IP arkasındaki istemciler (operatör NAT) sınırı paylaşır. 0 devre dışı bırakır.",
"mtgAdTagInvalid": "Reklam etiketi tam olarak 32 onaltılık karakter olmalıdır.",
"mtgPublicIpv4": "Genel IPv4",
"mtgPublicIpv6": "Genel IPv6",
@@ -2423,19 +2425,7 @@
"tag": "Sponsor",
"visit": "Ziyaret et",
"yourBrand": "Markanız burada",
"yourBrandText": "Şirketinizi her gün 3X-UI açan binlerce sunucu yöneticisinin önüne çıkarın.",
"placements": "Mevcut yerleşimler",
"placementDashboard": "Genel bakış afişi",
"placementDashboardDesc": "Panonun üstünde premium alan, aynı anda tek sponsor.",
"placementSidebar": "Kenar çubuğu kartı",
"placementSidebarDesc": "Tüm panel sayfalarında gösterilir, sponsorlar arasında döner.",
"placementLogin": "Giriş sayfası",
"placementLoginDesc": "Yönetici her girişte görür.",
"placementPage": "Sponsorlar sayfası",
"placementPageDesc": "Logo, açıklama ve bağlantıyla burada listelenir.",
"available": "Müsait",
"takenUntil": "{date} tarihine kadar dolu",
"activeCount": "{count} aktif"
"yourBrandText": "Şirketinizi her gün 3X-UI açan binlerce sunucu yöneticisinin önüne çıkarın."
}
},
"tgbot": {
+3 -13
View File
@@ -505,6 +505,8 @@
"mtprotoFakeTlsDomainHint": "Домен FakeTLS за замовчуванням для генерації секрету нового клієнта. Кожен клієнт може використовувати власний домен.",
"mtgThrottleMaxConnections": "Макс. з'єднань",
"mtgThrottleMaxConnectionsHint": "Обмеження одночасних з'єднань усіх користувачів зі справедливим розподілом. 0 — вимкнено.",
"mtgPendingHandshakesMaxPerIp": "Макс. незавершених рукостискань на IP",
"mtgPendingHandshakesMaxPerIpHint": "Нові з'єднання з IP, що вже має стільки незавершених рукостискань, закриваються. Клієнти за спільним IP (NAT оператора) ділять цей ліміт. 0 — вимкнено.",
"mtgAdTagInvalid": "Рекламний тег має містити рівно 32 шістнадцяткові символи.",
"mtgPublicIpv4": "Публічний IPv4",
"mtgPublicIpv6": "Публічний IPv6",
@@ -2423,19 +2425,7 @@
"tag": "Спонсор",
"visit": "Перейти",
"yourBrand": "Тут може бути ваш бренд",
"yourBrandText": "Покажіть свою компанію тисячам адміністраторів серверів, які щодня відкривають 3X-UI.",
"placements": "Доступні місця",
"placementDashboard": "Банер на головній",
"placementDashboardDesc": "Преміум-місце вгорі панелі, один спонсор за раз.",
"placementSidebar": "Картка в бічній панелі",
"placementSidebarDesc": "Показується на всіх сторінках панелі, спонсори чергуються.",
"placementLogin": "Сторінка входу",
"placementLoginDesc": "Адміністратор бачить її під час кожного входу.",
"placementPage": "Сторінка спонсорів",
"placementPageDesc": "Розміщення тут із логотипом, описом і посиланням.",
"available": "Вільно",
"takenUntil": "Зайнято до {date}",
"activeCount": "Активних: {count}"
"yourBrandText": "Покажіть свою компанію тисячам адміністраторів серверів, які щодня відкривають 3X-UI."
}
},
"tgbot": {
+3 -13
View File
@@ -526,6 +526,8 @@
"mtprotoFakeTlsDomainHint": "Tên miền FakeTLS mặc định dùng để tạo secret cho client mới. Mỗi client có thể dùng tên miền riêng.",
"mtgThrottleMaxConnections": "Số kết nối tối đa",
"mtgThrottleMaxConnectionsHint": "Giới hạn kết nối đồng thời của tất cả người dùng theo phân bổ công bằng. 0 để tắt.",
"mtgPendingHandshakesMaxPerIp": "Số bắt tay chờ tối đa mỗi IP",
"mtgPendingHandshakesMaxPerIpHint": "Đóng kết nối mới từ IP đang có số bắt tay chưa hoàn tất bằng mức này. Các máy khách dùng chung IP (NAT nhà mạng) chia sẻ giới hạn. 0 để tắt.",
"mtgAdTagInvalid": "Ad-tag phải có đúng 32 ký tự thập lục phân.",
"mtgPublicIpv4": "IPv4 công khai",
"mtgPublicIpv6": "IPv6 công khai",
@@ -2423,19 +2425,7 @@
"tag": "Tài trợ",
"visit": "Truy cập",
"yourBrand": "Thương hiệu của bạn ở đây",
"yourBrandText": "Đưa công ty bạn đến hàng nghìn quản trị viên máy chủ mở 3X-UI mỗi ngày.",
"placements": "Vị trí hiện có",
"placementDashboard": "Banner tổng quan",
"placementDashboardDesc": "Vị trí cao cấp ở đầu bảng điều khiển, mỗi lần một nhà tài trợ.",
"placementSidebar": "Thẻ thanh bên",
"placementSidebarDesc": "Hiển thị trên mọi trang của panel, luân phiên giữa các nhà tài trợ.",
"placementLogin": "Trang đăng nhập",
"placementLoginDesc": "Quản trị viên thấy mỗi lần đăng nhập.",
"placementPage": "Trang nhà tài trợ",
"placementPageDesc": "Được liệt kê tại đây với logo, mô tả và liên kết.",
"available": "Còn trống",
"takenUntil": "Đã đặt đến {date}",
"activeCount": "{count} đang hoạt động"
"yourBrandText": "Đưa công ty bạn đến hàng nghìn quản trị viên máy chủ mở 3X-UI mỗi ngày."
}
},
"tgbot": {
+3 -13
View File
@@ -525,6 +525,8 @@
"mtprotoFakeTlsDomainHint": "生成新客户端密钥时使用的默认 FakeTLS 域名。每个客户端可使用各自的域名。",
"mtgThrottleMaxConnections": "最大连接数",
"mtgThrottleMaxConnectionsHint": "按公平分配限制所有用户的并发连接数。0 表示不限制。",
"mtgPendingHandshakesMaxPerIp": "每 IP 最大未完成握手数",
"mtgPendingHandshakesMaxPerIpHint": "当某个 IP 的未完成握手达到此数量时,关闭其新连接。共享 IP(运营商 NAT)后的客户端共用此上限。0 表示禁用。",
"mtgAdTagInvalid": "广告标签必须为 32 位十六进制字符。",
"mtgPublicIpv4": "公网 IPv4",
"mtgPublicIpv6": "公网 IPv6",
@@ -2423,19 +2425,7 @@
"tag": "赞助",
"visit": "访问",
"yourBrand": "您的品牌展示位",
"yourBrandText": "让每天打开 3X-UI 的数千名服务器管理员看到您的公司。",
"placements": "可用展示位",
"placementDashboard": "概览横幅",
"placementDashboardDesc": "仪表盘顶部的高级位置,每次仅一家赞助商。",
"placementSidebar": "侧边栏卡片",
"placementSidebarDesc": "在面板所有页面显示,并在赞助商之间轮换。",
"placementLogin": "登录页",
"placementLoginDesc": "管理员每次登录时都能看到。",
"placementPage": "赞助商页面",
"placementPageDesc": "在此页面展示徽标、简介和链接。",
"available": "可预订",
"takenUntil": "已预订至 {date}",
"activeCount": "{count} 个进行中"
"yourBrandText": "让每天打开 3X-UI 的数千名服务器管理员看到您的公司。"
}
},
"tgbot": {
+3 -13
View File
@@ -505,6 +505,8 @@
"mtprotoFakeTlsDomainHint": "產生新用戶端金鑰時使用的預設 FakeTLS 網域。每個用戶端可使用各自的網域。",
"mtgThrottleMaxConnections": "最大連線數",
"mtgThrottleMaxConnectionsHint": "以公平分配限制所有使用者的並行連線數。0 表示不限制。",
"mtgPendingHandshakesMaxPerIp": "每 IP 最大未完成交握數",
"mtgPendingHandshakesMaxPerIpHint": "當某個 IP 的未完成交握達到此數量時,關閉其新連線。共用 IP(電信 NAT)後的用戶端共用此上限。0 表示停用。",
"mtgAdTagInvalid": "廣告標籤必須為 32 位十六進位字元。",
"mtgPublicIpv4": "公網 IPv4",
"mtgPublicIpv6": "公網 IPv6",
@@ -2423,19 +2425,7 @@
"tag": "贊助",
"visit": "造訪",
"yourBrand": "您的品牌展示位",
"yourBrandText": "讓每天打開 3X-UI 的數千名伺服器管理員看到您的公司。",
"placements": "可用展示位",
"placementDashboard": "總覽橫幅",
"placementDashboardDesc": "儀表板頂部的高級位置,每次僅一家贊助商。",
"placementSidebar": "側邊欄卡片",
"placementSidebarDesc": "在面板所有頁面顯示,並在贊助商之間輪換。",
"placementLogin": "登入頁",
"placementLoginDesc": "管理員每次登入時都能看到。",
"placementPage": "贊助商頁面",
"placementPageDesc": "在此頁面展示標誌、簡介與連結。",
"available": "可預訂",
"takenUntil": "已預訂至 {date}",
"activeCount": "{count} 個進行中"
"yourBrandText": "讓每天打開 3X-UI 的數千名伺服器管理員看到您的公司。"
}
},
"tgbot": {
+29 -3
View File
@@ -3,6 +3,7 @@ package xray
import (
"bytes"
"encoding/json"
"maps"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
@@ -234,7 +235,7 @@ func InboundUserOps(oldIb, newIb *InboundConfig) (*HotDiff, bool) {
return diff, true
}
var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}, "masque": {}}
var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}, "masque": {}, "shadowsocks": {}}
// diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds
// differ only in settings.clients, so the handler (and its listener) survives.
@@ -261,6 +262,10 @@ func diffInboundUsers(oldIb, newIb *InboundConfig, diff *HotDiff) bool {
if !bytes.Equal(oldRest, newRest) {
return false
}
cipher := ""
if newIb.Protocol == "shadowsocks" {
cipher = shadowsocksSettingsMethod(newRest)
}
for email, oldC := range oldClients {
newC, exists := newClients[email]
if exists && bytes.Equal(oldC.norm, newC.norm) {
@@ -268,17 +273,38 @@ func diffInboundUsers(oldIb, newIb *InboundConfig, diff *HotDiff) bool {
}
diff.RemovedUsers = append(diff.RemovedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email})
if exists {
diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: newC.user})
diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: userWithCipher(newC.user, cipher)})
}
}
for email, newC := range newClients {
if _, exists := oldClients[email]; !exists {
diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: newC.user})
diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: userWithCipher(newC.user, cipher)})
}
}
return true
}
// shadowsocksSettingsMethod reads the inbound-level method: 2022 clients carry
// none, and AddUser needs it to pick the account type the running inbound expects.
func shadowsocksSettingsMethod(rest []byte) string {
var settings struct {
Method string `json:"method"`
}
if json.Unmarshal(rest, &settings) != nil {
return ""
}
return settings.Method
}
func userWithCipher(user map[string]any, cipher string) map[string]any {
if cipher == "" {
return user
}
withCipher := maps.Clone(user)
withCipher["cipher"] = cipher
return withCipher
}
type clientEntry struct {
user map[string]any
norm []byte
+42 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
"github.com/op/go-logging"
"github.com/xtls/xray-core/proxy/shadowsocks"
)
func TestMain(m *testing.M) {
@@ -242,7 +243,7 @@ func TestComputeHotDiff_ClientChangeFallsBackToReplace(t *testing.T) {
{
name: "unsupported protocol",
mutate: func(cfg *Config) {
cfg.InboundConfigs[1].Protocol = "shadowsocks"
cfg.InboundConfigs[1].Protocol = "http"
},
},
{
@@ -559,3 +560,43 @@ func TestComputeHotDiff_HysteriaClientOnlyChangeKeepsListener(t *testing.T) {
t.Fatalf("expected a single AddUser op for b, got %+v", diff)
}
}
// 2022 clients carry no method of their own, so a user op built from the client
// object alone has no cipher and its account cannot be built for the core.
func TestInboundUserOps_ShadowsocksAddedUserBuildsTheInboundAccount(t *testing.T) {
cases := []struct {
name, settingsHead, clientMethod string
wantLegacy bool
}{
{"legacy aead", `"method":"aes-256-gcm"`, `"method":"aes-256-gcm",`, true},
{"2022", `"method":"2022-blake3-aes-128-gcm","password":"AAECAwQFBgcICQoLDA0ODw=="`, "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
inbound := func(clients ...string) *InboundConfig {
return &InboundConfig{
Port: 8388, Protocol: "shadowsocks", Tag: "inbound-8388",
Listen: json_util.RawMessage(`"0.0.0.0"`),
Settings: json_util.RawMessage(`{` + tc.settingsHead + `,"network":"tcp,udp","clients":[` + strings.Join(clients, ",") + `]}`),
}
}
clientA := `{` + tc.clientMethod + `"email":"a","password":"EBESExQVFhcYGRobHB0eHw=="}`
clientB := `{` + tc.clientMethod + `"email":"b","password":"ICEiIyQlJicoKSorLC0uLw=="}`
diff, ok := InboundUserOps(inbound(clientA), inbound(clientA, clientB))
if !ok {
t.Fatal("a client-only shadowsocks change must apply through per-user ops")
}
if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 1 || diff.AddedUsers[0].Email != "b" {
t.Fatalf("expected only user b added, got added %+v removed %+v", diff.AddedUsers, diff.RemovedUsers)
}
_, legacy, modern := decodeSSAccount(t, diff.AddedUsers[0].User)
if tc.wantLegacy && (legacy == nil || legacy.CipherType != shadowsocks.CipherType_AES_256_GCM) {
t.Fatalf("want a legacy aes-256-gcm account, got legacy %v modern %v", legacy, modern)
}
if !tc.wantLegacy && (modern == nil || modern.Key != "ICEiIyQlJicoKSorLC0uLw==") {
t.Fatalf("want a 2022 account keyed by b's password, got legacy %v modern %v", legacy, modern)
}
})
}
}
+117 -78
View File
@@ -12,8 +12,8 @@ import (
// Compiled once at package load: Write runs on every line Xray emits, so
// recompiling these per write is wasted work.
var (
crashRegex = regexp.MustCompile(`(?i)(panic|exception|stack trace|fatal error)`)
logLineRegex = regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}\.\d{6}) \[([^\]]+)\] (.+)$`)
crashStartRegex = regexp.MustCompile(`^(panic|fatal error): `)
logLineRegex = regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}\.\d{6}) \[([^\]]+)\] (.+)$`)
)
// NewLogWriter returns a new LogWriter for processing Xray log output.
@@ -21,9 +21,12 @@ func NewLogWriter() *LogWriter {
return &LogWriter{}
}
// LogWriter processes and filters log output from the Xray process, handling crash detection and message filtering.
// LogWriter splits Xray's stdout/stderr into lines and forwards them to the
// panel log; once a Go panic or fatal error starts, all output is kept for one crash report.
type LogWriter struct {
mu sync.RWMutex
pending string
crash strings.Builder
lastLine string
}
@@ -36,86 +39,122 @@ func (lw *LogWriter) LastLine() string {
return lw.lastLine
}
func (lw *LogWriter) setLastLine(line string) {
lw.mu.Lock()
lw.lastLine = line
lw.mu.Unlock()
}
// Write processes and filters log output from the Xray process, handling crash detection and message filtering.
// Write buffers chunks into whole lines: the pipe splits output at arbitrary
// points, e.g. the Go runtime writes "panic: " on its own before the message.
func (lw *LogWriter) Write(m []byte) (n int, err error) {
// Convert the data to a string
message := strings.TrimSpace(string(m))
msgLowerAll := strings.ToLower(message)
// Suppress noisy Windows process-kill signal that surfaces as exit status 1
if runtime.GOOS == "windows" && strings.Contains(msgLowerAll, "exit status 1") {
lw.mu.Lock()
defer lw.mu.Unlock()
if lw.crash.Len() > 0 {
lw.crash.Write(m)
return len(m), nil
}
// Check if the message contains a crash
if crashRegex.MatchString(message) {
logger.Debug("Core crash detected:\n", message)
lw.setLastLine(message)
err1 := writeCrashReport(m)
if err1 != nil {
logger.Error("Unable to write crash report:", err1)
lw.pending += string(m)
for {
i := strings.IndexByte(lw.pending, '\n')
if i < 0 {
break
}
return len(m), nil
}
messages := strings.SplitSeq(message, "\n")
for msg := range messages {
matches := logLineRegex.FindStringSubmatch(msg)
if len(matches) > 3 {
level := matches[2]
msgBody := matches[3]
msgBodyLower := strings.ToLower(msgBody)
if strings.Contains(msgBodyLower, "tls handshake error") ||
strings.Contains(msgBodyLower, "connection ends") {
logger.Debug("XRAY: " + msgBody)
lw.setLastLine("")
continue
}
if strings.Contains(msgBodyLower, "failed") {
logger.Error("XRAY: " + msgBody)
} else {
switch level {
case "Debug":
logger.Debug("XRAY: " + msgBody)
case "Info":
logger.Info("XRAY: " + msgBody)
case "Warning":
logger.Warning("XRAY: " + msgBody)
case "Error":
logger.Error("XRAY: " + msgBody)
default:
logger.Debug("XRAY: " + msg)
}
}
lw.setLastLine("")
} else if msg != "" {
msgLower := strings.ToLower(msg)
if strings.Contains(msgLower, "tls handshake error") ||
strings.Contains(msgLower, "connection ends") {
logger.Debug("XRAY: " + msg)
lw.setLastLine(msg)
continue
}
if strings.Contains(msgLower, "failed") {
logger.Error("XRAY: " + msg)
} else {
logger.Debug("XRAY: " + msg)
}
lw.setLastLine(msg)
line := lw.pending[:i]
lw.pending = lw.pending[i+1:]
if lw.startCrashLocked(line + "\n" + lw.pending) {
lw.pending = ""
break
}
lw.processLineLocked(line)
}
return len(m), nil
}
// Flush handles an unterminated final line and writes the crash report, if any;
// it must run after cmd.Wait, which guarantees every output chunk was delivered.
func (lw *LogWriter) Flush() {
lw.mu.Lock()
defer lw.mu.Unlock()
if rest := lw.pending; rest != "" {
lw.pending = ""
if !lw.startCrashLocked(rest) {
lw.processLineLocked(rest)
}
}
if lw.crash.Len() == 0 {
return
}
report := lw.crash.String()
lw.crash.Reset()
logger.Debug("Core crash detected:\n", report)
if err := writeCrashReport([]byte(report)); err != nil {
logger.Error("Unable to write crash report:", err)
}
}
// startCrashLocked begins capturing a crash when output opens with a Go runtime
// crash header; the header line becomes LastLine so GetResult shows the cause.
func (lw *LogWriter) startCrashLocked(output string) bool {
if lw.crash.Len() > 0 || !crashStartRegex.MatchString(output) {
return false
}
header, _, _ := strings.Cut(output, "\n")
lw.lastLine = strings.TrimSpace(header)
lw.crash.WriteString(output)
return true
}
func (lw *LogWriter) processLineLocked(line string) {
msg := strings.TrimSpace(line)
if msg == "" {
return
}
msgLower := strings.ToLower(msg)
// Suppress noisy Windows process-kill signal that surfaces as exit status 1
if runtime.GOOS == "windows" && strings.Contains(msgLower, "exit status 1") {
return
}
matches := logLineRegex.FindStringSubmatch(msg)
if len(matches) > 3 {
level := matches[2]
msgBody := matches[3]
msgBodyLower := strings.ToLower(msgBody)
if strings.Contains(msgBodyLower, "tls handshake error") ||
strings.Contains(msgBodyLower, "connection ends") {
logger.Debug("XRAY: " + msgBody)
lw.lastLine = ""
return
}
if strings.Contains(msgBodyLower, "failed") {
logger.Error("XRAY: " + msgBody)
} else {
switch level {
case "Debug":
logger.Debug("XRAY: " + msgBody)
case "Info":
logger.Info("XRAY: " + msgBody)
case "Warning":
logger.Warning("XRAY: " + msgBody)
case "Error":
logger.Error("XRAY: " + msgBody)
default:
logger.Debug("XRAY: " + msg)
}
}
lw.lastLine = ""
return
}
if strings.Contains(msgLower, "tls handshake error") ||
strings.Contains(msgLower, "connection ends") {
logger.Debug("XRAY: " + msg)
lw.lastLine = msg
return
}
if strings.Contains(msgLower, "failed") {
logger.Error("XRAY: " + msg)
} else {
logger.Debug("XRAY: " + msg)
}
lw.lastLine = msg
}
+78 -1
View File
@@ -1,6 +1,10 @@
package xray
import (
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
)
@@ -20,7 +24,7 @@ func TestLogWriterLastLineConcurrent(t *testing.T) {
go func() {
defer wg.Done()
for range iterations {
_, _ = lw.Write([]byte("2024/01/01 00:00:00.000000 [Info] connection accepted"))
_, _ = lw.Write([]byte("2024/01/01 00:00:00.000000 [Info] connection accepted\n"))
}
}()
}
@@ -34,3 +38,76 @@ func TestLogWriterLastLineConcurrent(t *testing.T) {
}
wg.Wait()
}
func crashReportsIn(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
var contents []string
for _, e := range entries {
if strings.HasPrefix(e.Name(), crashReportPrefix) {
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatal(err)
}
contents = append(contents, string(b))
}
}
return contents
}
// The Go runtime prints "panic: " with its own write(2), so the pipe hands it
// over as a chunk of its own; the report must still hold the whole traceback.
func TestLogWriterCrashReportHoldsWholePanic(t *testing.T) {
dir := t.TempDir()
t.Setenv("XUI_LOG_FOLDER", dir)
chunks := []string{
"panic: ",
"runtime error: index out of range [3] with length 1\n",
"\ngoroutine 1 [running]:\nmain.main()\n\t/src/main.go:5 +0x1d\n",
"exit status 2",
}
lw := NewLogWriter()
for _, c := range chunks {
_, _ = lw.Write([]byte(c))
}
lw.Flush()
got := crashReportsIn(t, dir)
want := strings.Join(chunks, "")
if len(got) != 1 || got[0] != want {
t.Fatalf("crash reports = %q, want exactly [%q]", got, want)
}
if line := lw.LastLine(); line != "panic: runtime error: index out of range [3] with length 1" {
t.Fatalf("LastLine = %q", line)
}
}
func TestLogWriterOrdinaryLineMentioningPanicIsNotACrash(t *testing.T) {
dir := t.TempDir()
t.Setenv("XUI_LOG_FOLDER", dir)
lw := NewLogWriter()
_, _ = lw.Write([]byte("2026/07/12 15:16:00.000000 [Info] [42] proxy/vless: sniffed domain panic.example.com\n"))
lw.Flush()
if got := crashReportsIn(t, dir); len(got) != 0 {
t.Fatalf("ordinary log line produced crash reports %q", got)
}
}
func TestWriteCrashReportKeepsReportsFromTheSameSecond(t *testing.T) {
dir := t.TempDir()
t.Setenv("XUI_LOG_FOLDER", dir)
for _, m := range []string{"panic: first", "panic: second"} {
if err := writeCrashReport([]byte(m)); err != nil {
t.Fatal(err)
}
}
got := crashReportsIn(t, dir)
slices.Sort(got)
if !slices.Equal(got, []string{"panic: first", "panic: second"}) {
t.Fatalf("crash reports = %q, want both", got)
}
}
+31
View File
@@ -5,6 +5,7 @@ import (
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"time"
@@ -280,6 +281,33 @@ func TestWaitForCommand_CrashExitRecordsError(t *testing.T) {
}
}
// A real child panic reaches the LogWriter in several pipe chunks; the crash
// report is only whole if waitForCommand flushes the writer after cmd.Wait.
func TestWaitForCommand_PanicWritesWholeCrashReport(t *testing.T) {
dir := t.TempDir()
t.Setenv("XUI_LOG_FOLDER", dir)
cmd := exec.Command(os.Args[0], "-test.run=TestMutationAuditHelper", "--", "panic")
cmd.Env = append(os.Environ(), "XRAY_MUT_HELPER=1")
p := newProcess(nil)
cmd.Stdout = p.logWriter
cmd.Stderr = p.logWriter
if err := p.startCommand(cmd); err != nil {
t.Fatalf("startCommand: %v", err)
}
if err := p.waitForExit(5 * time.Second); err != nil {
t.Fatalf("child did not exit: %v", err)
}
reports := crashReportsIn(t, dir)
if len(reports) != 1 || !strings.HasPrefix(reports[0], "panic: xray helper panic\n\ngoroutine ") {
t.Fatalf("crash reports = %q, want one holding the whole panic traceback", reports)
}
if got := p.GetResult(); got != "panic: xray helper panic" {
t.Fatalf("GetResult = %q, want the panic header", got)
}
}
// TestStop_RemovesTempConfigFile pins process.go:579 — the
// `if p.configPath != ""` guard that removes the per-run temp config file on
// Stop (so test runs never disturb the main config.json). A mutated guard
@@ -337,5 +365,8 @@ func TestMutationAuditHelper(t *testing.T) {
os.Exit(2)
case "block":
select {}
case "panic":
go func() { panic("xray helper panic") }()
select {}
}
}
+16 -5
View File
@@ -734,6 +734,7 @@ func (p *process) waitForCommand(cmd *exec.Cmd, done chan struct{}) {
defer close(done)
err := cmd.Wait()
p.logWriter.Flush()
if err == nil || p.intentionalStop.Load() {
return
}
@@ -830,17 +831,27 @@ const (
maxCrashReports = 10
)
// writeCrashReport persists a captured xray crash chunk to the log folder
// with nanosecond-precision filename so restart-loop bursts don't overwrite
// each other, and prunes old reports to keep the folder bounded.
// writeCrashReport persists a captured xray crash to the log folder; CreateTemp's
// random suffix keeps restart-loop bursts within one second from overwriting each other.
func writeCrashReport(m []byte) error {
dir := config.GetLogFolder()
if err := os.MkdirAll(dir, 0o770); err != nil {
return err
}
pruneOldCrashReports(dir, maxCrashReports-1)
name := crashReportPrefix + time.Now().Format("20060102_150405_000000000") + crashReportSuffix
return os.WriteFile(filepath.Join(dir, name), m, 0o640)
pattern := crashReportPrefix + time.Now().Format("20060102_150405") + "_*" + crashReportSuffix
f, err := os.CreateTemp(dir, pattern)
if err != nil {
return err
}
_, err = f.Write(m)
if cerr := f.Close(); err == nil {
err = cerr
}
if err == nil {
err = os.Chmod(f.Name(), 0o640)
}
return err
}
func pruneOldCrashReports(dir string, keep int) {
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+54
View File
@@ -0,0 +1,54 @@
{
"RT_GROUP_ICON": {
"APP": {
"0000": "icon.png"
}
},
"RT_MANIFEST": {
"#1": {
"0409": {
"identity": {
"name": "3x-ui",
"version": ""
},
"description": "3X-UI web panel for Xray-core",
"minimum-os": "win7",
"execution-level": "as invoker",
"ui-access": false,
"auto-elevate": false,
"dpi-awareness": "system",
"disable-theming": false,
"disable-window-filtering": false,
"high-resolution-scrolling-aware": false,
"ultra-high-resolution-scrolling-aware": false,
"long-path-aware": false,
"printer-driver-isolation": false,
"gdi-scaling": false,
"segment-heap": false,
"use-common-controls-v6": false
}
}
},
"RT_VERSION": {
"#1": {
"0000": {
"fixed": {
"file_version": "0.0.0.0",
"product_version": "0.0.0.0"
},
"info": {
"0409": {
"CompanyName": "MHSanaei",
"FileDescription": "3X-UI web panel for Xray-core",
"FileVersion": "",
"InternalName": "3x-ui",
"LegalCopyright": "3X-UI contributors, licensed under GPL-3.0",
"OriginalFilename": "3x-ui.exe",
"ProductName": "3X-UI",
"ProductVersion": ""
}
}
}
}
}
}