内网穿透 HTTP协议路由技术剖析
前言
frp(Fast Reverse Proxy)是一个高性能的反向代理应用,可以帮助将内网服务暴露到公网。其中最常用的功能之一就是通过 80 和 443 端口提供 HTTP/HTTPS 服务,并通过域名区分不同的隧道。本文将深入探讨其底层实现原理。
一、核心概念:基于域名的虚拟主机
1.1 问题背景
在传统的网络环境中,我们面临几个挑战:
- 性能限制:便宜好用的服务器一般性能不够好,服务跑不动
- 内网穿透:内网服务无法直接被外网访问
- 多服务共存:需要在同一端口上运行多个不同的服务
frp 通过虚拟主机技术优雅地解决了这些问题。
1.2 解决方案核心
frps(服务端)通过 HTTP Host 头 和 TLS SNI(Server Name Indication) 来区分不同的隧道,从而实现:
- 一个端口(80/443)服务多个域名
- 每个域名对应一个独立的内网服务
- 用户访问体验与直接访问无异
二、HTTP 协议层面(80 端口)
2.1 HTTP Host 头的作用
HTTP/1.1 协议中,Host 请求头是必填字段,用于指定请求的目标主机名。这是虚拟主机技术的基石。
GET /api/users HTTP/1.1
Host: app1.example.com
User-Agent: Mozilla/5.0
Accept: */*
2.2 frps 的处理流程
┌──────────────────────────────────────────────────────────────┐
│ frps HTTP 处理流程 │
├──────────────────────────────────────────────────────────────┤
│ 1. 监听 80 端口,等待 TCP 连接 │
│ 2. 接收连接,解析 HTTP 请求头 │
│ 3. 提取 Host 字段值(如 app1.example.com) │
│ 4. 查询隧道映射表,找到对应的 frpc 连接 │
│ 5. 将请求数据转发给对应的 frpc │
│ 6. 接收 frpc 返回的响应,返回给客户端 │
└──────────────────────────────────────────────────────────────┘
2.3 HTTP 请求解析示例
当用户访问 http://app1.example.com/api/data 时:
// 伪代码:frps 处理 HTTP 请求
func handleHTTPRequest(conn net.Conn) {
// 1. 读取 HTTP 请求
reader := bufio.NewReader(conn)
request, _ := http.ReadRequest(reader)
// 2. 提取域名
host := request.Host // "app1.example.com"
// 去除端口号(如果有)
host = strings.Split(host, ":")[0]
// 3. 查找隧道
tunnel := tunnelRegistry.Get(host)
if tunnel == nil {
// 返回 404
conn.Write([]byte("HTTP/1.1 404 Not Found\r\n\r\n"))
return
}
// 4. 转发请求
tunnel.Send(request)
response := tunnel.Recv()
conn.Write(response)
}
三、HTTPS 协议层面(443 端口)
3.1 HTTPS 的挑战
HTTPS 在建立连接时立即进行 TLS 握手,此时 HTTP 请求头还未发送,整个通信过程都是加密的。那么如何区分不同的域名呢?
答案是 TLS SNI 扩展。
3.2 TLS SNI(Server Name Indication)
SNI 是 TLS 协议的扩展,允许客户端在 TLS 握手期间(加密开始前)指定要访问的主机名。
TLS ClientHello 消息结构:
┌─────────────────────────────────────────┐
│ Handshake Protocol │
│ ├─ Handshake Type: ClientHello (1) │
│ ├─ Version: TLS 1.2/1.3 │
│ ├─ Random: 32 bytes │
│ ├─ Session ID │
│ ├─ Cipher Suites │
│ ├─ Compression Methods │
│ └─ Extensions: │
│ ├─ server_name (SNI 扩展) │
│ │ └─ Server Name: app1.example.com │ ← 关键!
│ ├─ supported_versions │
│ ├─ signature_algorithms │
│ └─ ... │
└─────────────────────────────────────────┘
关键点:SNI 在 TLS 握手的明文阶段发送,服务器可以在加密前读取到目标域名。
3.3 frps 处理 HTTPS 的流程
┌──────────────────────────────────────────────────────────────┐
│ frps HTTPS 处理流程 │
├──────────────────────────────────────────────────────────────┤
│ 1. 监听 443 端口,等待 TLS 握手 │
│ 2. 解析 ClientHello 中的 SNI 扩展 │
│ 3. 根据 SNI 域名查找对应的证书配置 │
│ 4. 选择对应的隧道(frpc 连接) │
│ 5. 将 TLS 连接透传给对应的 frpc(或由 frpc 处理 TLS) │
│ 6. 数据双向透明转发 │
└──────────────────────────────────────────────────────────────┘
3.4 SNI 解析示例
// 伪代码:解析 TLS SNI
func extractSNI(conn net.Conn) string {
// 读取 TLS ClientHello
buffer := make([]byte, 4096)
n, _ := conn.Read(buffer)
record := buffer[:n]
// 解析 TLS 记录
if record[0] != 0x16 { // Handshake
return ""
}
// 查找 SNI 扩展
// SNI 扩展类型为 0x0000
sni := parseSNIExtension(record)
return sni // "app1.example.com"
}
四、底层数据通信架构
4.1 整体架构图
公网
│
┌──────────┴──────────┐
│ frps │
│ ┌───────────┐ │
│ │ HTTP :80 │◄───────── 用户请求 (Host: app1.example.com)
│ └───────────┘ │
│ ┌───────────┐ │
│ │HTTPS :443 │◄───────── 用户请求 (SNI: app1.example.com)
│ └───────────┘ │
│ │ │
│ ┌───────────┐ │
│ │ 隧道路由表 │ │
│ └───────────┘ │
└──────────┬──────────┘
│
┌────────────────┼────────────────┐
│ │ │
控制连接 数据连接 数据连接
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ frpc-A │ │ frpc-B │ │ frpc-C │
│ app1域名 │ │ app2域名 │ │ api域名 │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐
│本地服务 │ │本地服务 │ │本地服务 │
│ :8080 │ │ :3000 │ │ :9000 │
└─────────┘ └─────────┘ └─────────┘
4.2 控制通道与数据通道
frp 采用 控制通道 + 数据通道 的双通道架构:
控制通道
- frpc 启动时主动连接 frps,建立长连接
- 用于心跳保活、隧道注册、命令传输
- 整个生命周期保持连接
// frpc 注册隧道示例(控制通道消息)
{
"type": "Login",
"version": "0.52.0",
"hostname": "client-001"
}
{
"type": "NewProxy",
"proxy_name": "app1.example.com",
"proxy_type": "http",
"local_ip": "127.0.0.1",
"local_port": 8080,
"subdomain": "app1", // 子域名模式
// 或 custom_domain: "app1.example.com"
}
数据通道
- 用于传输实际的用户请求和响应数据
- 可以复用控制通道(多路复用)
- 也可以建立独立的连接
4.3 多路复用协议
frp 使用 yamux 或 smux 协议实现单连接多路复用:
单个 TCP 连接上的多路复用:
┌────────────────────────────────────────────────┐
│ TCP 连接 (frpc ↔ frps) │
├────────────────────────────────────────────────┤
│ Stream 1: 控制消息(心跳、注册等) │
│ Stream 2: 用户A请求 → app1.example.com │
│ Stream 3: 用户B请求 → app1.example.com │
│ Stream 4: 用户C请求 → app2.example.com │
│ Stream 5: 用户D请求 → app1.example.com │
│ ... │
└────────────────────────────────────────────────┘
每个 Stream 有独立的 ID,可并行传输,互不阻塞。
4.4 完整请求流程时序图
用户 frps 隧道表 frpc 本地服务
│ │ │ │ │
│ TCP连接 │ │ │ │
│───────────→│ │ │ │
│ │ │ │ │
│ HTTP请求 │ │ │ │
│ Host:app1 │ │ │ │
│───────────→│ │ │ │
│ │ 查询 │ │ │
│ │───────────→│ │ │
│ │ app1→frpc-A│ │ │
│ │←───────────│ │ │
│ │ │ │ │
│ │ 创建Stream │ │ │
│ │────────────────────────→│ │
│ │ 转发HTTP请求│ │ │
│ │────────────────────────→│ 转发请求 │
│ │ │ │────────────→│
│ │ │ │ │
│ │ │ │←────────────│
│ │ │ │ 返回响应 │
│ │←────────────────────────│ │
│ │ Stream数据 │ │ │
│ │ │ │ │
│←───────────│ HTTP响应 │ │ │
│ │ │ │ │
五、frp 配置详解
5.1 frps 服务端配置
# frps.toml
bindPort = 7000 # 控制通道端口
vhostHTTPPort = 80 # HTTP 虚拟主机端口
vhostHTTPSPort = 443 # HTTPS 虚拟主机端口
# 认证配置
auth.method = "token"
auth.token = "your-secret-token"
# 域名配置
subdomainHost = "example.com" # 子域名后缀
5.2 frpc 客户端配置
# frpc.toml
serverAddr = "your-server-ip"
serverPort = 7000
auth.method = "token"
auth.token = "your-secret-token"
# HTTP 代理配置
[[proxies]]
name = "web-app1"
type = "http"
localIP = "127.0.0.1"
localPort = 8080
subdomain = "app1" # 访问地址: app1.example.com
# HTTPS 代理配置(需要证书)
[[proxies]]
name = "web-secure"
type = "https"
localIP = "127.0.0.1"
localPort = 8443
subdomain = "secure" # 访问地址: secure.example.com
5.3 隧道注册流程
1. frpc 启动,连接 frps:7000
2. 发送认证信息
3. 发送代理注册请求:
- 代理名称
- 代理类型
- 本地地址和端口
- 域名信息(subdomain 或 customDomains)
4. frps 接收注册,更新隧道表:
┌─────────────────────┬─────────────┬─────────────┐
│ 域名 │ frpc ID │ 本地地址 │
├─────────────────────┼─────────────┼─────────────┤
│ app1.example.com │ conn-001 │ 127.0.0.1:8080│
│ app2.example.com │ conn-002 │ 127.0.0.1:3000│
│ secure.example.com │ conn-001 │ 127.0.0.1:8443│
└─────────────────────┴─────────────┴─────────────┘
5. 注册完成,开始转发流量
六、HTTPS 证书处理方案
6.1 方案一:frps 集中管理证书
frps 服务器持有所有域名的证书,负责 TLS 终止:
用户 ──TLS──→ frps (证书) ──HTTP──→ frpc ──HTTP──→ 本地服务
TLS终止点
配置示例:
# frps.toml
vhostHTTPSPort = 443
# 证书配置
[[httpPlugins]]
name = "https2http"
addr = "127.0.0.1:443"
# 或使用插件管理证书
6.2 方案二:透传模式
frps 仅根据 SNI 路由,TLS 由 frpc 或本地服务处理:
用户 ──TLS──→ frps (透传) ──TLS──→ frpc ──TLS──→ 本地服务
仅路由 完整TLS隧道
配置示例:
# frpc.toml
[[proxies]]
name = "https-proxy"
type = "https"
localIP = "127.0.0.1"
localPort = 443 # 本地 HTTPS 服务
customDomains = ["app.example.com"]
6.3 方案三:使用 acme 自动证书
frp 支持集成 Let's Encrypt 自动证书:
# frps.toml
vhostHTTPSPort = 443
# ACME 配置
[[httpPlugins]]
name = "acmeHost"
addr = "127.0.0.1:443"
七、与传统反向代理的对比
7.1 Nginx 反向代理
传统 Nginx 架构
用户 ──→ Nginx ──→ 后端服务器 (同一内网)
反向代理 直接可达
Nginx 配置:
server {
listen 80;
server_name app1.example.com;
location / {
proxy_pass http://192.168.1.100:8080; # 后端必须在可达网络
}
}
7.2 frp 内网穿透
frp 架构
用户 ──→ frps ──→ frpc ──→ 本地服务
公网 主动连接 内网
(穿透NAT)
7.3 关键区别
| 特性 | Nginx | frp |
|---|---|---|
| 后端位置 | 必须可达 | 任意网络 |
| 连接方向 | Nginx→后端 | frpc→frps |
| NAT 穿透 | 不支持 | 支持 |
| 适用场景 | 数据中心内 | 内网穿透 |
| 配置复杂度 | 需配置每个代理 | 客户端自主注册 |
| 证书管理 | 手动配置 | 可自动化 |
八、源码关键实现
8.1 路由表的实现
(1) 相关数据结构的定义
在 frp 项目的 pkg/util/vhost/router.go 文件下,第 15 到 18 行,定义了路由表的数据结构:
// 这里的 Router 就是下面的 Router 结构体
type routerByHTTPUser map[string][]*Router
type Routers struct {
// 域名 -> (HTTP用户 -> 路由列表)
indexByDomain map[string]routerByHTTPUser
// 读写锁保护并发访问
mutex sync.RWMutex
}
type Routers struct {
indexByDomain map[string]routerByHTTPUser
mutex sync.RWMutex
}
Router 结构体的定义如下:
type Router struct {
domain string // 域名
location string // 路径前缀,如 "/api"
httpUser string // HTTP Basic Auth 用户名
payload any // 携带的数据(实际是 Listener)
}
Listener是frp中负责接收外部请求的组件。比如HttpListener负责监听80端口,处理所有进来的HTTP请求;TCPListener监听某个端口,转发TCP流量......
Router是 路由器,匹配规则和目标绑定,他的功能是通过payload找到真正的处理者(Listener)
这里使用any类型,是为了避免编译时循环引用,这里我先不做继续深入的研究了
(2) 路由查找算法
Routers.Get() 方法实现了路由查找逻辑:
// 这个 get 方法,用于根据 主机名、路径、HTTP用户 查找一个匹配的路由配置项(*Router)
func (r *Routers) Get(host, path, httpUser string) (vr *Router, exist bool) {
host = strings.ToLower(host) // 域名不区分大小写
// 根据域名匹配主机
routersByHTTPUser, found := r.indexByDomain[host]
if !found {
return nil, false
}
// 匹配这个 httpUser 的 routers
vrs, found := routersByHTTPUser[httpUser]
if !found {
return nil, false
}
// 根据 prefix,查找最终匹配的 *Router
for _, vr = range vrs {
if strings.HasPrefix(path, vr.location) {
return vr, true
}
}
return nil, false
}
路由表索引层级关系:
indexByDomain (map)
┌─────────────────────────────────────────────────────────┐
│ "example.com" → routerByHTTPUser (map) │
│ ┌────────────────────────────────────┐ │
│ │ "admin" → []*Router │ │
│ │ ├─ Router{location: "/"} │ │
│ │ └─ Router{location: "/api"}│
│ │ "user" → []*Router │ │
│ └────────────────────────────────────┘ │
│ "*.example.com" → routerByHTTPUser │
│ "*" → routerByHTTPUser │
└─────────────────────────────────────────────────────────┘
设计原因:
| 设计选择 | 原因 |
|---|---|
map[string]routerByHTTPUser |
O(1) 时间复杂度按域名查找,避免遍历 |
| 二级 map 按 HTTPUser 分类 | 支持同一域名不同用户的隔离路由 |
每个用户下是 []*Router 切片 |
支持同一域名+用户下的多个路径前缀匹配 |
sync.RWMutex 而非 sync.Mutex |
读多写少场景,读写锁允许多个读并发 |
(3)通配符域名匹配
HTTPReverseProxy.getVhost() 实现了通配符域名的匹配逻辑:
func (rp *HTTPReverseProxy) getVhost(domain, location, routeByHTTPUser string) (*Router, bool) {
// 1. 精确匹配
vr, ok := findRouter(domain, location, routeByHTTPUser)
if ok { return vr, ok }
// 2. 通配符匹配: *.example.com
domainSplit := strings.Split(domain, ".")
for len(domainSplit) >= 3 {
domainSplit[0] = "*"
domain = strings.Join(domainSplit, ".")
vr, ok = findRouter(domain, location, routeByHTTPUser)
if ok { return vr, true }
domainSplit = domainSplit[1:]
}
// 3. 全局通配符: *
vr, ok = findRouter("*", location, routeByHTTPUser)
return vr, ok
}
匹配优先级设计:
请求域名: app1.prod.example.com
查找顺序(从精确到宽泛):
1. app1.prod.example.com (精确匹配)
2. *.prod.example.com (一级通配)
3. *.example.com (二级通配)
4. * (全局通配)
为什么这样设计?
- 更具体的配置应该优先于通用的配置
- 允许用户为特定子域名配置特殊规则
- 同时保留通配符作为默认/后备规则
8.2 SNI 解析实现
(1)从 HTTPS 连接提取域名
在 pkg/util/vhost/https.go 中,frp 利用 Go 标准库的 TLS 握手机制提取 SNI:
func GetHTTPSHostname(c net.Conn) (_ net.Conn, _ map[string]string, err error) {
reqInfoMap := make(map[string]string, 0)
// 关键:创建共享连接,允许数据被多次读取
sc, rd := libnet.NewSharedConn(c)
clientHello, err := readClientHello(rd)
if err != nil {
return nil, reqInfoMap, err
}
reqInfoMap["Host"] = clientHello.ServerName // SNI 域名
reqInfoMap["Scheme"] = "https"
return sc, reqInfoMap, nil
}
为什么需要 SharedConn?
原始连接的数据流向:
┌──────────┐ ClientHello ┌──────────┐
│ Client │ ───────────────→ │ Server │
└──────────┘ └──────────┘
问题:TLS ClientHello 被读取后,数据就从 socket 缓冲区消失了
解决方案:SharedConn
┌──────────┐ ClientHello ┌─────────────┐
│ Client │ ───────────────→ │ SharedConn │
└──────────┘ │ ┌─────────┐ │
│ │ buffer │ │ ← 数据被复制一份到缓冲区
│ └─────────┘ │
└──────┬──────┘
│
┌─────────────────┼─────────────────┐
│ │ │
↓ ↓ ↓
readClientHello 后续TLS握手 业务数据读取
(读取缓冲区) (读取缓冲区) (读取缓冲区)
(2)利用 Go TLS 库解析 ClientHello
frp 使用了一种巧妙的方式,不手动解析 TLS 协议,而是利用 Go 标准库的回调机制提取 SNI:
func readClientHello(reader io.Reader) (*tls.ClientHelloInfo, error) {
var hello *tls.ClientHelloInfo
// 创建一个"假"的 TLS 服务器
err := tls.Server(readOnlyConn{reader: reader}, &tls.Config{
GetConfigForClient: func(argHello *tls.ClientHelloInfo) (*tls.Config, error) {
// 这是关键:在握手过程中捕获 ClientHello
hello = &tls.ClientHelloInfo{}
*hello = *argHello // 复制一份(因为 argHello 是临时的)
return nil, nil // 返回 nil 终止握手(我们只需要读取)
},
}).Handshake()
if hello == nil {
return nil, err
}
return hello, nil
}
readOnlyConn 是什么?
// 这是一个适配器,把 io.Reader 包装成 net.Conn
type readOnlyConn struct {
reader io.Reader
}
func (c readOnlyConn) Read(b []byte) (int, error) { return c.reader.Read(b) }
func (c readOnlyConn) Write(b []byte) (int, error) { return 0, errors.New("read only") }
func (c readOnlyConn) Close() error { return nil }
func (c readOnlyConn) LocalAddr() net.Addr { return nil }
func (c readOnlyConn) RemoteAddr() net.Addr { return nil }
func (c readOnlyConn) SetDeadline(t time.Time) error { return nil }
func (c readOnlyConn) SetReadDeadline(t time.Time) error { return nil }
func (c readOnlyConn) SetWriteDeadline(t time.Time) error { return nil }
8.3 HTTP 反向代理实现
(1)HTTPReverseProxy 结构
type HTTPReverseProxy struct {
proxy *httputil.ReverseProxy // Go 标准库的反向代理
vhostRouter *Routers // 路由表
responseHeaderTimeoutS int64 // 响应头超时
}
type RouteConfig struct {
Domain string
Location string
RewriteHost string // 是否重写 Host 头
Headers map[string]string // 添加的请求头
ResponseHeaders map[string]string // 添加的响应头
Username string // HTTP Basic Auth
Password string
CreateConnFn func(remoteAddr string) (net.Conn, error) // 创建到 frpc 的连接
}
(2)ServeHTTP 处理流程
func (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// 1. 提取域名(去除端口号)
domain, _ := httppkg.CanonicalHost(req.Host) // "example.com:8080" → "example.com"
location := req.URL.Path
user, passwd, _ := req.BasicAuth()
// 2. 认证检查
if !rp.CheckAuth(domain, location, user, user, passwd) {
rw.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
rw.WriteHeader(401)
rw.Write([]byte("401 Unauthorized"))
return
}
// 3. 注入路由信息到请求上下文
newreq := rp.injectRequestInfoToCtx(req)
// 4. 根据请求方法分发
if req.Method == http.MethodConnect {
rp.connectHandler(rw, newreq) // HTTPS 代理(CONNECT 方法)
} else {
rp.proxy.ServeHTTP(rw, newreq) // 普通 HTTP 请求
}
}
(3)为什么用回调函数 CreateConnFn?
// 反向代理需要创建到后端的连接,这里通过回调实现
func (rp *HTTPReverseProxy) createTransport() *http.Transport {
return &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
routeConfig := ctx.Value("routeConfig").(*RouteConfig)
return routeConfig.CreateConnFn(remoteAddr)
},
}
}
设计原因:
- 解耦:HTTPReverseProxy 不知道 frpc 的存在,连接创建逻辑由 HTTPProxy 提供
- 灵活性:可以返回不同类型的连接(加密、压缩等),方便测试(可以注入 mock 函数)
- 生命周期管理:HTTPProxy 管理连接池,可以实现连接复用
8.4 Muxer 多路复用器
(1)核心数据结构
type Muxer struct {
listener net.Listener // 底层监听器(如 :443)
timeout time.Duration // 握手超时
vhostFunc muxFunc // 域名提取函数(HTTP/HTTPS 不同)
registryRouter *Routers // 路由表
}
// muxFunc 类型定义
type muxFunc func(c net.Conn) (net.Conn, map[string]string, error)
为什么 vhostFunc 是函数类型?
// HTTP 和 HTTPS 的域名提取方式完全不同
// HTTP: 解析 Host 头
func GetHTTPRequestInfo(c net.Conn) (net.Conn, map[string]string, error) {
req, err := http.ReadRequest(bufio.NewReader(c))
return c, map[string]string{"Host": req.Host}, nil
}
// HTTPS: 解析 SNI
func GetHTTPSHostname(c net.Conn) (net.Conn, map[string]string, error) {
// 解析 TLS ClientHello 中的 ServerName
}
// 使用时注入不同的函数(策略模式)
httpMuxer := &Muxer{vhostFunc: GetHTTPRequestInfo}
httpsMuxer := &Muxer{vhostFunc: GetHTTPSHostname}
(2)连接处理流程
func (v *Muxer) handle(c net.Conn) {
// 1. 设置超时(防止恶意连接占用资源)
c.SetDeadline(time.Now().Add(v.timeout))
// 2. 提取域名
sConn, reqInfoMap, err := v.vhostFunc(c)
if err != nil {
c.Close()
return
}
// 3. 提取路由参数
name := strings.ToLower(reqInfoMap["Host"])
path := strings.ToLower(reqInfoMap["Path"])
httpUser := reqInfoMap["HTTPUser"]
// 4. 查找 Listener
l, ok := v.getListener(name, path, httpUser)
if !ok {
v.failHook(sConn) // 调用失败钩子(如返回 404 页面)
return
}
// 5. 将连接发送给对应的 Listener
l.accept <- c
}
为什么用 channel 传递连接?
type Listener struct {
accept chan net.Conn // 接收连接的 channel
}
func (l *Listener) Accept() (net.Conn, error) {
conn, ok := <-l.accept // 阻塞等待连接
if !ok {
return nil, errors.New("listener closed")
}
return conn, nil
}
这实现了 Go 语言的 net.Listener 接口,好处:
- 兼容标准库,可用
http.Serve(listener, handler) - 多个 goroutine 可以安全地并发 Accept
- 通过 channel 天然支持阻塞/非阻塞模式
8.5 控制连接与工作连接池
(1)连接池设计
type Control struct {
workConnCh chan net.Conn // 工作连接池(channel 实现)
}
// 初始化时创建带缓冲的 channel
func NewControl() *Control {
return &Control{
workConnCh: make(chan net.Conn, 10), // 最多预存 10 个连接
}
}
为什么用 channel 实现连接池?
// 方案对比
// 方案1: slice + mutex(传统方式)
type Pool struct {
conns []net.Conn
mutex sync.Mutex
}
func (p *Pool) Get() net.Conn {
p.mutex.Lock()
defer p.mutex.Unlock()
if len(p.conns) == 0 {
return nil
}
c := p.conns[len(p.conns)-1]
p.conns = p.conns[:len(p.conns)-1]
return c
}
// 方案2: channel(frp 采用)
type Pool struct {
ch chan net.Conn
}
func (p *Pool) Get() net.Conn {
select {
case c := <-p.ch:
return c
default:
return nil
}
}
Channel 方案优势:
- 代码更简洁
- 原生支持阻塞等待
- 可以配合
select实现超时
(2)工作连接获取流程
func (ctl *Control) GetWorkConn() (net.Conn, error) {
// 1. 尝试从池中获取
select {
case workConn, ok := <-ctl.workConnCh:
if ok {
return workConn, nil
}
default:
// 池为空,继续
}
// 2. 发送消息要求 frpc 创建新连接
if err := ctl.msgDispatcher.Send(&msg.ReqWorkConn{}); err != nil {
return nil, err
}
// 3. 等待 frpc 建立连接
select {
case workConn, ok := <-ctl.workConnCh:
if ok {
return workConn, nil
}
case <-time.After(timeout):
return nil, errors.New("timeout")
}
}
完整流程图:
frps (服务端)
┌─────────────────────────────────────────┐
│ │
用户请求 │ HTTPReverseProxy │
──────────────→│ ↓ │
│ GetWorkConn() │
│ ↓ │
│ Control.workConnCh │
│ ↓ ↑ │
│ [池为空?] │ │
│ ↓ │ │
│ Send(ReqWorkConn) ────┘ │
│ ↓ │
│ 等待新连接... │
│ │
└─────────────────────────────────────────┘
↕
控制连接
↕
┌─────────────────────────────────────────┐
│ frpc (客户端) │
│ │
│ 收到 ReqWorkConn 消息 │
│ ↓ │
│ 建立新 TCP 连接到 frps │
│ ↓ │
│ 发送 WorkConn 注册消息 │
│ ↓ │
│ 连接放入 workConnCh │
└─────────────────────────────────────────┘
8.6 关键设计模式总结
(1)使用的设计模式
| 模式 | 应用位置 | 作用 |
|---|---|---|
| 策略模式 | vhostFunc muxFunc |
HTTP/HTTPS 域名提取策略可替换 |
| 回调模式 | CreateConnFn |
解耦代理与连接创建 |
| 生产者-消费者 | workConnCh |
frpc 生产连接,frps 消费连接 |
| 适配器模式 | readOnlyConn |
把 io.Reader 适配成 net.Conn |
(2)并发安全设计
// Routers 的读写锁
type Routers struct {
mutex sync.RWMutex // 读多写少场景用 RWMutex
}
func (r *Routers) Get(...) {
r.mutex.RLock() // 读锁,允许多个并发读
defer r.mutex.RUnlock()
// ...
}
func (r *Routers) Register(...) {
r.mutex.Lock() // 写锁,独占访问
defer r.mutex.Unlock()
// ...
}
(3)内存安全
// SharedConn 避免数据丢失
func GetHTTPSHostname(c net.Conn) (net.Conn, map[string]string, error) {
sc, rd := libnet.NewSharedConn(c) // 创建共享副本
// 从 rd 读取 ClientHello(数据同时保存到缓冲区)
clientHello, err := readClientHello(rd)
// 返回 sc,后续可以重新读取缓冲区中的数据
return sc, reqInfoMap, nil
}
如果不用 SharedConn:
- 读取 ClientHello 后,数据从 socket 消失
- 后续 TLS 握手无法进行(缺少 ClientHello)
- 连接报废
8.7 完整数据流
外部客户端
│
↓ HTTPS/TLS
┌───────────────────┐
│ frps :443 │
│ Muxer.listener │
└─────────┬─────────┘
│
┌─────────↓─────────┐
│ Muxer.handle() │
│ 1. SetDeadline │
│ 2. vhostFunc() │ ←── GetHTTPSHostname 提取 SNI
│ 3. getListener() │ ←── 查路由表
└─────────┬─────────┘
│
┌─────────↓─────────┐
│ Listener.accept │ ←── channel 接收
│ (阻塞等待) │
└─────────┬─────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
↓ ↓ ↓
┌─────────┐ ┌─────────┐ ┌─────────┐
│ frpc-1 │ │ frpc-2 │ │ frpc-3 │
│ (a.com) │ │ (b.com) │ │ (*.com) │
└─────────┘ └─────────┘ └─────────┘
│ │ │
↓ ↓ ↓
内部服务 内部服务 内部服务
九、总结
frp 的 HTTP/HTTPS 虚拟主机技术通过以下核心机制实现:
- HTTP 层面:利用
Host请求头区分不同的域名 - HTTPS 层面:利用 TLS SNI 扩展在加密前获取目标域名
- 连接复用:通过 yamux/smux 协议实现多路复用
- 动态注册:frpc 主动连接 frps 注册隧道,突破 NAT 限制
这种设计使得:
- 单个端口可以服务无限多个域名
- 内网服务无需公网 IP 即可暴露
- 用户访问体验与直接访问无异
- 支持灵活的证书管理方案
参考资料
- frp 官方文档
- RFC 7230 - HTTP/1.1 Message Syntax and Routing
- RFC 6066 - TLS Extensions including SNI
- yamux - Multiplexer Library
