Files
autossh-mgr/docs/check-l1-l2-implementation.md
tech 08ef0c9253 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.
2026-08-05 15:46:47 +08:00

10 KiB

check 命令 L1+L2 端到端预检实施记录

日期: 2026-08-05 Commit: 6124abb 改动文件: src/autossh_mgr/check.pytests/unit/test_check.pytests/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.pystart_tunnel 里,但逻辑错误已被删除:

# ❌ 已删除的错误检查 (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 <name>
  │
  ├─ Layer 1: 本地后端端口探测
  │   TCP connect 到 (local_host, local_port),超时 2 秒
  │   失败 → 返回 FAIL "local backend <host>:<port> not listening"
  │   成功 ↓
  │
  └─ Layer 2: SSH 认证 (保留原有逻辑)
      ssh -o BatchMode=yes -o ConnectTimeout=5 ... true
      失败 → 返回 FAIL "SSH auth failed: <stderr>"
      成功 → 返回 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)

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 短路)

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.pycheck_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 <name> 用法不变,退出码语义不变(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 <url> 或改用 --editable

本机开发建议用 editable 安装,改完立即生效:

cd ~/workspace/autossh-mgr
uv tool install --force --editable .

8. 回退方案

任何时候可回退到改动前(commit 4c9cd00):

# 源码回退
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 回退:

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 已基本够用