利用 frp 进行内网穿透

在购入 NAS 并搭建常用服务后,外网访问通常是许多人追求的下一个目标。在众多常用的外网访问方案中,frp 内网穿透工具可能是最热门的选择。

frp 特点

根据笔者的体验,frp 相对于其他方案有这些优势:

  • 相较于 VPN 异地组网方案,frp 比 Wireguard 配置更简单、更容易理解,比 Tailscale 更自由(Tailscale 想要好的体验需要自托管第三方开发的中继,例如 headscale),并且不要求访问者使用客户端。
  • 相较于同样是内网穿透方案、在简中比较出名的 NPS,笔者自认为 frp 更轻量、稳定性更高、性能更好;况且 frp 仍在积极维护并且有齐全文档,而原仓库 ehang-io/nps 早已停止维护并且文档有些模糊,目前只有 yisier/nps 仍在维护。

不过 nps 也有个优点:NPS 的客户端的内网穿透可在服务端可视化配置,方便分发和管理;而 frp 的配置文件只能手写 TOML、不能可视化配置、必须由客户端提供。

方案设计

设想这样一个情景:

家里网段是 192.168.1.0/24,家中有台群晖,IP 地址是 192.168.1.44,上面跑了基于 HTTP 的 Jellyfin 和基于 TCP 的 SSH。

问题来了:要想在家门外访问 Jellyfin 或 SSH,需要知道对应的 IP 地址和端口,即 ip:port。群晖所处网络是内网,而我们在家门外的设备连接的是另一个内网,二者不互通。所以必须要让这个 ip 是一个全球可路由的 IP 地址,即俗称的“公网 IP”。

这个“公网 IP”如何获取?对于非移动用户,可以找客服申请一个动态的公网 IPv4(虽然最近几年申请也很麻烦了);但对于移动用户就没辙,客服死活不分配。于是就有了“内网穿透”这个方案:

  1. 租一台拥有公网 IP(假设为 vsp-ip)的 VPS,上面运行内网穿透服务;
  2. 内网设备运行内网穿透客户端,主动和内网穿透服务建立持久的连接;
  3. 外网客户端尝试访问 vsp-ip:remote-port,内网穿透服务将请求转发至 local-ip:local-port,对应内网设备上的服务。同时服务的回包也通过 VPS 转发回去。

由此就解决了在外网无法访问内网服务的问题。

理论上说,你也可以选用运营商提供的全球单播 IPv6 地址(相当于 IPv4 的公网地址)来访问内网设备,但一来需要家中网关开启配置,二来尽管截至目前绝大多数设备已经从硬件上支持 IPv6,许多网络环境并不默认开启获取 IPv6 前缀的功能,导致网络内设备根本获取不到全球单播的 IPv6,自然也就无法通信了。

配置步骤

frp 相关的配置步骤具有时效性,部署前请参考 官方文档。

对 frp 的配置基于以下场景:

  • 家庭网络环境,子网为 192.168.1.0/24;
  • frp 客户端(frpc)运行在群晖 NAS 上,IP 地址为 192.168.1.44;
  • 待穿透服务为 Jellyfin 和 SSH,运行在群晖上:前者走 HTTP 协议、监听端口为 8096;后者走 TCP 协议、监听端口为 2222;
  • frp 服务端(frps)运行在 VPS 上,example.com 解析到 VPS 的 IP 地址 123.123.123.123。VPS 使用 Linux 主流发行版。

实际上笔者并不推荐穿透 SSH 服务到公网,或至少配置 10000 以上的映射端口,否则容易被自动脚本爆破骚扰;更推荐的做法是通过 Wireguard 等异地组网方案访问 SSH 服务。此处仅仅是为了提供一个简单的 TCP 穿透样例。

但方案具有一定的通用性,略作修改即可在其他场景下应用。

服务端(frps)配置

frp 可直接运行在宿主机上,或者运行在 Docker 容器中;对于 frps,建议选择前者,以避免 Docker 更新导致服务暂时中断。

下载服务端并配置 systemd

从 Github Release 页面下载最新版本的二进制文件压缩包。以 v0.69.0 为例:需要下载名为 frp_0.69.0_linux_amd64.tar.gz 的版本,其中 linux 代表 OS,amd64 代表 x86-64 平台。

1
wget https://github.com/fatedier/frp/releases/download/v0.69.0/frp_0.69.0_linux_amd64.tar.gz

然后将压缩包解压到合适的位置,比如当前工作目录:

1
tar -xzf frp_0.69.0_linux_amd64.tar.gz

重命名一下:

1
mv frp_0.69.0_linux_amd64 frps

进去看看有什么内容:

1
2
3
4
5
6
7
8
9
$ cd frps && ls -al
total 36708
drwxr-xr-x 2 1001 1001 4096 May 21 17:11 .
drwxr-xr-x 4 1001 1001 4096 May 31 09:52 ..
-rw-r--r-- 1 1001 1001 11358 May 21 17:11 LICENSE
-rwxr-xr-x 1 1001 1001 16801976 May 21 17:06 frpc
-rw-r--r-- 1 1001 1001 142 May 21 17:11 frpc.toml
-rwxr-xr-x 1 1001 1001 20750520 May 21 17:06 frps
-rw-r--r-- 1 1001 1001 16 May 21 17:11 frps.toml

可以看到打包了客户端和服务端及各自配置文件,以及 license。我们只需要服务端 frps,为防止混淆可以删掉客户端:

1
rm frpc frpc.toml

按理说如果写好了配置文件 frps.toml,现在就可以通过 ./frps -c ./frps.toml 运行了。但这样显然不妥:进程依附于当前 Shell,若连接断开就会关闭;VPS 启动时也不会自动启动 frps。因此我们要配置 systemd。

创建 frps 的 systemd 配置 /etc/systemd/system/frps.service,将以下内容写入文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
[Unit]
# 服务名称,可自定义
Description = frp server
After = network.target syslog.target
Wants = network.target

[Service]
Type = simple
# 启动 frps 的命令,需修改为您的 frps 的安装路径
ExecStart = /path/to/frps -c /path/to/frps.toml

[Install]
WantedBy = multi-user.target

然后就可以通过 systemd 管理 frps 了。常用命令有:

1
2
3
4
5
6
7
8
9
10
11
12
# 启动 frps
systemctl start frps
# 停止 frps
systemctl stop frps
# 重启 frps
systemctl restart frps
# 查看 frps 状态
systemctl status frps
# 设置 frps 开机自启
systemctl enable frps
# 禁用 frps 开机自启
systemctl disable frps

编写 frps.toml

在启动 frps 前,我们还需要编写其配置文件。frp 的客户端配置存储在 frpc,因此 frps 的配置比较简单。

frps 目前支持的文件格式包括 TOML/YAML/JSON,旧的 INI 格式仍然支持,但已经不再推荐。网络上大量 frps 教程均基于 INI 配置格式,存在一定的误导性。

因为官方文档的配置示例均使用 TOML,所以我们也采用 TOML。下面给出一个适用于大多数场景的配置示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 全局配置,主要和客户端连接方式相关
bindPort = 7000
vhostHTTPPort = 8080
vhostHTTPSPort = 4430

# 客户端鉴权设置(可选,默认客户端连接无须鉴权)
[auth]
method = "token"
token = "my_token" # 修改成自己配置的 token,建议随机生成

# 日志设置(可选,默认不对外输出日志)
[log]
to = "/path/to/frps.log" # 当天日志存放的 **绝对路径**,历史日志会按日期重命名存放在同目录下
maxDays = 10 # 超过此天数的日志会被清理

# Web UI 面板设置(可选,默认不提供此功能)
[webServer]
port = 7500 # 后台面板端口号
addr = "frps.example.com" # 后台管理地址,需要确保 frps.example.com 解析到 VPS
user = "chimes-of-freedom" # 后台管理员账号
password = "my_password" # 后台管理员密码

注意:上述配置文件涉及端口需要被 VPS 及其提供商的防火墙放行入站流量。

重点讲解以下字段:

  • bindPort:frps 监听端口,默认为 7000。frpc 会尝试连接此端口,建立连接后 frpc 和 frps 间的所有流量都走该端口。默认使用 TCP,如需使用其他协议,可参考 官方文档 将此键替换为 kcpBindPort / quicBindPort 等其他协议对应的配置。
  • vhostHTTPPort:frps HTTP 类型代理监听的端口。外网设备尝试访问穿透的 HTTP 服务时需要访问该端口,frps 会将流量转发给 frpc。类似的还有 vhostHTTPSPort 等。

TCP 服务不需要类似配置,因为每一个 TCP 服务都需要 frps 监听一个独一无二的端口(由客户端指定),而 frps 可通过域名(由客户端指定)区分不同的 HTTP 连接。

更多字段配置详见 官方文档。

值得注意的是,官方文档将字段归类为不同的表格,表格名称对应 Go 源码中的结构体名称,字段名、类型等也是如此,注意分辨和 TOML 的映射关系。据笔者所知,ServerConfig / ClientConfig 分别对应 frps.toml / frpc.toml 的顶层表,其余表格名对应一些表格中字段类型名,属于嵌套表,而如果一个字段的类型和表格名匹配,则字段名就是对应表的名称。表数组规则类似。

编写完配置文件后记得运行服务:

1
systemctl start frps

此时浏览器访问 http://frps.example.com,输入账号密码后登录,应能看到 frps 的后台面板。不过 frps 提供的面板功能很少,只能起到一个简略的监控作用,无法查看具体日志,也无法修改配置。

客户端(frpc)配置

由于客户端可能是群晖之类的设备,其 Linux 环境不一定标准,因此建议使用 Docker 部署 frpc。

frpc 和需要穿透的服务不一定要在同一台机器上:frpc 本质上是一个反向代理服务器,它可以把请求转发给其他设备,因此也完全可以在支持的网关(如软路由)上部署 frpc。本文为举例方便,仍选择二者合一。

Docker 部署 frpc

推荐使用 Docker Compose 部署 frpc,原因是通用性好、易维护。当然也可以选择使用 Docker CLI 或者是群晖等 NAS 提供的可视化界面,但笔者更习惯 compose.yml + 命令行。

新建存放 compose.yml 和 frpc.toml 的目录 frpc 并 cd 进去:

1
mkdir frpc && cd frpc

将以下内容写入 compose.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
services:
frpc:
container_name: frpc
image: ghcr.io/fatedier/frpc:v0.69.0 # 替换为需要的版本,最好和 frps 一致
restart: always # 除非通过命令行停止,否则始终尝试重启
network_mode: host # 直接使用宿主机网络,否则功能可能受限
mem_limit: 8G # 按情况配置内存限制,也可以不配置
entrypoint: ["/usr/bin/frpc"] # frpc 容器内的路径
command: ["-c", "/etc/frpc/frpc.toml"] # 和下面映射到容器内的配置文件路径一致即可
volumes:
- ./frpc.toml:/etc/frpc/frpc.toml:ro # 配置文件映射,保持只读即可
environment:
- PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

待 frpc.toml 编写完成后,通过 docker compose up -d 启动服务。

编写 frpc.toml

frpc 配置稍微复杂,因为不仅需要指定要连接的服务端,还需要指定待穿透的服务。下面是一个配置示例:

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
serverAddr = "example.com"  # 或者直接用 IP 地址
serverPort = 7000 # 需要和 frps.toml 的 bindPort 保持一致
loginFailExit = false # 有时网络质量不佳会导致连接失败或断开,设置为 false 表示此时重试连接而非直接退出

[auth]
method = "token"
token = "my_token" # 和 frps.toml 保持一致

# === 每个 [[proxies]] 表数组代表一个待穿透服务 ===

# jellyfin
[[proxies]]
name = "jellyfin-http" # 代理的唯一标识,按需配置
type = "http" # HTTP 协议,如果需要 HTTPS 则配置为 "https"
#localIP = "127.0.0.1" # 被代理的本地服务 IP 地址,缺省为 "127.0.0.1"
localPort = 8096 # 服务监听的端口
customDomains = ["jellyfin.example.com"] # 若使用数组内的域名之一访问服务,则会被转发到当前 frpc
# frpc 和 frps 间传输相关配置
[proxies.transport]
useEncryption = true # 传输加密
useCompression = true # 内容压缩

# ssh
[[proxies]]
name = "ssh-tcp"
type = "tcp" # TCP 协议,值为 "tcp"
#localIP = "127.0.0.1"
localPort = 22
remotePort = 2222 # frps 需要监听的端口,即外网通过 123.123.123.123:2222
# 或 example.com:2222 可 SSH 连接到群晖
[proxies.transport]
useEncryption = true
useCompression = true

注意到 TCP 代理需要配置 remotePort,而 HTTP(S) 代理则不需要。这是因为 HTTP(S) 代理统一通过 http://custom.example.com:vhostHTTPPort 或 https://custom.example.com:vhostHTTPSPort 访问,frps 根据 URL 将请求路由到不同的 frpc。

例如,对于我们假设的场景,假设再增加一个群晖 DSM Web 管理界面的 HTTP 代理,配置 customDomains = ["dsm.example.com"],则:

  • 当外网设备访问 http://jellyfin.example.com:8080 时,实际访问 Jellyfin 的 Web UI;
  • 当外网设备访问 http://dsm.example.com:8080 时,实际访问 DSM 的 Web UI;
  • 当外网设备执行 ssh user@123.123.123.123 -p 2222 时,实际访问群晖的 SSH 服务。

注意:部分服务可能需要配置代理后的 URL 方可返回正确的响应,例如对于上述情景,Jellyfin 需要在“控制台 -> 网络 -> 基础 URL”中填写 http://jellyfin.example.com:8080。具体配置请参阅被代理服务的官方文档。

【可选】配置反向代理服务器

配置反向代理服务器的理由有这几个:

  • 若 vhostHTTPPort / vhostHTTPSPort 不配置为默认的 80 和 443,则访问 HTTP 服务时必须带上对应端口号,比较麻烦;而 VPS 可能不止 frps 这一个服务,例如可能还托管了静态博客,80 / 443 端口可能被占用;
  • 为安全起见,推荐为 HTTP 服务配置 SSL 证书,但若让 frps 管理证书则难以自动化,并且其他服务也要申请证书,如此一来证书散落在各处,不方便续期;
  • frps 监听的端口直接暴露在公网,容易被攻击。

而反向代理服务器选择 Caddy 而非普遍使用的 nginx 的理由则是:Caddy 配置反向代理和 SSL 证书极其简单,并且个人内网穿透场景完全不会出现高并发,Caddy 的性能不会成为内网穿透的瓶颈。

在 VPS 上安装 Caddy 后,向其配置文件 /etc/caddy/Caddyfile 写入以下内容:

1
2
3
jellyfin.example.com {
reverse_proxy localhost:8080
}

上述配置自动为 jellyfin.example.com 申请 SSL 证书,将来自 https://jellyfin.example.com:443 的请求转发至 http://localhost:8080,即 frps 监听的 HTTP 协议端口。Caddy 会自动处理 HTTPS,因此 frps 只需要监听 HTTP,不用考虑 HTTPS,因而 vhostHTTPSPort 可以不用配置;另外防火墙也可删除对 8080 和 4430 端口的放行规则,转而配置对 443 端口的放行规则。

随后设置 Caddy 开机自启并立即启动:

1
systemctl enable --now caddy

关于更详细的 Caddy 配置,请参阅其 官方文档。

总结

通过上述步骤,我们打通了内外网设备的链路,使得内网服务像服务器一样暴露在公网可供人访问。不过当服务暴露在人人触手可及的地方时,更须注意网络安全,否则整个内网都会受到威胁。