内网穿透 HTTP协议路由技术剖析

前言

frp(Fast Reverse Proxy)是一个高性能的反向代理应用,可以帮助将内网服务暴露到公网。其中最常用的功能之一就是通过 80 和 443 端口提供 HTTP/HTTPS 服务,并通过域名区分不同的隧道。本文将深入探讨其底层实现原理。

一、核心概念:基于域名的虚拟主机

1.1 问题背景

在传统的网络环境中,我们面临几个挑战:

  1. 性能限制:便宜好用的服务器一般性能不够好,服务跑不动
  2. 内网穿透:内网服务无法直接被外网访问
  3. 多服务共存:需要在同一端口上运行多个不同的服务

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 使用 yamuxsmux 协议实现单连接多路复用:

单个 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)
}

Listenerfrp 中负责接收外部请求的组件。比如 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)
        },
    }
}

设计原因:

  1. 解耦:HTTPReverseProxy 不知道 frpc 的存在,连接创建逻辑由 HTTPProxy 提供
  2. 灵活性:可以返回不同类型的连接(加密、压缩等),方便测试(可以注入 mock 函数)
  3. 生命周期管理: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 接口,好处:

  1. 兼容标准库,可用 http.Serve(listener, handler)
  2. 多个 goroutine 可以安全地并发 Accept
  3. 通过 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 方案优势:

  1. 代码更简洁
  2. 原生支持阻塞等待
  3. 可以配合 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:

  1. 读取 ClientHello 后,数据从 socket 消失
  2. 后续 TLS 握手无法进行(缺少 ClientHello)
  3. 连接报废

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 虚拟主机技术通过以下核心机制实现:

  1. HTTP 层面:利用 Host 请求头区分不同的域名
  2. HTTPS 层面:利用 TLS SNI 扩展在加密前获取目标域名
  3. 连接复用:通过 yamux/smux 协议实现多路复用
  4. 动态注册:frpc 主动连接 frps 注册隧道,突破 NAT 限制

这种设计使得:

  • 单个端口可以服务无限多个域名
  • 内网服务无需公网 IP 即可暴露
  • 用户访问体验与直接访问无异
  • 支持灵活的证书管理方案

参考资料

阅读剩余
THE END