fectun

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 28, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

README

fectun

socat 式 TCP 端口映射,跨境段走 FEC over UDP。

当链路存在随机丢包时,TCP 的拥塞控制会把丢包误判为拥塞而降速。fectun 用 Reed-Solomon 前向纠错在 UDP 上把丢包补回来,让上层 TCP 看到一条"干净"的链路。

A socat-style TCP port forwarder whose cross-border hop runs over FEC-protected UDP. It uses Reed-Solomon erasure coding to mask random packet loss, so the upper-layer TCP never sees the loss and never backs off.

[客户端] ──TCP──> [fectun client] ══FEC/UDP══> [fectun server] ──TCP──> [目标服务]
                    入口侧                        落地侧

快速开始

# 落地侧(例:把自己的 22 端口暴露出去)
fectun -mode server -target 127.0.0.1:22 -peer <入口侧IP> -uport 55700 -k 20 -m 15 -rate 25

# 入口侧(本地 4422 映射到落地侧的 22)
fectun -mode client -listen 0.0.0.0:4422 -peer <落地侧IP> -uport 55700 -k 20 -m 15 -rate 25

# 然后
ssh -p 4422 user@<入口侧IP>      # 实际登录的是落地侧

两端必须使用同一 UDP 端口(默认 55700),这是打洞所需 —— 见下文「心跳与 conntrack」。

多对端模式(客户端在 NAT 后面、IP 会变)

落地侧不给 -peer,就按 UDP 源地址为每个客户端各建一个会话。客户端用 -lport -1 走临时端口:

# 落地侧:安全组必须放行入站 UDP 55700(没法打洞,事先不知道对端是谁)
FECTUN_KEY=<密钥> fectun -mode server -target 127.0.0.1:22 -uport 55700 -rate 25

# 客户端
FECTUN_KEY=<密钥> fectun -mode client -listen 127.0.0.1:4422 -peer <落地侧IP> -uport 55700 -lport -1
  • k/m 由各客户端自己的 -k/-m 决定(会话按客户端首包建立),落地侧的 -k/-m 在这个模式下不起作用;-rate 是每个会话的发送限速,N 个客户端同时满载时总发送量是 N×rate。
  • 对端 30 秒没有任何包(心跳 100ms 一个)就回收会话;同时最多 64 个会话。
  • 客户端换网后源地址变了,落地侧会当成新对端建新会话,原有连接断开由上层重连。
  • 公网上务必配密钥:不配的话任何人都能经它向 -target 开连接。
作为库嵌入
cli, _ := fectun.Dial("<落地侧IP>:55700", fectun.Options{K: 20, M: 15, RateMbps: 25, Key: key})
conn := cli.OpenStream() // net.Conn,对端 Server 为它连一次 target

同一对端上的多条连接应共用一个 Client:每个 Client 各自按 RateMbps 限速,各建一个会让总速率成倍超标。需要先对 socket 做平台处理(如 Android VpnService.protect)时,自己建 *net.UDPConn 传给 NewClient。

构建

go build -o fectun ./cmd/fectun

# 交叉编译到 Linux 服务器
GOOS=linux GOARCH=amd64 go build -o fectun-linux ./cmd/fectun

依赖仅一个:klauspost/reedsolomon(带 SIMD 优化的 RS 编解码;标准库无 RS 实现,手写 Galois Field 运算既不现实、性能也差)。

参数

参数 默认 说明
-mode client client 监听 TCP;server 转发到 -target
-listen 0.0.0.0:4422 client 模式监听地址
-target 127.0.0.1:22 server 模式转发目标
-peer (client 必填) 对端 IP。server 模式留空 = 多对端
-uport 55700 对端 UDP 端口
-lport 同 -uport 本地 UDP 端口。生产环境两端同端口便于打洞;本地回环测试时需分开;-1 = 临时端口
-key (空) 预共享密钥,两端必须一致;留空读环境变量 FECTUN_KEY。非空时每包带 8 字节 HMAC-SHA256 签名,验签失败直接丢弃;为空时线格式与旧版一致
-k 20 FEC 数据分片数
-m 15 FEC 校验分片数
-rate 25 含冗余的线路限速(Mbps)

净数据吞吐上限 ≈ rate / (1 + m/k)。默认 25 / 1.75 ≈ 14 Mbps。

⚠️ 调参禁忌

-rate 不要超过链路当时的实际容量。 开发中曾把 rate 设到 60 Mbps(而链路实测出口只有 28~48 Mbps),结果把对端入向带宽打满,把同一台机器上的 SSH 会话挤断了。FEC 不会凭空创造带宽,超发只会加剧丢包。

冗余度怎么选

用实测信道模拟出的残余丢包率(丢包近似独立随机时):

配置 带宽开销 9% 丢包残余 22% 丢包残余
10:3 30% 0.78% 11.25% ❌
20:10 50% 0.001% 1.88% ⚠️
20:15 75% 0.000% 0.073% ✅
10:10 100% 0.000% 0.074% ✅

残余丢包 <0.1% 可视为对上层透明;>1% 仍会明显影响 TCP。丢包率会波动时,静态参数必须按最坏情况配。


工作原理

1. FEC(Reed-Solomon)

每 k 个数据分片配 m 个校验分片。接收端收到该组任意 ≥k 片即可重构出全部原始数据 —— 丢哪几片都行。

数据分片立即发出,不等整组攒齐(降低延迟);校验分片在攒满 k 片后补发。

2. NACK-based ARQ 兜底

这一层不能省。 常有人认为"隧道里跑的是 TCP,上层自己会重传,不需要 ARQ" —— 这只对透明转发 TCP 包(VPN 式)成立。fectun 是 socat 式:在入口侧终结 TCP 连接、在落地侧重建,隧道自身承载的是字节流,哪怕 0.001% 的残余丢包也会撕坏流。

所以 FEC 之上还有一层轻量 ARQ:接收端发现序号缺口且 FEC 无法恢复时发 NACK,发送端从缓冲重传。因为 FEC 已吃掉绝大部分丢包,ARQ 极少触发(实测 39125 包中重传 0 次),不需要 KCP 那套复杂的拥塞控制。

3. 心跳与 conntrack

云厂商安全组通常默认拒绝入站 UDP。实测单向发 UDP 会被 100% 丢弃 —— 发 200 个包,对端网卡上一个都抓不到。

解法是双向打洞:两端各自持续发心跳,各自的出站 conntrack 条目会让对方的入站包被识别为"回程"而放行。因此两端必须同时运行、且用同一端口。

心跳还有第二个作用:携带发送端的 nextSeq。没有这个字段,尾部丢包会导致死锁 —— NACK 只在"有后续包到达、发现中间缺口"时触发,而如果丢的正是最后一个包,就永远没有后续包来触发它。SSH 握手全是小消息,这个 bug 会直接让握手卡死。

4. 会话 epoch(对端重启自愈)

每个进程启动时生成随机 epoch 并写进每个包头。收到与记录值不同的 epoch,即判定对端重启过,于是双向重置序号空间并关闭所有 stream(上层自行重连)。

没有这个机制,单端重启会导致永久死锁:重启方 nextSeq 归零,而对端 expected 仍停在旧的高位,新包全被当作"旧包"丢弃 —— 双方都在正常收发,却谁也前进不了。实测重启后 60 秒仍无法恢复;加上 epoch 后 3~5 秒自愈。

5. 令牌桶限速

实测发现:链路丢包率在短时测试中与发送速率无关(20/40/80 Mbps 下都是 ~9%),但持续满负载会把丢包推到 30%,此时连 75% 冗余的 FEC 也会崩。所以必须限速,不能贪心跑满。

6. 多连接复用

在可靠字节流之上再套一层帧协议实现 stream 复用:

[4B streamID][1B cmd][2B length][payload]
cmd: 0=data  1=open  2=close  3=shutdown(半关闭)

shutdown 是必需的:TCP 允许单向关闭,把"本端读到 EOF"当成整条连接结束会导致对端还没回完的数据被丢弃。


实测数据

测试环境:两台云主机跨境互联(约 70ms RTT),2026-07。

链路特性:

  • 丢包严格单向 —— 一个方向零丢包(80 Mbps 下仍 0%),反方向丢 6~22% 波动
  • 近乎独立随机 —— 82.8% 为单包丢失,15.1% 双包,最长突发仅 4 包,平均突发长度 1.10。这是 FEC 的理想场景
  • 短时与速率无关 —— 20/40/80 Mbps 下丢包率一致,说明不是拥塞型丢包,发冗余包不会加剧丢包
  • ICMP 测不准 —— ping 丢包率(13~15%)与真实传输质量无关,ICMP 另有单独限速。不要用 ping 判断这类链路

FEC 效果:

线路原始丢包:   10.822%
FEC 后数据丢包:  0.0000%
2500 组:75 组完整 / 2425 组被恢复 / 0 组失败
解码 CPU 开销:  0.03 秒 / 20 秒的流(约 0.15%)

何时不该用它

如果对端启用了 BBR,而丢包在 ~10% 量级,大概率不需要这个工具。 BBR 不把丢包当拥塞信号,实测 10% 丢包下 BBR 直连仍能跑 2427 Mbps,而本工具因 75% 冗余开销只有 8.99.6 Mbps —— 反而更慢。

FEC 的价值是在链路劣化时兜底,不是提升峰值。值得上的场景是:

  • 丢包率经常超过 15~20%,且拥塞控制算法对丢包敏感(CUBIC/Reno)
  • 对稳定性的要求高于对峰值吞吐的要求

先测清楚你的链路到底有多差、以及对端用的什么拥塞控制算法,再决定。

已知限制

  • 无自适应冗余 —— k:m 是静态的,丢包率波动时只能按最坏情况配,平时浪费带宽
  • 无拥塞控制 —— 靠固定令牌桶限速,不会自动适应链路容量变化
  • 发送缓冲按固定条数清理 —— 极端乱序下可能误删仍需重传的分片
  • 对端重启会断开既有连接 —— epoch 重置后上层需自行重连(ssh 会话会掉)
  • 小帧效率低 —— 每个 mux 帧头会单独占用一个分片,大量小消息时开销明显
  • 单 UDP 会话,未做多路径/故障切换

测试

go test -race ./...

回归测试针对开发中实际踩过的坑,每条都做过红灯验证(移除对应修复后测试必然失败):

测试 覆盖的缺陷
TestPeerRestartRecovery 单端重启后序号失同步导致的永久死锁
TestTailLossIsRecovered 尾部连续丢包无法触发 NACK 的死锁
TestHalfCloseKeepsReadDirection 把读到 EOF 当成整条连接结束,丢弃对端回程数据
TestFECRecoversUnderLoss 15% 随机丢包下的字节流完整性
TestMuxConcurrentStreamsIsolated 多 stream 并发串流
TestMuxFrameSpansShards 帧跨分片边界解析
TestEpochChangeResetsState epoch 变化时收发状态重置

测试用内置的丢包代理(lossyProxy)在本地复现随机丢包与确定性尾部丢包 —— 这几个 bug 只在丢包时才暴露,无丢包环境测不出来。

License

Apache License 2.0

Documentation

Overview

fectun —— socat 式 TCP 隧道,跨境段走 FEC over UDP 设计要点:

  1. FEC(Reed-Solomon)把链路 10~20% 丢包压到近零,大幅减少重传触发
  2. NACK-based ARQ 兜底 FEC 未能恢复的残余丢包(TCP 字节流必须完整有序)
  3. 内建心跳维持 NAT/安全组 conntrack —— 实测单向 UDP 会被阿里云安全组全丢
  4. 令牌桶速率控制 —— 实测持续满负载会把链路丢包从 10% 推到 30%,FEC 会崩

Index

Constants

View Source
const (
	DefaultK        = 20
	DefaultM        = 15
	DefaultRateMbps = 25
)

与 CLI 默认值保持一致。

View Source
const (
	DefaultIdleTimeout = 30 * time.Second
	DefaultMaxPeers    = 64
)

Variables

View Source
var Logf = func(format string, args ...any) {
	fmt.Printf(format+"\n", args...)
}

Logf 是库内所有日志的出口,默认打到 stdout。 嵌入方(例如 stdout 另有用途的进程)可以换成自己的 logger,或置为 nil 静默。

Functions

This section is empty.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client 是入口侧:每个 OpenStream 在隧道里开一条 stream, 对端(Server)为它连一次 target。

一个 Client 独占一个 UDP socket 和一套限速。同一对端上的多条连接应共用 一个 Client,而不是各建一个 —— 否则每个 Client 各按 RateMbps 发, 总速率成倍超出限速。

func Dial

func Dial(peer string, o Options) (*Client, error)

Dial 是 NewClient 的便捷形式:在本地临时端口上开 UDP socket 连到 peer。

func NewClient

func NewClient(conn *net.UDPConn, peer *net.UDPAddr, o Options) (*Client, error)

NewClient 在 conn 上建立到 peer 的隧道。conn 由调用方创建,这样调用方能在 交出之前对 socket 做平台相关处理(例如 Android 的 VpnService.protect)。 Client 接管 conn 的所有权,Close 时一并关闭。

func (*Client) Close

func (c *Client) Close() error

Close 关闭隧道与底层 UDP socket,所有 stream 随之断开。

func (*Client) HandleConn

func (c *Client) HandleConn(conn net.Conn)

HandleConn 把一条现成的连接(例如 CLI 接受的 TCP 连接)接进隧道。 与 OpenStream 的区别是:下游是真 TCP 时半关闭会透传(CloseWrite)。

func (*Client) OpenStream

func (c *Client) OpenStream() net.Conn

OpenStream 开一条新 stream,返回它的本地一端。

读写语义与一条 TCP 连接相同;Close 即结束该 stream。 RemoteAddr 返回隧道对端的 UDP 地址。

func (*Client) Peer

func (c *Client) Peer() *net.UDPAddr

Peer 返回隧道对端地址。

func (*Client) Stats

func (c *Client) Stats() string

Stats 返回一行运行统计,格式见 session.statsLine。

type Options

type Options struct {
	K, M        int
	RateMbps    float64
	RateMinMbps float64
	Key         []byte
}

Options 是一端的 FEC 与限速参数。

K/M 两端必须一致:接收侧按本端的 K 解组,对不上的包直接丢弃。 RateMbps 是本端发送方向的线路限速(含冗余),净数据 ≈ RateMbps/(1+M/K)。 不要超过链路当时的实际容量 —— 超发只会加剧丢包,实测曾把同机 SSH 挤断。

RateMinMbps > 0 时打开拥塞控制:RateMbps 变成上限,实际速率按对端回报的丢包率 在 [RateMinMbps, RateMbps] 内自动调(见 congCtl)。为 0 时按 RateMbps 固定发, 与旧版行为相同。对端是不回反馈的旧版本时也自动退回固定速率。

Key 是可选的预共享密钥:非空时每个包带 HMAC 签名,验签失败的包直接丢弃 (见 packetAuth)。两端必须一致;为空时线格式与旧版相同。

type PeerServer

type PeerServer struct {
	// contains filtered or unexported fields
}

PeerServer 是落地侧的固定对端模式:只与 -peer 指定的一个对端通信。 两端都是云主机、安全组默认拒绝入站 UDP 时,靠双方同时发心跳打洞 —— 这要求 落地侧从一开始就知道对端地址,所以保留这一模式。

func NewPeerServer

func NewPeerServer(conn *net.UDPConn, peer *net.UDPAddr, target string, o Options) (*PeerServer, error)

NewPeerServer 在 conn 上服务固定对端 peer,每条 stream 连一次 target。

func (*PeerServer) Close

func (p *PeerServer) Close() error

func (*PeerServer) Stats

func (p *PeerServer) Stats() string

type Server

type Server struct {

	// IdleTimeout:对端多久没有任何包(心跳 100ms 一个)就回收其会话。
	IdleTimeout time.Duration
	// MaxPeers:同时存在的会话上限。超出时新对端的包直接丢弃,
	// 防止伪造源地址的包把内存和 goroutine 撑爆。
	MaxPeers int
	// contains filtered or unexported fields
}

Server 是落地侧的多对端模式:不预设对端地址,按 UDP 源地址为每个对端 各建一个会话。

这是给 NAT 后面、IP 会变的客户端(笔记本、手机)用的:客户端先发包, 服务端从源地址学到该往哪回。代价是落地侧的安全组必须放行入站 UDP —— 它没法像 PeerServer 那样靠双向心跳打洞,因为事先不知道对端是谁。

k/m 由客户端决定:会话用对端第一个包头里的 k/m 建立(心跳与数据包都带), 所以 Options 里的 K/M 在这里不起作用,只有 RateMbps 是每个会话的发送限速。 注意限速是逐会话的,N 个对端同时满载时总发送量是 N×RateMbps。

客户端换了源地址(换网、NAT 映射过期)在服务端看来就是一个新对端:新会话 的 epoch 不同,客户端据此重置并断开旧 stream,上层重连即可;旧会话空闲超时 后回收。

func NewServer

func NewServer(conn *net.UDPConn, target string, o Options) (*Server, error)

NewServer 在 conn 上服务任意对端,每条 stream 连一次 target。 调用 Serve 开始收包。

func (*Server) Close

func (s *Server) Close() error

Close 关闭所有会话与 socket,Serve 随之返回。

func (*Server) Peers

func (s *Server) Peers() int

Peers 返回当前会话数。

func (*Server) Serve

func (s *Server) Serve() error

Serve 阻塞收包直到 Close 或 socket 出错。

func (*Server) Stats

func (s *Server) Stats() string

Stats 返回每个对端一行的运行统计。

Directories

Path Synopsis
cmd
fectun command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL