Compare commits

...

4 Commits

Author SHA1 Message Date
Sanaei 4605f00a15 fix(nodes): keep the credential-presence flag on the node heartbeat push
The Nodes page cache is overwritten wholesale by the heartbeat websocket
push, but the job broadcast a raw []*model.Node while the REST list returns
[]*service.NodeView. model.Node tags the api token json:"-" and carries no
hasApiToken field, so every push stripped the flag the edit form reads to
decide whether a token is already stored.

One 5s tick after the page loaded, editing any non-mTLS node then failed
with "Name, address, port and API token are required" — and stayed failed,
because setQueryData refreshes dataUpdatedAt, so the query never goes stale
and never refetches the intact REST payload.

Broadcast the NodeView read contract instead.
2026-07-28 17:38:46 +02:00
Sanaei dc6a16019e fix(xray): reject configs xray-core refuses, and check the fixtures against it
The frontend's golden fixtures are the panel's model of an xray config, but
nothing ever asked xray-core whether it would accept them: the snapshots only
prove the Zod schemas agree with themselves. Building every fixture through the
same config builders the panel hands its config to — conf.InboundDetourConfig
for the full-config and AddInbound paths, conf.RouterConfig for
ApplyRoutingConfig, conf.DNSConfig for the dns section — found seven the core
refuses, three of them reachable from the panel's own UI. A refusal is not
scoped to one inbound: the config fails to load and every inbound stays down.

Hysteria: xray-core builds version 2 only, in both the protocol settings and
the transport settings, but the inbound settings schema accepted any version
from 1 up and its comment claimed upstream still supported v1. Both fixtures
carried version 1. The schema now pins 2, GenXrayInboundConfig heals stored
rows on the way out the way it already heals shadowsocks ciphers and wireguard
peers, and the share link drops the dead hysteria:// scheme — the subscription
server already emitted hysteria2:// for the same inbound.

XHTTP uplinkDataPlacement: both transport forms offered "query", which the core
has never accepted for that field (auto and body always, cookie and header in
packet-up mode). Replaced with auto, which was missing, and the default label
now names auto rather than body.

FinalMask items: switching an item to the rand-driven array kind wrote
packet:[] next to the rand. xray-core counts an empty array as a packet and
every item kind is exclusive, so noise answers "len(item.Packet) > 0 &&
item.Rand.To > 0" and header-custom "exactly one item kind must be set". The
editor now clears the packet, and GetXrayConfig strips the residue from rows
already saved with it.

The remaining four were stale fixtures: an xmc mask still on the usernames
shape v26.7.28 replaced with profiles, a fragment mask with no length, and
header-custom and noise items passing an array to the string packet kind — all
shapes the panel's own editors cannot produce.

golden_fixtures_xray_test.go keeps this from drifting again: every fixture in
every category is built through xray-core on each run, with a self-signed pair
standing in for the deployment certificate paths, so the next core bump reports
which fixture it broke.
2026-07-28 14:43:55 +02:00
Sanaei fea6a20f7c fix(xray): stop the runtime user API from crashing xray-core
Exercising the whole XrayAPI surface against a real xray-core 26.7.28 (the
version go.mod pins) turned up a way for ordinary panel activity to kill the
core process, plus two smaller mismatches with what the core actually does.

buildUserAccount picked the shadowsocks account type by falling through to a
2022 account whenever the cipher was not one of six hardcoded names. xray's
legacy and 2022 inbounds cast the account they are handed without checking
(proxy/shadowsocks/validator.go, proxy/shadowsocks_2022/inbound_multi.go), so
the wrong type is not an error — it panics the core and drops every connection
on the server. The fallback was reachable without any misconfiguration:
autoRenewClients hands AddUser the client object straight out of the inbound's
settings, where the cipher lives under "method", never "cipher", so every
auto-renewed client on a legacy-cipher shadowsocks inbound took xray down. The
xray-valid aead_* aliases hit it too. The cipher is now read from either key,
matched with the same table (and case-insensitivity) the core's own conf
package uses, and an unrecognized one is an error instead of a guess.

The legacy shadowsocks validator is also the only one that accepts a second
user under an email it already holds, and RemoveUser then drops just one of
them — a disabled or expired client kept connecting. AddUser now drops the
email first on that account type so a single removal fully revokes the client.

GetTraffic skipped every stat the first time it saw it. xray creates a
counter on a user's first use, so that dropped a new client's traffic for a
whole polling interval, as did the counter reset after a core restart. Only
the first poll of a process is a baseline now; later, unseen and rewound
counters both count from zero.

Also fixes three unchecked settings["method"].(string) assertions that panic
the panel on a shadowsocks inbound whose settings carry no method, and bounds
TestRoute's port so an out-of-range value cannot wrap into the uint32 the
core is asked about.

Tests: api_users_e2e_test.go drives add/remove for every protocol against a
real core and asserts it survives each one (skipped unless XRAY_E2E_BINARY is
set); the account-type, traffic-delta and renew paths get unit coverage.
2026-07-28 13:52:10 +02:00
Sanaei 7f7b7e16a4 feat(xray): update xray-core to v26.7.28 and adapt panel
Bump xtls/xray-core to 5ca6f4b7d4dc (v26.7.28) and move the three binary
pins (DockerInit.sh, the Linux and Windows URLs in release.yml) in lockstep
so the in-process conf.Build() validation and the child binary agree.

XMC finalmask (#6487) is the breaking change. The mask's `usernames` string
list is gone, replaced by a required `profiles` array whose entries each need
a 3-16 character [A-Za-z0-9_] username, a parseable UUID and both Mojang
texture fields; the "default to Dream when empty" fallback was removed, so an
xmc mask saved by an older panel now fails to build and takes the whole
config down with it rather than degrading one inbound.

The textures are a signed blob only Mojang's session server can issue, so a
legacy username cannot be upgraded automatically. The panel now:

- rejects an incomplete xmc mask at save time (AddInbound/UpdateInbound),
  pointing at the specific field that is missing;
- drops only the offending mask when generating the core config, for rows
  that never went through the form (upgrade, node sync, restored backup,
  direct DB edit), warning which inbound lost its obfuscation instead of
  leaving every inbound offline;
- carries legacy usernames into profile stubs in the finalmask form so the
  operator keeps their player names and sees exactly what still needs
  filling in, and edits profiles through a list editor.

No destructive DB migration: unlike the removed shadowsocks ciphers there is
no valid replacement to rewrite to, and dropping the mask from stored rows
would discard the operator's hostname and password for config they can still
repair. The generation-time strip already prevents the startup failure.

Also track the core's xmux maxConnections fallback, lowered from 6 to 3 for
anti-TSPU, in the fresh-XMUX seed so a new panel config matches what the core
would pick on its own.

TUN gained a `desc` key and random utunN naming, but the Go validator no
longer accepts TUN inbounds and the panel only renders legacy saved rows, so
nothing there needs adapting. The remaining commits are REALITY log-warning
wording, gRPC/XHTTP localAddr accuracy and a routing tweak, none of which
change the JSON config surface.

Tests cross-check the panel's profile predicate against conf.XMCProfile.Build()
so a future core release that tightens or relaxes the rules fails loudly
rather than silently emitting configs the core refuses to start on.
2026-07-28 13:14:06 +02:00
43 changed files with 2492 additions and 128 deletions
+2 -2
View File
@@ -124,7 +124,7 @@ jobs:
cd x-ui/bin
# Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.28/"
if [ "${{ matrix.platform }}" == "amd64" ]; then
fetch ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip
@@ -282,7 +282,7 @@ jobs:
cd x-ui\bin
# Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.28/"
Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip"
+2 -1
View File
@@ -2,7 +2,8 @@
.idea/
.vscode/
.cursor/
.claude/*
.specify/
.claude/
.cache/
.sync*
+1 -1
View File
@@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
fi
mkdir -p build/bin
cd build/bin
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/Xray-linux-${ARCH}.zip"
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.7.28/Xray-linux-${ARCH}.zip"
unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}"
@@ -82,12 +82,43 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
case 'header-custom':
return { clients: [], servers: [] };
case 'xmc':
return { hostname: '', usernames: [], password: RandomUtil.randomLowerAndNum(16) };
return { hostname: '', profiles: [defaultXmcProfile()], password: RandomUtil.randomLowerAndNum(16) };
default:
return {};
}
}
function defaultXmcProfile(): Record<string, unknown> {
return { username: '', uuid: '', texturesValue: '', texturesSignature: '' };
}
// xray-core #6487 replaced the xmc mask's `usernames` string list with
// `profiles` objects carrying a Mojang-signed session profile, and dropped the
// "default to Dream" fallback so at least one complete profile is now
// mandatory. The signature can only come from Mojang's session server, so a
// legacy username cannot be upgraded automatically — carry it into a profile
// stub instead, which keeps the operator's player names visible and leaves the
// per-field validators pointing at exactly what still has to be filled in.
export function migrateXmcSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
const out: Record<string, unknown> = { ...settings };
let changed = false;
if (!Array.isArray(out.profiles) && Array.isArray(out.usernames)) {
out.profiles = out.usernames
.filter((name): name is string => typeof name === 'string' && name.trim() !== '')
.map((name) => ({ ...defaultXmcProfile(), username: name }));
changed = true;
}
if ('usernames' in out) {
delete out.usernames;
changed = true;
}
if (!Array.isArray(out.profiles)) {
out.profiles = [];
changed = true;
}
return { next: out, changed };
}
// xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges
// with `lengths`/`delays` arrays (the singular keys remain in core only as a
// fallback). Lift any legacy singular value into a one-element array so the
@@ -171,8 +202,8 @@ function defaultUdpHop(): Record<string, unknown> {
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
const base = asPath(name);
// Migrate legacy single-range fragment masks to the per-segment arrays once
// on mount so configs saved before #6334 render in the list UI.
// Migrate legacy TCP mask shapes once on mount so configs saved before
// #6334 (fragment ranges) and #6487 (xmc profiles) render in the list UI.
const migratedRef = useRef(false);
useEffect(() => {
if (migratedRef.current) return;
@@ -183,8 +214,12 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
const next = tcp.map((mask) => {
if (!mask || typeof mask !== 'object') return mask;
const m = mask as Record<string, unknown>;
if (m.type !== 'fragment' || !m.settings || typeof m.settings !== 'object') return mask;
const { next: migrated, changed } = migrateFragmentSettings(m.settings as Record<string, unknown>);
if (m.type !== 'fragment' && m.type !== 'xmc') return mask;
if (!m.settings || typeof m.settings !== 'object') return mask;
const settings = m.settings as Record<string, unknown>;
const { next: migrated, changed } = m.type === 'fragment'
? migrateFragmentSettings(settings)
: migrateXmcSettings(settings);
if (!changed) return mask;
anyChanged = true;
return { ...m, settings: migrated };
@@ -380,13 +415,7 @@ function TcpMaskItem({
<Form.Item label="Hostname" name={[fieldName, 'settings', 'hostname']}>
<Input placeholder="Server address mimicked in the handshake" />
</Form.Item>
<Form.Item
label="Usernames"
name={[fieldName, 'settings', 'usernames']}
extra="Player names offered to probes; core defaults to Dream when empty."
>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
</Form.Item>
<XmcProfilesList tcpFieldName={fieldName} />
<Form.Item label="Password" required>
<Space.Compact block>
<Form.Item
@@ -528,6 +557,92 @@ function getDeep(obj: unknown, path: (string | number)[]): unknown {
return cur;
}
// Mojang hands the profile UUID back undashed from the session server and
// dashed from most other endpoints; xray-core parses either, so accept both
// rather than forcing the operator to reformat what they pasted.
const XMC_UUID_PATTERN = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
const XMC_USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
function validateXmcUsername(_rule: unknown, value: unknown): Promise<void> {
if (typeof value === 'string' && XMC_USERNAME_PATTERN.test(value)) return Promise.resolve();
return Promise.reject(new Error('3-16 characters, letters/digits/underscore only'));
}
function validateXmcUuid(_rule: unknown, value: unknown): Promise<void> {
if (typeof value === 'string' && XMC_UUID_PATTERN.test(value.trim())) return Promise.resolve();
return Promise.reject(new Error('Enter the profile UUID (dashed or 32 hex characters)'));
}
// Each mask needs at least one fully signed profile since xray-core #6487 —
// an empty or partial list makes the core reject the whole config, so the
// panel blocks the save here rather than letting the backend drop the mask.
function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
const { t } = useTranslation();
return (
<Form.List name={[tcpFieldName, 'settings', 'profiles']}>
{(profiles, { add, remove }) => (
<>
<Form.Item
label="Profiles"
extra="Signed Minecraft session profiles; resolve the UUID by username, then fetch the profile with unsigned=false."
>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add(defaultXmcProfile())}
/>
</Form.Item>
{profiles.map((profile, idx) => (
<div key={profile.key}>
<Divider style={{ margin: 0 }}>
Profile {idx + 1}
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(profile.name)}
onKeyDown={activateOnKey(() => remove(profile.name))}
/>
</Divider>
<Form.Item
label="Username"
name={[profile.name, 'username']}
rules={[{ validator: validateXmcUsername }]}
>
<Input placeholder="Notch" />
</Form.Item>
<Form.Item
label="UUID"
name={[profile.name, 'uuid']}
rules={[{ validator: validateXmcUuid }]}
>
<Input placeholder="069a79f4-44e9-4726-a5be-fca90e38aaf5" />
</Form.Item>
<Form.Item
label="Textures Value"
name={[profile.name, 'texturesValue']}
rules={[{ required: true, message: 'Textures value is required' }]}
>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 value from the session profile" />
</Form.Item>
<Form.Item
label="Textures Signature"
name={[profile.name, 'texturesSignature']}
rules={[{ required: true, message: 'Textures signature is required' }]}
>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 signature from the session profile" />
</Form.Item>
</div>
))}
</>
)}
</Form.List>
);
}
function HeaderCustomGroups({
tcpFieldName, form, absoluteSettingsPath,
}: {
@@ -1031,12 +1146,18 @@ function ItemEditor({
onRemove?: () => void;
}) {
const { t } = useTranslation();
/**
* Switching to `array` clears the packet instead of emptying it to `[]`:
* that branch is rand-driven, and xray-core counts even an empty array as a
* packet, rejecting an item that carries both a packet and a rand. That
* error fails the whole config, so one such item keeps every inbound offline.
*/
const onTypeChange = (v: string) => {
if (v === 'base64') {
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
} else if (v === 'array') {
form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0);
form.setFieldValue([...absoluteItemPath, 'packet'], []);
form.setFieldValue([...absoluteItemPath, 'packet'], undefined);
} else {
form.setFieldValue([...absoluteItemPath, 'packet'], '');
}
+1 -1
View File
@@ -174,7 +174,7 @@ export function createDefaultShadowsocksInboundSettings(
// constructor — the field discriminates v1 vs v2 inside the same settings
// shape. Callers that explicitly want v1 pass `{ version: 1 }`.
export interface HysteriaInboundSeed {
version?: number;
version?: 2;
}
export function createDefaultHysteriaInboundSettings(
+7 -7
View File
@@ -704,11 +704,12 @@ function hysteriaPinHex(pin: string): string {
}
}
// Hysteria share link: hysteria://<auth>@<host>:<port>?<query>#<remark>.
// The URL scheme is "hysteria2" when settings.version === 2 (hysteria v2
// AKA hysteria2), "hysteria" otherwise. Salamander obfuscation pulls its
// password from finalmask.udp[type=salamander] when present; the broader
// finalmask payload still rides under `fm` like the other links.
// Hysteria share link: hysteria2://<auth>@<host>:<port>?<query>#<remark>.
// The scheme is always hysteria2 — xray-core builds version 2 only, so the
// settings schema pins it there and the subscription server emits the same
// scheme. Salamander obfuscation pulls its password from
// finalmask.udp[type=salamander] when present; the broader finalmask payload
// still rides under `fm` like the other links.
//
// Note: legacy genHysteriaLink reads stream.tls.settings.allowInsecure,
// which isn't a field on TlsStreamSettings.Settings — the guard is always
@@ -727,8 +728,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
const stream = inbound.streamSettings;
if (!stream || stream.security !== 'tls') return '';
const settings = inbound.settings;
const scheme = settings.version === 2 ? 'hysteria2' : 'hysteria';
const scheme = 'hysteria2';
const params = new URLSearchParams();
params.set('security', 'tls');
@@ -265,11 +265,11 @@ export default function XhttpForm() {
>
<Select
options={[
{ value: '', label: 'Default (body)' },
{ value: '', label: 'Default (auto)' },
{ value: 'auto', label: 'auto' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</FormField>
@@ -240,11 +240,11 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
>
<Select
options={[
{ value: '', label: 'Default (body)' },
{ value: '', label: 'Default (auto)' },
{ value: 'auto', label: 'auto' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</FormField>
@@ -1,8 +1,9 @@
import { z } from 'zod';
// Hysteria v1 inbound (legacy — upstream xray-core kept v1 support but the
// panel defaults to v2). Each client supplies an `auth` token instead of a
// UUID/password.
// Hysteria inbound. Each client supplies an `auth` token instead of a
// UUID/password. xray-core builds version 2 only — it answers anything else
// with "version != 2" and rejects the entire config, so a legacy row is
// coerced rather than carried through.
export const HysteriaClientSchema = z.object({
auth: z.string().min(1),
email: z.string().min(1),
@@ -20,7 +21,7 @@ export const HysteriaClientSchema = z.object({
export type HysteriaClient = z.infer<typeof HysteriaClientSchema>;
export const HysteriaInboundSettingsSchema = z.object({
version: z.number().int().min(1).default(2),
version: z.preprocess(() => 2, z.literal(2)).default(2),
clients: z.array(HysteriaClientSchema).default([]),
});
export type HysteriaInboundSettings = z.infer<typeof HysteriaInboundSettingsSchema>;
@@ -31,12 +31,14 @@ export const XHttpXmuxSchema = z.object({
export type XHttpXmux = z.infer<typeof XHttpXmuxSchema>;
// Seed for freshly enabling XMUX on a config that had no xmux block:
// mirrors xray-core v26.6.27's own anti-RKN maxConnections=6 fallback
// rather than the concurrency strategy.
// mirrors xray-core's own maxConnections fallback rather than the
// concurrency strategy. v26.7.28 lowered that fallback from 6 to 3 for
// anti-TSPU, so track it here to keep a fresh panel config matching what
// the core would have picked on its own.
export const XMUX_FRESH_DEFAULTS: XHttpXmux = {
...XHttpXmuxSchema.parse({}),
maxConcurrency: '',
maxConnections: 6,
maxConnections: 3,
};
// Predefined sessionIDTable names xray-core accepts as a shorthand for a
@@ -14,6 +14,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses combined byte-stably 1`
"tcp": [
{
"settings": {
"length": "10-20",
"packets": "1-3",
},
"type": "fragment",
@@ -145,9 +146,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
[
{
"delay": 0,
"packet": [
"GET / HTTP/1.1",
],
"packet": "GET / HTTP/1.1",
"type": "str",
},
],
@@ -157,9 +156,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
[
{
"delay": 0,
"packet": [
"HTTP/1.1 200 OK",
],
"packet": "HTTP/1.1 200 OK",
"type": "str",
},
],
@@ -171,8 +168,13 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
"settings": {
"hostname": "mc.example.com",
"password": "s3cr3t",
"usernames": [
"Dream",
"profiles": [
{
"texturesSignature": "Zm9yLWZpeHR1cmUtdXNlLW9ubHktbm90LWEtcmVhbC1tb2phbmctc2lnbmF0dXJl",
"texturesValue": "eyJ0aW1lc3RhbXAiOjE3MDAwMDAwMDAwMDAsInByb2ZpbGVJZCI6ImVjNzBiY2FmNzAyZjRiYjhiNDhkMjc2ZmE1MmE3ODBjIn0=",
"username": "Dream",
"uuid": "ec70bcaf-702f-4bb8-b48d-276fa52a780c",
},
],
},
"type": "xmc",
@@ -219,13 +221,11 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
{
"delay": "10-16",
"rand": "10-20",
"type": "rand",
"type": "array",
},
{
"delay": "5",
"packet": [
"ping",
],
"packet": "ping",
"type": "str",
},
],
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`] = `
exports[`InboundSchema (full) fixtures > parses hysteria-tls byte-stably 1`] = `
{
"down": 0,
"enable": true,
@@ -9,7 +9,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
"listen": "",
"port": 36715,
"protocol": "hysteria",
"remark": "gina-hysteria-v1",
"remark": "gina-hysteria",
"settings": {
"clients": [
{
@@ -25,7 +25,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
"totalGB": 0,
},
],
"version": 1,
"version": 2,
},
"shareAddr": "",
"shareAddrStrategy": "node",
@@ -78,7 +78,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
},
},
},
"tag": "inbound-hysteria-v1",
"tag": "inbound-hysteria",
"total": 0,
"up": 0,
}
@@ -1,8 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`genHysteriaLink > hysteria-v1-tls: byte-stable 1`] = `"hysteria://hyst-v1-auth-XYZ@example.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
exports[`genHysteriaLink > hysteria-tls: byte-stable 1`] = `"hysteria2://hyst-v1-auth-XYZ@example.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
exports[`genInboundLinks orchestrator > hysteria-v1-tls: byte-stable 1`] = `"hysteria://hyst-v1-auth-XYZ@override.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
exports[`genInboundLinks orchestrator > hysteria-tls: byte-stable 1`] = `"hysteria2://hyst-v1-auth-XYZ@override.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://2022-blake3-aes-256-gcm:ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ%3D%3D:dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ%3D%3D@override.test:8388?type=tcp#parity-test"`;
@@ -37,7 +37,7 @@ exports[`InboundSettingsSchema fixtures > parses hysteria-basic byte-stably 1`]
"totalGB": 0,
},
],
"version": 1,
"version": 2,
},
}
`;
@@ -100,7 +100,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-padding byte-stably
"xPaddingBytes": "500-1500",
"xPaddingHeader": "X-Pad",
"xPaddingKey": "secret-key",
"xPaddingMethod": "random",
"xPaddingMethod": "tokenish",
"xPaddingObfsMode": true,
"xPaddingPlacement": "header",
},
@@ -114,7 +114,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stab
"enableXmux": false,
"headers": {},
"host": "edge.example.test",
"mode": "auto",
"mode": "packet-up",
"noGRPCHeader": false,
"noSSEHeader": false,
"path": "/sp",
@@ -131,7 +131,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stab
"sessionIDTable": "",
"uplinkChunkSize": 0,
"uplinkDataKey": "u",
"uplinkDataPlacement": "query",
"uplinkDataPlacement": "cookie",
"uplinkHTTPMethod": "",
"xPaddingBytes": "100-1000",
"xPaddingHeader": "",
@@ -184,7 +184,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-tuning byte-stably
"hMaxRequestTimes": "600-900",
"hMaxReusableSecs": "1800-3000",
"maxConcurrency": "16-32",
"maxConnections": 4,
"maxConnections": 0,
},
},
}
+45 -1
View File
@@ -1,7 +1,7 @@
/// <reference types="vite/client" />
import { describe, expect, it } from 'vitest';
import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
import { migrateXmcSettings, parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
import { FinalMaskStreamSettingsSchema } from '@/schemas/protocols/stream';
const fixtures = import.meta.glob<unknown>(
@@ -26,6 +26,50 @@ describe('FinalMaskStreamSettingsSchema fixtures', () => {
}
});
describe('migrateXmcSettings', () => {
it('carries legacy usernames into profile stubs and drops the dead key', () => {
const { next, changed } = migrateXmcSettings({
hostname: 'mc.example.com',
usernames: ['Dream', 'Notch'],
password: 'pw',
});
expect(changed).toBe(true);
expect(next.usernames).toBeUndefined();
expect(next.hostname).toBe('mc.example.com');
expect(next.password).toBe('pw');
expect(next.profiles).toEqual([
{ username: 'Dream', uuid: '', texturesValue: '', texturesSignature: '' },
{ username: 'Notch', uuid: '', texturesValue: '', texturesSignature: '' },
]);
});
it('gives a mask with neither key an empty profiles list', () => {
const { next, changed } = migrateXmcSettings({ hostname: '', password: 'pw' });
expect(changed).toBe(true);
expect(next.profiles).toEqual([]);
});
it('leaves an already migrated mask untouched', () => {
const profiles = [
{ username: 'Notch', uuid: '069a79f4-44e9-4726-a5be-fca90e38aaf5', texturesValue: 'dmFsdWU=', texturesSignature: 'c2ln' },
];
const { next, changed } = migrateXmcSettings({ hostname: '', password: 'pw', profiles });
expect(changed).toBe(false);
expect(next.profiles).toEqual(profiles);
});
it('discards blank legacy usernames rather than seeding unfixable stubs', () => {
const { next } = migrateXmcSettings({ usernames: ['Dream', '', ' '], password: 'pw' });
expect(next.profiles).toEqual([
{ username: 'Dream', uuid: '', texturesValue: '', texturesSignature: '' },
]);
});
});
describe('parseGeckoPacketSize', () => {
it('accepts positive ordered packet size ranges', () => {
expect(parseGeckoPacketSize('512-1200')).toEqual({ min: 512, max: 1200 });
@@ -1,15 +1,35 @@
{
"tcp": [
{ "type": "fragment", "settings": { "packets": "1-3" } }
{
"type": "fragment",
"settings": {
"packets": "1-3",
"length": "10-20"
}
}
],
"udp": [
{ "type": "salamander", "settings": { "password": "swordfish" } },
{ "type": "mkcp-legacy", "settings": { "header": "wireguard", "value": "" } }
{
"type": "salamander",
"settings": {
"password": "swordfish"
}
},
{
"type": "mkcp-legacy",
"settings": {
"header": "wireguard",
"value": ""
}
}
],
"quicParams": {
"congestion": "brutal",
"brutalUp": "100 mbps",
"brutalDown": "200 mbps",
"udpHop": { "ports": "10000-20000", "interval": "5-10" }
"udpHop": {
"ports": "10000-20000",
"interval": "5-10"
}
}
}
@@ -9,18 +9,28 @@
"maxSplit": "0"
}
},
{ "type": "sudoku" },
{
"type": "sudoku"
},
{
"type": "header-custom",
"settings": {
"clients": [
[
{ "type": "str", "packet": ["GET / HTTP/1.1"], "delay": 0 }
{
"type": "str",
"packet": "GET / HTTP/1.1",
"delay": 0
}
]
],
"servers": [
[
{ "type": "str", "packet": ["HTTP/1.1 200 OK"], "delay": 0 }
{
"type": "str",
"packet": "HTTP/1.1 200 OK",
"delay": 0
}
]
],
"errors": []
@@ -30,8 +40,15 @@
"type": "xmc",
"settings": {
"hostname": "mc.example.com",
"usernames": ["Dream"],
"password": "s3cr3t"
"password": "s3cr3t",
"profiles": [
{
"username": "Dream",
"uuid": "ec70bcaf-702f-4bb8-b48d-276fa52a780c",
"texturesValue": "eyJ0aW1lc3RhbXAiOjE3MDAwMDAwMDAwMDAsInByb2ZpbGVJZCI6ImVjNzBiY2FmNzAyZjRiYjhiNDhkMjc2ZmE1MmE3ODBjIn0=",
"texturesSignature": "Zm9yLWZpeHR1cmUtdXNlLW9ubHktbm90LWEtcmVhbC1tb2phbmctc2lnbmF0dXJl"
}
]
}
}
]
@@ -1,35 +1,77 @@
{
"udp": [
{ "type": "salamander", "settings": { "password": "swordfish" } },
{ "type": "mkcp-legacy", "settings": { "header": "", "value": "abcdef0123456789" } },
{ "type": "mkcp-legacy", "settings": { "header": "dns", "value": "cloudflare.com" } },
{ "type": "mkcp-legacy", "settings": { "header": "wireguard", "value": "" } },
{
"type": "salamander",
"settings": {
"password": "swordfish"
}
},
{
"type": "mkcp-legacy",
"settings": {
"header": "",
"value": "abcdef0123456789"
}
},
{
"type": "mkcp-legacy",
"settings": {
"header": "dns",
"value": "cloudflare.com"
}
},
{
"type": "mkcp-legacy",
"settings": {
"header": "wireguard",
"value": ""
}
},
{
"type": "noise",
"settings": {
"reset": "60",
"noise": [
{ "type": "rand", "rand": "10-20", "delay": "10-16" },
{ "type": "str", "packet": ["ping"], "delay": "5" }
{
"type": "array",
"rand": "10-20",
"delay": "10-16"
},
{
"type": "str",
"packet": "ping",
"delay": "5"
}
]
}
},
{
"type": "xdns",
"settings": {
"domains": ["example.com:txt", "example.org:a"],
"resolvers": ["example.com:txt+udp://1.1.1.1:53"]
"domains": [
"example.com:txt",
"example.org:a"
],
"resolvers": [
"example.com:txt+udp://1.1.1.1:53"
]
}
},
{
"type": "xicmp",
"settings": { "dgram": false, "ips": [] }
"settings": {
"dgram": false,
"ips": []
}
},
{
"type": "realm",
"settings": {
"url": "realm://public@example.com/my-realm",
"stunServers": ["stun.l.google.com:19302", "global.stun.twilio.com:3478"]
"stunServers": [
"stun.l.google.com:19302",
"global.stun.twilio.com:3478"
]
}
}
]
@@ -3,15 +3,20 @@
"up": 0,
"down": 0,
"total": 0,
"remark": "gina-hysteria-v1",
"remark": "gina-hysteria",
"enable": true,
"expiryTime": 0,
"listen": "",
"port": 36715,
"tag": "inbound-hysteria-v1",
"tag": "inbound-hysteria",
"sniffing": {
"enabled": false,
"destOverride": ["http", "tls", "quic", "fakedns"],
"destOverride": [
"http",
"tls",
"quic",
"fakedns"
],
"metadataOnly": false,
"routeOnly": false,
"ipsExcluded": [],
@@ -19,7 +24,7 @@
},
"protocol": "hysteria",
"settings": {
"version": 1,
"version": 2,
"clients": [
{
"auth": "hyst-v1-auth-XYZ",
@@ -56,7 +61,9 @@
"buildChain": false
}
],
"alpn": ["h3"],
"alpn": [
"h3"
],
"echServerKeys": "",
"settings": {
"fingerprint": "chrome",
@@ -1,7 +1,7 @@
{
"protocol": "hysteria",
"settings": {
"version": 1,
"version": 2,
"clients": [
{
"auth": "hyst3ria-v1-token-XYZ",
@@ -9,6 +9,6 @@
"xPaddingKey": "secret-key",
"xPaddingHeader": "X-Pad",
"xPaddingPlacement": "header",
"xPaddingMethod": "random"
"xPaddingMethod": "tokenish"
}
}
@@ -3,12 +3,12 @@
"xhttpSettings": {
"path": "/sp",
"host": "edge.example.test",
"mode": "auto",
"mode": "packet-up",
"sessionIDPlacement": "header",
"sessionIDKey": "X-Session",
"seqPlacement": "cookie",
"seqKey": "X-Seq",
"uplinkDataPlacement": "query",
"uplinkDataPlacement": "cookie",
"uplinkDataKey": "u"
}
}
@@ -19,7 +19,6 @@
},
"xmux": {
"maxConcurrency": "16-32",
"maxConnections": 4,
"cMaxReuseTimes": 0,
"hMaxRequestTimes": "600-900",
"hMaxReusableSecs": "1800-3000",
@@ -158,8 +158,8 @@ describe('normalizeXhttpForWire stream-one', () => {
expect(XHttpXmuxSchema.parse({}).maxConcurrency).toBe('16-32');
});
it('XMUX_FRESH_DEFAULTS seeds the anti-RKN maxConnections=6 without a competing maxConcurrency', () => {
expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(6);
it('XMUX_FRESH_DEFAULTS seeds the core maxConnections fallback without a competing maxConcurrency', () => {
expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(3);
expect(XMUX_FRESH_DEFAULTS.maxConcurrency).toBe('');
const out = normalizeXhttpForWire({
@@ -170,7 +170,7 @@ describe('normalizeXhttpForWire stream-one', () => {
}, 'outbound');
const xmux = out.xmux as Record<string, unknown>;
expect(xmux.maxConnections).toBe(6);
expect(xmux.maxConnections).toBe(3);
expect(xmux.maxConcurrency).toBe('');
});
});
+1 -1
View File
@@ -21,7 +21,7 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/valyala/fasthttp v1.72.0
github.com/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc
go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.54.0
golang.org/x/sys v0.47.0
+2 -2
View File
@@ -218,8 +218,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c h1:SbB1ez0bqZllbzaVj0PC+Vje3dRA8m/7jW1ussjDSgM=
github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c/go.mod h1:Jts8yHqPCpvsdL5CW5xMd8H9d2fkg1cILeBNqEwRXNw=
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc h1:fkOkmgHWbF2Q8MdV9VxrsyxRz4OndcrUXUkh1ANBTg0=
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc/go.mod h1:wukQoBGnQ6GaLTGuKwv8rCTgf80QxPj+6iznDZHQEWo=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -0,0 +1,151 @@
package model
import (
"encoding/json"
"strings"
"testing"
)
func settingsVersion(t *testing.T, settings string) any {
t.Helper()
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
return parsed["version"]
}
func TestHealHysteriaVersion(t *testing.T) {
tests := []struct {
name string
settings string
wantChanged bool
wantVersion any
}{
{
name: "legacy v1 row",
settings: `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`,
wantChanged: true,
wantVersion: float64(2),
},
{
name: "no version at all",
settings: `{"clients":[{"auth":"tok","email":"a@x"}]}`,
wantChanged: true,
wantVersion: float64(2),
},
{
name: "already v2",
settings: `{"version":2,"clients":[]}`,
wantChanged: false,
wantVersion: float64(2),
},
{
name: "version as a string",
settings: `{"version":"1","clients":[]}`,
wantChanged: true,
wantVersion: float64(2),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
healed, changed := HealHysteriaVersion(tt.settings)
if changed != tt.wantChanged {
t.Fatalf("changed = %v, want %v", changed, tt.wantChanged)
}
if got := settingsVersion(t, healed); got != tt.wantVersion {
t.Fatalf("version = %#v, want %#v", got, tt.wantVersion)
}
})
}
}
func TestHealHysteriaVersionKeepsClients(t *testing.T) {
healed, changed := HealHysteriaVersion(`{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`)
if !changed {
t.Fatal("a v1 row must be healed")
}
if !strings.Contains(healed, `"auth": "tok"`) || !strings.Contains(healed, `"email": "a@x"`) {
t.Fatalf("healing dropped client data: %s", healed)
}
}
func TestHealHysteriaVersionLeavesUnparsableSettings(t *testing.T) {
const broken = `{"version":1,`
healed, changed := HealHysteriaVersion(broken)
if changed || healed != broken {
t.Fatalf("unparsable settings must be left alone, got changed=%v %q", changed, healed)
}
if healed, changed := HealHysteriaVersion(""); changed || healed != "" {
t.Fatalf("empty settings must be left alone, got changed=%v %q", changed, healed)
}
}
func TestHealHysteriaStreamVersion(t *testing.T) {
healed, changed := HealHysteriaStreamVersion(`{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`)
if !changed {
t.Fatal("a v1 transport must be healed")
}
var parsed map[string]any
if err := json.Unmarshal([]byte(healed), &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
hysteria, _ := parsed["hysteriaSettings"].(map[string]any)
if hysteria["version"] != float64(2) {
t.Fatalf("version = %#v, want 2", hysteria["version"])
}
if hysteria["udpIdleTimeout"] != float64(60) {
t.Fatalf("healing dropped transport settings: %#v", hysteria)
}
}
func TestHealHysteriaStreamVersionWithoutHysteriaSettings(t *testing.T) {
const stream = `{"network":"tcp","tcpSettings":{}}`
healed, changed := HealHysteriaStreamVersion(stream)
if changed || healed != stream {
t.Fatalf("a stream without hysteriaSettings must be left alone, got changed=%v %q", changed, healed)
}
}
// TestGenXrayInboundConfigHealsHysteriaVersion is the regression for a stored
// v1 row: xray-core answers "version != 2" and rejects the whole config, so
// every other inbound on the server stays offline until the row is fixed.
func TestGenXrayInboundConfigHealsHysteriaVersion(t *testing.T) {
in := Inbound{
Protocol: Hysteria,
Port: 36715,
Listen: "127.0.0.1",
Tag: "in-hysteria",
Settings: `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`,
StreamSettings: `{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`,
}
cfg := in.GenXrayInboundConfig()
if got := settingsVersion(t, string(cfg.Settings)); got != float64(2) {
t.Fatalf("generated settings.version = %#v, want 2", got)
}
var stream map[string]any
if err := json.Unmarshal(cfg.StreamSettings, &stream); err != nil {
t.Fatalf("unmarshal generated streamSettings: %v", err)
}
hysteria, _ := stream["hysteriaSettings"].(map[string]any)
if hysteria["version"] != float64(2) {
t.Fatalf("generated hysteriaSettings.version = %#v, want 2", hysteria["version"])
}
if !strings.Contains(in.Settings, `"version":1`) {
t.Fatal("the stored row must keep its own value; only the generated config is healed")
}
}
func TestGenXrayInboundConfigLeavesOtherProtocolsAlone(t *testing.T) {
in := Inbound{
Protocol: VLESS,
Port: 443,
Tag: "in-vless",
Settings: `{"clients":[],"decryption":"none"}`,
}
if got := settingsVersion(t, string(in.GenXrayInboundConfig().Settings)); got != nil {
t.Fatalf("a non-hysteria inbound must not gain a version key, got %#v", got)
}
}
+60
View File
@@ -229,6 +229,57 @@ func jsonStringFieldFromRaw(r json.RawMessage) string {
return string(trimmed)
}
// hysteriaConfigVersion is the only hysteria version xray-core builds. Both
// the protocol settings and the transport settings answer anything else with
// "version != 2", and that error rejects the whole config — every other
// inbound on the server goes down with it, not just the hysteria one.
const hysteriaConfigVersion = 2
// HealHysteriaVersion pins a hysteria inbound's settings.version to the
// version xray-core accepts. Rows written before the panel settled on v2, or
// through the API and the raw JSON editor, can still carry the legacy 1 or no
// version at all, either of which stops the core from starting.
func HealHysteriaVersion(settings string) (string, bool) {
return healVersionField(settings, nil)
}
// HealHysteriaStreamVersion does the same for the transport half,
// streamSettings.hysteriaSettings.version, which xray-core validates
// separately. An absent hysteriaSettings object is left alone.
func HealHysteriaStreamVersion(streamSettings string) (string, bool) {
return healVersionField(streamSettings, []string{"hysteriaSettings"})
}
// healVersionField rewrites the "version" key of the object reached by path to
// hysteriaConfigVersion, reporting whether anything changed. A path that does
// not resolve to an object leaves the input untouched.
func healVersionField(raw string, path []string) (string, bool) {
if raw == "" {
return raw, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return raw, false
}
target := parsed
for _, key := range path {
next, ok := target[key].(map[string]any)
if !ok {
return raw, false
}
target = next
}
if version, ok := target["version"].(float64); ok && version == hysteriaConfigVersion {
return raw, false
}
target["version"] = hysteriaConfigVersion
out, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return raw, false
}
return string(out), true
}
// StripInboundXhttpClientFields removes xHTTP knobs that belong on the
// client dialer and subscription share-link extras only. xray-core's XHTTP
// inbound listener does not consume them; the panel still stores them on
@@ -298,11 +349,20 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
if converted, ok := WireguardClientsToPeers(settings); ok {
settings = converted
}
case Hysteria:
if healed, ok := HealHysteriaVersion(settings); ok {
settings = healed
}
}
streamSettings := i.StreamSettings
if stripped, ok := StripInboundXhttpClientFields(streamSettings); ok {
streamSettings = stripped
}
if i.Protocol == Hysteria {
if healed, ok := HealHysteriaStreamVersion(streamSettings); ok {
streamSettings = healed
}
}
return &xray.InboundConfig{
Listen: json_util.RawMessage(listen),
Port: i.Port,
+1 -1
View File
@@ -63,7 +63,7 @@ func (j *NodeHeartbeatJob) Run() {
if !websocket.HasClients() {
return
}
updated, err := j.nodeService.GetNodeTree()
updated, err := j.nodeService.GetNodeTreeView()
if err != nil {
logger.Warning("node heartbeat: load nodes for broadcast failed:", err)
return
+2 -2
View File
@@ -476,7 +476,7 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
}
cipher := ""
if oldInbound.Protocol == "shadowsocks" {
cipher = oldSettings["method"].(string)
cipher, _ = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
"email": client.Email,
@@ -858,7 +858,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
if clients[0].Enable {
cipher := ""
if oldInbound.Protocol == "shadowsocks" {
cipher = oldSettings["method"].(string)
cipher, _ = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
"email": clients[0].Email,
@@ -0,0 +1,160 @@
package service
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func streamWithNoiseItem(t *testing.T, item map[string]any) map[string]any {
t.Helper()
return map[string]any{
"network": "tcp",
"finalmask": map[string]any{
"udp": []any{map[string]any{
"type": "noise",
"settings": map[string]any{
"reset": "60",
"noise": []any{item},
},
}},
},
}
}
func noiseItem(t *testing.T, stream map[string]any) map[string]any {
t.Helper()
finalmask, _ := stream["finalmask"].(map[string]any)
udp, _ := finalmask["udp"].([]any)
mask, _ := udp[0].(map[string]any)
settings, _ := mask["settings"].(map[string]any)
noise, _ := settings["noise"].([]any)
item, _ := noise[0].(map[string]any)
return item
}
// TestDropEmptyRandPacketsClearsEditorResidue is the regression for the mask
// editor writing packet:[] alongside a rand. xray-core counts the empty array
// as a packet and refuses the config, which keeps every inbound offline.
func TestDropEmptyRandPacketsClearsEditorResidue(t *testing.T) {
stream := streamWithNoiseItem(t, map[string]any{
"type": "array",
"rand": "1-8192",
"packet": []any{},
"delay": "5",
})
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 1 {
t.Fatalf("cleared = %d, want 1", cleared)
}
item := noiseItem(t, stream)
if _, present := item["packet"]; present {
t.Fatalf("packet survived: %#v", item)
}
if item["rand"] != "1-8192" || item["delay"] != "5" {
t.Fatalf("healing changed the mask: %#v", item)
}
}
func TestDropEmptyRandPacketsLeavesRealPacketsAlone(t *testing.T) {
tests := []struct {
name string
item map[string]any
}{
{"packet without a rand", map[string]any{"type": "array", "packet": []any{1.0, 2.0}, "rand": 0.0}},
{"empty packet without a rand", map[string]any{"type": "array", "packet": []any{}}},
{"empty packet with a zero rand", map[string]any{"type": "array", "packet": []any{}, "rand": 0.0}},
{"empty packet with a zero range", map[string]any{"type": "array", "packet": []any{}, "rand": "0-0"}},
{"string packet", map[string]any{"type": "str", "packet": "ping", "rand": "1-10"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stream := streamWithNoiseItem(t, tt.item)
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 0 {
t.Fatalf("cleared = %d, want the item left alone", cleared)
}
if _, present := noiseItem(t, stream)["packet"]; !present {
t.Fatal("packet was dropped")
}
})
}
}
// TestDropEmptyRandPacketsReachesNestedItems covers header-custom, whose items
// sit two arrays deep and are subject to the same exclusive-kind rule.
func TestDropEmptyRandPacketsReachesNestedItems(t *testing.T) {
stream := map[string]any{
"finalmask": map[string]any{
"tcp": []any{map[string]any{
"type": "header-custom",
"settings": map[string]any{
"clients": []any{[]any{map[string]any{"type": "array", "rand": 64.0, "packet": []any{}}}},
"servers": []any{[]any{map[string]any{"type": "array", "rand": 32.0, "packet": []any{}}}},
},
}},
},
}
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 2 {
t.Fatalf("cleared = %d, want 2", cleared)
}
}
func TestDropEmptyRandPacketsIgnoresMissingFinalMask(t *testing.T) {
stream := map[string]any{"network": "tcp"}
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 0 {
t.Fatalf("cleared = %d, want 0", cleared)
}
}
// TestHealedConfigsBuildInXray closes the loop on both heals: the rows xray
// refuses outright must build once the panel has healed them.
func TestHealedConfigsBuildInXray(t *testing.T) {
t.Run("hysteria v1 row", func(t *testing.T) {
in := model.Inbound{
Protocol: model.Hysteria,
Port: 36715,
Listen: "127.0.0.1",
Tag: "in-hysteria",
Settings: `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`,
StreamSettings: `{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`,
}
raw, err := json.Marshal(in.GenXrayInboundConfig())
if err != nil {
t.Fatalf("marshal generated inbound: %v", err)
}
var healed map[string]any
if err := json.Unmarshal(raw, &healed); err != nil {
t.Fatalf("decode generated inbound: %v", err)
}
assertXrayAccepts(t, "the healed hysteria inbound", buildGoldenInbound(t, healed))
var unhealed map[string]any
if err := json.Unmarshal([]byte(`{
"tag":"in-hysteria","listen":"127.0.0.1","port":36715,"protocol":"hysteria",
"settings":`+in.Settings+`,"streamSettings":`+in.StreamSettings+`}`), &unhealed); err != nil {
t.Fatalf("decode raw inbound: %v", err)
}
if err := buildGoldenInbound(t, unhealed); err == nil {
t.Fatal("the unhealed v1 row is expected to be refused; the heal is what makes it buildable")
}
})
t.Run("noise item with an empty packet", func(t *testing.T) {
item := map[string]any{"type": "array", "rand": "1-8192", "packet": []any{}, "delay": "5"}
stream := streamWithNoiseItem(t, item)
inbound := map[string]any{
"tag": "in-vless", "listen": "127.0.0.1", "port": 8443, "protocol": "vless",
"settings": map[string]any{"clients": []any{}, "decryption": "none"},
"streamSettings": stream,
}
if err := buildGoldenInbound(t, inbound); err == nil {
t.Fatal("xray-core is expected to refuse a packet and a rand on one item")
}
dropEmptyRandPackets(stream["finalmask"])
inbound["streamSettings"] = stream
assertXrayAccepts(t, "the healed noise mask", buildGoldenInbound(t, inbound))
})
}
@@ -0,0 +1,351 @@
package service
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/xtls/xray-core/infra/conf"
)
// The frontend's golden fixtures are the panel's model of an xray config: the
// Zod snapshots pin what the forms parse and emit. Parsing proves the panel
// agrees with itself, not that xray-core would accept the result, so every
// fixture is also built here through the very config builders the panel hands
// its config to — conf.InboundDetourConfig for the full-config and AddInbound
// paths, conf.RouterConfig for ApplyRoutingConfig, conf.DNSConfig for the dns
// section. A fixture xray-core refuses is a config the panel would let an
// admin save and then fail to start the core with, taking every inbound down.
//
// mtproto is excluded: it is served by the bundled mtg-multi sidecar, not by
// xray, so xray-core has no config id for it.
func goldenFixtureDir(t *testing.T, category string) string {
t.Helper()
dir, err := filepath.Abs(filepath.Join("..", "..", "..", "frontend", "src", "test", "golden", "fixtures", category))
if err != nil {
t.Fatalf("resolve fixture dir: %v", err)
}
return dir
}
// goldenFixtures returns every fixture in a category as name -> decoded object.
func goldenFixtures(t *testing.T, category string) map[string]map[string]any {
t.Helper()
dir := goldenFixtureDir(t, category)
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read %s: %v", dir, err)
}
out := make(map[string]map[string]any)
names := make([]string, 0, len(entries))
for _, entry := range entries {
if filepath.Ext(entry.Name()) != ".json" {
continue
}
names = append(names, entry.Name())
}
sort.Strings(names)
if len(names) == 0 {
t.Fatalf("no fixtures under %s", dir)
}
for _, name := range names {
raw, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
t.Fatalf("unmarshal %s: %v", name, err)
}
out[strings.TrimSuffix(name, ".json")] = obj
}
return out
}
// writeTestCertificate writes a self-signed certificate and key, returning both
// paths. The TLS fixtures point certificateFile/keyFile at deployment paths
// that do not exist here, and xray-core reads them while building.
func writeTestCertificate(t *testing.T) (certPath, keyPath string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "golden-fixture.test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
DNSNames: []string{"golden-fixture.test"},
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
dir := t.TempDir()
certPath = filepath.Join(dir, "fixture.crt")
keyPath = filepath.Join(dir, "fixture.key")
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
t.Fatalf("write certificate: %v", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
return certPath, keyPath
}
// repointCertificateFiles rewrites every certificateFile/keyFile reference to
// the generated pair, so a fixture is judged on its shape rather than on paths
// that only exist on a deployed server.
func repointCertificateFiles(node any, certPath, keyPath string) {
switch value := node.(type) {
case map[string]any:
for key, child := range value {
switch key {
case "certificateFile":
if _, ok := child.(string); ok {
value[key] = certPath
continue
}
case "keyFile":
if _, ok := child.(string); ok {
value[key] = keyPath
continue
}
}
repointCertificateFiles(child, certPath, keyPath)
}
case []any:
for _, child := range value {
repointCertificateFiles(child, certPath, keyPath)
}
}
}
// assertXrayAccepts fails unless xray-core built the fixture. Fixtures naming
// geoip:/geosite: need the dat files the panel ships next to the xray binary;
// where they are absent the loader fails on the missing file rather than on the
// fixture, so those are skipped instead of reported as broken.
func assertXrayAccepts(t *testing.T, subject string, err error) {
t.Helper()
if err == nil {
return
}
if isMissingGeoAssetErr(err) {
t.Skipf("geo data files not available, cannot judge %s: %v", subject, err)
}
t.Fatalf("xray-core refuses %s: %v", subject, err)
}
func buildGoldenInbound(t *testing.T, inbound map[string]any) error {
t.Helper()
certPath, keyPath := writeTestCertificate(t)
repointCertificateFiles(inbound, certPath, keyPath)
raw, err := json.Marshal(inbound)
if err != nil {
t.Fatalf("marshal inbound: %v", err)
}
detour := new(conf.InboundDetourConfig)
if err := json.Unmarshal(raw, detour); err != nil {
return err
}
_, err = detour.Build()
return err
}
// TestGoldenInboundFixturesBuildInXray wraps each protocol fixture in a minimal
// inbound and builds it.
func TestGoldenInboundFixturesBuildInXray(t *testing.T) {
for name, fixture := range goldenFixtures(t, "inbound") {
if protocol, _ := fixture["protocol"].(string); protocol == string(model.MTProto) {
continue
}
t.Run(name, func(t *testing.T) {
inbound := map[string]any{
"tag": "golden-in",
"listen": "127.0.0.1",
"port": 8443,
"protocol": fixture["protocol"],
"settings": fixture["settings"],
}
assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound))
})
}
}
// TestGoldenInboundFullFixturesBuildInXray runs the complete panel Inbound
// model through GenXrayInboundConfig, the conversion both the full-config and
// the live AddInbound paths use, and builds the result.
func TestGoldenInboundFullFixturesBuildInXray(t *testing.T) {
for name, fixture := range goldenFixtures(t, "inbound-full") {
protocol, _ := fixture["protocol"].(string)
if protocol == string(model.MTProto) {
continue
}
t.Run(name, func(t *testing.T) {
section := func(key string) string {
value, ok := fixture[key]
if !ok || value == nil {
return ""
}
raw, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal %s: %v", key, err)
}
return string(raw)
}
port := 0
if p, ok := fixture["port"].(float64); ok {
port = int(p)
}
listen, _ := fixture["listen"].(string)
tag, _ := fixture["tag"].(string)
ib := &model.Inbound{
Protocol: model.Protocol(protocol),
Port: port,
Listen: listen,
Tag: tag,
Settings: section("settings"),
StreamSettings: section("streamSettings"),
Sniffing: section("sniffing"),
}
raw, err := json.Marshal(ib.GenXrayInboundConfig())
if err != nil {
t.Fatalf("marshal generated inbound: %v", err)
}
var generated map[string]any
if err := json.Unmarshal(raw, &generated); err != nil {
t.Fatalf("decode generated inbound: %v", err)
}
assertXrayAccepts(t, "the generated inbound", buildGoldenInbound(t, generated))
})
}
}
// TestGoldenStreamFixturesBuildInXray attaches the transport fragments — whole
// stream sections, the security block, sockopt and finalmask — to an inbound.
func TestGoldenStreamFixturesBuildInXray(t *testing.T) {
for _, category := range []string{"stream", "security", "sockopt", "finalmask"} {
for name, fixture := range goldenFixtures(t, category) {
t.Run(category+"/"+name, func(t *testing.T) {
stream := map[string]any{"network": "tcp"}
switch category {
case "stream":
stream = fixture
case "security":
for key, value := range fixture {
stream[key] = value
}
case "sockopt":
stream["sockopt"] = fixture
case "finalmask":
stream["finalmask"] = fixture
}
inbound := map[string]any{
"tag": "golden-in", "listen": "127.0.0.1", "port": 8443, "protocol": "vless",
"settings": map[string]any{
"clients": []any{map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "golden"}},
"decryption": "none",
},
"streamSettings": stream,
}
assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound))
})
}
}
}
func buildGoldenRouting(routing map[string]any) error {
raw, err := json.Marshal(routing)
if err != nil {
return err
}
router := new(conf.RouterConfig)
if err := json.Unmarshal(raw, router); err != nil {
return err
}
_, err = router.Build()
return err
}
// TestGoldenRoutingFixturesBuildInXray builds the rule and balancer fixtures
// through the router config ApplyRoutingConfig hands to the running core.
func TestGoldenRoutingFixturesBuildInXray(t *testing.T) {
for name, rule := range goldenFixtures(t, "rule") {
t.Run("rule/"+name, func(t *testing.T) {
assertXrayAccepts(t, "this rule", buildGoldenRouting(map[string]any{
"domainStrategy": "AsIs",
"rules": []any{rule},
"balancers": []any{map[string]any{"tag": "balancer-load", "selector": []any{"proxy-"}}},
}))
})
}
for name, balancer := range goldenFixtures(t, "balancer") {
t.Run("balancer/"+name, func(t *testing.T) {
assertXrayAccepts(t, "this balancer", buildGoldenRouting(map[string]any{
"domainStrategy": "AsIs",
"balancers": []any{balancer},
"rules": []any{map[string]any{
"type": "field", "port": "443", "balancerTag": balancer["tag"],
}},
}))
})
}
}
// TestGoldenDNSFixturesBuildInXray builds the dns section, and each dns-server
// fixture inside one.
func TestGoldenDNSFixturesBuildInXray(t *testing.T) {
build := func(dns map[string]any) error {
raw, err := json.Marshal(dns)
if err != nil {
return err
}
dnsConf := new(conf.DNSConfig)
if err := json.Unmarshal(raw, dnsConf); err != nil {
return err
}
_, err = dnsConf.Build()
return err
}
for name, fixture := range goldenFixtures(t, "dns") {
t.Run("dns/"+name, func(t *testing.T) {
assertXrayAccepts(t, "this dns section", build(fixture))
})
}
for name, server := range goldenFixtures(t, "dns-server") {
t.Run("dns-server/"+name, func(t *testing.T) {
assertXrayAccepts(t, "this dns server", build(map[string]any{"servers": []any{server}}))
})
}
}
func isMissingGeoAssetErr(err error) bool {
msg := err.Error()
return strings.Contains(msg, "geoip.dat") || strings.Contains(msg, "geosite.dat")
}
+191
View File
@@ -8,10 +8,13 @@ import (
"errors"
"fmt"
"net"
"regexp"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -592,6 +595,188 @@ func validateFinalMaskRealityCombo(streamSettings string) error {
return common.NewError("Finalmask is not supported with REALITY security — it crashes Xray-core on the first connection (see XTLS/Xray-core#6453). Remove the finalmask configuration or switch security to tls/none.")
}
var xmcProfileUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]{3,16}$`)
// xmcMaskProfilesComplete reports whether an xmc finalmask carries the signed
// Minecraft session profiles xray-core has required since v26.7.28 (#6487).
// The core replaced the old `usernames` string list with `profiles` objects
// and removed the "default to Dream when empty" fallback, so a mask still on
// the legacy shape — or one whose profiles are incomplete — now fails
// conf.XMC.Build() and takes the entire config down with it rather than
// degrading that one inbound.
//
// The texture fields are a signed blob only Mojang's session server can issue
// (resolve the UUID by username, then fetch the profile with unsigned=false),
// so the panel cannot synthesize a valid profile from a legacy username; an
// incomplete mask can only be reported or dropped.
func xmcMaskProfilesComplete(mask map[string]any) bool {
settings, ok := mask["settings"].(map[string]any)
if !ok {
return false
}
profiles, _ := settings["profiles"].([]any)
if len(profiles) == 0 {
return false
}
for _, entry := range profiles {
profile, ok := entry.(map[string]any)
if !ok {
return false
}
username, _ := profile["username"].(string)
if !xmcProfileUsernamePattern.MatchString(username) {
return false
}
id, _ := profile["uuid"].(string)
if _, err := uuid.Parse(id); err != nil {
return false
}
if value, _ := profile["texturesValue"].(string); value == "" {
return false
}
if signature, _ := profile["texturesSignature"].(string); signature == "" {
return false
}
}
return true
}
// isIncompleteXmcMask reports whether a finalmask.tcp entry is an xmc mask
// xray-core would refuse to build.
func isIncompleteXmcMask(entry any) bool {
mask, ok := entry.(map[string]any)
if !ok {
return false
}
if maskType, _ := mask["type"].(string); maskType != "xmc" {
return false
}
return !xmcMaskProfilesComplete(mask)
}
// incompleteXmcMaskCount counts the stream's xmc finalmask entries that
// xray-core would refuse to build.
func incompleteXmcMaskCount(stream map[string]any) int {
finalmask, ok := stream["finalmask"].(map[string]any)
if !ok {
return 0
}
tcp, _ := finalmask["tcp"].([]any)
count := 0
for _, entry := range tcp {
if isIncompleteXmcMask(entry) {
count++
}
}
return count
}
// stripIncompleteXmcMasks removes every xmc finalmask entry xray-core would
// refuse to build, returning how many were dropped, and clears the finalmask
// object once nothing is left in it.
//
// AddInbound and UpdateInbound reject an incomplete mask at save time, but a
// row that never went through those paths — an upgrade from a panel predating
// v26.7.28, node sync, a restored backup, a direct DB edit — would otherwise
// fail the whole config build and keep every other inbound offline too.
// Dropping only the offending mask degrades that one inbound instead, which
// the accompanying warning tells the admin to reconfigure.
func stripIncompleteXmcMasks(stream map[string]any) int {
finalmask, ok := stream["finalmask"].(map[string]any)
if !ok {
return 0
}
tcp, _ := finalmask["tcp"].([]any)
if len(tcp) == 0 {
return 0
}
kept := make([]any, 0, len(tcp))
dropped := 0
for _, entry := range tcp {
if isIncompleteXmcMask(entry) {
dropped++
continue
}
kept = append(kept, entry)
}
if dropped == 0 {
return 0
}
if len(kept) == 0 {
delete(finalmask, "tcp")
} else {
finalmask["tcp"] = kept
}
if len(finalmask) == 0 {
delete(stream, "finalmask")
}
return dropped
}
// dropEmptyRandPackets removes the leftover empty "packet" from finalmask
// items that also carry a rand, and reports how many it cleared.
//
// xray-core treats even an empty array as a packet, and every item kind is
// exclusive: noise refuses "len(item.Packet) > 0 && item.Rand.To > 0" and
// header-custom refuses "exactly one item kind must be set". Either error
// fails the whole config build, so one such item keeps every inbound offline.
// The panel's mask editor wrote that pair whenever an item was switched to the
// rand-driven array kind, so stored rows carry it; clearing an empty packet
// changes nothing about the mask the admin configured.
func dropEmptyRandPackets(node any) int {
switch value := node.(type) {
case map[string]any:
cleared := 0
if packet, ok := value["packet"].([]any); ok && len(packet) == 0 && randIsSet(value["rand"]) {
delete(value, "packet")
cleared++
}
for _, child := range value {
cleared += dropEmptyRandPackets(child)
}
return cleared
case []any:
cleared := 0
for _, child := range value {
cleared += dropEmptyRandPackets(child)
}
return cleared
default:
return 0
}
}
// randIsSet reports whether a finalmask item's rand selects a random packet.
// It is a number on header-custom items and a dash-range string on noise ones.
func randIsSet(value any) bool {
switch rand := value.(type) {
case float64:
return rand > 0
case string:
return rand != "" && rand != "0" && rand != "0-0"
default:
return false
}
}
// validateFinalMaskXmcProfiles rejects an xmc finalmask without complete
// profiles at save time, so the admin gets a targeted error instead of a core
// that refuses to start (or, after GetXrayConfig heals it, an inbound quietly
// serving without the obfuscation they configured).
func validateFinalMaskXmcProfiles(streamSettings string) error {
if streamSettings == "" {
return nil
}
var stream map[string]any
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
return nil
}
if incompleteXmcMaskCount(stream) == 0 {
return nil
}
return common.NewError("XMC finalmask requires at least one complete Minecraft profile — each needs a username (3-16 of A-Z a-z 0-9 _), a UUID, and both texture fields from Mojang's session server (XTLS/Xray-core#6487). Complete the profiles or remove the XMC mask.")
}
// normalizeMtprotoSecret rebuilds every mtproto client's FakeTLS secret so it is
// always valid before the row is persisted, and drops the vestigial inbound-level
// secret and adTag: MTProto is multi-client, so mtg and every share link read
@@ -725,6 +910,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
return inbound, false, err
}
if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
return inbound, false, err
}
s.normalizeMtprotoSecret(inbound)
if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
return inbound, false, err
@@ -1148,6 +1336,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
return inbound, false, err
}
if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
return inbound, false, err
}
s.normalizeMtprotoSecret(inbound)
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
@@ -0,0 +1,108 @@
package service
import (
"encoding/json"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// TestAPIUserFromClientCarriesShadowsocksCipher pins what the renew path must
// hand the runtime API. A shadowsocks client object holds no cipher of its own,
// and xray's legacy and 2022 inbounds take different account types that they
// cast without checking — an account built for the wrong one panics the core.
func TestAPIUserFromClientCarriesShadowsocksCipher(t *testing.T) {
client := map[string]any{"email": "a@x", "password": "pw", "method": "aes-256-gcm"}
user := apiUserFromClient(client, "aes-256-gcm")
if got := user["cipher"]; got != "aes-256-gcm" {
t.Fatalf("cipher = %v, want the inbound method", got)
}
if _, polluted := client["cipher"]; polluted {
t.Fatal("the stored client object was mutated; the API-only cipher would be persisted into the inbound settings")
}
user["email"] = "changed@x"
if client["email"] != "a@x" {
t.Fatal("the API user shares storage with the stored client object")
}
}
func TestAPIUserFromClientWithoutCipher(t *testing.T) {
client := map[string]any{"email": "a@x", "id": "11111111-1111-1111-1111-111111111111"}
user := apiUserFromClient(client, "")
if _, ok := user["cipher"]; ok {
t.Fatal("a non-shadowsocks client must not gain a cipher key")
}
if user["id"] != client["id"] {
t.Fatalf("id = %v, want it copied from the client", user["id"])
}
}
// TestAutoRenewShadowsocksKeepsSettingsClean renews a shadowsocks client and
// checks the inbound settings the panel writes back: the cipher the API needs
// must not leak into a stored client object, where it would end up in the
// generated xray config as a per-user key.
func TestAutoRenewShadowsocksKeepsSettingsClean(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
past := time.Now().Add(-48 * time.Hour).UnixMilli()
clients := []model.Client{
{Email: "ss@x", Password: "pw", Enable: false, Reset: 30, ExpiryTime: past},
}
settings := map[string]any{
"method": "aes-256-gcm",
"network": "tcp,udp",
"clients": []map[string]any{
{"email": "ss@x", "password": "pw", "method": "aes-256-gcm", "enable": false, "expiryTime": past, "reset": 30},
},
}
raw, err := json.MarshalIndent(settings, "", " ")
if err != nil {
t.Fatal(err)
}
ib := mkInbound(t, 30011, model.Shadowsocks, string(raw))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
if err := db.Create(&[]xray.ClientTraffic{
{InboundId: ib.Id, Email: "ss@x", Enable: false, Up: 10, Down: 20, Reset: 30, ExpiryTime: past},
}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
if _, count, err := svc.autoRenewClients(db); err != nil {
t.Fatalf("autoRenewClients: %v", err)
} else if count != 1 {
t.Fatalf("renewed count = %d, want 1", count)
}
var stored model.Inbound
if err := db.Where("id = ?", ib.Id).First(&stored).Error; err != nil {
t.Fatalf("read inbound: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(stored.Settings), &parsed); err != nil {
t.Fatalf("unmarshal stored settings: %v", err)
}
storedClients, _ := parsed["clients"].([]any)
if len(storedClients) != 1 {
t.Fatalf("stored clients = %d, want 1", len(storedClients))
}
client, _ := storedClients[0].(map[string]any)
if _, polluted := client["cipher"]; polluted {
t.Fatalf("the renewed client was persisted with an API-only cipher key: %+v", client)
}
if client["method"] != "aes-256-gcm" {
t.Fatalf("the client's method was lost: %+v", client)
}
if enabled, _ := client["enable"].(bool); !enabled {
t.Fatalf("the renewed client was not re-enabled: %+v", client)
}
}
@@ -0,0 +1,204 @@
package service
import (
"encoding/json"
"testing"
"github.com/xtls/xray-core/infra/conf"
)
const completeXmcProfile = `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`
func TestValidateFinalMaskXmcProfiles(t *testing.T) {
tests := []struct {
name string
streamSettings string
wantErr bool
}{
{
name: "empty streamSettings",
streamSettings: "",
wantErr: false,
},
{
name: "no finalmask",
streamSettings: `{"network":"tcp","security":"none"}`,
wantErr: false,
},
{
name: "non-xmc mask is untouched",
streamSettings: `{"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
wantErr: false,
},
{
name: "xmc with a complete profile",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"hostname":"mc.example.com","password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
wantErr: false,
},
{
name: "legacy usernames shape without profiles",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"hostname":"mc.example.com","password":"pw","usernames":["Dream"]}}]}}`,
wantErr: true,
},
{
name: "xmc with an empty profiles array",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[]}}]}}`,
wantErr: true,
},
{
name: "profile missing the textures signature",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":""}]}}]}}`,
wantErr: true,
},
{
name: "profile with an unparseable uuid",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"Notch","uuid":"not-a-uuid","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}]}}]}}`,
wantErr: true,
},
{
name: "profile with an out-of-range username",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"ab","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}]}}]}}`,
wantErr: true,
},
{
name: "one complete and one incomplete profile",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `,{"username":"Herobrine","uuid":"","texturesValue":"","texturesSignature":""}]}}]}}`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateFinalMaskXmcProfiles(tt.streamSettings)
if (err != nil) != tt.wantErr {
t.Errorf("validateFinalMaskXmcProfiles(%q) error = %v, wantErr %v", tt.streamSettings, err, tt.wantErr)
}
})
}
}
// TestXmcMaskProfilesCompleteMatchesCoreValidation pins the panel's predicate
// to xray-core's own loader instead of a restatement of it: every profile the
// panel accepts must build, and every one it rejects must fail to build. A
// future core release that tightens or relaxes the rules fails here rather
// than silently producing configs the core refuses to start on.
func TestXmcMaskProfilesCompleteMatchesCoreValidation(t *testing.T) {
profiles := []struct {
name string
raw string
}{
{name: "complete", raw: completeXmcProfile},
{name: "undashed uuid", raw: `{"username":"Notch","uuid":"069a79f444e94726a5befca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "username at the 16 char limit", raw: `{"username":"Abcdefghijklmnop","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "username over the limit", raw: `{"username":"Abcdefghijklmnopq","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "username with a hyphen", raw: `{"username":"No-tch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "empty uuid", raw: `{"username":"Notch","uuid":"","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "missing textures value", raw: `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"","texturesSignature":"c2ln"}`},
}
for _, tt := range profiles {
t.Run(tt.name, func(t *testing.T) {
var coreProfile conf.XMCProfile
if err := json.Unmarshal([]byte(tt.raw), &coreProfile); err != nil {
t.Fatalf("unmarshal into conf.XMCProfile: %v", err)
}
_, coreErr := coreProfile.Build()
mask := map[string]any{"type": "xmc"}
var settings map[string]any
if err := json.Unmarshal([]byte(`{"profiles":[`+tt.raw+`]}`), &settings); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
mask["settings"] = settings
panelAccepts := xmcMaskProfilesComplete(mask)
coreAccepts := coreErr == nil
if panelAccepts != coreAccepts {
t.Errorf("xmcMaskProfilesComplete = %v, but conf.XMCProfile.Build() accepts = %v (err %v)", panelAccepts, coreAccepts, coreErr)
}
})
}
}
// TestXmcEmptyProfilesRejectedByCore covers the rule that lives on XMC rather
// than XMCProfile: v26.7.28 dropped the "default to Dream" fallback, so a mask
// with no profiles at all is now a build failure.
func TestXmcEmptyProfilesRejectedByCore(t *testing.T) {
var core conf.XMC
if err := json.Unmarshal([]byte(`{"hostname":"mc.example.com","password":"pw","profiles":[]}`), &core); err != nil {
t.Fatalf("unmarshal into conf.XMC: %v", err)
}
if _, err := core.Build(); err == nil {
t.Fatal("conf.XMC.Build() accepted an empty profiles list; the panel's strip/validate pair is no longer needed")
}
}
func TestStripIncompleteXmcMasks(t *testing.T) {
tests := []struct {
name string
stream string
wantDropped int
wantStream string
}{
{
name: "legacy usernames mask is dropped and finalmask removed",
stream: `{"network":"tcp","finalmask":{"tcp":[{"type":"xmc","settings":{"usernames":["Dream"],"password":"pw"}}]}}`,
wantDropped: 1,
wantStream: `{"network":"tcp"}`,
},
{
name: "complete mask is kept",
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
wantDropped: 0,
wantStream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
},
{
name: "sibling masks survive the drop",
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"usernames":["Dream"]}},{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
wantDropped: 1,
wantStream: `{"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
},
{
name: "udp masks are preserved when tcp empties out",
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{}}],"udp":[{"type":"salamander"}]}}`,
wantDropped: 1,
wantStream: `{"finalmask":{"udp":[{"type":"salamander"}]}}`,
},
{
name: "stream without finalmask is untouched",
stream: `{"network":"tcp","security":"tls"}`,
wantDropped: 0,
wantStream: `{"network":"tcp","security":"tls"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var stream map[string]any
if err := json.Unmarshal([]byte(tt.stream), &stream); err != nil {
t.Fatalf("unmarshal stream: %v", err)
}
dropped := stripIncompleteXmcMasks(stream)
if dropped != tt.wantDropped {
t.Errorf("stripIncompleteXmcMasks dropped = %d, want %d", dropped, tt.wantDropped)
}
var want map[string]any
if err := json.Unmarshal([]byte(tt.wantStream), &want); err != nil {
t.Fatalf("unmarshal wantStream: %v", err)
}
got, err := json.Marshal(stream)
if err != nil {
t.Fatalf("marshal stream: %v", err)
}
wantJSON, err := json.Marshal(want)
if err != nil {
t.Fatalf("marshal want: %v", err)
}
if string(got) != string(wantJSON) {
t.Errorf("stream after strip = %s, want %s", got, wantJSON)
}
})
}
}
+23 -2
View File
@@ -287,6 +287,23 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
return dbClientTraffics, newExpiryByEmail, nil
}
// apiUserFromClient prepares a stored client object for the runtime AddUser
// call. The copy matters twice over: the stored object keeps being mutated and
// marshalled back into the inbound's settings, which must not gain an API-only
// key, and shadowsocks clients carry no cipher of their own — it lives on the
// inbound, and without it the API cannot tell which of xray's two shadowsocks
// account types the running inbound expects.
func apiUserFromClient(client map[string]any, cipher string) map[string]any {
user := maps.Clone(client)
if user == nil {
user = map[string]any{}
}
if cipher != "" {
user["cipher"] = cipher
}
return user
}
func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
// check for time expired
var traffics []*xray.ClientTraffic
@@ -367,6 +384,10 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
if len(clients) == 0 {
continue
}
cipher := ""
if inbounds[inbound_index].Protocol == model.Shadowsocks {
cipher, _ = settings["method"].(string)
}
for client_index := range clients {
c := clients[client_index].(map[string]any)
email, _ := c["email"].(string)
@@ -393,7 +414,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
}{
protocol: string(inbounds[inbound_index].Protocol),
tag: inbounds[inbound_index].Tag,
client: c,
client: apiUserFromClient(c, cipher),
})
}
clients[client_index] = any(c)
@@ -603,7 +624,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
if err != nil {
return false, err
}
cipher = oldSettings["method"].(string)
cipher, _ = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), inbound, map[string]any{
"email": client.Email,
+6
View File
@@ -281,6 +281,12 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
delete(stream, "finalmask")
}
dropEmptyRandPackets(stream["finalmask"])
if dropped := stripIncompleteXmcMasks(stream); dropped > 0 {
logger.Warningf("Inbound %q: dropping %d XMC finalmask mask(s) without complete Minecraft profiles — reconfigure them to restore the obfuscation (see XTLS/Xray-core#6487)", inbound.Tag, dropped)
}
// xray-core v26.6.22 (#6258) renamed the XHTTP session keys and
// kept no fallback. Lift legacy sessionPlacement/sessionKey onto the
// new names here so inbounds stored before the rename keep working
-3
View File
@@ -63,9 +63,6 @@ func BroadcastInbounds(inbounds any) {
}
}
// BroadcastNodes broadcasts the fresh node list to all connected clients.
// Pushed by NodeHeartbeatJob at the end of each 10s tick so the Nodes page
// reflects status / latency / cpu / mem updates without polling.
func BroadcastNodes(nodes any) {
if hub := GetHub(); hub != nil {
hub.Broadcast(MessageTypeNodes, nodes)
+98 -25
View File
@@ -345,6 +345,10 @@ func (x *XrayAPI) TestRoute(req RouteTestRequest) (*RouteTestResult, error) {
return nil, common.NewError("xray RoutingServiceClient is not initialized")
}
if req.Port < 0 || req.Port > math.MaxUint16 {
return nil, common.NewErrorf("invalid port: %d", req.Port)
}
network := xnet.Network_TCP
if strings.EqualFold(req.Network, "udp") {
network = xnet.Network_UDP
@@ -461,11 +465,71 @@ func collectStringSlice(value any) []string {
}
}
// legacyShadowsocksAccountType is the type URL serial.ToTypedMessage stamps on
// a pre-2022 shadowsocks account, which identifies the one inbound whose user
// list tolerates duplicate emails.
const legacyShadowsocksAccountType = "xray.proxy.shadowsocks.Account"
// shadowsocks2022Ciphers are the methods that select xray's shadowsocks-2022
// inbound (sing's shadowaead_2022 list). They take a different account type
// than the legacy AEAD ciphers, and the running inbound casts the account it
// receives without checking, so a wrong guess takes the whole core down.
var shadowsocks2022Ciphers = map[string]struct{}{
"2022-blake3-aes-128-gcm": {},
"2022-blake3-aes-256-gcm": {},
"2022-blake3-chacha20-poly1305": {},
}
// shadowsocksCipherName resolves the cipher a shadowsocks user's account must
// be built for. Panel-built user maps carry it under "cipher"; client objects
// taken verbatim from an inbound's settings carry the inbound's method under
// "method" instead (HealShadowsocksClientMethods writes it onto every
// legacy-cipher client).
func shadowsocksCipherName(user map[string]any) (string, error) {
cipher, err := getOptionalUserString(user, "cipher")
if err != nil {
return "", err
}
if cipher != "" {
return cipher, nil
}
return getOptionalUserString(user, "method")
}
// shadowsocksCipherType mirrors xray-core's infra/conf cipherFromString,
// aliases and case-insensitivity included, so the account the panel builds for
// a live user matches the one the core built for that inbound from its config.
func shadowsocksCipherType(cipher string) shadowsocks.CipherType {
switch strings.ToLower(cipher) {
case "aes-128-gcm", "aead_aes_128_gcm":
return shadowsocks.CipherType_AES_128_GCM
case "aes-256-gcm", "aead_aes_256_gcm":
return shadowsocks.CipherType_AES_256_GCM
case "chacha20-poly1305", "aead_chacha20_poly1305", "chacha20-ietf-poly1305":
return shadowsocks.CipherType_CHACHA20_POLY1305
case "xchacha20-poly1305", "aead_xchacha20_poly1305", "xchacha20-ietf-poly1305":
return shadowsocks.CipherType_XCHACHA20_POLY1305
default:
return shadowsocks.CipherType_UNKNOWN
}
}
// isShadowsocks2022Cipher reports whether the method selects the
// shadowsocks-2022 inbound rather than the legacy AEAD one.
func isShadowsocks2022Cipher(cipher string) bool {
_, ok := shadowsocks2022Ciphers[strings.ToLower(cipher)]
return ok
}
// buildUserAccount constructs the typed xray account for a user of the given
// protocol. It returns (nil, nil) for protocols that cannot be altered live so
// callers skip the AlterInbound call. WireGuard keys must be converted to the
// hex form xray's wireguard proxy expects (its ParseKey uses hex.DecodeString),
// unlike the file-config path which accepts base64 and converts internally.
// Shadowsocks is resolved strictly from the inbound's cipher: the legacy and
// 2022 inbounds take different account types and cast whatever they receive
// without checking, so an unrecognized cipher is an error rather than a guess
// that would panic the core and kill every connection on the server.
func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMessage, error) {
switch protocolName {
case "vmess":
@@ -523,7 +587,7 @@ func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMe
Password: password,
}), nil
case "shadowsocks":
cipher, err := getOptionalUserString(user, "cipher")
cipher, err := shadowsocksCipherName(user)
if err != nil {
return nil, err
}
@@ -533,28 +597,19 @@ func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMe
return nil, err
}
var ssCipherType shadowsocks.CipherType
switch cipher {
case "aes-128-gcm":
ssCipherType = shadowsocks.CipherType_AES_128_GCM
case "aes-256-gcm":
ssCipherType = shadowsocks.CipherType_AES_256_GCM
case "chacha20-poly1305", "chacha20-ietf-poly1305":
ssCipherType = shadowsocks.CipherType_CHACHA20_POLY1305
case "xchacha20-poly1305", "xchacha20-ietf-poly1305":
ssCipherType = shadowsocks.CipherType_XCHACHA20_POLY1305
default:
ssCipherType = shadowsocks.CipherType_UNKNOWN
}
if ssCipherType != shadowsocks.CipherType_UNKNOWN {
return serial.ToTypedMessage(&shadowsocks.Account{
Password: password,
CipherType: ssCipherType,
if isShadowsocks2022Cipher(cipher) {
return serial.ToTypedMessage(&shadowsocks_2022.Account{
Key: password,
}), nil
}
return serial.ToTypedMessage(&shadowsocks_2022.Account{
Key: password,
ssCipherType := shadowsocksCipherType(cipher)
if ssCipherType == shadowsocks.CipherType_UNKNOWN {
return nil, common.NewErrorf("shadowsocks: unknown cipher %q, cannot build an account for the running inbound", cipher)
}
return serial.ToTypedMessage(&shadowsocks.Account{
Password: password,
CipherType: ssCipherType,
}), nil
case "hysteria":
auth, err := getRequiredUserString(user, "auth")
@@ -605,7 +660,11 @@ func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMe
}
}
// AddUser adds a user to an inbound in the Xray core using the specified protocol and user data.
// AddUser adds a user to an inbound in the Xray core using the specified
// protocol and user data. On a legacy shadowsocks inbound the add first drops
// any existing holder of the email: that is the one inbound whose validator
// does not reject a duplicate email, and a later removal would then drop just
// one of the two registrations, leaving a disabled client able to connect.
func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
userEmail, err := getRequiredUserString(user, "email")
if err != nil {
@@ -625,6 +684,10 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
}
client := *x.HandlerServiceClient
if account.Type == legacyShadowsocksAccountType {
_ = x.RemoveUser(inboundTag, userEmail)
}
ctx, cancel := context.WithTimeout(context.Background(), handlerRPCTimeout)
defer cancel()
_, err = client.AlterInbound(ctx, &command.AlterInboundRequest{
@@ -663,7 +726,13 @@ func (x *XrayAPI) RemoveUser(inboundTag, email string) error {
return nil
}
// GetTraffic queries traffic statistics from the Xray core, optionally resetting counters.
// GetTraffic queries traffic statistics from the Xray core and reports what
// accrued since the previous call; the counters themselves are never reset.
// The first call of a process only records baselines, since it may be reading
// counters that already hold traffic the panel cannot attribute. After that a
// name the panel has not seen — xray creates a counter on a user's first use —
// and a counter that moved backwards because the core restarted both count
// from zero, so no client's traffic is dropped for a whole polling interval.
func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) {
if x.grpcClient == nil {
return nil, nil, common.NewError("xray api is not initialized")
@@ -685,13 +754,17 @@ func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) {
tagTrafficMap := make(map[string]*Traffic)
emailTrafficMap := make(map[string]*ClientTraffic)
baselinePass := len(x.StatsLastValues) == 0
for _, stat := range resp.GetStat() {
lastValue, ok := x.StatsLastValues[stat.Name]
x.StatsLastValues[stat.Name] = stat.Value
if !ok || stat.Value < lastValue {
// skip first time of seen stat
if baselinePass {
continue
}
if !ok || stat.Value < lastValue {
lastValue = 0
}
value := stat.Value - lastValue
if matches := trafficRegex.FindStringSubmatch(stat.Name); len(matches) == 4 {
processTraffic(matches, value, tagTrafficMap)
+149
View File
@@ -0,0 +1,149 @@
package xray
import (
"strings"
"testing"
"github.com/xtls/xray-core/proxy/shadowsocks"
"github.com/xtls/xray-core/proxy/shadowsocks_2022"
"google.golang.org/protobuf/proto"
)
// decodeSSAccount decodes the typed message buildUserAccount produced for a
// shadowsocks user. The type URL is what the running inbound casts on, so the
// test asserts on it directly.
func decodeSSAccount(t *testing.T, user map[string]any) (typeURL string, legacy *shadowsocks.Account, modern *shadowsocks_2022.Account) {
t.Helper()
tm, err := buildUserAccount("shadowsocks", user)
if err != nil {
t.Fatalf("buildUserAccount: %v", err)
}
if tm == nil {
t.Fatal("buildUserAccount returned no account for shadowsocks")
}
typeURL = tm.Type
switch {
case strings.Contains(typeURL, "shadowsocks_2022"):
modern = new(shadowsocks_2022.Account)
if err := proto.Unmarshal(tm.Value, modern); err != nil {
t.Fatalf("unmarshal shadowsocks_2022 account: %v", err)
}
case strings.Contains(typeURL, "shadowsocks"):
legacy = new(shadowsocks.Account)
if err := proto.Unmarshal(tm.Value, legacy); err != nil {
t.Fatalf("unmarshal shadowsocks account: %v", err)
}
default:
t.Fatalf("unexpected account type %q", typeURL)
}
return typeURL, legacy, modern
}
func TestBuildUserAccountShadowsocksLegacyCiphers(t *testing.T) {
tests := []struct {
cipher string
want shadowsocks.CipherType
}{
{"aes-128-gcm", shadowsocks.CipherType_AES_128_GCM},
{"aead_aes_128_gcm", shadowsocks.CipherType_AES_128_GCM},
{"aes-256-gcm", shadowsocks.CipherType_AES_256_GCM},
{"AES-256-GCM", shadowsocks.CipherType_AES_256_GCM},
{"aead_aes_256_gcm", shadowsocks.CipherType_AES_256_GCM},
{"chacha20-poly1305", shadowsocks.CipherType_CHACHA20_POLY1305},
{"chacha20-ietf-poly1305", shadowsocks.CipherType_CHACHA20_POLY1305},
{"aead_chacha20_poly1305", shadowsocks.CipherType_CHACHA20_POLY1305},
{"xchacha20-poly1305", shadowsocks.CipherType_XCHACHA20_POLY1305},
{"xchacha20-ietf-poly1305", shadowsocks.CipherType_XCHACHA20_POLY1305},
{"aead_xchacha20_poly1305", shadowsocks.CipherType_XCHACHA20_POLY1305},
}
for _, tt := range tests {
t.Run(tt.cipher, func(t *testing.T) {
user := map[string]any{"email": "a@example.test", "password": "pw", "cipher": tt.cipher}
typeURL, legacy, modern := decodeSSAccount(t, user)
if modern != nil {
t.Fatalf("cipher %q built a shadowsocks-2022 account (%s); the legacy inbound casts to *shadowsocks.MemoryAccount and panics the core", tt.cipher, typeURL)
}
if legacy.CipherType != tt.want {
t.Fatalf("CipherType = %v, want %v", legacy.CipherType, tt.want)
}
if legacy.Password != "pw" {
t.Fatalf("Password = %q, want %q", legacy.Password, "pw")
}
})
}
}
func TestBuildUserAccountShadowsocks2022Ciphers(t *testing.T) {
for _, cipher := range []string{
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305",
} {
t.Run(cipher, func(t *testing.T) {
user := map[string]any{"email": "a@example.test", "password": b64Key(7), "cipher": cipher}
typeURL, _, modern := decodeSSAccount(t, user)
if modern == nil {
t.Fatalf("cipher %q built a legacy account (%s), want shadowsocks-2022", cipher, typeURL)
}
if modern.Key != b64Key(7) {
t.Fatalf("Key = %q, want the client password", modern.Key)
}
})
}
}
// TestBuildUserAccountShadowsocksReadsMethodKey covers the client maps taken
// verbatim from an inbound's settings (the auto-renew path): they carry the
// inbound's cipher under "method", never "cipher".
func TestBuildUserAccountShadowsocksReadsMethodKey(t *testing.T) {
user := map[string]any{"email": "a@example.test", "password": "pw", "method": "aes-256-gcm"}
_, legacy, modern := decodeSSAccount(t, user)
if modern != nil {
t.Fatal("a client carrying only \"method\" must still build a legacy account")
}
if legacy.CipherType != shadowsocks.CipherType_AES_256_GCM {
t.Fatalf("CipherType = %v, want AES_256_GCM", legacy.CipherType)
}
}
func TestBuildUserAccountShadowsocksCipherKeyWins(t *testing.T) {
user := map[string]any{
"email": "a@example.test",
"password": b64Key(3),
"cipher": "2022-blake3-aes-128-gcm",
"method": "aes-256-gcm",
}
_, _, modern := decodeSSAccount(t, user)
if modern == nil {
t.Fatal("the explicit \"cipher\" must win over a stale \"method\"")
}
}
// TestBuildUserAccountShadowsocksUnknownCipherErrors is the regression for the
// account-type guess that killed the core: an unrecognized cipher used to fall
// through to a shadowsocks-2022 account, which xray's legacy inbound casts
// without checking, panicking the whole process.
func TestBuildUserAccountShadowsocksUnknownCipherErrors(t *testing.T) {
for _, cipher := range []string{"", "rc4-md5", "2022-blake3-future-gcm", "none"} {
t.Run("cipher="+cipher, func(t *testing.T) {
user := map[string]any{"email": "a@example.test", "password": "pw"}
if cipher != "" {
user["cipher"] = cipher
}
account, err := buildUserAccount("shadowsocks", user)
if err == nil {
t.Fatalf("cipher %q built account %v, want an error instead of an account-type guess", cipher, account)
}
if !strings.Contains(err.Error(), "unknown cipher") {
t.Fatalf("error = %q, want it to name the unknown cipher", err)
}
})
}
}
func TestBuildUserAccountShadowsocksMissingPassword(t *testing.T) {
user := map[string]any{"email": "a@example.test", "cipher": "aes-256-gcm"}
if _, err := buildUserAccount("shadowsocks", user); err == nil {
t.Fatal("expected an error for a shadowsocks user without a password")
}
}
+192
View File
@@ -0,0 +1,192 @@
package xray
import (
"context"
"net"
"testing"
statsService "github.com/xtls/xray-core/app/stats/command"
"google.golang.org/grpc"
)
// fakeStatsServer serves a scripted sequence of QueryStats responses, one per
// call, so the delta bookkeeping in GetTraffic can be driven exactly.
type fakeStatsServer struct {
statsService.UnimplementedStatsServiceServer
rounds [][]*statsService.Stat
calls int
}
func (f *fakeStatsServer) QueryStats(context.Context, *statsService.QueryStatsRequest) (*statsService.QueryStatsResponse, error) {
round := f.calls
f.calls++
if round >= len(f.rounds) {
round = len(f.rounds) - 1
}
return &statsService.QueryStatsResponse{Stat: f.rounds[round]}, nil
}
func stat(name string, value int64) *statsService.Stat {
return &statsService.Stat{Name: name, Value: value}
}
// startFakeStats runs the scripted stats service and returns an XrayAPI wired
// to it.
func startFakeStats(t *testing.T, rounds [][]*statsService.Stat) *XrayAPI {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := grpc.NewServer()
statsService.RegisterStatsServiceServer(srv, &fakeStatsServer{rounds: rounds})
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)
api := &XrayAPI{}
if err := api.Init(lis.Addr().(*net.TCPAddr).Port); err != nil {
t.Fatalf("api init: %v", err)
}
t.Cleanup(api.Close)
return api
}
func clientTrafficByEmail(t *testing.T, traffics []*ClientTraffic) map[string]*ClientTraffic {
t.Helper()
byEmail := make(map[string]*ClientTraffic, len(traffics))
for _, ct := range traffics {
byEmail[ct.Email] = ct
}
return byEmail
}
// TestGetTrafficFirstPollIsBaselineOnly pins the one case the skip protects:
// the panel may attach to counters that already hold traffic it cannot
// attribute, so the first poll of a client only records baselines.
func TestGetTrafficFirstPollIsBaselineOnly(t *testing.T) {
api := startFakeStats(t, [][]*statsService.Stat{
{stat("user>>>alice>>>traffic>>>uplink", 5000)},
})
_, clients, err := api.GetTraffic()
if err != nil {
t.Fatalf("GetTraffic: %v", err)
}
if len(clients) != 0 {
t.Fatalf("first poll reported %+v, want no traffic (baseline only)", clients[0])
}
if got := api.StatsLastValues["user>>>alice>>>traffic>>>uplink"]; got != 5000 {
t.Fatalf("baseline = %d, want 5000", got)
}
}
// TestGetTrafficCountsNewStatFromZero is the regression for a new client's
// traffic being dropped: xray creates a counter on first use, so a name that
// appears after the baseline poll starts at zero and every byte in it is new.
func TestGetTrafficCountsNewStatFromZero(t *testing.T) {
api := startFakeStats(t, [][]*statsService.Stat{
{stat("user>>>alice>>>traffic>>>uplink", 100)},
{
stat("user>>>alice>>>traffic>>>uplink", 180),
stat("user>>>bob>>>traffic>>>uplink", 4096),
stat("user>>>bob>>>traffic>>>downlink", 8192),
},
})
if _, _, err := api.GetTraffic(); err != nil {
t.Fatalf("GetTraffic (baseline): %v", err)
}
_, clients, err := api.GetTraffic()
if err != nil {
t.Fatalf("GetTraffic: %v", err)
}
byEmail := clientTrafficByEmail(t, clients)
bob, ok := byEmail["bob"]
if !ok {
t.Fatal("a client whose counter appeared after the baseline poll reported no traffic")
}
if bob.Up != 4096 || bob.Down != 8192 {
t.Fatalf("bob = up %d / down %d, want 4096 / 8192", bob.Up, bob.Down)
}
alice, ok := byEmail["alice"]
if !ok {
t.Fatal("alice reported no traffic")
}
if alice.Up != 80 {
t.Fatalf("alice up = %d, want the delta 80", alice.Up)
}
}
// TestGetTrafficCountsAfterCounterReset covers a core restart: the counters
// start over at zero, so a value below the recorded baseline is all new
// traffic rather than something to drop.
func TestGetTrafficCountsAfterCounterReset(t *testing.T) {
api := startFakeStats(t, [][]*statsService.Stat{
{stat("user>>>alice>>>traffic>>>uplink", 100)},
{stat("user>>>alice>>>traffic>>>uplink", 900)},
{stat("user>>>alice>>>traffic>>>uplink", 250)},
})
if _, _, err := api.GetTraffic(); err != nil {
t.Fatalf("GetTraffic (baseline): %v", err)
}
if _, _, err := api.GetTraffic(); err != nil {
t.Fatalf("GetTraffic (delta): %v", err)
}
_, clients, err := api.GetTraffic()
if err != nil {
t.Fatalf("GetTraffic (after reset): %v", err)
}
alice, ok := clientTrafficByEmail(t, clients)["alice"]
if !ok {
t.Fatal("traffic after a counter reset was dropped entirely")
}
if alice.Up != 250 {
t.Fatalf("alice up = %d, want 250 counted from zero", alice.Up)
}
}
// TestGetTrafficSkipsAPIInboundAndPrunes checks the tag-level parsing: the api
// inbound is the panel's own gRPC channel and is never a user-facing inbound,
// and baselines for vanished stats are dropped once they outgrow the live set.
func TestGetTrafficSkipsAPIInboundAndPrunes(t *testing.T) {
api := startFakeStats(t, [][]*statsService.Stat{
{
stat("inbound>>>api>>>traffic>>>uplink", 10),
stat("inbound>>>in-443>>>traffic>>>uplink", 10),
stat("inbound>>>gone-1>>>traffic>>>uplink", 10),
stat("inbound>>>gone-2>>>traffic>>>uplink", 10),
stat("inbound>>>gone-3>>>traffic>>>uplink", 10),
stat("inbound>>>gone-4>>>traffic>>>uplink", 10),
stat("inbound>>>gone-5>>>traffic>>>uplink", 10),
},
{
stat("inbound>>>api>>>traffic>>>uplink", 99),
stat("inbound>>>in-443>>>traffic>>>uplink", 60),
stat("inbound>>>in-443>>>traffic>>>downlink", 70),
},
})
if _, _, err := api.GetTraffic(); err != nil {
t.Fatalf("GetTraffic (baseline): %v", err)
}
tags, _, err := api.GetTraffic()
if err != nil {
t.Fatalf("GetTraffic: %v", err)
}
if len(tags) != 1 {
t.Fatalf("got %d tag traffics, want only the non-api inbound: %+v", len(tags), tags)
}
if tags[0].Tag != "in-443" || !tags[0].IsInbound || tags[0].IsOutbound {
t.Fatalf("tag traffic = %+v, want inbound in-443", tags[0])
}
if tags[0].Up != 50 || tags[0].Down != 70 {
t.Fatalf("in-443 = up %d / down %d, want 50 / 70", tags[0].Up, tags[0].Down)
}
if _, stale := api.StatsLastValues["inbound>>>gone-1>>>traffic>>>uplink"]; stale {
t.Fatal("baselines for stats that no longer exist were not pruned")
}
}
+447
View File
@@ -0,0 +1,447 @@
package xray
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
// e2eCore is a real xray-core process plus a connected panel API client.
type e2eCore struct {
api *XrayAPI
cmd *exec.Cmd
port int
}
// alive reports whether the core is still serving its API port. A gRPC call
// that hands the core an account type its inbound does not expect takes the
// whole process down, so every user probe checks this.
func (c *e2eCore) alive() bool {
if c.cmd.ProcessState != nil {
return false
}
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", c.port), time.Second)
if err != nil {
return false
}
conn.Close()
return true
}
// startE2ECore boots xray-core with the given inbounds plus the api inbound the
// panel talks through, and returns a connected client. Skips unless
// XRAY_E2E_BINARY points at an xray built from the version go.mod pins.
func startE2ECore(t *testing.T, inbounds []any) *e2eCore {
t.Helper()
bin := os.Getenv("XRAY_E2E_BINARY")
if bin == "" {
t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
}
apiPort := freePort(t)
all := []any{
map[string]any{
"listen": "127.0.0.1",
"port": apiPort,
"protocol": "tunnel",
"settings": map[string]any{"rewriteAddress": "127.0.0.1"},
"tag": "api",
},
}
all = append(all, inbounds...)
cfg := map[string]any{
"log": map[string]any{"loglevel": "warning"},
"api": map[string]any{
"services": []string{"HandlerService", "StatsService", "RoutingService"},
"tag": "api",
},
"inbounds": all,
"outbounds": []any{
map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
map[string]any{"protocol": "blackhole", "settings": map[string]any{}, "tag": "blocked"},
},
"routing": map[string]any{
"domainStrategy": "AsIs",
"rules": []any{
map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
},
},
"policy": map[string]any{
"levels": map[string]any{
"0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true, "statsUserOnline": true},
},
"system": map[string]any{"statsInboundUplink": true, "statsInboundDownlink": true},
},
"stats": map[string]any{},
}
raw, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
t.Fatal(err)
}
cfgPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(cfgPath, raw, 0o644); err != nil {
t.Fatal(err)
}
cmd := exec.Command(bin, "-c", cfgPath)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("failed to start xray: %v", err)
}
t.Cleanup(func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
})
waitForPort(t, apiPort)
api := &XrayAPI{}
if err := api.Init(apiPort); err != nil {
t.Fatalf("api init: %v", err)
}
t.Cleanup(api.Close)
return &e2eCore{api: api, cmd: cmd, port: apiPort}
}
// ssKey builds a base64 shadowsocks-2022 key of the given byte length.
func ssKey(n int, seed byte) string {
raw := make([]byte, n)
for i := range raw {
raw[i] = seed + byte(i)
}
return base64.StdEncoding.EncodeToString(raw)
}
// panelUser mirrors the map shape the panel's client-apply paths hand to
// AddUser: every field is always present, unused ones are empty.
func panelUser(email string, fields map[string]any) map[string]any {
user := map[string]any{
"email": email,
"id": "",
"auth": "",
"security": "",
"flow": "",
"password": "",
"cipher": "",
"publicKey": "",
"allowedIPs": nil,
"preSharedKey": "",
"keepAlive": "",
}
for k, v := range fields {
user[k] = v
}
return user
}
// TestXrayAPI_E2E_Users runs the panel's add/remove user surface against a live
// core for every protocol it builds accounts for, pinning the error texts
// IsUserExistsErr and IsMissingHandlerErr match on.
func TestXrayAPI_E2E_Users(t *testing.T) {
c := startE2ECore(t, []any{
map[string]any{
"listen": "127.0.0.1", "port": freePort(t), "protocol": "vmess", "tag": "vmess-in",
"settings": map[string]any{"clients": []any{
map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "seed-vmess"},
}},
},
map[string]any{
"listen": "127.0.0.1", "port": freePort(t), "protocol": "vless", "tag": "vless-in",
"settings": map[string]any{"clients": []any{
map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30812", "email": "seed-vless"},
}, "decryption": "none"},
},
map[string]any{
"listen": "127.0.0.1", "port": freePort(t), "protocol": "trojan", "tag": "trojan-in",
"settings": map[string]any{"clients": []any{
map[string]any{"password": "seed-pw", "email": "seed-trojan"},
}},
},
map[string]any{
"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in",
"settings": map[string]any{
"method": "aes-256-gcm", "network": "tcp,udp",
"clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}},
},
},
map[string]any{
"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss2022-in",
"settings": map[string]any{
"method": "2022-blake3-aes-256-gcm", "password": ssKey(32, 1), "network": "tcp,udp",
"clients": []any{map[string]any{"password": ssKey(32, 9), "email": "seed-ss2022"}},
},
},
})
tests := []struct {
name string
protocol string
tag string
user map[string]any
// rejectsDuplicateEmail is false for the legacy shadowsocks inbound,
// whose validator does not dedupe emails at all.
rejectsDuplicateEmail bool
}{
{
name: "vmess", protocol: "vmess", tag: "vmess-in",
user: panelUser("e2e-vmess", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30821", "security": "auto"}),
rejectsDuplicateEmail: true,
},
{
name: "vless", protocol: "vless", tag: "vless-in",
user: panelUser("e2e-vless", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30822", "flow": "xtls-rprx-vision"}),
rejectsDuplicateEmail: true,
},
{
name: "trojan", protocol: "trojan", tag: "trojan-in",
user: panelUser("e2e-trojan", map[string]any{"password": "e2e-pw"}),
rejectsDuplicateEmail: true,
},
{
name: "shadowsocks legacy", protocol: "shadowsocks", tag: "ss-in",
user: panelUser("e2e-ss", map[string]any{"password": "e2e-pw", "cipher": "aes-256-gcm"}),
rejectsDuplicateEmail: false,
},
{
name: "shadowsocks 2022", protocol: "shadowsocks", tag: "ss2022-in",
user: panelUser("e2e-ss2022", map[string]any{"password": ssKey(32, 40), "cipher": "2022-blake3-aes-256-gcm"}),
rejectsDuplicateEmail: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := tt.user["email"].(string)
if err := c.api.AddUser(tt.protocol, tt.tag, tt.user); err != nil {
t.Fatalf("AddUser: %v", err)
}
if !c.alive() {
t.Fatal("core died on AddUser: the account type does not match the running inbound")
}
if tt.rejectsDuplicateEmail {
err := c.api.AddUser(tt.protocol, tt.tag, tt.user)
if err == nil {
t.Fatal("adding a user whose email is taken must fail")
}
if !IsUserExistsErr(err) {
t.Fatalf("duplicate-email error not matched by IsUserExistsErr: %q", err)
}
}
if err := c.api.RemoveUser(tt.tag, email); err != nil {
t.Fatalf("RemoveUser: %v", err)
}
err := c.api.RemoveUser(tt.tag, email)
if err == nil {
t.Fatal("the user is still registered after RemoveUser")
}
if !IsMissingHandlerErr(err) {
t.Fatalf("missing-user error not matched by IsMissingHandlerErr: %q", err)
}
})
}
t.Run("unknown inbound tag", func(t *testing.T) {
err := c.api.AddUser("vmess", "no-such-tag", panelUser("e2e-ghost", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30899"}))
if err == nil {
t.Fatal("AddUser on an unknown tag must fail")
}
if !IsMissingHandlerErr(err) {
t.Fatalf("unknown-tag error not matched by IsMissingHandlerErr: %q", err)
}
})
}
// TestXrayAPI_E2E_ShadowsocksAccountTypeGuess is the regression for the crash
// that took the whole core down: a shadowsocks user whose cipher the panel
// could not resolve used to be sent as a shadowsocks-2022 account, which the
// legacy inbound casts without checking. Every case here must leave the core
// running.
func TestXrayAPI_E2E_ShadowsocksAccountTypeGuess(t *testing.T) {
c := startE2ECore(t, []any{
map[string]any{
"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in",
"settings": map[string]any{
"method": "aes-256-gcm", "network": "tcp,udp",
"clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}},
},
},
map[string]any{
"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss2022-in",
"settings": map[string]any{
"method": "2022-blake3-aes-256-gcm", "password": ssKey(32, 1), "network": "tcp,udp",
"clients": []any{map[string]any{"password": ssKey(32, 9), "email": "seed-ss2022"}},
},
},
})
// The auto-renew path hands over the client object straight out of the
// inbound's settings, which carries the cipher under "method".
t.Run("client object from settings", func(t *testing.T) {
user := map[string]any{"email": "renew-ss", "password": "pw", "method": "aes-256-gcm", "enable": true}
if err := c.api.AddUser("shadowsocks", "ss-in", user); err != nil {
t.Fatalf("AddUser with the settings-shaped client: %v", err)
}
if !c.alive() {
t.Fatal("core died: a legacy shadowsocks client was sent as a 2022 account")
}
if err := c.api.RemoveUser("ss-in", "renew-ss"); err != nil {
t.Fatalf("RemoveUser: %v", err)
}
})
t.Run("cipher missing entirely", func(t *testing.T) {
err := c.api.AddUser("shadowsocks", "ss-in", map[string]any{"email": "nocipher", "password": "pw"})
if err == nil {
t.Fatal("a shadowsocks user with no resolvable cipher must be refused, not guessed")
}
if !c.alive() {
t.Fatal("core died on a shadowsocks user with no cipher")
}
})
t.Run("legacy cipher against a 2022 inbound", func(t *testing.T) {
// The reverse mismatch is only reachable when the panel's view of the
// inbound has drifted from the running one; the account type is still
// the one the cipher names, so the panel never guesses here.
user := map[string]any{"email": "drift", "password": ssKey(32, 50), "cipher": "2022-blake3-aes-256-gcm"}
if err := c.api.AddUser("shadowsocks", "ss2022-in", user); err != nil {
t.Fatalf("AddUser: %v", err)
}
if !c.alive() {
t.Fatal("core died adding a 2022 user to a 2022 inbound")
}
if err := c.api.RemoveUser("ss2022-in", "drift"); err != nil {
t.Fatalf("RemoveUser: %v", err)
}
})
}
// TestXrayAPI_E2E_ShadowsocksAddIsIdempotent covers the legacy shadowsocks
// inbound accepting a second user under an email it already holds: one removal
// then left the client connectable. Adding twice must leave exactly one
// registration, so a single removal fully revokes the client.
func TestXrayAPI_E2E_ShadowsocksAddIsIdempotent(t *testing.T) {
c := startE2ECore(t, []any{
map[string]any{
"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in",
"settings": map[string]any{
"method": "aes-256-gcm", "network": "tcp,udp",
"clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}},
},
},
})
user := map[string]any{"email": "dup-ss", "password": "pw", "cipher": "aes-256-gcm"}
for i := range 2 {
if err := c.api.AddUser("shadowsocks", "ss-in", user); err != nil {
t.Fatalf("AddUser #%d: %v", i+1, err)
}
}
if err := c.api.RemoveUser("ss-in", "dup-ss"); err != nil {
t.Fatalf("RemoveUser: %v", err)
}
err := c.api.RemoveUser("ss-in", "dup-ss")
if err == nil {
t.Fatal("a second registration survived removal: a disabled client would keep connecting")
}
if !IsMissingHandlerErr(err) {
t.Fatalf("missing-user error not matched by IsMissingHandlerErr: %q", err)
}
}
// TestXrayAPI_E2E_NewClientTrafficIsCounted proves against a live core that a
// counter appearing after the baseline poll is reported in full: xray creates a
// user's counter on first use, and dropping that first sample lost a new
// client's traffic for a whole polling interval.
func TestXrayAPI_E2E_NewClientTrafficIsCounted(t *testing.T) {
const payload = 64 * 1024
backendLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
backend := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(make([]byte, payload))
})}
go func() { _ = backend.Serve(backendLn) }()
t.Cleanup(func() { _ = backend.Close() })
proxyPort := freePort(t)
c := startE2ECore(t, []any{
map[string]any{
"listen": "127.0.0.1", "port": proxyPort, "protocol": "http", "tag": "http-in",
"settings": map[string]any{
"accounts": []any{map[string]any{"user": "e2e-fresh", "pass": "e2e-pass"}},
},
},
})
// Baseline poll: the client's counter does not exist yet.
if _, _, err := c.api.GetTraffic(); err != nil {
t.Fatalf("GetTraffic (baseline): %v", err)
}
proxyURL, err := url.Parse(fmt.Sprintf("http://e2e-fresh:e2e-pass@127.0.0.1:%d", proxyPort))
if err != nil {
t.Fatal(err)
}
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
resp, err := client.Get(fmt.Sprintf("http://%s/", backendLn.Addr().String()))
if err != nil {
t.Fatalf("proxied request: %v", err)
}
n, err := io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if err != nil {
t.Fatalf("reading the proxied response: %v", err)
}
if n != payload {
t.Fatalf("proxied %d bytes, want %d", n, payload)
}
time.Sleep(500 * time.Millisecond)
_, clients, err := c.api.GetTraffic()
if err != nil {
t.Fatalf("GetTraffic: %v", err)
}
var got *ClientTraffic
for _, ct := range clients {
if ct.Email == "e2e-fresh" {
got = ct
}
}
if got == nil {
t.Fatalf("the new client's traffic was dropped; reported clients: %+v", clients)
}
if got.Down < payload {
t.Fatalf("downlink = %d, want at least the %d bytes that were proxied", got.Down, payload)
}
}
// TestXrayAPI_E2E_TestRoutePortRange keeps TestRoute from wrapping an
// out-of-range port into the uint32 the core is asked about.
func TestXrayAPI_E2E_TestRoutePortRange(t *testing.T) {
c := startE2ECore(t, nil)
for _, port := range []int{-1, 65536, 70000} {
if _, err := c.api.TestRoute(RouteTestRequest{Domain: "example.com", Port: port}); err == nil {
t.Fatalf("TestRoute accepted the out-of-range port %d", port)
}
}
if _, err := c.api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 65535}); err != nil {
t.Fatalf("TestRoute rejected the valid port 65535: %v", err)
}
}