Atlantis
GitHub 切换暗/亮/自动模式 切换暗/亮/自动模式 切换暗/亮/自动模式 返回首页

7. 内网穿透

Created At 2024-07-24 Updated on 2025-10-25

1. 前言

内网穿透可能是国内发明的一个词,在英文资料里我只查到 NAT Traversal,也就是 NAT 穿透。

在上一篇 wiki 中我们解决了出向流量的问题,这里就要解决入向流量的问题:

  1. 如何从外网访问内网的 Proxmox VE 控制台、LXC 容器与 KVM 虚拟机
  2. 如何将 LXC 容器与 KVM 虚拟机上的服务暴露到公网

内网穿透的方案有很多,在我看来主要分为两种:

  1. 依赖中继节点转发流量(relay):IPsec VPN、wireguard、ngrok、frp 等都是这种架构,需要一个网络条件良好的中继节点才能提供稳定的服务
  2. 利用 NAT 打洞直连(direct):依赖网络环境,目前测试下来节点双方具备公网 IPv6 地址时,基本可以稳定直连

下面是我常用的三个内网穿透工具。

2. gost

gost 是一个 golang 开发的安全隧道,工作在应用层,它的特点就是支持多种传输协议如:Websocket、KCP、QUIC、H2c、TLS,可以根据使用环境切换协议,将我们的流量通过多种方式传输,我最常用的是 Websocket。

我一直有从外网访问内网设备的需求,以前甚至专门写过一篇博客:使用V2ray反向代理实现内网穿透,当时觉得配置复杂,现在看来依旧复杂,不过有了 gost 后,利用 Websocket 转发实现内网穿透变得简单了。

这里使用 gost 的远端端口转发功能来实现内网穿透:端口转发,gost 的端口转发功能类似于 ssh,服务端只是普通的 gost 代理服务器,具体端口映射关系在 gost 客户端上设置。

假设客户端本地有一个 HTTP 服务需要暴露到公网,它监听 8080 端口,访问该服务会返回一个时间戳,服务端 VPS 的公网 IP 地址为 118.194.255.155。

2.1 基础用法

网络结构如下,虚线部分是用远程端口转发连接的双向通道,外部浏览器通过双向通道访问到本地的 HTTP 服务。

flowchart TD
    subgraph 客户端 gost
        A1[gost]
        A2[tcp://127.0.0.1:8080]
    end
    subgraph VPS
        subgraph 服务端 gost
            G1[wss://118.194.255.155:12345?path=/ReverseService]
            G2[rtcp://:58080]
        end
    end
    A1 --> A2
    A1 <-.-> G1
    G1 <-.->G2
    B --> G2
    B[浏览器]

服务端使用 Websocket 作为传输协议(传输协议使用 wss,启用 gost 内置证书加密传输层),监听 12345 端口,Path 使用 ReverseService,如下:

gost -L wss://:12345?path=/ReverseService

客户端上使用 rtcp 设置端口映射格式为 gost -L rtcp://远程地址:远程端口/本地地址:本地端口 -F wss://118.194.255.155:12345?path=/ReverseService,远程地址也可不设置,表示监听所有地址端口,这里希望将 VPS 的 58080 端口映射到本地的 8080 端口,则命令如下:

gost -L=rtcp://:58080/127.0.0.1:8080 -F wss://118.194.255.155:12345?path=/ReverseService

下面是服务端与客户端的截图,可以看到客户端成功连接上服务端,并设置了 58080 端口映射。

alt text

使用浏览器访问服务端的 IP + 58080 端口,就可以接收到本地 HTTP 服务的响应:

alt text

如果需要同时暴露多个端口,比如将服务端的 58082 映射到客户端本地的 22 端口,客户端命令新增一个 rtcp 参数即可,如下:

gost -L=rtcp://:58080/127.0.0.1:8080 -L=rtcp://:58082/127.0.0.1:22 -F wss://118.194.255.155:12345?path=/ReverseService

2.2 进阶用法

基础用法只适合临时使用,要长期运行还需要做一些调整:

  1. 容器化部署,用 Docker 管理服务端程序
  2. 利用反向代理(如 caddy、nginx)转发客户端请求,这样可以使用域名访问服务端 gost 和本地 HTTP 服务 ,减少不必要的端口暴露
  3. 添加账号密码验证 Websocket 请求

网络结构变更如下:

flowchart TD
    subgraph 客户端 gost
        A1[gost]
        A2[tcp://127.0.0.1:8080]
    end
    subgraph VPS
        subgraph caddy
            C1[gost.wbuntu.com:443]
            C2[ts.wbuntu.com:443]
        end
        subgraph 服务端 gost
            G1[ws://127.0.0.1:12345?path=/ReverseService]
            G2[rtcp://127.0.0.1:58080]
        end
    end
    A1 --> A2
    A1 <-.-> C1
    C1 <-.-> G1
    G1 <-.->G2
    B --> C2
    C2 --> G2
    B(浏览器)

配置 caddy 服务端容器

这里使用一个域名 gost.wbuntu.com 解析到服务端 IP 供客户端连接,证书使用 Let’s Encrypt 签发的泛域名证书,caddy 的配置文件如下:

gost.wbuntu.com {
	root * /usr/share/caddy
	file_server
	tls /etc/cert/wbuntu.crt /etc/cert/wbuntu.key
	log {
		output file /var/log/caddy.log
	}
	reverse_proxy /ReverseService http://localhost:12345
}

配置文件保存为 Caddyfile,使用以下脚本管理 Caddy 容器:

#!/bin/bash

# 容器镜像
image="caddy:2"

# 容器名称
container="caddy"

# 检查容器是否存在
if [ "$(docker ps -a -q -f name=$container)" ]; then
    # 如果容器未处于停止状态,则停止容器
    if [ "$(docker ps -q -f name=$container)" ]; then
        docker stop $container
    fi
    # 删除容器
    docker rm $container
fi

# 启动容器: 容器名称-> 是否后台运行 -> 重启策略 -> 网络类型 -> 环境变量 -> 挂载项 -> 镜像名
docker run --name $container -d \
    --restart unless-stopped \
    --network host \
    -v $PWD/Caddyfile:/etc/caddy/Caddyfile \
    -v $PWD/log:/var/log \
    $image

这里会使用宿主机网络运行 caddy,占用宿主机 的 80 和 443 端口。

启动 gost 容器

我们已经在 caddy 上处理了 TLS 流量,这里 gost 只需要处理 Websocket 流量即可,使用 ws 传输协议,为了安全起见设置一个随机生成的用户密码,如下:

gost -L ws://MYLRjUUGHmxl:9hpSHIapbTXOuc@127.0.0.1:12345?path=/ReverseService

完整格式为 ws://user:password@ip:port?path=/CustomPath,这里设置用户名为 MYLRjUUGHmxl,密码为 9hpSHIapbTXOuc,监听服务端本地 IP 的 12345 端口,将它转换为配置文件如下:

{
    "ServeNodes": [
        "ws://MYLRjUUGHmxl:9hpSHIapbTXOuc@127.0.0.1:12345?path=/ReverseService"
    ]
}

将文件保存为 config.json,使用以下脚本管理 gost 容器:

#!/bin/bash

# 容器镜像
image="wbuntu/gost:v2.11.5"

# 容器名称
container="gost"

# 检查容器是否存在
if [ "$(docker ps -a -q -f name=$container)" ]; then
    # 如果容器未处于停止状态,则停止容器
    if [ "$(docker ps -q -f name=$container)" ]; then
        docker stop $container
    fi
    # 删除容器
    docker rm $container
fi

# 启动容器: 容器名称-> 是否后台运行 -> 重启策略 -> 网络类型 -> 环境变量 -> 挂载项 -> 镜像名
docker run --name $container -d \
    --restart unless-stopped \
    --network host \
    -v $PWD/config.json:/etc/gost/config.json \
    $image

注意这里也使用宿主机网络,方便客户端进行端口映射。

配置 gost 客户端

现在使用域名连接服务端,将 VPS 的 58080 端口映射到本地的 8080 端口,命令如下:

gost -L=rtcp://127.0.0.1:58080/127.0.0.1:8080 -F wss://MYLRjUUGHmxl:9hpSHIapbTXOuc@gost.wbuntu.com:443?path=/ReverseService

这里相比上一节中的命令行,rtcp 参数增加了远程地址,占用服务端本地 IP 的 58080 端口,连接服务端的参数增加了用户名、密码,服务端的端口和地址也改为域名和 443 端口,将它转换为配置文件如下:

{
  "Routes": [
    {
      "ServeNodes": [
        "rtcp://127.0.0.1:58080/127.0.0.1:8080"
      ],
      "ChainNodes": [
        "wss://MYLRjUUGHmxl:9hpSHIapbTXOuc@gost.wbuntu.com:443?path=/ReverseService"
      ]
    }
  ]
}

保存成 config.json 文件后,可以执行 gost -C config.json 指定配置文件运行。

使用域名暴露内网服务

现在服务端上已经有了 caddy,而且 58080 端口已经映射到本地的 8080 端口,如果此时新增一个域名 ts.wbuntu.com 解析到服务端 IP,并在 caddy 中将请求全部转发 58080 端口,就可以将访问域名的流量转发到本地,Caddyfile 修改如下:

gost.wbuntu.com {
	root * /usr/share/caddy
	file_server
	tls /etc/cert/wbuntu.crt /etc/cert/wbuntu.key
	log {
		output file /var/log/caddy.log
	}
	reverse_proxy /ReverseService http://localhost:12345
}

ts.wbuntu.com {
	tls /etc/cert/wbuntu.crt /etc/cert/wbuntu.key
	log {
		output file /var/log/ts.log
	}
	reverse_proxy http://localhost:58080
}

重启 caddy 容器后,使用浏览器访问域名就可以获取到本地 HTTP 服务响应:

alt text

2.3 更进一步

gost 最强大的功能就是随心所欲的流量转发,如果将本地 8080 端口的 HTTP 服务替换为一个 gost 服务端,那么外部用户就可以通过域名访问到本地的代理服务,然后使用本地网络环境访问内网服务。

由于我们使用 Websocket 作为传输协议,因此只需要使用不同的 Path 区分客户端,无需新增域名,网络结构如下:

flowchart TD
    subgraph 客户端 A
        A1[gost agent]
        A2[gost server ws://127.0.0.1:8080?path=/GOSTServiceA]
    end
    subgraph 客户端 B
        B1[gost agent]
        B2[浏览器]
    end
    subgraph VPS
        subgraph caddy
            C1[gost.wbuntu.com/ReverseService]
            C2[gost.wbuntu.com/GOSTServiceA]
        end
        subgraph 服务端 gost
            G1[ws://127.0.0.1:12345?path=/ReverseService]
            G2[rtcp://127.0.0.1:58080]
        end
    end
    A1 --> A2
    A1 <-.-> C1
    C1 <-.-> G1
    G1 <-.->G2
    B2 --> B1
    B1 --> C2
    C2 --> G2

在 Chrome 浏览器中使用 SwitchyOmega 插件切换代理即可访问到内网设备,ssh 配置文件中也可以使用 ProxyCommand 连接 socks5 代理来跳转到内网服务器。

3. Cloudflare Tunnel

如果你的域名托管在 Cloudflare,那 Cloudflare Tunnel 也许能满足内网穿透需求,它通用需要中继节点转发流量,稳定性取决于客户端到 Cloudflare 边缘节点的网络状况,能否顺利使用需要实操测试。

进入 Cloudflare 控制台的 Zero Trust,在 Networks 中可以找到 Tunnel,如下:

alt text

选择 Create a tunnel 后,就会引导你创建并命名 Tunnel,然后显示安装 cloudflared 的页面,我们可以直接安装二进制文件,也可以通过 Docker 部署:

alt text

部署完成后回到 Tunnel 列表页面,点击 Connector ID 可以进入设备详情,这里是我的一台 armbian 系统小主机,部署在老家的卧室中。

alt text

这台 armbian 主机上运行着随机密码生成器,通过 Cloudflare Tunnel 暴露到了公网:random.wbuntu.com

主机上的 cloudflared 能自动从远端同步转发规则到本地,只要在 Cloudflare Tunnel 的配置页面中设置了域名和转发目标后就可以使用域名访问本地服务。

alt text

注意事项

在中国大陆使用 Cloudflare Tunnel 时,可能会遇到一些网络问题,因为新版本默认使用 QUIC 作为传输协议,底层 UDP 连接的丢包率较大,目前还可以在运行 Cloudflared Tunnel 时设置参数 --protocol http2 切换到老版本默认的 HTTP2 协议,但不排除后面 Cloudflare 会彻底移除掉 HTTP2 支持,使用 Docker 运行 cloudflared 的命令行配置如下:

docker run -d --name cloudflared --restart unless-stopped --network host cloudflare/cloudflared:latest tunnel --no-autoupdate run --protocol http2 --token eyJhIjo...

4. Tailscale

4.1 什么是Tailscale

Tailscale 是一款基于 WireGuard 协议的 VPN 服务,最大的特点就是支持点对点直连,NAT 打洞时也有较高的成功率,如果两个节点可以通过公网 IP 或者 NAT 打洞实现直连,就无需中继服务器转发流量,此时网络延迟也会降到最低。

alt text

Tailscale 在 NAT 穿透方面做了大量工作,实现了稳健的 NAT 遍历,详细内容参考官方文档:how-nat-traversal-works

总得来说,还是需要一个中心服务器用于协调节点,优先尝试直连,无法直连时就回退到使用中继服务器。我觉得 Tailscale 的风险也在于中心服务器,假设未来在国内 Tailscale 的中心服务器被封锁,那所有节点都会失联,好在还有开源代替方案:headscale

4.2 启用直连的前置条件

直连成功时,在节点上执行 tailscale status 能看到 direct 关键字,下图是我的 iStoreOS 旁路由直连到一台外地的 armbian 主机:

alt text

按照以往使用经验,同时具备公网 IPv6 地址的两个节点通常可以稳定直连,目前在国内获取公网 IPv6 地址途径有以下几种:

  1. 手机使用 4G/5G 网络:分配一个运营商内网 IPv4 地址和一个动态公网 IPv6 地址
  2. 手机热点共享 4G/5G 网络给其他设备:笔记本、平板连接手机热点时,会分配到一个热点内网 IPv4 地址和一个动态公网 IPv6 地址
  3. 使用光纤接入的家庭宽带:每个光猫都会获取一个运营商内网 IPv4 地址和一个掩码为 64 的公网 IPv6 地址段,将无线路由器配置为有线中继模式接入光猫 LAN 后,就能为家里的设备分配 IPv6 公网地址

经过测试,前两种方案默认没有限制主动入向请求,我可以直接从一个具备 IPv6 地址的国外 VPS 上 ping 通手机的 IPv6 地址。

第三种方案默认限制主动入向请求,只能由内网设备主动对外发起连接,不过也有办法解除,以联通的光猫为例,需要在 安全->防火墙 中关闭 Ipv6spi 放行来自广域网的主动请求 (不建议开启广域网访问,会将内网设备直接暴露到公网)

alt text

在用上 Tailscale 前,我尝试过关闭 Ipv6spi,并在 iStoreOS 旁路由上部署了个定时上报本地 IP 的程序,这样就可以从服务端获取 iStoreOS 的公网 IPv6 地址主动发起连接,可惜我的服务端运行在 Oracle Cloud 美国西海岸机房,即使直连也有接近 200ms 的延迟,最可行的方案是购买一个具备 IPv6 地址的国内服务器,后来因为费用作罢。

4.3 Tailscale使用场景

最常见的使用场景是在外面访问家里 Web 服务、网络存储等,以 iPhone 手机使用 Tailscale 为例。

alt text

接入 Tailscale 网络的设备都会分配一个内网 IP (100 开头的 IP)和一个内网域名(默认使用设备名作为子域名),设备间可以通过内网 IP 和内网域名互访,上面的截图中就使用内网域名访问 iStoreOS 的控制台。

这台 iStoreOS 上部署了 PassWall 提供透明代理,并且里面的 tailscaled 启用了 Exit Node 功能,允许路由 192.168.1.0/24 网段。 当 iPhone 选择 iStoreOS 作为 Exit Node 后,就具备以下功能:

  1. 全部流量都经过 iStoreOS 路由,可以利用 PassWall 透明代理访问国内外网站
  2. 通过 iStoreOS 的路由访问设备家里同子网 192.168.1.0/24 下的设备

之前出门去北方旅行时,经常遇到代理服务网络不稳定,这时开启 Tailscale 就可以借用家里的光纤宽带网络稳定访问国外网站。

4.4 安装与配置Tailscale

Tailscale 的安装配置并不繁琐,iStoreOS 的应用商店中可直接安装 Tailscale,手机的应用商店里也可以直接下载 App,注册并登录后即可使用,这里记录下一些注意事项。

alt text

4.4.1 登录Tailscale

Tailscale 支持多种登录方式,Google 排在了第一个,但在手机上开启 Tailscale 登录账号时,默认会切换到 Tailscale VPN,原有代理 VPN 如 shadowrocket、surfboard 失效,紧接着就卡在登录,原因还是谷歌被墙,幸好家里还有一个旁路由,临时切换手机 Wi-Fi 网关后才就可以登录。

如果没有办法解决登录问题,建议选择微软、苹果账号登录。

alt text

4.4.2 禁用密钥过期

设备登录 Tailscale 账号成功后,默认开启密钥过期,过期后需要重新登录,可以在 tailscale 控制台上选择设备关闭禁用密钥过期:

alt text

4.4.2 iStoreOS配置

iStoreOS 上通过应用商店安装的 Tailscale,完整配置文件保存在:/etc/tailscale/tailscaled.state,需要确认配置的话可以检查文件,需使用 base64 解码 _daemon 字段的值。

在 iStoreOS 上安装好 Tailscale 后,它只具备节点间互访以及路由公开网段的能力,如下:

alt text

在全局设置中:

  1. 允许组网对应 tailscale up 命令行的 --accept-routes 选项,控制是否接收其他节点广播的子网路由,这里就不开启了
  2. 公开网段对应 tailscale up 命令行的 --advertise-routes 选项,可以在 tailscale 内网中广播指定子网路由,这里设置为 iStoreOS 所在的家庭内网网段 192.168.1.0/24

我们还需要开启 Exit Node,即出口节点,出口节点可以路由所有非 Tailscale 流量,让我们使用该节点网络访问外网,这里需要登录 iStoreOS,执行以下命令:

tailscale up --reset --advertise-exit-node --advertise-routes 192.168.1.0/24

--advertise-exit-node 标志位控制是否开启出口节点功能,操作完成后还需登录 Tailscale 控制台,选中 iStoreOS 节点点击 Edit Route Settings,批准 Subnet routesExit node 设置:

alt text