从 frp 暴露端口,到泛域名 + Caddy,再到 frp Host 分流:一次内网穿透架构的升级
最近我在折腾一个很实际的问题:
我有一台内网机器,上面跑着很多服务,每个服务都有自己的端口。现在我想把其中某些服务暴露到公网,让外部可以访问。
这个需求很常见。
比如我家里或者局域网里有一台 Ubuntu 机器,上面跑着:
MCP 服务:8120
API 服务:8122
测试后台:8080
某个 Go 服务:9000
某个 Python 服务:7860
这些服务本身都在内网里,外部是访问不到的。
如果机器没有公网 IP,或者路由器不方便做端口映射,那传统方式就很麻烦。于是我用了 frp。
一开始我对 frp 的理解还比较简单:
它就是一个内网穿透工具,可以把内网端口暴露到公网服务器上。
但真正跑通之后,我对它的理解一步一步升级了。
一、frp 的核心逻辑:公网服务器帮内网机器“代收连接”
frp 的结构其实很清晰:
外部用户
↓
公网服务器某个端口
↓
frps
↓
frp 隧道
↓
frpc
↓
内网真实服务
公网服务器上跑的是 frps,内网机器上跑的是 frpc。
最关键的一点是:
内网机器是主动连接公网服务器的。
这就绕开了一个非常麻烦的问题:内网机器不需要公网 IP,也不需要在家里路由器上做端口映射。
只要内网机器能主动访问公网服务器,frpc 就能和 frps 建立连接。之后外部访问公网服务器的某个端口,frps 就可以通过这条连接把流量转回内网机器。
比如:
公网服务器:18120 -> 内网机器:8120
这个过程看起来像是外部直接访问了内网服务,但实际上中间是公网服务器在帮忙转发。
这个设计非常优雅。
二、真正让我觉得优雅的是:新增服务时,frps 通常不用改
一开始我以为,每暴露一个新服务,公网服务器上的 frps 也要改配置。
后来实际搞了一下发现:
大多数情况下,新增端口映射只需要改 frpc,frps 根本不用动。
这点非常爽。
frps 更像一个已经部署好的“中转中心”。它只需要提前启动,并监听一个控制端口,比如:
frps:7000
之后内网机器上的 frpc 可以不断声明:
我要把本地 8120 映射到公网服务器 18120
我要把本地 8122 映射到公网服务器 18122
我要把本地 8080 映射到公网服务器 18080
frps 收到这些注册后,就负责监听对应的端口,并把流量转回去。
也就是说,公网服务器上的 frps 是一个相对稳定的入口。后续新增什么服务,主要由内网侧的 frpc 决定。
这个时候我第一次感觉到:
frp 不只是“穿透工具”,它更像是一个内网服务注册到公网入口的隧道系统。
三、第一阶段:每个服务暴露一个公网端口
最直观的做法是这样:
公网服务器:18120 -> 内网 MCP 服务:8120
公网服务器:18122 -> 内网 API 服务:8122
公网服务器:18080 -> 内网后台服务:8080
访问的时候就是:
http://公网IP:18120
http://公网IP:18122
http://公网IP:18080
这个方式非常直接,适合临时调试,也适合自己用。
但是用着用着就会发现一些问题。
1. 每加一个服务,都要去服务器控制台开放端口
比如你新增一个服务,要用 18123,那你不仅要改 frpc,还要去云服务器安全组或者防火墙里开放 18123。
服务一多,这个操作很烦。
2. 端口越来越多,很难管理
一开始几个端口还好,后面可能变成:
18080
18120
18121
18122
18123
19000
19001
时间长了就容易忘:
18120 是哪个服务?
18122 又是哪个服务?
这个端口还在用吗?
这对于我这种一堆 Go/Python/Docker 服务,每个服务一个端口的模式来说,迟早会变混乱。
3. 业务端口直接暴露在公网,不够干净
虽然 frp 很方便,但如果每个业务端口都直接暴露到公网,本质上就是把这些端口都变成了公网入口。
如果暴露的是普通 Web 服务还好,如果不小心暴露了:
Redis
PostgreSQL
后台管理
无鉴权 API
MCP 服务
Docker API
那风险就很高。
所以第一阶段的方案虽然能用,但不是最优雅的长期方案。
四、第二阶段:用 Caddy 接管公网入口
后来我突然想到一个问题:
我的公网服务器上本来就有 Caddy,也有自己的域名。
既然 Caddy 本来就可以根据域名做反向代理,那我是不是可以不直接暴露每个 frp 业务端口,而是让 Caddy 来统一接收公网请求?
也就是说,外部访问:
https://mcp.dev.weini.xin
然后 Caddy 根据域名转发到服务器本机的某个端口:
127.0.0.1:18120
这个 18120 再通过 frp 回到内网的真实服务:
内网机器:8120
最终链路就变成:
用户访问 mcp.dev.weini.xin
↓
公网服务器 Caddy:443
↓
reverse_proxy 127.0.0.1:18120
↓
frps/frpc 隧道
↓
内网机器:8120
这一下架构就清晰了。
公网入口只需要 Caddy 负责,frp 业务端口不再直接暴露给外部。
这个阶段相比第一阶段已经进步很多。
公网不再到处开放业务端口,而是统一通过:
80
443
对外提供服务。
五、真正点睛之笔:泛域名解析
不过这里还有一个问题:
如果每新增一个服务,我都要去 DNS 控制台新建一个子域名,那还是麻烦。
比如:
mcp.dev.weini.xin
api.dev.weini.xin
test.dev.weini.xin
admin.dev.weini.xin
如果每个都手动添加 DNS 解析,也挺烦。
然后就想到了泛域名解析:
*.dev.weini.xin -> 公网服务器 IP
*.test.weini.xin -> 公网服务器 IP
这一下就彻底舒服了。
有了泛域名解析之后,只要是:
xxx.dev.weini.xin
都会自动解析到我的公网服务器。
比如:
mcp.dev.weini.xin
api.dev.weini.xin
ghost.dev.weini.xin
demo.dev.weini.xin
anything.dev.weini.xin
这些域名都不需要单独配置 DNS。
DNS 这一层只负责一件事:
只要是 *.dev.weini.xin,都指向我的公网服务器。至于具体哪个子域名转发到哪个服务,则交给 Caddy 决定。
六、DNS 这次突然变得很有存在感
说实话,以前我对 DNS 的感觉很普通。
以前我理解 DNS,大概就是:
把一个域名解析到一个 IP
比如:
weini.xin -> 服务器 IP
www.weini.xin -> 服务器 IP
它更像是一个“域名和 IP 的映射表”。有用是有用,但平时感知并不强。
这次搞 frp、Caddy、泛域名之后,我第一次明显感觉到:
DNS 不是一个简单的配置项,它其实是整个公网入口体系的第一层。
尤其是泛域名解析:
*.dev.weini.xin -> 公网服务器 IP
*.test.weini.xin -> 公网服务器 IP
这个东西一下子把体验改变了。
以前新增一个服务,我的脑子里想的是:
我要开哪个端口?
我要不要去安全组放行?
我要不要再配一个域名?
这个端口以后我还能不能记住?
有了泛域名之后,思路直接变成:
我给这个服务起个什么名字?
比如:
mcp.dev.weini.xin
api.dev.weini.xin
demo.test.weini.xin
ghost-test.test.weini.xin
这个变化挺关键的。
它让服务管理从“端口思维”变成了“命名思维”。
端口是偏底层的,适合机器识别;域名是偏上层的,适合人理解。
这次我才真切感觉到,DNS 不只是为了让用户访问网站方便,它也可以成为服务治理的一部分。
DNS 负责把一类域名都引到公网入口
Caddy 负责根据具体域名分流
frp 负责把请求带回内网
这三层配合起来之后,整个结构就很自然。
也正是这个时候,我第一次觉得:
原来 DNS 这么有用。
以前觉得它只是“配一下解析”。
现在发现它其实是在帮我定义整个服务入口的命名空间。
比如我可以直接规划:
*.dev.weini.xin 开发服务命名空间
*.test.weini.xin 测试服务命名空间
以后我看到一个域名,就大概知道它属于哪个环境、是什么用途。
这比一堆端口号直观太多了。
比如:
https://mcp.dev.weini.xin
一看就知道是开发环境里的 MCP 服务。
而如果是:
http://服务器IP:18120
时间一长,我自己都可能忘了它到底是什么。
所以这次最让我意外的点之一就是:
我从没觉得 DNS 这么有用。
尤其是泛域名解析配合 Caddy 的时候,它不是锦上添花,而是直接把整个多服务暴露体验变成了另一种形态。
从手动配置每个服务的入口,变成提前规划好一个域名命名空间,然后所有服务都往这个命名空间里挂。
这个体验真的很爽。
七、第二阶段的结构:DNS 负责入口,Caddy 负责分流,frp 负责回内网
到这里为止,我的理解是这样的:
泛域名 DNS
↓
公网服务器 Caddy 80/443
↓
根据 Host 分流到本机端口
↓
frps/frpc 隧道
↓
内网真实服务
更具体一点:
mcp.dev.weini.xin
↓
Caddy
↓
127.0.0.1:18120
↓
frp
↓
内网机器:8120
api.dev.weini.xin
↓
Caddy
↓
127.0.0.1:18122
↓
frp
↓
内网机器:8122
demo.test.weini.xin
↓
Caddy
↓
127.0.0.1:18080
↓
frp
↓
内网机器:8080
这个阶段已经非常好用了。
新增服务时,流程变成:
1. frpc 新增一个端口映射
2. Caddyfile 新增一个域名分流
3. reload Caddy
DNS 不用动。
服务器安全组也不用每次开新业务端口。
比如新增一个服务:
内网服务:8120
服务器本机 frp 端口:18120
域名:mcp.dev.weini.xin
frpc 里加映射:
[[proxies]]
name = "mcp-8120"
type = "tcp"
localIP = "127.0.0.1"
localPort = 8120
remotePort = 18120
Caddy 里加:
mcp.dev.weini.xin {
reverse_proxy 127.0.0.1:18120
}
然后 reload Caddy。
外部访问就是:
https://mcp.dev.weini.xin
这已经从“端口管理”升级成了“域名管理”。
但是后来我又意识到一个问题。
八、第三次顿悟:为什么新增服务还要登录云服务器改 Caddy?
当我以为这套已经很优雅的时候,又突然发现一个不爽的地方:
虽然 DNS 不用改了,安全组不用开新端口了,但是每新增一个服务,我还是要登录云服务器改 Caddy 配置。
也就是说,新增服务仍然要做两件事:
1. 改内网 frpc
2. 改云端 Caddy
这就有点不彻底。
我真正想要的是:
新增服务时,只改内网 frpc
云端服务器完全不用登录
DNS 不用动
Caddy 不用动
安全组不用动
这个想法出来之后,整个理解又升级了一层。
因为 frp 本身不只是能做 TCP 端口转发,它也可以做 HTTP 类型代理,并根据 HTTP 请求里的 Host 来分流。
也就是说,可以让 frp 自己来识别:
Host: mcp.dev.weini.xin
Host: api.dev.weini.xin
Host: demo.test.weini.xin
然后把不同域名转发到不同的内网服务。
这样 Caddy 就不需要再为每个服务单独写一段配置了。
Caddy 只需要做一件事:
所有*.dev.weini.xin和*.test.weini.xin的请求,统一转发给 frps 的 HTTP vhost 端口。
后面的具体服务分流,交给 frp。
九、终极形态:Caddy 只做统一入口,frp 根据 Host 分流
新的链路变成:
用户访问 mcp.dev.weini.xin
↓
DNS 泛解析到云服务器
↓
Caddy 接收 443
↓
Caddy 统一转发给 frps 的 HTTP vhost 端口
↓
frps 根据 Host 判断该给哪个 frpc
↓
内网服务 8120
也就是:
用户
↓
*.dev.weini.xin
↓
Caddy 统一入口
↓
frps HTTP vhost
↓
frp 根据 Host 分流
↓
内网真实服务
这个阶段里,Caddy 不再关心:
mcp.dev.weini.xin 应该去哪个端口
api.dev.weini.xin 应该去哪个端口
demo.test.weini.xin 应该去哪个端口
Caddy 只关心:
所有 dev/test 的请求都交给 frps
然后 frp 根据 frpc 注册的信息分流。
十、云端 frps 的配置
云端 frps 需要开启一个 HTTP vhost 端口,比如:
bindPort = 7000
vhostHTTPPort = 8080
auth.token = "你的复杂token"
这里的意思是:
7000:frpc 连接 frps 的控制端口
8080:frps 接收 HTTP Host 分流流量
注意,这个 8080 不需要对公网开放。
因为外部用户访问的是 Caddy 的 443,Caddy 再从服务器本机转发到:
127.0.0.1:8080
所以公网安全组仍然只需要:
80
443
7000
8080 不必对外开。
十一、云端 Caddy 只配置一次
云端 Caddy 可以变成统一入口。
理想情况下,Caddy 配置类似这样:
*.dev.weini.xin {
reverse_proxy 127.0.0.1:8080
}
*.test.weini.xin {
reverse_proxy 127.0.0.1:8080
}
这样所有:
xxx.dev.weini.xin
xxx.test.weini.xin
都会先进入 Caddy,再统一转给 frps 的 HTTP vhost 端口。
Caddy 不再配置每一个服务。
以前 Caddy 是这样:
mcp.dev.weini.xin {
reverse_proxy 127.0.0.1:18120
}
api.dev.weini.xin {
reverse_proxy 127.0.0.1:18122
}
demo.test.weini.xin {
reverse_proxy 127.0.0.1:18080
}
现在变成:
*.dev.weini.xin {
reverse_proxy 127.0.0.1:8080
}
*.test.weini.xin {
reverse_proxy 127.0.0.1:8080
}
云端配置从“每个服务一条”变成“每个命名空间一条”。
这就是质变。
十二、内网 frpc 负责注册具体服务
以后新增服务,只需要在内网机器的 frpc 里加:
[[proxies]]
name = "mcp"
type = "http"
localIP = "127.0.0.1"
localPort = 8120
customDomains = ["mcp.dev.weini.xin"]
再新增一个 API 服务:
[[proxies]]
name = "api"
type = "http"
localIP = "127.0.0.1"
localPort = 8122
customDomains = ["api.dev.weini.xin"]
再新增一个测试服务:
[[proxies]]
name = "demo"
type = "http"
localIP = "127.0.0.1"
localPort = 8080
customDomains = ["demo.test.weini.xin"]
之后访问:
https://mcp.dev.weini.xin
https://api.dev.weini.xin
https://demo.test.weini.xin
就分别进入不同的内网服务。
云端 Caddy 不用动。
DNS 不用动。
安全组不用动。
只改内网 frpc。
这才是我真正想要的体验。
十三、这两种模式的区别
到这里,其实有两种模式。
模式一:Caddy 分流模式
结构是:
mcp.dev.weini.xin
↓
Caddy
↓
127.0.0.1:18120
↓
frp TCP
↓
内网 8120
特点:
frp 用 type = "tcp"
Caddy 每个服务都要配置
新增服务要登录云服务器改 Caddy
理解简单,控制清晰
这个模式适合刚开始使用,也适合需要非常明确控制每个路由的场景。
模式二:frp Host 分流模式
结构是:
mcp.dev.weini.xin
↓
Caddy 通配入口
↓
frps vhostHTTPPort
↓
frp 根据 Host 分流
↓
内网 8120
特点:
frp 用 type = "http"
frpc 写 customDomains
Caddy 只配置一次通配入口
新增服务只改内网 frpc
云端不用动
这个模式才更接近“平台化”。
十四、这个方案的真正意义:云端入口固定,服务注册下沉到内网
这次认知升级最关键的点是:
云端不应该总是跟着业务服务变化。
如果每新增一个服务,云端就要改一次配置,那云端还是承担了太多动态路由的职责。
更优雅的方式是:
云端入口固定
内网服务自己注册
也就是:
DNS 固定
Caddy 固定
frps 固定
安全组固定
frpc 动态新增服务
这个结构就很舒服。
它有点像一个轻量级的服务注册系统:
我在内网启动了一个服务
我在 frpc 里声明它的域名
frps 收到注册
外部访问这个域名时,frps 自动转回对应服务
这样新增服务的操作边界就很清楚:
服务在哪里,就在哪里改配置
内网服务在内网机器上,所以新增服务只改内网机器。
云端只是入口,不需要反复登录修改。
这才是真的优雅。
十五、但是这里有一个现实门槛:通配 HTTPS 证书
这个终极方案有一个需要注意的地方:
Caddy 如果写:
*.dev.weini.xin {
reverse_proxy 127.0.0.1:8080
}
那 Caddy 需要给 *.dev.weini.xin 申请通配 HTTPS 证书。
通配证书一般需要 DNS Challenge。
也就是说,Caddy 需要能通过 DNS 服务商的 API 自动验证域名所有权。
如果 DNS 在阿里云,就需要 Caddy 支持阿里云 DNS 插件。
如果 DNS 托管到 Cloudflare,就可以用 Cloudflare DNS 插件。
所以这里有两条路线。
路线 A:暂时不用通配证书
继续让 Caddy 对每个具体域名单独自动签证书。
这种最简单,但还是需要给每个域名写 Caddy 配置。
路线 B:搞通配证书
一次性搞定:
*.dev.weini.xin
*.test.weini.xin
之后 Caddy 统一接 HTTPS,后面全交给 frp。
这才是最接近最终形态的方案。
不过这一步会多一个证书和 DNS API 的配置门槛。
所以它更适合作为第二阶段升级,不一定一开始就上。
十六、现在最理想的最终架构
最终我现在理解的理想架构是:
DNS
*.dev.weini.xin -> 云服务器 IP
*.test.weini.xin -> 云服务器 IP
云服务器安全组
80 对外开放
443 对外开放
7000 给 frpc 连接 frps
业务端口不对公网开放。
云端 Caddy
*.dev.weini.xin {
reverse_proxy 127.0.0.1:8080
}
*.test.weini.xin {
reverse_proxy 127.0.0.1:8080
}
云端 frps
bindPort = 7000
vhostHTTPPort = 8080
auth.token = "你的复杂token"
内网 frpc
[[proxies]]
name = "mcp"
type = "http"
localIP = "127.0.0.1"
localPort = 8120
customDomains = ["mcp.dev.weini.xin"]
[[proxies]]
name = "api"
type = "http"
localIP = "127.0.0.1"
localPort = 8122
customDomains = ["api.dev.weini.xin"]
[[proxies]]
name = "demo"
type = "http"
localIP = "127.0.0.1"
localPort = 8080
customDomains = ["demo.test.weini.xin"]
访问链路:
https://mcp.dev.weini.xin
↓
DNS
↓
Caddy
↓
frps HTTP vhost
↓
frpc
↓
内网 8120
新增服务时:
只改 frpc
不改 DNS
不改 Caddy
不开放新端口
不登录云服务器
这才是我现在认为最爽的形态。
十七、为什么这套东西接近生产思路
我前面一直说,这套东西“接近生产”,不是说它本身就是严肃生产级架构,而是它的入口设计思路已经接近生产环境。
生产环境一般不会让用户这样访问:
http://服务器IP:18120
http://服务器IP:18122
http://服务器IP:18080
更常见的是:
https://api.example.com
https://admin.example.com
https://dashboard.example.com
也就是:
用户
↓
域名
↓
DNS
↓
统一入口网关 / 反向代理 / 负载均衡
↓
内部服务
而我现在这套是:
用户
↓
*.dev.weini.xin / *.test.weini.xin
↓
DNS 泛解析
↓
Caddy 80/443
↓
frps HTTP vhost
↓
frp 隧道
↓
内网服务
虽然后面用的是 frp 回内网,不是 Kubernetes、Ingress、负载均衡或者云内网服务,但整体抽象很像:
DNS 负责入口
Caddy 负责 HTTPS 和统一网关
frp 负责内部路由和回源
业务服务藏在后面
这已经不是简单的“开个端口能访问就行”。
它开始有了入口治理、命名空间、服务分层、安全边界这些东西。
十八、安全上需要注意的点
这套方案虽然优雅,但不能因为优雅就忽略安全。
尤其是 frp 和 Caddy 结合后,服务更容易被公网访问到,所以有几个点要注意。
1. frps 的 7000 端口必须开放,但最好限制来源 IP
frpc 要连接 frps,所以 7000 端口必须能被内网机器访问。
但是如果条件允许,最好在云服务器安全组里限制:
只允许自己的家宽出口 IP 访问 7000
如果家宽 IP 不固定,至少要保证 frp 的 token 足够复杂。
2. frps 的 vhostHTTPPort 不要对公网开放
比如:
vhostHTTPPort = 8080
这个端口只需要给 Caddy 在服务器本机访问:
127.0.0.1:8080
公网安全组不应该开放它。
外部用户只应该访问:
80
443
3. 后台类服务一定要加鉴权
不要因为套了域名就以为安全了。
像这些服务:
MCP
后台管理
测试 API
数据库管理工具
任务队列面板
内部调试接口
如果暴露到公网,至少要加:
Basic Auth
登录鉴权
IP 白名单
路径限制
访问 token
Caddy 可以很方便地加 Basic Auth,这对于临时内部工具很有用。
4. 不要暴露数据库和 Redis
这类东西尽量不要直接通过 frp 暴露到公网域名后面:
PostgreSQL
Redis
Docker API
MinIO 管理端
消息队列管理端
即使要远程访问,也应该走更受控的方式,比如 SSH Tunnel、VPN、ZeroTier、WireGuard,或者至少严格限制来源 IP。
5. dev 和 test 域名也不是绝对安全的
dev、test 只是命名空间,不是安全机制。
不是说服务挂在:
xxx.dev.weini.xin
它就天然安全了。
只要公网能访问,它就可能被扫描到。
所以该加鉴权还是要加鉴权。
十九、这次折腾的总结
这次折腾之后,我对 frp、Caddy、DNS 泛解析的理解连续升级了好几次。
一开始我的需求只是:
把内网的一个端口暴露出去。
第一阶段,我发现 frp 很优雅。
因为它可以让内网机器主动连接公网服务器,不需要公网 IP,也不需要路由器端口映射。
第二阶段,我发现只用 frp 暴露端口还不够优雅。
因为端口多了以后,会出现:
端口难记
安全组反复开放
业务端口裸露公网
访问形式不优雅
于是我想到用 Caddy 接管公网入口。
第三阶段,我发现 DNS 泛解析非常关键。
通过:
*.dev.weini.xin
*.test.weini.xin
可以提前规划好开发和测试的域名命名空间。
这让我从“端口思维”切换到了“域名思维”。
以前是:
我这个服务用哪个端口?
现在是:
我这个服务叫什么名字?
这个变化非常大。
第四阶段,我又发现即使 DNS 和安全组都不用动了,每新增一个服务还是要登录云服务器改 Caddy。
这还不够彻底。
于是理解继续升级:
让 Caddy 只做统一入口,让 frp 根据 Host 做服务分流。
最终形态就变成:
DNS 泛解析
↓
Caddy 通配入口
↓
frps HTTP vhost
↓
frp 根据 Host 分流
↓
内网真实服务
这样新增服务时,只需要改内网 frpc。
不改 DNS
不改 Caddy
不开放新端口
不登录云服务器
这才是真的舒服。
二十、最终认知
这次最大的收获不是“学会了 frp 怎么用”。
而是理解了一个更大的东西:
公网入口应该稳定,服务注册应该灵活。
DNS 负责定义命名空间。
Caddy 负责统一 HTTPS 入口。
frp 负责把服务从内网注册到公网入口。
内网服务只负责自己的业务。
最终结构是:
公网入口层:DNS + Caddy
服务注册层:frp
业务运行层:内网服务
以前是:
记端口、开端口、配端口
后来是:
配域名、走 HTTPS、Caddy 分流、frp 回内网
现在进一步升级成:
DNS 和 Caddy 固定
frp 动态注册服务
新增服务只改内网配置
这就是从“端口暴露”升级到“域名入口管理”,再升级到“轻量服务注册”。
对我这种本地和服务器上跑了很多小服务的人来说,这套方式非常适合:
开发调试
MCP 服务
API 测试
临时演示
内部后台
多项目多端口管理
我以前从没觉得 DNS 这么有用。
这次是真的感觉到了:
DNS 不是简单地把域名指向 IP,它是在定义整个服务入口的命名空间。
配合 Caddy 和 frp 之后,整个体验确实优雅很多。
陕公网安备61011302002223号