diff --git a/.gitignore b/.gitignore index 8c1e5b2..a5e5732 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ # .nfs files are created when an open file is removed but is still being accessed .nfs* +# AI Agent +.claude +.codexpotter diff --git a/README.md b/README.md index 3bb8a33..fe41429 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ - 仅管理本工具创建的 `MGMT:` 规则。 - 支持 TCP / UDP / both,支持 IPv4 / IPv6 / both。 - 支持 `iptables-persistent` / `netfilter-persistent save` 持久化。 +- 所有写操作通过 `flock` 串行化,降低多实例并发竞争风险。 - 附带单元测试与真实 iptables 集成测试。 ## 目录结构 @@ -143,7 +144,7 @@ tests/run_all.sh --skip-integration - `tests/test_common.sh`:输入校验 - `tests/test_storage.sh`:规则存储 - `tests/test_env_check.sh`:环境检查与修复 -- `tests/test_rules_unit.sh`:mock iptables 下的增删回滚 +- `tests/test_rules_unit.sh`:mock iptables 下的增删回滚与并发写保护 - `tests/test_integration.sh`:真实 iptables 生命周期 + save/reload 持久化回放测试 `tests/test_integration.sh` 的执行策略: diff --git a/lib/rules_mgr.sh b/lib/rules_mgr.sh index bd8e09a..a8b210a 100644 --- a/lib/rules_mgr.sh +++ b/lib/rules_mgr.sh @@ -172,7 +172,7 @@ cmd_list() { fi } -cmd_add_batch() { +_cmd_add_batch_locked() { local proto=${1-} local lport=${2-} local tip=${3-} @@ -191,14 +191,14 @@ cmd_add_batch() { return 1 fi - if ! storage_add "${line}"; then + if ! storage_add_unlocked "${line}"; then ipt_remove_rule "${uuid}" "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}" >/dev/null 2>&1 || true log_err '写入规则数据库失败。' return 1 fi if ! persist_save; then - storage_delete "${uuid}" >/dev/null 2>&1 || true + storage_delete_unlocked "${uuid}" >/dev/null 2>&1 || true ipt_remove_rule "${uuid}" "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}" >/dev/null 2>&1 || true log_err '保存持久化规则失败,已回滚。' return 1 @@ -208,6 +208,10 @@ cmd_add_batch() { printf '%s\n' "${uuid}" } +cmd_add_batch() { + storage_with_lock _cmd_add_batch_locked "$@" +} + cmd_add() { local proto_choice ipver_choice proto ipver lport tip tport desc @@ -264,7 +268,7 @@ cmd_add() { cmd_add_batch "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}" "${desc}" >/dev/null } -cmd_delete_uuid() { +_cmd_delete_uuid_locked() { local uuid=${1-} local line proto lport tip tport ipver line=$(storage_get "${uuid}") || { @@ -283,13 +287,14 @@ cmd_delete_uuid() { return 1 fi - if ! storage_delete "${uuid}"; then - log_err '删除规则数据库记录失败。' + if ! storage_delete_unlocked "${uuid}"; then + ipt_apply_rule "${uuid}" "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}" >/dev/null 2>&1 || true + log_err '删除规则数据库记录失败,已尝试回滚。' return 1 fi if ! persist_save; then - storage_add "${line}" >/dev/null 2>&1 || true + storage_add_unlocked "${line}" >/dev/null 2>&1 || true ipt_apply_rule "${uuid}" "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}" >/dev/null 2>&1 || true log_err '持久化保存失败,已尝试回滚。' return 1 @@ -298,6 +303,10 @@ cmd_delete_uuid() { log_ok "规则 ${uuid} 已删除。" } +cmd_delete_uuid() { + storage_with_lock _cmd_delete_uuid_locked "$@" +} + cmd_delete() { local index answer line uuid rules_load_lines @@ -348,7 +357,7 @@ cmd_show_env_status() { pause_return } -cmd_save_rules() { +_cmd_save_rules_locked() { if persist_save; then log_ok '规则已保存到磁盘。' else @@ -356,3 +365,7 @@ cmd_save_rules() { return 1 fi } + +cmd_save_rules() { + storage_with_lock _cmd_save_rules_locked +} diff --git a/lib/storage.sh b/lib/storage.sh index 7007491..4b0fe0d 100644 --- a/lib/storage.sh +++ b/lib/storage.sh @@ -34,20 +34,30 @@ storage_init() { chmod 600 "${lock}" } -storage_add() { - local line=${1-} - local db lock - [[ -n ${line} ]] || return 1 +storage_with_lock() { + local lock storage_init - db=$(storage_db_path) lock=$(storage_lock_path) ( flock -x 9 - printf '%s\n' "${line}" >>"${db}" + "$@" ) 9>>"${lock}" } +storage_add_unlocked() { + local line=${1-} + local db + [[ -n ${line} ]] || return 1 + storage_init + db=$(storage_db_path) + printf '%s\n' "${line}" >>"${db}" +} + +storage_add() { + storage_with_lock storage_add_unlocked "$@" +} + storage_list() { local db db=$(storage_db_path) @@ -83,34 +93,34 @@ storage_get() { return 1 } -storage_delete() { +storage_delete_unlocked() { local uuid=${1-} - local db lock tmp found=0 line current + local db tmp found=0 line current [[ -n ${uuid} ]] || return 1 storage_init db=$(storage_db_path) - lock=$(storage_lock_path) tmp="${db}.tmp.$$" - ( - flock -x 9 - : >"${tmp}" - while IFS= read -r line || [[ -n ${line} ]]; do - current=$(storage_parse "${line}" uuid || true) - if [[ ${current} == "${uuid}" ]]; then - found=1 - continue - fi - printf '%s\n' "${line}" >>"${tmp}" - done <"${db}" - - if (( found == 0 )); then - rm -f "${tmp}" - exit 1 + : >"${tmp}" + while IFS= read -r line || [[ -n ${line} ]]; do + current=$(storage_parse "${line}" uuid || true) + if [[ ${current} == "${uuid}" ]]; then + found=1 + continue fi + printf '%s\n' "${line}" >>"${tmp}" + done <"${db}" - mv "${tmp}" "${db}" - ) 9>>"${lock}" + if (( found == 0 )); then + rm -f "${tmp}" + return 1 + fi + + mv "${tmp}" "${db}" +} + +storage_delete() { + storage_with_lock storage_delete_unlocked "$@" } storage_count() { diff --git a/plan_iptables_forward.md b/plan_iptables_forward.md new file mode 100644 index 0000000..981945d --- /dev/null +++ b/plan_iptables_forward.md @@ -0,0 +1,560 @@ +# IPTables 端口转发管理脚本 · 实施方案 + +> 目标:在 Debian 12/13 上交付一个**自包含**、**交互式中文 CLI**、**可通过自动化测试验证**的端口转发管理工具。 +> 本方案按**阶段化排队执行**,每个阶段都有明确的**进入/退出状态**与**验证方式**。 + +--- + +## 0. 速览 (Executive Summary) + +| 维度 | 决策 | +|------|------| +| 协议 | 每条规则可选 TCP / UDP / 两者 | +| IP 版本 | 每条规则可选 IPv4 / IPv6 / 两者 (ip6tables) | +| 持久化 | `iptables-persistent` (netfilter-persistent 1.0.23) | +| 管理范围 | 仅管理本脚本添加的规则,靠 `-m comment --comment "MGMT:"` 标识 | +| 存储 | `/var/lib/iptables-forward/rules.db` (pipe-separated KV) | +| UI 语言 | 交互全部中文,日志 & 代码英文 | +| 入口 | `iptables-forward.sh` (可选 `install.sh` 链接到 `/usr/local/bin/iptables-forward`) | +| 最小运行依赖 | bash ≥ 4, root/sudo, iptables, (可选) ip6tables, iptables-persistent | + +--- + +## 1. 目标与边界 + +### 1.1 必须达成 (Must) + +1. `./iptables-forward.sh` 启动后立即做**环境自检**;若缺失依赖,**明确列出并询问是否自动安装**,用户确认后静默 apt 安装完成。 +2. 主菜单提供:**查看规则**、**添加规则**、**删除规则**、**环境状态**、**保存规则**、**退出**。 +3. 添加时支持字段:协议 (tcp/udp/both)、本地端口、目标 IP (v4 或 v6)、目标端口、IP 版本 (4/6/both)、描述 (可选)。 +4. 规则立即生效,且通过 `netfilter-persistent save` 写入 `/etc/iptables/rules.v4` 与 `/etc/iptables/rules.v6`。 +5. 重启后规则不丢失;重启后脚本再次打开仍能**正确列出已有的本脚本规则**。 +6. 删除时仅删除本脚本管理的规则;操作需**二次确认**。 +7. 所有输入经**校验** (IP 合法性、端口范围、协议枚举、IP 族与 IP 字符串匹配)。 +8. 整套代码随附**自动化测试**,至少覆盖单元级校验与核心生命周期。测试脚本在 `tests/` 目录,`tests/run_all.sh` 一键运行。 + +### 1.2 不做 (Non-Goals) + +- 不管理 INPUT/OUTPUT、路由表、防火墙策略。 +- 不替代/改写非本脚本创建的 iptables 规则。 +- 不实现 Web UI、REST API。 +- 不在 nftables 纯原生语法下工作 (但兼容 Debian 13 的 `iptables-nft` 后端)。 +- 不自动开放 `ufw`/`firewalld` (用户自行负责)。 + +--- + +## 2. 技术选型 + +| 模块 | 方案 | 理由 | +|------|------|------| +| Shell | `bash`, `set -Eeuo pipefail`, `trap` 兜底 | Debian 默认,行为可控 | +| 包管理 | `apt-get -y install` (non-interactive, `DEBIAN_FRONTEND=noninteractive`) | 避免交互弹窗 | +| 持久化 | `iptables-persistent` + `netfilter-persistent save/reload` | 官方包,1.0.23 在 trixie 可用 | +| IP 转发 | 写 `/etc/sysctl.d/99-iptables-forward.conf` 并 `sysctl --system` | 现代 drop-in 模式 | +| 规则定位 | `iptables -m comment --comment "MGMT:"` + 精确 `-D` 删除 | 无需 line-number 脆弱匹配 | +| UUID | `head -c 8` from `/proc/sys/kernel/random/uuid` (去掉 `-`) | 无外部依赖 | +| 存储格式 | pipe-separated KV,一行一条规则 | 人眼可读、易用 `awk`/`grep` 解析 | +| UI | ANSI 颜色 + Unicode 盒线 (`╔═╗║╚╝╠╣╤╧`) | Debian 默认 UTF-8 终端可用 | +| IP 校验 | Bash 正则 (`^([0-9]{1,3}\.){3}[0-9]{1,3}$` + 段值 <=255 校验) + IPv6 相对宽松匹配 | 避免 `python`/`perl` 依赖 | +| 测试 | 纯 bash 测试框架,支持 mock 与 integration,整数退出码断言 | 无需 bats/shunit | + +### 2.1 iptables 规则模板 (每条逻辑规则生成) + +对协议 P (tcp/udp) × IP 族 F (iptables/ip6tables),生成 **3 条** iptables 规则: + +```text +# 1) PREROUTING DNAT — 外部进入的包改目标 +$CMD -t nat -A PREROUTING -p

--dport \ + -j DNAT --to-destination : \ + -m comment --comment "MGMT:" + +# 2) POSTROUTING MASQUERADE — 返程包伪装源地址 +$CMD -t nat -A POSTROUTING -p

-d --dport \ + -j MASQUERADE \ + -m comment --comment "MGMT:" + +# 3) FORWARD ACCEPT — 绕过 FORWARD DROP 策略 +$CMD -A FORWARD -p

-d --dport \ + -m conntrack --ctstate NEW,ESTABLISHED,RELATED \ + -j ACCEPT \ + -m comment --comment "MGMT:" +``` + +其中 `$CMD` 为 `iptables`(v4) 或 `ip6tables`(v6)。`` v6 时需加 `[]` (仅在 `--to-destination` 部分)。 + +一条"逻辑规则"的展开上限: +- `proto=both` × `ipv=both` = 2 × 2 × 3 条 = **12** 条 iptables 规则 +- 最常见 `proto=tcp` × `ipv=4` = 1 × 1 × 3 条 = **3** 条 + +所有规则共享同一个 UUID,通过注释绑定,删除时一并清除。 + +--- + +## 3. 目录结构 + +```text +IPTables-Management/ +├── iptables-forward.sh # 主入口,交互菜单 (short, delegates to lib) +├── install.sh # 一键链接到 /usr/local/bin (可选) +├── lib/ +│ ├── common.sh # 颜色/盒线/提示/通用校验 (IP, port) +│ ├── env_check.sh # 环境自检 + 自动安装 + sysctl 配置 +│ ├── rules_mgr.sh # 添加/列表/删除 业务逻辑 +│ ├── iptables_ops.sh # 纯 iptables 命令封装 (可被 mock) +│ ├── persist.sh # netfilter-persistent save/reload +│ └── storage.sh # rules.db 读写与解析 +├── tests/ +│ ├── lib/ +│ │ └── assert.sh # 测试断言工具 (assert_eq, assert_contains) +│ ├── mocks/ +│ │ └── iptables-mock.sh # mock iptables 命令, 记录调用 +│ ├── test_common.sh # 校验函数单元测试 (IP, port 合法性) +│ ├── test_storage.sh # 存储层 CRUD 单元测试 +│ ├── test_rules_unit.sh # rules_mgr + mock iptables +│ ├── test_env_check.sh # 环境检查决策表测试 +│ ├── test_integration.sh # 真实 iptables 测试 (需要 root, 可跳过) +│ └── run_all.sh # 统一入口,支持 --skip-integration +├── plan_iptables_forward.md # 本文档 +└── README.md # 使用说明 (最小化) +``` + +运行期状态与配置 (安装后产生): + +```text +/var/lib/iptables-forward/ +└── rules.db # 规则元数据库 (权限 640, root:root) + +/etc/sysctl.d/ +└── 99-iptables-forward.conf # ip_forward / ipv6 forwarding 开关 + +/etc/iptables/ # 由 iptables-persistent 管理 +├── rules.v4 +└── rules.v6 +``` + +--- + +## 4. 核心组件设计 + +### 4.1 `lib/common.sh` — 基础设施层 + +职责: +- ANSI 颜色常量:`CLR_RED/GREEN/YELLOW/BLUE/CYAN/BOLD/DIM/RESET` +- 盒线绘制函数:`box_top / box_bottom / box_line / box_separator` (参数为宽度与内容) +- 日志函数:`log_info / log_warn / log_err / log_ok` (彩色 + 时间戳) +- 交互函数: + - `prompt_input "提示" [默认值]` → 回显字符串 + - `prompt_confirm "提示" [y|n]` → 返回 0/1 + - `prompt_select "提示" "选项1" "选项2" …` → 回显索引 +- 校验: + - `validate_ipv4 ` → 0/1 + - `validate_ipv6 ` → 0/1 + - `validate_port ` → 0/1 (1–65535) + - `validate_proto ` → 0/1 (tcp/udp/both) + - `validate_ipver ` → 0/1 (4/6/both) +- `require_root` — 非 root 则打印提示并退出 2 + +### 4.2 `lib/env_check.sh` — 环境自检与安装 + +决策表 (脚本启动第 1 步): + +| 检测项 | 条件 | 缺失行为 | +|--------|------|----------| +| 运行用户 | UID==0 | 立即 `exit 2`,提示 `sudo` | +| `iptables` | `command -v iptables` | apt install iptables | +| `ip6tables` | `command -v ip6tables` (若开启 v6) | 同上 (同包) | +| `iptables-persistent` | `dpkg -s iptables-persistent` | apt install iptables-persistent | +| `/proc/sys/net/ipv4/ip_forward` | 值为 1 | 写 `/etc/sysctl.d/99-iptables-forward.conf` 并 `sysctl --system` | +| `/proc/sys/net/ipv6/conf/all/forwarding` | 值为 1 | 同上 | +| `/var/lib/iptables-forward/` | 存在且可写 | `mkdir -p` + `chmod 750` | + +关键实现要点: +- 汇总所有缺失项,一次性展示给用户,**只问一次**「是否自动修复?(y/N)」。 +- 用户拒绝则退出 3,告知用户手动命令。 +- 用户同意则按顺序执行;任一步失败立即终止并回滚 sysctl 写入 (非常重要)。 +- apt 安装使用: + ```bash + DEBIAN_FRONTEND=noninteractive apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq iptables iptables-persistent + ``` +- `iptables-persistent` 安装时 debconf 会询问是否保存当前规则 → 预置答案: + ```bash + echo iptables-persistent iptables-persistent/autosave_v4 boolean true | debconf-set-selections + echo iptables-persistent iptables-persistent/autosave_v6 boolean true | debconf-set-selections + ``` + +### 4.3 `lib/storage.sh` — 规则元数据存储 + +文件:`/var/lib/iptables-forward/rules.db` +格式:每行一条规则,以 `|` 分隔 k=v 对。示例: + +```text +uuid=a1b2c3d4|proto=tcp|lport=8080|tip=192.168.1.100|tport=80|ipver=4|desc=web|created=2026-04-17T10:00:00+08:00 +uuid=e5f6g7h8|proto=both|lport=53535|tip=8.8.8.8|tport=53|ipver=4|desc=DNS|created=2026-04-17T10:05:00+08:00 +``` + +接口: +- `storage_init` — 确保文件存在且权限为 0640 root:root +- `storage_add "||…"` — 追加一行 +- `storage_list` — 输出全部行 +- `storage_get ` — 输出指定行,未找到返回 1 +- `storage_delete ` — 原子替换 (写入临时文件后 `mv`) +- `storage_parse ` — 从行中抽取字段值 + +### 4.4 `lib/iptables_ops.sh` — iptables 命令封装 + +为可测试性,所有 iptables 调用都经该层;测试时可以用 `IPTABLES_BIN` / `IP6TABLES_BIN` 环境变量替换为 mock。 + +```bash +: "${IPTABLES_BIN:=iptables}" +: "${IP6TABLES_BIN:=ip6tables}" + +ipt_apply_rule() # args: uuid proto lport tip tport ipver +ipt_remove_rule() # args: uuid proto lport tip tport ipver +ipt_find_by_uuid() # args: uuid → 列出匹配的 (cmd, table, chain, rule) 组 +``` + +`ipt_apply_rule` 内部展开为:对每个 (proto × ipver) 组合,调用 3 次对应的二进制。 + +`ipt_remove_rule` 使用**与添加完全一致的参数**加 `-D` 即可精确删除;不依赖 line number。 + +### 4.5 `lib/rules_mgr.sh` — 业务编排层 + +职责: +- `cmd_list` — 从 storage 读出全部规则,格式化为表格,对每条规则用 `ipt_find_by_uuid` 校验是否仍在 iptables 中 (健康指示 [✓]/[!])。 +- `cmd_add` — 交互表单 → 校验 → 生成 UUID → `ipt_apply_rule` → `storage_add` → `persist_save`。失败自动回滚已加 iptables 规则。 +- `cmd_delete` — 调用 `cmd_list` 的交互版 → 用户选编号 → 二次确认 → `ipt_remove_rule` → `storage_delete` → `persist_save`。 + +### 4.6 `lib/persist.sh` + +```bash +persist_save() { netfilter-persistent save >/dev/null; } +persist_reload() { netfilter-persistent reload >/dev/null; } +``` + +注意:Debian 13 默认使用 `iptables-nft`,`netfilter-persistent save` 已兼容。若未来需要 legacy 后端,可通过 `update-alternatives --set iptables /usr/sbin/iptables-legacy` 切换,**本方案不做自动切换**。 + +### 4.7 `iptables-forward.sh` — 主入口 + +启动流程: + +```text +1. trap ERR 收集异常, 打印彩色栈 +2. source lib/common.sh +3. require_root +4. source 其它 lib +5. env_check_all (缺失→提示→修复→再检测;全部就绪才继续) +6. storage_init +7. main_menu loop +``` + +主菜单循环: + +```text +┌──────────────────────────────────────┐ +│ [1] 查看所有转发规则 │ +│ [2] 添加新的转发规则 │ +│ [3] 删除现有转发规则 │ +│ [4] 查看环境状态 │ +│ [5] 立即保存规则到磁盘 │ +│ [0] 退出 │ +└──────────────────────────────────────┘ +``` + +--- + +## 5. 交互界面样例 + +### 5.1 主菜单 + +```text +╔══════════════════════════════════════════════════════╗ +║ IPTables 端口转发管理工具 v1.0 ║ +╠══════════════════════════════════════════════════════╣ +║ 状态: [✓] 已就绪 规则数: 3 持久化: [✓] ║ +╠══════════════════════════════════════════════════════╣ +║ [1] 查看所有转发规则 ║ +║ [2] 添加新的转发规则 ║ +║ [3] 删除现有转发规则 ║ +║ [4] 查看系统环境状态 ║ +║ [5] 立即保存到磁盘 ║ +║ [0] 退出 ║ +╚══════════════════════════════════════════════════════╝ + 请选择 [0-5]: +``` + +### 5.2 规则列表 + +```text +╔══╤══════╤═════════╤═════════════════╤═════════╤══════╤════╤══════════════╗ +║# │ 协议 │ 本地端口 │ 目标地址 │ 目标端口 │ IPv │ ✓ │ 描述 ║ +╠══╪══════╪═════════╪═════════════════╪═════════╪══════╪════╪══════════════╣ +║ 1│ TCP │ 8080 │ 192.168.1.100 │ 80 │ 4 │ ✓ │ web 服务 ║ +║ 2│ UDP │ 53535 │ 8.8.8.8 │ 53 │ 4 │ ✓ │ DNS 转发 ║ +║ 3│ TCP │ 2222 │ 2001:db8::1 │ 22 │ 6 │ ! │ SSH 跳板 ║ +╚══╧══════╧═════════╧═════════════════╧═════════╧══════╧════╧══════════════╝ + [✓] 规则存在且生效 [!] 规则在 db 但不在 iptables,需修复 + 按回车返回主菜单... +``` + +### 5.3 添加表单 + +```text +╔══════════════════════════════════════════════════════╗ +║ 添加新的端口转发规则 ║ +╚══════════════════════════════════════════════════════╝ + ▸ 协议类型 [1] TCP [2] UDP [3] 两者 (默认 1): + ▸ 本地监听端口 (1-65535): + ▸ 目标 IP 地址: + ▸ 目标端口 (1-65535): + ▸ IP 版本 [1] IPv4 [2] IPv6 [3] 两者 (默认 1): + ▸ 描述 (可选, 回车跳过): + + ─── 预览 ───────────────────────────────── + 协议: TCP + 监听端口: 8080 + 目标: 192.168.1.100:80 + IP 版本: IPv4 + 描述: web 服务 + + ▸ 确认添加? [y/N]: +``` + +### 5.4 删除确认 + +```text + ▸ 请选择要删除的规则编号 (0 返回): 2 + + ─── 即将删除 ──────────────────────────── + UDP 127.0.0.1:53535 → 8.8.8.8:53 [DNS 转发] + ▸ 确认删除? [y/N]: +``` + +--- + +## 6. 测试策略 + +### 6.1 单元测试 (Unit, 无需 root) + +`tests/test_common.sh`: +- `validate_ipv4` 通过: `0.0.0.0 / 192.168.1.1 / 255.255.255.255` +- `validate_ipv4` 拒绝: `256.1.1.1 / 1.2.3 / abc / 空` +- `validate_ipv6` 通过: `::1 / 2001:db8::1 / fe80::1%eth0` +- `validate_ipv6` 拒绝: `:::1 / 1.2.3.4 / 空` +- `validate_port` 边界: `1 / 65535` 通过; `0 / 65536 / -1 / abc` 拒绝 +- `validate_proto` / `validate_ipver` 枚举 + +`tests/test_storage.sh`: +- 初始化空 db +- 插入 3 条,list 返回 3 条 (顺序保留) +- `get` 已存在 uuid 返回正确字段 +- `get` 不存在 uuid 返回非零 +- `delete` 存在的 uuid,剩 2 条 +- `delete` 不存在的 uuid 返回 1,文件不变 +- 并发安全:tmpfile + mv 替换语义 + +`tests/test_rules_unit.sh`: +- 用 `iptables-mock.sh` 作为 `IPTABLES_BIN`,记录所有调用到 `/tmp/ipt_calls.log` +- `cmd_add` (tcp/ipv4) 后: + - mock 日志包含 3 条命令 (PREROUTING/POSTROUTING/FORWARD) 且参数正确 + - storage 中新增 1 条,UUID 与 mock 注释一致 +- `cmd_add` (both/both) 后:mock 日志包含 12 条命令 +- `cmd_delete` 后:mock 日志包含对应 `-D` 命令,storage 中记录被移除 +- `cmd_add` 中途失败 (mock 注入错误):确认回滚,storage 不写入,已执行的 iptables `-A` 对应 `-D` 被调用 + +`tests/test_env_check.sh`: +- 构造 PATH sandbox 让 `iptables` / `dpkg` 不可见 +- 断言 `env_check_all` 识别出正确的缺失项数量 +- 模拟 sysctl 为 0:断言写入 drop-in 并标记需要 `sysctl --system` +- 用户拒绝修复:断言退出码 3 + +### 6.2 集成测试 (Integration, 需要 root;WSL/容器/VM 均可) + +`tests/test_integration.sh`: +1. **准备**:确保 iptables/ip6tables/iptables-persistent 存在,备份当前规则 `iptables-save > /tmp/ipt.bak`。 +2. **端到端规则生命周期**: + - 以非交互模式 (传入 stdin 预置输入或新增 `--batch` 入口) 添加规则 `tcp 65432 → 127.0.0.1:22` + - 断言 `iptables -t nat -L PREROUTING -n` 含 `MGMT:` 注释与预期参数 + - 执行 `curl -sS --max-time 2 127.0.0.1:65432` 或 `nc -zv`,应命中 22 端口服务 + - 删除规则,断言 iptables 中对应条目消失 +3. **持久化**: + - 添加规则 → `netfilter-persistent save` → 检查 `/etc/iptables/rules.v4` 包含 `MGMT:` 注释 + - `netfilter-persistent flush && netfilter-persistent reload`,规则恢复 +4. **IPv6**:同上流程使用 `::1`/`2001:db8::1`。 +5. **仅管理自身范围**:手动添加一条无 `MGMT:` 注释的 iptables 规则 → 列表中**不出现**,删除所有管理规则后该条**仍存在**。 +6. **清理**:`iptables-restore < /tmp/ipt.bak`。 + +测试脚本必须**可重入**:中途失败也清理残留。 + +### 6.3 手工验收 (最小清单) + +- [ ] 干净 Debian 13 VM:`./iptables-forward.sh` 首次启动能识别缺失并一键修复 +- [ ] 在 VM A 上转发 `8080 → VM B:80` (HTTP 容器),从第三台主机访问 `A:8080` 能正常看到 B 的页面 +- [ ] 重启 VM A,规则依然有效 +- [ ] 删除规则后访问 `A:8080` 失败 +- [ ] 列表中的健康列能正确反映 iptables 与 db 的同步状态 +- [ ] 中文显示无乱码、盒线对齐 +- [ ] 非 root 启动得到明确提示 + +--- + +## 7. 阶段化实施计划 (按轮次排队) + +> 每阶段完成后应处于**可独立运行**的状态。阶段之间可以分轮次交给 CodexPotter / Claude Code 执行。 + +### 阶段 1 · 骨架 + 环境检查 + +**入口提示词**: +> 依据 `plan_iptables_forward.md` 第 3、4.1、4.2、4.7 节实现: +> - 目录结构与空文件骨架 +> - `lib/common.sh` 的颜色/盒线/prompt/validate_* 全部函数 (无外部依赖) +> - `lib/env_check.sh` 完整环境检测与自动安装 +> - `iptables-forward.sh` 的启动流程 (到 `env_check_all` 为止,菜单先打 TODO) +> +> 同步完成 `tests/lib/assert.sh`、`tests/test_common.sh`、`tests/test_env_check.sh`,并确保 `tests/run_all.sh --skip-integration` 全绿。 +> 参考规范:CLAUDE.md 第 3 节代码治理。 + +**退出条件**: +- `bash iptables-forward.sh` 在缺 iptables 的容器中能列出缺失并询问修复 +- 非 root 运行立即退出并提示 +- `tests/run_all.sh --skip-integration` 通过 + +### 阶段 2 · 存储层 + iptables 封装 + 基础 CRUD + +**入口提示词**: +> 依据 `plan_iptables_forward.md` 第 4.3、4.4、4.5 节实现 `storage.sh`、`iptables_ops.sh`、`rules_mgr.sh` 中的 `cmd_list / cmd_add / cmd_delete`。 +> 菜单接入真实实现,暂不接持久化 (`persist_save` 留空)。 +> 同步完成 `tests/mocks/iptables-mock.sh`、`tests/test_storage.sh`、`tests/test_rules_unit.sh`。 +> 校验:所有 unit 测试通过;手工在 VM 里加一条 tcp/ipv4 规则并验证 `iptables -t nat -L -n` 中出现对应条目。 + +**退出条件**: +- 菜单 1/2/3 可用 +- 单元测试覆盖正路径、错误路径、回滚路径 +- `iptables -t nat -L -n -v` 能看到带 `MGMT:` 注释的规则 + +### 阶段 3 · 持久化 + IPv6 + +**入口提示词**: +> 依据 `plan_iptables_forward.md` 第 4.6、2.1 节,完成 `persist.sh` 并接到 `cmd_add/cmd_delete` 末尾。 +> 将 `iptables_ops.sh` 对 IPv6 的分支完整打通 (`ip6tables` 二进制、`[..]` 目标写法)。 +> 补齐 `tests/test_integration.sh` 的步骤 3、4 (持久化与 IPv6)。 + +**退出条件**: +- `netfilter-persistent save` 被自动触发,`/etc/iptables/rules.v{4,6}` 含 `MGMT:` 注释 +- 重启后规则仍有效 (手工验证) +- 集成测试在 root 环境下全绿 + +### 阶段 4 · UI 美化 + 菜单打磨 + +**入口提示词**: +> 依据 `plan_iptables_forward.md` 第 5 节样例,把所有交互界面按样例输出: +> - 主菜单含状态行 (规则数 / 持久化状态 / 运行时错误指示) +> - 列表渲染成表格,中文对齐考虑宽字符 (汉字占 2 列) +> - 添加表单带「预览」与二次确认 +> - 错误提示红色,成功绿色,警告黄色 +> 不得改变功能行为,仅改 UI 层;测试仍然全绿。 + +**退出条件**: +- 终端宽度 < 60 时优雅降级 (不渲染盒线,改纯文本) +- 中文与 ASCII 混排对齐 (宽字符计算 via `awk` 或 locale `wc -L`) + +### 阶段 5 · 查漏补缺 + 文档 + +**入口提示词**: +> 审阅 `plan_iptables_forward.md` 与当前代码: +> 1. 对照第 1.1 节 Must 列表逐项核对实现与测试 +> 2. 列出测试空白并补齐 (尤其回滚、并发、非法输入) +> 3. 更新 `README.md`:安装、运行、故障排查、卸载 +> 4. 编写 `install.sh` (link 到 /usr/local/bin/iptables-forward, 不拷贝源码) +> 5. 所有脚本加 `shellcheck` 校验零警告 (对无法避免的使用 `# shellcheck disable=SCxxxx reason=...`) + +**退出条件**: +- `shellcheck lib/*.sh iptables-forward.sh install.sh` 零警告 +- `tests/run_all.sh` 全绿 +- README 可指导零知识用户完成安装与使用 + +### 阶段 6 · 精简 (simplify) + +**入口提示词**: +> 运行 `/simplify`。重点审查: +> - 冗余的 if 分支、重复的字符串格式化 +> - 不必要的 echo → printf 转换 +> - 局部工具函数抽取 (如重复的 confirm 弹窗) +> - 移除 over-engineering (回看 CLAUDE.md 3.3) + +**退出条件**: +- 代码体量明显下降但测试仍全绿 +- 保留的每个函数/文件都有明确职责 + +--- + +## 8. 风险与回滚 + +| 风险 | 影响 | 缓解 | +|------|------|------| +| `iptables-nft` vs `iptables-legacy` 并存 | `save/reload` 后端不一致 | 仅用发行版默认,不切换;在 README 声明 | +| `iptables-persistent` 安装时 debconf 交互 | 卡住脚本 | `debconf-set-selections` 预置答案 | +| `sysctl` 修改永久生效导致测试环境污染 | 误改宿主 | 写 drop-in 而非 `/etc/sysctl.conf`;集成测试 teardown 时清理 | +| `ufw`/`firewalld` 存在 | 规则被覆盖 | 启动时检测并打黄色警告,不阻断 | +| 删除时 iptables 与 db 不一致 | 残留/孤儿规则 | `cmd_list` 显示健康列;提供"扫描修复"隐藏命令 (Phase 5 可选) | +| 并发两个脚本实例 | rules.db 竞争 | `flock /var/lib/iptables-forward/.lock` 包裹所有写操作 | +| 用户输入 IPv6 但填入 IPv4 地址 | 规则创建失败 | 校验 IP 族与字符串一致性;不一致直接拒绝 | + +--- + +## 9. 验收标准 (Definition of Done) + +1. 清洁 Debian 13 系统上首次运行:检测→自动安装→进入菜单,**全程中文、美观** +2. 能添加 TCP/UDP/both × IPv4/IPv6/both 的任意组合,立即生效 +3. 重启系统后规则仍在,列表健康状态为 `[✓]` +4. 删除时只删本脚本加的规则,**绝不误伤外部规则** +5. `tests/run_all.sh` 全绿 (含集成层, root 下) +6. `shellcheck` 零警告 +7. README 能指导新手从零开始完成一条转发并验证生效 +8. 源码总行数 (不含测试/文档) 控制在 **≤ 1500 行**;超过需说明 + +--- + +## 10. 附录 + +### 10.1 最终生成的 iptables 规则示例 (参考) + +对 `uuid=a1b2c3d4, proto=tcp, lport=8080, tip=192.168.1.100, tport=80, ipver=4`: + +```bash +iptables -t nat -A PREROUTING -p tcp --dport 8080 \ + -j DNAT --to-destination 192.168.1.100:80 \ + -m comment --comment "MGMT:a1b2c3d4" + +iptables -t nat -A POSTROUTING -p tcp -d 192.168.1.100 --dport 80 \ + -j MASQUERADE \ + -m comment --comment "MGMT:a1b2c3d4" + +iptables -A FORWARD -p tcp -d 192.168.1.100 --dport 80 \ + -m conntrack --ctstate NEW,ESTABLISHED,RELATED \ + -j ACCEPT \ + -m comment --comment "MGMT:a1b2c3d4" +``` + +### 10.2 卸载指引 + +```bash +# 删除所有本脚本规则 +sudo iptables-save | grep -v 'MGMT:' | sudo iptables-restore +sudo ip6tables-save | grep -v 'MGMT:' | sudo ip6tables-restore +sudo netfilter-persistent save + +# 清理状态 +sudo rm -rf /var/lib/iptables-forward +sudo rm -f /etc/sysctl.d/99-iptables-forward.conf +sudo rm -f /usr/local/bin/iptables-forward + +# 可选:卸载包 +sudo apt-get remove --purge iptables-persistent +``` + +### 10.3 参考资料 + +- Debian iptables-persistent 1.0.23 源 [apt-cache show, 2026-04-17] +- Linux netfilter DNAT/MASQUERADE 最佳实践 [external search, 2026-04-17] +- CLAUDE.md 全局协议 (本项目 3.1/3.3/3.6 约束) diff --git a/tests/mocks/persist-mock.sh b/tests/mocks/persist-mock.sh index f8b792b..13a5050 100644 --- a/tests/mocks/persist-mock.sh +++ b/tests/mocks/persist-mock.sh @@ -3,9 +3,31 @@ set -euo pipefail : "${PERSIST_MOCK_LOG:=/tmp/persist-mock.log}" : "${PERSIST_MOCK_FAIL:=0}" +: "${PERSIST_MOCK_DELAY_SECS:=0}" +: "${PERSIST_MOCK_ACTIVE_DIR:=}" +: "${PERSIST_MOCK_FAIL_ON_CONCURRENT:=0}" printf '%s %s\n' "$(basename -- "$0")" "$*" >>"${PERSIST_MOCK_LOG}" +cleanup() { + [[ -n ${PERSIST_MOCK_ACTIVE_DIR} ]] || return 0 + rmdir "${PERSIST_MOCK_ACTIVE_DIR}" 2>/dev/null || true +} + +if [[ -n ${PERSIST_MOCK_ACTIVE_DIR} ]]; then + if ! mkdir "${PERSIST_MOCK_ACTIVE_DIR}" 2>/dev/null; then + if [[ ${PERSIST_MOCK_FAIL_ON_CONCURRENT} == 1 ]]; then + exit 1 + fi + else + trap cleanup EXIT + fi +fi + +if [[ ${PERSIST_MOCK_DELAY_SECS} != 0 ]]; then + sleep "${PERSIST_MOCK_DELAY_SECS}" +fi + if [[ ${PERSIST_MOCK_FAIL} == 1 ]]; then exit 1 fi diff --git a/tests/test_rules_unit.sh b/tests/test_rules_unit.sh index 4872fef..e3755ef 100644 --- a/tests/test_rules_unit.sh +++ b/tests/test_rules_unit.sh @@ -13,6 +13,14 @@ status_of() { printf '%s\n' "${rc}" } +wait_status() { + local pid=$1 + set +e + wait "${pid}" + WAIT_STATUS_RESULT=$? + set -e +} + TMP_DIR=$(mktemp -d) trap 'rm -rf "${TMP_DIR}"' EXIT @@ -46,6 +54,7 @@ reset_mock_state() { : >"${PERSIST_MOCK_LOG}" unset IPTABLES_MOCK_FAIL_ON_N || true unset PERSIST_MOCK_FAIL || true + unset PERSIST_MOCK_DELAY_SECS PERSIST_MOCK_ACTIVE_DIR PERSIST_MOCK_FAIL_ON_CONCURRENT || true storage_init } @@ -91,4 +100,33 @@ assert_contains "$(storage_get "${uuid_delete_rollback}")" "uuid=${uuid_delete_r assert_contains "$(ipt_find_by_uuid "${uuid_delete_rollback}")" "MGMT:${uuid_delete_rollback}" 'persist_save failure on delete should restore runtime rules' assert_eq '2' "$(grep -Ec 'persist-mock\.sh save' "${PERSIST_MOCK_LOG}")" 'persist_save failure on delete should include add and delete save attempts' +reset_mock_state +uuid_storage_delete_rollback=$(cmd_add_batch tcp 9200 127.0.0.1 93 4 'storage rollback delete') +storage_delete_impl=$(declare -f storage_delete_unlocked) +storage_delete_unlocked() { + return 1 +} +assert_eq '1' "$(status_of cmd_delete_uuid "${uuid_storage_delete_rollback}")" 'cmd_delete_uuid should fail when storage delete fails' +assert_eq '1' "$(storage_count)" 'storage delete failure should keep storage record' +assert_contains "$(storage_get "${uuid_storage_delete_rollback}")" "uuid=${uuid_storage_delete_rollback}" 'storage delete failure should keep original storage line' +assert_contains "$(ipt_find_by_uuid "${uuid_storage_delete_rollback}")" "MGMT:${uuid_storage_delete_rollback}" 'storage delete failure should restore runtime rules' +eval "${storage_delete_impl}" + +reset_mock_state +export PERSIST_MOCK_DELAY_SECS=0.2 +export PERSIST_MOCK_ACTIVE_DIR="${TMP_DIR}/persist-active" +export PERSIST_MOCK_FAIL_ON_CONCURRENT=1 +cmd_add_batch tcp 9300 127.0.0.1 94 4 'parallel one' >"${TMP_DIR}/parallel-1.out" 2>"${TMP_DIR}/parallel-1.err" & +pid1=$! +sleep 0.05 +cmd_add_batch tcp 9301 127.0.0.1 95 4 'parallel two' >"${TMP_DIR}/parallel-2.out" 2>"${TMP_DIR}/parallel-2.err" & +pid2=$! +wait_status "${pid1}" +assert_eq '0' "${WAIT_STATUS_RESULT}" 'first concurrent add should succeed' +wait_status "${pid2}" +assert_eq '0' "${WAIT_STATUS_RESULT}" 'second concurrent add should wait and succeed' +assert_eq '2' "$(storage_count)" 'concurrent adds should persist both rules' +assert_eq '2' "$(grep -Ec 'persist-mock\.sh save' "${PERSIST_MOCK_LOG}")" 'concurrent adds should serialize persist_save calls' +unset PERSIST_MOCK_DELAY_SECS PERSIST_MOCK_ACTIVE_DIR PERSIST_MOCK_FAIL_ON_CONCURRENT + pass 'test_rules_unit.sh'