NAT 后的机器怎么统一纳管:受限密钥 + 正反向 SSH 隧道的舰队接入实践
手里有多台分布在各地的云主机时,最容易被忽视的不是"能不能连上",而是连上之后你到底把多大的权限交了出去。一台管理中枢要采集所有节点的状态、偶尔执行受控动作,常见做法是在每台机器上放同一把私钥——这把钥匙一旦泄露,等于把整个舰队一次性交出去。
另一种更常见的问题是拓扑:并非每台机器都有公网 SSH。位于家庭宽带、企业内网或 NAT 网关后的节点,中枢根本连不进去。
本文给一套落地方案:任何一把密钥只做一件事,有公网的机器走正向隧道,NAT 后的机器走反向隧道,配合一条 iptables 链统一收口监控端口。文中的端口均为示例规划,可整体平移。
一、总体拓扑:两类通道
┌──────────── 管理中枢(hub 所在) ────────────┐
公网节点 A ──正向隧道─▶│ -L 本地端口转发 ──▶ hub 容器 │
公网节点 B ──正向隧道─▶│ │
公网节点 C ──正向隧道─▶│ socat 桥接(docker 网关侧)◀── 反向隧道 ◀── NAT 节点 1 │
│ │
│ fleet worker ──▶ 按机钉钥 SSH ─▶ 各机 agent │
└──────────────────────────────────────────┘
差异只有一句话:谁主动连谁。
| 机器类型 | 通道方向 | 谁主动 | 中枢侧暴露面 |
|---|---|---|---|
| 有公网 SSH 的节点 | 正向隧道 | 中枢 → 节点 | -L 本地转发,绑定 docker 网关地址 |
| NAT 后的节点 | 反向隧道 | 节点 → 中枢 | authorized_keys 里 permitlisten 限定端口 + socat 桥接 |
二、端口规划:回环端口和网关端口是两套编号
这是最容易埋雷的一节。规划时把下面两件事分开记:
- 回环端口=这台机器真实的 sshd / agent 监听口,由
RemoteForward在中枢本机回环上开出来; - 网关端口=hub 容器实际能访问到的映射口,由 socat 或
-L转发提供。
二者编号独立,只有桥接那一刻才对上。示例规划如下(端口可整体替换):
| 用途 | 中枢侧地址 | 说明 |
|---|---|---|
| hub 自身 agent | 网关:19001 | hub 容器经 docker 网关访问宿主机 agent |
| 公网节点 A/B/C 探针 | 网关:19002–19004 | ssh -L 本地转发 |
| NAT 节点 1 探针 / SSH | 127.0.0.1:19005 / 19006 | 反向隧道 RemoteForward 回环监听 |
| NAT 节点 1 探针桥接 | 网关:19007 | socat → 127.0.0.1:19005 |
三、受限密钥:一把钥匙只做一件事
每机一对密钥,只存放在中枢 /etc/fleet/tunnels/<node>_ed25519,并配套 <node>_known_hosts 钉死主机指纹。节点侧的 authorized_keys 是整套方案的权限边界:
from="127.0.0.1",restrict,command="sudo -n /usr/bin/python3 /usr/local/libexec/fleet-agent.py" ssh-ed25519 AAAA... fleet-<node>
四个限制各有分工:
from="127.0.0.1":只允许经隧道进来的连接。正向隧道的落点是节点本机回环,反向隧道同理,因此公网扫描根本碰不到这个入口。restrict:一次性禁掉 pty、端口转发、X11、agent 转发。command=:无论客户端传什么命令,服务端只执行那一个 agent 脚本。这是"给密钥不等于给 shell"的关键。- sudoers 只放行
NOPASSWD执行这一个脚本路径,并用env_keep透传SSH_ORIGINAL_COMMAND。
上层协议:白名单分发
-agent 解出 SSH_ORIGINAL_COMMAND 后按动作白名单分发,例如 status / logs / restart / network_probe / speedtest。三个必做的自检点:
- 动作白名单在 agent 内二次校验,不是靠调用方自觉;
- 参数再校验,比如测速的
mb上限锁在 50,超过即拒; - 所有输出过脱敏函数(密钥、token、邮箱等模式擦除)并限长——否则一次
logs就可能把凭据打进你的日志系统。
此外,agent 每次调用自记账:追加一行 JSON(时间/agent/动作/结果/摘要)到本机日志,控制台可回看。"任何一次远程执行都可追溯"这句话,只有在设计时就有日志才算数。
四、反向隧道:让 NAT 后的机器自己走出来
NAT 节点上运行一个常驻服务,开机自连中枢:
ExecStart=/usr/bin/ssh -N -T -F reverse-ssh.conf hub-entry
# reverse-ssh.conf 关键行
RemoteForward 127.0.0.1:19005 127.0.0.1:19001 # 中枢 → NAT 节点探针
RemoteForward 127.0.0.1:19006 127.0.0.1:22 # 中枢 → NAT 节点 sshd
ExitOnForwardFailure yes
ServerAliveInterval 20
中枢侧对这把"反向密钥"的授权行,要用 permitlisten 把转发端口钉死:
restrict,port-forwarding,permitopen="127.0.0.1:1",
permitlisten="127.0.0.1:19005",permitlisten="127.0.0.1:19006",
command="/usr/bin/false" ssh-ed25519 AAAA... reverse-<node>
效果很直接:这把密钥除了开这两个转发端口,什么都做不了,连 shell 都没有。 即使这把密钥被人拿到,攻击者也只能"替你转发流量",拿不到中枢权限。
端口撞号:一个真实踩过的坑
给第二台 NAT 节点规划回环端口时,如果复用了某个编号,而中枢上另一台公网节点的正向转发已经占用了网关侧的同号端口——注意,回环地址和网关地址不同,两边互不冲突,但桥接层会撞。
结论有三条:
- hub 经 docker 网关访问 NAT 节点探针时,桥接端口必须用网关侧独立编号,用 socat 转发到回环真端口;
- 改了 hub 数据库里的 agent 端口后,必须重启 hub 进程才会重新解析,改配置不重启=白改;
- 排障顺序固定:
ss -tlnp看监听归属 → 回环监听(sshd RemoteForward)→ 网关桥接(socat /-L)→ hub 数据库的端口字段。
五、防火墙:一条链管住所有监控端口
不要在 INPUT 上堆零散规则,用一条自定义链 + 端口区间覆盖:
iptables -N FLEET_AGENTS
iptables -A FLEET_AGENTS -s 127.0.0.0/8 -j ACCEPT # 本机
iptables -A FLEET_AGENTS -s <docker 网段> -j ACCEPT # hub 容器网段
iptables -A FLEET_AGENTS -j REJECT # 其余全拒
iptables -I INPUT 1 -p tcp --dport 19001:19010 -j FLEET_AGENTS
三个实践要点:
- 端口用区间规则一条覆盖,扩容时改范围而不是加规则;
- 用 oneshot systemd 单元在每次开机重放(规则不持久化的发行版全靠它自愈);
- 该单元若手工重启要留意依赖顺序——带
Requires=依赖它的探针单元会被连带停掉。做法是在单元里加ExecStartPost=systemd-run --on-active=3 systemctl try-restart <agent>,延迟触发避免死锁。
六、新机纳管:三段式 enroll
重复劳动一定要脚本化,纳管一台新机固定三步:
- 引导:用服务商网关或带外通道(一次性)把 agent 脚本、隧道配置、密钥对、
authorized_keys、sudoers 装到机器上,全过程先备份再原子替换; - 中枢授权:追加节点密钥授权行(先按注释标签去重旧行)、写
known_hosts、重建网关桥接、刷新防火墙端口范围; - 起服务:节点侧 enable + start 探针与反向隧道,中枢侧验证监听与 agent 应答。
参数化之后(基准端口、机器名),同一套脚本可以反复用。
七、故障速查
| 症状 | 先查 | 常见根因 |
|---|---|---|
| 派发返回 Connection refused | 中枢 ss -tlnp 回环端口 |
反向隧道没连上(节点服务停了 / 重启后没起来) |
| 派发返回 publickey 拒绝 | 节点 authorized_keys 标签行 |
中枢侧更新时旧行被顶掉,或重复行残留 |
| hub 显示节点 down | 桥接端口监听 + hub 库里的端口字段 | 撞号 / 改端口未重启 hub / 防火墙范围没覆盖 |
| 隧道频繁重连 | 节点 journal | ExitOnForwardFailure 撞上端口占用,或心跳参数过激 |
| agent 返回统一错误 JSON | SSH_ORIGINAL_COMMAND 编码 |
协议前缀 / base64 不符——通道是通的,是协议层被拒 |
最后一条最有诊断价值:能返回统一的错误 JSON,说明网络、密钥、隧道全都通了,问题一定在应用层协议,不用再去翻防火墙。
小结
整套方案的核心不是"用 SSH 打通网络",而是三个约束:密钥按机隔离且 command= 钉死、反向密钥 permitlisten 限定、监听端口只对本机与容器网段开放。做到这三点,即使某一台机器的凭据泄露,爆炸半径也止于那台机器的那一个动作。
下一篇讲同一个控制面的前端工程:没有构建框架的情况下,怎么做单文件 bundle、版本缓存纪律和多皮肤移动端适配。
- 点赞
- 收藏
- 关注作者
评论(0)