Serialize rule writes and add tests
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,3 +13,6 @@
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
# AI Agent
|
||||
.claude
|
||||
.codexpotter
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- 仅管理本工具创建的 `MGMT:<UUID>` 规则。
|
||||
- 支持 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` 的执行策略:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
560
plan_iptables_forward.md
Normal file
560
plan_iptables_forward.md
Normal file
@@ -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:<UUID>"` 标识 |
|
||||
| 存储 | `/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:<UUID>"` + 精确 `-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 <P> --dport <LPORT> \
|
||||
-j DNAT --to-destination <TIP>:<TPORT> \
|
||||
-m comment --comment "MGMT:<UUID>"
|
||||
|
||||
# 2) POSTROUTING MASQUERADE — 返程包伪装源地址
|
||||
$CMD -t nat -A POSTROUTING -p <P> -d <TIP> --dport <TPORT> \
|
||||
-j MASQUERADE \
|
||||
-m comment --comment "MGMT:<UUID>"
|
||||
|
||||
# 3) FORWARD ACCEPT — 绕过 FORWARD DROP 策略
|
||||
$CMD -A FORWARD -p <P> -d <TIP> --dport <TPORT> \
|
||||
-m conntrack --ctstate NEW,ESTABLISHED,RELATED \
|
||||
-j ACCEPT \
|
||||
-m comment --comment "MGMT:<UUID>"
|
||||
```
|
||||
|
||||
其中 `$CMD` 为 `iptables`(v4) 或 `ip6tables`(v6)。`<TIP>` 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 <str>` → 0/1
|
||||
- `validate_ipv6 <str>` → 0/1
|
||||
- `validate_port <str>` → 0/1 (1–65535)
|
||||
- `validate_proto <str>` → 0/1 (tcp/udp/both)
|
||||
- `validate_ipver <str>` → 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 "<k=v>|<k=v>|…"` — 追加一行
|
||||
- `storage_list` — 输出全部行
|
||||
- `storage_get <uuid>` — 输出指定行,未找到返回 1
|
||||
- `storage_delete <uuid>` — 原子替换 (写入临时文件后 `mv`)
|
||||
- `storage_parse <line> <key>` — 从行中抽取字段值
|
||||
|
||||
### 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 约束)
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user