上周心血来潮买了台腾讯云的 Lighthouse 服务器,想着随便部署点小玩具,于是被折磨了三四天。

Uptime Kuma

Uptime Kuma 是一个开源的监控面板,用于监控网站、服务器和各类网络服务的可用性。可以使用 Docker 快速部署并映射至宿主机端口,这里还是非常简单的。

1
2
3
4
5
6
7
8
9
services:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    container_name: uptime-kuma
    volumes:
      - ./uptime-kuma-data:/app/data
    ports:
      - 3001:3001
    restart: always

这样就可以通过 IP+端口访问到网站。下一步自然是要把网站挂到域名上,由于DNS只能解析到 IP,浏览器会默认访问服务器的80/443端口(当然也有 https://example.com:8080这样手动指定端口的访问方式,但过于丑陋了),所以我们还需要配置 Nginx 反向代理,监听80/443端口并把请求转发至对应的内部端口。这确实可行,但很快就被腾讯云ban了,因为我的域名没有备案:( 备案属实有点麻烦了,而且我的域名后缀似乎还不支持备案。这样一来,我就不能使用服务器的公网 IP作为域名解析的目标了,需要另寻出路,也就是内网穿透。

Cloudflare Tunnel

一点网络基础知识补充

IP(Internet Protocol)地址是分配给连接到网络之中的设备的数字标签。其核心功能有两个:标识主机(是谁)和指示地理位置(在哪)。

公网IP与私网IP

由于IPv4地址资源有限(约42.9亿个),全球网络被划分为公网(Public Network)和私网(Private Network,即内网):

  • 公网IP:在互联网上具有唯一性,由ICANN等机构统一分配。拥有公网IP的设备可以直接被全球任何接入互联网的设备访问。

  • 私网IP:仅在局部区域(如家庭、公司、学校)内部有效。常见的私网频段包括 192.168.x.x、10.x.x.x 和 172.16.x.x。这些地址在不同的局域网内可以重复使用。

NAT(网络地址转换)机制

大多数终端设备(手机、电脑)并不直接拥有公网IP,而是通过路由器接入互联网。路由器拥有一个公网IP,并利用NAT技术为内网设备分配私网IP。 当内网设备请求外部数据时,路由器会将数据包的源IP从“内网私有地址”修改为“路由器的公网IP”,并记录一个映射关系(端口号);当外部数据返回时,再根据映射表转发给对应的内网设备。 显然,内网设备访问公网时,只需要知道那个公网 IP就可以正常进行访问;而公网设备无法通过一个内网 IP找到特定的设备。

内网穿透

内网穿透(Intranet Penetration),专业术语叫NAT穿透(NAT Traversal),简单来说,就是让公网(互联网)上的设备,能够访问到内网(局域网)中的特定设备。虽然我的服务器拥有自己的公网 IP,但实际上无法用于域名解析,因此在这里也等同于一个内网设备。我需要通过内网穿透来让域名最终能够访问到服务器中的内容。 那么,有什么手段可以实现内网穿透呢?这里只介绍反向代理与隧道。

反向代理 摘自Cloudflare文档

反向代理是位于 Web 服务器前面的服务器,其将客户端(例如 Web 浏览器)请求转发到这些 Web 服务器。反向代理通常用于帮助提高安全性、性能和可靠性。为了更好地理解反向代理的工作原理以及它可以提供的好处,我们来首先定义什么是代理服务器。

什么是代理服务器

转发代理,通常称为代理、代理服务器或 Web 代理,是位于一组客户端计算机之前的服务器。当这些计算机向互联网上的站点和服务发出请求时,代理服务器将拦截这些请求,然后代表客户端与 Web 服务器进行通信,起到中间设备的作用。

例如,典型的转发代理通信中涉及 3 台计算机:

  • A:这是用户的家用计算机
  • B:这是一个转发代理服务器
  • C:这是网站的源站(用于存储网站数据)

正向代理流:流量从用户的设备 (A) 到正向代理 (B) 到互联网到源服务器 (C)

在标准的互联网通信中,计算机 A 将直接与计算机 C 保持联系,客户端将请求发送到源服务器,并且源服务器将响应客户端。当存在转发代理时,A 将请求发送到 B,B 随后将请求转发给 C。C 将向 B 发送响应,而 B 则将响应转发给 A。

反向代理有何不同

反向代理是位于一个或多个 Web 服务器前面的服务器,拦截来自客户端的请求。这与转发代理不同 - 在转发代理中,代理位于客户端的前面。使用反向代理,当客户端将请求发送到网站的源服务器时,反向代理服务器会在网络边缘拦截这些请求。然后,反向代理服务器将向源服务器发送请求并从源服务器接收响应。

转发代理和反向代理之间的区别非常细微,但非常重要。简单概括而言,转发代理位于客户端的前面,确保没有源站直接与该特定客户端通信;而反向代理服务器位于源站前面,确保没有客户端直接与该源站通信。

这一次,所涉及的计算机包括:

  • D:任意数量的用户家用计算机
  • E:这是反向代理服务器
  • F:一台或多台源站

反向代理流:流量从用户的设备 (D) 到互联网到反向代理 (E) 到源服务器 (F)

通常,来自 D 的所有请求都将直接发送到 F,而 F 会直接将响应发送到 D。使用反向代理,来自 D 的所有请求都将直接发送给 E,而 E 会将其请求发送到 F 并从 F 接收响应,然后将适当响应传递给 D。

隧道

隧道(Tunneling) 是一种通过使用一种网络协议作为传输载体,来承载另一种协议数据的技术,其本质是协议封装(Protocol Encapsulation)。 隧道操作的核心步骤分为三个阶段:

  1. 封装 (Encapsulation): 隧道入口节点接收到原始数据包(称为 Payload,载荷)。它不解析载荷的内容,而是将其作为数据部分,在其外面包裹一个新的报头(称为 Delivery Header,传输报头)。

  2. 传输 (Transit): 封装后的数据包在公共网络(或中间网络)中路由。中间节点(如路由器)只检查“外层”报头,并将其转发到隧道终点。

  3. 拆封 (Decapsulation): 隧道出口节点收到数据包,剥离外层传输报头,还原出原始数据包,并将其转发到最终的目的地址。 简单来讲,隧道就是将你的源数据进行套娃,发往中转服务器,从而实现数据的加密。

基于反向隧道的内网穿透

很显然,如果我们拥有一台具有公网 IP的中转服务器,就可以将域名先解析至中转服务器,让中转服务器继续转发至源服务器,从而实现对源服务器的间接访问。那么我们要如何搭建中转呢?当然是通过伟大的Cloudflare。 比较普遍的方案是将域名的 DNS 解析托管到 Cloudflare 之后,添加一条Origin Rules,设置转发至服务器的实际端口。这允许用户将 443 端口的请求发送至 Cloudflare 边缘节点,然后将其转发到你服务器的任意自定义端口。这是最经典的反向代理方案,但依旧需要源服务器开放特定的入站端口,也存在被审查封锁的可能性。 第二种方案是反向隧道。 这里还需要补充两点:

  • TCP/QUIC 是全双工协议,一旦连接建立,就可以双向发送数据。
  • 防火墙默认允许所有出站请求,但会对入站请求进行审查。 所谓反向隧道,就是源服务器主动向中转服务器发起请求建立隧道,这不需要源服务器开放任何入站端口,也能双向收发数据。在这里,我使用了Cloudflare Tunnel,将我的服务器连接至 Cloudflare 中转服务器,从而实现内网穿透。

部署 Cloudflare Tunnel

既然我们已经成功将服务器连接至 Cloudflare 节点了,同时又将域名托管在 Cloudflare DNS 上,那么实现反代简直轻而易举啊。 坏了坏了,我们还没有部署 Tunnel,这一切还只是幻想。那么让我们来看看如何优雅地部署一个可复用的 Cloudflare Tunnel。进入 Cloudflare One 面板,选择 Networks > Connectors > Cloudflare Tunnels,选择 Cloudflared ,命名并保存后,你会看到该隧道的 token 以及部署命令。我们依旧通过 Docker 来部署,面板上给出的部署命令长这样:

1
docker run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token 'your token'

我们可以改写成 docker-compose 形式以便管理和扩展:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
services:
  tunnel:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared-tunnel
    restart: always
    command:
      - tunnel
      - --no-autoupdate
      - run
      - --token
      - 'your token'
    networks:
      - my-shared-tunnel

networks:
  # 这里定义一个外部网络,你可以把其他 Compose 项目也连进来
  my-shared-tunnel:
    external: true

这里也许需要插播一些 Docker 网络的小知识。

Docker Networks

Docker 网络默认使用 Bridge 模式。

  • 宿主机虚拟出一个网桥(名为 docker0),作为虚拟交换机。
  • 每个容器被分配一个私有 IP(如 172.17.0.x)。
  • 当容器内的程序访问 google.com 时:
  1. 容器内部:发现目标地址不在 172.17.0.0/16 网段,于是把包发给网关 172.17.0.1(即 docker0)。
  2. 宿主机内核:由于宿主机开启了 ip_forward(路由转发),它接收到这个包。
  3. SNAT(源地址转换):宿主机的 iptables 规则会将这个包的源 IP 从容器 IP 改为宿主机的物理 IP,然后通过物理网卡发出去。
  4. 返回:外部服务器回包到宿主机,宿主机再根据连接跟踪表,把包转发回对应的容器。
  • 外部访问容器:通过端口映射(docker run -p 8080:80),宿主机会通过 iptables 将发往 8080 端口的流量转发给容器的 80 端口。

如果启动容器时不显式指定 --network,会默认加入docker0。在默认网桥 (docker0)中,容器间只能通过 IP 地址互相访问,不支持 DNS 解析(即无法通过容器名访问),且 IP 地址在容器重启后可能会变,导致配置失效。 我们可以通过docker network create net-name创建一个自定义网桥。处于同一个自定义网桥内的容器,可以直接通过容器名或别名 (Alias) 进行通信。Docker 内部维护了一个 DNS 解析器,能自动将容器名解析为对应的内部 IP。使用自定义网桥还可以很好地进行权限隔离,每个容器只能访问自己所在的网桥中的容器。

部署并连接

我们先在宿主机上执行docker network create my-shared-tunnel,创建一个用于 tunnel 连接的自定义网桥,然后将其写入容器的 docker-compose 中,这样 tunnel 就能访问到所有加入该网桥的容器了。别忘了更改一下我们在开头想要部署的 Uptime Kuma 的 compose 配置,使其同样加入my-shared-tunnel。 现在执行docker compose up -d,Docker 会自动拉取镜像并启动容器。tunnel 启动后,你应该能在 Cloudflare 面板上看到“已连接”的提示。继续在面板上操作,点击"Published applications",选择想要连接的域名并指定子域名,在 Service 中选择http,URL 填写容器名:端口,在本例中是uptime-kuma:3001,点击保存,Cloudflare 就会在其 DNS 服务器中新增一条记录并指向你的 tunnel 与内部地址。不出意外的话,去浏览器访问你的域名,就能看到你部署在服务器上的页面了。这样我们就在不开放任何端口的情况下,实现了指向服务器的域名解析。