用 SSH 反向 SOCKS 与 KubeVirt 搭建四天临时出口

我需要把新加坡服务器的部分流量临时从家里的 Ubuntu VM 出口发出,持续一个周末。Ubuntu 不是普通物理机,而是运行在本地 K3s 集群里的 KubeVirt 虚拟机。

要求看起来只是“一条 ssh -R”,真正落地后却必须同时解决五件事:方向不能搞反、断线能够恢复、四天后一定关闭、使用期间可以观察、结束后不能留下可复用凭据。

最终方案很小:

  • 用 OpenSSH 远程动态转发,在新加坡服务器的回环地址上提供 SOCKS5。
  • 用 Ubuntu VM 上的 systemd 托管 SSH,网络闪断后自动重连。
  • 用绝对时间 timer 在 96 小时后停止并禁用服务,VM 重启也不顺延。
  • 用进程 socket 观察目标 IP 和端口,但不假装能解密 HTTPS。
  • 先从服务端撤销公钥,再删除 Ubuntu 上的私钥和全部运行痕迹。

本文记录完整过程,也保留中间踩过的坑。所有公网地址、用户名和密钥均已替换成示例值。

TL;DR

真正让 Ubuntu VM 成为出口的是这条命令:

bash
ssh -N -T \
-i ~/.ssh/sg-egress-ed25519 \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=~/.ssh/sg-egress-known_hosts \
-o GlobalKnownHostsFile=/dev/null \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-o ExitOnForwardFailure=yes \
-R 127.0.0.1:1080 \
sg-egress@203.0.113.10

关键点是 -R 127.0.0.1:1080 后面没有固定目标。现代 OpenSSH 会把它解释成远程动态转发:新加坡服务器上的应用连接 127.0.0.1:1080,SSH 客户端在 Ubuntu 一侧为每个 SOCKS 请求建立真实目标连接,因此公网看到的是 Ubuntu 上游出口地址。

在新加坡服务器上验证:

bash
curl --proxy socks5h://127.0.0.1:1080 https://ifconfig.me/ip

如果结果等于 Ubuntu VM 直连公网 IP,同时不同于新加坡服务器的直连 IP,路径才算正确。

需求与安全边界

这是一项刻意收窄的临时能力:

  1. 只有新加坡服务器本机进程可以使用代理。
  2. SOCKS 端口不监听 0.0.0.0
  3. SSH 密钥属于无特权账号,而且只能创建所需的远程监听。
  4. 隧道可以自动恢复,但截止时间不能因重启而顺延。
  5. 服务端密钥在相同截止时间失效,清理时先撤销公钥,再删本地私钥。

坚持绑定 127.0.0.1 很重要。如果写成:

text
-R 0.0.0.0:1080

这会主动请求公网监听。但客户端地址本身并不是可靠边界:如果 SSH 服务端配置了 GatewayPorts yes,即使客户端明确请求 127.0.0.1,sshd 也会强制把远程转发绑定到 wildcard 地址。因此我为隧道账号强制设置了 GatewayPorts noPermitListen 127.0.0.1:1080,并验证真实监听地址。

拓扑与数据路径

Ubuntu VM 运行在 K3s 的 KubeVirt 中,其 SSH 通过工作节点上的 NodePort 暴露。

这里最容易看反:SSH 控制连接由 Ubuntu 主动连向新加坡,但被代理的业务请求从新加坡发起,最终从 Ubuntu 出口离开。

先确认进入的是虚拟机,而不是 virt-launcher

KubeVirt Pod 里运行的是 QEMU。直接 kubectl exec 进入 virt-launcher,得到的是 compute 容器,不是 Ubuntu Guest OS。

先确认 VMI 和 SSH Service:

bash
kubectl -n kubevirt-vms get vmi ubuntu-vm -o wide
kubectl -n kubevirt-vms get service ubuntu-vm-ssh -o wide

Service 把 NodePort 30022 映射到虚拟机的 22,因此真正登录 Guest 的命令是:

bash
ssh -p 30022 vmuser@192.168.22.31

临时管理时也可以在 Mac 上开本地转发:

bash
kubectl -n kubevirt-vms port-forward service/ubuntu-vm-ssh 2222:22
ssh -p 2222 vmuser@127.0.0.1

三个端口含义完全不同:

  • 30022:K3s 长期存在的 NodePort。
  • 2222:Mac 上由 kubectl port-forward 临时创建的监听。
  • 1080:新加坡服务器本机的 SOCKS5 入口。

还有一个命令行细节:ssh 指定端口用小写 -pscp 使用大写 -P

准备独立的临时 SSH 凭据与截止时间

私钥只放在 Ubuntu VM,权限固定为 0600

bash
install -d -m 700 ~/.ssh
install -m 600 /secure/input/sg-egress-ed25519 ~/.ssh/sg-egress-ed25519
ssh-keygen -lf ~/.ssh/sg-egress-ed25519

我只计算一次截止时间,并从同一个时间值生成 OpenSSH 所需的密钥过期格式:

bash
deadline=$(date -u -d '+96 hours' '+%Y-%m-%d %H:%M:%S UTC')
key_expiry=$(date -u -d "$deadline" '+%Y%m%d%H%M%SZ')
public_key=$(ssh-keygen -y -f ~/.ssh/sg-egress-ed25519)
printf 'deadline=%s\nkey_expiry=%s\n' "$deadline" "$key_expiry"
printf 'expiry-time="%s" %s sg-egress-four-day\n' "$key_expiry" "$public_key"

我只通过经过认证的管理通道传输最后生成的公钥行。在新加坡服务器上,独立管理员创建专用账号,并把该行安装为 /home/sg-egress/.ssh/authorized_keys

bash
sudo useradd --create-home --user-group --shell /usr/sbin/nologin sg-egress
sudo install -d -o sg-egress -g sg-egress -m 700 /home/sg-egress/.ssh
sudo install -o sg-egress -g sg-egress -m 600 \
/secure/input/sg-egress-authorized-key \
/home/sg-egress/.ssh/authorized_keys

同时安装 /etc/ssh/sshd_config.d/sg-egress.conf

text
Match User sg-egress
AuthenticationMethods publickey
PasswordAuthentication no
KbdInteractiveAuthentication no
AllowAgentForwarding no
AllowStreamLocalForwarding no
AllowTcpForwarding remote
GatewayPorts no
PermitListen 127.0.0.1:1080
PermitTTY no
PermitTunnel no
PermitUserRC no
X11Forwarding no
MaxSessions 0
Match all

MaxSessions 0 会拒绝 shell、命令和 subsystem session,但仍允许 forwarding。AllowTcpForwarding remote 拒绝本地转发,PermitListen 则把远程监听限制到唯一地址。使用密钥前先校验并重载服务端配置:

bash
sudo sshd -t
sudo systemctl reload ssh
sudo sshd -T -C user=sg-egress,host=localhost,addr=127.0.0.1 | \
grep -E '^(allowagentforwarding|allowstreamlocalforwarding|allowtcpforwarding|gatewayports|maxsessions|permitlisten|permittty|permittunnel|permituserrc|x11forwarding) '

严格校验主机密钥还需要显式建立信任。在新加坡服务器已经完成认证的管理控制台中,我先记录 ED25519 主机密钥指纹:

bash
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

随后在 Ubuntu VM 上以 vmuser 把公钥扫描到专用文件,但只有扫描结果与可信通道取得的指纹完全一致时才安装。隧道不会读取或修改共享 known_hosts 文件:

bash
pinned_hosts=$(mktemp ~/.ssh/sg-egress-known_hosts.XXXXXX)
trap 'test ! -e "$pinned_hosts" || unlink "$pinned_hosts"' EXIT
ssh-keyscan -t ed25519 203.0.113.10 > "$pinned_hosts"
scanned_fingerprint=$(ssh-keygen -lf "$pinned_hosts" | awk 'NR == 1 { print $2 }')
read -r -p 'Trusted ED25519 SHA256 fingerprint: ' trusted_fingerprint
if [ -z "$scanned_fingerprint" ] || \
[ -z "$trusted_fingerprint" ] || \
[ "$scanned_fingerprint" != "$trusted_fingerprint" ]; then
printf 'host-key fingerprint mismatch\n' >&2
exit 1
fi
chmod 600 "$pinned_hosts"
mv "$pinned_hosts" ~/.ssh/sg-egress-known_hosts
trap - EXIT
ssh-keygen -F 203.0.113.10 -f ~/.ssh/sg-egress-known_hosts
ssh-keygen -lf ~/.ssh/sg-egress-known_hosts

ssh-keyscan 只负责收集服务端提供的公钥,不是信任根;通过独立可信通道取得的指纹才是信任根。所有隧道命令都显式使用这个文件并忽略全局主机密钥文件,因此只信任经过核验的密钥,同时不影响其它 SSH 状态。

不要把生产私钥粘贴到工单、聊天、Shell 历史或仓库。应使用独立短期密钥和经过认证的传输方式。如果私钥已经暴露,必须撤销服务端授权;只删除某台机器上的私钥副本没有意义。

第一版 -R 为什么方向错了

最初想到的是反向 SSH 登录端口:

bash
ssh -N -R 127.0.0.1:10022:127.0.0.1:22 sg-egress@203.0.113.10

这条命令会把新加坡的 127.0.0.1:10022 转到 Ubuntu 的 SSH 22。它解决的是“新加坡如何反向登录 Ubuntu”,不是“新加坡如何从 Ubuntu 出网”。

正确方案去掉固定目标:

bash
ssh -N -T \
-i ~/.ssh/sg-egress-ed25519 \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=~/.ssh/sg-egress-known_hosts \
-o GlobalKnownHostsFile=/dev/null \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-o TCPKeepAlive=yes \
-o ConnectTimeout=15 \
-o ExitOnForwardFailure=yes \
-R 127.0.0.1:1080 \
sg-egress@203.0.113.10

此时 1080 是 SOCKS5 入口,每个客户端请求可以选择不同目标。

用 systemd 稳定运行一个周末

前台终端不是可靠性机制。我把下面的单元安装到 /etc/systemd/system/sg-egress-socks.service

ini
[Unit]
Description=Reverse SOCKS5 tunnel using Ubuntu VM as SG egress
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=0
[Service]
Type=simple
User=vmuser
Environment=LANG=C
ExecStart=/usr/bin/ssh -N -T \
-i /home/vmuser/.ssh/sg-egress-ed25519 \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=/home/vmuser/.ssh/sg-egress-known_hosts \
-o GlobalKnownHostsFile=/dev/null \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-o TCPKeepAlive=yes \
-o ConnectTimeout=15 \
-o ExitOnForwardFailure=yes \
-R 127.0.0.1:1080 \
sg-egress@203.0.113.10
Restart=always
RestartSec=5s
TimeoutStopSec=15s
NoNewPrivileges=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target

ExitOnForwardFailure=yes 是核心配置。如果远端端口绑定失败却让 SSH 继续存活,systemd 会看到一个“绿色进程”,实际代理却不可用。显式退出才能触发重试。

启用前先做静态校验:

bash
systemd-analyze verify /etc/systemd/system/sg-egress-socks.service

再启动:

bash
sudo systemctl daemon-reload
sudo systemctl enable --now sg-egress-socks.service
systemctl is-enabled sg-egress-socks.service
systemctl is-active sg-egress-socks.service

96 小时后必须关闭

RuntimeMaxSec=4d 看起来很方便,但运行时限制与自动重启策略组合后,截止语义容易含糊。我需要一个不会因 VM 重启而变化的绝对时间。准备密钥时已经从同一时刻计算出 deadlinekey_expiry;timer 必须直接消费这个 deadline,不能复制一个示例时间。

停止动作 /etc/systemd/system/sg-egress-socks-stop.service

ini
[Unit]
Description=Stop and disable the SG egress SOCKS tunnel
[Service]
Type=oneshot
ExecStart=/usr/bin/systemctl disable sg-egress-socks.service
ExecStart=/usr/bin/systemctl stop sg-egress-socks.service

我先拒绝空值和过期时间,检查 systemd 的解析结果,再直接用 shell 变量生成 timer:

bash
test -n "${deadline:-}" || {
printf 'deadline is not set\n' >&2
exit 1
}
deadline_epoch=$(date -u -d "$deadline" '+%s')
test "$deadline_epoch" -gt "$(date -u '+%s')" || {
printf 'deadline is not in the future: %s\n' "$deadline" >&2
exit 1
}
systemd-analyze calendar "$deadline"
sudo tee /etc/systemd/system/sg-egress-socks-stop.timer >/dev/null <<EOF
[Unit]
Description=Stop the SG egress SOCKS tunnel after the four-day window
[Timer]
OnCalendar=$deadline
AccuracySec=1s
Persistent=yes
Unit=sg-egress-socks-stop.service
[Install]
WantedBy=timers.target
EOF

Persistent=yes 可以让 VM 在错过截止时间后补执行。停止动作同时禁用主服务,之后重启 VM 也不会重新开放隧道。新加坡服务端公钥使用同一截止时间的 expiry-time,即使客户端 timer 失效,截止后也无法重新认证。

bash
sudo systemctl daemon-reload
sudo systemctl enable --now sg-egress-socks-stop.timer
systemctl list-timers --all sg-egress-socks-stop.timer

验证真实出口

需要对比三组结果:

bash
# Ubuntu VM 直连
curl -4fsS https://ifconfig.me/ip
# 新加坡服务器直连
curl -4fsS https://ifconfig.me/ip
# 新加坡通过反向 SOCKS
curl -4fsS \
--proxy socks5h://127.0.0.1:1080 \
https://ifconfig.me/ip

正确关系是:

text
SG 经 SOCKS 的 IP == Ubuntu 直连 IP
SG 直连 IP != Ubuntu 直连 IP

同时检查新加坡监听:

bash
ss -lntp 'sport = :1080'

它必须显示 127.0.0.1:1080,不能是 0.0.0.0:1080

最后我主动终止过一次 SSH 主进程。systemd 在 7 秒内换了新 PID,NRestarts 增加,SOCKS 出口再次可用。相比“第一次启动成功”,这种测试更接近真正需要抵抗的故障。

观察实时流量,但不要虚构可见性

SSH 客户端会在 Ubuntu 上为代理请求创建目标 socket。实时连接可以这样看:

bash
tunnel_pid=$(systemctl show sg-egress-socks.service -p MainPID --value)
sudo lsof -nP -a -p "$tunnel_pid" -i

需要长期挂在终端时:

bash
tmux new-session -A -s sg-egress-monitor
watch -n 1 'pid=$(systemctl show sg-egress-socks.service -p MainPID --value); printf "tunnel_pid=%s\n" "$pid"; if [ "$pid" != 0 ]; then sudo -n lsof -nP -a -p "$pid" -i; else printf "service is not running\n"; fi'

Ctrl-b,松开后按 d,即可让 tmux 留在后台。

这个视图有明确边界:

  • 能看到当前活动的目标 IP 和端口。
  • 每秒采样可能漏掉极短连接。
  • 无法补查历史连接。
  • HTTPS 内容、账号、密码和完整 URL 仍然加密。
  • 域名只有在普通 DNS 或额外审计记录到时才可能出现。

如果业务真的要求历史统计,必须在开始前取得知情同意并启用流量元数据审计。一个实时 lsof 页面不是审计日志。

实际遇到的故障

1. 单个公网 IP 查询站点不可达

Ubuntu 出口访问 api.ipify.org:443 时收到 Connection refused,另一个 Cloudflare 端点超时;两个独立查询站点却返回了相同、有效的公网地址。

正确结论不是“隧道坏了”,而是“某些测量目标从当前出口不可达”。排查时应拆开 DNS、TCP 连通性和应用响应,并至少使用两个互不相关的验证端点。

2. 远端 1080 被占用

服务曾在大约两小时内累计 1428 次失败:

text
Error: remote port forwarding failed for listen port 1080

ExitOnForwardFailure=yes 让每次绑定失败都暴露给 systemd,随后每 5 秒重试。证据只能证明新加坡的端口冲突,不能证明占用者一定是残留 SSH 还是另一条手工隧道。

正确检查方式是:

bash
ss -lntp 'sport = :1080'
ps -fp <listener-pid>

不要只看到端口号就杀进程,必须先确认监听者和连接来源。冲突监听消失后,托管服务成功拿到 1080 并保持运行。

3. 多行粘贴破坏 tmux 监控命令

Shell 收到了真正的换行,把服务名和参数拆开:

text
sg-egress-
socks.service
-p
MainPID

于是出现 socks.service: not found-p: not found,空 PID 又导致 lsof -a 没有可组合的筛选条件。终端视觉换行没有问题,命令字符串中的真实换行才有问题。把 tmux 中保存的命令替换成一条完整单行后恢复正常。

4. 控制连接存在,不等于有人使用代理

监控始终会显示一条 Ubuntu 到新加坡 22 端口的 ESTABLISHED,它只是 SSH 控制连接。只有客户端实际使用 SOCKS 时,Ubuntu 上才会出现额外目标 socket。

一次连续 10 秒采样里,新加坡没有任何连接进入 127.0.0.1:1080,也没有测速进程。正确结论只是“代理当前空闲”,不是“监控坏了”。

完整撤销与清理

顺序不能反:先停止现有隧道,通过独立管理账号撤销公钥,确认旧密钥无法建立新隧道,最后才删除私钥。

下面的远程脚本使用 stdin 传递脚本内容,因此不能再用同一个 stdin 输入 sudo 密码。改变任何状态之前,我先确认独立管理员已经具备预先配置好的非交互 sudo 权限:

bash
ssh \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=~/.ssh/sg-egress-known_hosts \
-o GlobalKnownHostsFile=/dev/null \
admin@203.0.113.10 \
sudo -n true

如果检查失败,立即停止,改在已经完成认证的新加坡管理控制台中直接执行后续特权命令主体。不要通过脚本输入流传递 sudo 密码。

1. 停止现有隧道,但暂时保留私钥

bash
sudo systemctl disable --now sg-egress-socks-stop.timer
sudo systemctl disable --now sg-egress-socks.service
sudo systemctl stop sg-egress-socks-stop.service

先停止服务会关闭已经通过认证的 SSH 连接。删除 authorized_keys 中的公钥并不会终止现有 session。

2. 在新加坡只删除匹配公钥

从 Ubuntu VM 派生公钥主体,不输出任何私钥材料。使用新加坡上的独立管理账号把它作为一个受控参数传入,要求恰好匹配一条记录,再原子替换 authorized_keys。受限隧道密钥不能、也不应该执行这段清理脚本:

bash
key_blob=$(ssh-keygen -y -f ~/.ssh/sg-egress-ed25519 | awk '{print $2}')
ssh \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=~/.ssh/sg-egress-known_hosts \
-o GlobalKnownHostsFile=/dev/null \
admin@203.0.113.10 \
sudo -n bash -s -- "$key_blob" <<'REMOTE'
set -eu
key_blob=$1
auth_file=/home/sg-egress/.ssh/authorized_keys
matches=$(awk -v key_blob="$key_blob" \
'index($0, key_blob) { count++ } END { print count+0 }' \
"$auth_file")
if [ "$matches" -ne 1 ]; then
printf 'refusing to edit: expected 1 key, found %s\n' "$matches" >&2
exit 1
fi
temp_auth=$(mktemp /home/sg-egress/.ssh/authorized_keys.cleanup.XXXXXX)
trap 'test ! -e "$temp_auth" || unlink "$temp_auth"' EXIT
awk -v key_blob="$key_blob" \
'index($0, key_blob) == 0' \
"$auth_file" > "$temp_auth"
chmod --reference="$auth_file" "$temp_auth"
chown --reference="$auth_file" "$temp_auth"
mv "$temp_auth" "$auth_file"
REMOTE

OpenSSH 公钥主体只包含受限的 base64 字符,但“恰好匹配一条”的保护仍然必要:清理脚本不能在零匹配或多匹配时静默改写授权文件。

随后验证旧密钥无法认证新的转发连接。此时托管服务已经停止,1080 端口空闲;必须看到明确的 Permission denied (publickey),不能把超时或网络错误当成撤销成功:

bash
timeout 10s ssh -N -T \
-i ~/.ssh/sg-egress-ed25519 \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=~/.ssh/sg-egress-known_hosts \
-o GlobalKnownHostsFile=/dev/null \
-o ConnectTimeout=5 \
-o ExitOnForwardFailure=yes \
-R 127.0.0.1:1080 \
sg-egress@203.0.113.10

预期结果必须是 Permission denied (publickey)

现在管理员可以删除专用账号及其作用域内的 SSH 配置:

bash
ssh \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=~/.ssh/sg-egress-known_hosts \
-o GlobalKnownHostsFile=/dev/null \
admin@203.0.113.10 \
sudo -n bash -s <<'REMOTE'
set -eu
userdel -r sg-egress
unlink /etc/ssh/sshd_config.d/sg-egress.conf
sshd -t
systemctl reload ssh
REMOTE

3. 删除 Ubuntu 上的运行痕迹

bash
tmux kill-session -t sg-egress-monitor
sudo unlink /etc/systemd/system/sg-egress-socks.service
sudo unlink /etc/systemd/system/sg-egress-socks-stop.service
sudo unlink /etc/systemd/system/sg-egress-socks-stop.timer
sudo systemctl daemon-reload
unlink ~/.ssh/sg-egress-ed25519
unlink ~/.ssh/sg-egress-known_hosts

已知的临时副本也应逐个精确删除。处理凭据时不要使用宽泛 glob。

systemd journal 被保留为审计记录。journal 不适合只删除某一个 unit 的历史,而清空整个 journal 会破坏其它服务的运维证据。

4. 最终验收

bash
systemctl show sg-egress-socks.service -p LoadState --value
systemctl list-timers --all 'sg-egress-socks*'
pgrep -af '[s]sh.*-R.*1080'
tmux has-session -t sg-egress-monitor

最终状态是:

  • 三个 unit 全部 not-found
  • 无匹配 timer
  • ssh -R 进程
  • 无监控 tmux 会话
  • Ubuntu 无私钥、专用主机密钥文件和临时 unit 副本
  • 新加坡无专用 sg-egress 账号及其作用域内的 SSH 配置
  • 旧私钥认证被新加坡拒绝
  • 新加坡 127.0.0.1:1080 无监听

下次还会保留哪些原则

  1. 反向登录转发和反向动态转发是两种不同工具。
  2. 临时代理默认只绑定回环地址。
  3. SSH keepalive、systemd restart 和 ExitOnForwardFailure=yes 缺一不可。
  4. 用绝对持久化截止时间,并在截止时禁用主服务。
  5. 对比直连与代理公网 IP,验证真实数据路径。
  6. 开始前先确定只要实时观察,还是需要历史审计。
  7. 先撤销服务端公钥,再删除本地私钥。
  8. 保留审计日志,但删除凭据与运行痕迹。

隧道本身只有一行。真正的工程工作,是让它稳定、有限、可观察,而且随时可以被彻底撤销。


Comments