只有一扇门的楼
把服务器放进私有子网,互联网就够不到它。这正是目的。可是运维人员得进去。解决这个矛盾的经典答案就是 bastion host:在公有子网放一台小主机,对互联网开放的门只有它的 SSH 端口,其余服务器只接受来自 bastion 的连接。
用楼来比喻,就是把出入口减到一个,只在那扇门上安排门卫。门卫只有一个,出入记录也就集中在一处。代价是那扇门一旦被攻破,整栋楼都被攻破。所以 bastion 是要加固得最狠、打补丁最勤、记录最仔细的那台主机。
经典的 bastion 用一行 SSH 就能做出来
OpenSSH 内置了这个模式。ssh_config 手册对 ProxyJump 的说明如下。
Setting this option will cause ssh(1) to connect to the target host by
first making an ssh(1) connection to the specified ProxyJump host and
then establishing a TCP forwarding to the ultimate target from there.
先 SSH 到跳板机,再从那里向最终目标打开 TCP 转发。命令行上是 -J。我在家庭实验室里实际做了一遍:从我的 Mac(192.168.219.101)经 cubi01(192.168.219.120)进入 nuc1(192.168.219.116)。
ssh -J cubi01 nuc1 'hostname; echo $SSH_CONNECTION'
第一次失败了。
channel 0: open failed: connect failed: Temporary failure in name resolution
stdio forwarding failed
加上 -v 就能看到 ssh 在做什么。
debug1: Setting implicit ProxyCommand from ProxyJump: ssh -v -W '[%h]:%p' cubi01
debug1: Executing proxy command: exec ssh -v -W '[nuc1]:22' cubi01
Authenticated to cubi01 ([192.168.219.120]:22) using "publickey".
debug1: channel_connect_stdio_fwd: nuc1:22
nuc1 这个名字在我 Mac 的 /etc/hosts 里有,在 cubi01 上却没有(那里 getent hosts nuc1 返回为空)。目标的名字是由跳板机解析的,不是你的笔记本。 这是用 bastion 的人最先撞上的事实。改成 IP,并加上 HostKeyAlias 让 known_hosts 里按名字记录的条目继续生效,就能通过。
ssh -o HostKeyAlias=nuc1 -J cubi01 192.168.219.116 'hostname; echo $SSH_CONNECTION'
nuc1
SSH_CONNECTION=192.168.219.120 53994 192.168.219.116 22
和不经跳板直接连接时对比,差别就出来了。
SSH_CONNECTION=192.168.219.101 55330 192.168.219.116 22
目标服务器认为连接来自 bastion(…120)。 我 Mac 的地址(…101)哪里都没有。只看目标服务器的日志,只知道"cubi01 进来了"。到底是谁进来的,只存在于 bastion 的日志里。bastion 的审计日志必须保留并送到外部,理由就在这一行。
这个家庭实验室有五个节点在 192.168.219.0/24 内,从互联网开放的只有网关的 80(用于 ACME HTTP-01)和 443。运维命令都是在局域网内通过 ssh cubi01 进去执行的。也就是说 cubi01 是局域网内的管理节点,不是互联网可见的 bastion。从必须向互联网开放 22 端口的那一刻起才是真正的 bastion,从那一刻起上面的担忧全都成真。
自己运维 bastion 要背负的东西
- 入站 22 端口。 对互联网开放的 SSH 整天被敲。开着的门本身就是攻击面。
- 密钥管理。 有人离职,就得修改 bastion 和所有目标上的
authorized_keys。实际上很少有人做。 - 打补丁。 bastion 是公有子网里的一台普通 EC2。内核或 OpenSSH 出了漏洞,它必须最先打补丁。
- 日志盲区。 如上所见,目标只看到 bastion 的地址。bastion 上不记录会话,"谁做了什么"就消失了。
- 单点故障。 bastion 挂了谁也进不去。可放两台,管理对象就变成两个。
AWS 几乎把这份清单原样写进了文档,并给出两个替代方案。
AWS 的答案 1:SSM Session Manager
Systems Manager 文档把 Session Manager 介绍为"无需开放入站端口、维护 bastion 主机或管理 SSH 密钥"就能管理节点的功能。实例里的 SSM Agent 向外 连接 Systems Manager 端点,运维人员顺着这条连接进去。一条入站规则都不需要。
aws ssm start-session --target i-0123456789abcdef0
权限全靠 IAM。谁能进哪台实例由标签或资源 ARN 决定,会话内容送到 S3 或 CloudWatch Logs,API 调用记进 CloudTrail。没有密钥文件,也就没有离职者的密钥要删。
端口转发也行。把实例的 80 端口拉到笔记本的 56789,
aws ssm start-session --target i-0123456789abcdef0 \
--document-name AWS-StartPortForwardingSession \
--parameters '{"portNumber":["80"], "localPortNumber":["56789"]}'
或者以实例为踏板,拉到它后面的 RDS。远程主机不需要注册到 Systems Manager。
aws ssm start-session --target i-0123456789abcdef0 \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["mydb.example.us-east-2.rds.amazonaws.com"],"portNumber":["3306"], "localPortNumber":["3306"]}'
想继续用原来的 ssh、scp,在 ~/.ssh/config 里放一条 ProxyCommand 即可。节点的 22 端口仍然关着(文档原话是 "You can close inbound ports on the node")。
# SSH over Session Manager
Host i-* mi-*
ProxyCommand sh -c "aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'"
User ec2-user
这里有一个文档明确写出的陷阱。通过 SSH 或端口转发建立的会话不记录内容。 SSH 在 TLS 隧道里再加了一层加密,Session Manager 只充当隧道。"谁在何时连了哪台实例"在 CloudTrail 里有,"敲了什么命令"只有普通会话(只用 start-session)才会记录。若以审计为目的,可以用 IAM 拒绝经 Session Manager 的 SSH,文档给出了策略。
{
"Effect": "Deny",
"Action": "ssm:StartSession",
"Resource": "arn:aws:ssm:*:*:document/AWS-StartSSHSession"
}
AWS 的答案 2:EC2 Instance Connect Endpoint
Session Manager 需要实例里有代理。对于装不了代理的实例,或者就想用普通的 SSH、RDP 的场合,AWS 提供了 EC2 Instance Connect Endpoint(EICE)。文档称之为 "identity-aware TCP proxy"。在 VPC 的某个子网里建一个端点,用 IAM 凭据认证的隧道就会从你的电脑打开到该端点,再从那里以 TCP 连到 VPC 内的实例。
文档里确认的性质如下。
- 实例 不需要公网 IP,VPC 也不需要互联网网关。
- 无论成功与否,所有连接尝试都记进 CloudTrail。
- 没有额外费用(连接另一个可用区的实例时只收跨 AZ 传输费)。
- 每个 VPC、每个子网只能建一个,一个端点最多 20 个并发连接。
- 隧道 最长 1 小时,可用 IAM 策略的
maxTunnelDuration条件强制更短。即使 IAM 凭据先过期,隧道也会维持到上限。 - 只用于管理流量。大量传输会被限流。
- 默认情况下实例看到的客户端是端点 ENI 的地址。开启客户端 IP 保留后能看到真实地址,但仅限 IPv4 且必须在同一 VPC。
用法只是换一条 SSH 的 ProxyCommand。
Host i-*
ProxyCommand aws ec2-instance-connect open-tunnel --instance-id %h
或者让 CLI 代劳。
aws ec2-instance-connect ssh --instance-id i-0123456789abcdef0 --connection-type eice
Windows 实例用 --remote-port 3389 打开隧道,再把 RDP 客户端指向本地端口。
三者并排
| EC2 bastion(自建) | SSM Session Manager | EC2 Instance Connect Endpoint | |
|---|---|---|---|
| 对互联网开放的端口 | 22 | 无(代理向外连接) | 无(端点在 VPC 内) |
| 实例上需要的东西 | 带公网 IP 的 bastion + 目标的 SG 规则 | SSM Agent + IAM 角色 | 无(只要密钥对) |
| 认证 | SSH 密钥 | IAM | IAM + SSH 密钥 |
| 连接记录 | bastion 的 sshd 日志(自行管理) | CloudTrail + 会话内容(S3/CloudWatch) | CloudTrail(所有尝试) |
| 会话内容记录 | 需要另外的工具 | 仅普通会话,经 SSH/转发不行 | 不行(TCP 代理) |
| 上限 | 无 | — | 每子网 1 个、并发 20、1 小时 |
| 费用 | EC2 实例费用 | 无 | 无(跨 AZ 传输除外) |
| 适合 | 代理和端点都用不了的情况 | 需要审计与命令记录的运维 | 普通 SSH/RDP、没有代理的 AMI |
在 AWS 上这样用才好
首选是 Session Manager。 没有入站端口、没有密钥、连命令都有记录。bastion 背负的五种负担全部消失。只需给实例装上 SSM Agent,并附加带 AmazonSSMManagedInstanceCore 权限的角色。私有子网没有互联网路由的话,放上 Systems Manager 的 VPC 端点(PrivateLink),代理就在 VPC 内连接。
需要 SSH 本身时用 EICE。 rsync、scp、IDE 远程开发、RDP 这类需要真正 TCP 连接的工作,就用 EICE 打开隧道,照常使用惯用的工具。记住每子网一个、并发 20、1 小时的上限,并用 maxTunnelDuration 绑得更短。
bastion EC2 是最后的选择。 若仍要放一台,就守住实测教给我们的两点。目标服务器只看到 bastion 的地址,所以 把 bastion 的 sshd 日志送到外部(例如 CloudWatch Logs),目标的安全组只允许来自 bastion 安全组的 22 端口。再给那台 bastion 也装上 SSM Agent,哪天关掉 22 端口也还有路可进。
一句话
bastion 是"把门减到一扇"的想法,在 AWS 上把这个想法实现得最好的已经不是 bastion 主机,而是 不开入站端口、凭 IAM 进入的 Session Manager。需要真正的 SSH 就用 EICE;自建 bastion 放在最后选,并且别忘了目标只看得到 bastion 的地址。