feat: add nftables forwarding mode

This commit is contained in:
sagitchu
2026-06-01 19:47:26 +08:00
parent 6bfa7f0166
commit a599f383f5
29 changed files with 5161 additions and 53 deletions
+6
View File
@@ -129,6 +129,12 @@ export const getNodeReleases = (channel: ReleaseChannel = "stable") =>
Network.post<NodeReleaseApiItem[]>("/node/releases", { channel });
export const rollbackNode = (id: number) =>
Network.post("/node/rollback", { id });
export const testNodeNftables = (nodeId: number) =>
Network.post("/node/nftables/test", { nodeId });
export const reconcileNodeNftables = (nodeId: number) =>
Network.post("/node/nftables/reconcile", { nodeId });
export const clearNodeNftables = (nodeId: number) =>
Network.post("/node/nftables/clear", { nodeId });
// 隧道CRUD操作 - 全部使用POST请求
export const createTunnel = (data: TunnelMutationPayload) =>
+31
View File
@@ -2,6 +2,8 @@ export interface NodeApiItem {
id: number;
name: string;
status: number;
forwardMode?: "agent" | "nftables";
sshConfig?: NodeSshConfigApiItem | null;
inx?: number;
remark?: string;
expiryTime?: number;
@@ -44,6 +46,7 @@ export interface TunnelApiItem {
name: string;
type: number;
status: number;
forwardMode?: "agent" | "nftables";
flow?: number;
trafficRatio?: number;
inIp?: string;
@@ -63,6 +66,7 @@ export interface ForwardApiItem {
id: number;
name: string;
status: number;
forwardMode?: "agent" | "nftables";
tunnelName?: string;
tunnelTrafficRatio?: number;
inIp?: string;
@@ -303,6 +307,8 @@ export interface NodeMutationPayload {
id?: number | null;
name?: string;
status?: number;
forwardMode?: "agent" | "nftables";
sshConfig?: NodeSshConfigMutationPayload | null;
inx?: number;
remark?: string;
expiryTime?: number;
@@ -320,6 +326,29 @@ export interface NodeMutationPayload {
socks?: number;
}
export interface NodeSshConfigApiItem {
host?: string;
port?: number;
username?: string;
authType?: "password" | "private_key";
password?: string;
privateKey?: string;
passphrase?: string;
sudoMode?: "none" | "sudo" | "sudo_su";
[key: string]: unknown;
}
export interface NodeSshConfigMutationPayload {
host?: string;
port?: number;
username?: string;
authType?: "password" | "private_key";
password?: string;
privateKey?: string;
passphrase?: string;
sudoMode?: "none" | "sudo" | "sudo_su";
}
export interface TunnelChainNodePayload {
nodeId: number;
protocol?: string;
@@ -334,6 +363,7 @@ export interface TunnelMutationPayload {
name?: string;
type?: number;
status?: number;
forwardMode?: "agent" | "nftables";
flow?: number;
trafficRatio?: number;
inIp?: string;
@@ -381,6 +411,7 @@ export interface ForwardMutationPayload {
id?: number;
name?: string;
status?: number;
forwardMode?: "agent" | "nftables";
tunnelId?: number | null;
inIp?: string;
inPort?: number | null;
+2
View File
@@ -106,6 +106,7 @@ import { JwtUtil } from "@/utils/jwt";
interface Forward {
id: number;
name: string;
forwardMode?: "agent" | "nftables";
tunnelId: number;
tunnelName: string;
tunnelTrafficRatio?: number;
@@ -134,6 +135,7 @@ interface Forward {
interface Tunnel {
id: number;
name: string;
forwardMode?: "agent" | "nftables";
type?: number;
inIp?: string;
inNodeId?: Array<{ nodeId: number }>;
+316 -10
View File
@@ -109,6 +109,17 @@ interface Node {
socks?: number; // 0 关 1 开
status: number;
isRemote?: number;
forwardMode?: "agent" | "nftables";
sshConfig?: {
host?: string;
port?: number;
username?: string;
authType?: "password" | "private_key";
password?: string;
privateKey?: string;
passphrase?: string;
sudoMode?: "none" | "sudo" | "sudo_su";
} | null;
remoteUrl?: string;
syncError?: string;
connectionStatus: "online" | "offline";
@@ -132,6 +143,15 @@ interface NodeForm {
udpListenAddr: string;
interfaceName: string;
extraIPs: string;
forwardMode: "agent" | "nftables";
sshHost: string;
sshPort: string;
sshUsername: string;
sshAuthType: "password" | "private_key";
sshPassword: string;
sshPrivateKey: string;
sshPassphrase: string;
sshSudoMode: "none" | "sudo" | "sudo_su";
http: number; // 0 关 1 开
tls: number; // 0 关 1 开
socks: number; // 0 关 1 开
@@ -344,11 +364,25 @@ export default function NodePage() {
udpListenAddr: "[::]",
interfaceName: "",
extraIPs: "",
forwardMode: "agent",
sshHost: "",
sshPort: "22",
sshUsername: "",
sshAuthType: "private_key",
sshPassword: "",
sshPrivateKey: "",
sshPassphrase: "",
sshSudoMode: "none",
http: 0,
tls: 0,
socks: 0,
});
const [errors, setErrors] = useState<Record<string, string>>({});
const isNftablesMode = form.forwardMode === "nftables";
const protocolControlsDisabled = protocolDisabled || isNftablesMode;
const protocolControlsReason = isNftablesMode
? "nftables 模式不支持 agent 协议开关"
: protocolDisabledReason || "等待节点上线后再设置";
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
@@ -856,6 +890,27 @@ export default function NodePage() {
newErrors.port = portValidation.error || "端口格式错误";
}
if (form.forwardMode === "nftables") {
if (!form.sshHost.trim()) {
newErrors.sshHost = "请输入 SSH 主机";
}
const sshPort = Number(form.sshPort);
if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65535) {
newErrors.sshPort = "SSH 端口必须为 1-65535";
}
if (!form.sshUsername.trim()) {
newErrors.sshUsername = "请输入 SSH 用户名";
}
if (form.sshAuthType === "password") {
if (!isEdit && !form.sshPassword.trim()) {
newErrors.sshPassword = "请输入 SSH 密码";
}
} else if (!isEdit && !form.sshPrivateKey.trim()) {
newErrors.sshPrivateKey = "请输入 SSH 私钥";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
@@ -898,15 +953,28 @@ export default function NodePage() {
udpListenAddr: node.udpListenAddr || "[::]",
interfaceName: (node as any).interfaceName || "",
extraIPs: node.extraIPs || "",
forwardMode: node.forwardMode === "nftables" ? "nftables" : "agent",
sshHost: node.sshConfig?.host || normalizedHost || legacy,
sshPort: String(node.sshConfig?.port || 22),
sshUsername: node.sshConfig?.username || "",
sshAuthType: node.sshConfig?.authType || "private_key",
sshPassword: node.sshConfig?.password || "",
sshPrivateKey: node.sshConfig?.privateKey || "",
sshPassphrase: node.sshConfig?.passphrase || "",
sshSudoMode: node.sshConfig?.sudoMode || "none",
http: typeof node.http === "number" ? node.http : 1,
tls: typeof node.tls === "number" ? node.tls : 1,
socks: typeof node.socks === "number" ? node.socks : 1,
});
const offline = node.connectionStatus !== "online";
setProtocolDisabled(offline);
setProtocolDisabled(offline || node.forwardMode === "nftables");
setProtocolDisabledReason(
offline ? "节点未在线,等待节点上线后再设置" : "",
node.forwardMode === "nftables"
? "nftables 模式不支持 agent 协议开关"
: offline
? "节点未在线,等待节点上线后再设置"
: "",
);
setDialogVisible(true);
};
@@ -1166,12 +1234,33 @@ export default function NodePage() {
try {
const apiCall = isEdit ? updateNode : createNode;
const { serverHost, ...rest } = form;
const sshConfig =
form.forwardMode === "nftables"
? {
host: form.sshHost.trim(),
port: Number(form.sshPort || 22),
username: form.sshUsername.trim(),
authType: form.sshAuthType,
password:
form.sshAuthType === "password"
? form.sshPassword.trim()
: undefined,
privateKey:
form.sshAuthType === "private_key"
? form.sshPrivateKey
: undefined,
passphrase: form.sshPassphrase,
sudoMode: form.sshSudoMode,
}
: null;
const data = {
...rest,
remark: form.remark.trim(),
expiryTime: form.expiryTime,
renewalCycle: form.renewalCycle,
extraIPs: form.extraIPs,
forwardMode: form.forwardMode,
sshConfig,
serverIp:
form.serverIpV4?.trim() ||
form.serverIpV6?.trim() ||
@@ -1206,6 +1295,8 @@ export default function NodePage() {
tcpListenAddr: form.tcpListenAddr,
udpListenAddr: form.udpListenAddr,
interfaceName: form.interfaceName,
forwardMode: form.forwardMode,
sshConfig,
http: form.http,
tls: form.tls,
socks: form.socks,
@@ -1242,6 +1333,15 @@ export default function NodePage() {
udpListenAddr: "[::]",
interfaceName: "",
extraIPs: "",
forwardMode: "agent",
sshHost: "",
sshPort: "22",
sshUsername: "",
sshAuthType: "password",
sshPassword: "",
sshPrivateKey: "",
sshPassphrase: "",
sshSudoMode: "none",
http: 0,
tls: 0,
socks: 0,
@@ -2581,6 +2681,43 @@ export default function NodePage() {
}
/>
<Select
label="转发模式"
selectedKeys={[form.forwardMode]}
variant="bordered"
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as
| NodeForm["forwardMode"]
| undefined;
setForm((prev) => ({
...prev,
forwardMode: selected || "agent",
}));
}}
>
<SelectItem key="agent" textValue="agent">
agent 模式
</SelectItem>
<SelectItem key="nftables" textValue="nftables">
nftables 模式
</SelectItem>
</Select>
{form.forwardMode === "nftables" ? (
<Alert
color="warning"
description="nftables 模式仅支持纯转发,不支持隧道、流量控制和 agent 安装,规则将由面板通过 SSH 下发。"
variant="flat"
/>
) : (
<Alert
color="primary"
description="agent 模式会继续使用节点 agent 执行转发与管理操作。"
variant="flat"
/>
)}
{/* 高级配置 */}
<Accordion className="px-0" variant="light">
<AccordionItem
@@ -2594,6 +2731,177 @@ export default function NodePage() {
}
>
<div className="space-y-4 pb-2">
{form.forwardMode === "nftables" ? (
<div className="space-y-4 rounded-xl border border-warning-200 bg-warning-50/60 p-4 dark:border-warning-500/30 dark:bg-warning-950/20">
<div>
<div className="text-sm font-medium text-warning-700 dark:text-warning-300">
SSH 配置
</div>
<div className="text-xs text-default-500">
面板会通过 SSH 下发 nftables 规则,不会安装 agent。
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
errorMessage={errors.sshHost}
isInvalid={!!errors.sshHost}
label="SSH 主机"
placeholder="例如: 192.0.2.10"
value={form.sshHost}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({
...prev,
sshHost: e.target.value,
}))
}
/>
<Input
errorMessage={errors.sshPort}
isInvalid={!!errors.sshPort}
label="SSH 端口"
placeholder="22"
value={form.sshPort}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({
...prev,
sshPort: e.target.value,
}))
}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
errorMessage={errors.sshUsername}
isInvalid={!!errors.sshUsername}
label="SSH 用户名"
placeholder="例如: root"
value={form.sshUsername}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({
...prev,
sshUsername: e.target.value,
}))
}
/>
<Select
label="SSH 认证方式"
selectedKeys={[form.sshAuthType]}
variant="bordered"
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as
| "password"
| "private_key"
| undefined;
setForm((prev) => ({
...prev,
sshAuthType: selected || "private_key",
}));
}}
>
<SelectItem
key="private_key"
textValue="private_key"
>
私钥
</SelectItem>
<SelectItem key="password" textValue="password">
密码
</SelectItem>
</Select>
</div>
{form.sshAuthType === "password" ? (
<Input
errorMessage={errors.sshPassword}
isInvalid={!!errors.sshPassword}
label="SSH 密码"
placeholder="请输入 SSH 密码"
type="password"
value={form.sshPassword}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({
...prev,
sshPassword: e.target.value,
}))
}
/>
) : (
<Textarea
errorMessage={errors.sshPrivateKey}
isInvalid={!!errors.sshPrivateKey}
label="SSH 私钥"
minRows={6}
placeholder="粘贴 PEM 格式私钥内容"
value={form.sshPrivateKey}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({
...prev,
sshPrivateKey: e.target.value,
}))
}
/>
)}
<Input
label="SSH 私钥密码短语"
placeholder="可选"
type="password"
value={form.sshPassphrase}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({
...prev,
sshPassphrase: e.target.value,
}))
}
/>
<Select
label="sudo 提权方式"
selectedKeys={[form.sshSudoMode]}
variant="bordered"
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as
| "none"
| "sudo"
| "sudo_su"
| undefined;
setForm((prev) => ({
...prev,
sshSudoMode: selected || "none",
}));
}}
>
<SelectItem key="none" textValue="none">
无需提权
</SelectItem>
<SelectItem key="sudo" textValue="sudo">
sudo
</SelectItem>
<SelectItem key="sudo_su" textValue="sudo_su">
sudo su
</SelectItem>
</Select>
</div>
) : (
<Alert
color="primary"
description="agent 模式下无需填写 SSH 配置;节点安装 agent 后会自行接管转发。"
variant="flat"
/>
)}
<Input
description="用于多IP服务器指定使用那个IP请求远程地址,不懂的默认为空就行"
errorMessage={errors.interfaceName}
@@ -2677,18 +2985,16 @@ export default function NodePage() {
<div className="text-xs text-default-500 mb-2">
开启开关以屏蔽对应协议
</div>
{protocolDisabled && (
{protocolControlsDisabled && (
<Alert
className="mb-2"
color="warning"
description={
protocolDisabledReason || "等待节点上线后再设置"
}
description={protocolControlsReason}
variant="flat"
/>
)}
<div
className={`grid grid-cols-1 sm:grid-cols-3 gap-3 bg-content1/30 dark:bg-content1/20 p-3 rounded-md border border-divider ${protocolDisabled ? "opacity-70" : ""}`}
className={`grid grid-cols-1 sm:grid-cols-3 gap-3 bg-content1/30 dark:bg-content1/20 p-3 rounded-md border border-divider ${protocolControlsDisabled ? "opacity-70" : ""}`}
>
{/* HTTP tile */}
<div className="px-3 py-3 rounded-lg bg-content1/55 dark:bg-content1/35 border border-divider hover:border-primary-200 dark:hover:border-primary-500/30 transition-colors">
@@ -2715,7 +3021,7 @@ export default function NodePage() {
禁用/启用
</div>
<Switch
isDisabled={protocolDisabled}
isDisabled={protocolControlsDisabled}
isSelected={form.http === 1}
size="sm"
onValueChange={(v) =>
@@ -2762,7 +3068,7 @@ export default function NodePage() {
禁用/启用
</div>
<Switch
isDisabled={protocolDisabled}
isDisabled={protocolControlsDisabled}
isSelected={form.tls === 1}
size="sm"
onValueChange={(v) =>
@@ -2801,7 +3107,7 @@ export default function NodePage() {
禁用/启用
</div>
<Switch
isDisabled={protocolDisabled}
isDisabled={protocolControlsDisabled}
isSelected={form.socks === 1}
size="sm"
onValueChange={(v) =>
+32 -3
View File
@@ -122,6 +122,7 @@ interface Tunnel {
inx?: number;
name: string;
type: number; // 1: 端口转发, 2: 隧道转发
forwardMode?: "agent" | "nftables";
inNodeId: ChainTunnel[]; // 入口节点列表
outNodeId?: ChainTunnel[]; // 出口节点列表
chainNodes?: ChainTunnel[][]; // 转发链节点列表,二维数组
@@ -150,6 +151,7 @@ interface Node {
id: number;
name: string;
status: number; // 1: 在线, 0: 离线
forwardMode?: "agent" | "nftables";
serverIp?: string;
serverIpV4?: string;
serverIpV6?: string;
@@ -160,6 +162,7 @@ interface TunnelForm {
id?: number;
name: string;
type: number;
forwardMode?: "agent" | "nftables";
inNodeId: ChainTunnel[];
outNodeId?: ChainTunnel[];
chainNodes?: ChainTunnel[][]; // 转发链节点列表,二维数组,外层是跳数,内层是该跳的节点
@@ -172,6 +175,23 @@ interface TunnelForm {
status: number;
}
type TunnelForwardMode = NonNullable<TunnelForm["forwardMode"]>;
const getTunnelForwardMode = (
inNodeId: ChainTunnel[],
nodes: Node[],
): TunnelForwardMode =>
inNodeId.some((item) => {
const node = nodes.find((candidate) => candidate.id === item.nodeId);
return node?.forwardMode === "nftables";
})
? "nftables"
: "agent";
const createTypedTunnelFormDefaults = (): TunnelForm =>
createTunnelFormDefaults() as TunnelForm;
interface BatchProgressState {
active: boolean;
label: string;
@@ -416,7 +436,7 @@ export default function TunnelPage() {
};
// 表单状态
const [form, setForm] = useState<TunnelForm>(createTunnelFormDefaults());
const [form, setForm] = useState<TunnelForm>(createTypedTunnelFormDefaults());
// 表单验证错误
const [errors, setErrors] = useState<{ [key: string]: string }>({});
@@ -564,7 +584,14 @@ export default function TunnelPage() {
// 表单验证
const validateForm = (): boolean => {
const newErrors = validateTunnelForm(form, nodes, isEdit);
const newErrors = validateTunnelForm(
{
...form,
forwardMode: getTunnelForwardMode(form.inNodeId, nodes),
},
nodes,
isEdit,
);
setErrors(newErrors);
@@ -574,7 +601,7 @@ export default function TunnelPage() {
// 新增隧道
const handleAdd = () => {
setIsEdit(false);
setForm(createTunnelFormDefaults());
setForm(createTypedTunnelFormDefaults());
setErrors({});
setModalOpen(true);
};
@@ -588,6 +615,7 @@ export default function TunnelPage() {
id: tunnel.id,
name: tunnel.name,
type: tunnel.type,
forwardMode: tunnel.forwardMode === "nftables" ? "nftables" : "agent",
inNodeId: tunnel.inNodeId || [],
outNodeId: tunnel.outNodeId || [],
chainNodes: tunnel.chainNodes || [],
@@ -885,6 +913,7 @@ export default function TunnelPage() {
const data = {
...form,
forwardMode: getTunnelForwardMode(form.inNodeId, nodes),
inIp: inIpString,
outNodeId: cleanedOutNodeId,
chainNodes: cleanedChainNodes,
+24
View File
@@ -8,6 +8,7 @@ interface TunnelFormInput {
inNodeId: TunnelChainNode[];
outNodeId?: TunnelChainNode[];
trafficRatio: number;
forwardMode?: "agent" | "nftables";
probeTargetHost?: string;
probeTargetPort?: number;
}
@@ -15,6 +16,7 @@ interface TunnelFormInput {
interface TunnelNodeInput {
id: number;
status: number;
forwardMode?: "agent" | "nftables";
}
const isValidProbeIPv4 = (host: string) => {
@@ -116,6 +118,7 @@ export const createTunnelFormDefaults = () => {
chainNodes: [],
flow: 1,
trafficRatio: 1.0,
forwardMode: "agent",
inIp: "",
ipPreference: "",
probeTargetHost: "",
@@ -183,6 +186,10 @@ export const validateTunnelForm = (
}
if (form.type === 2) {
if (form.forwardMode === "nftables") {
errors.type = "nftables 节点仅支持端口转发";
}
if (!form.outNodeId || form.outNodeId.length === 0) {
errors.outNodeId = "请至少选择一个出口节点";
} else {
@@ -208,6 +215,23 @@ export const validateTunnelForm = (
}
}
if (form.forwardMode === "nftables") {
const nftEntryNodes = (form.inNodeId || []).filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
return node?.forwardMode === "nftables";
});
if (nftEntryNodes.length > 0) {
if (form.inNodeId.length !== 1) {
errors.inNodeId = "nftables 节点仅支持单入口隧道";
}
if ((form.outNodeId || []).length > 0) {
errors.outNodeId = "nftables 节点不支持出口节点配置";
}
}
}
return errors;
};