使用Node实现简单的内网穿透

7,882 阅读10分钟

前言

最近在维护一款内网穿透的工具,我发现市面上的内网穿透工具很多,包括 NgrokLocaltunnelFRP ,他们的实现原理都差不多。我一开始认为内网穿透可以通过 Nginx 实现,但是通过对一些内网穿透工具原理的分析我才发现我理解得太简单了,接下来我会讲下内网穿透工具的原理,并用 Node 实现一个内网穿透工具。

什么是内网穿透

内网穿透,即 NAT 穿透,简单来说就是让外网能够访问本地的服务。

外网 IP 比较昂贵,企业虽然有少量独立的外网 IP,但是出于成本限制无法为每一台主机提供一个外网 IP,或者出于安全限制并不是所有的服务都需要暴露到外网中。所以企业就有可能使用 NAT 技术将大量内网 IP 通过一定规则映射到少量的外网 IP 上。

通过 NAT 技术,我们可以访问到外部网络资源,但是弊端是外网并不能向内网主机发起请求,这时候就要借助于内网穿透服务。

什么是 NAT 技术

NAT 网络地址转换

上面提到了 NAT 技术,什么是 NAT 技术?NAT(Network Address Translation,网络地址转换)简单来说就是将大量内网 IP 通过一定规则转换为少量外网 IP,实现内网用户能够访问外部资源的技术。一般是在路由器上搭建 NAT 网关,网关上维护内网 IP 与外网 IP 的映射表。NAT 技术的实现方案有许多中,下面介绍比较常用的一种。

NAPT 网络地址端口转换

NAPT(Network Address Port Translation,网络地址端口转换)是 NAT 技术的一种,因为一个 IP 可以对应多个 端口,所以通过多个内网 IP 跟单个外网 IP 的多个端口映射关系实现信息通讯。

NAPT 实现原理是这样的,举个例子,假如内网有两台机器分别是:192.168.1.1、192.168.1.2,路由器上的外网 IP是:162.105.178.62 。当 192.168.1.1 这台机器访问外网的时候,路由器上的 NAT 网关会自动分配一个端口 6666,同时保存端口跟内网 IP 的映射关系(6666 -> 192.168.1.1)。路由就会以 162.105.178.62:6666 的方式访问外部资源,当外部资源有所响应的时候数据也会发送到 162.105.178.62:6666 这个地址,这时候就会根据端口查 NAT 网关映射表,发现 6666 端口跟 192.168.1.1 的映射关系,把资源返回到 192.168.1.1 这台机器上,如下图所示:

举个更通俗的例子,出去购物,商家至少要知道所在的小区和门牌号才能为我送货,货物送到小区物业那,小区物业根据门牌号送到我家,这时候我所在的小区就是外网 IP,门牌号就是端口,物业就是 NAT 网关,我家就是内网机器。

NAT 的局限

但是 NAT 技术也有局限,外网无法主动向内网机器发起请求,不过也是出于安全性的考虑。上面的过程中只有内网先发起请求,NAT 网关才会建立映射关系,没有了映射关系内外网的通道就无法打通了。

就好比是门牌号是变化的,物业在出门前才发放门牌号,外来人没有门牌号无法找到我家。

跟 VPN 的区别

VPN(Virtual Private Network,虚拟专用网络),通常用来做两件事情,一个是可以让世界上任意2台机器进入一个虚拟的局域网中,一个是可以用来翻墙。

实现原理是通过操作系统虚拟一张网卡,虚拟网卡会跟 VPN Server 协商得到一个虚拟的局域网 IP ,数据经过网卡进行拦截,通过加密封装后转发给 VPN Server,如果是虚拟局域网用户之间的通讯就把请求根据虚拟局域网的 IP 进行转发就可以了,如果是为了翻墙的话,假如访问谷歌,VPN Server 会去修改数据的源 IP 为 VPN Server 的 IP 地址,然后将数据发给谷歌实现数据互相通讯,这点跟 NAT 很类似,如下图所示:

这么说 VPN 也可以实现内网穿透的功能,但是跟接下来要讲的内网穿透实现有区别:

  • 用户的使用的成本有点大,用户为了访问内网的服务需要安装 VPN 客户端来虚拟网卡,这点不如为内网机器提供一个外网 IP 方便
  • 使用场景不太一致,VPN 能够提供内网机器的所有端口服务,然而接下来要实现的内网穿透主要是为单台机器单个端口提供服务,所以 VPN 一般用于虚拟办公环境而不是用于提供单一的服务

为什么不用Nginx实现

首先Nginx能做什么?负载均衡和动静分离就不多说了,偏题了,说说 Nginx 作为代理的功能:

正向代理

正向代理是将请求转发到对应的服务器上,但Nginx 部署的代理服务器和目标服务器都不在一个网络环境,都有各自的外网 IP。这跟我们要实现的是为目标服务器提供一个外网 IP的需求不符。

反向代理

反向代理,也就是将外部的请求转发到内部的服务器。反向代理可以实现内网穿透的功能,但前提是内部服务器跟 Nginx 部署的代理服务器在同一个网络环境,如下图所示:

其他场景

上面说了通过反向代理,也可以实现内网穿透的功能,但更多的情况是代理服务器跟内部服务器部署在不同的环境,这种情况下 Nginx 就无法满足了。所以接下来讨论并实现的是这种 Nginx 无法满足的情况。

实现原理

要实现内网穿透,前提是要有一个能够访问外部资源的服务器作为代理,如果这台服务器也同时处在内网环境中,那通过 Nginx 的反向代理就能够实现。但现实是服务器所处的网络都相互独立,代理服务器跟内部服务器之间的桥梁没有打通。但是可以通过建立 Socket 通讯隧道方式建立代理服务器和内部服务器之间通讯,再由代理服务器访问外部资源传达给内部服务器。

内网穿透工具一般分为 Server 端还有 Client 端。Server 端部署在代理服务器上,主要负责管理 Client 端的通讯,将请求分发到对应的 Client 端;Client 端部署在本地机器上,一般是随启随用的,主要负责把 Server 端的请求分发到对应的本地服务上,下图简单描述了下通域名映射的方式实现的内网穿透流程:

这种实现方式是不是跟 NAT 的实现原理异曲同工。那么我们延续 NAT 的流程也举个通俗的例子来解释下,小区的服务升级了,外包了一个服务站点,外人想要访问你家需要你去服务站点提前填写申请表格,当外人到小区的时候查询下服务站点的表格就知道你家的位置了,这里的服务站点就是内网穿透工具,申请表格就是 Server 端和 Client 端的映射关系。

下面通过域名的映射方式说下实现过程:

准备阶段

假如本地的 8000 端口服务需要内网穿透服务,首先要起 Client 端来建立本地 8000 端口服务跟 Server 端的通讯连接,Client 端会发起一个请求给 Server 端,Server 端收到请求会去创建一个跟 Client 端的通讯通道(一般是 Socket 连接)并分配一个子域名假如是 a.example.com,Server 端会保存子域名映射关系(a.example.com -> Client 端 Socket 连接),最后将子域名返回给 Client 端。准备阶段完毕,访问 a.example.com 就相当于访问本地 8000 端口服务。

请求阶段

当访问 a.example.com 的时候,请求首先会到 Server 端,通过查询子域名映射关系找到对应的 Socket 连接,通过 Socket 将请求返回给本地 8000 端口服务,待处理完毕再原路返回。

销毁阶段

当关闭 Client 端的时候,Server 端通讯通道(Socket连接)会被关闭,这时候需要销毁相应的域名映射关系。

用Node实现一个版本

使用Node实现一个简单版本的内网穿透工具。

为了方便演示就不做域名的映射了,直接将子域名作为 URL 参数传递

首先实现 Server 端的代码,创建一个 Server 服务,当有 Client 端连接被建立的时候随机生成一个子域名,保持在 map 映射表中,并把子域名返回给 Client 端。

// server.js
const net = require('net');

// 随机获取4个字符
const getRandomStr = () => {
    const chars = 'abcdefghijklmnopqrstuvwxyz';
    let result = '';
    for (var i = 4; i > 0; --i) {
        result += chars[ Math.floor( Math.random() * chars.length ) ];
    }
    return result;
};

// 获取子域名
const getSubdoman = () => {
    const subdoman = getRandomStr();
    if (map[subdoman]) {
        return getSubdoman();
    } else {
        return subdoman;
    }
};

// 映射表
const map = {};

// 建立 Server 端与 Client 端连接
const server = net.createServer(socket => {
    // 随机获取子域名
    const subdoman = getSubdoman();
    // 保存子域名跟 Socket 连接映射
    map[subdoman] = socket;
    // 返回子域名给 Client 端
    socket.write(`Tunnel-Subdoman: ${subdoman}`);
});

server.listen(9999, () => {
    console.log('start server');
});

然后来实现 Client 端代码,下面假设本地服务的端口号为 8888,建立本地服务连接与 Server 端连接,当收到请求时判断 Server 端是否返回子域名,如果返回的话,输出子域名,否则将请求传递给本地服务。

// client.js
const net = require('net');

// 建立跟本地服务连接
const local = net.createConnection({
    port: 8888
});

// 建立跟 Server 端的连接
const client = net.createConnection({
    port: 9999
});

// Client 端收到请求
client.on('data', data => {
    const content = data.toString();
    // 判断数据是否是子域名
    if (content.indexOf('Tunnel-Subdoman') !== -1) {
        // 输出子域名
        console.log(`${content}`);
    } else {
        // 将数据转发给本地服务
        local.write(data);
    }
});

// 本地服务将处理结果返回给 Client 端
local.on('data', data => {
    client.write(data);
});

这样虽然打通了 Server 端跟多个 Client 端的通讯,但是外部请求还是无法传递到 Server 端,所以还需要起一个 Web 服务,由于接受外部请求并通过查询映射表,将请求准确的传递到对应的 Client 端。

// server.js
...

// 获取url上子域名参数
const getSubdomanFromUrl = data => {
    const content = data.toString();
    const match = content.match(/tunnelSubdoman=(\w*)/);
    if (match) {
        return match[1];
    }
};

// 接收外部请求
const web = net.createServer(socket => {
    let subdoman = '';
    socket.on('data', chunk => {
        if (!subdoman) {
            // 从协议内容中获取获取子域名参数
            subdoman = getSubdomanFromUrl(chunk);
            // 将 Client 端处理完的内容返回
            map[subdoman].pipe(socket);
        }
        // 将请求转发给 Client 端
        map[subdoman].write(chunk);
    });
});

web.listen(9997, () => {
    console.log('start web');
});

注意:以上代码仅做演示不能用于生产环境

启动 Server 端,再启动 Client 端(记得要起本地6666端口服务):

node server.js
// start server
// start web
node client.js
// Tunnel-Subdoman: esrs

上面 Client 端输出 esrs 即子域名,这里主要是为了方便没有没有通过域名去做映射,直接所以通过 URL 参数去传递,访问 http://127.0.0.1:9997?tunnelSubdoman=esrs 就相当于访问本地的 6666 服务了。

写在最后

以上代码仅做演示,不能用于生产环境,因为还有许多需要完善的地方。上面的 Client 端跟 Server 端的 Socket 很容易断线,但是没有重连的机制, 而且只实现了 TCP 的转发并没有实现 UDP 的转发。如果大家有更好的实现方案或者文章有任何纰漏欢迎和我探讨和学习。

参考阅读: