feat: add custom IP selection for nodes, tunnels, and forwards

- Add extra_ips field to nodes for multi-IP servers
- Add connect_ip field to chain_tunnel for specifying connection address
- Add in_ip field to forward_port for specifying listen address
- Frontend: add UI controls for extra IPs on node form
- Frontend: add connect IP input for tunnel chain nodes
- Frontend: add listen IP input for forward form
- Backend: resolve forward ingress with custom listen IP priority

Entire-Checkpoint: 557563462c16
This commit is contained in:
sagitchu
2026-03-03 08:24:15 +08:00
parent 15e4508be4
commit b8193417f5
10 changed files with 255 additions and 87 deletions
+17 -1
View File
@@ -127,6 +127,7 @@ interface ForwardForm {
name: string;
tunnelId: number | null;
inPort: number | null;
inIp: string;
remoteAddr: string;
interfaceName?: string;
strategy: string;
@@ -546,6 +547,7 @@ export default function ForwardPage() {
name: "",
tunnelId: null,
inPort: null,
inIp: "",
remoteAddr: "",
interfaceName: "",
strategy: "fifo",
@@ -1164,6 +1166,7 @@ export default function ForwardPage() {
name: "",
tunnelId: null,
inPort: null,
inIp: "",
remoteAddr: "",
interfaceName: "",
strategy: "fifo",
@@ -1182,6 +1185,7 @@ export default function ForwardPage() {
name: forward.name,
tunnelId: forward.tunnelId,
inPort: forward.inPort,
inIp: forward.inIp || "",
remoteAddr: forward.remoteAddr.split(",").join("\n"),
interfaceName: forward.interfaceName || "",
strategy: forward.strategy || "fifo",
@@ -1263,6 +1267,7 @@ export default function ForwardPage() {
name: form.name,
tunnelId: form.tunnelId,
inPort: form.inPort,
inIp: form.inIp || null,
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : "fifo",
speedId: normalizeSpeedId(form.speedId),
@@ -1270,11 +1275,11 @@ export default function ForwardPage() {
res = await updateForward(updateData);
} else {
// 创建时不需要id和userId(后端会自动设置)
const createData = {
name: form.name,
tunnelId: form.tunnelId,
inPort: form.inPort,
inIp: form.inIp || null,
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : "fifo",
speedId: normalizeSpeedId(form.speedId),
@@ -4023,6 +4028,17 @@ export default function ForwardPage() {
}}
/>
<Input
description="留空使用默认入口IP,多IP节点可指定监听地址"
label="监听IP"
placeholder="留空使用默认"
value={form.inIp}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({ ...prev, inIp: e.target.value }))
}
/>
<Textarea
description="格式: IP:端口 或 域名:端口,支持多个地址(每行一个)"
errorMessage={errors.remoteAddr}
+18 -1
View File
@@ -76,6 +76,7 @@ interface Node {
port: string;
tcpListenAddr?: string;
udpListenAddr?: string;
extraIPs?: string;
version?: string;
http?: number; // 0 关 1 开
tls?: number; // 0 关 1 开
@@ -99,7 +100,7 @@ interface Node {
rollbackLoading?: boolean;
}
interface NodeForm {
interface NodeForm {
id: number | null;
name: string;
serverHost: string;
@@ -109,6 +110,7 @@ interface NodeForm {
tcpListenAddr: string;
udpListenAddr: string;
interfaceName: string;
extraIPs: string;
http: number; // 0 关 1 开
tls: number; // 0 关 1 开
socks: number; // 0 关 1 开
@@ -183,6 +185,7 @@ export default function NodePage() {
tcpListenAddr: "[::]",
udpListenAddr: "[::]",
interfaceName: "",
extraIPs: "",
http: 0,
tls: 0,
socks: 0,
@@ -590,6 +593,7 @@ export default function NodePage() {
tcpListenAddr: node.tcpListenAddr || "[::]",
udpListenAddr: node.udpListenAddr || "[::]",
interfaceName: (node as any).interfaceName || "",
extraIPs: node.extraIPs || "",
http: typeof node.http === "number" ? node.http : 1,
tls: typeof node.tls === "number" ? node.tls : 1,
socks: typeof node.socks === "number" ? node.socks : 1,
@@ -817,6 +821,7 @@ export default function NodePage() {
const { serverHost, ...rest } = form;
const data = {
...rest,
extraIPs: form.extraIPs,
serverIp:
form.serverIpV4?.trim() ||
form.serverIpV6?.trim() ||
@@ -880,6 +885,7 @@ export default function NodePage() {
tcpListenAddr: "[::]",
udpListenAddr: "[::]",
interfaceName: "",
extraIPs: "",
http: 0,
tls: 0,
socks: 0,
@@ -1637,6 +1643,17 @@ export default function NodePage() {
}
/>
<Input
description="多IP服务器可填写额外IP地址,逗号分隔"
label="额外IP地址"
placeholder="例如: 192.168.1.100, 10.0.0.5"
value={form.extraIPs}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({ ...prev, extraIPs: e.target.value }))
}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
errorMessage={errors.tcpListenAddr}
+82
View File
@@ -70,6 +70,7 @@ interface ChainTunnel {
strategy?: string; // 'fifo' | 'round' | 'rand' - 仅转发链需要
chainType?: number; // 1: 入口, 2: 转发链, 3: 出口
inx?: number; // 转发链序号
connectIp?: string; // 连接IP(多IP节点指定连接地址)
}
interface Tunnel {
@@ -343,6 +344,20 @@ export default function TunnelPage() {
});
};
// 更新某一跳的所有节点的连接IP
const updateChainConnectIp = (groupIndex: number, connectIp: string) => {
setForm((prev) => {
const chainNodes = [...(prev.chainNodes || [])];
chainNodes[groupIndex] = (chainNodes[groupIndex] || []).map((node) => ({
...node,
connectIp,
}));
return { ...prev, chainNodes };
});
};
// 获取所有转发链中已选择的节点ID列表
const getSelectedChainNodeIds = (): number[] => {
return (form.chainNodes || []).flatMap((group) =>
@@ -1741,6 +1756,29 @@ export default function TunnelPage() {
<SelectItem key="rand">随机</SelectItem>
</Select>
</div>
{/* 连接IP - 转发链节点 */}
<Input
classNames={{
label: "text-xs",
}}
description="多IP节点可指定连接地址,留空使用默认"
label="连接IP"
placeholder="留空使用默认"
size="sm"
value={
groupNodes.length > 0
? groupNodes[0].connectIp || ""
: ""
}
variant="bordered"
onChange={(e) => {
updateChainConnectIp(
groupIndex,
e.target.value,
);
}}
/>
</div>
);
})}
@@ -2002,6 +2040,50 @@ export default function TunnelPage() {
<SelectItem key="rand">随机</SelectItem>
</Select>
</div>
{/* 连接IP - 出口节点 */}
<Input
classNames={{
label: "text-xs",
}}
description="多IP节点可指定连接地址,留空使用默认"
label="连接IP"
placeholder="留空使用默认"
size="sm"
value={
form.outNodeId && form.outNodeId.length > 0
? form.outNodeId[0].connectIp || ""
: ""
}
variant="bordered"
onChange={(e) => {
const value = e.target.value;
setForm((prev) => {
const currentOutNodes = prev.outNodeId || [];
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,
})),
};
});
}}
/>
</>
)}
</div>