mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-05 04:35:01 +03:00
Compare commits
4 Commits
fd17255f1d
...
4605f00a15
| Author | SHA1 | Date | |
|---|---|---|---|
| 4605f00a15 | |||
| dc6a16019e | |||
| fea6a20f7c | |||
| 7f7b7e16a4 |
@@ -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
@@ -2,7 +2,8 @@
|
||||
.idea/
|
||||
.vscode/
|
||||
.cursor/
|
||||
.claude/*
|
||||
.specify/
|
||||
.claude/
|
||||
.cache/
|
||||
.sync*
|
||||
|
||||
|
||||
+1
-1
@@ -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'], '');
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+12
-5
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user