Files
3x-ui/frontend/src/pages/inbounds/form/useInboundFallbacks.ts
T
Hamed 19f2f21d81 fix(inbounds): reject incompatible VLESS fallbacks and prevent Xray crashes (#7003)
Xray refuses a VLESS inbound with server decryption whenever fallbacks is non-nil, even an empty list, so an API or imported inbound with decryption and fallbacks: [] took every inbound down. Empty fallback arrays are now normalized away on add, update, config build and node push; non-empty ones are refused with decryption, enabling decryption over stored fallback rules is refused, and a legacy conflicting row holds back the running core instead of stopping it. The form, API docs and locales follow. Closes #7001.
2026-11-03 15:52:30 +01:00

204 lines
7.3 KiB
TypeScript

import { useRef, useState } from 'react';
import { HttpUtil } from '@/utils';
import type { FallbackRow } from '@/schemas/forms/inbound-form';
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
// Fallback rows for VLESS/Trojan TLS inbounds: state + the load/save/derive
// and add/update/remove/move handlers, plus the eligible-child option list.
// Lifted out of InboundFormModal so the modal body stays focused on layout.
export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBInbound[]) {
const fallbackKeyRef = useRef(0);
const [fallbacks, setFallbacks] = useState<FallbackRow[]>([]);
const [hasStoredFallbacks, setHasStoredFallbacks] = useState(false);
const [fallbacksLoaded, setFallbacksLoaded] = useState(false);
const fallbackChildOptions = (dbInbounds || [])
.filter((ib) => ib.id !== dbInbound?.id)
.map((ib) => ({
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
value: ib.id,
}));
const loadFallbacks = async (masterId: number | null) => {
setFallbacksLoaded(false);
setHasStoredFallbacks(false);
if (!masterId) {
setFallbacksLoaded(true);
setFallbacks([]);
return;
}
const msg = await HttpUtil.get(`/panel/api/inbounds/${masterId}/fallbacks`);
if (!msg?.success || !Array.isArray(msg.obj)) {
setFallbacks([]);
return;
}
setFallbacksLoaded(true);
setHasStoredFallbacks(msg.obj.length > 0);
setFallbacks(
(
msg.obj as {
childId: number;
name?: string;
alpn?: string;
path?: string;
dest?: string;
xver?: number;
}[]
).map((r) => ({
rowKey: `fb-${++fallbackKeyRef.current}`,
childId: r.childId && r.childId > 0 ? r.childId : null,
name: r.name || '',
alpn: r.alpn || '',
path: r.path || '',
dest: r.dest || '',
xver: r.xver || 0,
})),
);
};
const saveFallbacks = async (masterId: number) => {
if (!masterId) return true;
const payload = {
fallbacks: fallbacks
.filter((c) => c.childId || (c.dest ?? '').trim())
.map((c, i) => ({
childId: c.childId,
name: c.name,
alpn: c.alpn,
path: c.path,
dest: c.dest,
xver: Number(c.xver) || 0,
sortOrder: i,
})),
};
const msg = await HttpUtil.post(`/panel/api/inbounds/${masterId}/fallbacks`, payload, {
headers: { 'Content-Type': 'application/json' },
});
if (msg?.success) setHasStoredFallbacks(payload.fallbacks.length > 0);
return !!msg?.success;
};
// Derive a fallback row's SNI / ALPN / Path / xver from a child
// inbound's streamSettings — what the legacy panel auto-filled when an
// operator wired a fallback target. SNI/ALPN come straight off the
// child's TLS block; path depends on the child's transport (ws/grpc
// /httpupgrade carry an explicit path; tcp/kcp/xhttp have no path of
// their own). xver stays 0 unless the child explicitly opts in via
// PROXY-protocol sockopt.
const deriveFallbackDefaults = (childId: number): Partial<FallbackRow> => {
const child = (dbInbounds || []).find((ib) => ib.id === childId);
if (!child) return {};
const stream = coerceInboundJsonField(child.streamSettings);
const tls = (stream.tlsSettings as Record<string, unknown> | undefined) ?? {};
const network = typeof stream.network === 'string' ? stream.network : '';
const sni = typeof tls.serverName === 'string' ? tls.serverName : '';
const alpnArr = Array.isArray(tls.alpn) ? tls.alpn : [];
const alpn = alpnArr.filter((v) => typeof v === 'string').join(',');
let path = '';
if (network === 'ws') {
const ws = (stream.wsSettings as Record<string, unknown> | undefined) ?? {};
if (typeof ws.path === 'string') path = ws.path;
} else if (network === 'grpc') {
const grpc = (stream.grpcSettings as Record<string, unknown> | undefined) ?? {};
if (typeof grpc.serviceName === 'string') path = grpc.serviceName;
} else if (network === 'httpupgrade') {
const hu = (stream.httpupgradeSettings as Record<string, unknown> | undefined) ?? {};
if (typeof hu.path === 'string') path = hu.path;
} else if (network === 'xhttp') {
const xh = (stream.xhttpSettings as Record<string, unknown> | undefined) ?? {};
if (typeof xh.path === 'string') path = xh.path;
}
return { name: sni, alpn, path, xver: 0 };
};
const addFallback = () => {
setFallbacks((prev) => [
...prev,
{
rowKey: `fb-${++fallbackKeyRef.current}`,
childId: null,
name: '',
alpn: '',
path: '',
dest: '',
xver: 0,
},
]);
};
const updateFallback = (rowKey: string, patch: Partial<FallbackRow>) => {
setFallbacks((prev) =>
prev.map((r) => {
if (r.rowKey !== rowKey) return r;
// When the picker selects a new child inbound and the row hasn't
// been hand-edited yet (sni/alpn/path/dest all blank, xver = 0),
// pull the SNI/ALPN/Path defaults off that child. Operators who
// intentionally typed values keep them — we only fill the empties.
if (typeof patch.childId === 'number' && patch.childId !== r.childId) {
const isPristine = !r.name && !r.alpn && !r.path && !r.dest && r.xver === 0;
if (isPristine) return { ...r, ...patch, ...deriveFallbackDefaults(patch.childId) };
}
return { ...r, ...patch };
}),
);
};
const removeFallback = (idx: number) => {
setFallbacks((prev) => prev.filter((_, i) => i !== idx));
};
// Move a fallback row up/down by swapping adjacent indices. The order
// is persisted via the fallback row's sortOrder (rebuilt by index on
// save), so reordering survives reloads.
const moveFallback = (idx: number, direction: -1 | 1) => {
setFallbacks((prev) => {
const target = idx + direction;
if (target < 0 || target >= prev.length) return prev;
const next = prev.slice();
[next[idx], next[target]] = [next[target], next[idx]];
return next;
});
};
// One-shot: add a fresh fallback row for every eligible inbound (i.e.
// every option in fallbackChildOptions) that is not already wired up.
// Convenient for operators who want catch-all routing to every host
// they manage on the panel.
const addAllFallbacks = () => {
setFallbacks((prev) => {
const alreadyHave = new Set(prev.map((r) => r.childId));
const additions = fallbackChildOptions
.filter((opt) => !alreadyHave.has(opt.value))
.map<FallbackRow>((opt) => {
const derived = deriveFallbackDefaults(opt.value);
return {
rowKey: `fb-${++fallbackKeyRef.current}`,
childId: opt.value,
name: derived.name ?? '',
alpn: derived.alpn ?? '',
path: derived.path ?? '',
dest: '',
xver: derived.xver ?? 0,
};
});
if (additions.length === 0) return prev;
return [...prev, ...additions];
});
};
return {
fallbacks,
hasStoredFallbacks,
fallbacksLoaded,
fallbackChildOptions,
loadFallbacks,
saveFallbacks,
addFallback,
updateFallback,
removeFallback,
moveFallback,
addAllFallbacks,
};
}