自定义 ProxyCommand 以代理 SSH 的底层连接
同作为一款使用 TCP 协议、C/S 模型的远程登录程序,SSH(客户端)在登录的时候免不了需要提供服务器的地址和端口。 SSH 会首先基于此地址、端口二元组建立到服务器的 TCP 连接,然后进行密钥协商并打开安全通道(Channel),最后是用户登录并启动子系统。
“连接”初探
在第一步“连接建立”的过程中,根据 SSH 的协议第 4.2 节说明,通信的双方都必须要发送一串身份识别字符串(identification string)。其内容为:
SSH-protoversion-softwareversion SP comments CR LF
由于这里是纯文本协议,所以很容易验证。比如我直接连接我的 nuc 机器(IP 地址为:192.168.1.86)的 ssh 端口:
1 2 |
|
以上是服务器发送的它的身份识别字符串,确实符合协议所述。 这非常直接了当地表明了我们访问的端口可能是什么协议。 这既是好处,也是坏处。
网络有“墙”,我们需要代理
现实是,我们可能没法直接访问服务器。而其中的原因,可能是防火墙、可能是安全要求、可能是不在公网上没法直接连接。
“Any problem in computer science can be solved by another layer of indirection.”
“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。”
考虑到 SSH 需要的仅仅是一个普普通通的、支持读写的……“连接”,仅此而已。为什么一定需要自己建立这个连接呢?
为了解决这个问题,SSH 特地提供了 ProxyCommand(代理命令)
选项(只保留关键说明部分):
1 2 3 4 5 |
|
当指定了这个选项(option)的时候,ssh 客户端不会再自己去尝试连接服务器,而是把待连接的服务器的地址和端口作为变量并以参数的形式传递给 ProxyCommand 指定的命令;由此命令建立与服务器的连接,并通过标准输入与标准输出(分别表示网络连接的读与写)交换数据。 (没想到标准输入、标准输出还能这么用吧?)
可以看出来,SSH 自身并没有实现任何应用层的代理协议(比如 HTTP/S,SOCKS v4/v5 等),而是提供了一套通用的、基于标准输入输出的“接口”。 放任用户去实现这套通信标准。用白话讲:我(ssh)告诉你(代理命令)我想和谁通信,你帮我们传个“纸条”就行,我懒得管你是怎么传的。
使用 nc 进行最简单的代理测试
如其名,nc(net cat)
,它只会简单地连接到指定的地址和端口(简单称其为“裸连接”),读取标准输入并发送数据、读取数据并写入到标准输出。所以它恰好非常适合用来演示作为代理命令的使用(尽管此处没有“墙”):
1 2 |
|
-o ProxyCommand
用于说明我们要使用代理命令连接服务器;nc %h %p
即是我们的代理命令。其中的%h
和%p
分别表示待连接的服务器的地址和端口(更多的参数请参考man ssh_config
);nuc
是我的 Intel NUC 小主机的别名;date
是在服务器上执行的一条打印当前日期与时间的一条命令。
SSH 客户端本来是要自己连接到服务器的,现在有了 nc,于是 nc 负责连接到服务器,并通过标准输入、标准输出与 ssh 客户端交换数据(纯双向数据拷贝操作)。
看起来是成功执行了。 如果加上详细的输出,可以看到命令是如何被调用的:
1 2 3 4 5 |
|
所谓“祼连”,算是一种最简单的 TCP Relay 的方式了。 下图分别展示了 ssh 直接与使用 nc 时的区别:
SSH client <-------> SSH server
network
SSH client <-------> nc <-------> SSH server
stdin/stdout tcp, direct
nc 还支持更多的协议
除了直接创建一条“裸连接”外,nc 也支持通过 HTTP 代理和 SOCKS 代理建立连接。 这里以 SOCKS v5 代理为例:
1 2 3 4 |
|
因为我把地址直接写在了 nc 命令的参数内,忽略了 %h,所以后面的 xxx
就无关紧要了。
现在的数据交换图示:
SSH client <-------> nc <-----------------> SOCKS server <-------> SSH server
stdin/stdout socks v5 (client) tcp, direct
ssh -W
ssh 自带的这个 -W
选项,如果我们成功登录服务器,它不会进入 shell,而是创建一个到到 -W
指定的地址和端口的连接,并把标准输入与标准输出作为读与写,就像 nc 的行为一样,只不过需要成功登录 ssh 服务器后才能建立连接,相当于多增加了一层认证。
$ ssh -W httpbin.org:80 nuc
GET /get HTTP/1.1
Host: httpbin.org
HTTP/1.1 200 OK
Date: Sun, 04 Jun 2023 15:03:53 GMT
Content-Type: application/json
Content-Length: 199
Connection: keep-alive
Server: gunicorn/19.9.0
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
{
"args": {},
"headers": {
"Host": "httpbin.org",
"X-Amzn-Trace-Id": "Root=1-647ca7d6-56d55d4261937f394fcf02d3"
},
"origin": "116.30.133.88",
"url": "http://httpbin.org/get"
}
在成功登录到 nuc 后,ssh 服务端就会连接到 httpbin.org:80,此时我在 ssh 客户端这边拼接一个 HTTP 请求发送出去,即可收到响应。
当然,作为 ProxyCommand 使用完全没问题:
1 2 |
|
一不小心套了个娃,应该能看明白吧?🥹
常用吗?常用。这是很多公司的跳板机的常规配置,一种追求简单的方法。可以少开防火墙,可以集中鉴权(第一道)。
ssh client <-------> ssh(-W) client <-------> ssh(-W) server <-------> ssh server
stdin/stdout tcp, direct tcp, direct
自定义:http2tcp
只要满足前面的“基于标准输入与标准输出的接口模型”,谁都可以实现自己的 ProxyCommand。基于文件、基于管道、基于 UDP、基于自定义协议,爱咋的咋的。
我在《http2tcp: 一个通过 HTTP 转发 TCP 流量的小工具》这篇文章里面就根据我当时的需求实现过一个非常适合我们的场景的 ProxyCommand:在 K8s 集群内运行了一个 ssh server,装好了各种调试工具,然后通过把站点首页域名的 HTTP 请求转换到 TCP 连接连接到 ssh server,从而实现了从本地直接通过域名登录到了容器调试环境内部。
最后
为简单起见,文章没有把配置写到 ~/.ssh/config
文件中,实际上应该像下面这样写:
Host blog
User root
IdentityFile ~/.ssh/id_ed25519
HostName blog.twofei.com
ProxyCommand http2tcp -c -d localhost:22 -e https://blog.twofei.com/???/ -t ??? 2> /tmp/http2tcp-%h-$(date +%%s).log
以上就是我个人多年使用 ssh ProxyCommand 的一些总结,感觉挺好用。如果阅读到此文章的你有更多好用的方法,欢迎交流。