mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-04 09:06:38 +08:00
feat: restrict user permissions and multi-node IP constraints
- Non-admin users cannot set speedId or inPort on forward create/update - Multi-entrance tunnels disable custom listen IP for forwards - Multi-exit tunnels disable custom connect IP - Multi-node hop chains disable custom connect IP per hop - Remove tunnel-first-IP fallback in forward ingress resolution - Add contract tests for non-admin permission restrictions Entire-Checkpoint: 133693290660
This commit is contained in:
@@ -670,6 +670,16 @@ export default function ForwardPage() {
|
||||
return tunnelInIpOptionMap.get(form.tunnelId) || [];
|
||||
}, [form.tunnelId, tunnelInIpOptionMap]);
|
||||
|
||||
const isCurrentTunnelMultiEntrance = useMemo(() => {
|
||||
if (!form.tunnelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentTunnel = allTunnels.find((tunnel) => tunnel.id === form.tunnelId);
|
||||
|
||||
return (currentTunnel?.inNodeId?.length || 0) > 1;
|
||||
}, [allTunnels, form.tunnelId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
diagnosisAbortRef.current?.abort();
|
||||
@@ -4249,35 +4259,37 @@ export default function ForwardPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="限速规则"
|
||||
placeholder="不限速"
|
||||
selectedKeys={
|
||||
selectedSpeedId !== null
|
||||
? [selectedSpeedId.toString()]
|
||||
: []
|
||||
}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as
|
||||
| string
|
||||
| undefined;
|
||||
{isAdmin && (
|
||||
<Select
|
||||
label="限速规则"
|
||||
placeholder="不限速"
|
||||
selectedKeys={
|
||||
selectedSpeedId !== null
|
||||
? [selectedSpeedId.toString()]
|
||||
: []
|
||||
}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
speedId: selectedKey ? Number(selectedKey) : null,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{availableSpeedLimits.map((speedLimit) => (
|
||||
<SelectItem
|
||||
key={speedLimit.id.toString()}
|
||||
textValue={speedLimit.name}
|
||||
>
|
||||
{speedLimit.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
speedId: selectedKey ? Number(selectedKey) : null,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{availableSpeedLimits.map((speedLimit) => (
|
||||
<SelectItem
|
||||
key={speedLimit.id.toString()}
|
||||
textValue={speedLimit.name}
|
||||
>
|
||||
{speedLimit.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Select
|
||||
description={
|
||||
@@ -4306,33 +4318,43 @@ export default function ForwardPage() {
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
description="指定入口端口,留空则从节点可用端口中自动分配"
|
||||
errorMessage={errors.inPort}
|
||||
isInvalid={!!errors.inPort}
|
||||
label="入口端口"
|
||||
placeholder="留空则自动分配可用端口"
|
||||
type="number"
|
||||
value={form.inPort !== null ? form.inPort.toString() : ""}
|
||||
variant="bordered"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
{isAdmin && (
|
||||
<Input
|
||||
description="指定入口端口,留空则从节点可用端口中自动分配"
|
||||
errorMessage={errors.inPort}
|
||||
isInvalid={!!errors.inPort}
|
||||
label="入口端口"
|
||||
placeholder="留空则自动分配可用端口"
|
||||
type="number"
|
||||
value={form.inPort !== null ? form.inPort.toString() : ""}
|
||||
variant="bordered"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
inPort: value ? parseInt(value) : null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
inPort: value ? parseInt(value) : null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Select
|
||||
description="从入口节点IP中选择,留空使用默认"
|
||||
description={
|
||||
isCurrentTunnelMultiEntrance
|
||||
? "多入口隧道不支持自定义监听IP,使用各节点默认IP"
|
||||
: "从入口节点IP中选择,留空使用默认"
|
||||
}
|
||||
isDisabled={
|
||||
!form.tunnelId || currentTunnelIpOptions.length === 0
|
||||
!form.tunnelId ||
|
||||
currentTunnelIpOptions.length === 0 ||
|
||||
isCurrentTunnelMultiEntrance
|
||||
}
|
||||
label="监听IP"
|
||||
placeholder={
|
||||
form.tunnelId
|
||||
isCurrentTunnelMultiEntrance
|
||||
? "多入口隧道使用节点默认IP"
|
||||
: form.tunnelId
|
||||
? currentTunnelIpOptions.length > 0
|
||||
? "选择入口监听IP"
|
||||
: "当前隧道入口节点暂无可选IP"
|
||||
|
||||
@@ -1604,6 +1604,8 @@ export default function TunnelPage() {
|
||||
.map((ct) => ct.nodeId);
|
||||
const groupIpOptions =
|
||||
getCommonIpOptions(groupSelectedNodeIds);
|
||||
const isMultiNodeGroup =
|
||||
groupSelectedNodeIds.length > 1;
|
||||
const selectedGroupConnectIp =
|
||||
groupNodes.length > 0
|
||||
? groupNodes[0].connectIp || ""
|
||||
@@ -1826,14 +1828,21 @@ export default function TunnelPage() {
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
description="按当前跳所选节点的共有IP进行选择,留空使用默认"
|
||||
description={
|
||||
isMultiNodeGroup
|
||||
? "多节点跳不支持设置自定义连接IP,使用各节点默认IP"
|
||||
: "按当前跳所选节点的共有IP进行选择,留空使用默认"
|
||||
}
|
||||
isDisabled={
|
||||
groupSelectedNodeIds.length === 0 ||
|
||||
groupIpOptions.length === 0
|
||||
groupIpOptions.length === 0 ||
|
||||
isMultiNodeGroup
|
||||
}
|
||||
label="连接IP"
|
||||
placeholder={
|
||||
groupSelectedNodeIds.length === 0
|
||||
isMultiNodeGroup
|
||||
? "多节点跳使用节点默认IP"
|
||||
: groupSelectedNodeIds.length === 0
|
||||
? "请先选择节点"
|
||||
: groupIpOptions.length > 0
|
||||
? "选择连接IP"
|
||||
@@ -1886,7 +1895,17 @@ export default function TunnelPage() {
|
||||
<Divider />
|
||||
<h3 className="text-lg font-semibold">出口配置</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
|
||||
{(() => {
|
||||
const selectedOutNodeIds = (form.outNodeId || [])
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId);
|
||||
const isMultiExit = selectedOutNodeIds.length > 1;
|
||||
const commonOutIpOptions =
|
||||
getCommonIpOptions(selectedOutNodeIds);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
|
||||
{/* 节点选择 - 移动端100%,桌面端50% */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<Select
|
||||
@@ -2124,88 +2143,82 @@ export default function TunnelPage() {
|
||||
<SelectItem key="round">轮询</SelectItem>
|
||||
<SelectItem key="rand">随机</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接IP - 出口节点 */}
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
description="按出口节点共同可用IP选择,留空使用默认"
|
||||
isDisabled={
|
||||
(form.outNodeId || []).filter(
|
||||
(ct) => ct.nodeId !== -1,
|
||||
).length === 0 ||
|
||||
getCommonIpOptions(
|
||||
(form.outNodeId || [])
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId),
|
||||
).length === 0
|
||||
}
|
||||
label="连接IP"
|
||||
placeholder={
|
||||
(form.outNodeId || []).filter(
|
||||
(ct) => ct.nodeId !== -1,
|
||||
).length === 0
|
||||
? "请先选择出口节点"
|
||||
: getCommonIpOptions(
|
||||
(form.outNodeId || [])
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId),
|
||||
).length > 0
|
||||
? "选择连接IP"
|
||||
: "所选节点无共同可选IP"
|
||||
}
|
||||
selectedKeys={[
|
||||
form.outNodeId && form.outNodeId.length > 0
|
||||
? form.outNodeId[0].connectIp || "__default__"
|
||||
: "__default__",
|
||||
]}
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
const value =
|
||||
selectedKey === "__default__" ? "" : selectedKey;
|
||||
{/* 连接IP - 出口节点 */}
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
description={
|
||||
isMultiExit
|
||||
? "多出口隧道不支持设置自定义连接IP,使用各节点默认IP"
|
||||
: "按出口节点共同可用IP选择,留空使用默认"
|
||||
}
|
||||
isDisabled={
|
||||
selectedOutNodeIds.length === 0 ||
|
||||
commonOutIpOptions.length === 0 ||
|
||||
isMultiExit
|
||||
}
|
||||
label="连接IP"
|
||||
placeholder={
|
||||
isMultiExit
|
||||
? "多出口隧道使用节点默认IP"
|
||||
: selectedOutNodeIds.length === 0
|
||||
? "请先选择出口节点"
|
||||
: commonOutIpOptions.length > 0
|
||||
? "选择连接IP"
|
||||
: "所选节点无共同可选IP"
|
||||
}
|
||||
selectedKeys={[
|
||||
form.outNodeId && form.outNodeId.length > 0
|
||||
? form.outNodeId[0].connectIp || "__default__"
|
||||
: "__default__",
|
||||
]}
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
const value =
|
||||
selectedKey === "__default__" ? "" : selectedKey;
|
||||
|
||||
setForm((prev) => {
|
||||
const currentOutNodes = prev.outNodeId || [];
|
||||
setForm((prev) => {
|
||||
const currentOutNodes = prev.outNodeId || [];
|
||||
|
||||
if (currentOutNodes.length === 0) {
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: [
|
||||
{
|
||||
nodeId: -1,
|
||||
chainType: 3,
|
||||
protocol: "tls",
|
||||
strategy: "round",
|
||||
connectIp: value,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (currentOutNodes.length === 0) {
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: [
|
||||
{
|
||||
nodeId: -1,
|
||||
chainType: 3,
|
||||
protocol: "tls",
|
||||
strategy: "round",
|
||||
connectIp: value,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: currentOutNodes.map((ct) => ({
|
||||
...ct,
|
||||
connectIp: value,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectItem key="__default__">默认连接IP</SelectItem>
|
||||
{getCommonIpOptions(
|
||||
(form.outNodeId || [])
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId),
|
||||
).map((ip) => (
|
||||
<SelectItem key={ip}>{ip}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: currentOutNodes.map((ct) => ({
|
||||
...ct,
|
||||
connectIp: value,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectItem key="__default__">默认连接IP</SelectItem>
|
||||
{commonOutIpOptions.map((ip) => (
|
||||
<SelectItem key={ip}>{ip}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user