原文链接:

一、内网穿透例子

有两个内网,简单记为 A 和 B,其中 A1,A2 是 A 中的两台机器,B1,B2 是 B 中的两台机器。另外还有一台设备 C。其中

  1. A 内网和 B 内网的上级路由器你都没有操作权限,也即不能实现端口转发;
  2. A1 设备只有内网访问权限,无法访问 C,但是可以和同在一个内网的 A2 直接互相通信;
  3. B1 设备只有内网访问权限,无法访问 C,但是可以和同在一个内网的 B2 直接互相通信;
  4. A2、B2 这两台设备都可以访问 C,但是 C 不可以反过来主动访问 A2 和 B2。(自然也不能访问 A1 和 B1)

现在,要使 B1 设备可以访问到 A1 设备。(前面的例子是一个 A1 和 A2 重合,B1 和 B2 重合并且 C 限定为互联网设备的简化版本)。为了实现这个目标,我们需要再A2、B2、C上分别运行程序a,b,c。其中三个程序的职责分别如下:

  1. a 主动连接到 c,等待来自 c 发送数据,把 c 发送的数据全部转发给 A1 机器
  2. b 主动连接到 c,等待来自 B2的 数据,将 B2 发送的数据转发给 c
  3. c 等待 a 和 b 的连接,然后把 b 发送的数据全部发送给 a

为了更直观一些,我们用下面的图来说明具体的关系和流程。

§ 内网穿透:从原理到代码实现 - 图1

这个图里面的箭头仅代表连接方向,即是谁发起的连接,而不代表数据的传送方向,因为连接建立的过程中以及连接建立以后数据的传输都是双向的。另外这里扩展了穿透的一般性,也即内网主机不可以直接访问外网的情况,多数情况下内网主机可以直接访问互联网,此时 A1、A2 重合为一台主机,B1、B2 同理。另外这里也没有限定桥梁 C 必须是公网的机器,只 A2 和 B2 可以直接访问即可。

二、内网穿透实现:基于 ssh

前面的例子中我们看到,要完成整个内网穿透的流程需要三个程序的协助(当然有时候情况可以简化)。不过幸运的是,现在已经有很多工具可以帮助完成上述的功能,其中 ssh 就是其一(后面我们还将介绍 frp 的方法)。

SSH 除了登录服务器,还有一大用途,就是作为加密通信的中介,充当两台服务器之间的通信加密跳板,使得原本不加密的通信变成加密通信。这个功能称为端口转发(port forwarding),又称 SSH 隧道(tunnel)。

端口转发有两个主要作用:

  1. 将不加密的数据放在 SSH 安全连接里面传输,使得原本不安全的网络服务增加了安全性,比如通过端口转发访问 Telnet、FTP 等明文服务,数据传输就都会加密。
  2. 作为数据通信的加密跳板,绕过网络防火墙。

ssh 提供了三种端口转发,分别是本地端口转发、远程端口转发以及动态端口转发。其中的动态端口转发我们暂且先按下不表,我们先简单介绍一下前两者。

§ 内网穿透:从原理到代码实现 - 图2

3.1 ssh本地端口转发

本地转发(local forwarding)指的是,SSH 服务器作为中介的跳板机,建立本地计算机与特定目标网站之间的加密连接。本地转发是在本地计算机的 SSH 客户端建立的转发规则。

§ 内网穿透:从原理到代码实现 - 图3 命令格式

它会指定一个本地端口(local-port),所有发向那个端口的请求,都会转发到 SSH 跳板机(tunnel-host),然后 SSH 跳板机作为中介,将收到的请求发到目标服务器(target-host)的目标端口(target-port)。

§ 内网穿透:从原理到代码实现 - 图4

  1. ssh -L local-port:target-host:target-port tunnel-host

上面命令中,-L参数表示本地转发,local-port是本地端口,target-host是你想要访问的目标服务器,target-port是目标服务器的端口,tunnel-host是 SSH 跳板机。

§ 内网穿透:从原理到代码实现 - 图5 例子1

举例来说,现在有一台 SSH 跳板机tunnel-host,我们想要通过这台机器,在本地2121端口与目标网站www.example.com的80端口之间建立 SSH 隧道,就可以写成下面这样。

  1. ssh -L 2121:www.example.com:80 tunnel-host -N

然后,访问本机的2121端口,就是访问www.example.com的 80 端口。

  1. curl http://localhost:2121

注意,本地端口转发采用 HTTP 协议,不用转成 SOCKS5 协议。

§ 内网穿透:从原理到代码实现 - 图6 例子2

另一个例子是加密访问邮件获取协议 POP3。

  1. ssh -L 1100:mail.example.com:110 mail.example.com

上面命令将本机的 1100 端口,绑定邮件服务器mail.example.com的 110 端口(POP3 协议的默认端口)。端口转发建立以后,POP3 邮件客户端只需要访问本机的 1100 端口,请求就会通过 SSH 跳板机(这里是mail.example.com),自动转发到mail.example.com的 110 端口。

上面这种情况有一个前提条件,就是mail.example.com必须运行 SSH 服务器。否则,就必须通过另一台 SSH 服务器中介,执行的命令要改成下面这样。

  1. ssh -L 1100:mail.example.com:110 other.example.com

上面命令中,本机的 1100 端口还是绑定mail.example.com的 110 端口,但是由于mail.example.com没有运行 SSH 服务器,所以必须通过other.example.com中介。本机的 POP3 请求通过 1100 端口,先发给other.example.com的 22 端口(sshd 默认端口),再由后者转给mail.example.com,得到数据以后再原路返回。

注意,采用上面的中介方式,只有本机到other.example.com的这一段是加密的,other.example.commail.example.com的这一段并不加密。

这个命令最好加上-N参数,表示不在 SSH 跳板机执行远程命令,让 SSH 只充当隧道。另外还有一个-f参数表示 SSH 连接在后台运行。

如果经常使用本地转发,可以将设置写入 SSH 客户端的用户个人配置文件(~/.ssh/config)。

  1. Host test.example.com
  2. LocalForward client-IP:client-port server-IP:server-port

3.2 ssh远程端口转发

远程转发指的是在远程 SSH 服务器建立的转发规则。

§ 内网穿透:从原理到代码实现 - 图7 命令格式

它跟本地转发正好反过来。建立本地计算机到远程计算机的 SSH 隧道以后,本地转发是通过本地计算机访问远程计算机,而远程转发则是通过远程计算机访问本地计算机。它的命令格式如下。

§ 内网穿透:从原理到代码实现 - 图8

  1. ssh -R remote-port:target-host:target-port -N remotehost

上面命令中,-R参数表示远程端口转发,remote-port是远程计算机的端口,target-hosttarget-port是目标服务器及其端口,remotehost是远程计算机。

远程转发主要针对内网的情况。下面举两个例子。

§ 内网穿透:从原理到代码实现 - 图9 例子 1

第一个例子是内网某台服务器localhost在 80 端口开了一个服务,可以通过远程转发将这个 80 端口,映射到具有公网 IP 地址的my.public.server服务器的 8080 端口,使得访问my.public.server:8080这个地址,就可以访问到那台内网服务器的 80 端口。

  1. ssh -R 8080:localhost:80 -N my.public.server

上面命令是在内网localhost服务器上执行,建立从localhostmy.public.server的 SSH 隧道。运行以后,用户访问my.public.server:8080,就会自动映射到localhost:80

§ 内网穿透:从原理到代码实现 - 图10 例子 2

第二个例子是本地计算机local在外网,SSH 跳板机和目标服务器my.private.server都在内网,必须通过 SSH 跳板机才能访问目标服务器。但是,本地计算机local无法访问内网之中的 SSH 跳板机,而 SSH 跳板机可以访问本机计算机。

由于本机无法访问内网 SSH 跳板机,就无法从外网发起 SSH 隧道,建立端口转发。必须反过来,从 SSH 跳板机发起隧道,建立端口转发,这时就形成了远程端口转发。跳板机执行下面的命令,绑定本地计算机local2121端口,去访问my.private.server:80

  1. ssh -R 2121:my.private.server:80 -N local

上面命令是在 SSH 跳板机上执行的,建立跳板机到local的隧道,并且这条隧道的出口映射到my.private.server:80

显然,远程转发要求本地计算机local也安装了 SSH 服务器,这样才能接受 SSH 跳板机的远程登录。

执行上面的命令以后,跳板机到local的隧道已经建立了。然后,就可以从本地计算机访问目标服务器了,即在本机执行下面的命令。

  1. curl http://localhost:2121

本机执行上面的命令以后,就会输出服务器my.private.server的 80 端口返回的内容。

如果经常执行远程端口转发,可以将设置写入 SSH 客户端的用户个人配置文件(~/.ssh/config)。

  1. Host remote-forward
  2. HostName test.example.com
  3. RemoteForward remote-port target-host:target-port

完成上面的设置后,执行下面的命令就会建立远程转发。

  1. ssh -N remote-forward
  2. # 等同于
  3. ssh -R remote-port:target-host:target-port -N test.example.com

3.3 远程端口和本地端口的理解

远程端口转发与本地端口转发干的事情都是端口转发,也可以从本身的名字上来理解它们之间的不同。

  • 本地端口转发顾名思义就是绑定本地端口,把访问本地端口的请求转发到远程服务器的端口上去,多用于本机访问远程服务器的某一个服务;
  • 远程端口转发顾名思义就是绑定远程端口,把访问远程端口的请求转发到本地服务器所在的某个网段的端口上去,多用于让远程主机访问本地的服务,比如本地的 x11 服务(当然这个更推荐直接用 ssh -X 啦)。

这两个命令中指定端口的中间那一段可以被看做一个统一的 src:dst 的格式,更具体的,两条命令可以一起这么理解:

  1. ssh -L/R src:dst host

这个命令干的事情就是绑定 src,把访问 src 的流量发送到 dst 中,其中如果是 -L,即本地端口转发,则 src 指的就是本机的地址,然后流量会通过 host 这个主机发送给 dst。如果是 -R,即远程端口转发,则 src 指的就是远程主机 host,访问 host 中 src 地址的流量都会通过本地主机发送到 dst。

3.4 ssh动态端口转发

动态转发指的是,本机与 SSH 服务器之间创建了一个加密连接,然后本机内部针对某个端口的通信,都通过这个加密连接转发。它的一个使用场景就是,访问所有外部网站,都通过 SSH 转发。

动态转发需要把本地端口绑定到 SSH 服务器。至于 SSH 服务器要去访问哪一个网站,完全是动态的,取决于原始通信,所以叫做动态转发。

  1. ssh -D local-port tunnel-host -N

上面命令中,-D表示动态转发,local-port是本地端口,tunnel-host是 SSH 服务器,-N表示这个 SSH 连接只进行端口转发,不登录远程 Shell,不能执行远程命令,只能充当隧道。

举例来说,如果本地端口是2121,那么动态转发的命令就是下面这样。

  1. ssh -D 2121 tunnel-host -N

注意,这种转发采用了 SOCKS5 协议。访问外部网站时,需要把 HTTP 请求转成 SOCKS5 协议,才能把本地端口的请求转发出去。

下面是 SSH 隧道建立后的一个使用实例。

  1. curl -x socks5://localhost:2121 http://www.example.com

上面命令中,curl 的-x参数指定代理服务器,即通过 SOCKS5 协议的本地2121端口,访问http://www.example.com

如果经常使用动态转发,可以将设置写入 SSH 客户端的用户个人配置文件(~/.ssh/config)。

  1. DynamicForward tunnel-host:local-port

3.5 一个应用实例

我们来考虑一个最常用的简化的应用实例,也即我们有内网机器 A,公网机器 C(IP: hostc),我们要在另一个内网的机器 B 上访问内网机器 A 的 22 端口。这时候我们可以预先在内网机器 A 上执行:

  1. ssh -R 10022:127.0.0.1:22 username@hostc

如果离开了内网环境,想连接到内网机器 A 上时,可以在内网 B 上直接通过 ssh 连接到公网机器 C 上:

  1. ssh username@hostc

然后在在 C 上执行:

  1. ssh -p 10022 username@127.0.0.1

即可成功连接到内网。

当然,如果你不希望这么麻烦,在内网机器上执行的命令中可以将 127.0.0.1 改为 0.0.0.0 并且打开 hostc 的 GatewayPort 选项,这样直接在 B 上执行ssh -p 10022 userename@hostc也可以连接到内网机器 A 中(但是这样是强烈不建议的)

现在我们再让情况稍微复杂一些,假定我们的内网 B 机器不能访问互联网,但是内网 B 中存在一台可以访问互联网的机器 BO,我们可以在 BO 上建立一个本地端口转发即可:

  1. ssh -L 0.0.0.0:20022:127.0.0.1:10022 username@hostc

这样与 BO 同在一个内网的机器就可以直接用ssh -p 20022 username@hostb就可以访问到内网机器 A 了。

最后留一个小疑问:如果内网机器 A 不能访问互联网,但是 A 所在的内网中有一台 AO 可以访问互联网,这时候应该怎样操作呢?如果前面的内容都理解了的话,这个问题应该是显然的。

答案 :将在内网机器 A 上执行的命令改为在 AO 上执行ssh -R 10022:hosta:22 username@hostc即可(此时内网机器 A 不用执行任何命令)

写到这里,相信你已经可以灵活使用 ssh 实现内网穿透了,但是现在还有一个问题,就是如果内网机器重启了怎么办呢?如果内网机器临时断网导致 ssh 创建的连接断开了怎么办呢?这时候就需要一个叫做 autossh 的小工具来帮助你解决这个问题了。你可以访问 autossh 的官网(https://www.harding.motd.ca/autossh/)了解相关的信息,这里是从这个网站中摘出来的一句简单的介绍:

三、内网穿透实例:基于frp

frp(https://github.com/fatedier/frp)是一个非常专业的内网穿透工具,具有非常非常多的功能,支持大量的协议,这里我们只选取几个最基本的功能来介绍。下面是从github官方仓库的readme中截取的一段简介:

frp 是一个专注于内网穿透的高性能的反向代理应用,支持 TCP、UDP、HTTP、HTTPS 等多种协议。可以将内网服务以安全、便捷的方式通过具有公网 IP 节点的中转暴露到公网。

4.1 frp 的结构

一个完整的 frp 应用由三部分组成,一个是客户端1 (frpc),对应于前面的模型中的程序 a;一个是服务端(frps),对应于前面模型中的程序 c; 一个是客户端2(frpc),对应于前面模型中的程序 b。你可以在官方仓库的readme中找到这个介绍结构的图:

§ 内网穿透:从原理到代码实现 - 图11

这个图已经把 frp 的结构解释的非常清晰了,其中内网的服务通过一个 frp c客户端暴露出来,这个暴露可以有两种方式

  • 方式1:直接绑定到服务器的某一个端口,直接访问服务器的这个端口就可以访问到内网服务
  • 方式2:不绑定服务器端口,然后通过在另一台机器上运行另一个 frpc,访问配置文件中指定的一个本机端口来访问内网服务。接下来我们将分别介绍这两种情况。

4.2 frp 服务器端的设置

frp 服务端可执行文件为frps,对应于我们前面模型中的程序 c,其配置文件是frps.ini,一些我们接下来可能用到的相对重要的配置项如下所示:

  1. [common]
  2. bind_port = 7000
  3. bind_udp_port = 7001
  4. token = somerandomtoken
  5. tcp_mux = true
  6. log_file = /var/log/frp/frps.log
  7. log_max_days = 30

其中各个配置项的含义如下所示:

配置项 含义
bind_port frps服务端口,对应于客户端中的server_port项
bind_udp_port frps用于P2P穿透的服务端端口(这个客户端不用关心)
token 客户端frpc连接到服务端frps端口的密码,对应于客户端的token项
tcp_mux 是否允许TCP复用,打开以后可以使多个TCP连接复用同一个TCP连接,避免频繁创建TCP连接(三次握手很耗时的喂!),可以在一定程度上提高网络性能
log_file 日志文件路径
log_max_days 最大保留日志天数

服务端配置完成以后执行 frps -c frps.ini 即可。

另外 frp 官方也提供了 systemd 脚本,复制 frps 和 frps.ini 到指定的目录下,然后复制提供的 systemd 脚本到 systemd 脚本的目录下,执行sudo systemctl start frps就要可以后台启动 frps 了,执行systemctl enable frps就可以做到 frps 的开机器自动了。(frpc 同理)

4.3 被连接客户端与访问客户端的配置

被连接客户端可执行文件为frpc,对应于我们前面模型中的程序 a,其配置文件是frpc.ini;访问端对应于前面模型中的 b,其可执行文件和配置文件也是这两个,一般成对一起配置,这里就一起说了。

§ 内网穿透:从原理到代码实现 - 图12

首先看被访问端 A 的配置:

  1. [common]
  2. server_addr = 123.123.123.123 # server ip
  3. server_port = 1234 # server port
  4. token = somerandomtoke # same as the token configured in server
  5. tcp_mux = true
  6. [somename]
  7. type = tcp
  8. local_ip = 127.0.0.1
  9. local_port = 22
  10. remote_port = 6000

被访问客户端的配置有两个主要的部分,一个是[common],主要包括如何连接到服务器、服务器的密码以及一些公共的设置。另一个而部分就是[somename]这一部分了,这里的somename可以是任何字符串,用于区分不同的连接,每一段是一个配置,这里各段的配置含义如下:

配置项 含义
type 配置类型,tcp
就是把本地端口绑定到远程端口的一种配置
local_ip 内网A中的一台机器的IP,如果是本机,就是127.0.0.1
,如果是其他的内网机器,写对应的IP即可
local_port 本地的端口,如果是ssh服务,写22即可(当然也可能是其他的,你没改就是22)
remote_port 绑定的远程端口,也即你把本地的locaip:localport绑定到了运行着frps服务器的哪一个端口上了,访问frps服务器的这个端口就相当于访问本地localip:localport这个端口。

配置完成后运行./frpc -c ./frpc.ini即可。以上面的例子为例,你访问123.123.123.123:6000,就相当于让运行 frpc 被连接客户端帮你访问127.0.0.0.1:22 这个地址,这时候访问端,也即机器 B 不需要任何配置。

必须注意的是,直接暴露内网端口尤其是 22 端口到公网是非常危险的行为,因为你不能保证内网这台服务器上的所有用户都有强密码,而这个暴露是不区分用户的,你可以用这个端口连接到这个服务器上的任何账户,因此一旦有一个人采用的密码比较弱,一旦被破解,后果不堪设想!所以在任何情况下,除非你非常清晰的知道你在做什么并且可以百分之百保证这样做是安全的,请不要使用这种配置

§ 内网穿透:从原理到代码实现 - 图13

接下来我们再看一种更安全的配置模式,即stcp模式,这种模式的被访问客户端的配置方式如下

  1. [common]
  2. server_addr = 123.123.123.123 # server ip
  3. server_port = 1234 # server port
  4. token = somerandomtoke # same as the token configured in server
  5. tcp_mux = true
  6. [somename]
  7. type = stcp
  8. sk = some_secret_token
  9. local_ip = 127.0.0.1
  10. local_port = 22
  11. use_compression = true
  12. tls_enable = true

各配置项的含义如下:

配置项 含义
type 配置类型,stcp
是一种较安全的配置方式,详情在下面叙述。
sk 一个密码,需要有密码才可以建立连接
local_ip 内网A中的一台机器的IP,如果是本机,就是127.0.0.1
,如果是其他的内网机器,写对应的IP即可
local_port 本地的端口,如果是ssh服务,写22即可(当然也可能是其他的,你没改就是22)
use_compression 启用压缩,可以节省一点点流量,提高一点点速度
tls_enable 启用加密,保证数据传输的安全性(不过如果你都是ssh了讲道理应该挺安全的…)

这种模式下如果要访问这个内网服务器,就不可以直接访问运行 frps 的服务器的某一个端口了,还需要在机器 B 上运行另一个客户端(对应于程序 b),也叫做访问客户端,访问客户端的配置如下所示:

  1. [somevisitorname]
  2. type = stcp
  3. role = visitor
  4. server_name = somename
  5. sk = some_secret_token
  6. bind_addr = 127.0.0.1
  7. bind_port = 10022
  8. use_compression = true
  9. tls_enable = true
配置项 含义
type 配置类型,stcp
是一种较安全的配置方式
role 角色设置,因为是访问端,所以为visitor
server_name 对应于前面被访问段配置文件中[]
里的名字,这里就是somename
sk 一个密码,要和前面被访问端中设置的密码保持一直才可以建立连接,否则认证失败
bind_addr 在访问端绑定的一个地址,一般没特殊情况写127.0.0.1即可,如果希望用于局域网访问可以写0.0.0.0
bind_port 在访问端绑定的端口,在访问段访问bind_addr:bind_port就相当于访问前面被访问端的local_ip:local_port
use_compression 启用压缩,可以节省一点点流量,提高一点点速度
tls_enable 启用加密,保证数据传输的安全性(不过如果你都是ssh了讲道理应该挺安全的…)

配置完成后运行./frpc -c ./frpc.ini即可访问到内网服务器。在这种配置模式下,要连接到内网服务器实际有两层密码保护,一层是frpc客户端连接到frps服务端的密码,另一层是被访问端的sk,这两者缺一不可,这样就保证了一定程度的安全性。

尽管sk的存在使得访问需要密码,已经比较安全了,但是依然强烈不建议你使用公共的frp服务,尽量自建!

如果你希望访问多台服务器,只需要多添加几个字段即可,无需在每一台服务器上都执行frpc客户端,配置如下所示:

  1. [common]
  2. server_addr = xxx.xxx.xxx.xxx
  3. server_port = xxxxx
  4. token = somerandomtoken
  5. [name1]
  6. ... # 配置省略
  7. [name2]
  8. ... # 配置省略
  9. [name3]
  10. ... # 配置省略

记得保证名字不重复即可。(不过建议多搞个备份,不要把鸡蛋放在一个篮子里面 2333)访问端的配置也是类似的,就没必要重复写出来了。

总而言之,frp的功能非常强大,整体模型也比较好理解。但是一定要记清楚,内网穿透可能是非常危险的行为,一定要处处考虑安全!

§ 内网穿透:从原理到代码实现 - 图14

最后虽然本文并不涉及到 P2P 内网穿透,但是还是有必要说一下 frp 是支持 P2P 内网穿透的,P2P 的内网穿透是基于 UDP 协议来实现的,利用了 NAT 的一些原理(本质上 NAT 很类似于动态的端口转发,由内网主动发起连接后会给内网 IP 分配一个临时的对应的网关服务器的端口,然后所有发往这个端口的数据都会被转发到内网对应端口,这样就实现了连接建立以后的双向通信。根据这个转发策略的不同,会有多种不同的类型(静态、动态、锥形、对称形等等),但是这里我们不关心。我们在这里需要知道的就是 P2P 的内网穿透和前面的 TCP 用中间人的内网穿透完成的是同样的功能,但是区别在于中间人只是帮助建立连接,并不用于后续的通信。连接建立以后,两台内网机器之间的通信不需要中间人的参与,因此带宽不受制于中间用作转发的服务器。在 frp 中,基于 P2P 的内网穿透配置和基于 TCP 的差距不大,一个配置示例如下所示:

服务端配置:服务端需要添加一个bind_udp_port字段,指定一个端口号用于UDP通信即可(前面的服务端配置示例中已经包含了)

被访问客户端配置:

  1. [common]
  2. server_addr = x.x.x.x
  3. server_port = xxxx
  4. [somename]
  5. type = xtcp
  6. sk = somerandomtoken
  7. local_ip = 127.0.0.1
  8. local_port = 22

被访问客户端中不需要对应与服务端的bind_udp_port的配置,和之前的stcp模式不同的是这里的type字段的值为xtcp,其余的选项都是一致的(包括压缩、加密之类的)

访问端的配置:

  1. [common]
  2. server_addr = x.x.x.x
  3. server_port = xxxx
  4. [somevisitorname]
  5. type = xtcp
  6. role = visitor
  7. server_name = somename
  8. sk = somerandomtoken
  9. bind_addr = 127.0.0.1
  10. bind_port = 6000

这里和上面基本对应,没啥需要额外说明的。

最后,frp的官方文档非常详细,这里只是一个简单的入门教程,强烈推荐去看官方自己的文档。

四、从工具到实战:实战内网穿透

这一小节来简单介绍一些如何从 socket 开始写一个真正能用的内网穿透小程序,如果感兴趣的话可以看看,如果只是想知道怎么做的话还是没必要看了,这里只会实现一个非常非常非常 naive 的版本,并且完全不考虑安全因素,因此这里的代码看看就好,理解意思就行,只是用于告诉你代码层面大概是怎么做的,而不是让你把这个代码拿去直接用的(因为真的真的真的非常不安全!!!),然后迎合一下主题,从原理到代码实现,哈哈哈哈。

4.1 简介与准备工作

首先这里为了简单起见,就选取 python 作为编程语言,使用 python 自带的 socket 库完成整个过程。因为涉及到同时收发数据,简单起见就用多线程了(当然你也可以尝试更高效的协程以及异步 IO 的组合,但是我还没搞的很明白以及为了简单起见就不这么玩了),因此也会用到 threading 这个库(这俩都是 python 自带的,你啥都不需要安装)

因为涉及到多线程,所以还是简单介绍一下 threading 库的使用,但是会非常简单,想了解更多请考虑查阅官方文档或其他教程。在这里我们用到的的主要就是threading.Thread这个类,通常情况下你可以继承这个类来创建自己的线程,也可以直接用这个类。下面是这个类的定义:

  1. class threading.Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)

其中我们关心的参数一共五个,target 代表你想在另一个线程执行的函数;name 是一个字符串,表示名字,args 就是传给 target 的参数;kwargs 表示传给 target 的关键词参数,就是name=value这种形式的;daemon 表示是否为守护线程,守护线程会随着主线程的结束自动结束,这里我们设置为 True。

接下来我们来简单封装一下 socket,写一个可以接受多个连接的 SocketServer。其实这个 python 里面也有实现了,但是我们还是自己写一个看起来更直观(虽然更垃圾)。

  1. import socket
  2. import threading
  3. import traceback
  4. import time
  5. def parse_address(address):
  6. try:
  7. ip = address.split(':')[0]
  8. port = int(address.split(':')[1])
  9. return ip, port
  10. except:
  11. print('Invalid address [{0}]').format(address)
  12. print('exception information:')
  13. print(traceback.format_exc())
  14. raise ValueError
  15. class BasicTCPServer():
  16. def __init__(self, address="127.0.0.1:12345", handler=None):
  17. self.ip, self.port = parse_address(address)
  18. self.handler = handler if handler is not None else lambda clientsocket, addr:None
  19. self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  20. self.socket.bind((self.ip, self.port))
  21. self.socket.listen(5)
  22. self.socket.settimeout(0.1)
  23. self.terminate = False
  24. def set_handler(self, handler):
  25. self.handler = handler
  26. def handle_message(self, clientsocket, addr):
  27. self.handler(clientsocket, addr)
  28. def loop(self):
  29. try:
  30. while not self.terminate:
  31. try:
  32. clientsocket,addr = self.socket.accept()
  33. t = threading.Thread(target=self.handler, args=[clientsocket, addr], name='Client[{0}]'.format(addr), daemon=True)
  34. t.start()
  35. except socket.timeout:
  36. pass
  37. except:
  38. print('Exception occured. more information is shown below:')
  39. print(traceback.format_exc())
  40. self.socket.close()
  41. def start(self, back=True):
  42. if back:
  43. threading.Thread(target=self.loop, name="mainloop", daemon=True)
  44. else:
  45. self.loop()
  46. def stop(self):
  47. self.terminate = True
  48. self.socket.close()
  49. if __name__ == '__main__':
  50. def echo(clientsocket, clientaddr):
  51. print("Connection established from", clientaddr)
  52. server = BasicTCPServer(handler=echo)
  53. server.start(back=False)

基本没啥可说的,关键内容在 loop 里面,通过不断循环一个 socket.accept 来接收连接,接收到连接以后在新的线程里面调用 handler 函数进行处理,最后面也给了一个非常简单的应用示例,应该不难理解。

然后我们还常用到的一个功能就是转发了,把连接到这个 socket 的请求转发到另一个 socket 里面,建立双向的通信,这个问题也不大,代码如下所示:

  1. class BridgeConnection():
  2. def __init__(self, sock1, sock2):
  3. self.sock1 = sock1
  4. self.sock2 = sock2
  5. self.exit = False
  6. self.sock1.settimeout(1)
  7. self.sock2.settimeout(1)
  8. def forward(self, src, dst, name='None'):
  9. while not self.exit:
  10. msg = None
  11. try:
  12. msg = src.recv(default_recv_buffer)
  13. except (ConnectionAbortedError, ConnectionResetError):
  14. self.exit = True
  15. break
  16. except socket.timeout:
  17. pass
  18. if msg is not None:
  19. if len(msg) == 0:
  20. self.exit = True
  21. break
  22. dst.send(msg)
  23. print('forward {0} terminated.'.format(name))
  24. def start(self):
  25. t1 = threading.Thread(target=self.forward, args=[self.sock1, self.sock2], name='1->2', daemon=True)
  26. t2 = threading.Thread(target=self.forward, args=[self.sock2, self.sock1], name='2->1', daemon=True)
  27. t1.start()
  28. t2.start()
  29. def wait_for_stop(self):
  30. while not self.exit:
  31. time.sleep(1)
  32. self.sock1.close()
  33. self.sock2.close()

4.2 结构设计

因为我们的目的是为了理解内网穿透在代码层面大致是怎么做的,而不是真的要做一个可以实用的工具,因此为了简单起见,我们就不实现配置文件读取、解析以及多个客户端、多个访问目标了,我们仅仅实现支持单个客户端、完全不考虑安全性、只访问单个目标的极简情况下的内网穿透。

现在我们再来回顾一下我们需要干什么。首先我们需要三个程序,分别是中间人作为转发、客户端将中间人转发过来的请求发到指定内网地址以及一个访问端将应用程序的请求发送给中间人。因为我们现在只有一个目标以及一个客户端,因此就不需要费神造通信协议了。一个非常简单的流程大概如下:

  1. 准备:客户端发起到中间人的一个 socket 连接,作为通信,为了区分是客户端还是访问端,连接建立以后客户端发送 b’0’ 作为标识符(注意因为 TCP 是流式协议,你在一头调用一次 send 在另一头调用一次 recv 并不是直观上的收到了另一头 send 的内容,可能多次 send 被一次 recv 收到,也可能一次 send 需要 recv 多次,因此这里为了避免自己定应用层协议分包,就用了一个字节,如果是多个字节或者长度不定写起来会稍微麻烦一些,但是一个字节的头没这个麻烦了哈哈哈哈)
  2. 连接发起:应用程序发起到访问端的 socket 连接,然后访问端收到连接后发起一个到中间人的 socket 连接,在到中间人的 socket 连接建立以后,发送 b’2’ 作为标识符,然后将应用程序发起的连接的 socket 与刚刚建立的到中间人的 socket 连接用前面的BridgeConnection连接到一起。
  3. 中间人控制客户端发起连接: 中间人利用之前客户端和自己建立的连接发送 b’0’ 控制字符,要求客户端建立一个新的 socket 连接用于数据传输。
  4. 客户端发起新的连接到中间人:客户端收到中间人的控制字符后发起一个新的连接到中间人,并且发送 b’1’ 作为标识符,表明这是一个用于数据传输的客户端连接。
  5. 中间人数据转发:中间人收到客户端发起的新的连接后,利用BridgeConnection将这个客户端新建立的连接和访问端之前发起的连接接到一起。
  6. 客户端发起实际请求:客户端无需等到步骤5,步骤四之后可以直接创建到指定内网地址的连接,然后将其发起的到中间人的连接和刚刚发起的到内网地址的连接用BridgeConnection串接到一起。

至此,一个完整的连接通路就可以建立了。

4.3 代码实现

有了前面的结构约定,我们就可以用代码来实现了。这一部分没有什么过多的需要讨论的,就直接放代码了。

§ 内网穿透:从原理到代码实现 - 图15 首先是中间人的代码:

  1. class MiddleMan():
  2. def __init__(self, address):
  3. self.address = address
  4. self.server = BasicTCPServer(address, handler=self.handler)
  5. self.client = None
  6. self.current_client = None
  7. self.current_visitor = None
  8. def start(self, *args, **kwargs):
  9. self.server.start(*args, **kwargs)
  10. def handler(self, sock, addr):
  11. msg = sock.recv(1)
  12. if msg == b'0': # is client controller sock.
  13. self.client = sock
  14. elif msg == b'1':
  15. self.current_client = sock
  16. elif msg == b'2':
  17. self.current_visitor = sock
  18. self.client.send(b'0')
  19. if self.current_client is not None and self.current_visitor is not None:
  20. bridge = BridgeConnection(self.current_client, self.current_visitor)
  21. self.current_client = None
  22. self.current_visitor = None
  23. bridge.start()
  24. bridge.wait_for_stop()

§ 内网穿透:从原理到代码实现 - 图16 然后是客户端的代码:

  1. class Client():
  2. def __init__(self, server_addr, target_addr):
  3. self.ip, self.port = parse_address(server_addr)
  4. self.tip, self.tport = parse_address(target_addr)
  5. self.communter = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  6. self.communter.connect((self.ip, self.port))
  7. def start(self):
  8. self.controller(self.communter)
  9. def controller(self, sock):
  10. sock.send(b'0') # mark as client controller sock.
  11. while True:
  12. msg = sock.recv(1)
  13. if len(msg) == 0:
  14. break
  15. if msg == b'0':
  16. # connect a new socket for forwarding to server.
  17. newsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  18. newsock.connect((self.ip, self.port))
  19. newsock.send(b'1') # mark as client forward sock.
  20. # connect to target addr.
  21. targetsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  22. targetsock.connect((self.tip, self.tport))
  23. # concat connect.
  24. bridge = BridgeConnection(newsock, targetsock)
  25. bridge.start()

§ 内网穿透:从原理到代码实现 - 图17 最后是访问端的代码:

  1. class Visitor():
  2. def __init__(self, bind_addr, server_addr):
  3. self.ip, self.port = parse_address(server_addr)
  4. self.server = BasicTCPServer(bind_addr, handler=self.handler)
  5. def start(self, *args, **kwargs):
  6. self.server.start(*args, **kwargs)
  7. # connection established from process on visitor machine.
  8. def handler(self, sock, addr):
  9. newsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  10. newsock.connect((self.ip, self.port))
  11. newsock.send(b'2') # mark as visitor forward sock.
  12. bridge = BridgeConnection(sock, newsock)
  13. bridge.start()
  14. bridge.wait_for_stop()

§ 内网穿透:从原理到代码实现 - 图18 最后给一个使用的例子:

  1. if __name__ == '__main__':
  2. import sys
  3. mode = sys.argv[1]
  4. print("starting", mode)
  5. if mode == 'mid':
  6. mid = MiddleMan("127.0.0.1:12345")
  7. mid.start(back=False)
  8. if mode == "cli":
  9. cli = Client("127.0.0.1:12345", "192.168.123.101:22")
  10. cli.start()
  11. if mode == "vis":
  12. vis = Visitor("127.0.0.1:5678", "127.0.0.1:12345")
  13. vis.start(back=False)

为了调试方便,三个程序就直接都在本地运行了,在这个例子中,中间人的监听地址是127.0.0.1:12345,客户端主动连接的内网服务器地址就是192.168.123.101:22,访问端绑定的本地地址就是127.0.0.1:5678。如果没什么意外的话,如果在192.168.123.101:22上运行了ssh服务,你在本地执行ssh -p 5678 username@127.0.0.1,就可以访问到192.168.123.101:22上的ssh服务了。

4.4 安全警告

这里是极度简化的例子,有很多功能没有实现,而且协议的指定也是简单的依靠一个字节来避免分包等问题,同时,也完全没有考虑到安全性,比如如果这时候有个人创建一个新的客户端,就可以把原有的客户端给覆盖掉,然后就可以把访问端的请求重定向到一个蜜罐中,从而实现一些目的。这里只是一个简单的显然的漏洞,这里面其他的可以用来攻击的漏洞数不胜数,所以千万千万不要真的拿来用,玩玩可以,如果真的要用,还是尝试一下 frp 之类的成熟的工具吧。

另外,网络编程是一个需要处处考虑安全性的地方,尤其是你从 socket 开始造轮子的时候,你必须要清楚的知道自己在干什么,自己这么写可能会有什么问题,千万不要瞎糊一个能用的代码就直接拿来用,一旦被攻击后果不堪设想。总之,我们的宗旨是,写个玩具拿来玩是好的,但是千万不要真的在生产环境应用。