一个 SSH 网关,用 Rust 和 russh 写的。
它做的事:你在外面用 SSH 连机器时,先连到网关,网关验证你的身份、决定你"应该"连到哪台后端机器,然后帮你把连接转发过去。客户端这边的用法不变——ssh 用户名@网关地址,差别只在"用户名"在这里是路由信息:网关拿这个用户名去问控制面(或按 OpenStack 解析),查出你实际要登录的后端地址和账号,再替你拨过去。
ssh alex_host1@gw.example.com
│
▼
网关(终结客户端 SSH,认证 + 授权,双会话中继)
│
▼
后端 sshd(目标机器)
数据是纯转发的,网关默认不落盘(会话录制由控制面按 grant 开启)。认证、审计、访问控制都发生在网关这一侧;后端机器唯一的配置是把终端用户的公钥放进 authorized_keys——网关不持有任何后端私钥,它是靠转发用户的 ssh-agent 去拨号的。
- 用户名即路由(控制面 / OpenStack / 简单映射 三选一)
- 控制面:
POST /api/v1/resolve按用户名返回目标后端{host, port, user}和策略;拒绝 403,未知 404。 - OpenStack(默认关,见下):规范形式的实例 UUID(或实例名)由 Nova 解析成目标地址和登录用户。
- 简单映射(默认关):本地
mappings.toml表把登录用户名直接映射到目标后端{host, port, user},无需控制面或 OpenStack。
- 控制面:
- OpenStack 路由(实验室模式):开启
[openstack]后(当前唯一可启用组合是routing="openstack_only"+authorization="openstack_only"+allow_any_authenticated_key=true)——route_by = ["uuid"](默认)按实例 UUID 解析;加"name"可再按实例名(小写)解析,重名实例拒绝不猜;scope = "single_project"(默认)只解析本项目的实例;"cross_project"用all_tenants=1解析任意项目的实例(不指定 Project,凭条件见部署);address_kind = "any"(默认)| "floating" | "fixed":"floating"只取浮动 IP(公网可达前提),无浮动地址即拒绝、绝不回退 fixed;- 后端登录用户按 实例 metadata →
[[openstack.backend_user_map]]规则 →default_backend_user解析。
- 简单映射模式(实验室模式,默认关):开启
[mapping]后——- 路由唯一依据是本地
mappings.toml:[[entries]]每行把一个username映射到host/port(默认 22)/user(默认同username);表内用户名命中即用该行,表外一律拒绝; - 与
[control_plane]、[openstack]互斥(三者只可同时启用其一),无控制面、无 OpenStack 回退; - 授权仍是
allow_any_authenticated_key=true:任何通过前腿验签的 key 都可到达其 principal 映射的后端,仅用于实验室/内网; - 表在启动时一次性加载并常驻内存,改文件需重启才生效;
recording默认 false(映射会话默认不录,需显式recording = true)。
- 路由唯一依据是本地
- 双会话中继:终结客户端 SSH(server 腿)+ 拨号后端(client 腿),双向转发;上行有窗口熔断(
outbound_guard)防止缓冲无限增长。 - 认证:客户端 → 网关支持公钥 / CA 签发的用户证书;网关 → 后端只有一种方式——转发客户端的 ssh-agent(
ssh -A语义)。签名算法对齐当前 SSH 生态:后端 RSA 拨号走rsa-sha2-512(默认,可配[backend] rsa_signature_hash="sha256"),ed25519 / ecdsa 原生支持;前腿与后端 host-key 协商都不再包含ssh-rsa(SHA-1)。 - 防爆破 / 防时序:每次认证拒绝有恒定时间填充(
auth_rejection_pad),尝试次数上限(max_auth_attempts),每 IP 并发上限。 - 会话语义完整:exec / shell / scp / sftp / stderr(extended data)/ exit status / rekey / 信号全部按 SSH 协议原样透传;不伪造成功、不注入协议行为。
- 审计与录制:字节计数与操作事件写审计;按通道录制抓包(
.srec),由控制面每个 grant 的policy.recording按会话开启,[recording] max_bytes_per_session只是预算上限(默认 64 MiB,设 0 整体禁用)。 - SFTP 命令级拦截(默认关):可拒绝特定命令(如
REMOVE/RENAME)并按路径前缀控制。 - 可观测:
/healthz、/readyz、/metrics(Prometheus 文本格式)。 - 优雅停机:SIGTERM 排水后向客户端发协议级
DISCONNECT[BY_APPLICATION]。
需要 Rust 工具链。
cargo build --release
# 产物:target/release/ssh-gw(受 CARGO_TARGET_DIR 影响)
install -m 0755 target/release/ssh-gw /usr/local/bin/ssh-gw按 config.example.toml 复制成生产配置(例如 /etc/ssh-gw/gateway.toml),改里面指向你机器的路径。加载顺序:--config/-c 旗标或第一个命令行参数 → $SSHGW_CONFIG 环境变量 → /etc/ssh-gateway/config.toml。
/usr/local/bin/ssh-gw --config /etc/ssh-gw/gateway.tomlsystemd 参考单元在 deploy/ssh-gw.service(已配好优雅排水和基本加固)。
-
生成 host key(网关对外提供身份):
mkdir -p /etc/ssh-gw && chmod 700 /etc/ssh-gw ssh-keygen -t ed25519 -N "" -f /etc/ssh-gw/host_ed25519 chmod 600 /etc/ssh-gw/host_ed25519
权限不对会拒绝启动(fail-closed)。
-
准备后端授权:把终端用户自己的公钥加进各后端机器的
authorized_keys。网关没有自己的后端私钥,也不需要——它拿用户转发来的 ssh-agent 去拨号(ssh -A/-o ForwardAgent=yes)。 -
配置路由来源(默认控制面,共三种模式):默认方式下网关把用户名发给控制面服务,由它响应
POST /api/v1/resolve返回目标;不想搭控制面可用下方"可选:简单映射表即路由"或"可选:OpenStack 实例即路由"。控制面请求体示例:{"principal": "alex_host1", "client_ip": "203.0.113.7", "key_fp": "SHA256:..."}响应示例:
{"target": {"host": "db.internal", "port": 22, "user": "alex"}, "policy": {"agent_forwarding": true, "recording": false, "ttl": 60}}拒绝返回
{"status":403,...},未知{"status":404,...}。policy.agent_forwarding为true才会建立后端会话;缺省或false一律拒绝(网关没有可回落的凭据)。不想搭真实服务的话,仓库里有 scripts/mock_control_plane.py(按一条 JSON 规则表应答,用于本地联调)。 -
后端 host key 信任:生产用
host_key_policy="pinned"(首次固化指纹);初次联调可先accept-new再尽快 pin。
不启用 [openstack] 时网关行为与上述控制面模式完全一致。启用后,规范形式的实例 UUID(以及可选的名字)由 Nova 解析成目标实例。
当前唯一可启用组合就是实验室/内网 PoC 模式:routing="openstack_only" + authorization="openstack_only" + allow_any_authenticated_key=true——没有控制面回退,任意通过前腿验签的密钥 + 已知 UUID 即可达该实例(名字路由同样)。routing="hybrid" 与"控制面授权"(方案 A)解析通过但启用即被配置校验拒绝(尚未实现)。
最小配置(截取自 config.example.toml,字段全注释):
[openstack]
enabled = true
routing = "openstack_only"
authorization = "openstack_only"
allow_any_authenticated_key = true
route_by = ["uuid"] # 加 "name" 启用实例名路由;UUID 形态永远优先
scope = "single_project" # 默认;"cross_project" = 不指定 Project(见下)
address_kind = "any" # any | floating(只取浮动 IP)| fixed
# Keystone v3 认证:application_credential(推荐)| password(仅 cross_project)| token(仅测试)
auth_url = "https://keystone.example.internal:5000/v3"
application_credential_id = "..."
application_credential_secret = "..." # 或 _file / $SSHGW_OPENSTACK_APPLICATION_CREDENTIAL_SECRET
project_id = "..." # single_project:可见性边界;cross_project:认证 scope
region = "RegionOne"
interface = "public"
# nova_endpoint = "https://nova:8774/v2.1" # 跳过 catalog 发现(测试/内网云)
# 地址与登录用户
# network = "私有网络名" # 多网络时必填
# prefer = "floating" # floating | fixed | any
# on_ambiguous = "reject" # reject(默认)| first
default_backend_user = "ubuntu" # 或靠实例 metadata / backend_user_map两个需先想清楚的开关:
scope = "cross_project"(不指定 Project,实验):让网关以all_tenants=1解析任意项目的实例(UUID 全局唯一可直接命中;名字要求全云唯一)。必须auth_method="password"(app-credential 被 Keystone 锁死在单项目),且该project_id是认证 scope(决定 token 带哪些角色)。⚠ 网关将持有能看/能拨其角色覆盖的一切实例的凭据——仅限内网/实验云且 Nova 策略宽松(允许项目级 admin 用all_tenants),严禁公网/共享。启动会有scope = "cross_project": this is a LAB modeWARN。address_kind = "floating"(只取浮动 IP):当实例只在公网侧可达(fixed/VXLAN 需要手工打通网络)时用。前置条件:实例已关联浮动 IP(Neutron 操作);否则一律no_floating拒绝。无浮动地址一律拒绝、绝不回退到 fixed。
完整说明、安全边界与已知限制见 docs/ops-runbook.md §5.8 / §5.9 与 docs/known-limitations.md §9 / §11。
不想搭控制面、也没有 OpenStack 时,可以把登录用户名直接映射到目标后端。启用后它成为唯一路由权威:表内用户名命中即用该行拨号,表外一律拒绝(无回退、无控制面)。
与 [control_plane]、[openstack] 互斥(三者只可同时启用其一)。开启需显式确认 allow_any_authenticated_key=true——任何通过前腿验签的 key 都可到达其 principal 映射的后端,仅用于实验室/内网。
[mapping]
enabled = true
mapping_file = "/etc/ssh-gw/mappings.toml"
allow_any_authenticated_key = true # 实验室授权语义,启用时必填
# recording = false # 默认不录;映射会话需显式 recording = true 才录映射文件(mappings.toml)每行一个 [[entries]]:
[[entries]]
username = "alex" # 登录用户名(须满足 principal 语法、表内唯一)
host = "10.0.0.7" # 目标后端 host(IP 或可解析名)
port = 22 # 可选,默认 22
user = "alex" # 可选,默认同 username(后端登录用户)表在启动时一次性加载并常驻内存,改文件需重启才生效;文件不可读/损坏/超 4 MiB / 非符号链接/group-other 可写也拒绝启动(fail-closed)。
客户端不用装任何东西,普通 OpenSSH 即可。必须转发 ssh-agent(-A 或 -o ForwardAgent=yes)——网关没有自己的后端私钥,会拿你 agent 里的 key 去拨号。用户名按控制面规则 / OpenStack 实例路由 / 简单映射表填。
# 控制面:用户名即路由
ssh -A -p 2222 alex_host1@gw.example.com
ssh -A -p 2222 alex_host1@gw.example.com 'uptime'
scp -o ForwardAgent=yes -P 2222 build.tar alex_host1@gw.example.com:/tmp/
sftp -o ForwardAgent=yes -P 2222 alex_host1@gw.example.com
# OpenStack:实例 UUID 即用户名
ssh -A -p 2222 550e8400-e29b-41d4-a716-446655440000@gw.example.com
# OpenStack:实例名(小写)即用户名(需 route_by=["uuid","name"])
ssh -A -p 2222 web-1@gw.example.com
# 简单映射:映射表里的用户名即路由(需 [mapping] enabled)
ssh -A -p 2222 mapped_alex@gw.example.com被连载的会话行为和直连后端基本一致;端口转发(direct-tcpip)一律拒绝——网关不是出站跳板,也没有任何配置能放开它。
观察运行状态:
curl http://127.0.0.1:9090/healthz # 存活
curl http://127.0.0.1:9090/readyz # 就绪(后端/控制面健康)
curl http://127.0.0.1:9090/metrics # 指标以下均为本机 loopback、64 MiB、字节校验过的实测(Fedora 44,OpenSSH 10.2 / russh 0.64.1,chacha20-poly1305 默认加密)。别的机器、网络、算法下数字会不同,只作参考。
| 场景 | 数值 |
|---|---|
| 数据面单向持续吞吐(单连接,trace transfer phase) | 上行 356–486 MB/s,下行 356–438 MB/s |
| 复用连接(OpenSSH ControlMaster)端到端 | 386–602 MB/s |
| 新建连接(默认配置) | 握手→认证结束约 1.17 s |
两个需要知道的点:
- 数据面没有额外瓶颈。网关本身的转发、拷贝、调度在实测里都不是限制(这个结论是经过多轮 A/B 验证的:零拷贝、调度解耦、窗口、加密算法、nodelay 单独调都动不了这个数)。要达到上面的吞吐,用连接复用(ControlMaster / 长连接多通道)。
- 每个新连接有一笔约 1 s 的固定认证开销,这是故意的(防爆破/防时序:每次认证拒绝都有恒定时间填充)。它的意思是:短小传输(几十 MB 级别,每次新建连接)的端到端速度会被这 1 s 摊得很低——
make bench里经网关 scp 的 ~45 MB/s 就是这个原因,不是数据面上限。对延迟敏感、以公钥/证书认证为主的部署,可以把[limits] auth_timeout设为"0s"(保留auth_rejection_pad="1s",被拒的用户仍每次付约 1 s,防爆破不丢),新连接降到约 0.17 s。
完整测量与推理见 docs/perf-investigation.md。
make lint # fmt + clippy
make test # 单元测试
make integration # 黑盒集成套件(本地 sshd + mock 控制面/OpenStack,全自动)
make bench # 吞吐基准(benchmark v2:A 每连接成本 / B 复用连接数据面 / C 记录)集成套件自包含:起一个本地 sshd 当后端 + mock 控制面/OpenStack,不需要外网。全部落盘细节、已知限制分别见 docs/ops-runbook.md 和 docs/known-limitations.md。
Apache-2.0。