SSH through different kinds of proxy

有时候因为网络、安全等原因,我们不能通过 ssh 直接连接到目标主机,而是需要通过代理服务器或跳板机实现连接。本文总结通过代理或跳板机使用 ssh 的各种方法,并且分析这些方法的基本原理。

我们设定本地主机的地址为 homepc,绑定有公网 ip;运行有各类代理的代理服务器或跳板机地址为 proxy-server,proxy-server 上绑定一个公网 ip,同时绑定一个内网 ip(假定为10.0.10.252);需要连接的目标主机 target-server,绑定内网 ip(假定为 10.0.10.25)。所有的用户名、登录用户名使用 apple。

首先我们介绍一些常见的连接方法

登录跳板机,在跳板机上连接目标主机


  • 方法A:直接登录,可以通过 -A 选项利用Agent forwarding 特性
1
2
3
4
5
6
7
# now on homepc
apple@homepc
➜ ssh -A apple@proxy-server

# now on proxy-server
apple@proxy-server
➜ ssh apple@10.0.10.25
  • 方法B:A useful trick,通过 -tt 强制分配 tty,直接执行 ssh 命令
1
2
apple@homepc
➜ ssh -A -tt apple@proxy-server ssh apple@10.0.10.25
  • 方法C:利用 netcat 在跳板机上建立 tunnel,通过此 tunnel 连接目标主机
1
2
apple@homepc
➜ ssh -oProxyCommand='ssh apple@proxy-server nc %h %p' apple@10.0.10.25

借助 proxy 连接目标主机


  • 方法D:本地 ssh 代理,在 proxy-server 上用户 apple 设有 nologin 的 shell 权限,不能通过 ssh 登录 proxy-server 但是可以进行 ssh 连接,通过 -D 进行本地的端口转发,详情可以查看 man ssh。
1
2
3
4
5
6
# run ssh daemon on homepc for local “dynamic” application-level port forwarding
apple@homepc
➜ ssh -N -D 12171 apple@proxy-server &

apple@homepc
➜ ssh -oProxyCommand='nc -x 127.0.0.1:12171 %h %p' apple@10.0.10.25
  • 方法E:类似于本地 ssh 代理的方式,可以在 proxy-server 上运行任何协议类型的代理,在 homepc 本地运行代理客户端连接 proxy-server 上的 proxy,在 ssh 的 ProxyCommand 指定为本地代理客户端的连接点即可。

  • 方法F:利用 corkscrewconnect-proxyproxychains 等直接连接 proxy-server 上的代理。假定 proxy-server 上运行有 squid http 代理,代理使用用户名/密码这种基本验证方式。corkscrew 仅支持 http 代理,connect-proxy 和 proxychains 支持 http, socks4, socks5 代理。proxychains 还支持 shell 内所有流量都通过代理,提供了更过的功能,这里不展开叙述。只简单看一下通过它们使用 http 代理连接 ssh 的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# corkscrew can specify authfile with pattern of username:password for http proxy authentication
apple@homepc
➜ ssh -oProxyCommand='corkscrew proxy-server 3128 %h %p ~/.ssh/authfile' apple@10.0.10.25

# use connect-proxy
apple@homepc
➜ ssh -oProxyCommand='connect -H apple@proxy-server:3128 %h %p' apple@10.0.10.25

# use proxychains
# add "http xxx.yyy.zzz.www 3128 apple apple" to "[ProxyList]" node in proxychains config file
# xxx.yyy.zzz.www is the WAN ip of proxy-server. proxychains doesn't support dns lookup for proxy server
# more details: https://github.com/rofl0r/proxychains-ng/issues/25
apple@homepc
➜ proxychains4 ssh apple@10.0.10.25

ProxyCommand


关于 ProxyCommand 在此不多叙述,详情参考 man ssh_config。可以在 ~/.ssh/config 中配置 ProxyCommand,还可以根据不同的 host 配置不同的 ProxyCommand。

原理分析


这些方法看起来有些眼花缭乱,但其实原理都很简单,除去登录到跳板机的情形,剩下场景的都是通过某种形式连接到代理服务器上的代理(或通过更多层的代理连接到代理服务器上的代理,形成一个代理链),由代理转发数据到目标服务器。

首先看方法C 的场景,参考transparent-proxy-with-ssh

    +--------+                  +--------------+                +---------------+
    | homepc |                  | proxy-server |                | target-server |
    |        | ===ssh=over=netcat=tunnel======================> | 10.0.10.25    |
    +--------+                  +--------------+                +---------------+

该场景中,proxy-server 上实际运行有 nc 10.0.10.25 22 进程,该进程将会完成数据在 homepc 和 target-server 之间的转发。

方法D, E, F 中包含有明显的代理,以方法F 中的 corkscrew + squid http proxy 进行分析。

ssh 指定使用 ProxyCommand 之后,在建立连接时有这样一段关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
// from openssh-5.9p1, sshconnect.c
int
ssh_connect(const char *host, struct sockaddr_storage * hostaddr,
u_short port, int family, int connection_attempts, int *timeout_ms,
int want_keepalive, int needpriv, const char *proxy_command)
{
...
/* If a proxy command is given, connect using it. */
if (proxy_command != NULL)
return ssh_proxy_connect(host, port, proxy_command);
...
}

在 ssh_proxy_connect 中会 fork 出子进程来执行 ProxyCommand 中的命令,同时会重定向子进程的标准输入和标准输出,子进程的标准输入重定向到 pin[0],所以子进程会通过 pin[1] 获得父进程标准输出的内容;子进程的标准输出重定向到 pout[1],所以写到子进程标准输出的内容可以在父进程通过读取 pout[0] 获得。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// from openssh-5.9p1, sshconnect.c
static int
ssh_proxy_connect(const char *host, u_short port, const char *proxy_command)
{
...

/* Fork and execute the proxy command. */
if ((pid = fork()) == 0) {
...

/* Redirect stdin and stdout. */
close(pin[1]);
if (pin[0] != 0) {
if (dup2(pin[0], 0) < 0)
perror("dup2 stdin");
close(pin[0]);
}
close(pout[0]);
if (dup2(pout[1], 1) < 0)
perror("dup2 stdout");
/* Cannot be 1 because pin allocated two descriptors. */
close(pout[1]);

/* Stderr is left as it is so that error messages get
printed on the user's terminal. */
argv[0] = shell;
argv[1] = "-c";
argv[2] = command_string;
argv[3] = NULL;

/* Execute the proxy command. Note that we gave up any
extra privileges above. */
signal(SIGPIPE, SIG_DFL);
execv(argv[0], argv);
perror(argv[0]);
exit(1);
}
...
/* Close child side of the descriptors. */
close(pin[0]);
close(pout[1]);

/* Set the connection file descriptors. */
packet_set_connection(pout[0], pin[1]);

...
}
 +----------+             +---------------+               +----------+              +---------------+
 | terminal |  -------->  | parent stdout |  ---------->  |  pin[0]  |  --------->  |  child stdin  |
 |  input   |             |    pin[1]     |     read      |          |   redirect   |               |
 +----------+             +---------------+               +----------+              +---------------+

 +----------+             +---------------+               +----------+              +---------------+
 | terminal |  <--------  | parent stdin  |  <----------  |  pout[1] |  <---------  |  child stdout |
 | display  |             |    pout[0]    |     read      |          |   redirect   |               |
 +----------+             +---------------+               +----------+              +---------------+

上图描述了调用 ProxyCommand 时 ssh 客户端数据的流动情况,在我们的应用场景中,父进程对应 ssh 客户端进程,子进程运行 corkscrew。corkscrew 的实现很简单,它与代理服务器创建 tcp 连接,然后进入一个主循环,通过 select(2) 处理文件事件。
(注意 corkscrew 将与代理服务器协商的代码也写在主循环中,通过 setup 标志来确定处于建立连接后的协商阶段还是已经建立好到代理的连接,协商部分的代码可以抽离出来,类似的 connect-proxy 就是抽离出协商阶段和稳定连接阶段。下边分析的主循环略过协商阶段的代码。),这样处理文件读写事件的代码就非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// from corkscrew2.0, corkscrew.c
for(;;) {
FD_ZERO(&sfd);
FD_ZERO(&rfd);

FD_SET(csock, &rfd);
FD_SET(0, &rfd);

tv.tv_sec = 5;
tv.tv_usec = 0;

if(select(csock+1,&rfd,&sfd,NULL,&tv) == -1) break;

if (FD_ISSET(csock, &rfd)) {
len = read(csock, buffer, sizeof(buffer));
if (len<=0) break;
len = write(1, buffer, len);
if (len<=0) break;
}

if (FD_ISSET(0, &rfd)) {
len = read(0, buffer, sizeof(buffer));
if (len<=0) break;
len = write(csock, buffer, len);
if (len<=0) break;
}
}

corkscrew 的处理逻辑很清楚,从标准输入读取的数据,write 到 csock 中;从 csock 读取的数据,write 到标准输出。与 ssh 客户端结合起来,就可以得到下边的一张图:

 +--------------+             +------------+               +-----------------+          +---------+
 | child stdin  |  -------->  | corkscrew  |  ---------->  | csock           |  ----->  |  proxy  |
 | corkscrew    |    read     |            |     write     | conn with proxy |          |         |
 +--------------+             +------------+               +-----------------+          +---------+

 +--------------+             +------------+               +-----------------+          +---------+
 | child stdout |  <--------  | corkscrew  |  <----------  | csock           |  <-----  |  proxy  |
 | corkscrew    |    write    |            |     read      | conn with proxy |          |         |
 +--------------+             +------------+               +-----------------+          +---------+

从代理到目标服务器的数据收发与上述实现类似,只是 socket 有所不同,不再按照不同代理具体分析。使用不同的代理形式,只是在代理协商阶段有所不同,当稳定连接后,代理的工作就是不停的转发数据了。从数据的发送接收角度,加入代理后不影响 ssh 客户端和服务器之间传输数据的内容和顺序,因而可以将代理看做是透明的,就好像 ssh 客户端直接连接到目标服务器一样。

问答

TODO

  1. 使用代理会不会存在安全问题?例如 Security Issues With Key Agents 提到的安全问题。
  2. 我使用 mosh,这些方法是否可以使用?
  3. 如果我使用公私钥登录的方式,代理服务器上需要进行哪些操作?

参考链接

  1. Using SSH ProxyCommand to Tunnel Connections
  2. SSH Through or Over Proxy
  3. Corkscrew
  4. OpenSSH/Cookbook/Proxies and Jump Hosts
  5. An Illustrated Guide to SSH Agent Forwarding
  6. SSH through HTTP proxy
  7. 透过代理连接SSH