运维 go frp 从frp的go源码分析frp是怎么实现https协议分流的 Joshua 2023-01-03 2023-09-29 一、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都干了什么:
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 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 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的客户端去。