systemd 服务里 %h、%p 会被展开:一次 SSH 隧道启动失败的排查
背景
2026 年 11 月 12 日,我需要把一条经 HTTP CONNECT 代理的 SSH 动态转发隧道做成 systemd 常驻服务。隧道本身在命令行里已经跑通过:
ssh -N -D 127.0.0.1:11080 -o ProxyCommand="/root/connect_proxy.py %h %p" root@18.219.168.124
其中 connect_proxy.py 是一个 CONNECT 中继脚本,%h、%p 是 SSH 的占位符,ssh 会在建立连接时把它们替换成目标主机和端口。手动执行完全正常,出口固定为 18.219.168.124,Google 204 也通。
现象:写进 service 就起不来
把同一条命令原样搬进 unit 文件:
[Service]
ExecStart=/usr/bin/ssh -N -D 127.0.0.1:11080 -o ProxyCommand="/root/connect_proxy.py %h %p" root@18.219.168.124
systemctl start 后服务立即退出。journalctl 里看到的不是 SSH 的报错,而是中继脚本收到了完全错误的参数——它拿到的根本不是主机名和端口。
根因:systemd 先展开了一遍 %
问题不在 SSH,在 systemd。ExecStart 里的 % 开头序列是 systemd 的 specifier(说明符),服务启动前就会被 systemd 自己展开,根本到不了 ssh:%h 是本机主机名,%p 是 unit 名称前缀,%n 是完整 unit 名称,%u 是运行该服务的用户名,而 %% 转义后才还原成一个字面 %。
所以上面那行实际传给 ssh 的 ProxyCommand 已经变成了类似 connect_proxy.py 加本机主机名加服务名前缀,中继脚本去 CONNECT 一个不存在的目标,自然直接崩掉。而在交互 shell 里没有这一层展开,%h %p 原样进了 ssh,ssh 再按自己的规则替换,所以手动跑是好的——同一条命令,shell 里对、service 里错,这是最容易误导人的地方。
修复:写成 %%h %%p
[Service]
ExecStart=/usr/bin/ssh -N -D 127.0.0.1:11080 -o ProxyCommand="/root/connect_proxy.py %%h %%p" root@18.219.168.124
systemd 把 %% 还原成单个 % 再传给命令,ssh 拿到的就是它期望的 %h %p,隧道随即正常启动。改完务必 systemctl daemon-reload 后再启动,并用 journalctl 看进程实际收到的参数,不要只看 unit 文件原文。
两个顺带踩到的坑
- ExecStart 不经过 shell。systemd 默认不启动 shell 解释 ExecStart,管道、重定向、$变量都不会生效,需要 shell 语义时必须显式包一层 /bin/sh -c。但包了 shell 也救不了 % 的问题,因为 specifier 展开发生在 shell 启动之前,%% 该写还得写。
- 后台进程别指望 nohup。同一次排查里还发现:在受限的 exec 会话里用 nohup ssh 起的隧道,会话收尾时会被连带杀掉(日志里是 Broken pipe),测通只在同一次会话内成立。需要长期存活的隧道必须走 systemd 服务,而不是靠 nohup 顶着。
检查清单
- ExecStart 里出现 % 先问一句:这是给 systemd 的,还是给目标程序的?
- 给目标程序的 %h、%p、%u 等一律写成 %%h、%%p、%%u。
- 改完 daemon-reload,用 journalctl -u 服务名看进程实际收到的参数,不要只看 unit 文件原文。
- 凡是「shell 里能跑、service 里起不来」的命令,先查 specifier 展开,再查 shell 语义缺失。
- 点赞
- 收藏
- 关注作者
评论(0)