From 08ef0c9253c343c7da4be3bfed1426d5d28fdf24 Mon Sep 17 00:00:00 2001 From: tech Date: Wed, 5 Aug 2026 15:46:47 +0800 Subject: [PATCH] docs: add L1+L2 check implementation record Document the two-layer short-circuit probe added in 6124abb: design rationale, the historical lesson from the removed _is_port_in_use bind check, test coverage, real-world verification results on both local machine and NAS, compatibility, deployment, and rollback procedure. --- docs/check-l1-l2-implementation.md | 284 +++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 docs/check-l1-l2-implementation.md diff --git a/docs/check-l1-l2-implementation.md b/docs/check-l1-l2-implementation.md new file mode 100644 index 0000000..a8e8269 --- /dev/null +++ b/docs/check-l1-l2-implementation.md @@ -0,0 +1,284 @@ +# check 命令 L1+L2 端到端预检实施记录 + +**日期:** 2026-08-05 +**Commit:** `6124abb` +**改动文件:** `src/autossh_mgr/check.py`、`tests/unit/test_check.py`、`tests/unit/test_cli_lifecycle.py` + +--- + +## 1. 背景与问题 + +### 1.1 旧版 `check` 的假阳性 + +旧版 `check_connectivity` 只做一件事:发起一次轻量 SSH 登录(`ssh ... true`), +验证"密钥能不能登 + 网络可达 + sshd 在"。它**完全不碰真实隧道的数据通路**。 + +由此产生**假阳性**(check 报 OK 但隧道实际不通): + +| 场景 | 旧 check | 真实状态 | +|------|---------|---------| +| 本地后端服务没起(如 ftdl 的 Next.js 停了) | ✅ OK | ❌ 公网访问连不上 | +| 远端端口被占用 | ✅ OK | ❌ `-R` 绑不上 | +| sshd `GatewayPorts no` | ✅ OK | ❌ 只绑 127.0.0.1,外网进不来 | +| `AllowTcpForwarding no` | ✅ OK | ❌ 转发被禁 | + +**本机实测复现:** `ftdl` 隧道的后端 Next.js 未运行,但旧版 `check ftdl` 仍返回 OK。 + +### 1.2 历史教训:被删除的 `_is_port_in_use` (commit `4c9cd00`) + +历史上曾有过一个端口检查,位于 `process.py` 的 `start_tunnel` 里,但逻辑错误已被删除: + +```python +# ❌ 已删除的错误检查 (commit 4c9cd00): +def _is_port_in_use(port): + with socket.socket(...) as s: + try: + s.bind(("127.0.0.1", port)) # 尝试占用端口 + return False # 占用成功→端口空闲 + except OSError: + return True # 占用失败→端口被占 +# start_tunnel 里: if _is_port_in_use(local_port): 拒绝启动 +``` + +**为什么错:** 对 `-R` 反向隧道,`local_port` 是**转发目标**(如 Docker 容器、Gitea web), +它**本该在监听**,不该是空闲的。旧检查把"目标服务在跑"误判成"端口冲突",语义反了。 + +### 1.3 本次方案的区别 + +本次新增的 Layer 1 用 `connect()` 而非 `bind()`,语义完全不同,不会重蹈覆辙: + +| | 被删的旧检查 | 本次 Layer 1 | +|---|---|---| +| **方法** | `bind()` 占用端口 | `connect()` 探测目标 | +| **位置** | `start_tunnel` (启动门禁) | `check` (诊断命令) | +| **语义** | "端口被占=冲突" ❌ 反了 | "目标响应=健康" ✅ 对 | +| **目的** | 阻止启动 | 报告隧道是否端到端可用 | + +--- + +## 2. 设计:L1+L2 精简版(三层中的前两层) + +### 2.1 范围 + +原始设计(见备份文档 `check-port-validation.md`)提出三层检查 L1/L2/L3。 +本次**只实施 L1+L2**,跳过 L3 探针转发,理由见 §2.4。 + +**不做的事:** +- 不做 L3 探针转发(带 `-R` + `ExitOnForwardFailure` 的真实转发探测) +- 不改 autossh 进程的启动参数(`ExitOnForwardFailure` 留给方案 D) +- 不改 `start`/`status`/`stop` 等其它命令的行为 +- 不改配置文件格式 `tunnels.yaml` + +### 2.2 检查流程(两层,顺序短路) + +``` +check + │ + ├─ Layer 1: 本地后端端口探测 + │ TCP connect 到 (local_host, local_port),超时 2 秒 + │ 失败 → 返回 FAIL "local backend : not listening" + │ 成功 ↓ + │ + └─ Layer 2: SSH 认证 (保留原有逻辑) + ssh -o BatchMode=yes -o ConnectTimeout=5 ... true + 失败 → 返回 FAIL "SSH auth failed: " + 成功 → 返回 OK "reachable + backend up" +``` + +**顺序的理由:** Layer 1 最便宜(纯本地 TCP,毫秒级),Layer 2 较贵(一次 SSH 握手)。 +便宜的前置过滤掉明显错误(后端没起),避免无谓的网络往返。 + +### 2.3 两层覆盖的失败场景 + +| 失败场景 | Layer 1 (本地端口) | Layer 2 (SSH 认证) | +|---------|:--:|:--:| +| 后端服务没起(最常见) | ✅ | — | +| 本地端口绑错地址(127.x vs 0.0.0.0) | ✅ | — | +| 网络不通/防火墙 | — | ✅ | +| 密钥被拒/被 fail2ban | — | ✅ | +| 远端端口被占 | ❌ 不覆盖 | — | +| `GatewayPorts no` | ❌ 不覆盖 | — | +| `AllowTcpForwarding no` | ❌ 不覆盖 | — | + +L1+L2 覆盖了最常见的"后端没起"假阳性。端口绑定类问题留给未来的 L3 或方案 D。 + +### 2.4 为什么跳过 L3 + +| 考量 | 说明 | +|------|------| +| **副作用** | L3 探针会在云端短暂占用 `remote_port` 约 1 秒 | +| **性能** | 成功路径增加约 1.5 秒(L1 毫秒级 + L3 的 sleep 1);失败路径因短路反而更快 | +| **误报风险** | 某些 sshd 配置下可能因端口已被 autossh 占用而误报失败 | +| **性价比** | L3 主要堵"端口绑定类"问题,这些场景相对少见,真遇到用 `status` + 手动 ssh 也能发现 | + +L1+L2 覆盖 80% 的常见假阳性,改动最小、零副作用。L3 可后续按需再加。 + +--- + +## 3. 实现 + +仅改 1 个源码文件:`src/autossh_mgr/check.py` + +### 3.1 新增函数 `check_local_backend` (Layer 1) + +```python +import socket + +def check_local_backend(tunnel: TunnelConfig) -> tuple[bool, str]: + """Layer 1: probe whether the local backend port has a listener.""" + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(2) + try: + s.connect((tunnel.local_host, tunnel.local_port)) + except OSError as e: + return False, f"local backend {tunnel.local_host}:{tunnel.local_port} not listening ({e})" + finally: + s.close() + return True, "" +``` + +**关键点:** +- 用 `connect()` 而非 `bind()`,语义正确(探测目标是否响应,而非端口是否空闲) +- `finally: s.close()` 保证任何情况下都释放 socket +- 超时 2 秒,避免后端慢响应时卡住 + +### 3.2 改写 `check_connectivity` (L1→L2 短路) + +```python +def check_connectivity(tunnel: TunnelConfig) -> tuple[bool, str]: + # Layer 1: 本地后端端口 + ok, msg = check_local_backend(tunnel) + if not ok: + return False, msg + + # Layer 2: SSH 认证 (原有逻辑) + result = subprocess.run( + build_ssh_check_cmd(tunnel), + capture_output=True, + text=True, + ) + if result.returncode != 0: + return False, f"SSH auth failed: {result.stderr.strip()}" + return True, "reachable + backend up" +``` + +**接口契约不变:** 签名仍是 `(bool, str)`,`cli.py` 的 `check_cmd` 无需改动。 +成功时 `cli.py:245` 显示固定的 `OK: ...`,忽略 msg;失败时 msg 进入 `Connection failed: {msg}`。 + +### 3.3 `build_ssh_check_cmd` 未改动 + +Layer 2 复用原有命令构建函数,保持 SSH 认证探测行为不变。 + +--- + +## 4. 测试 + +### 4.1 单元测试 (`tests/unit/test_check.py`) + +新增/改写 6 个测试: + +| 测试 | 覆盖点 | +|------|--------| +| `test_build_ssh_check_cmd` | 命令构建(原有,保留) | +| `test_check_local_backend_success` | L1 成功路径,mock socket,断言 connect/settimeout/close 调用 | +| `test_check_local_backend_failure` | L1 失败路径,connect 抛 `ConnectionRefusedError`,断言错误消息含端口 | +| `test_check_connectivity_layer1_short_circuits` | **L1 短路关键测试**:L1 失败时 `subprocess.run` 必须不被调用 | +| `test_check_connectivity_success` | L1+L2 全成功,断言返回 "reachable + backend up" | +| `test_check_connectivity_ssh_failure` | L1 成功但 L2 失败,断言返回 "SSH auth failed" | + +### 4.2 CLI 集成测试修复 (`tests/unit/test_cli_lifecycle.py`) + +`test_check_success` / `test_check_failure` 原本只 mock `subprocess.run`, +新增 L1 后会真去 connect 端口导致测试失败。修复:同时 mock `autossh_mgr.check.socket.socket`。 + +### 4.3 全量测试结果 + +``` +72 passed in 52.84s +``` + +含 integration 7 + unit 65,两次运行结果一致,稳定通过。 + +--- + +## 5. 实测验证 + +### 5.1 本机实测 + +本机 `tunnels.yaml` 配置两条隧道,editable 安装新版源码后实测: + +| 隧道 | 后端端口 | L1 探测 | 结果 | 旧行为对比 | +|------|---------|---------|------|-----------| +| `hub-ftdev-android` | :8003 ✅ 在监听 | connect 成功 → L2 → SSH 通 | ✅ OK | 旧版也 OK(巧合正确) | +| `ftdl` | :3000 ❌ Next.js 没起 | connect 拒绝 → **L1 短路失败** | ❌ `local backend 127.0.0.1:3000 not listening` | ❗ 旧版会误报 OK(假阳性) | + +**验证了方案C的核心价值:** ftdl 后端没起,旧版 `check` 报 OK(隧道"通"但实际访问拿不到东西), +新版 L1 立刻拦住并明确告知"本地后端没监听",且根本没发起 SSH(短路)。 + +### 5.2 NAS 实测 + +NAS 上 `tunnels.yaml` 配置两条隧道(后端都是 NAS 本机服务),实测: + +| 隧道 | 后端端口 | L1 | L2 | 结果 | +|------|---------|:--:|:--:|------| +| `gitea` | :80 Gitea web | ✅ | ✅ | ✅ OK | +| `gitea-ssh` | :2222 Gitea ssh | ✅ | ✅ | ✅ OK | + +两条隧道全绿,零假阳性、零误报。 + +--- + +## 6. 兼容性 + +- **配置文件:** 无变更,`tunnels.yaml` 格式不变 +- **命令接口:** `autossh-mgr check ` 用法不变,退出码语义不变(0=成功,1=失败) +- **输出格式:** 成功仍是 `OK: ...`,失败仍是 `Error: Connection failed: ...`, + 只是失败原因更具体(指明是 L1 还是 L2 挂的) +- **性能:** 成功路径增加约几十毫秒(L1 TCP connect);失败路径因短路反而更快 +- **无新依赖:** `socket` 是 Python 标准库 + +--- + +## 7. 部署方式 + +| 位置 | 安装方式 | 更新方式 | +|------|---------|---------| +| **NAS** | editable (`uv tool install --editable .`) | 改 `src/` 即生效,无需重装 | +| **本机** | 非 editable (从 git URL 装) | `uv tool install --force --refresh ` 或改用 `--editable` | + +本机开发建议用 editable 安装,改完立即生效: +```bash +cd ~/workspace/autossh-mgr +uv tool install --force --editable . +``` + +--- + +## 8. 回退方案 + +任何时候可回退到改动前(commit `4c9cd00`): + +```bash +# 源码回退 +cd ~/workspace/autossh-mgr +git reset --hard 4c9cd00 # 或用 tag: git reset --hard pre-plan-c + +# 若本机是 editable 安装,源码回退后立即生效 +# 若本机是非 editable 安装,需重装旧版: +uv tool install --force --refresh ssh://git@git.zz.com:2222/Developer/autossh-mgr.git +``` + +NAS 回退: +```bash +ssh mnas 'cd ~/workspace/autossh-mgr && git checkout -- src/autossh_mgr/check.py' +``` + +--- + +## 9. 未来工作 + +| 项 | 说明 | 优先级 | +|----|------|--------| +| **Layer 3 探针转发** | 带 `-R` + `ExitOnForwardFailure` 的真实转发探测,覆盖端口绑定类假阳性 | 按需,当前 L1+L2 已覆盖 80% 场景 | +| **方案 D: autossh 启动参数** | 给 `build_autossh_cmd` 加 `-o ExitOnForwardFailure=yes`,让运行中的 autossh 在端口绑不上时自愈重生 | 中,根治运行态假活 | +| **verify 命令** | 独立的端到端探测命令,通过真实隧道发 HTTP 请求验证后端响应 | 低,`check` + `status` 已基本够用 |