从frp的go源码分析frp是怎么实现https协议分流的

一、frp相关概念

frp是什么?

frp 是一个专注于内网穿透的高性能的反向代理应用,支持 TCP、UDP、HTTP、HTTPS 等多种协议。可以将内网服务以安全、便捷的方式通过具有公网 IP 节点的中转暴露到公网。

frp https的配置

frp实现https穿透的方式,是在server端开启一个监听端口,可以使用vhost_https_port来指定, 这个端口接收到的所有流量,会根据客户端通过custom_domains设置的域名和客户端信息转发到对应的client端,再由client端把数据转发到任意的服务去。

二、文章背景

此文章诞生于作者使用frp搭配nginx实现https流量穿透的时候,使用curl命令的curl -H "Host: example.com" https://loalhost:frp的vhost_https_port,结果curl一直报错https连接错误,经查询frp在GitHub的issue,有人也遇到了这样的问题,但是都是讲的解决方法。

有人猜测是frp只会看url中域名的host,而不遵循规范查看Host。但作者以为不然,这么大的项目不可能不遵循规范,因此这篇文章尝试从源码角度看看frp是如何实现不同https服务的分流的。

具体想知道如何解决curl和nginx如何解决对接frp的问题,可以查询作者专门写的文章,这里只是对frp进行分析。

三、分析

初拿到一个项目,对其还不熟悉,所以首先是对其项目架构的探究。

查看项目目录,发现server文件夹,由于分流这种事情肯定是server端的事情,所以直接点进去,发现里面proxy文件夹下面有https的一个文件,打开分析一波:

打开里面关键性代码,看见run这个方法,这个肯定就是frp要启动https代理一开始调用函数了,函数内容如下:

type HTTPSProxy struct {
*BaseProxy
cfg *config.HTTPSProxyConf
}

func (pxy *HTTPSProxy) Run() (remoteAddr string, err error) {
xl := pxy.xl
routeConfig := &vhost.RouteConfig{}

defer func() {
if err != nil {
pxy.Close()
}
}()
addrs := make([]string, 0)
for _, domain := range pxy.cfg.CustomDomains {
if domain == "" {
continue
}

routeConfig.Domain = domain
l, errRet := pxy.rc.VhostHTTPSMuxer.Listen(pxy.ctx, routeConfig)
if errRet != nil {
err = errRet
return
}
xl.Info("https proxy listen for host [%s]", routeConfig.Domain)
pxy.listeners = append(pxy.listeners, l)
addrs = append(addrs, util.CanonicalAddr(routeConfig.Domain, pxy.serverCfg.VhostHTTPSPort))
}

if pxy.cfg.SubDomain != "" {
routeConfig.Domain = pxy.cfg.SubDomain + "." + pxy.serverCfg.SubDomainHost
l, errRet := pxy.rc.VhostHTTPSMuxer.Listen(pxy.ctx, routeConfig)
if errRet != nil {
err = errRet
return
}
xl.Info("https proxy listen for host [%s]", routeConfig.Domain)
pxy.listeners = append(pxy.listeners, l)
addrs = append(addrs, util.CanonicalAddr(routeConfig.Domain, pxy.serverCfg.VhostHTTPSPort))
}

pxy.startListenHandler(pxy, HandleUserTCPConnection)
remoteAddr = strings.Join(addrs, ",")
return
}

由于我们知道,客户端是通过custom_domains配置项来告诉服务端,该配置下的域名应该往我这里发送的,我们在源码里面又看到custom_Domains,仔细看那段代码:

for _, domain := range pxy.cfg.CustomDomains {
if domain == "" {
continue
}

routeConfig.Domain = domain
l, errRet := pxy.rc.VhostHTTPSMuxer.Listen(pxy.ctx, routeConfig)
if errRet != nil {
err = errRet
return
}
xl.Info("https proxy listen for host [%s]", routeConfig.Domain)
pxy.listeners = append(pxy.listeners, l)
addrs = append(addrs, util.CanonicalAddr(routeConfig.Domain, pxy.s

其就是把custom_domains配置项下面的每个域名,都告诉VhostHTTPSMuxer去监听,看变量名Muxer,合并器的意思。那么显然,是这个类在控制不同https流量的走向,listen只是在注册这个域名。那么我们就看看Listen都干了什么:

// listen for a new domain name, if rewriteHost is not empty  and rewriteFunc is not nil
// then rewrite the host header to rewriteHost
func (v *Muxer) Listen(ctx context.Context, cfg *RouteConfig) (l *Listener, err error) {
l = &Listener{
name: cfg.Domain,
location: cfg.Location,
routeByHTTPUser: cfg.RouteByHTTPUser,
rewriteHost: cfg.RewriteHost,
userName: cfg.Username,
passWord: cfg.Password,
mux: v,
accept: make(chan net.Conn),
ctx: ctx,
}
err = v.registryRouter.Add(cfg.Domain, cfg.Location, cfg.RouteByHTTPUser, l)
if err != nil {
return
}
return l, nil
}

我们从该方法的注释,也知道了该方法是

监听一个新的域名名字。

那么可以确定Muxer就是用来分流的了,查看Muxer类的注释

Muxer is only used for https and tcpmux proxy

可以看出Muxer其实类似一个抽象类的作用,可以给https和tcpmux一起用。翻看Muxer其还有个方法run:

func (v *Muxer) run() {
for {
conn, err := v.listener.Accept()
if err != nil {
return
}
go v.handle(conn)
}
}

有过socket编程经验的就知道这是接受一个新来的连接,可见其接受完链接之后,调用了v.handle这个方法,我们查看handle这个方法

func (v *Muxer) handle(c net.Conn) {
if err := c.SetDeadline(time.Now().Add(v.timeout)); err != nil {
_ = c.Close()
return
}

sConn, reqInfoMap, err := v.vhostFunc(c)
if err != nil {
log.Debug("get hostname from http/https request error: %v", err)
_ = c.Close()
return
}

name := strings.ToLower(reqInfoMap["Host"])
path := strings.ToLower(reqInfoMap["Path"])
httpUser := reqInfoMap["HTTPUser"]
l, ok := v.getListener(name, path, httpUser)
if !ok {
res := notFoundResponse()
if res.Body != nil {
defer res.Body.Close()
}
_ = res.Write(c)
log.Debug("http request for host [%s] path [%s] httpUser [%s] not found", name, path, httpUser)
_ = c.Close()
return
}

xl := xlog.FromContextSafe(l.ctx)
if v.successFunc != nil {
if err := v.successFunc(c, reqInfoMap); err != nil {
xl.Info("success func failure on vhost connection: %v", err)
_ = c.Close()
return
}
}

// if authFunc is exist and username/password is set
// then verify user access
if l.mux.authFunc != nil && l.userName != "" && l.passWord != "" {
bAccess, err := l.mux.authFunc(c, l.userName, l.passWord, reqInfoMap["Authorization"])
if !bAccess || err != nil {
xl.Debug("check http Authorization failed")
res := noAuthResponse()
if res.Body != nil {
defer res.Body.Close()
}
_ = res.Write(c)
_ = c.Close()
return
}
}

if err = sConn.SetDeadline(time.Time{}); err != nil {
_ = c.Close()
return
}
c = sConn

xl.Debug("new request host [%s] path [%s] httpUser [%s]", name, path, httpUser)
err = errors.PanicToError(func() {
l.accept <- c
})
if err != nil {
xl.Warn("listener is already closed, ignore this request")
}
}

查看这段代码,就可以看到frp是怎么分流流量的了

name := strings.ToLower(reqInfoMap["Host"])
path := strings.ToLower(reqInfoMap["Path"])
httpUser := reqInfoMap["HTTPUser"]
l, ok := v.getListener(name, path, httpUser)

其是根据reqInfoMap中的Host和Path几个关键字,调用getlistener方法,来获得对应域名真实需要执行的操作的。

那么现在就需要研究reqInfoMap是怎么来的了,其实reqInfoMap的赋值操作就在这段代码的上面的位置:

 sConn, reqInfoMap, err := v.vhostFunc(c)
if err != nil {
log.Debug("get hostname from http/https request error: %v", err)
_ = c.Close()
}

然而由于Muxer的功能更多是个抽象类的作用,因此这个方法并未由Muxer自己实现,而只是一个变量。是创建Muxer实列化的时候赋值进去的。因此我们现在要寻找https的Muxer实列化的位置。

于是乎我们回到最开始的https文件下,用IDE的Find Usage功能查找VhostHTTPSMuxer其初始化的地方。

可见其是由NewHttpsMuxer方法生成的,接着查看该函数:

func NewHTTPSMuxer(listener net.Listener, timeout time.Duration) (*HTTPSMuxer, error) {
mux, err := NewMuxer(listener, GetHTTPSHostname, nil, nil, nil, timeout)
return &HTTPSMuxer{mux}, err
}

其中NewMuxer方法如下:

func NewMuxer(
listener net.Listener,
vhostFunc muxFunc,
authFunc httpAuthFunc,
successFunc successFunc,
rewriteFunc hostRewriteFunc,
timeout time.Duration,
) (mux *Muxer, err error) {
mux = &Muxer{
listener: listener,
timeout: timeout,
vhostFunc: vhostFunc,
authFunc: authFunc,
successFunc: successFunc,
rewriteFunc: rewriteFunc,
registryRouter: NewRouters(),
}
go mux.run()
return mux, nil
}

可以看到HttpsMuxer实质上就是对Muxer的实现,在调用NewMuxer方法时,实际上在创建完mux之后,函数又调用了mux.run方法,正是我们上文已经查看过的run函数。

对照NewMuxer和NewHttpsMuxer,我们就知道实际的vhostFunc,是函数GetHTTPSHostname,我们查看这个函数:

func GetHTTPSHostname(c net.Conn) (_ net.Conn, _ map[string]string, err error) {
reqInfoMap := make(map[string]string, 0)
sc, rd := gnet.NewSharedConn(c)

clientHello, err := readClientHello(rd)
if err != nil {
return nil, reqInfoMap, err
}

reqInfoMap["Host"] = clientHello.ServerName
reqInfoMap["Scheme"] = "https"
return sc, reqInfoMap, nil
}

从代码看出reqInfoMap的Host字段的来源,是来自clientHello的ServerName,clientHello又是由函数readClientHello而来,查看该函数:

func readClientHello(reader io.Reader) (*tls.ClientHelloInfo, error) {
var hello *tls.ClientHelloInfo

// Note that Handshake always fails because the readOnlyConn is not a real connection.
// As long as the Client Hello is successfully read, the failure should only happen after GetConfigForClient is called,
// so we only care about the error if hello was never set.
err := tls.Server(readOnlyConn{reader: reader}, &tls.Config{
GetConfigForClient: func(argHello *tls.ClientHelloInfo) (*tls.Config, error) {
hello = &tls.ClientHelloInfo{}
*hello = *argHello
return nil, nil
},
}).Handshake()

if hello == nil {
return nil, err
}
return hello, nil
}

这就是我们要找的最终函数了,host就是从hello变量里面得到的。hello又是从tls的clientHello中获得的。

那么问题什么是clientHello呢,这就要涉及到https的握手过程了。

四、https握手阶段中的clientHello

从网上随便扒过来的一张https协议握手过程的图,已经能很好的说明了。首先在tcp握手结束之后,客户端会首先发送一个clientHello信息。

如图中所示,该阶段会发送客户端支持的加密算法以及随机生成的密码,其实还会发送图中没有标明的拓展字段。

这个拓展字段中有什么呢?其中一个重要的拓展字段就是,自tls 1.1之后,提出的SNI(Server Name Indication)字段。

SNI字段是为了解决,在https设计之初,没有想到一个服务器会运行多个服务,因此在之前访问一个服务器,服务器只能默认返回指定的一个证书的问题。因此就需要东西来让https能正确返回对应的证书,但由于https是全加密的,这包括了请求头,如果请求不用私钥解密,是无法看到头部Header的,然而不告诉服务器当前需要的是哪个证书,服务器又没办法用私钥来解密。

为了解决这个问题,客户端可以在clientHello的拓展字段SNI来标明当前访问的服务。

五、结论

通过查找源码我们可以发现,frp是完全遵循规范的,其就是读取https握手阶段clientHello里面的SNI拓展字段中标明的域名,来区分不同服务,将其发送到对应的frp的客户端去。