Merge remote-tracking branch 'upstream/main' into sync-3.6.0

# Conflicts:
#	.github/workflows/claude-bot.yml
#	.github/workflows/release.yml
#	DockerInit.sh
#	frontend/package-lock.json
#	frontend/package.json
#	frontend/src/hooks/useClients.ts
#	frontend/src/layouts/AppSidebar.tsx
#	frontend/src/main.tsx
#	internal/config/version
#	internal/database/model/model.go
#	internal/web/service/client_wireguard.go
#	internal/web/service/inbound.go
This commit is contained in:
Kuzz007
2026-08-01 21:59:29 +03:00
250 changed files with 12904 additions and 5650 deletions
@@ -82,12 +82,43 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
case 'header-custom':
return { clients: [], servers: [] };
case 'xmc':
return { hostname: '', usernames: [], password: RandomUtil.randomLowerAndNum(16) };
return { hostname: '', profiles: [defaultXmcProfile()], password: RandomUtil.randomLowerAndNum(16) };
default:
return {};
}
}
function defaultXmcProfile(): Record<string, unknown> {
return { username: '', uuid: '', texturesValue: '', texturesSignature: '' };
}
// xray-core #6487 replaced the xmc mask's `usernames` string list with
// `profiles` objects carrying a Mojang-signed session profile, and dropped the
// "default to Dream" fallback so at least one complete profile is now
// mandatory. The signature can only come from Mojang's session server, so a
// legacy username cannot be upgraded automatically — carry it into a profile
// stub instead, which keeps the operator's player names visible and leaves the
// per-field validators pointing at exactly what still has to be filled in.
export function migrateXmcSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
const out: Record<string, unknown> = { ...settings };
let changed = false;
if (!Array.isArray(out.profiles) && Array.isArray(out.usernames)) {
out.profiles = out.usernames
.filter((name): name is string => typeof name === 'string' && name.trim() !== '')
.map((name) => ({ ...defaultXmcProfile(), username: name }));
changed = true;
}
if ('usernames' in out) {
delete out.usernames;
changed = true;
}
if (!Array.isArray(out.profiles)) {
out.profiles = [];
changed = true;
}
return { next: out, changed };
}
// xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges
// with `lengths`/`delays` arrays (the singular keys remain in core only as a
// fallback). Lift any legacy singular value into a one-element array so the
@@ -171,8 +202,8 @@ function defaultUdpHop(): Record<string, unknown> {
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
const base = asPath(name);
// Migrate legacy single-range fragment masks to the per-segment arrays once
// on mount so configs saved before #6334 render in the list UI.
// Migrate legacy TCP mask shapes once on mount so configs saved before
// #6334 (fragment ranges) and #6487 (xmc profiles) render in the list UI.
const migratedRef = useRef(false);
useEffect(() => {
if (migratedRef.current) return;
@@ -183,8 +214,12 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
const next = tcp.map((mask) => {
if (!mask || typeof mask !== 'object') return mask;
const m = mask as Record<string, unknown>;
if (m.type !== 'fragment' || !m.settings || typeof m.settings !== 'object') return mask;
const { next: migrated, changed } = migrateFragmentSettings(m.settings as Record<string, unknown>);
if (m.type !== 'fragment' && m.type !== 'xmc') return mask;
if (!m.settings || typeof m.settings !== 'object') return mask;
const settings = m.settings as Record<string, unknown>;
const { next: migrated, changed } = m.type === 'fragment'
? migrateFragmentSettings(settings)
: migrateXmcSettings(settings);
if (!changed) return mask;
anyChanged = true;
return { ...m, settings: migrated };
@@ -380,13 +415,7 @@ function TcpMaskItem({
<Form.Item label="Hostname" name={[fieldName, 'settings', 'hostname']}>
<Input placeholder="Server address mimicked in the handshake" />
</Form.Item>
<Form.Item
label="Usernames"
name={[fieldName, 'settings', 'usernames']}
extra="Player names offered to probes; core defaults to Dream when empty."
>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
</Form.Item>
<XmcProfilesList tcpFieldName={fieldName} />
<Form.Item label="Password" required>
<Space.Compact block>
<Form.Item
@@ -528,6 +557,92 @@ function getDeep(obj: unknown, path: (string | number)[]): unknown {
return cur;
}
// Mojang hands the profile UUID back undashed from the session server and
// dashed from most other endpoints; xray-core parses either, so accept both
// rather than forcing the operator to reformat what they pasted.
const XMC_UUID_PATTERN = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
const XMC_USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
function validateXmcUsername(_rule: unknown, value: unknown): Promise<void> {
if (typeof value === 'string' && XMC_USERNAME_PATTERN.test(value)) return Promise.resolve();
return Promise.reject(new Error('3-16 characters, letters/digits/underscore only'));
}
function validateXmcUuid(_rule: unknown, value: unknown): Promise<void> {
if (typeof value === 'string' && XMC_UUID_PATTERN.test(value.trim())) return Promise.resolve();
return Promise.reject(new Error('Enter the profile UUID (dashed or 32 hex characters)'));
}
// Each mask needs at least one fully signed profile since xray-core #6487 —
// an empty or partial list makes the core reject the whole config, so the
// panel blocks the save here rather than letting the backend drop the mask.
function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
const { t } = useTranslation();
return (
<Form.List name={[tcpFieldName, 'settings', 'profiles']}>
{(profiles, { add, remove }) => (
<>
<Form.Item
label="Profiles"
extra="Signed Minecraft session profiles; resolve the UUID by username, then fetch the profile with unsigned=false."
>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add(defaultXmcProfile())}
/>
</Form.Item>
{profiles.map((profile, idx) => (
<div key={profile.key}>
<Divider style={{ margin: 0 }}>
Profile {idx + 1}
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(profile.name)}
onKeyDown={activateOnKey(() => remove(profile.name))}
/>
</Divider>
<Form.Item
label="Username"
name={[profile.name, 'username']}
rules={[{ validator: validateXmcUsername }]}
>
<Input placeholder="Notch" />
</Form.Item>
<Form.Item
label="UUID"
name={[profile.name, 'uuid']}
rules={[{ validator: validateXmcUuid }]}
>
<Input placeholder="069a79f4-44e9-4726-a5be-fca90e38aaf5" />
</Form.Item>
<Form.Item
label="Textures Value"
name={[profile.name, 'texturesValue']}
rules={[{ required: true, message: 'Textures value is required' }]}
>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 value from the session profile" />
</Form.Item>
<Form.Item
label="Textures Signature"
name={[profile.name, 'texturesSignature']}
rules={[{ required: true, message: 'Textures signature is required' }]}
>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 signature from the session profile" />
</Form.Item>
</div>
))}
</>
)}
</Form.List>
);
}
function HeaderCustomGroups({
tcpFieldName, form, absoluteSettingsPath,
}: {
@@ -1031,12 +1146,18 @@ function ItemEditor({
onRemove?: () => void;
}) {
const { t } = useTranslation();
/**
* Switching to `array` clears the packet instead of emptying it to `[]`:
* that branch is rand-driven, and xray-core counts even an empty array as a
* packet, rejecting an item that carries both a packet and a rand. That
* error fails the whole config, so one such item keeps every inbound offline.
*/
const onTypeChange = (v: string) => {
if (v === 'base64') {
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
} else if (v === 'array') {
form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0);
form.setFieldValue([...absoluteItemPath, 'packet'], []);
form.setFieldValue([...absoluteItemPath, 'packet'], undefined);
} else {
form.setFieldValue([...absoluteItemPath, 'packet'], '');
}
+1 -1
View File
@@ -175,7 +175,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(
@@ -42,6 +42,7 @@ export interface RawInboundRow {
enable?: boolean;
expiryTime?: number;
trafficReset?: string;
trafficResetDay?: number;
lastTrafficResetTime?: number;
nodeId?: number | null;
shareAddrStrategy?: string;
@@ -61,6 +62,7 @@ export interface WireInboundPayload {
enable: boolean;
expiryTime: number;
trafficReset: TrafficReset;
trafficResetDay: number;
lastTrafficResetTime: number;
listen: string;
port: number;
@@ -203,6 +205,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
down: row.down ?? 0,
total: row.total ?? 0,
trafficReset: coerceTrafficReset(row.trafficReset),
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
nodeId: row.nodeId ?? null,
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
@@ -346,6 +349,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
enable: values.enable,
expiryTime: values.expiryTime,
trafficReset: values.trafficReset,
trafficResetDay: values.trafficResetDay,
lastTrafficResetTime: values.lastTrafficResetTime,
listen: values.listen,
port: values.port,
+17 -9
View File
@@ -43,8 +43,7 @@ function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
// Pull the bidirectional SplitHTTPConfig fields out of xhttp into a
// compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts,
// scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client
// reading the share link wouldn't honor them. Mirrors the legacy
// Inbound.buildXhttpExtra exactly so the shadow link snapshots line up.
// reading the share link wouldn't honor them.
function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string, unknown> | null {
if (!xhttp) return null;
const extra: Record<string, unknown> = {};
@@ -86,6 +85,15 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
const v = xhttp[k];
if (typeof v === 'string' && v.length > 0 && v !== coreDefaults[k]) extra[k] = v;
}
// xray-core #6258 renamed these fields, but older clients still read the
// legacy names from share-link extra. Emit both names so one link works
// across old and new clients while the stored panel config stays canonical.
if (typeof extra.sessionIDPlacement === 'string') {
extra.sessionPlacement = extra.sessionIDPlacement;
}
if (typeof extra.sessionIDKey === 'string') {
extra.sessionKey = extra.sessionIDKey;
}
// Headers on the wire are a record; emit them as a map upstream's
// SplitHTTPConfig.headers expects, dropping Host (already on the URL).
@@ -705,11 +713,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
@@ -728,8 +737,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');
@@ -104,6 +104,63 @@ export function validateRealityTarget(target: string): string | undefined {
return undefined;
}
/**
* Parses a REALITY client-version string the way xray-core's config loader
* does: one to three dot-separated numeric parts, each 0-255. Returns the
* parts padded to three entries, or undefined when the string is not a valid
* version.
*/
export function parseRealityClientVer(value: string): [number, number, number] | undefined {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parts = trimmed.split('.');
if (parts.length > 3) return undefined;
const nums: number[] = [];
for (const part of parts) {
if (!/^\d+$/.test(part)) return undefined;
const n = Number(part);
if (n > 255) return undefined;
nums.push(n);
}
while (nums.length < 3) nums.push(0);
return nums as [number, number, number];
}
/**
* Validates a REALITY client-version field; empty means "not set" and is
* valid. The value is saved exactly as typed and xray-core's part parser
* accepts no surrounding whitespace, so a value that differs from its
* trimmed form is rejected rather than silently passed to the wire.
*/
export function validateRealityClientVer(value: string): string | undefined {
if (!value) return undefined;
if (value !== value.trim() || !parseRealityClientVer(value)) {
return 'pages.inbounds.form.clientVerInvalid';
}
return undefined;
}
/**
* Validates the max client-version field: format first, then that a non-empty
* max is not below a non-empty min (an inverted range rejects every client).
* An empty or malformed min is left to the min field's own validation.
*/
export function validateRealityMaxClientVer(max: string, min: string): string | undefined {
const formatError = validateRealityClientVer(max);
if (formatError) return formatError;
const maxParts = parseRealityClientVer(max);
const minParts = parseRealityClientVer(min);
if (!maxParts || !minParts) return undefined;
for (let i = 0; i < 3; i++) {
if (maxParts[i] !== minParts[i]) {
return maxParts[i] < minParts[i]
? 'pages.inbounds.form.maxClientVerBelowMin'
: undefined;
}
}
return undefined;
}
function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
const lift = (legacy: string, renamed: string) => {
const v = obj[legacy];