frp
frp copied to clipboard
反馈一个关于关于MTU值引起的frp穿透问题
Issue is only used for submiting bug report and documents typo. If there are same issues or answers can be found in documents, we will close it directly. (为了节约时间,提高处理问题的效率,不按照格式填写的 issue 将会直接关闭。)
Use the commands below to provide key information from your environment: You do NOT have to include this information if this is a FEATURE REQUEST
What version of frp are you using (./frpc -v or ./frps -v)?
v0.21.0
What operating system and processor architecture are you using (go env
)?
Frps: Lede X86_64版 Frpc: ubuntu 14.04
Configures you used:
[web-bs] type = http local_ip = 127.0.0.1 local_port = 80
use_compression = true
custom_domains = www.mydomain.com
[ssh-bs] type = tcp local_ip = 127.0.0.1 local_port = 22 remote_port = 10022
Steps to reproduce the issue: 1.两个光猫的MTU值不同,其中一个是1500,另外一个1492 2.Ubuntu系统默认的MTU值为1500 3.内网连接SSH正常,frp内网穿透连接SSH异常
Describe the results you received:
其中网络的MTU值1492的那台,出现一个奇怪的故障, 内网访问完全正常;外网出错,访问部分html页面会出错(内网完全正常); 并且内网SSH连接正常,而经过Frp外网穿透的SSH连接断开,原因不明
Describe the results you expected:
Additional information you deem important (e.g. issue happens only occasionally):
Can you point out what caused this issue (optional) 不知道这个算不算BUG, 猜测与Ubuntu的默认的MTU值1500>外网的MTU值1492有关; 尝试修改Ubuntu的MTU值为1492,故障解决
相关的错误和Log记录在我的Blog上有记录 https://www.cainiao.io/archives/820
遇到同样的情况。
可以 tcpdump 分别在 frpc 和 frps 的机器上抓下包导出 pcap 文件格式,方便的话可以发出来我分析下。我这边测试没有复现这样的问题。
我遇到一样的问题,情境是这样:
当双方mtu都是1500,但是中间某个路由器MTU小于1500
防火墙又过滤ICMP封包导致Path MTU Discovery失败,就会造成此情况
┌────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐
│ frps │ │ Router │ │ Firewall │ │ frpc │
├────────┤ ├──────────┤ │ ICMP │ ├────────┤
│mtu:1500├──┤ mtu:1492 ├────┤ Blocked ├─┤mtu:1500│
└────────┘ └──────────┘ └──────────┘ └────────┘
以下是我的推测
正常情况,TCP连线建立时,内核会读取本地出口网卡的MTU,在SYN包里设置MSS(Maximum Segment Size)
服务器收到SYN,也会读取入口网卡的MTU,在返回的SYN+ACK里面设置MSS
双方交换完对方的MSS以后,就用小的值作为传输的MSS
如果今天双方网卡MTU都很大,但是中间某一跳路由器MTU很小怎么办?
此时就会用到Path MTU Discovery。
这个中间路由器就会丢弃该封包,并返回ICMP Fragmentation Needed (Type 3, Code 4)
收到以后,内核就会减少这个tcp session的MSS,并重新传输该封包
但是现今,很多公有云商家防火墙默认禁止ICMP传输。还有很多地方,例如公司网路,为了安全性也不允许ICMP传输
此时Path MTU Discovery就会失败,内核不知道要缩小MSS,导致丢包,进而导致整个tcp session中断
我的具体表现为通过frp,ssh连线成功以后,执行这行
python3 -c "print('A'*1350)"
1350逐渐增大,当他大于中间设备的MTU之时,连线中断
或是cat
一个大档案,htop
之类,都会导致连线中断
大部分的情况,可以在其中一边把网卡MTU手动调小来解决问题。 TCP握手的时候,linux kernel取网卡MTU,直接用了小的MSS 双方会交换MSS之后以,小的为准,这个session就会用这个MTU
但是部分时候不允许这么做,例如网路上的docker容器服务,没有CAP_NET_ADMIN
。
还有一些情境,比如办公设备,没有root权限,不能更动设备MTU
在这些场景下,frp的tcp模式變得完全无法使用,十分不便
Feature Request
增加一个设定档选项Custom MSS
当启用时,tcp模式下,linux底下使用setsockopt API,在connect()或listen()以前,先使用
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
int mss = 1400;
setsockopt(sockfd , IPPROTO_TCP, TCP_MAXSEG, &mss, sizeof(mss))
不要读取网卡MTU,而是手动设定MSS。之后tcp连线建立时,SYN就会带有我们指定的MSS了。 再之后内核就会按照tcp协议,用指定的MSS自动帮我们分包了
Custom MSS
還可以允許3種情況:
-
None
: 默認值,不使用setsockopt
。MSS全權交給kernel決定 -
Int
: 使用setsockopt
手工設定MSS -
Auto
: 建立一條測試用tcp,一端是echo端,一端發送測試資料- 第一步檢查Path MTU Discovery有沒有正常運作
- 若正常運作(一次寫入10000byte,收到10000byte返回,而且tcp沒斷),使用
None模式
建立真正tcp - 若傳輸失敗,逐步增加傳輸資訊量,找出適合的MSS以後使用
Int模式
建立真正的tcp
其他平台要怎么弄可能还要调查一下。 windows好像也是类似,这边提到
That should be standard setsockopt with TCP_MAXSEG. Just remember that this has to be done before connection is initiated (i.e. before connect or listen) and that TCP stack might change the actual value according to path MTU.
如果平台不支援这个选项(正常来说只要一边有支援就好,标准tcp交换MSS以后要用小的为准),就只能用备援方案了
服务端/客户端会先交换MSS,之后这个session在每次send的时候,单次最多只发送设定好byte数,剩下的留在buffer内,来解决这个问题。
虽然这样做性能可能会比较不好,需要自己维护一个buffer
每次都send()->flush()->send()->flush()来保证单个封包不超过指定大小
@KusakabeSi frp 版本是多少 ?
frps 0.37.1 frpc 0.37.1
@KusakabeSi 感谢你上述的说明,很有帮助。
按照你上面描述的路径,服务端和客户端的 MTU 都是 1500,中间路由器是 1492,且禁止 ICMP 包,那么不仅仅是 frp,其他所有应用都会受到影响。那么,是不是不应该仅仅在 frp 层面去解决这一类问题,而是当做一个通用的问题去解决?
在 Go 中,由于跨平台的特性,要设置这些参数通常比较复杂,我们尽量不考虑这一类需要针对不同平台/系统去分别设置的场景。
当然,还有一个折中的方案,可以使用 KCP 协议作为 frp 的通信协议,底层是 UDP。KCP 层面的 MTU 目前设置的是 1350,这个倒是可以开放出来允许配置。你可以测试一下使用 KCP 的场景下,你上述的问题是否还存在?
測試過了,kcp正常使用 但是kcp有比較耗cpu,或是QoS,或因不公平競爭,被某些服務商禁止使用,等等的其他問題
沒錯,其他基於tcp,而且通過這個mtu 1492節點的應用都會受到影響。 因為我家是PPPoE撥號,mtu 1492 但是我的本地網卡的mtu也是1492,所以本地應用一直都沒問題
我這裡mtu 1500的網卡是docker裡面的網卡,frps運行在docker內部 但是大部分後端都放行ICMP,Path MTU Discovery正常運作,所以我也沒發現這個問題 直到我最近把frpc佈署在一個擋掉icmp的容器裡面,才發現這個問題
以我個人的use case來說,可以把本地docker裡面的網卡mtu設置成1492解決
┌────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐
│ frps │ │ Router │ │ Firewall │ │ frpc │
├────────┤ ├──────────┤ │ ICMP │ ├────────┤
│mtu:1492├──┤ mtu:1492 ├────┤ Blocked ├─┤mtu:1500│
└────────┘ └──────────┘ └──────────┘ └────────┘
但是我想到某些場景,例如公司網路(為了安全性擋掉icmp+防止p2p擋掉udp)+公司電腦(本地無root),就有用的上這個配置的必要了 雖然我不知道會不會真的有公司用限制這麼多網路配置就是了(笑)
真要實現,可能是平台獨佔的功能了,畢竟setsockopt沒有比較高階的API,只能針對linux/windows/freebsd分別去做適配
~linux應該長這樣~ (更新,這個寫法不行。linux連線分兩步驟,先創建sock,再connect。setsockopt要安插在中間才可以。golang的Dial把創建sock和connect綁在一起了)
package main
import (
"fmt"
"net"
"os"
"syscall"
"github.com/higebu/netfd"
)
func main() {
sock, err := net.Dial("tcp", "1.1.1.1:80")
if err != nil {
fmt.Println("error in create socket:", err)
os.Exit(1)
}
mss := 1400
fd := netfd.GetFdFromConn(sock)
err = syscall.SetsockoptInt(fd, syscall.IPPROTO_TCP, syscall.TCP_MAXSEG, mss)
if err != nil {
fmt.Println("error in setting priority option on socket:", err)
os.Exit(1)
}
//剩下的東西
sock.Write([]byte("GET /\n"))
buffer := make([]byte, 10000)
sock.Read(buffer)
fmt.Println(string(buffer[:]))
}
試著弄了個linux客戶端實現,要自己實現一個net.Dial,不能用官方給的
因為linux連線分兩步驟,先創建socket,再connect。
setsockopt要安插在中間才可以。
然而golang的Dial把socket()和connect()綁在一起了
所以要自己實現一個Dial,分別呼叫socket->setsockopt->connect
然後再轉成原本的net.Conn物件
當啟用CustomMSS的時候,用自己的dialWithMSS,而不是golang的net.Dial
參考一下main 第二行
一樣拿到net.Conn,之後操作一樣
package main
import (
"fmt"
"net"
"os"
"strconv"
"syscall"
"time"
)
func main() {
mss := 1400
//啟用CustomMSS的時候,用自己的dialWithMSS,而不是golang的net.Dial。一樣拿到net.Conn,之後操作一樣
sock, err := dialWithMSS("tcp4", "1.1.1.1:80", mss)
//sock, err := net.Dial("tcp", "1.1.1.1:80")
if err != nil {
fmt.Println("error in create socket:", err)
os.Exit(1)
}
//剩下的東西,就和普通的net.Dial一樣操作了
sock.Write([]byte("GET /\n"))
buffer := make([]byte, 10000)
sock.Read(buffer)
fmt.Println(string(buffer[:]))
}
type conn struct {
fd int
f *os.File
net.Conn
}
func (c *conn) Close() error {
if c.Conn != nil {
c.Conn.Close()
}
if c.f != nil {
err := c.f.Close()
c.fd, c.f = -1, nil
return err
}
if c.fd != -1 {
err := syscall.Close(c.fd)
c.fd = -1
return err
}
return nil
}
func sockaddrToString(sa syscall.Sockaddr) string {
switch sa := sa.(type) {
case *syscall.SockaddrInet4:
return net.JoinHostPort(net.IP(sa.Addr[:]).String(), strconv.Itoa(sa.Port))
case *syscall.SockaddrInet6:
return net.JoinHostPort(net.IP(sa.Addr[:]).String(), strconv.Itoa(sa.Port))
default:
return fmt.Sprintf("(unknown - %T)", sa)
}
}
// dialWithMSS dials a TCP connection to the specified host and sets marking on the
// socket. The host must be given as an IP address. A mark of zero results in a
// normal (non-marked) connection.
// https://github.com/google/seesaw/blob/master/healthcheck/dial.go
func dialWithMSS(network, addr string, mss int) (nc net.Conn, err error) {
timeout := time.Second * 15
mark := 0
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ip := net.ParseIP(host)
if ip == nil {
return nil, fmt.Errorf("invalid IP address %q", host)
}
p, err := strconv.ParseUint(port, 10, 16)
if err != nil {
return nil, fmt.Errorf("invalid port number %q", port)
}
var domain int
var rsa syscall.Sockaddr
switch network {
case "tcp4":
domain = syscall.AF_INET
if ip.To4() == nil {
return nil, fmt.Errorf("invalid IPv4 address %q", host)
}
sa := &syscall.SockaddrInet4{Port: int(p)}
copy(sa.Addr[:], ip.To4())
rsa = sa
case "tcp6":
domain = syscall.AF_INET6
if ip.To4() != nil {
return nil, fmt.Errorf("invalid IPv6 address %q", host)
}
sa := &syscall.SockaddrInet6{Port: int(p)}
copy(sa.Addr[:], ip.To16())
rsa = sa
default:
return nil, fmt.Errorf("unsupported network %q", network)
}
c := &conn{}
defer func() {
if err != nil {
c.Close()
}
}()
c.fd, err = syscall.Socket(domain, syscall.SOCK_STREAM|syscall.SOCK_CLOEXEC, 0)
if err != nil {
return nil, os.NewSyscallError("socket", err)
}
// 自己實現的Dial,在socket()之後,在connect之前插入Setsockopt
err = syscall.SetsockoptInt(c.fd, syscall.IPPROTO_TCP, syscall.TCP_MAXSEG, mss)
if err != nil {
fmt.Println("error in setting priority option on socket:", err)
os.Exit(1)
}
if mark != 0 {
if err := setSocketMark(c.fd, mark); err != nil {
return nil, err
}
}
if err := setSocketTimeout(c.fd, timeout); err != nil {
return nil, err
}
for {
err := syscall.Connect(c.fd, rsa)
if err == nil {
break
}
// Blocking socket connect may be interrupted with EINTR
if err != syscall.EINTR {
return nil, os.NewSyscallError("connect", err)
}
}
if err := setSocketTimeout(c.fd, 0); err != nil {
return nil, err
}
lsa, _ := syscall.Getsockname(c.fd)
rsa, _ = syscall.Getpeername(c.fd)
name := fmt.Sprintf("%s %s -> %s", network, sockaddrToString(lsa), sockaddrToString(rsa))
c.f = os.NewFile(uintptr(c.fd), name)
// When we call net.FileConn the socket will be made non-blocking and
// we will get a *net.TCPConn in return. The *os.File needs to be
// closed in addition to the *net.TCPConn when we're done (conn.Close
// takes care of that for us).
if c.Conn, err = net.FileConn(c.f); err != nil {
return nil, err
}
if _, ok := c.Conn.(*net.TCPConn); !ok {
return nil, fmt.Errorf("%T is not a *net.TCPConn", c.Conn)
}
return c, nil
}
// setSocketMark sets packet marking on the given socket.
func setSocketMark(fd, mark int) error {
if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, mark); err != nil {
return os.NewSyscallError("failed to set mark", err)
}
return nil
}
// setSocketTimeout sets the receive and send timeouts on the given socket.
func setSocketTimeout(fd int, timeout time.Duration) error {
tv := syscall.NsecToTimeval(timeout.Nanoseconds())
for _, opt := range []int{syscall.SO_RCVTIMEO, syscall.SO_SNDTIMEO} {
if err := syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, opt, &tv); err != nil {
return os.NewSyscallError("setsockopt", err)
}
}
return nil
}
可以看到MSS變成1400了
root@docker-aaaaaa /d/l/t/test-mss# tcpdump -nnn -i any "host 1.1.1.1 and port 80"
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on any, link-type LINUX_SLL (Linux cooked v1), capture size 262144 bytes
04:53:54.766663 IP 172.17.0.7.43720 > 1.1.1.1.80: Flags [S], seq 2635177868, win 64400, options [mss 1400,sackOK,TS val 1391984947 ecr 0,nop,wscale 7], length 0
04:53:54.770558 IP 1.1.1.1.80 > 172.17.0.7.43720: Flags [S.], seq 361568098, ack 2635177869, win 65535, options [mss 1460,nop,nop,sackOK,nop,wscale 10], length 0
04:53:54.770574 IP 172.17.0.7.43720 > 1.1.1.1.80: Flags [.], ack 1, win 504, length 0
04:53:54.770644 IP 172.17.0.7.43720 > 1.1.1.1.80: Flags [P.], seq 1:7, ack 1, win 504, length 6: HTTP: GET /
04:53:54.774655 IP 1.1.1.1.80 > 172.17.0.7.43720: Flags [.], ack 7, win 64, length 0
04:53:54.777458 IP 1.1.1.1.80 > 172.17.0.7.43720: Flags [P.], seq 1:156, ack 7, win 64, length 155: HTTP
04:53:54.777464 IP 172.17.0.7.43720 > 1.1.1.1.80: Flags [.], ack 156, win 503, length 0
04:53:54.778084 IP 172.17.0.7.43720 > 1.1.1.1.80: Flags [F.], seq 7, ack 156, win 503, length 0
04:53:54.778875 IP 1.1.1.1.80 > 172.17.0.7.43720: Flags [F.], seq 156, ack 7, win 64, length 0
04:53:54.778883 IP 172.17.0.7.43720 > 1.1.1.1.80: Flags [.], ack 157, win 503, length 0
04:53:54.781752 IP 1.1.1.1.80 > 172.17.0.7.43720: Flags [.], ack 8, win 64, length 0
感覺再frps比較容易實現,可以試著在listen以後,accept以前加入setsockopt
看看是否生效
frps比較容易實現,listen以後,accept以前加入setsockopt,經測試有效。不用自己實現listen和accept了
package main
import (
"flag"
"fmt"
"log"
"net"
"os"
"strconv"
"syscall"
"github.com/higebu/netfd"
)
func main() {
mss := 1400
port := flag.Int("port", 3333, "Port to accept connections on.")
host := flag.String("host", "127.0.0.1", "Host or IP to bind to")
flag.Parse()
l, err := net.Listen("tcp", *host+":"+strconv.Itoa(*port))
if err != nil {
log.Panicln(err)
}
//在這邊插入setsockopt
fd := netfd.GetFdFromListener(l)
err = syscall.SetsockoptInt(fd, syscall.IPPROTO_TCP, syscall.TCP_MAXSEG, mss)
if err != nil {
fmt.Println("error in setting TCP_MAXSEG option on socket:", err)
os.Exit(1)
}
log.Println("Listening to connections at '"+*host+"' on port", strconv.Itoa(*port))
defer l.Close()
for {
conn, err := l.Accept()
if err != nil {
log.Panicln(err)
}
go handleRequest(conn)
}
}
func handleRequest(conn net.Conn) {
log.Println("Accepted new connection.")
defer conn.Close()
defer log.Println("Closed connection.")
for {
buf := make([]byte, 1024)
size, err := conn.Read(buf)
if err != nil {
return
}
data := buf[:size]
log.Println("Read new data from connection", data)
conn.Write(data)
}
}
tcpdump可以看到mss=1400
root@docker-aaaaaa /d/l/t/test-mss# tcpdump -nnn -i any "port 3333"
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on any, link-type LINUX_SLL (Linux cooked v1), capture size 262144 bytes
05:07:53.618371 IP 127.0.0.1.33602 > 127.0.0.1.3333: Flags [S], seq 2136944848, win 65495, options [mss 65495,sackOK,TS val 2653917747 ecr 0,nop,wscale 7], length 0
05:07:53.618381 IP 127.0.0.1.3333 > 127.0.0.1.33602: Flags [S.], seq 1492825370, ack 2136944849, win 65236, options [mss 1400,sackOK,TS val 2653917747 ecr 2653917747,nop,wscale 7], length 0
05:07:53.618391 IP 127.0.0.1.33602 > 127.0.0.1.3333: Flags [.], ack 1, win 512, options [nop,nop,TS val 2653917747 ecr 2653917747], length 0
通用層解決,要嘛修改MTU,要嘛用geneva之類的工具攔截/抓包/修改封包的MSS欄位。但是也有不能使用的時候(分別需要CAP_NET_ADMIN和CAP_NET_RAW權限)
我個人覺得仅仅在 frp 层面去解决这一类问题是有意義的。解決以後就可以在沒有上述權限的情況下,在frp的基礎上,承載原本無法通過的服務了
freebsd的實現應該和linux非常類似
windows的應該也能做到,我在winsock的文檔中有發現還linux的setsockopt功能參數一模一樣的API
除了fd變成Hendle以外。
印象中linux和windows的TCP/IP stack都是抄自BSD,只是要一一適配確實挺麻煩的
Custom MSS
變成linux版獨有功能我倒是感覺還好,大部分情境下frps都架設在linux底下,而且只需要一邊是linux就可以了
真的不行就用WSL吧
不知道能不能做成插件的形式,此插件僅限linux使用之類
So... 有点好奇,作者大大打算怎么解决?
- 无视这个问题
- 使用
setsockopt
- 优点: 使用原本OS的tcp socket,由内核负责分包,性能好
- 缺点: 每个平台需要独立实现一个Dial函数和SetMSS函数
- 没有实现的平台不能使用/无视
CustomMSS
选项,或是fallback到方案3
- 额外维护buffer,c/s沟通好MSS以后,单次最多只发送设定好byte数,剩下的留在buffer内下次发送
- 优点: 相容性好,平台无关
- 缺点: 额外又维护一个buffer,多了不必要的拷贝,增加了数据拷贝的开销,降低性能
- BTW: 记得设定 TCP_NODELAY 避免粘包。幸好这个golang有内建支援,
conn.SetNoDelay(true)
就好
- 其他我没想到的方案
So... 有点好奇,作者大大打算怎么解决?
无视这个问题
使用
setsockopt
- 优点: 使用原本OS的tcp socket,由内核负责分包,性能好
- 缺点: 每个平台需要独立实现一个Dial函数和SetMSS函数
- 没有实现的平台不能使用/无视
CustomMSS
选项,或是fallback到方案3额外维护buffer,c/s沟通好MSS以后,单次最多只发送设定好byte数,剩下的留在buffer内下次发送
- 优点: 相容性好,平台无关
- 缺点: 额外又维护一个buffer,多了不必要的拷贝,增加了数据拷贝的开销,降低性能
- BTW: 记得设定 TCP_NODELAY 避免粘包。幸好这个golang有内建支援,
conn.SetNoDelay(true)
就好其他我没想到的方案
2 和 3 都不是很理想的解决方案,并且上述你描述的场景其实是一个通用的问题,会影响到所有的网络应用,而不是由于某一个项目自身的架构所造成的问题。
在没有找到优雅的解决方案前,我倾向于暂时不解决,目前看上去实际上遇到这样场景的用户非常少,并不值得引入一个不太优雅的配置增加复杂度。
遇到一样的问题。腾讯云搭建frps,海外路由web界面跑frpc。在海外访问内网穿透端口进入路由web界面快,大陆进入路由web界面慢,而且是非常慢,呈现出完全不一样的速度。大陆连腾讯云15ms瞬发跑满没有速度瓶颈。速度不一致猜测是mtu问题。
Issues go stale after 21d of inactivity. Stale issues rot after an additional 7d of inactivity and eventually close.