tide

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 6, 2026 License: MIT Imports: 27 Imported by: 0

README

TIDE

Transport-Independent Data Envelope —— Coast 自研的代理协议。

状态:draft-01,有可用实现,规范未冻结。 三期机制全部落地并在实网(树莓派 5 ↔ x86,netem 制造损伤)上验过。 仍不建议第三方据此实现服务端——线格式可能随实测继续调整。

为什么是 TIDE 这个名字

配置里它长这样:type: tide。选它的理由按重要性排:

  1. 不撞车。全仓库(含 clash/ 子模块的全部 40 个协议实现)搜索 tide 零命中;现有生态里也没有同名协议。相比之下 surge 直接出局——那是 Snell 作者的客户端。
  2. 语义贴合设计。潮汐会改变方向,而这个协议的核心机制之一就是路径调度器按线路质量在 TCP/QUIC 之间迁移。名字描述的是它实际干的事,不是随便挑的海洋词。
  3. 符合品牌。产品叫 Coast,协议叫 Tide。
  4. 工程上好用。4 个字母,全小写无歧义,作为 YAML 标量、Go 包名、日志前缀都不需要转义或缩写。

落选的候选:STRAIT(海峡,"狭窄通道"语义其实最准,但 6 个字母且与 straight 同音易混)、RIPTIDE(离岸流,是 TIDE 的加长版,没有额外收益)、UNDERTOW(暗流,语义好但太长)、SURGE(与现有产品冲突,直接排除)。

为什么单独放在仓库根目录,而不是塞进 clash/

clash/ 是 MetaCubeX/mihomo 的 fork,按 CLAUDE.md 的约定上游变更只能靠 merge 拿,不能手工搬运。往里面直接加一个完整的新协议(握手、帧、多路径调度、填充策略)会在 adapter/transport/listener/ 三处同时留下大片自有代码,每次 merge 上游都要在这些文件上处理冲突。

所以 TIDE 做成独立的 Go modulegithub.com/ClashrAuto/tide),clash/ 那边只留最薄的一层适配:一个 outbound、一个 listener、一处 registry 注册。上游合并面从"三个目录的大片改动"缩到"三个新文件"。

目录结构

tide/
├── README.md
├── go.mod                  # 独立 module,供 clash fork 引用
├── docs/
│   ├── design.md           # 设计动机与取舍论证(先读这个)
│   └── spec.md             # 线格式规范(规范性文本,draft-01)
├── cmd/tide-selftest/      # 自检 + 实网波动压测工具
├── varint.go frame.go record.go        # 线格式
├── crypto.go handshake.go ticket.go    # 混合后量子握手 + 单次票据
├── session.go stream.go path.go        # 会话层:复用、可靠性、调度
├── client.go server.go transport_quic.go
├── padding.go addr.go datagram.go diag.go
└── tide_test.go

阅读顺序

先读 docs/design.md——它解释了「低延迟 / 高吞吐 / 高安全」这三个目标彼此冲突在哪,以及每个机制是为了化解哪一组冲突。脱离这个上下文看 docs/spec.md 的线格式,会觉得很多字段是多余的。

一键部署(Docker Compose)

git clone https://github.com/ClashrAuto/tide.git && cd tide
cp .env.example .env      # 至少把 TIDE_PASSWORD 改掉
docker compose up -d
docker compose logs tide  # 这里会打印客户端该贴的完整配置(含 public-key)

编排里有两个服务,第二个不是凑数的:

  • tide —— 服务端,TCP 与 QUIC 共用 8443(QUIC 是加速通道,丢包链路上 p90 从 197ms 降到 9ms;UDP 被封时客户端静默回落 TCP)。
  • cover —— 掩护源站(nginx)。认证失败时 TIDE 把连接的全部字节原样转发给它, 而不是模拟一个响应。时序是伪装里唯一真正难伪造的东西:失败路径 0.1ms 而真实站点 50ms,探测方量一下响应时间分布就分开了(spec §7)。所以它必须真实可达、延迟合理, 放在同一个 compose 网络里正合适。把它换成你自己的站点会更像样。

几件必须知道的事:

  • /data 一定要是持久卷。 静态密钥在里面,删了就等于换了 public-key, 所有客户端配置一起作废。

  • 默认用自动生成的自签证书。 能跑,但削弱伪装——真实网站都有受信任的证书。 正式部署把真证书挂到 /data/tls.crt/data/tls.key,并把客户端的 skip-cert-verify 删掉。

  • QUIC 想吃满带宽要调宿主的 UDP 缓冲net.core.rmem_max 不是命名空间隔离的, 在 compose 里写 sysctls 无效):

    printf 'net.core.rmem_max=7500000\nnet.core.wmem_max=7500000\n' > /etc/sysctl.d/99-tide.conf && sysctl --system
    
  • proxy.golang.org 连不上的网络里,.env 里把 GOPROXY 换成 https://goproxy.cn,direct

不用 Docker 跑

# 进程内跑完整链路(握手 → 0-RTT 复用 → 失败关闭 → 路径迁移 → UDP),exit 0 = 通过
go run ./cmd/tide-selftest -mode local

# 生成一对静态密钥
go run ./cmd/tide-server -keygen

# 服务端(真代理)
go run ./cmd/tide-server -listen :8443 -quic-listen :8443 \
    -cover 127.0.0.1:8080 -users alice:<password> -advertise example.com

# 压测客户端(输出往返时延的尾部分位 + 每条路径的收发字节)
go run ./cmd/tide-selftest -mode client -server host:8443 -key <public> \
    -duration 60s -quic

⚠️ tide-selftest -mode server 把每条流回声回去,那是压测夹具,不是代理。 要部署的是 tide-server

-cover 必须指向一个真实可达、延迟合理的源站。认证失败时这条连接的全部字节会被 原样转发过去——这不是可选项,见 spec §7。

落地状态

内容 状态
前重后轻填充、裸帧模式协商 已实现。kTLS + splice() 做不到且已从待办移除——多路复用协议拿不到零拷贝转发,理由见 spec §12.3
单次票据 0-RTT、信道绑定、混合后量子 已实现并实测
多路径调度(TCP ↔ QUIC) 已实现。每条流一条独立 QUIC 流,路径内队头阻塞已消除(spec §12.5);UDP 走 RFC 9221 数据报(§12.8)

spec §12 的八个条目里七个已定稿,唯一残留的是 §12.6 的一半:QUIC 端口现在 "什么也给不出"(强制只能加入已有会话),但还没有伪装成别的东西——那需要 完整的 HTTP/3 掩护,做半套比不做更糟。

实测(详见 spec §11.2)

树莓派 5 ↔ x86,netem 只损伤目的端口 8443(TCP/UDP 同时):

  • 持续 5% 丢包:单 TCP 路径 p99 = 619ms;TCP+QUIC 按评分调度 p99 = 194ms、p90 从 197ms 降到 9ms
  • 完全断网:5s / 20s / 60s 三档,恢复开销分别为 11ms / 225ms / 274ms,各只重连一次,零字节丢失、零乱序
  • 断网超过宽限期则干净失败session grace period expired),不挂死。

三个反直觉、且都是实测把设计推翻的结论:

  • TCP 路径的丢包率从应用层看恒为 0(spec §8.1)。探测走在 TCP 里,丢的段由内核 重传,所以永远测不到丢包——draft-00 那条"丢包率超 2% 就迁移"按字面实现从不触发。
  • 单 QUIC 流复用比裸 TCP 还差(spec §12.5)。对照组是 4 条独立连接、一条丢包只卡 自己;单流复用把 4 条流全塞进一条,一个丢包卡住四条。分流后 p90 从 8.6ms 降到 1.67ms。
  • BBR 把尾部搞差了 5 倍(spec §12.7)。design.md 写的是 BBRv3,而 Linux 内核里的 bbr 是 v1,它对随机丢包的处理正是弱点。默认已改回"不动系统默认"。

共同的教训:判据必须建立在能观测到的量上,而"更好的算法"必须实测

⚠️ 在推广前必须先回答的问题

没有任何机场会提供 TIDE 节点。 这个协议只对自建服务端的用户有意义。如果 Coast 的用户主要用订阅节点,它的实际使用率会接近零。这一条不因为代码写完了就消失——它决定的是要不要在 UI 里给它一等公民的位置。

另一条同等重要的:一个设计更好的新协议,在头两年里的实际安全性大概率低于一个设计一般但被审烂了的老协议。 Trojan 有十年公开审计,TIDE 只有一个实现、零第三方审计。上面那些实测数字全是性能与可用性的,不是安全性的——不要把它们当成安全性的证据,也不要在文档或 UI 里把 TIDE 宣传成"比 Trojan 更安全"。

Documentation

Index

Constants

View Source
const (
	// DefaultProbeInterval:有活跃流时的路径探测间隔。1s 不是为了测 RTT——
	// 是为了**尽早发现路径死了**。探测间隔直接决定故障检测下界,而检测越晚,
	// 用户看到的卡顿越长。空闲时会自动退到 DefaultIdleProbeInterval。
	DefaultProbeInterval     = 1 * time.Second
	DefaultIdleProbeInterval = 15 * time.Second
	// DefaultProbeTimeout:单次探测判定丢失的时限。取 max(3×SRTT, 这个值),
	// 所以高延迟线路不会被误杀。
	DefaultProbeTimeout = 2 * time.Second
	// DefaultPathDeadAfter:连续多久收不到任何字节就判定路径死亡。
	DefaultPathDeadAfter = 8 * time.Second

	// DefaultSessionGrace:会话在**一条路径都没有**的情况下能存活多久。
	// 这是整个协议对网络波动最重要的一个数:Wi-Fi 切换、蜂窝换基站、路由器重启
	// 都在这个窗口内,用户的 TCP 连接一条都不会断。代价是服务端要在这段时间里
	// 替一个已经不在线的客户端持有上游连接与缓冲区。
	DefaultSessionGrace = 120 * time.Second

	// DefaultStreamWindow:单流发送窗口,同时也是重传缓冲上限。
	// 迁移时要重发 [acked, sent) 的全部字节,所以这个数直接决定一次迁移最坏要重传多少。
	// 512 KiB 在 100 Mbps × 40ms 的 BDP(约 500 KiB)附近,既不限速也不会让一次
	// 迁移拖出几秒的重传。
	DefaultStreamWindow = 512 * 1024
	// DefaultSessionWindow:会话级接收缓冲上限,防止大量流各自吃满窗口把内存打爆。
	DefaultSessionWindow = 8 * 1024 * 1024

	DefaultMaxStreams = 1024
)

默认值集中在这里,方便一眼看全"网络波动下的行为"由哪些常数决定。

View Source
const (
	ProtocolVersion uint8 = 0x01

	ChannelBindingLabel = "tide-channel-binding"
)

版本字节。draft-01 相对 spec.md 的 draft-00 有三处线格式变化,见 spec.md §11 变更记录: STREAM_DATA 带绝对 offset、新增 STREAM_ACK/TICKET_REQUEST、填充长度改为尾部 u16。

View Source
const (
	FlagPad  uint8 = 1 << 0
	FlagEnd  uint8 = 1 << 1
	FlagPush uint8 = 1 << 2
)

标志位(spec §2.2)。

View Source
const (
	DefaultTicketCount = 1024
	TicketLifetime     = 24 * time.Hour
)
View Source
const MaxFrameBody = 56 * 1024

MaxFrameBody 是 length 字段允许的上限。定成 56 KiB 而不是 varint 的 2^62: 一个恶意对端只要声明一个巨大的 length 就能让接收方预分配等量内存, 上限是这里唯一的防线。上界还必须让一帧装得进一条 sealed 记录(见 record.go 的 maxRecordPlain),否则批量阶段会出现"帧合法但永远发不出去"的死角。

View Source
const MaxPayload = 16 * 1024

MaxPayload 是单个 STREAM_DATA 的载荷上限。16 KiB 对齐 TLS 记录最大明文长度, 让"一帧 = 一条 TLS 记录"在批量阶段成立,避免一帧被拆进两条记录后产生额外的包长特征。

View Source
const MaxVarint = uint64(1)<<62 - 1
View Source
const TimestampTolerance = 120 * time.Second

TimestampTolerance 是 spec §3.1 的 ±120 秒容差。

Variables

View Source
var (
	ErrClosed          = errors.New("tide: session closed")
	ErrStreamClosed    = errors.New("tide: stream closed")
	ErrStreamReset     = errors.New("tide: stream reset by peer")
	ErrProtocol        = errors.New("tide: protocol violation")
	ErrFrameTooLarge   = errors.New("tide: frame exceeds max size")
	ErrBadTicket       = errors.New("tide: ticket invalid or already consumed")
	ErrTicketExhausted = errors.New("tide: no unconsumed tickets left")
	ErrChannelBinding  = errors.New("tide: channel binding mismatch")
	ErrStaleTimestamp  = errors.New("tide: timestamp outside tolerance window")
	ErrVersion         = errors.New("tide: unsupported version")
	ErrNoPath          = errors.New("tide: no usable path")
	ErrSessionGone     = errors.New("tide: session grace period expired")
	ErrFlowControl     = errors.New("tide: peer exceeded flow-control window")
	ErrTooManyStreams  = errors.New("tide: stream limit reached")
)

协议级错误。注意:这些**不会**被发到线上——§6 的失败关闭要求任何认证失败都不产生 可区分的响应。它们只用于本地日志与调用方决策。

Functions

func AppendFrame

func AppendFrame(b []byte, t FrameType, flags uint8, streamID uint64, payload []byte, pad int) []byte

AppendFrame 把一帧序列化到 b。pad 为附加的填充字节数(0 表示不填充)。

func AppendVarint

func AppendVarint(b []byte, v uint64) []byte

AppendVarint 把 v 追加到 b。超范围时 panic——这是编码器内部约束, 所有调用点的取值都在协议定义的范围内,运行期出现只可能是逻辑错误。

func DefaultHandler

func DefaultHandler(ctx context.Context, st *Stream)

DefaultHandler 直连目标并对拷。

func DefaultPacketHandler

func DefaultPacketHandler(ctx context.Context, ps *PacketStream)

DefaultPacketHandler 为一条 UDP 关联建一个本地 socket 并转发。

func ReadVarint

func ReadVarint(b []byte) (v uint64, n int)

ReadVarint 从 b 头部解出一个 varint,返回值与消耗的字节数。 字节不足时返回 n=0——调用方据此判断"还要继续收",而不是报错。

func UserIDFromPassword

func UserIDFromPassword(password string) [16]byte

UserIDFromPassword 把任意长度的口令折成 16 字节 user_id。

直接截断口令是不行的:短口令会留下尾部零字节,长口令则丢掉后半段—— 两个前 16 字节相同的口令会塌成同一个用户,而且不会有任何报错, 表现为 A 的流量被记在 B 头上、或者 A 能用 B 的票据。 用 SHA-256 折叠保证任意两个不同口令映射到同一个 id 的概率可忽略。

func VarintLen

func VarintLen(v uint64) int

VarintLen 返回 v 编码后的字节数。v 超范围时返回 0。

Types

type Client

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

func NewClient

func NewClient(cfg *ClientConfig) (*Client, error)

func (*Client) Close

func (c *Client) Close() error

Close 关闭客户端与其会话。

func (*Client) CloseSession

func (c *Client) CloseSession()

CloseSession 关掉当前会话但保留客户端(票据钱包留着)。 下一次拨号会用 0-RTT 重新起一个会话——这是验证 0-RTT 复用最直接的办法。

func (*Client) CurrentSession

func (c *Client) CurrentSession() *Session

CurrentSession 返回当前会话(可能为 nil)。

func (*Client) DialContext

func (c *Client) DialContext(ctx context.Context, network, addr string) (net.Conn, error)

DialContext 开一条代理 TCP 流。签名与 net.Dialer.DialContext 兼容, 方便直接塞进 clash 的 dialer 链。

func (*Client) DialPacket

func (c *Client) DialPacket(ctx context.Context, addr string) (*PacketStream, error)

DialPacket 开一条 UDP 关联。

func (*Client) Session

func (c *Client) Session(ctx context.Context) (*Session, error)

Session 返回当前会话,必要时新建。

func (*Client) TicketsRemaining

func (c *Client) TicketsRemaining() int

TicketsRemaining 返回钱包里还剩多少张可用票据。

func (*Client) TicketsTaken

func (c *Client) TicketsTaken() uint64

TicketsTaken 返回累计用掉多少张票据。等于"有多少次握手是 0-RTT"。

type ClientConfig

type ClientConfig struct {
	// Server 是 host:port。
	Server string
	// PublicKey 是服务端静态公钥(base64,见 PublicKey 文档说明为什么这么长)。
	PublicKey *PublicKey
	// UserID 16 字节用户标识。
	UserID [16]byte
	// ServerName 外层 TLS SNI;空则取 Server 的 host。
	ServerName string
	// TLSConfig 可选,用于自定义外层 TLS(ALPN、指纹、跳过校验等)。
	TLSConfig *tls.Config

	// Bare 请求裸帧模式(内层不加密,安全性完全由外层 TLS 承担)。
	// 服务端只在信道绑定校验通过时才会同意。
	Bare bool

	// EnableQUIC 允许调度器探测并使用 QUIC 路径。
	EnableQUIC bool
	// QUICPort 为 0 时复用 Server 的端口。
	QUICPort int

	// Redundancy 常驻维持两条路径。
	//
	// ★ 这是对抗网络波动最有效、也最贵的一个开关:路径死亡时不需要重连,
	// 流直接切到另一条已经握好手的路径上,用户侧几乎无感(一个 RTT 内)。
	// 代价是两条连接的保活开销,以及服务端两倍的会话资源。
	// 弱网/移动网络建议开,稳定有线网不需要。
	Redundancy bool

	// SessionGrace 覆盖 DefaultSessionGrace。
	SessionGrace time.Duration
	// ProbeInterval 覆盖 DefaultProbeInterval。
	ProbeInterval time.Duration
	// StreamWindow 覆盖 DefaultStreamWindow。
	StreamWindow uint64

	// Dial 允许注入自定义拨号(clash 侧用来走 dialer 的接口绑定/DNS)。
	Dial DialFunc

	// Congestion 指定 TCP 路径的拥塞控制算法(Linux 专有,如 "bbr"、"cubic")。
	// 空 = **不动系统默认**。填 "-" 同义。
	//
	// ⚠️ 别想当然地填 "bbr":实测双向 5% 丢包下它把 p99 从 125ms 抬到 620ms
	//    (详见 congestion_linux.go)。Linux 的 `bbr` 是 v1,对随机丢包的处理
	//    正是它的弱点;design.md 里说的 BBRv3 内核里并没有。
	// ⚠️ 只作用于 TCP 路径:quic-go v0.61 内置只有 cubic 且不暴露选择接口。
	Congestion string
}

ClientConfig 是出站配置。

type Datagram

type Datagram struct {
	Assoc uint64 // 关联标识 = 开这条 UDP 关联时用的流号
	Addr  string // 对端地址
	Data  []byte
}

Datagram 是一个收到的 UDP 数据报。

type DialFunc

type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)

DialFunc 与 net.Dialer.DialContext 同形。

type Frame

type Frame struct {
	Type     FrameType
	Flags    uint8
	StreamID uint64
	Payload  []byte
}

Frame 是解码后的帧。Payload 指向读缓冲的切片——调用方若要跨帧持有必须自己拷贝, 解帧循环会复用底层数组。

type FrameType

type FrameType uint8
const (
	FrameHello       FrameType = 0x01
	FrameAccept      FrameType = 0x02
	FrameZeroRTT     FrameType = 0x03
	FrameStreamOpen  FrameType = 0x10
	FrameStreamData  FrameType = 0x11
	FrameStreamFin   FrameType = 0x12
	FrameStreamRst   FrameType = 0x13
	FrameStreamAck   FrameType = 0x14 // draft-01 新增,见 spec §2.1 注
	FrameDatagram    FrameType = 0x20
	FramePathProbe   FrameType = 0x30
	FramePathAck     FrameType = 0x31
	FramePathMigrate FrameType = 0x32
	FrameTicketRepl  FrameType = 0x40
	FrameTicketReq   FrameType = 0x41 // draft-01 新增
	FramePadding     FrameType = 0x50
	FrameClose       FrameType = 0x5F
)

func (FrameType) String

func (t FrameType) String() string

type MemTicketStore

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

MemTicketStore 是单进程实现。

除了 per-user 的批次链,它还维护一份**按 base 排序的全局批次索引**。 这是为了解开 0-RTT 里的一个鸡生蛋:服务端要先查位图才能解密, 而用户身份就在待解密的密文里。全局基址单调分配 ⇒ ticket_id 本身唯一确定批次 ⇒ 可以先按 id 反查到批次(连带用户),再原子消费,最后才解密。 集群实现要么复制这个性质(全局单调发号),要么把 user_id 明文放进 ZERO_RTT—— 后者会泄露用户标识,不可接受。

func NewMemTicketStore

func NewMemTicketStore() *MemTicketStore

func (*MemTicketStore) Consume

func (s *MemTicketStore) Consume(user [16]byte, id uint64) ([32]byte, bool)

func (*MemTicketStore) ConsumeAny

func (s *MemTicketStore) ConsumeAny(id uint64) ([32]byte, [16]byte, bool)

ConsumeAny 是 anyConsumer 的导出版本,让外部 store 实现可以照抄这个契约。

func (*MemTicketStore) Issue

func (s *MemTicketStore) Issue(user [16]byte, count uint16) (uint64, [32]byte, error)

func (*MemTicketStore) Remaining

func (s *MemTicketStore) Remaining(user [16]byte) int

func (*MemTicketStore) Sweep

func (s *MemTicketStore) Sweep(now time.Time)

type PacketStream

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

PacketStream 是一条 UDP 关联,实现 net.PacketConn 的常用子集。

func (*PacketStream) Assoc

func (ps *PacketStream) Assoc() uint64

Assoc 返回关联标识。

func (*PacketStream) Close

func (ps *PacketStream) Close() error

func (*PacketStream) LocalAddr

func (ps *PacketStream) LocalAddr() net.Addr

func (*PacketStream) ReadFrom

func (ps *PacketStream) ReadFrom() (*Datagram, error)

ReadFrom 收一个数据报。

func (*PacketStream) SetReadDeadline

func (ps *PacketStream) SetReadDeadline(t time.Time) error

func (*PacketStream) WriteTo

func (ps *PacketStream) WriteTo(b []byte, addr string) (int, error)

WriteTo 发一个数据报。

UDP **不做重传也不进重传缓冲**:它本来就是不可靠的,硬给它加可靠性会改变 上层协议(QUIC、DNS、游戏)自己的拥塞与超时行为,通常比丢包更糟。 路径切换期间丢掉的数据报就是丢了——这与在真实网络上丢包没有区别, 上层应用早就为此做好了准备。

type PaddingPhase

type PaddingPhase uint8

PaddingPhase 供日志与自检观察当前处于哪一阶段。

const (
	PhaseDecision PaddingPhase = iota // 判决窗口:每帧填到采样目标长度
	PhaseDecay                        // 衰减:填充概率线性降到 0
	PhaseBulk                         // 批量:不填充
	PhaseOff                          // 填充被关闭(bare 模式:用户态不碰载荷)
)

func (PaddingPhase) String

func (p PaddingPhase) String() string

type PathInfo

type PathInfo struct {
	ID    uint32
	Kind  string // "tcp" / "quic"
	State string // active / degraded / suspect / dead
	RTT   time.Duration
	Loss  float64
	Age   time.Duration
	Pad   string // 当前填充阶段
	TX    uint64
	RX    uint64
}

PathInfo 是一条路径的可观测状态。

type PrivateKey

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

PrivateKey 是服务端长期密钥:X25519 静态私钥 + ML-KEM-768 解封装私钥。

func GenerateKey

func GenerateKey() (*PrivateKey, error)

GenerateKey 生成一对新的服务端静态密钥。

func ParsePrivateKey

func ParsePrivateKey(s string) (*PrivateKey, error)

ParsePrivateKey 解析 Seed()/String() 的产物。

func (*PrivateKey) Public

func (k *PrivateKey) Public() *PublicKey

Public 导出对应的客户端公钥。

func (*PrivateKey) Seed

func (k *PrivateKey) Seed() []byte

Seed 序列化私钥为 96 字节(32 X25519 + 64 ML-KEM 种子)。

func (*PrivateKey) String

func (k *PrivateKey) String() string

type PublicKey

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

PublicKey 是客户端配置里那一大坨 base64:X25519 公钥(32) || ML-KEM-768 封装公钥(1184)。

1216 字节 → base64 约 1624 字符,确实不好看。但这是后量子的真实价格: ML-KEM 的公钥就是这么大,而客户端必须在**第一个包**里就完成封装才能 0-RTT, 没有"先问服务端要公钥"的余地——那会引入一个 RTT,把机制 1 的全部收益抵消掉。

func ParsePublicKey

func ParsePublicKey(s string) (*PublicKey, error)

ParsePublicKey 解析配置里的 base64 公钥(标准或 URL 变体、有无 padding 都接受)。

func ParsePublicKeyBytes

func ParsePublicKeyBytes(b []byte) (*PublicKey, error)

func (*PublicKey) Bytes

func (p *PublicKey) Bytes() []byte

func (*PublicKey) String

func (p *PublicKey) String() string

type Server

type Server struct {

	// Handler 处理一条被代理的流。为空时用 DefaultHandler(直连目标)。
	Handler func(ctx context.Context, st *Stream)
	// PacketHandler 处理一条 UDP 关联。
	PacketHandler func(ctx context.Context, ps *PacketStream)
	// contains filtered or unexported fields
}

func NewServer

func NewServer(cfg *ServerConfig) (*Server, error)

func (*Server) Close

func (s *Server) Close() error

Close 停止服务端。

func (*Server) Serve

func (s *Server) Serve(l net.Listener) error

Serve 在 l 上接受连接。l 应当已经是**明文** listener: TLS 由本函数套上,因为信道绑定要拿到 tls.Conn 本身。

func (*Server) ServeQUIC

func (s *Server) ServeQUIC(addr string) error

ServeQUIC 在 addr 上接受 QUIC 路径。与 Serve 并存:同一个 Server 可以同时 提供 TCP 与 QUIC 两条数据面,客户端的调度器按实测质量挑。

type ServerConfig

type ServerConfig struct {
	// PrivateKey 服务端静态私钥。
	PrivateKey *PrivateKey
	// Users 允许的用户集合。空表示接受任何通过认证的 user_id
	// (只有在 PrivateKey 本身即凭据的部署里才合理,不推荐)。
	Users map[[16]byte]string

	// TLSConfig 外层 TLS。必须提供(bare 模式与信道绑定都依赖它)。
	TLSConfig *tls.Config

	// CoverAddr 是掩护源站地址(host:port)。
	//
	// ★ 认证失败时必须把这条连接的全部字节**真的转发**过去,而不是模拟响应。
	// 时序是这里唯一真正难伪造的东西:失败路径 0.1ms、真实站点 50ms,
	// 探测方量一下响应时间分布就分开了,伪装随即全部作废。
	// 所以掩护站点必须真实可达且延迟合理(同机房或本机)。
	CoverAddr string

	// TicketStore 为空时用 MemTicketStore(**只在单机正确**,见 TicketStore 文档)。
	TicketStore TicketStore
	// TicketCount 单批签发数量,0 取 DefaultTicketCount。
	TicketCount uint16

	// AllowBare 是否允许协商裸帧模式。
	AllowBare bool

	// SessionGrace 覆盖 DefaultSessionGrace。
	SessionGrace time.Duration
	// StreamWindow 覆盖 DefaultStreamWindow。
	StreamWindow uint64
	// MaxStreams 单会话并发流上限。
	MaxStreams int

	// Congestion 同 ClientConfig.Congestion。
	Congestion string
}

ServerConfig 是入站配置。

type Session

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

Session 是由 Session ID 标识的逻辑连接,**独立于任何一条路径存在**。

这一条是整个设计的地基:会话密钥、流状态、身份都不绑定到某条 TCP/QUIC 连接, 于是多路径、无缝迁移、0-RTT 全都从这里派生。反过来说,网络波动下的稳定性 也全都落在这个结构体上——路径来来去去,Session 得一直在。

func (*Session) AcceptStream

func (s *Session) AcceptStream(ctx context.Context) (*Stream, error)

AcceptStream 取一条对端开的流(服务端侧)。

func (*Session) Close

func (s *Session) Close() error

Close 关闭会话与其上全部流。

func (*Session) Done

func (s *Session) Done() <-chan struct{}

Done 在会话彻底结束后关闭。

func (*Session) ID

func (s *Session) ID() [16]byte

ID 返回会话标识。

func (*Session) KillAllPaths

func (s *Session) KillAllPaths() int

KillAllPaths 立刻打死本会话的全部路径,不走任何优雅关闭。

这是**故障注入**,模拟"网线被拔了 / Wi-Fi 切走了 / 运营商掐了连接"。 与 Close() 的区别很重要:Close 会发 CLOSE 帧、对端知道该收摊了; 这里对端什么都收不到,只能靠自己的探测与静默计时器发现—— 而那正是真实网络波动的样子,也正是要验的东西。

func (*Session) LocalAddr

func (s *Session) LocalAddr() net.Addr

LocalAddr 返回当前主路径的本地地址;无路径时返回一个占位值。

func (*Session) OpenPacket

func (s *Session) OpenPacket(ctx context.Context, dst string) (*PacketStream, error)

OpenPacket 开一条 UDP 关联。dst 是"默认目标",实际每个数据报都自带地址。

func (*Session) OpenStream

func (s *Session) OpenStream(ctx context.Context, dst string) (*Stream, error)

OpenStream 开一条流并把目标地址告诉对端。

func (*Session) PathCount

func (s *Session) PathCount() int

PathCount 返回当前还活着的路径数。

func (*Session) Paths

func (s *Session) Paths() []PathInfo

Paths 返回当前路径快照,供日志、UI 与自检展示。

func (*Session) PathsEstablished

func (s *Session) PathsEstablished() uint64

PathsEstablished 返回本会话累计接入过多少条路径。

初始值 1;每一次重连或新增冗余路径 +1。它是判断"恢复逻辑到底跑没跑"的唯一硬证据—— 在环回或低延迟链路上,重连快到几毫秒,光看传输成功与否完全分辨不出来。

type Stream

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

Stream 是会话内的一条应用层字节流,对应一个被代理的 TCP 连接。实现 net.Conn。

★ 为什么这里要重新实现一遍可靠传输(绝对偏移 + 累积 ACK + 重传缓冲), 明明底下的 TCP/QUIC 已经是可靠的了?

因为"可靠"是**每条路径**的属性,而会话要活得比路径长。路径死掉的那一刻, 内核发送队列里已经被 TCP ACK 过、但还没到达对端应用层的字节全部消失, 且本端无从知道丢了哪些——TCP 的 ACK 只保证到达内核,不保证到达对端应用。 没有会话级的偏移与 ACK,一次 Wi-Fi 切换就会让每条流的中间凭空少掉一段字节, 表现为"网页加载到一半卡住"或"下载文件校验和不对",而且不会有任何报错。

代价是发送方要缓存 [ackedOff, sendOff) 这段未确认字节(上限 = 流窗口), 以及每 ackThreshold 字节一个小 ACK 帧。这是无缝迁移的真实价格。

func (*Stream) Close

func (st *Stream) Close() error

func (*Stream) CloseWrite

func (st *Stream) CloseWrite() error

CloseWrite 只关半边(对应 TCP 的 shutdown(SHUT_WR))。

func (*Stream) ID

func (st *Stream) ID() uint64

ID 返回流号(自检与日志用)。

func (*Stream) LocalAddr

func (st *Stream) LocalAddr() net.Addr

func (*Stream) Read

func (st *Stream) Read(p []byte) (int, error)

func (*Stream) RemoteAddr

func (st *Stream) RemoteAddr() net.Addr

func (*Stream) SetDeadline

func (st *Stream) SetDeadline(t time.Time) error

func (*Stream) SetReadDeadline

func (st *Stream) SetReadDeadline(t time.Time) error

func (*Stream) SetWriteDeadline

func (st *Stream) SetWriteDeadline(t time.Time) error

func (*Stream) Write

func (st *Stream) Write(p []byte) (int, error)

type StreamError

type StreamError uint32

StreamError 是 STREAM_RST 携带的错误码。

const (
	StreamErrNone         StreamError = 0
	StreamErrRefused      StreamError = 1 // 目标连接失败
	StreamErrCanceled     StreamError = 2 // 本端主动取消
	StreamErrFlowControl  StreamError = 3
	StreamErrProtocol     StreamError = 4
	StreamErrSessionClose StreamError = 5
)

func (StreamError) Error

func (e StreamError) Error() string

type TicketStore

type TicketStore interface {
	// Issue 为 user 签发一批新票据,返回基址与种子。
	Issue(user [16]byte, count uint16) (base uint64, seed [32]byte, err error)
	// Consume 原子地消费一张票据。返回 (ticketKey 派生用的种子, ok)。
	Consume(user [16]byte, id uint64) (seed [32]byte, ok bool)
	// Remaining 返回该用户当前未消费票据数,供补充决策。
	Remaining(user [16]byte) int
	// Sweep 清理过期批次。
	Sweep(now time.Time)
}

TicketStore 是票据消费状态的存储接口。

★ Consume 必须是**原子的**:同一个 (user, id) 并发到达时只能有一个返回 true。 违反这一条,重放保护就完全失效,而且不会有任何报错——攻击者重放的连接会正常建立。 这也是 spec §3.3 步骤 3 要求"置位先于解密 early_data"的原因。

多节点部署:MemTicketStore 只在单机正确。集群必须换成共享实现(Redis 的 SETBIT + Lua 保证原子性,或按 user_id 一致性哈希把同一用户钉在同一节点)。 这是 TIDE 唯一显著增加运维负担的地方。

Jump to

Keyboard shortcuts

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