FRP内网穿透
前沿
什么是内网穿透
内网穿透,又叫 NET 穿透,是计算机用语。用通俗的说法就是你家里的个人电脑,可以直接被外网的人访问。例如你在公司,不通过远程工具,直接也可以访问到家里的电脑
一句话总结:内网穿透就是让外部的互联网主机,可以访问你局域网内的机器上的HTTP, HTTPS, TCP 或 UDP 服务
为什么需要内网穿透
-
一是方便访问某些内网环境,不需要使用远程工具
-
二是方便把个人电脑上的应用开放到外网进行访问
市面上常见内网穿透工具

FRP 内网穿透
基本介绍
frp(Fast Reverse Proxy) 是一个专注于内网穿透的高性能的反向代理应用,可以将内网服务以安全、便捷的方式通过具有公网 IP 节点的中转暴露到公网;只需要通过在具有公网 IP 的节点上部署 frp 服务端,可以轻松地将内网服务穿透到公网,同时提供诸多专业的功能特性,这包括:
-
客户端服务端通信支持 TCP、QUIC、KCP 以及 Websocket 等多种协议
-
采用 TCP 连接流式复用,在单个连接间承载更多请求,节省连接建立时间,降低请求延迟
-
代理组间的负载均衡
-
端口复用,多个服务通过同一个服务端端口暴露
-
支持 P2P 通信,流量不经过服务器中转,充分利用带宽资源
-
多个原生支持的客户端插件(静态文件查看,HTTPS/HTTP 协议转换,HTTP、SOCK5 代理等),便于独立使用 frp 客户端完成某些工作
-
高度扩展性的服务端插件系统,易于结合自身需求进行功能扩展
-
服务端和客户端 UI 页面
功能示意图
支持协议

TCP 多路复用:客户端和服务器端之间的连接支持多路复用,不再需要为每一个用户请求创建一个连接,使连接建立的延迟降低,并且避免了大量文件描述符的占用,使 frp 可以承载更高的并发数
KCP 协议:底层通信协议支持选择 KCP 协议,相比于 TCP,在弱网环境下传输效率提升明显,但是会有一些额外的流量消耗
QUIC 协议:底层通信协议支持选择 QUIC 协议,底层采用 UDP 传输,解决了 TCP 上的一些问题,传输效率更高,连接延迟低
HTTP和HTTPS: HTTP 和 HTTPS 协议的一个特点是发送的请求都具有 Host 字段,通过该字段描述要访问的服务。基于这个特点,frp 服务端只需要监听在一个端口(通过 vhostHTTPPort
和 vhostHTTPSPort
指定)。就可以根据请求的 Host 来决定需要路由给哪一个代理,而不需要像 TCP 类型那样为每一个服务绑定一个端口
Nginx 和 FRP 流量走向区别
PS:这部分内容引用自参考文档(感谢网络活雷锋)
FRP流量请求的处理流程
-
首先,
frpc
启动之后,连接frps
,并且发送一个请求login()
,之后保持住这个长连接,如果断开了,就重试 -
frps
收到请求之后,会建立一个listener
监听来自公网的请求 -
当
frps
接收到请求之后,会在本地看是否有可用的连接(frp
可以设置连接池),如果没有就下发一个msg.StartWorkConn
并且 等待来自frpc
的请求 -
frpc
收到之后,对frps
发起请求,请求的最开始会指明这个连接是去向哪个proxy
的 -
frps
收到来自frpc
的连接之后,就把新建立的连接与来自公网的连接进行流量互转 -
如果请求断开了,那么就把另一端的请求也断开
代码框架
frp 名词
-
visitor
:visitor
是指使用stcp
和xtcp
的时候,请求公网服务器的那台电脑也要装一个客户端,那个就是所谓的visitor
-
control
是用来管理连接用的,比如连接、断开等等 -
workConn
就是指frpc
和frps
所建立的连接
源码解析
Client 逻辑
客户端启动逻辑
Client 先与 frp-Server 建立链接
主程序就做两件事
-
与 frps 建立连接
-
保持controller持续工作,但是这个 keepControllerWorking 的逻辑也就是一直调用 loopLoginUntilSuccess 直到成功
func (svr *Service) Run(ctx context.Context) error {
ctx, cancel := context.WithCancelCause(ctx)
....
// first login to frps
svr.loopLoginUntilSuccess(10*time.Second, lo.FromPtr(svr.common.LoginFailExit))
....
go svr.keepControllerWorking()
....
}
所以我们只需要看 loopLoginUntilSuccess 函数具体干了些什么就行
这里建立连接的逻辑会自动加上重试,一直到重新成功为止
func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginExit bool) {
xl := xlog.FromContextSafe(svr.ctx)
loginFunc := func() (bool, error) {
xl.Info("try to connect to server...")
conn, connector, err := svr.login()
......
基于上面与server建立连接成功后的conn构造了一个sessionCtx
sessionCtx := &SessionContext{
Common: svr.common,
RunID: svr.runID,
Conn: conn,
ConnEncrypted: connEncrypted,
AuthSetter: svr.authSetter,
Connector: connector,
}
利用这个SessionCtx来建立一个新的Ctl
ctl, err := NewControl(svr.ctx, sessionCtx)
....
ctl.SetInWorkConnCallback(svr.handleWorkConnCb)
ctl.Run(proxyCfgs, visitorCfgs)
// close and replace previous control
svr.ctlMu.Lock()
if svr.ctl != nil {
svr.ctl.Close()
}
svr.ctl = ctl
svr.ctlMu.Unlock()
return true, nil
}
// try to reconnect to server until success
这里执行无限重试一只等到frp-client与frp-server建立连接
wait.BackoffUntil(loginFunc, wait.NewFastBackoffManager(
wait.FastBackoffOptions{
Duration: time.Second,
Factor: 2,
Jitter: 0.1,
MaxDuration: maxInterval,
}), true, svr.ctx.Done())
}
消息类型
var msgTypeMap = map[byte]interface{}{
TypeLogin: Login{},
TypeLoginResp: LoginResp{},
TypeNewProxy: NewProxy{},
TypeNewProxyResp: NewProxyResp{},
TypeCloseProxy: CloseProxy{},
TypeNewWorkConn: NewWorkConn{},
TypeReqWorkConn: ReqWorkConn{},
TypeStartWorkConn: StartWorkConn{},
TypeNewVisitorConn: NewVisitorConn{},
TypeNewVisitorConnResp: NewVisitorConnResp{},
TypePing: Ping{},
TypePong: Pong{},
TypeUDPPacket: UDPPacket{},
TypeNatHoleVisitor: NatHoleVisitor{},
TypeNatHoleClient: NatHoleClient{},
TypeNatHoleResp: NatHoleResp{},
TypeNatHoleSid: NatHoleSid{},
TypeNatHoleReport: NatHoleReport{},
}
Server 逻辑
多路复用
http 反向代理支持多路复用
利用 http.client 的参数 transport 来支持连接多路复用
Transport is an implementation of RoundTripper that supports HTTP, HTTPS, and HTTP proxies (for either HTTP or HTTPS with CONNECT).
By default, Transport caches connections for future re-use.
func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *HTTPReverseProxy {
...
proxy := &httputil.ReverseProxy{
// Modify incoming requests by route policies.
Director: func(req *http.Request) {
...
}
// Create a connection to one proxy routed by route policy.
Transport: &http.Transport{
ResponseHeaderTimeout: rp.responseHeaderTimeout,
IdleConnTimeout: 60 * time.Second, 限制每个空闲连接的超时时间
MaxIdleConnsPerHost: 5, 限制单个 Host 最大空闲连接数
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return rp.CreateConnection(ctx.Value(RouteInfoKey).(*RequestRouteInfo), true)
},
....
},
...
}
rp.proxy = proxy
return rp
}
演示Demo
通过 SSH 访问内网机器
实验环境
机器名称 | 机器系统 | 充当角色 | IP |
开发机 | Linux | 代理的 server 端 | 10.37.4.140 |
机器一 | Linux | 代理的 client 端(模拟内网的机器) | 10.88.172.247 |
机器二 | Linux | 代理的 client 端(模拟内网的机器) | 10.88.171.160 |
本地mac | Mac | 模拟通过代理 server 连接内网的机器 | 10.88.225.137 |
具体操作

- 下载工具
- 下载 frp 工具 https://github.com/fatedier/frp/releases/tag/v0.53.2 并解压
- server 端部署
- 修改 frps.toml 文件如下
使用 tcpmux 类型的代理,可以实现多个 SSH 服务通过同一端口进行暴露
bindPort = 7000 # 代表服务代理的端口tcpmuxHTTPConnectPort = 5002
- 启动服务端
./frps -c ./frps.toml
- Client 端部署
- 机器一修改 frpc.toml 文件如下
serverAddr = "10.37.4.140" # 服务端的公网
IPserverPort = 7000 # 服务端开放的端口
[[proxies]]
name = "ssh1"
type = "tcpmux"
multiplexer = "httpconnect"
customDomains = ["machine-a.example.com"]
localIP = "127.0.0.1" # localIP 和 localPort 配置为需要从公网访问的内网服务的地址和端口localPort = 22
- 机器二修改 frpc.toml 文件如下
serverAddr = "10.37.4.140"
serverPort = 7000
[[proxies]]
name = "ssh2"
type = "tcpmux"
multiplexer = "httpconnect"
customDomains = ["machine-b.example.com"]
localIP = "127.0.0.1"
localPort = 22
注意标红色字体的区别
- 启动 Client 端
./frpc -c ./frpc.toml
- 本地mac通过server代理地址访问内网的机器
- mac访问访问两台client盒子
ssh -o 'proxycommand socat - PROXY:10.37.4.140:machine-a.example.com:22,proxyport=5002' root@machine-a
-o 用于指定 SSH 的配置选项proxycommand 指定代理命令,即 socat 命令,用于建立与代理服务器的连接socat 命令通过代理服务器将流量转发到目标主机 machine-a.example.com 的 SSH 服务端口 22,并使用代理端口 5002root@machine-a 是目标主机的连接地址,其中 root 是登录用户名,machine-a 是目标主机的名称
ssh -o 'proxycommand socat - PROXY:10.37.4.140:machine-b.example.com:22,proxyport=5002' root@machine-b
需要安装 socat 软件:socat介绍
显示如上输入代表访问成功
通过自定义域名访问内网的 Web 服务
具体操作
盒子部署服务
我们在其中一个盒子上部署 Nginx 服务来模拟内网的 web 服务
Client 端配置
修改 client 端的 toml 文件配置
serverAddr = "10.37.4.140"
serverPort = 7000
[[proxies]]
name = "web"
type = "http"
localPort = 80
customDomains = ["www.yourdomain.com"]
Server 端配置
bindPort = 7000
vhostHTTPPort = 8080
mac访问内网的服务
手动修改域名解析的地址
cat /etc/hosts
10.37.4.140 yourdomain.com
访问 curl
http://www.yourdomain.com:8080
成功
优秀代码
Panic 转化为 Error
func PanicToError(fn func()) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("Panic error: %v", r)
}
}()
fn()
return
}
err = gerr.PanicToError(func() {
tg.acceptCh <- c
})
判断为空赋值
package util
func EmptyOr[T comparable](v T, fallback T) T {
var zero T
if zero == v {
return fallback
}
return v
}
duration = util.EmptyOr(duration, time.Second)
错误处理
func AppendError(err error, errs ...error) error {
if len(errs) == 0 {
return err
}
return errors.Join(append([]error{err}, errs...)...)
}
待解决问题
p2p 通信(埋个坑:会在下一篇文章解决)
https://gofrp.org/zh-cn/docs/examples/xtcp/
需要实现一个 natHoleStunServer,否则可能报错如下
参考文档
https://www.modb.pro/db/1700051099159973888
https://zhuanlan.zhihu.com/p/654553693
https://github.com/ehang-io/nps/blob/master/README_zh.md
https://github.com/fatedier/frp
使用frp进行内网穿透(内网隧道搭建) - 11阳光 - 博客园
frp v0.5.0 源码分析 - JoXrays’s Blog
https://www.cnblogs.com/skymyyang/p/13392848.html
Go语言http.Transport的连接管理与复用技巧-Golang-PHP中文网
golang net/http HttpClient连接复用的坑位