From 6bfa7f01663e79b971c7d6144c89cf7e14ae462d Mon Sep 17 00:00:00 2001 From: sagitchu <601096721@qq.com> Date: Sat, 30 May 2026 22:14:38 +0800 Subject: [PATCH 1/3] docs: design nftables forwarding --- .../2026-05-30-nftables-forwarding-design.md | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-30-nftables-forwarding-design.md diff --git a/docs/superpowers/specs/2026-05-30-nftables-forwarding-design.md b/docs/superpowers/specs/2026-05-30-nftables-forwarding-design.md new file mode 100644 index 0000000..115d66e --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-nftables-forwarding-design.md @@ -0,0 +1,407 @@ +# nftables 纯转发设计 + +**日期**: 2026-05-30 +**状态**: 待审核 +**作者**: Codex + +## 概述 + +为 FLVX 增加一种不依赖 agent 的纯转发能力:节点可选择 `nftables` 转发模式,面板通过 SSH 在节点机器上下发和维护 nftables 规则。 + +第一阶段只支持端口级 DNAT/SNAT 纯转发。它不是 GOST 隧道能力的替代品,也不支持链路、限速、流量统计、连接数限制、Proxy Protocol、best exit 或 agent 诊断。目标是提供一个可靠、可回滚、可重建的轻量转发路径。 + +## 背景 + +当前 FLVX 的转发模型由三部分组成: + +- `node` 表描述节点,现有本地节点通过 agent WebSocket 接收运行时命令。 +- `tunnel` 表描述入口、出口和链路类型,`type=1` 表示端口转发,`type=2` 表示隧道转发。 +- `forward` 表描述用户规则、入口端口和目标地址,运行时通过 GOST service 下发到入口节点。 + +nftables 模式的核心差异是没有 agent,因此不能复用现有 WebSocket command 通道,也不能依赖 agent 上报在线状态、流量和诊断结果。面板必须成为唯一控制面,通过 SSH 把数据库中的期望状态同步到远端 nftables。 + +## 用户决策 + +- 创建或编辑节点时选择转发模式。 +- 选择 nftables 转发后,不需要安装 agent。 +- nftables 转发不支持隧道、流量控制等能力,只支持纯转发。 +- 规则由面板端维护,并通过 SSH 下放到节点。 + +## 推荐方案 + +新增节点运行时模式: + +| 模式 | 含义 | +|------|------| +| `agent` | 默认模式,保持现有 GOST agent 行为 | +| `nftables` | 面板通过 SSH 管理 nftables 规则 | + +业务层继续复用现有 `tunnel` 和 `forward` 概念,但对 nftables 模式加严格能力边界: + +- nftables 节点只能创建端口转发隧道。 +- nftables 隧道不能配置出口节点或转发链。 +- 同一个隧道的入口节点必须全部是同一种运行时模式。 +- nftables 转发规则创建、更新、删除时,由后端同步 SSH 规则。 +- 面板提供节点级“测试 SSH”“重建规则”“清理 FLVX 规则”操作。 + +## 非目标 + +- 不支持 `tunnel.type=2` 隧道转发。 +- 不支持多跳链路、远程面板共享节点和 federation runtime。 +- 不支持 GOST service 能力:限速、每 IP 限速、最大连接数、Proxy Protocol、策略负载均衡。 +- 不支持 agent 流量统计、实时系统指标、节点升级、回退、agent 安装命令。 +- 不在第一阶段支持 HA 漂移、自动探活切换或复杂负载均衡。 +- 不改写用户机器上的非 FLVX nftables 规则。 + +## 数据模型 + +### node 表 + +新增字段: + +| 字段 | 类型 | 默认 | 说明 | +|------|------|------|------| +| `forward_mode` | string | `agent` | `agent` 或 `nftables` | + +Go 模型使用 SQLite/PostgreSQL 兼容 tag: + +```go +ForwardMode string `gorm:"column:forward_mode;type:varchar(20);not null;default:'agent'"` +``` + +### node_ssh_config 表 + +新增表保存 nftables 节点 SSH 配置。SSH 凭据不放进 `node` 主表,避免普通节点列表过度暴露敏感字段。 + +| 字段 | 说明 | +|------|------| +| `id` | 主键 | +| `node_id` | 关联节点,唯一 | +| `host` | SSH 主机,默认可使用 node.server_ip | +| `port` | SSH 端口,默认 22 | +| `username` | SSH 用户 | +| `auth_type` | `password` 或 `private_key` | +| `password` | 加密后密码,可为空 | +| `private_key` | 加密后私钥,可为空 | +| `passphrase` | 加密后私钥口令,可为空 | +| `sudo_mode` | `none` / `sudo` | +| `created_time` | 创建时间 | +| `updated_time` | 更新时间 | + +第一阶段可使用现有配置密钥派生或面板本地密钥做对称加密;如果项目尚无统一密钥管理,应至少避免在列表 API 返回完整凭据。 + +### nft_rule_binding 表 + +记录面板认为已经应用到节点的规则状态,用于更新、删除、重建和错误展示。 + +| 字段 | 说明 | +|------|------| +| `id` | 主键 | +| `forward_id` | 转发规则 ID | +| `node_id` | 下发节点 ID | +| `in_port` | 入口端口 | +| `protocols` | 第一阶段固定 `tcp,udp` | +| `target_addr` | 目标地址 | +| `bind_ip` | 可选监听 IP | +| `rule_hash` | 当前期望规则 hash | +| `status` | `pending` / `applied` / `error` | +| `last_error` | 最近错误 | +| `applied_time` | 最近成功应用时间 | +| `created_time` | 创建时间 | +| `updated_time` | 更新时间 | + +绑定表不是最终事实来源。最终期望状态仍从 `forward`、`forward_port`、`tunnel` 和 `chain_tunnel` 推导,绑定表只记录应用结果。 + +## API 行为 + +### 节点创建和更新 + +`/node/create` 和 `/node/update` 新增入参: + +```json +{ + "forwardMode": "nftables", + "sshConfig": { + "host": "203.0.113.10", + "port": 22, + "username": "root", + "authType": "private_key", + "privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----...", + "passphrase": "", + "sudoMode": "none" + } +} +``` + +规则: + +- `forwardMode` 缺省时按 `agent`。 +- `agent` 节点保留现有字段和行为。 +- `nftables` 节点要求 SSH 配置完整。 +- 从 `agent` 切到 `nftables` 前,若该节点已有 agent 隧道链路或转发规则,应拒绝并提示先迁移或删除。 +- 从 `nftables` 切回 `agent` 前,若存在 nftables 规则,应拒绝并提示先清理或迁移。 + +### 隧道创建和更新 + +创建 nftables 隧道仍使用 `/tunnel/create`,但后端根据入口节点模式校验能力。 + +规则: + +- 入口节点为 nftables 时,`type` 必须为 `1`。 +- 不允许提交 `outNodeId` 或 `chainNodes`。 +- 入口节点必须在线的现有校验不能直接套用到 nftables 节点;应改为 SSH 可用性校验或允许保存后手动测试。 +- 同一隧道入口节点不能混用 `agent` 和 `nftables`。 +- 更新隧道时不允许改变运行时模式;需要通过迁移规则到新隧道实现。 + +### 转发创建和更新 + +选择 nftables 隧道时,`/forward/create` 和 `/forward/update` 强制收窄字段: + +- `speedId` 必须为空。 +- `ipSpeedId` 必须为空。 +- `maxConn` 和 `ipMaxConn` 必须为 0。 +- `proxyProtocol` 必须为 0。 +- 第一阶段 `remoteAddr` 只允许单目标 `host:port`。 +- `strategy` 固定为 `fifo` 或忽略。 + +创建流程: + +1. 校验权限、隧道状态、端口占用和 nftables 能力边界。 +2. 在数据库创建 `forward` 和 `forward_port`。 +3. 通过 nftables runtime 对关联入口节点执行同步。 +4. 若同步失败,回滚数据库创建,返回 SSH/nftables 错误。 + +更新流程: + +1. 保存旧 forward 和端口绑定。 +2. 更新数据库。 +3. 同步 nftables 规则。 +4. 若同步失败,回滚数据库状态并尝试恢复旧规则。 + +删除流程: + +1. 先删除远端 nftables 规则。 +2. 成功后删除数据库。 +3. 如果远端删除失败,普通删除返回错误;强制删除可删除数据库并保留 binding 错误记录,提示用户稍后清理。 + +## 后端组件 + +新增 package: + +```text +go-backend/internal/runtime/nftables/ +``` + +建议拆分: + +| 组件 | 职责 | +|------|------| +| `Manager` | 对 handler 暴露 Apply/Delete/Reconcile/Test 方法 | +| `Planner` | 从数据库记录生成节点级期望规则 | +| `Renderer` | 把期望规则渲染为 nftables 脚本 | +| `SSHRunner` | 负责 SSH 连接、sudo 包装、命令执行和超时 | +| `Parser` | 解析目标地址、协议和错误信息 | + +handler 不直接执行 SSH,也不拼 nft 脚本;handler 只做业务校验并调用 runtime manager。 + +## nftables 规则设计 + +FLVX 只维护自己的 table,避免触碰用户已有规则: + +```nft +table inet flvx { + chain prerouting { + type nat hook prerouting priority dstnat; policy accept; + } + + chain postrouting { + type nat hook postrouting priority srcnat; policy accept; + } + + chain forward { + type filter hook forward priority filter; policy accept; + } +} +``` + +每条 forward 生成 TCP 和 UDP 规则: + +```nft +tcp dport 12345 dnat to 198.51.100.20:443 comment "flvx forward:42 tcp" +udp dport 12345 dnat to 198.51.100.20:443 comment "flvx forward:42 udp" +``` + +第一阶段默认生成 masquerade: + +```nft +masquerade comment "flvx masquerade" +``` + +原因是大多数纯 DNAT 场景需要回程可达;如果不做 SNAT,目标服务回包可能绕过转发节点导致连接失败。后续可增加高级开关允许用户关闭 masquerade。 + +### 原子同步策略 + +推荐节点级 reconcile,而不是逐条追加: + +1. 从数据库查询该节点所有 nftables forward。 +2. 生成完整 `table inet flvx` 脚本。 +3. 通过 SSH 执行 `nft -f `。 +4. 成功后更新所有相关 `nft_rule_binding` 状态和 hash。 + +这样可以避免局部更新导致规则漂移,也能让“重建规则”与创建/更新走同一条路径。 + +## SSH 执行策略 + +基础要求: + +- 默认超时 10-15 秒。 +- 支持密码和私钥认证。 +- 支持 `sudo nft ...`。 +- 执行前检查 `command -v nft`。 +- 执行前检查 `nft --version`,错误时提示安装 nftables。 +- 所有临时脚本写入 `/tmp/flvx-nft-.nft`,执行后删除。 + +建议命令流程: + +```sh +cat > /tmp/flvx-nft-xxxx.nft <<'EOF' +table inet flvx { + ... +} +EOF +nft list table inet flvx >/dev/null 2>&1 && nft delete table inet flvx || true +nft -f /tmp/flvx-nft-xxxx.nft +rm -f /tmp/flvx-nft-xxxx.nft +``` + +如果目标 nft 版本支持 `destroy table`,也可以把删除动作放进脚本: + +```nft +destroy table inet flvx +table inet flvx { + ... +} +``` + +实现时应按目标 nft 版本兼容性选择 `destroy` 或 shell 中先检测 `nft list table inet flvx`。 + +## 前端体验 + +### 节点页 + +节点表单新增“转发模式”: + +- `Agent 节点`:默认,现有表单不变。 +- `nftables 节点`:显示 SSH 配置区块,隐藏 agent 安装相关提示。 + +nftables 节点列表操作: + +- 测试 SSH +- 重建规则 +- 清理 FLVX nftables 规则 + +隐藏或禁用: + +- 安装命令 +- 升级 +- 回退 +- agent 协议开关 +- 实时 agent 指标入口 + +### 隧道页 + +隧道类型文案建议改为更明确的运行时说明: + +- `Agent 端口转发` +- `Agent 隧道转发` +- `nftables 纯转发` + +如果保持现有 `端口转发 / 隧道转发` 选择器,则在选择 nftables 入口节点后禁用隧道转发,并提示“不支持出口节点和转发链”。 + +### 转发页 + +选择 nftables 隧道后: + +- 隐藏限速、每 IP 限速、最大连接数、Proxy Protocol。 +- 目标地址输入提示“第一阶段仅支持单目标 host:port”。 +- 创建/更新失败时显示远端 SSH 或 nftables 错误。 + +## 错误处理 + +- SSH 连接失败:返回“SSH 连接失败”,保留底层错误摘要。 +- 认证失败:返回“SSH 认证失败,请检查用户名和凭据”。 +- `nft` 不存在:返回“节点未安装 nftables”。 +- nft 脚本失败:返回 nft stderr 摘要,并记录到 `nft_rule_binding.last_error`。 +- 下发超时:标记 binding 为 `error`,允许用户重试“重建规则”。 +- 数据库成功但远端失败时,创建/更新路径应回滚数据库;批量重建路径不回滚业务规则,只记录错误。 + +## 安全边界 + +- SSH 凭据只在创建/更新时接收,列表 API 不返回明文。 +- 私钥和密码在数据库中加密保存。 +- 后端日志不得打印完整私钥、密码或 passphrase。 +- nft 脚本只由后端 renderer 生成,禁止直接拼接用户提交的自由文本。 +- `remoteAddr` 必须严格解析为 host/IP + port,端口必须为 1-65535。 +- `inPort` 仍复用现有端口占用校验。 +- comment 中只放 forward ID 和协议,不放用户输入。 + +## 与现有功能的关系 + +- `node/install` 对 nftables 节点返回错误或前端隐藏入口。 +- `node/check-status` 对 nftables 节点可返回 SSH 测试状态,而不是 agent 在线状态。 +- `forward/batch-redeploy` 对 nftables 规则执行节点级 reconcile。 +- `tunnel/batch-redeploy` 遇到 nftables 隧道时只重建相关 nftables 节点规则,不发送 GOST chain/service 命令。 +- federation 导入/共享第一阶段不支持 nftables 节点。 +- backup/import 应包含新增 node mode、SSH 配置和 binding 状态;导出时默认不导出 SSH 明文凭据。 + +## 测试计划 + +后端单元测试: + +- nftables 节点不能创建隧道转发。 +- nftables 隧道不能包含出口节点或转发链。 +- agent 和 nftables 节点不能混在同一隧道。 +- nftables forward 拒绝限速、连接限制和 Proxy Protocol。 +- nftables forward 拒绝多目标 remoteAddr。 +- renderer 为 TCP/UDP 生成稳定脚本和 comment。 +- SSH runner 正确隐藏敏感信息并返回 stderr 摘要。 + +后端集成测试: + +- 创建 nftables forward 时数据库和 binding 同步成功。 +- runtime 下发失败时创建回滚。 +- 更新失败时数据库和旧规则尽量恢复。 +- 删除失败时普通删除返回错误,强制删除保留清理提示。 + +前端验证: + +- 节点表单按转发模式切换字段。 +- nftables 节点隐藏安装/升级/回退操作。 +- 隧道表单阻止 nftables 隧道转发配置。 +- 转发表单选择 nftables 隧道后隐藏不支持字段。 + +验证命令: + +```bash +(cd go-backend && go test ./...) +(cd vite-frontend && pnpm run build) +``` + +## 实施顺序 + +1. 数据模型和 repository:新增字段、SSH 配置表、binding 表和查询方法。 +2. nftables runtime:实现 planner、renderer、SSH runner、manager。 +3. handler 校验:节点、隧道、转发 create/update/delete 接入 runtime。 +4. 前端节点表单:增加转发模式和 SSH 配置。 +5. 前端隧道/转发表单:按 nftables 能力收窄 UI。 +6. 批量重建和清理操作:提供运维入口。 +7. 测试与文案打磨。 + +## 第一阶段固定决策 + +本设计先固定以下选择,除非审核时调整: + +- 第一阶段同时下发 TCP 和 UDP。 +- 第一阶段只支持单目标。 +- 第一阶段默认启用 masquerade。 +- nftables 节点的“在线状态”以 SSH 测试为准,而不是常驻连接。 From a599f383f586b6e99b27ed01a814f7b740d4ba13 Mon Sep 17 00:00:00 2001 From: sagitchu <601096721@qq.com> Date: Mon, 1 Jun 2026 19:47:26 +0800 Subject: [PATCH 2/3] feat: add nftables forwarding mode --- .../plans/2026-05-30-nftables-forwarding.md | 2294 +++++++++++++++++ .../internal/http/handler/control_plane.go | 13 + go-backend/internal/http/handler/handler.go | 16 +- go-backend/internal/http/handler/mutations.go | 264 +- .../internal/http/handler/nftables_runtime.go | 365 +++ .../http/handler/nftables_runtime_test.go | 478 ++++ .../internal/runtime/nftables/manager.go | 54 + .../internal/runtime/nftables/manager_test.go | 113 + .../internal/runtime/nftables/parser.go | 51 + .../internal/runtime/nftables/parser_test.go | 46 + .../internal/runtime/nftables/renderer.go | 113 + .../runtime/nftables/renderer_test.go | 73 + .../internal/runtime/nftables/runner.go | 194 ++ .../internal/runtime/nftables/runner_test.go | 42 + go-backend/internal/runtime/nftables/types.go | 46 + go-backend/internal/store/model/model.go | 37 + go-backend/internal/store/repo/repository.go | 48 +- .../internal/store/repo/repository_control.go | 3 +- .../store/repo/repository_mutations.go | 56 +- .../store/repo/repository_nftables.go | 239 ++ .../store/repo/repository_nftables_test.go | 214 ++ vite-frontend/pnpm-lock.yaml | 29 +- vite-frontend/pnpm-workspace.yaml | 2 + vite-frontend/src/api/index.ts | 6 + vite-frontend/src/api/types.ts | 31 + vite-frontend/src/pages/forward.tsx | 2 + vite-frontend/src/pages/node.tsx | 326 ++- vite-frontend/src/pages/tunnel.tsx | 35 +- vite-frontend/src/pages/tunnel/form.ts | 24 + 29 files changed, 5161 insertions(+), 53 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-30-nftables-forwarding.md create mode 100644 go-backend/internal/http/handler/nftables_runtime.go create mode 100644 go-backend/internal/http/handler/nftables_runtime_test.go create mode 100644 go-backend/internal/runtime/nftables/manager.go create mode 100644 go-backend/internal/runtime/nftables/manager_test.go create mode 100644 go-backend/internal/runtime/nftables/parser.go create mode 100644 go-backend/internal/runtime/nftables/parser_test.go create mode 100644 go-backend/internal/runtime/nftables/renderer.go create mode 100644 go-backend/internal/runtime/nftables/renderer_test.go create mode 100644 go-backend/internal/runtime/nftables/runner.go create mode 100644 go-backend/internal/runtime/nftables/runner_test.go create mode 100644 go-backend/internal/runtime/nftables/types.go create mode 100644 go-backend/internal/store/repo/repository_nftables.go create mode 100644 go-backend/internal/store/repo/repository_nftables_test.go create mode 100644 vite-frontend/pnpm-workspace.yaml diff --git a/docs/superpowers/plans/2026-05-30-nftables-forwarding.md b/docs/superpowers/plans/2026-05-30-nftables-forwarding.md new file mode 100644 index 0000000..d2b4481 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-nftables-forwarding.md @@ -0,0 +1,2294 @@ +# nftables Forwarding Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an `nftables` node forwarding mode that lets FLVX manage pure DNAT/SNAT forwarding rules over SSH without installing the GOST agent. + +**Architecture:** Keep the existing node/tunnel/forward business model, but split runtime execution by node `forward_mode`: `agent` continues using WebSocket/GOST commands, while `nftables` uses a focused backend runtime package that plans rules from database state, renders a complete FLVX-owned nftables table, and applies it over SSH. The first implementation deliberately supports only single-target pure port forwarding and rejects unsupported GOST features at the API boundary. + +**Tech Stack:** Go `net/http`, GORM, SQLite/PostgreSQL, `golang.org/x/crypto/ssh`, nftables CLI over SSH, React/TypeScript, Vite, existing shadcn bridge components. + +--- + +## File Structure + +- Modify `go-backend/internal/store/model/model.go`: add node forwarding mode, SSH config, nft binding models, and record fields. +- Modify `go-backend/internal/store/repo/repository.go`: migrate new tables, include node mode in list output, add mode/config/binding repository methods if a narrower file is not used. +- Modify `go-backend/internal/store/repo/repository_mutations.go`: persist node mode and SSH config on create/update. +- Create `go-backend/internal/store/repo/repository_nftables.go`: focused repository methods for SSH configs and nft rule bindings. +- Create `go-backend/internal/store/repo/repository_nftables_test.go`: repository tests for mode/config/binding persistence. +- Create `go-backend/internal/runtime/nftables/types.go`: runtime constants, request/plan/result types. +- Create `go-backend/internal/runtime/nftables/parser.go`: strict `host:port` target parsing. +- Create `go-backend/internal/runtime/nftables/renderer.go`: render complete `table inet flvx` scripts. +- Create `go-backend/internal/runtime/nftables/runner.go`: SSH runner and command abstraction. +- Create `go-backend/internal/runtime/nftables/manager.go`: Reconcile/Test/Clear orchestration. +- Create `go-backend/internal/runtime/nftables/*_test.go`: parser, renderer, manager tests with fake runner. +- Modify `go-backend/internal/http/handler/handler.go`: initialize nftables manager and register nftables maintenance routes. +- Create `go-backend/internal/http/handler/nftables_runtime.go`: handler helpers for capability checks and runtime sync. +- Modify `go-backend/internal/http/handler/mutations.go`: enforce nftables restrictions in node/tunnel/forward create/update/delete/batch redeploy flows. +- Create `go-backend/internal/http/handler/nftables_runtime_test.go`: API-level validation and rollback tests. +- Modify `vite-frontend/src/api/index.ts`: add nftables node operations. +- Modify `vite-frontend/src/api/types.ts`: add `forwardMode` and `sshConfig` types. +- Modify `vite-frontend/src/pages/node.tsx`: add forwarding mode fields, SSH form section, and hide agent-only operations. +- Modify `vite-frontend/src/pages/tunnel.tsx` and `vite-frontend/src/pages/tunnel/form.ts`: prevent nftables tunnel forwarding/chain configuration. +- Modify `vite-frontend/src/pages/forward.tsx`: hide unsupported controls and enforce single-target nftables rules. + +Implementation should commit only when explicitly requested. If commits are requested later, commit after each task. + +--- + +### Task 1: Data Model And Repository + +**Files:** +- Modify: `go-backend/internal/store/model/model.go` +- Modify: `go-backend/internal/store/repo/repository.go` +- Modify: `go-backend/internal/store/repo/repository_mutations.go` +- Create: `go-backend/internal/store/repo/repository_nftables.go` +- Create: `go-backend/internal/store/repo/repository_nftables_test.go` + +- [ ] **Step 1: Write failing repository tests** + +Create `go-backend/internal/store/repo/repository_nftables_test.go`: + +```go +package repo + +import ( + "strings" + "testing" + "time" +) + +func TestNftablesNodeModeSSHConfigAndBindingPersistence(t *testing.T) { + r, err := Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + now := time.Now().UnixMilli() + if err := r.CreateNode( + "nft-node", + "secret", + "203.0.113.10", + nil, + nil, + "10000-20000", + nil, + nil, + nil, + nil, + nil, + 0, + 0, + 0, + now, + 1, + "[::]", + "[::]", + 1, + 0, + nil, + nil, + nil, + nil, + "nftables", + ); err != nil { + t.Fatalf("CreateNode: %v", err) + } + + nodes, err := r.ListNodes() + if err != nil { + t.Fatalf("ListNodes: %v", err) + } + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + nodeID := nodes[0]["id"].(int64) + if got := nodes[0]["forwardMode"]; got != "nftables" { + t.Fatalf("expected forwardMode nftables, got %#v", got) + } + + cfg := NftSSHConfigInput{ + Host: "203.0.113.10", + Port: 22, + Username: "root", + AuthType: "private_key", + PrivateKey: "encrypted-private-key", + SudoMode: "none", + } + if err := r.UpsertNodeSSHConfig(nodeID, cfg, now); err != nil { + t.Fatalf("UpsertNodeSSHConfig: %v", err) + } + loaded, err := r.GetNodeSSHConfig(nodeID) + if err != nil { + t.Fatalf("GetNodeSSHConfig: %v", err) + } + if loaded.Host != cfg.Host || loaded.Port != cfg.Port || loaded.Username != cfg.Username || loaded.AuthType != cfg.AuthType { + t.Fatalf("unexpected ssh config: %+v", loaded) + } + + binding := NftRuleBindingInput{ + ForwardID: 42, + NodeID: nodeID, + InPort: 24000, + Protocols: "tcp,udp", + TargetAddr: "198.51.100.20:443", + BindIP: "", + RuleHash: "hash-a", + Status: "applied", + LastError: "", + } + if err := r.UpsertNftRuleBinding(binding, now); err != nil { + t.Fatalf("UpsertNftRuleBinding: %v", err) + } + bindings, err := r.ListNftRuleBindingsByNode(nodeID) + if err != nil { + t.Fatalf("ListNftRuleBindingsByNode: %v", err) + } + if len(bindings) != 1 { + t.Fatalf("expected 1 binding, got %d", len(bindings)) + } + if bindings[0].ForwardID != 42 || bindings[0].RuleHash != "hash-a" || bindings[0].Status != "applied" { + t.Fatalf("unexpected binding: %+v", bindings[0]) + } + + if err := r.MarkNftRuleBindingError(42, nodeID, "nft failed", now+1); err != nil { + t.Fatalf("MarkNftRuleBindingError: %v", err) + } + bindings, err = r.ListNftRuleBindingsByNode(nodeID) + if err != nil { + t.Fatalf("ListNftRuleBindingsByNode after error: %v", err) + } + if bindings[0].Status != "error" || !strings.Contains(bindings[0].LastError, "nft failed") { + t.Fatalf("expected error binding, got %+v", bindings[0]) + } + + if err := r.DeleteNftRuleBindingsByForward(42); err != nil { + t.Fatalf("DeleteNftRuleBindingsByForward: %v", err) + } + bindings, err = r.ListNftRuleBindingsByNode(nodeID) + if err != nil { + t.Fatalf("ListNftRuleBindingsByNode after delete: %v", err) + } + if len(bindings) != 0 { + t.Fatalf("expected no bindings after delete, got %+v", bindings) + } +} +``` + +- [ ] **Step 2: Run repository test and verify failure** + +Run: + +```bash +(cd go-backend && go test ./internal/store/repo -run TestNftablesNodeModeSSHConfigAndBindingPersistence -count=1) +``` + +Expected: FAIL with undefined `NftSSHConfigInput`, `UpsertNodeSSHConfig`, `GetNodeSSHConfig`, `NftRuleBindingInput`, `UpsertNftRuleBinding`, `ListNftRuleBindingsByNode`, `MarkNftRuleBindingError`, `DeleteNftRuleBindingsByForward`, and the old `CreateNode` signature. + +- [ ] **Step 3: Add models and record fields** + +In `go-backend/internal/store/model/model.go`, add `ForwardMode` to `Node` after `IsRemote`: + +```go + ForwardMode string `gorm:"column:forward_mode;type:varchar(20);not null;default:'agent'"` +``` + +Add new GORM models after `Node`: + +```go +type NodeSSHConfig struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + NodeID int64 `gorm:"column:node_id;not null;uniqueIndex"` + Host string `gorm:"type:varchar(255);not null"` + Port int `gorm:"not null;default:22"` + Username string `gorm:"type:varchar(100);not null"` + AuthType string `gorm:"column:auth_type;type:varchar(20);not null"` + Password sql.NullString `gorm:"type:text"` + PrivateKey sql.NullString `gorm:"column:private_key;type:text"` + Passphrase sql.NullString `gorm:"type:text"` + SudoMode string `gorm:"column:sudo_mode;type:varchar(20);not null;default:'none'"` + CreatedTime int64 `gorm:"column:created_time;not null"` + UpdatedTime int64 `gorm:"column:updated_time;not null"` +} + +func (NodeSSHConfig) TableName() string { return "node_ssh_config" } + +type NftRuleBinding struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + ForwardID int64 `gorm:"column:forward_id;not null;uniqueIndex:idx_nft_rule_binding_forward_node;index"` + NodeID int64 `gorm:"column:node_id;not null;uniqueIndex:idx_nft_rule_binding_forward_node;index"` + InPort int `gorm:"column:in_port;not null"` + Protocols string `gorm:"type:varchar(20);not null;default:'tcp,udp'"` + TargetAddr string `gorm:"column:target_addr;type:text;not null"` + BindIP string `gorm:"column:bind_ip;type:text;not null;default:''"` + RuleHash string `gorm:"column:rule_hash;type:varchar(128);not null;default:''"` + Status string `gorm:"type:varchar(20);not null;default:'pending'"` + LastError string `gorm:"column:last_error;type:text;not null;default:''"` + AppliedTime int64 `gorm:"column:applied_time;not null;default:0"` + CreatedTime int64 `gorm:"column:created_time;not null"` + UpdatedTime int64 `gorm:"column:updated_time;not null"` +} + +func (NftRuleBinding) TableName() string { return "nft_rule_binding" } +``` + +Add `ForwardMode string` to `NodeRecord` in `model.go`. + +- [ ] **Step 4: Auto-migrate new tables** + +In `go-backend/internal/store/repo/repository.go`, add the new models to `autoMigrateAll` immediately after `&model.Node{}`: + +```go + &model.NodeSSHConfig{}, + &model.NftRuleBinding{}, +``` + +Because SQLite startup currently skips `Node` AutoMigrate when a legacy node table exists, add a legacy column preparation helper that runs before the model loop: + +```go + if db.Dialector.Name() == "sqlite" { + if err := prepareSQLiteNftablesColumns(db); err != nil { + return err + } + } +``` + +Add this helper near the existing SQLite legacy helpers: + +```go +func prepareSQLiteNftablesColumns(db *gorm.DB) error { + if db == nil || db.Dialector.Name() != "sqlite" { + return nil + } + if !db.Migrator().HasTable(&model.Node{}) { + return nil + } + if !db.Migrator().HasColumn(&model.Node{}, "forward_mode") { + if err := db.Exec("ALTER TABLE node ADD COLUMN forward_mode varchar(20) NOT NULL DEFAULT 'agent'").Error; err != nil { + return err + } + } + return nil +} +``` + +- [ ] **Step 5: Persist node mode and expose it in list output** + +Change `CreateNode` in `repository_mutations.go` to accept `forwardMode string` after `extraIPs interface{}` and set: + +```go + ForwardMode: defaultNodeForwardMode(forwardMode), +``` + +Change `UpdateNode` to accept `forwardMode string` after `renewalCycle interface{}` and add: + +```go + "forward_mode": defaultNodeForwardMode(forwardMode), +``` + +Add this helper in `repository_mutations.go`: + +```go +func defaultNodeForwardMode(mode string) string { + switch strings.TrimSpace(strings.ToLower(mode)) { + case "nftables": + return "nftables" + default: + return "agent" + } +} +``` + +If `repository_mutations.go` does not already import `strings`, add it. + +In `ListNodes`, include: + +```go + "forwardMode": defaultNodeForwardMode(n.ForwardMode), +``` + +- [ ] **Step 6: Add nftables repository methods** + +Create `go-backend/internal/store/repo/repository_nftables.go`: + +```go +package repo + +import ( + "database/sql" + "errors" + "strings" + + "go-backend/internal/store/model" + + "gorm.io/gorm" +) + +type NftSSHConfigInput struct { + Host string + Port int + Username string + AuthType string + Password string + PrivateKey string + Passphrase string + SudoMode string +} + +type NftRuleBindingInput struct { + ForwardID int64 + NodeID int64 + InPort int + Protocols string + TargetAddr string + BindIP string + RuleHash string + Status string + LastError string +} + +func (r *Repository) UpsertNodeSSHConfig(nodeID int64, cfg NftSSHConfigInput, now int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + if nodeID <= 0 { + return errors.New("node id is required") + } + port := cfg.Port + if port <= 0 { + port = 22 + } + authType := strings.TrimSpace(strings.ToLower(cfg.AuthType)) + if authType == "" { + authType = "private_key" + } + sudoMode := strings.TrimSpace(strings.ToLower(cfg.SudoMode)) + if sudoMode == "" { + sudoMode = "none" + } + var existing model.NodeSSHConfig + err := r.db.Where("node_id = ?", nodeID).First(&existing).Error + row := model.NodeSSHConfig{ + NodeID: nodeID, + Host: strings.TrimSpace(cfg.Host), + Port: port, + Username: strings.TrimSpace(cfg.Username), + AuthType: authType, + Password: nullStringFromInterface(cfg.Password), + PrivateKey: nullStringFromInterface(cfg.PrivateKey), + Passphrase: nullStringFromInterface(cfg.Passphrase), + SudoMode: sudoMode, + CreatedTime: now, + UpdatedTime: now, + } + if err == nil { + row.ID = existing.ID + row.CreatedTime = existing.CreatedTime + return r.db.Save(&row).Error + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return r.db.Create(&row).Error + } + return err +} + +func (r *Repository) GetNodeSSHConfig(nodeID int64) (*model.NodeSSHConfig, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + var cfg model.NodeSSHConfig + if err := r.db.Where("node_id = ?", nodeID).First(&cfg).Error; err != nil { + return nil, normalizeNotFoundErr(err) + } + return &cfg, nil +} + +func (r *Repository) DeleteNodeSSHConfig(nodeID int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + return r.db.Where("node_id = ?", nodeID).Delete(&model.NodeSSHConfig{}).Error +} + +func (r *Repository) UpsertNftRuleBinding(input NftRuleBindingInput, now int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + var existing model.NftRuleBinding + err := r.db.Where("forward_id = ? AND node_id = ?", input.ForwardID, input.NodeID).First(&existing).Error + row := model.NftRuleBinding{ + ForwardID: input.ForwardID, + NodeID: input.NodeID, + InPort: input.InPort, + Protocols: defaultString(strings.TrimSpace(input.Protocols), "tcp,udp"), + TargetAddr: strings.TrimSpace(input.TargetAddr), + BindIP: strings.TrimSpace(input.BindIP), + RuleHash: strings.TrimSpace(input.RuleHash), + Status: defaultString(strings.TrimSpace(input.Status), "pending"), + LastError: strings.TrimSpace(input.LastError), + AppliedTime: now, + CreatedTime: now, + UpdatedTime: now, + } + if err == nil { + row.ID = existing.ID + row.CreatedTime = existing.CreatedTime + return r.db.Save(&row).Error + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return r.db.Create(&row).Error + } + return err +} + +func (r *Repository) MarkNftRuleBindingError(forwardID, nodeID int64, message string, now int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + return r.db.Model(&model.NftRuleBinding{}). + Where("forward_id = ? AND node_id = ?", forwardID, nodeID). + Updates(map[string]interface{}{ + "status": "error", + "last_error": strings.TrimSpace(message), + "updated_time": now, + }).Error +} + +func (r *Repository) ListNftRuleBindingsByNode(nodeID int64) ([]model.NftRuleBinding, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + var rows []model.NftRuleBinding + err := r.db.Where("node_id = ?", nodeID).Order("forward_id ASC").Find(&rows).Error + return rows, err +} + +func (r *Repository) DeleteNftRuleBindingsByForward(forwardID int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + return r.db.Where("forward_id = ?", forwardID).Delete(&model.NftRuleBinding{}).Error +} + +func (r *Repository) GetNodeForwardMode(nodeID int64) (string, error) { + if r == nil || r.db == nil { + return "", errors.New("repository not initialized") + } + var row struct { + ForwardMode sql.NullString `gorm:"column:forward_mode"` + } + err := r.db.Model(&model.Node{}).Select("forward_mode").Where("id = ?", nodeID).First(&row).Error + if err != nil { + return "", normalizeNotFoundErr(err) + } + return defaultNodeForwardMode(row.ForwardMode.String), nil +} +``` + +- [ ] **Step 7: Update call sites for CreateNode and UpdateNode** + +In `go-backend/internal/http/handler/mutations.go`, update `h.repo.CreateNode(...)` to pass: + +```go + nullableText(asString(req["extraIPs"])), + defaultNodeForwardMode(asString(req["forwardMode"])), +``` + +Update `h.repo.UpdateNode(...)` to pass `defaultNodeForwardMode(asString(req["forwardMode"]))` after `renewalCycle`. + +Add this handler helper in `mutations.go` near other helpers: + +```go +func defaultNodeForwardMode(mode string) string { + switch strings.TrimSpace(strings.ToLower(mode)) { + case "nftables": + return "nftables" + default: + return "agent" + } +} +``` + +- [ ] **Step 8: Run repository tests** + +Run: + +```bash +(cd go-backend && go test ./internal/store/repo -run TestNftablesNodeModeSSHConfigAndBindingPersistence -count=1) +``` + +Expected: PASS. + +Run: + +```bash +(cd go-backend && go test ./internal/store/repo -count=1) +``` + +Expected: PASS. + +--- + +### Task 2: nftables Parser And Renderer + +**Files:** +- Create: `go-backend/internal/runtime/nftables/types.go` +- Create: `go-backend/internal/runtime/nftables/parser.go` +- Create: `go-backend/internal/runtime/nftables/renderer.go` +- Create: `go-backend/internal/runtime/nftables/parser_test.go` +- Create: `go-backend/internal/runtime/nftables/renderer_test.go` + +- [ ] **Step 1: Write failing parser tests** + +Create `go-backend/internal/runtime/nftables/parser_test.go`: + +```go +package nftables + +import "testing" + +func TestParseSingleTargetAcceptsHostPortAndIPv6(t *testing.T) { + tests := []struct { + name string + raw string + host string + port int + }{ + {name: "hostname", raw: "example.com:443", host: "example.com", port: 443}, + {name: "ipv4", raw: "198.51.100.20:8443", host: "198.51.100.20", port: 8443}, + {name: "ipv6", raw: "[2001:db8::1]:443", host: "2001:db8::1", port: 443}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target, err := ParseSingleTarget(tt.raw) + if err != nil { + t.Fatalf("ParseSingleTarget: %v", err) + } + if target.Host != tt.host || target.Port != tt.port { + t.Fatalf("expected %s/%d, got %+v", tt.host, tt.port, target) + } + }) + } +} + +func TestParseSingleTargetRejectsUnsupportedValues(t *testing.T) { + for _, raw := range []string{"", "example.com", "example.com:0", "example.com:65536", "a:1,b:2", "http://example.com:443"} { + t.Run(raw, func(t *testing.T) { + if _, err := ParseSingleTarget(raw); err == nil { + t.Fatalf("expected error for %q", raw) + } + }) + } +} +``` + +- [ ] **Step 2: Write failing renderer tests** + +Create `go-backend/internal/runtime/nftables/renderer_test.go`: + +```go +package nftables + +import ( + "strings" + "testing" +) + +func TestRenderTableIncludesDNATAndMasquerade(t *testing.T) { + script := RenderTable(NodePlan{ + NodeID: 10, + Rules: []Rule{ + { + ForwardID: 42, + InPort: 24000, + TargetHost: "198.51.100.20", + TargetPort: 443, + Protocols: []string{"tcp", "udp"}, + }, + }, + }) + + expectedParts := []string{ + "table inet flvx", + "type nat hook prerouting priority dstnat; policy accept;", + "type nat hook postrouting priority srcnat; policy accept;", + "tcp dport 24000 dnat to 198.51.100.20:443 comment \"flvx forward:42 tcp\"", + "udp dport 24000 dnat to 198.51.100.20:443 comment \"flvx forward:42 udp\"", + "masquerade comment \"flvx masquerade\"", + } + for _, part := range expectedParts { + if !strings.Contains(script, part) { + t.Fatalf("script missing %q:\n%s", part, script) + } + } +} + +func TestRenderTableBracketsIPv6Target(t *testing.T) { + script := RenderTable(NodePlan{ + NodeID: 10, + Rules: []Rule{ + {ForwardID: 42, InPort: 24000, TargetHost: "2001:db8::1", TargetPort: 443, Protocols: []string{"tcp"}}, + }, + }) + if !strings.Contains(script, "dnat to [2001:db8::1]:443") { + t.Fatalf("expected bracketed IPv6 dnat target, got:\n%s", script) + } +} + +func TestRuleHashIsStable(t *testing.T) { + rule := Rule{ForwardID: 42, InPort: 24000, TargetHost: "198.51.100.20", TargetPort: 443, Protocols: []string{"tcp", "udp"}} + if RuleHash(rule) != RuleHash(rule) { + t.Fatalf("expected stable rule hash") + } + if RuleHash(rule) == RuleHash(Rule{ForwardID: 42, InPort: 24001, TargetHost: "198.51.100.20", TargetPort: 443, Protocols: []string{"tcp", "udp"}}) { + t.Fatalf("expected hash to change when port changes") + } +} +``` + +- [ ] **Step 3: Run tests and verify failure** + +Run: + +```bash +(cd go-backend && go test ./internal/runtime/nftables -count=1) +``` + +Expected: FAIL because the package and functions do not exist. + +- [ ] **Step 4: Implement runtime types** + +Create `go-backend/internal/runtime/nftables/types.go`: + +```go +package nftables + +const ( + ModeAgent = "agent" + ModeNftables = "nftables" + + StatusPending = "pending" + StatusApplied = "applied" + StatusError = "error" +) + +type Target struct { + Host string + Port int +} + +type Rule struct { + ForwardID int64 + InPort int + BindIP string + TargetHost string + TargetPort int + Protocols []string +} + +type NodePlan struct { + NodeID int64 + Rules []Rule +} + +type SSHConfig struct { + Host string + Port int + Username string + AuthType string + Password string + PrivateKey string + Passphrase string + SudoMode string +} + +type ApplyResult struct { + NodeID int64 + Script string + Hashes map[int64]string +} +``` + +- [ ] **Step 5: Implement parser** + +Create `go-backend/internal/runtime/nftables/parser.go`: + +```go +package nftables + +import ( + "fmt" + "net" + "net/url" + "strconv" + "strings" +) + +func ParseSingleTarget(raw string) (Target, error) { + value := strings.TrimSpace(raw) + if value == "" { + return Target{}, fmt.Errorf("目标地址不能为空") + } + if strings.Contains(value, ",") || strings.Contains(value, "\n") { + return Target{}, fmt.Errorf("nftables 纯转发第一阶段仅支持单目标") + } + if u, err := url.Parse(value); err == nil && u.Scheme != "" { + return Target{}, fmt.Errorf("目标地址必须是 host:port,不能包含 URL scheme") + } + host, portText, err := net.SplitHostPort(value) + if err != nil { + return Target{}, fmt.Errorf("目标地址必须是 host:port") + } + host = strings.TrimSpace(strings.Trim(host, "[]")) + if host == "" { + return Target{}, fmt.Errorf("目标主机不能为空") + } + port, err := strconv.Atoi(portText) + if err != nil || port < 1 || port > 65535 { + return Target{}, fmt.Errorf("目标端口必须在 1-65535 之间") + } + return Target{Host: host, Port: port}, nil +} +``` + +- [ ] **Step 6: Implement renderer** + +Create `go-backend/internal/runtime/nftables/renderer.go`: + +```go +package nftables + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "sort" + "strings" +) + +func RenderTable(plan NodePlan) string { + var b strings.Builder + b.WriteString("table inet flvx {\n") + b.WriteString(" chain prerouting {\n") + b.WriteString(" type nat hook prerouting priority dstnat; policy accept;\n") + for _, rule := range sortedRules(plan.Rules) { + for _, protocol := range normalizedProtocols(rule.Protocols) { + b.WriteString(fmt.Sprintf(" %s dport %d dnat to %s comment \"flvx forward:%d %s\"\n", + protocol, + rule.InPort, + formatDNATTarget(rule.TargetHost, rule.TargetPort), + rule.ForwardID, + protocol, + )) + } + } + b.WriteString(" }\n\n") + b.WriteString(" chain postrouting {\n") + b.WriteString(" type nat hook postrouting priority srcnat; policy accept;\n") + if len(plan.Rules) > 0 { + b.WriteString(" masquerade comment \"flvx masquerade\"\n") + } + b.WriteString(" }\n\n") + b.WriteString(" chain forward {\n") + b.WriteString(" type filter hook forward priority filter; policy accept;\n") + b.WriteString(" }\n") + b.WriteString("}\n") + return b.String() +} + +func RuleHash(rule Rule) string { + protocols := normalizedProtocols(rule.Protocols) + sum := sha256.Sum256([]byte(fmt.Sprintf("%d|%d|%s|%d|%s|%s", + rule.ForwardID, + rule.InPort, + strings.TrimSpace(rule.TargetHost), + rule.TargetPort, + strings.TrimSpace(rule.BindIP), + strings.Join(protocols, ","), + ))) + return hex.EncodeToString(sum[:]) +} + +func PlanHashes(plan NodePlan) map[int64]string { + hashes := make(map[int64]string, len(plan.Rules)) + for _, rule := range plan.Rules { + hashes[rule.ForwardID] = RuleHash(rule) + } + return hashes +} + +func sortedRules(rules []Rule) []Rule { + out := append([]Rule(nil), rules...) + sort.SliceStable(out, func(i, j int) bool { + if out[i].InPort == out[j].InPort { + return out[i].ForwardID < out[j].ForwardID + } + return out[i].InPort < out[j].InPort + }) + return out +} + +func normalizedProtocols(protocols []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, 2) + for _, protocol := range protocols { + p := strings.ToLower(strings.TrimSpace(protocol)) + if p != "tcp" && p != "udp" { + continue + } + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + if len(out) == 0 { + return []string{"tcp", "udp"} + } + sort.Strings(out) + return out +} + +func formatDNATTarget(host string, port int) string { + trimmed := strings.Trim(strings.TrimSpace(host), "[]") + if ip := net.ParseIP(trimmed); ip != nil && ip.To4() == nil { + return fmt.Sprintf("[%s]:%d", trimmed, port) + } + return fmt.Sprintf("%s:%d", trimmed, port) +} +``` + +- [ ] **Step 7: Run parser and renderer tests** + +Run: + +```bash +(cd go-backend && go test ./internal/runtime/nftables -count=1) +``` + +Expected: PASS. + +--- + +### Task 3: SSH Runner And nftables Manager + +**Files:** +- Create: `go-backend/internal/runtime/nftables/runner.go` +- Create: `go-backend/internal/runtime/nftables/manager.go` +- Create: `go-backend/internal/runtime/nftables/manager_test.go` + +- [ ] **Step 1: Write failing manager tests with a fake runner** + +Create `go-backend/internal/runtime/nftables/manager_test.go`: + +```go +package nftables + +import ( + "context" + "errors" + "strings" + "testing" +) + +type fakeRunner struct { + scripts []string + err error +} + +func (f *fakeRunner) ApplyScript(ctx context.Context, cfg SSHConfig, script string) error { + f.scripts = append(f.scripts, script) + return f.err +} + +func (f *fakeRunner) Test(ctx context.Context, cfg SSHConfig) error { + return f.err +} + +func TestManagerReconcileAppliesRenderedScript(t *testing.T) { + runner := &fakeRunner{} + manager := NewManager(runner) + plan := NodePlan{ + NodeID: 7, + Rules: []Rule{{ForwardID: 42, InPort: 24000, TargetHost: "198.51.100.20", TargetPort: 443, Protocols: []string{"tcp", "udp"}}}, + } + result, err := manager.Reconcile(context.Background(), SSHConfig{Host: "203.0.113.10", Port: 22, Username: "root"}, plan) + if err != nil { + t.Fatalf("Reconcile: %v", err) + } + if len(runner.scripts) != 1 { + t.Fatalf("expected 1 script, got %d", len(runner.scripts)) + } + if !strings.Contains(runner.scripts[0], "flvx forward:42 tcp") { + t.Fatalf("script missing forward comment:\n%s", runner.scripts[0]) + } + if result.NodeID != 7 || result.Hashes[42] == "" { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestManagerReconcileReturnsRunnerError(t *testing.T) { + runner := &fakeRunner{err: errors.New("ssh failed")} + manager := NewManager(runner) + _, err := manager.Reconcile(context.Background(), SSHConfig{Host: "203.0.113.10", Port: 22, Username: "root"}, NodePlan{NodeID: 7}) + if err == nil || !strings.Contains(err.Error(), "ssh failed") { + t.Fatalf("expected ssh failed error, got %v", err) + } +} + +func TestManagerClearAppliesEmptyTable(t *testing.T) { + runner := &fakeRunner{} + manager := NewManager(runner) + if err := manager.Clear(context.Background(), SSHConfig{Host: "203.0.113.10", Port: 22, Username: "root"}); err != nil { + t.Fatalf("Clear: %v", err) + } + if len(runner.scripts) != 1 { + t.Fatalf("expected 1 script, got %d", len(runner.scripts)) + } + if strings.Contains(runner.scripts[0], "masquerade comment") { + t.Fatalf("empty table should not include masquerade:\n%s", runner.scripts[0]) + } +} +``` + +- [ ] **Step 2: Run manager tests and verify failure** + +Run: + +```bash +(cd go-backend && go test ./internal/runtime/nftables -run 'TestManager' -count=1) +``` + +Expected: FAIL with undefined `NewManager`. + +- [ ] **Step 3: Implement runner interfaces and real SSH runner** + +Create `go-backend/internal/runtime/nftables/runner.go`: + +```go +package nftables + +import ( + "bytes" + "context" + "fmt" + "net" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +type Runner interface { + ApplyScript(ctx context.Context, cfg SSHConfig, script string) error + Test(ctx context.Context, cfg SSHConfig) error +} + +type SSHRunner struct { + Timeout time.Duration +} + +func NewSSHRunner() *SSHRunner { + return &SSHRunner{Timeout: 15 * time.Second} +} + +func (r *SSHRunner) Test(ctx context.Context, cfg SSHConfig) error { + return r.run(ctx, cfg, "command -v nft >/dev/null 2>&1 && nft --version >/dev/null 2>&1") +} + +func (r *SSHRunner) ApplyScript(ctx context.Context, cfg SSHConfig, script string) error { + escaped := strings.ReplaceAll(script, "'", "'\"'\"'") + command := "tmp=/tmp/flvx-nft-$(date +%s%N).nft; " + + "cat > \"$tmp\" <<'EOF'\n" + escaped + "\nEOF\n" + + nftCommand(cfg, "list table inet flvx >/dev/null 2>&1 && nft delete table inet flvx || true") + "; " + + nftCommand(cfg, " -f \"$tmp\"") + "; " + + "rm -f \"$tmp\"" + return r.run(ctx, cfg, command) +} + +func (r *SSHRunner) run(ctx context.Context, cfg SSHConfig, command string) error { + timeout := r.Timeout + if timeout <= 0 { + timeout = 15 * time.Second + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + clientConfig, err := buildSSHClientConfig(cfg) + if err != nil { + return err + } + addr := net.JoinHostPort(strings.TrimSpace(cfg.Host), fmt.Sprintf("%d", normalizedSSHPort(cfg.Port))) + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(runCtx, "tcp", addr) + if err != nil { + return fmt.Errorf("SSH 连接失败: %w", err) + } + defer conn.Close() + + sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, clientConfig) + if err != nil { + return fmt.Errorf("SSH 认证失败: %w", err) + } + client := ssh.NewClient(sshConn, chans, reqs) + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("SSH 会话创建失败: %w", err) + } + defer session.Close() + + var stderr bytes.Buffer + session.Stderr = &stderr + if err := session.Run(command); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return fmt.Errorf("nftables 下发失败: %s", sanitizeSSHError(msg)) + } + return nil +} + +func buildSSHClientConfig(cfg SSHConfig) (*ssh.ClientConfig, error) { + authType := strings.TrimSpace(strings.ToLower(cfg.AuthType)) + var methods []ssh.AuthMethod + switch authType { + case "password": + if strings.TrimSpace(cfg.Password) == "" { + return nil, fmt.Errorf("SSH 密码不能为空") + } + methods = append(methods, ssh.Password(cfg.Password)) + case "private_key", "": + signer, err := parsePrivateKey(cfg.PrivateKey, cfg.Passphrase) + if err != nil { + return nil, err + } + methods = append(methods, ssh.PublicKeys(signer)) + default: + return nil, fmt.Errorf("不支持的 SSH 认证方式: %s", authType) + } + if strings.TrimSpace(cfg.Username) == "" { + return nil, fmt.Errorf("SSH 用户名不能为空") + } + return &ssh.ClientConfig{ + User: strings.TrimSpace(cfg.Username), + Auth: methods, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 15 * time.Second, + }, nil +} + +func parsePrivateKey(key, passphrase string) (ssh.Signer, error) { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return nil, fmt.Errorf("SSH 私钥不能为空") + } + if strings.TrimSpace(passphrase) != "" { + signer, err := ssh.ParsePrivateKeyWithPassphrase([]byte(trimmed), []byte(passphrase)) + if err != nil { + return nil, fmt.Errorf("SSH 私钥解析失败") + } + return signer, nil + } + signer, err := ssh.ParsePrivateKey([]byte(trimmed)) + if err != nil { + return nil, fmt.Errorf("SSH 私钥解析失败") + } + return signer, nil +} + +func nftCommand(cfg SSHConfig, command string) string { + if strings.TrimSpace(strings.ToLower(cfg.SudoMode)) == "sudo" { + return "sudo nft " + command + } + return "nft " + command +} + +func normalizedSSHPort(port int) int { + if port <= 0 { + return 22 + } + return port +} + +func sanitizeSSHError(message string) string { + msg := strings.TrimSpace(message) + if len(msg) > 800 { + msg = msg[:800] + } + return msg +} +``` + +- [ ] **Step 4: Implement manager** + +Create `go-backend/internal/runtime/nftables/manager.go`: + +```go +package nftables + +import ( + "context" + "errors" +) + +type Manager struct { + runner Runner +} + +func NewManager(runner Runner) *Manager { + if runner == nil { + runner = NewSSHRunner() + } + return &Manager{runner: runner} +} + +func (m *Manager) Test(ctx context.Context, cfg SSHConfig) error { + if m == nil || m.runner == nil { + return errors.New("nftables manager not initialized") + } + return m.runner.Test(ctx, cfg) +} + +func (m *Manager) Reconcile(ctx context.Context, cfg SSHConfig, plan NodePlan) (ApplyResult, error) { + if m == nil || m.runner == nil { + return ApplyResult{}, errors.New("nftables manager not initialized") + } + script := RenderTable(plan) + if err := m.runner.ApplyScript(ctx, cfg, script); err != nil { + return ApplyResult{}, err + } + return ApplyResult{NodeID: plan.NodeID, Script: script, Hashes: PlanHashes(plan)}, nil +} + +func (m *Manager) Clear(ctx context.Context, cfg SSHConfig) error { + if m == nil || m.runner == nil { + return errors.New("nftables manager not initialized") + } + return m.runner.ApplyScript(ctx, cfg, RenderTable(NodePlan{})) +} +``` + +- [ ] **Step 5: Fix shell command if tests expose quoting issues** + +If `go test` fails because `runner.go` builds an invalid shell string, replace `ApplyScript` command construction with this safer variant: + +```go +func (r *SSHRunner) ApplyScript(ctx context.Context, cfg SSHConfig, script string) error { + escaped := strings.ReplaceAll(script, "'", "'\"'\"'") + nftPrefix := "nft" + if strings.TrimSpace(strings.ToLower(cfg.SudoMode)) == "sudo" { + nftPrefix = "sudo nft" + } + command := fmt.Sprintf( + "tmp=/tmp/flvx-nft-$(date +%%s%%N).nft; cat > \"$tmp\" <<'EOF'\n%s\nEOF\n%s list table inet flvx >/dev/null 2>&1 && %s delete table inet flvx || true; %s -f \"$tmp\"; rc=$?; rm -f \"$tmp\"; exit $rc", + escaped, + nftPrefix, + nftPrefix, + nftPrefix, + ) + return r.run(ctx, cfg, command) +} +``` + +- [ ] **Step 6: Run runtime tests** + +Run: + +```bash +(cd go-backend && go test ./internal/runtime/nftables -count=1) +``` + +Expected: PASS. + +--- + +### Task 4: Handler Integration And Capability Validation + +**Files:** +- Modify: `go-backend/internal/http/handler/handler.go` +- Create: `go-backend/internal/http/handler/nftables_runtime.go` +- Create: `go-backend/internal/http/handler/nftables_runtime_test.go` +- Modify: `go-backend/internal/http/handler/mutations.go` +- Modify: `go-backend/internal/http/handler/control_plane.go` + +- [ ] **Step 1: Write failing handler validation tests** + +Create `go-backend/internal/http/handler/nftables_runtime_test.go`: + +```go +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "go-backend/internal/store/repo" +) + +func TestNftablesTunnelRejectsTunnelForwarding(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + h := New(r, "test-secret") + now := time.Now().UnixMilli() + if err := r.CreateNode("nft", "secret", "203.0.113.10", nil, nil, "10000-20000", nil, nil, nil, nil, nil, 0, 0, 0, now, 1, "[::]", "[::]", 1, 0, nil, nil, nil, nil, "nftables"); err != nil { + t.Fatalf("create node: %v", err) + } + + body := strings.NewReader(`{"name":"bad-nft-tunnel","type":2,"inNodeId":[{"nodeId":1,"protocol":"tcp"}],"outNodeId":[{"nodeId":1,"protocol":"tcp"}]}`) + res := httptest.NewRecorder() + h.tunnelCreate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", body)) + if !strings.Contains(res.Body.String(), "nftables") || !strings.Contains(res.Body.String(), "隧道转发") { + t.Fatalf("expected nftables tunnel forwarding rejection, got %s", res.Body.String()) + } +} + +func TestNftablesForwardRejectsUnsupportedFields(t *testing.T) { + if err := validateNftablesForwardRequest(map[string]interface{}{ + "remoteAddr": "198.51.100.20:443", + "speedId": float64(1), + "proxyProtocol": float64(1), + }); err == nil { + t.Fatalf("expected unsupported fields error") + } + if err := validateNftablesForwardRequest(map[string]interface{}{ + "remoteAddr": "198.51.100.20:443,198.51.100.21:443", + }); err == nil { + t.Fatalf("expected multi target error") + } + if err := validateNftablesForwardRequest(map[string]interface{}{ + "remoteAddr": "198.51.100.20:443", + }); err != nil { + t.Fatalf("expected valid nftables forward request, got %v", err) + } +} +``` + +- [ ] **Step 2: Run handler tests and verify failure** + +Run: + +```bash +(cd go-backend && go test ./internal/http/handler -run 'TestNftables' -count=1) +``` + +Expected: FAIL because `validateNftablesForwardRequest` and handler integration do not exist. + +- [ ] **Step 3: Initialize nftables manager on Handler** + +In `go-backend/internal/http/handler/handler.go`, import: + +```go + "go-backend/internal/runtime/nftables" +``` + +Add to `Handler`: + +```go + nftables *nftables.Manager +``` + +Initialize in `New`: + +```go + nftables: nftables.NewManager(nil), +``` + +- [ ] **Step 4: Add handler helpers** + +Create `go-backend/internal/http/handler/nftables_runtime.go`: + +```go +package handler + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "go-backend/internal/http/response" + runtimenft "go-backend/internal/runtime/nftables" + "go-backend/internal/store/repo" +) + +func isNftablesMode(mode string) bool { + return strings.TrimSpace(strings.ToLower(mode)) == runtimenft.ModeNftables +} + +func (h *Handler) nodeForwardMode(nodeID int64) string { + mode, err := h.repo.GetNodeForwardMode(nodeID) + if err != nil { + return runtimenft.ModeAgent + } + return mode +} + +func (h *Handler) tunnelUsesNftables(tunnelID int64) bool { + nodes, err := h.tunnelEntryNodeIDs(tunnelID) + if err != nil || len(nodes) == 0 { + return false + } + return isNftablesMode(h.nodeForwardMode(nodes[0])) +} + +func (h *Handler) validateNftablesTunnelState(state *tunnelCreateState) error { + if state == nil || len(state.InNodes) == 0 { + return nil + } + mode := h.nodeForwardMode(state.InNodes[0].NodeID) + for _, n := range state.InNodes { + if h.nodeForwardMode(n.NodeID) != mode { + return errors.New("同一隧道不能混用 agent 和 nftables 节点") + } + } + if !isNftablesMode(mode) { + return nil + } + if state.Type != 1 { + return errors.New("nftables 节点不支持隧道转发") + } + if len(state.OutNodes) > 0 || len(state.ChainHops) > 0 { + return errors.New("nftables 纯转发不支持出口节点或转发链") + } + return nil +} + +func validateNftablesForwardRequest(req map[string]interface{}) error { + if req == nil { + return errors.New("请求参数错误") + } + if asAnyToInt64Ptr(req["speedId"]) != nil { + return errors.New("nftables 纯转发不支持限速") + } + if asAnyToInt64Ptr(req["ipSpeedId"]) != nil { + return errors.New("nftables 纯转发不支持每 IP 限速") + } + if asInt(req["maxConn"], 0) > 0 || asInt(req["ipMaxConn"], 0) > 0 { + return errors.New("nftables 纯转发不支持连接数限制") + } + if asInt(req["proxyProtocol"], 0) > 0 { + return errors.New("nftables 纯转发不支持 Proxy Protocol") + } + if _, err := runtimenft.ParseSingleTarget(asString(req["remoteAddr"])); err != nil { + return err + } + return nil +} + +func (h *Handler) sshConfigForNode(nodeID int64) (runtimenft.SSHConfig, error) { + cfg, err := h.repo.GetNodeSSHConfig(nodeID) + if err != nil { + return runtimenft.SSHConfig{}, err + } + return runtimenft.SSHConfig{ + Host: cfg.Host, + Port: cfg.Port, + Username: cfg.Username, + AuthType: cfg.AuthType, + Password: cfg.Password.String, + PrivateKey: cfg.PrivateKey.String, + Passphrase: cfg.Passphrase.String, + SudoMode: cfg.SudoMode, + }, nil +} + +func (h *Handler) syncNftablesNode(nodeID int64) error { + cfg, err := h.sshConfigForNode(nodeID) + if err != nil { + return fmt.Errorf("读取 SSH 配置失败: %w", err) + } + plan, err := h.buildNftablesNodePlan(nodeID) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + result, err := h.nftables.Reconcile(ctx, cfg, plan) + now := time.Now().UnixMilli() + if err != nil { + for _, rule := range plan.Rules { + _ = h.repo.MarkNftRuleBindingError(rule.ForwardID, nodeID, err.Error(), now) + } + return err + } + for _, rule := range plan.Rules { + _ = h.repo.UpsertNftRuleBinding(repoBindingFromRule(nodeID, rule, result.Hashes[rule.ForwardID]), now) + } + return nil +} + +func repoBindingFromRule(nodeID int64, rule runtimenft.Rule, hash string) repo.NftRuleBindingInput { + return repo.NftRuleBindingInput{ + ForwardID: rule.ForwardID, + NodeID: nodeID, + InPort: rule.InPort, + Protocols: strings.Join(rule.Protocols, ","), + TargetAddr: fmt.Sprintf("%s:%d", rule.TargetHost, rule.TargetPort), + BindIP: rule.BindIP, + RuleHash: hash, + Status: runtimenft.StatusApplied, + LastError: "", + } +} +``` + +- [ ] **Step 5: Add build plan helper** + +Append to `nftables_runtime.go`: + +```go +func (h *Handler) buildNftablesNodePlan(nodeID int64) (runtimenft.NodePlan, error) { + forwards, err := h.repo.ListActiveForwardsByEntryNode(nodeID) + if err != nil { + return runtimenft.NodePlan{}, err + } + plan := runtimenft.NodePlan{NodeID: nodeID, Rules: make([]runtimenft.Rule, 0, len(forwards))} + for _, f := range forwards { + target, err := runtimenft.ParseSingleTarget(f.RemoteAddr) + if err != nil { + return runtimenft.NodePlan{}, fmt.Errorf("规则 %d 目标地址无效: %w", f.ID, err) + } + port := 0 + bindIP := "" + ports, err := h.listForwardPorts(f.ID) + if err != nil { + return runtimenft.NodePlan{}, err + } + for _, p := range ports { + if p.NodeID == nodeID { + port = p.Port + bindIP = p.InIP.String + break + } + } + if port <= 0 { + continue + } + plan.Rules = append(plan.Rules, runtimenft.Rule{ + ForwardID: f.ID, + InPort: port, + BindIP: bindIP, + TargetHost: target.Host, + TargetPort: target.Port, + Protocols: []string{"tcp", "udp"}, + }) + } + return plan, nil +} +``` + +Add `ListActiveForwardsByEntryNode` to `repository_nftables.go`: + +```go +func (r *Repository) ListActiveForwardsByEntryNode(nodeID int64) ([]model.ForwardRecord, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + var rows []model.Forward + err := r.db. + Joins("JOIN forward_port ON forward_port.forward_id = forward.id"). + Where("forward_port.node_id = ? AND forward.status = ?", nodeID, 1). + Order("forward.id ASC"). + Find(&rows).Error + if err != nil { + return nil, err + } + out := make([]model.ForwardRecord, 0, len(rows)) + for _, f := range rows { + out = append(out, model.ForwardRecord{ + ID: f.ID, + UserID: f.UserID, + UserName: f.UserName, + Name: f.Name, + TunnelID: f.TunnelID, + RemoteAddr: f.RemoteAddr, + Strategy: f.Strategy, + Status: f.Status, + SpeedID: f.SpeedID, + MaxConn: f.MaxConn, + IPMaxConn: f.IPMaxConn, + IPSpeedID: f.IPSpeedID, + ProxyProtocol: f.ProxyProtocol, + }) + } + return out, nil +} +``` + +- [ ] **Step 6: Route nftables forwards away from GOST sync** + +In `forwardCreate`, after loading `tunnel`, add: + +```go + nftTunnel := h.tunnelUsesNftables(tunnelID) + if nftTunnel { + if err := validateNftablesForwardRequest(req); err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + } +``` + +After creating `createdForward`, replace the existing unconditional `syncForwardServices` block with: + +```go + if nftTunnel { + for _, nodeID := range entryNodes { + if err := h.syncNftablesNode(nodeID); err != nil { + _ = h.deleteForwardByID(forwardID) + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + } + } else if err := h.syncForwardServices(createdForward, "UpdateService", true); err != nil { + _ = h.deleteForwardByID(forwardID) + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } +``` + +Make the same pattern in `forwardUpdate`: validate when target tunnel uses nftables, and after `updatedForward` use `syncNftablesNode` for new entry nodes instead of `syncForwardServicesWithWarnings`. + +- [ ] **Step 7: Validate nftables tunnel creation** + +In `tunnelCreate`, after `runtimeState, err := h.prepareTunnelCreateState(...)` and before federation runtime, add: + +```go + if err := h.validateNftablesTunnelState(runtimeState); err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } +``` + +In `tunnelUpdate`, add the same check after preparing update runtime state. + +- [ ] **Step 8: Skip GOST service sync for nftables forwards** + +At the top of `syncForwardServicesWithWarnings` in `control_plane.go`, after nil checks and before building services, add: + +```go + if h.tunnelUsesNftables(forward.TunnelID) { + entryNodes, _ := h.tunnelEntryNodeIDs(forward.TunnelID) + for _, nodeID := range entryNodes { + if err := h.syncNftablesNode(nodeID); err != nil { + return nil, err + } + } + return nil, nil + } +``` + +- [ ] **Step 9: Run handler tests** + +Run: + +```bash +(cd go-backend && go test ./internal/http/handler -run 'TestNftables' -count=1) +``` + +Expected: PASS. + +--- + +### Task 5: Delete, Redeploy, Test, And Clear Operations + +**Files:** +- Modify: `go-backend/internal/http/handler/handler.go` +- Modify: `go-backend/internal/http/handler/mutations.go` +- Modify: `go-backend/internal/http/handler/nftables_runtime.go` +- Modify: `vite-frontend/src/api/index.ts` + +- [ ] **Step 1: Add routes** + +In `handler.go` route registration, add: + +```go + mux.HandleFunc("/api/v1/node/nftables/test", h.nodeNftablesTest) + mux.HandleFunc("/api/v1/node/nftables/reconcile", h.nodeNftablesReconcile) + mux.HandleFunc("/api/v1/node/nftables/clear", h.nodeNftablesClear) +``` + +- [ ] **Step 2: Implement maintenance handlers** + +Append to `nftables_runtime.go`: + +```go +func (h *Handler) nodeNftablesTest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + id := idFromBody(r, w) + if id <= 0 { + return + } + if !isNftablesMode(h.nodeForwardMode(id)) { + response.WriteJSON(w, response.ErrDefault("该节点不是 nftables 节点")) + return + } + cfg, err := h.sshConfigForNode(id) + if err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + if err := h.nftables.Test(ctx, cfg); err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + response.WriteJSON(w, response.OKEmpty()) +} + +func (h *Handler) nodeNftablesReconcile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + id := idFromBody(r, w) + if id <= 0 { + return + } + if !isNftablesMode(h.nodeForwardMode(id)) { + response.WriteJSON(w, response.ErrDefault("该节点不是 nftables 节点")) + return + } + if err := h.syncNftablesNode(id); err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + response.WriteJSON(w, response.OKEmpty()) +} + +func (h *Handler) nodeNftablesClear(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + id := idFromBody(r, w) + if id <= 0 { + return + } + if !isNftablesMode(h.nodeForwardMode(id)) { + response.WriteJSON(w, response.ErrDefault("该节点不是 nftables 节点")) + return + } + cfg, err := h.sshConfigForNode(id) + if err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + if err := h.nftables.Clear(ctx, cfg); err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + response.WriteJSON(w, response.OKEmpty()) +} +``` + +Ensure `nftables_runtime.go` imports `go-backend/internal/http/response`. + +- [ ] **Step 3: Integrate delete cleanup** + +In `deleteForwardByID`, before deleting DB rows, load the forward and entry nodes: + +```go + forward, fErr := h.getForwardRecord(id) + var nftNodeIDs []int64 + if fErr == nil && forward != nil && h.tunnelUsesNftables(forward.TunnelID) { + nftNodeIDs, _ = h.tunnelEntryNodeIDs(forward.TunnelID) + } +``` + +After DB delete succeeds, add: + +```go + if len(nftNodeIDs) > 0 { + for _, nodeID := range nftNodeIDs { + if err := h.syncNftablesNode(nodeID); err != nil { + return err + } + } + _ = h.repo.DeleteNftRuleBindingsByForward(id) + } +``` + +For `forwardForceDelete`, call `DeleteNftRuleBindingsByForward(id)` after the forced DB cleanup, but do not require SSH success. + +- [ ] **Step 4: Route batch redeploy through reconcile** + +In `forwardBatchRedeploy`, before `h.syncForwardServices`, add: + +```go + if h.tunnelUsesNftables(forward.TunnelID) { + entryNodes, _ := h.tunnelEntryNodeIDs(forward.TunnelID) + for _, nodeID := range entryNodes { + if err := h.syncNftablesNode(nodeID); err != nil { + failures = append(failures, batchOperationFailure{ID: id, Name: forward.Name, Error: err.Error()}) + } + } + continue + } +``` + +In `tunnelBatchRedeploy`, when a tunnel uses nftables, reconcile entry nodes and skip `applyTunnelRuntimeUpsert`. + +- [ ] **Step 5: Add frontend API methods** + +In `vite-frontend/src/api/index.ts`, add: + +```ts +export const testNodeNftables = (id: number) => + Network.post("/node/nftables/test", { id }); +export const reconcileNodeNftables = (id: number) => + Network.post("/node/nftables/reconcile", { id }); +export const clearNodeNftables = (id: number) => + Network.post("/node/nftables/clear", { id }); +``` + +- [ ] **Step 6: Run backend tests** + +Run: + +```bash +(cd go-backend && go test ./internal/http/handler -run 'TestNftables|TestForward' -count=1) +``` + +Expected: PASS. + +Run: + +```bash +(cd go-backend && go test ./...) +``` + +Expected: PASS. + +--- + +### Task 6: Frontend Node Mode And SSH Form + +**Files:** +- Modify: `vite-frontend/src/api/types.ts` +- Modify: `vite-frontend/src/pages/node.tsx` + +- [ ] **Step 1: Add API types** + +In `vite-frontend/src/api/types.ts`, add near node types: + +```ts +export type NodeForwardMode = "agent" | "nftables"; + +export interface NodeSSHConfigPayload { + host: string; + port: number; + username: string; + authType: "password" | "private_key"; + password?: string; + privateKey?: string; + passphrase?: string; + sudoMode: "none" | "sudo"; +} +``` + +Add to `NodeApiItem`: + +```ts + forwardMode?: NodeForwardMode; + sshConfig?: Partial; +``` + +- [ ] **Step 2: Extend node form state** + +In `vite-frontend/src/pages/node.tsx`, extend `NodeForm`: + +```ts + forwardMode: "agent" | "nftables"; + sshHost: string; + sshPort: string; + sshUsername: string; + sshAuthType: "password" | "private_key"; + sshPassword: string; + sshPrivateKey: string; + sshPassphrase: string; + sshSudoMode: "none" | "sudo"; +``` + +Update the reset/default form object to include: + +```ts + forwardMode: "agent", + sshHost: "", + sshPort: "22", + sshUsername: "root", + sshAuthType: "private_key", + sshPassword: "", + sshPrivateKey: "", + sshPassphrase: "", + sshSudoMode: "none", +``` + +In `handleEdit`, set these fields from `node.forwardMode` and `node.sshConfig`: + +```ts + forwardMode: node.forwardMode === "nftables" ? "nftables" : "agent", + sshHost: + typeof node.sshConfig?.host === "string" + ? node.sshConfig.host + : normalizedV4 || normalizedV6 || normalizedHost, + sshPort: + typeof node.sshConfig?.port === "number" + ? String(node.sshConfig.port) + : "22", + sshUsername: + typeof node.sshConfig?.username === "string" + ? node.sshConfig.username + : "root", + sshAuthType: + node.sshConfig?.authType === "password" ? "password" : "private_key", + sshPassword: "", + sshPrivateKey: "", + sshPassphrase: "", + sshSudoMode: node.sshConfig?.sudoMode === "sudo" ? "sudo" : "none", +``` + +- [ ] **Step 3: Add validation for nftables SSH fields** + +In `validateForm`, after host/port validation, add: + +```ts + 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" && !isEdit && !form.sshPassword.trim()) { + newErrors.sshPassword = "请输入 SSH 密码"; + } + if ( + form.sshAuthType === "private_key" && + !isEdit && + !form.sshPrivateKey.trim() + ) { + newErrors.sshPrivateKey = "请输入 SSH 私钥"; + } + } +``` + +- [ ] **Step 4: Submit mode and SSH payload** + +In `handleSubmit`, build `sshConfig` before `data`: + +```ts + 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 + : undefined, + privateKey: + form.sshAuthType === "private_key" + ? form.sshPrivateKey + : undefined, + passphrase: form.sshPassphrase, + sudoMode: form.sshSudoMode, + } + : undefined; +``` + +Add to submitted `data`: + +```ts + forwardMode: form.forwardMode, + ...(sshConfig ? { sshConfig } : {}), +``` + +- [ ] **Step 5: Add form controls** + +In the node modal after the node address fields, add controls using existing imported `Select`, `SelectItem`, `Input`, and `Textarea` components: + +```tsx + + + {form.forwardMode === "nftables" && ( +
+ + setForm((prev) => ({ ...prev, sshHost: value })) + } + /> + + setForm((prev) => ({ ...prev, sshPort: value })) + } + /> + + setForm((prev) => ({ ...prev, sshUsername: value })) + } + /> + + {form.sshAuthType === "password" ? ( + + setForm((prev) => ({ ...prev, sshPassword: value })) + } + /> + ) : ( +