diff --git a/docs/plans/2026-09-05-p2p-mux-token.md b/docs/plans/2026-09-05-p2p-mux-token.md deleted file mode 100644 index 814c62b..0000000 --- a/docs/plans/2026-09-05-p2p-mux-token.md +++ /dev/null @@ -1,90 +0,0 @@ -# P2P 阶段一:mux 复用 + 内层协议矩阵验证 + 插件 token 校验 - -## Context - -桩里程碑已验收:`OpenTunnel(peer) → 本地 endpoint → GOST 消费` 的缝隙端到端跑通,inner 仅 `tcp`(每 Dial 一隧道)。当前形态的实际缺陷: - -1. **成本**:每请求一隧道 = 1 listener + 4 goroutine + 2 conn,并发 1000 即撞 fd 上限; -2. **协议覆盖**:`SupportedDialer` 白名单只有 `tcp`,tls/ws/mtcp/mtls/mws 三条前置(不二次包装、转发 Handshaker、白名单)已在 wrapper 实现,但未验证; -3. **安全**:控制通道无鉴权,仅靠 loopback 默认兜底,跨机部署前必须补 token。 - -本里程碑收口这三项。**不做**:rendezvous/DERP/打洞(阶段二)、x/api 覆盖、tunnel-to-destination。 - -## 关键事实(已实证) - -- **mux 路径**:`Transport.Multiplex()` 对 `dialer.Multiplexer` 的断言(`x/chain/transport.go:92`)→ `Chain.Route` 在 mux 节点处拆分路由(`x/chain/chain.go:147-155`)。inner=mtcp 时 `Multiplex()=true`,wrapper 已委托 inner(`x/p2p/tunnel_dialer.go:106`)。 -- **会话语义**:mtcp/mtls Dial 按 addr 缓存会话(`x/dialer/mtcp/dialer.go:62`),缓存命中时返回 `session.conn`、**不看 `options.Dialer`**。mux 模式下每个请求都走 `chainRoute.connect` → mux 节点 `Transport.Dial`(route-split 后 mux 节点恒为 `r.nodes[0]`,`x/chain/route.go:218`),因此 **wrapper 若先 `OpenTunnel` 再 `inner.Dial`,缓存命中时新隧道无人使用、无人关闭 —— 每请求漏一条隧道**(listener 存活至进程退出,Status 计数无界增长)。修复见改动清单 A':惰性开洞,一条隧道 = 一个 mux 会话 = N 条流;会话死亡 → 重建(dead-session 回收已修,`session.conn.Close()` 触发 `tunnelConn.Close` → `CloseTunnel`)。 -- **身份校验兼容**:mtcp Handshake 的 `session.conn != conn` 指针比对(`x/dialer/mtcp/dialer.go:120`)——wrapper 原样透传 tunnelConn,底座即会话 conn,校验自然通过。 -- **TLS ServerName 无需改代码**:`x/config/parsing/node/parse.go:208` `serverName = SplitHostPort(cfg.Addr)`,p2p 下 node addr 即 peer,与非 p2p 路径语义完全一致;addr 为 IP 且证书用域名时走既有 `dialer.tls.serverName` 配置。计划旧文"届时须显式提供"过虑了,只需文档说明 + e2e 实证。 -- **token 协议已在客户端侧就绪**:`x/internal/plugin/plugin.go` `WithPerRPCCredentials` 发 metadata `"token"`;`x/config` PluginConfig 已有 `token:` 字段且 `ParseP2P` 已传 `TokenOption`。**缺的只是宿主侧校验**。 -- **宿主侧校验范本**:`plugin/auth/example/grpc/main.go:26-43`——`metadata.FromIncomingContext` 取 `md["token"][0]` 比对。 - -## 改动清单 - -### A'. `x/p2p/tunnel_dialer.go` — 惰性 OpenTunnel(mux 前置,必做) - -现状:`tunnelDialer.Dial` 先 `OpenTunnel` 再 `inner.Dial`;mux inner(mtcp 等)缓存命中时不碰 base dialer,新开的隧道成为孤儿(fd 泄漏 + 计数无界增长)。修复:把 `OpenTunnel` 下沉进 `tunnelBaseDialer`,惰性开洞—— - -1. `tunnelBaseDialer.Dial` 内先 `OpenTunnel`(保留 3s `rpcTimeout` 预算在 base 内),再拨 endpoint,id 记在 base 结构上(`sync.Once`/mutex 防重复开洞); -2. `tunnelDialer.Dial` 不再预开隧道;`inner.Dial` 返回错误时,若 base 已开洞(id 非空)则补 `CloseTunnel`,fail-closed 语义不变; -3. tcp/tls/ws 路径行为不变(base 恒拨一次);mtcp 缓存命中时零隧道、零 RPC; -4. `Multiplex` 委托、`Handshake` 转发、`tunnelConn` 一次性关闭语义均不动。 - -### A. `x/p2p/tunnel_dialer.go` — 白名单扩容 - -`SupportedDialer` 从 `{"tcp"}` 扩到 `{"tcp","tls","ws","mtcp","mtls","mws"}`。**每项以各自 e2e 通过为准入**(逐个加,加一个验一个)。"mws" 视 mwss/wss listener 搭配可验性,可推迟。顺手修上游文案 bug:mtcp 的错误串 `"mtls: unrecognized connection"`(`x/dialer/mtcp/dialer.go:122`)改为 `"mtcp: ..."`。 - -### B. `x/p2p/plugin/grpc_test.go` — 测试补齐 - -1. `TestSupportedDialer` 更新新条目断言; -2. 新增 `TestMultiplexDelegation`:inner 用实现 `dialer.Multiplexer` 的假 dialer → 断言 `tunnelDialer.(dialer.Multiplexer).Multiplex()` 转发 inner 值、非 mux inner 返回 false(route-splitting 依赖此语义,2 个用例防回归); -3. 新增 `TestLazyOpenTunnel`:inner 用**不调用 base dialer** 的假 dialer(模拟 mtcp 缓存命中)→ 断言 provider 的 `OpenTunnel` 未被调用;inner 调用 base dialer 时 → 断言恰好开洞一次、`inner.Dial` 失败后隧道被 `CloseTunnel`(A' 的防回归)。 - -既有 8 用例不动(均基于 tcp inner,语义不变)。 - -### C. `p2p/main.go` + `server.go` — token 校验 - -1. `--token` flag(默认空 = 不校验,loopback 默认不变);比对用 `subtle.ConstantTimeCompare`(一行,顺手);注意客户端 `RequireTransportSecurity()=false`,token 走明文 gRPC——注释里写明 control TLS 仍是硬需求; -2. `grpc.NewServer(grpc.UnaryInterceptor(authInterceptor))`:`metadata.FromIncomingContext` 取 `md["token"]`,与 `--token` 比对,不符 → `codes.Unauthenticated`。范本照抄 `plugin/auth/example/grpc/main.go`,~20 行,放 main.go; -3. 信任边界注释更新:跨机部署前提 = `--token` + control TLS(TLS 出口仍留待硬需求)。 - -### D. `play/` — e2e 演示配置矩阵(新文件) - -| 文件 | client dialer | peer service(gost -L 等价) | 验证点 | -|---|---|---|---| -| `p2p.yaml`(既有) | tcp | `http://:18080` | 回归 | -| `p2p-tls.yaml` | tls | `http+tls://:18443` | 握手经 Handshake 转发触发 | -| `p2p-ws.yaml` | ws | `ws://:18086`(handler http) | ws 升级在隧道上 | -| `p2p-mtcp.yaml` | mtcp | `mtcp://:18090`(handler http) | **1 隧道 N 流**:多次 curl,stub 日志隧道数恒 1 | -| `p2p-mtls.yaml` | mtls | `mtcp://:18443` + service TLS | mux over TLS,含 serverName/证书匹配 | - -e2e 流程同桩里程碑三终端(peer gost / p2p 宿主 / client gost + curl 本地静态服务),每个配置跑一遍。TLS 证书用 gost 自签或 play/ 既有 cert 惯例(实现时定)。 - -### E. 文档 - -1. 本计划存为 `x/docs/plans/2026-09-05-p2p-mux-token.md`(沿用日期前缀惯例); -2. `p2p/CLAUDE.md`/`README.md`:`--token` flag 行、muxed-tunnel 从 roadmap 第 1 条移入"已实现"; -3. 桩计划文档"内层协议与 mux"节补验证结果注记。 - -## 已知边界(记录,不修) - -- 上游竞态(Dial→Handshake 窗口 `IsClosed()` 对 `session==nil` 误判):mux+p2p 下并发首次拨号可能多开一条隧道,身份校验失败后自动关掉(CloseTunnel 兜底),自愈,接受; -- mux 会话无进程外生命周期管理:隧道存活至进程退出(gost 既有 mux 语义同此),对中继模型反而是目标形态; -- 隧道数回归:mux 模式下"conn 关闭即计数归零"的断言不适用(会话常驻),e2e 断言改为"恒 1、进程退出后归零"。 - -## QUIC 演进决策点(仅记录,本里程碑不实现) - -**透明性原则**:隧道只透传字节,不解释、不改写、不替代 inner 协议。协议组合(dialer↔listener 成对)由用户端到端配置决定,p2p 层永不自动替换。smux-over-QUIC-stream 是合法配置(两端照常 (mtcp↔mtcp)),性能取舍归用户。 - -QUIC 引擎落地时可**新增**一种 endpoint 流语义选项(每 QUIC stream 映射一次本地 accept,conn-per-dial):选用它且配置 `tcp` 的用户获得引擎层复用;继续单管道桥接 + `mtcp` 的用户获得 smux-over-QUIC,同样合法。两种形态并存,由用户配置选择,系统不做任何自动替换。 - -## 验证清单 - -```bash -cd x && go build ./... && go vet ./... && CGO_ENABLED=1 go test -race ./p2p/... -cd p2p && go build ./... && go vet ./... && GOWORK=off go build ./... -cd gost && CGO_ENABLED=0 go build ./cmd/gost/... -# e2e 矩阵:5 配置 × 三终端 + curl;token 正/负用例(--token gost,配置带/不带 token) -# mux 成本断言:p2p-mtcp.yaml 下 10 次并发 curl,stub Status/日志隧道数恒 1; -# 单元层由 TestLazyOpenTunnel 锁定(缓存命中零 OpenTunnel、错误路径补 CloseTunnel) -``` diff --git a/docs/plans/2026-09-05-p2p-plugin-stub.md b/docs/plans/2026-09-05-p2p-plugin-stub.md deleted file mode 100644 index 4263068..0000000 --- a/docs/plans/2026-09-05-p2p-plugin-stub.md +++ /dev/null @@ -1,224 +0,0 @@ -# P2P 插件缝隙跑通桩(Stub Milestone)实现计划 - -## Context - -**背景**:研究 tailcat/Tailscale 后,GOST 确认要做 "P2P-as-连接建立方式",架构结论为: -- 不 import tailscale.com(依赖闭包实证:连最底层 derp 都拖 31+ 个包); -- 未来 P2P traversal 引擎(rendezvous/中继/打洞/保活)**外置为插件进程**,DERP-subset 中继 server 留作 GOST 进程内 service(后续里程碑); -- 插件契约:control 走 gRPC unary,data 面走"插件返回本地 TCP endpoint、GOST 本地拨号"。 - -**本里程碑(用户选定)**:只做**插件缝隙 + 桩实现**的端到端跑通——证明"经插件建隧道→本地端点→GOST 消费"这条缝隙与生命周期是对的。**不做** NAT 穿透/DERP/加密/rendezvous。 - -**交付形态(架构修订,用户拍板)**:p2p 插件为**常规命名插件组件**(与 recorder/bypass 同构:顶层 `p2ps:` 配置段 + `registry.P2PRegistry()` + loader 注册),**不新增 dialer**。任何现有 dialer 通过**单条节点 metadata `p2p: `** 声明"基础通路走哪个 p2p 插件";**在节点解析处把 dialer 包一层 p2p 适配器**(x 本地实现 core `dialer.Dialer`,无 core 改动),把内层 dialer 的 `options.Dialer` 底座替换为"向插件 OpenTunnel(peer)→拨本地端点",dialer 自身协议(tls/ws/mtls/mws)原样跑在隧道之上——**协议零耦合(dialer 名即协议;内层握手照搬内层自身 `Handshaker`,wrapper 仅负责转发触发,见"内层协议与 mux"),无 `p2p.inner`,无新增 dialer**。**p2p 宿主独立成 repo**(同 llm-api-converter 形态:workspace 根独立目录 p2p/ + 自有 go.mod/.git + 加入 go.work),**不进 gost-plugins**——p2p 引擎(DERP/打洞)复杂度归宿主 repo,gost-plugins 零改动。GOST 侧零 core 改动,connector 复用现有 http/socks5。 - -## 一、随依赖顺序新建/修改的文件 - -### A. `plugin/` 模块 — control 协议(先做,其余模块依赖它) - -| 文件 | 内容 | 镜像 | -|---|---|---| -| `plugin/p2p/proto/p2p.proto`(新) | P2P service:OpenTunnel/CloseTunnel/Status | `plugin/sd/proto/sd.proto` | -| `plugin/p2p/proto/p2p.pb.go` / `p2p_grpc.pb.go`(新,生成物入库) | protoc 生成 | 同目录既有生成物 | - -生成命令(从 `plugin/p2p/proto/` 目录执行): -```bash -protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative p2p.proto -``` -工具链已实证可用(protoc 3.15.8 / gen-go 1.28.1 / gen-go-grpc 1.2.0,均与 plugin/CLAUDE.md 记录一致)。`go_package` 必须设为 `github.com/go-gost/plugin/p2p/proto`(**不要**重蹈 recorder 的 go_package 指向 ingress 的坑)。不在 plugin/ 加 example/(`p2p/` 宿主 repo 即参考实现,偏离 plugin/CLAUDE.md 的 example 惯例是有意为之),不加 HTTP 变体(YAGNI)。 - -**Proto 契约**(最小可扩展,proto3 字段后加兼容): -```proto -syntax = "proto3"; -package proto; -option go_package = "github.com/go-gost/plugin/p2p/proto"; - -// peer 对客户端不透明,具体语义由插件自身决定(可为 rendezvous 能力标识、WG 公钥、 -// 自定义凭据)。客户端仅透传。 -message OpenTunnelRequest { string peer = 1; } -// id 由服务端分配,客户端关闭时原样回传。 -message OpenTunnelReply { - bool ok = 1; - string id = 2; - string endpoint = 3; // 隧道句柄(GOST 不解释):v1 为本地可拨 TCP "host:port"(不带 scheme),未来可承载流凭证 - string error = 4; // ok==false 时的人类可读原因 -} -message CloseTunnelRequest { string id = 1; } -message CloseTunnelReply { bool ok = 1; } -message StatusRequest {} -message StatusReply { int32 tunnels = 1; } // 未来路径/状态信息在此加字段 - -service P2P { - rpc OpenTunnel(OpenTunnelRequest) returns (OpenTunnelReply); - rpc CloseTunnel(CloseTunnelRequest) returns (CloseTunnelReply); - rpc Status(StatusRequest) returns (StatusReply); -} -``` -错误语义(单一失败通道,消除双通道歧义):OpenTunnel 校验/传输失败 → 只回 `status.Error(codes.InvalidArgument/Unavailable)`(不回包体);业务失败(如 listen 失败)→ 包体 `ok:false` + error 串且 gRPC status 为 OK;客户端对二者任一即判失败。CloseTunnel 对未知 id 返回 `ok:true`(契约幂等)。 - -### 扩展性契约(通用性——接口对超越 DERP 的实现保持通用) - -契约域 = **"TCP 语义的局部字节管道"**——契约卡在这个最大公约数上:"**给对端能力标识,产出一条局部字节管道**"。经 TCP 语义表达的引擎(DERP 会合+中继、WG 直连、HTTP/WS 隧道)在契约域内。**UDP 数据报类引擎(打洞产物)不在 v1 契约域内**:丢包且无拥塞控制的信道上跑裸 TCP 不成立,需要完整可靠流层(QUIC/kcp 级复杂度)——届时按不变式 3 新增 RPC 家族或扩展 endpoint 语义,是**显式未来对撞点**,不是已解决的问题。三条不变式,实现时不得破坏: - -1. **`peer` 以插件为界不透明**——实现细节只在插件内解析/分发。注意客户管线在 peer 到达 dialer 前先过 `xnet.Resolve(node.Addr)`(`x/chain/route.go:233`,受节点 resolver/hostmapper 影响);桩下 resolver 为空、host:port 直通,将来 peer 若是非 host:port 的 rendezvous 标识需在此绕开。插件内部可以按 peer 前缀分发到多个后端实现。 -2. **`endpoint` 是唯一输出(== 隧道句柄/凭证)**——对 GOST 完全透明。v1 具体化为本机可拨号的 TCP `host:port`;它是逻辑会话标识而非深层语义。承载 gRPC 双向流凭证是部署维度出口②的前提改造,不是本契约的自动演进(见"部署维度"出口②)。GOST 侧永远只消费普通 `net.Conn`。 -3. **只靠 proto3 增量演进,不手写新契约**——加字段=向后兼容;未来数据报/QUIC 隧道=在该 service 下新增 unary RPC 家族(老实现对未知 RPC 返回 Unimplemented,不 break)。**本桩不加能力协商 RPC、不加 `network` 字段**——additive-safe 的东西需要时再加(YAGNI)。 - -### 内层协议与 mux(协议归 dialer 名,p2p 只供隧道底座;wrapper 需转发 Handshaker) - -p2p 不定义自己的协议层——**对端协议由 dialer 名字承担**,p2p 适配器只把内层 dialer 的底座 conn(`options.Dialer`)替换为"向插件 OpenTunnel(peer)→拨本地端点"。 - -**评审更正(原来写"零新握手代码",实证不成立)**:内层 tls/ws/mtls/mws/mtcp **全部实现 `dialer.Handshaker`**,且 tls/ws 的 `Dial` 只返回**裸底座 conn**——TLS 握手 / ws 升级整个在各自的 `Handshake` 方法里(`x/dialer/tls/dialer.go:44`、`x/dialer/ws/dialer.go:78`)。`Transport.Handshake` 靠 `tr.dialer.(dialer.Handshaker)` 类型断言触发内层握手(`x/chain/transport.go:59`)——wrapper 夹在 Transport 与内层之间,**不转发 Handshaker,inner=tls/ws 拿到的就是明文裸 conn(静默失败)**。所以不是"零新握手代码",而是"握手代码照搬内层自身 Handshake,wrapper 负责让它在 `Transport.Handshake` 处被触发"。三条前置: - -1. **tunnelConn 在"合成底座"层创建**:`tunnelDialer.Dial` 对 inner 的返回值**原样透传、不二次包装**——否则 mtcp/mtls 的 `session.conn != conn` 连接身份校验(`x/dialer/mtcp/dialer.go:116`)按指针比对必炸; -2. **实现 `dialer.Handshaker` 并转发 inner**(连同 `AddrHandshakeOption` 原样传); -3. **内层 dialer 白名单(fail-closed)**——wrapper 的隐含前提是"内层经 `options.Dialer` 拨一条纯 TCP 流并当底座用",并非所有 dialer 满足:kcp/udp/quic 系底座是数据报 conn;http2/h2c 的 `Dial` 有 probe-dial-then-close(`x/dialer/http2/dialer.go:75-86`),会开一条隧道立刻销毁。接线处以白名单校验 `dialCfg.Type`(`SupportedDialer`,定义在 `x/p2p/tunnel_dialer.go`),不在列 → `ParseNode` 报错。本里程碑白名单仅 `tcp`(实测过);tls/ws 完成三终端验证后各加一行。新 dialer 默认不可用(fail-closed)是注入底座场景的正确默认。 - -``` -tunnelDialer.Dial(ctx, peer, opts...): // opts 来自 Transport.Dial(含 HostDialOption / NetDialerDialOption(netd)) - id, endpoint := pr.OpenTunnel(ctx, peer) // 限时 ctx(同 CloseTunnel) - base := 合成拨号器(恒返 &tunnelConn{endpoint, pr, id, once}) // 无视 network/addr;隧道 conn 在此层创建 - conn := inner.Dial(ctx, peer, append(opts, NetDialerDialOption(base))...) // 末尾追加覆盖底座 - return conn // 原样透传:底座即 tunnelConn,不二次包装 - -tunnelDialer.Handshake(ctx, conn, opts...) = // 实现 dialer.Handshaker(前置 2) - inner.(dialer.Handshaker).Handshake(ctx, conn, opts...) // 内层无 Handshake(tcp)→ 原样返回 conn -``` -`inner=tcp`(桩默认):无握手、无身份校验,1:1 退化——**本里程碑唯一验证的 inner**。`inner=tls/ws`:桩是透明 TCP 转发,补齐三条前置即可用(peer 侧需对应 tls/ws listener 且 ServerName 匹配),留待后续验证。`inner=mtls/mws/mtcp`:额外受 mux 会话生命周期约束(见下),归未来里程碑。**无 `p2p.inner`,无新 dialer,无 ALPN 协商。** - -mux 细节:会话缓存/复用资产生命周期归 **内层 mux dialer**(mtcp/mtls 自带 per-addr 会话缓存,`x/dialer/mtcp/dialer.go:62-95`;wrapper 只供底座)。隧道即会话的底座 conn(== tunnelConn):会话关闭→底座 Close→幂等 CloseTunnel;会话死亡→下次 Dial 经 `session.IsClosed()` 判定重建(`x/dialer/mtcp/dialer.go:63`)。插件/proto 无感。未来注意点:① 内层 TLS 的 ServerName 默认取节点 addr(`x/config/parsing/node/parse.go:207`,即 SplitHostPort(cfg.Addr)),届时须显式提供;② 隧道生命周期跟 mux 会话(上);③ 对端约束(部署选择非代码):mux 模式隧道落对端 `mtcp`/`mtls`/`mws` listener(`mux.ServerSession`,`x/listener/mtcp/listener.go:142`),普通模式落 http/socks/relay listener。**dead-session 回收(已落地,独立于 p2p 的既有 fd 泄漏修复)**:`x/dialer/{mtcp,mws,mtls}` 的 `Dial` 删除 dead session 前须 `session.Close()` + `session.conn.Close()`,否则底座 conn 及其 p2p 隧道永久泄漏(build/vet 已过)。已知上游竞态(记录,不修):`muxSession.IsClosed()` 对 `session.session==nil` 返回 true(`x/dialer/mtcp/conn.go:33-35`),Dial→Handshake 窗口内并发 Dial 会误判 dead 而重建。 - -**否掉插件侧复用**(proto 加 `OpenStream(tunnelID)` 让插件多路复用):等于在插件内再造 smux/yamux,绕开 GOST 自带帧复用,不采用。 - -### B. 新独立 repo `p2p/` — 桩宿主(不进 gost-plugins) - -| 文件 | 内容 | 镜像 | -|---|---|---| -| `p2p/go.mod`(新) | `module p2p`(同 llm-api-converter 纯模块名风格);require `github.com/go-gost/plugin`、`google.golang.org/grpc` | `llm-api-converter/go.mod` | -| `p2p/main.go`(新) | `package main`:flag `--addr`(默认 `127.0.0.1:8003`,gRPC 监听——loopback 默认是安全边界,见桩语义尾注)、`--bind`(默认 `127.0.0.1`,数据面监听地址,见"部署维度")、`net.Listen`、`grpc.NewServer`、`proto.RegisterP2PServer`、`s.Serve(ln)`;slog 配置 | `gost-plugins/limiter/traffic/limiter.go` 的 ListenAndServe 形状(**去 cobra**——单服务 repo 无需) | -| `p2p/server.go`(新) | `server`:嵌 `UnimplementedP2PServer`、`mu`、`tunnels map[string]*tunnel`;OpenTunnel/CloseTunnel/Status(语义见下);`git init` | 同上 | -| `gost-plugins/` | **零改动**(干净性目标:p2p 复杂度不落地此处) | — | -| `go.work`(改) | 追加 `./p2p`(同既有 `./llm-api-converter` 条目) | — | - -桩语义(OpenTunnel): -1. `net.SplitHostPort(req.Peer)` 校验,失败 → `codes.InvalidArgument`; -2. `net.Listen("tcp", bind+":0")`(bind 默认 `127.0.0.1`,可配可达接口);`id := fmt.Sprintf("tunnel-%d", atomic.AddInt64(&seq, 1))`(自增计数,免 uuid 依赖); -3. 注册 `tunnel{id, ln, conns map[net.Conn]struct{}, target}`,起 accept 循环:每接受一个连接 → `net.DialTimeout("tcp", target, 5s)` → 双向 `io.Copy`(每方向一 goroutine),任一侧结束 → **对端 `net.TCPConn.CloseWrite()`**(半关闭转发),两侧都结束后才双向 `Close`——避免"一端 EOF 即双向关闭"截断仍想读的另一端; -4. 回复 `{Ok:true, Id:id, Endpoint: ln.Addr().String()}`。 -CloseTunnel:删 map 项,`ln.Close()` + 关闭全部 tracked conns,恒回 `ok:true`。Status:`{Tunnels: len(tunnels)}`。slog.Debug 记录开/关。**不做服务端 token 校验**——但须写明信任边界:控制通道无鉴权,任何能连 gRPC 的进程可令本插件向任意 `host:port` 发起外连(SSRF 面)。与既有宿主"一致"的只是无鉴权;本插件会**主动外连**,威胁模型不同,故以**默认 loopback 绑定收口**(`--addr` 不得暴露到 loopback 之外,除非先加鉴权;server.go 注释声明此边界)。 - -### 部署维度(同机为默认,异机不破坏契约) - -`endpoint` 契约**本身不假定同机**——它就是一个任意可达 `host:port`,GOST 侧只 `net.Dial`,监听地址完全由插件决定。同机部署(默认):bind `127.0.0.1`,无暴露面、无防火墙改动(与 gost-plugins 的进程同机惯例一致)。异机部署:`--bind` 配可达接口,`endpoint` 由 `ln.Addr()` 自然携带该 host,GOST 跨网拨号即通;运维点:每隧道动态端口需防火墙放行,严格网络下建议等真实引擎的持久隧道 / mux 复用(一条隧道一端口)再上异机。异机 + 数据面需原生加密 → 两个出口留待硬需求出现再选:① endpoint 上加 TLS listener、内层 dialer 取 `tls`(复用既有 TLS 配置管线,TCP 语义完整保留);② 数据面改走 gRPC 双向流(复用 `x/dialer/grpc` 既有实现;**优势**:单端口防火墙即可放行、插件宿主入站受 NAT/防火墙限制时数据面继承已建立控制连接的连通性(B 语义=endpoint 为会话凭证,见"扩展性契约");**代价**:流 conn 无 deadline / `ApplyKeepalive` 静默失效 / 控制数据同生共死,这些在真实网络跳上比 loopback 更痛,见记忆)。 - -### C. `x/` 模块 — p2p 组件 + 节点解析接线(无新增 dialer,无 core 改动) - -| 文件 | 内容 | 镜像 | -|---|---|---| -| `x/p2p/plugin/grpc.go`(新) | `type TunnelProvider interface { OpenTunnel(ctx, peer) (id, endpoint string, err error); CloseTunnel(ctx, id) error; Close() error }`;`NewGRPCPlugin(name, addr string, opts ...plugin.Option) TunnelProvider`——构造形状镜像 recorder 宽松(连接失败记日志、恒返回非 nil provider),但**运行语义 fail-closed**:nil client / 端点拨不通 → `OpenTunnel` 返回错误,绝不 no-op(区别于 bypass/admission/recorder 的 fail-open/no-op) | `x/recorder/plugin/grpc.go`(构造);fail-closed 反向:hop/rewriter | -| `x/p2p/tunnel_dialer.go`(新) | `newTunnelDialer(inner dialer.Dialer, pr TunnelProvider) dialer.Dialer`:实现 core `dialer.Dialer`(`Init` 转发 inner;`Dial` 按上节伪码——tunnelConn 在合成底座层创建、inner 返回值原样透传不二次包装;**实现 `dialer.Handshaker` 转发 inner**,tls/ws/mtls/mws/mtcp 的握手全在其 `Handshake` 方法,`x/dialer/tls/dialer.go:76`;`Multiplex()` 委托 inner 若实现 `dialer.Multiplexer`);同文件 `SupportedDialer(name string) bool` 白名单(前置 3);`tunnelConn` 同文件(`sync.Once`-Close + 幂等 `CloseTunnel`,**开/关均用新 ctx+short timeout**) | 合成底座思路:`x/dialer/grpc/dialer.go` 的 `options.Dialer` 用法;接口转发模式:`x/hop/plugin/grpc.go` 的断言式委托 | -| `x/registry/p2p.go`(新) | `func P2PRegistry() registry.Registry[x_p2p.TunnelProvider]`(实例注册,存 `NewGRPCPlugin` 产物) | `x/registry/recorder.go` | -| `x/config/config.go`(改) | 顶层 `P2Ps []*P2PConfig` + `P2PConfig{Name, Plugin PluginConfig}` | 同文件 `Recorders` 段 | -| `x/config/parsing/p2p/parse.go`(新) | `ParseP2P(cfg)`:默认 grpc → `NewGRPCPlugin(name, plugin.Addr, TokenOption, TLSConfig)`;type=http → 弃用日志 | `x/config/parsing/recorder/parse.go` | -| `x/config/parsing/parse.go`(改) | 常量 `MDKeyP2P="p2p"`(单键,值=插件名) | 同文件 MDKey* 常量 | -| `x/config/parsing/node/parse.go`(改) | 节点 metadata `name := mdutil.GetString(md, MDKeyP2P)`;`name==""` 跳过;否则 `provider := registry.P2PRegistry().Get(name)`,为空 → 报错 `unregistered p2p: `;内层 dialer 须过白名单 `p2p.SupportedDialer(dialCfg.Type)`(前置 3),否则报错 `dialer %q does not support p2p`;`d = newTunnelDialer(d, provider)`(**置于既有 `d.Init(dialerCfg.Metadata)` 之前,`parse.go:272`**,wrapper.Init 转发内层 = 全链路只 Init 一次)后既有 `d.Init`/`NewTransport` 照旧 | — | -| `x/config/loader/loader.go`(改) | 新 `p2ps` 段:iterate `cfg.P2Ps` → `ParseP2P` → `registerGroup(entries, registry.P2PRegistry())` | 同文件 recorder 块(~242 行) | -| `x/p2p/plugin/grpc_test.go`(新) | 聚焦测试(见下) | 包内 fake server(`x/recorder/recorder_test.go` 风格) | - -**接线次序关键点**:p2p 包裹发生在 `ParseNode` 解析出 dialer 之后、**既有 `d.Init` 之前**(`x/config/parsing/node/parse.go:272`)——`d = newTunnelDialer(d, provider)` 后既有 `d.Init`(wrapper.Init 转发内层)/`NewTransport` 照旧,全程只 Init 一次。适配器不重做握手:内层握手经 `Transport.Handshake` 对 `dialer.Handshaker` 的类型断言触发(`x/chain/transport.go:59`),wrapper 实现并转发(见"内层协议与 mux")。与 route-splitting(`x/chain/chain.go:152` `Multiplex()` 子路径嵌入)不冲突:桩下内层 tcp → `Multiplex()=false`;且隧道即"基础通路",链前路径不再叠加(语义正确:p2p 取代到达节点的网络路径)。 - -### D. `gost/` 模块 — 无改动 - -p2p 组件经 config/loader 注册(同 recorder),无 `init()` 注册、无需空导入,`gost/cmd/gost/register.go` 不动。 - -### E. `play/` — 演示配置(新 `play/p2p.yaml`) - -```yaml -p2ps: # 命名 p2p 插件组件(同 recorders:) - - name: p2p-1 - plugin: - type: grpc - addr: 127.0.0.1:8003 # p2p 宿主 repo gRPC 地址 - # token: gost -services: - - name: service-0 - addr: :8080 - handler: - type: auto - chain: chain-0 - listener: - type: tcp -chains: - - name: chain-0 - hops: - - name: hop-0 - nodes: - - name: node-0 - addr: 127.0.0.1:18080 # "peer":桩桥接目标(对端 GOST HTTP 转发代理端口) - connector: - type: http # 显式:connector 由对端协议决定 - dialer: - type: tcp # 普通 tcp dialer——协议即底座之上的内层 - metadata: - p2p: p2p-1 # 单键声明:本节点基础通路走 p2p 插件 p2p-1 -log: - level: debug -``` -(config-validator 只查 forwarder 协议/chain/hop/auther 引用与必填字段,`p2p:` 节点 metadata 与 `p2ps:` 段不拦截,已确认可通过。`-F` CLI 可经节点 metadata 声明:`-F "http://127.0.0.1:18080?node.p2p=p2p-1"`,但需同时 `-C` 一个含 `p2ps:` 的定义——YAML 演示为主,CLI 属可选核验。) - -**本里程碑内层 dialer 只验证 `tcp`**(无握手路径,wrapper 全链路完整跑通);tls/ws/mtls/mws 需补完"内层协议与 mux"三条前置后用同类三终端流程另验(peer 侧起对应 listener)。 - -> **验证结果注记(2026-09-05 mux/token 里程碑)**:白名单已扩至 `tcp/tls/ws/mtcp/mtls/mws`(`x/p2p/tunnel_dialer.go`),wrapper 已转发 Handshaker,TLS ServerName 走既有 `node/parse.go` 的 `SplitHostPort(cfg.Addr)` 兜底(p2p 下 node addr 即 peer,与非 p2p 语义一致,无需改代码)。mux 形态:**隧道惰性开洞**(`OpenTunnel` 下沉进 `tunnelBaseDialer`——mtcp 缓存命中时不碰 base,否则每 Dial 漏一条孤儿隧道),一条隧道 = 一个 mux 会话 = N 条流,会话死亡经 `session.conn.Close()` → `CloseTunnel` 回收。控制通道 token 校验已补宿主侧(`p2p --token` + gRPC metadata,常量时间比对)。演示配置:`play/p2p-tls|ws|mtcp|mtls.yaml`。 - -### 分层认知(适配器=建底座管道,dialer=在管道上握手,connector=在管道上说话) - -p2p 适配器是 reach 面:对插件发 control RPC(OpenTunnel)→ 拨本地 endpoint → 产出 `net.Conn` 底座(经插件桥接连到对端 peer);内层 dialer 在底座上做协议握手(tls/ws/mux);connector 是 speak 面:在第 3 层做代理协议握手(HTTP CONNECT/SOCKS5/透传),只与"对端跑什么协议"相关,**永远看不到插件/endpoint**。core 只消费普通 conn,不感知 P2P。隧道是昂贵资产:本桩为"每 Dial 一隧道"的最简形态,未来里程碑在底座之上用内层 mux dialer 复用(一条隧道 = mtcp/mtls 会话 = N 条 stream;wrapper 只供底座,mux 生命周期归内层,connector=每连接服务)。 - -### connector 选型说明(回答问题"插件配 http 是否不合适") - -connector 与 p2p 插件**正交**:它只由隧道对端是什么决定,p2p dialer 仅替换"到达该对端的网络路径"。demo 对端是 GOST HTTP 转发代理节点,配 `http` 是正确且必要的(http connector 在隧道上做 HTTP CONNECT 握手)。若对端是 SOCKS5/relay 节点则改配 `socks5`/`relay`——纯配置,证明与插件机制无关。**唯一不合适的语义**:隧道直达对端某服务端口(tailcat 端口映射模型,对端非代理节点),那时应配 `tcp` 透传 connector(`x/connector/tcp/connector.go`:`return conn, nil`,无握手)而非 http。`direct` connector 不是透传(忽略隧道 conn、绕链重拨目标,`x/connector/direct/connector.go:58`)。**未来决策点**:"tunnel-to-destination" 语义是否引入、以何种形式(tcp 透传 connector / 端口映射),本桩不实现。 - -## 测试设计(`x/p2p/plugin/grpc_test.go`,包内测试) - -**不放 gost-plugins 依赖进 x**(x 不依赖 gost-plugins,方向倒置;x/CLAUDE.md 现"无测试"为现状描述——本测试作为模块内第一个聚焦测试,跑通即价值)。测试内联 fake gRPC server(实现同契约:tunnels map + 每隧道 listener + 桥接 goroutine,约 70 行)+ fake echo target;用 `NewGRPCPlugin(name, fakeAddr)` 建 provider,`newTunnelDialer` 包一个 `tcp` dialer 再 `Dial`。 - -用例: -1. `TestDialRoundTrip`——`Dial(ctx, targetAddr)` → 经桥接回环读/写;OpenTunnel 计数 1→返回 conn `Close()`→0;二次 Close 幂等。 -2. `TestPluginDown`——addr 指向已关闭端口 → Dial 报错(fail-closed:非 no-op)。 -3. `TestLocalEndpointDead`——fake 返回已关闭端口 endpoint → Dial 报错且隧道计数归零(清理路径验证)。 -4. `TestDialConcurrent`——8 goroutine 并发 dial/echo/close,全过、终计数 0(race 覆盖)。 -5. `TestOpenTunnelInvalidPeer`——peer 非 host:port(如 `foo`)→ Dial 报错(契约校验只走 gRPC status;覆盖单一失败通道)。 -6. `TestOpenTunnelBizFail`——fake 对合法 peer 回包体 `ok:false` + error → Dial 报错且计数归零(业务失败通道)。 -7. `TestHandshakeForward`——inner 用实现 `dialer.Handshaker` 的假 dialer 包 `tunnelDialer` → 断言 `tunnelDialer.(dialer.Handshaker)` 成立且转发 `AddrHandshakeOption`(防回归:内层握手必须被触发,评审实锤的缺陷)。 -8. `TestSupportedDialer`——`SupportedDialer("tcp")==true`、`SupportedDialer("kcp")==false`(前置 3 白名单回归;ParseNode 报错路径属 config 层,随接线验证)。 - -## 端到端手动验证(play/p2p.yaml + 真实二进制) - -```bash -# 终端 A — "对端":真实 gost 代理(其监听端口即桩桥接目标) -cd gost && go run ./cmd/gost -L "http://:18080" -# 终端 B — p2p 宿主(桩,独立 repo) -cd p2p && go run . --addr :8003 -# 终端 C — 客户端(p2p 链) -cd gost && go run ./cmd/gost -C play/p2p.yaml -# 验证数据面穿透桩:8080 → tcp dialer(底座经 p2p 适配器→插件隧道)→ 桥接 → 18080 → 目标 -curl -x http://127.0.0.1:8080 http://example.com/ -``` -日志中应对应看到 peer 侧连接、插件 OpenTunnel/Close 的 slog.Debug;curl 成功即缝隙跑通。可选 `-F` CLI 形态核验(节点 metadata 声明,需 `-C` 提供含 `p2ps:` 的配置):`go run ./cmd/gost -C play/p2p.yaml -L "http://:8081" -F "http://127.0.0.1:18080?node.p2p=p2p-1"`(`node.` 前缀 query 进节点 metadata)。 - -## 验证清单 - -```bash -cd plugin && go build ./... && go vet ./... # proto + 生成物 -cd p2p && go build ./... && go vet ./... # 宿主 repo -cd x && go build ./... && go vet ./... # p2p 组件/接线编译 -cd x && CGO_ENABLED=1 go test -race ./p2p/... -v # 聚焦测试 race 干净 -cd gost && CGO_ENABLED=0 go build ./cmd/gost/... && go vet ./cmd/gost/... -# 然后上述三终端 + curl 手动演示 -``` - -## 发布顺序与风险 - -1. **模块依赖链**:plugin 模块发 tag(如 v0.5.1)后,需在 x 与 p2p repo 用 `go get github.com/go-gost/plugin@` 升 pin(x 现 pin v0.5.0);gost-plugins 无关本次。**go.mod 直改被 hook 拦截**,只能走 go get/go mod tidy。go.work 内本地构建不受影响(`./p2p` 加入后);x 与 p2p repo 的独立构建在 tag 发出前会碎——先并排提交(plugin → x → p2p),符合 core→x→gost 发布顺序惯例。 -2. **生成物**:生成代码永远提交生成物、不手改;不要为 p2p 跑 `go mod tidy`(plugin 模块旧 require 会无谓抖动)。 -3. **不做的(本里程碑明确排除)**:DERP/rendezvous、打洞、data 面走 GostTunel gRPC 流(决定已锁:本地 TCP)、HTTP 插件变体、服务端 token 校验、control 通道 TLS、tunnel-to-destination(final-hop `direct` 接线,`x/connector/direct/connector.go:58`)、**x/api 的 /config CRUD 与 metrics 不覆盖 `p2ps:`**(命名组件注册不进 Web API,留到真实 p2p 引擎里程碑集成)、版本号 bump。 -4. **gofmt**:.claude/settings.json 提交钩子自动 gofmt;生成物已 gofmt 干净。 \ No newline at end of file diff --git a/docs/plans/2026-09-06-p2p-derp-relay.md b/docs/plans/2026-09-06-p2p-derp-relay.md deleted file mode 100644 index c864bdf..0000000 --- a/docs/plans/2026-09-06-p2p-derp-relay.md +++ /dev/null @@ -1,57 +0,0 @@ -# P2P 阶段二:DERP 中继(官方 derper + 自写 WS-DERP 客户端子集),GOST 侧零改动 - -## Context - -阶段一(桩 + mux + token)之后,宿主只会"loopback 听端口、桥接直拨 peer",跨机不可达。阶段二引入 **rendezvous + 中继**,路线决策(2026-09-06 定): - -- **中继服务器 = 官方 `derper` 二进制**(`tailscale.com/cmd/derper@v1.102.3`),不集成进 GOST——x/ 零代码改动。Tailscale 公网 DERP 集群不可用(coordination 校验 tailnet node key);自托管 derper `-verify-clients=false` 接受任意 node key。 -- **客户端协议 = 自写 WS-DERP 子集**(`p2p/internal/derpclient`)。实测 import tailscale.com/derp/derphttp 客户端闭包 = 289 包(含 tailcfg/kubetypes 耦合)→ 超门槛走预订退路。传输用 **WebSocket-DERP**(标准 RFC6455 升级 + `Sec-WebSocket-Protocol: derp` + DERP 二进制帧装在 WS binary 消息),与 derper 无条件挂的 `AddWebSocketSupport` 路径互通;原生 `Upgrade: DERP` 裸帧 + FastStart 跳过 101 响应,过不了 Cloudflare 等 L7 代理,故不做。 -- **peer 身份 = node key 自生成**:宿主首次启动生成 curve25519 keypair 落盘,peer-id = base64rawurl(公钥)。key 即地址;GOST 侧 `peer` 保持 opaque,配置里 node addr 直接填对方公钥。 -- **不做**(阶段三):STUN/UDP 打洞、地址发现、derper 集群 mesh。 - -endpoint 契约不变([[p2p-data-plane-seam]]):OpenTunnel → 本地 TCP endpoint → GOST 消费,隧道只透传字节。 - -## 关键事实(实证) - -- DERP 线协议:`tailscale.com/derp@v1.102.3`(derp.go)—— Magic `"DERP🔑"`、ProtocolVersion 2、帧 = 1B type + 4B BE 长度、MaxPacketSize 64KiB、ClientInfo = 32B pub + NaCl box(`NodePrivate.SealTo(serverPub, json)` 布局 = 24B nonce + box)。未知帧静默跳过(参考客户端 recv switch 无 default)。 -- derper 启动需 `-c ` 配置(`{"PrivateKey":"privkey:"}`,首次运行自动生成);`-certmode manual -certdir` 用 `.crt/.key`;`-a :4433` 配 `-certmode manual` = HTTPS。 -- derper 是纯中继不加密负载:保密性 = 内层 dialer(mtls/tls/wss),与 p2p 透明性原则一致。Cloudflare 橙云部署:WS-DERP 天然兼容,客户端 keepalive 30s ≤ CF ~100s 空闲超时;大流量有 ToS 风险(分级用法:直连主用、CF 当被墙兜底)。 - -## 改动清单(全部在 `p2p/` 模块) - -### T1. `p2p/internal/derpclient/` — WS-DERP 子集客户端(新包) - -`client.go`:`Generate`/`PrivateKey`/`PublicKey`(curve25519 + `SealTo`/`OpenFrom` 复刻 types/key 布局);`Dial(ctx, url, priv, tlsCfg)` = coder/websocket 标准 WS 升级(subprotocol "derp"、压缩禁用)→ DERP 握手(FrameServerKey magic 校验 → FrameClientInfo box 自证 → FrameServerInfo box 开解)→ `SendPacket`/`Recv`/`KeepAlive`/`Close`。Client 持有独立生命周期 context(NetConn 不能复用 Dial ctx,否则 Dial 返回后 cancel 立即掐断读循环——第一个实测坑)。依赖:`golang.org/x/crypto` + `github.com/coder/websocket`(零依赖,官方 WS 路径同款,互操作锚点)。Escape hatch 写进注释(退到 import tailscale 客户端)。 - -### T2. `p2p/engine.go` + `server.go`/`main.go` 接线 - -- `engine.go`:`Engine` 对称双角色——1 个 DERP 长连接 + 收包 pump(按 srcKey 路由到 per-peer 适配器)+ keepalive(30s) + 断线重连 ticker(5s);per-peer 适配器 = smux 会话(`net.Conn`,Write=SendPacket、Read=入站队列);**会话角色由公钥序决定**(两端确定性收敛一个 smux 会话),smux 双向都能开流;入站 accept 循环 → 桥接 `--target`(half-close 语义沿用桩)。pump 对未知 src 也建会话(入站隧道不被漏)。 -- `server.go`:`OpenTunnel` 在 derp 模式校验 peer 为 32B key、`bridge()` 把"拨 peer host:port"换成 `engine.OpenStream(peer)`;桩模式路径原样。 -- `main.go`:`--derp `(模式开关)、`--key `(hex 私钥,缺则 0600 生成,启动打印 base64 公钥)、`--target`(入站桥接目标);启动即 `engine.Connect()` 注册到中继(rendezvous 节点必须常连),失败则后台重连并继续服务 gRPC。 - -### T3. e2e(单机三进程模拟跨机中继,见验证) - -新增 `play/p2p-derp.yaml`(tcp inner)、`play/p2p-derp-mtcp.yaml`(mtcp inner)。 - -### T4. 文档(本文件 + p2p/README.md + p2p/CLAUDE.md) - -## 已知边界(记录,不修) - -- derper 无鉴权 = 公共中继:知道 pubK_b 的任何人都能向 B 发包;负载由内层协议保护;入站 target 是宿主自己的 `--target` 配置,不由对端指定——无 SSRF。 -- 客户端不能请求断开某 peer(ClosePeer 是 mesh 特权帧):会话清理靠 smux keepalive + 本地拆适配器。 -- 单收包 pump(每宿主一 goroutine):千级隧道够用,瓶颈时再分片(ponytail 边界)。 -- smux 流无半关(CloseWrite):桥接走全关降级分支,HTTP 实测可用。 -- DERP 引擎与桩模式互斥(`--derp` 开关),无运行时切换/热重载。 - -## 验证(全绿,2026-09-06) - -```bash -cd p2p && go build ./... && go vet ./... && GOWORK=off go build ./... -cd p2p && CGO_ENABLED=1 go test -race ./... # 8/8(derpclient 6 + engine 1 + parsePeerKey 1) -# derper 互操作:go install tailscale.com/cmd/derper@v1.102.3 + 自签 cert -# e2e:derp-tcp PASS / derp-mtcp PASS / derp-neg PASS(不可达 peer 拒绝) -# 回归:桩模式 7 用例(tcp/tls/ws/mtcp/mtls/token±)全 PASS -cd x && go build ./... && go vet ./... # x/ 零 diff -``` - -验收标准达成:单机三进程 e2e 全过、与官方 derper 互通、x/ 模块零改动(仅文档 + play 配置)。 diff --git a/docs/plans/2026-09-07-p2p-m2-holepunch.md b/docs/plans/2026-09-07-p2p-m2-holepunch.md deleted file mode 100644 index 3ef805e..0000000 --- a/docs/plans/2026-09-07-p2p-m2-holepunch.md +++ /dev/null @@ -1,111 +0,0 @@ -# P2P M2: STUN + UDP 打洞直连(KCP + smux,relay 先行后台升级) - -## Context - -DERP 中继里程碑(d3797f7)已打通跨机隧道,但流量全走中继。M2 引入直连:peer 间先经中继建立会话(零新增首拨延迟),后台并行 STUN + UDP 打洞,直连建成后新流走直连、旧流继续跑中继,打洞失败/直连断时静默回退中继。四个设计分叉已与用户敲定(2026-09-07): - -- **可靠层 = KCP + smux**:`kcp-go/v5 v5.6.5`(x/ 同款、gost 验证多年)上叠 smux——p2p 现有会话机制(角色按公钥序、accept 循环、OpenStream)原样复用,只换 smux 之下的传输;QUIC 需两套会话栈且依赖重,弃。 -- **升级策略 = relay 先行 + 后台打洞**。 -- **STUN = derper 自带**(`-stun` 默认开,:3478),p2p 加 `--stun` 可覆盖。 -- **单里程碑**,内部任务分阶段,一次验收。 - -数据面 seam 不变([[p2p-data-plane-seam]]):OpenTunnel → 本地 TCP endpoint,GOST/x 零改动。discovery 里程碑已否决([[p2p-derper-no-presence]]:真 derper 不给开放中继客户端发 PeerPresent),本里程碑与其无关,唯一复用的是它留下的**包级控制帧**思路(候选交换需要控制面通道)。 - -## 关键事实(已验证) - -- **kcp-go v5 API**(模块缓存 `kcp-go/v5@v5.6.5/sess.go` 实测):`NewConn3(convid, raddr, block, dataShards, parityShards, conn net.PacketConn)` = 客户端在**现有 socket** 上建会话;`ServeConn(block, shards, conn)` = 服务端在现有 socket 上监听。→ STUN 探测与 KCP 会话共用同一 UDP socket,端口相关型 NAT 下映射一致。 -- **derper 自带 STUN**:`-stun` 默认 true、`-stun-port` 3478,绑定同 IP。e2e 现有脚本跑 `-stun=false`(保持 relay-only 回归路径)。**实测发现**:derper 的 STUN 只应答 Tailscale 方言(binding 请求须带 `SOFTWARE="tailnode"` + 尾置 `FINGERPRINT` 属性,普通 RFC 5389 请求被静默丢弃);且 `-a :443` 通配时 STUN 绑 `[::]:3478`(IPv6-only),IPv4 默认 `--stun` 打不到——derper 须显式 `-a :443` 或 p2p 显式 `--stun`。 -- **PR#263 原型**([[p2p-pr263-reference]]):STUN 外部地址 + 最小 UDP 打洞 + 可靠层三件套的可行性证明;IRC 信令不可取。 -- **x/dialer/kcp 先例**:gost 自己就是 smux-over-kcp(`kcp-go/v5` + `smux` 同仓)。 -- **控制帧先例**:e28becd(已 revert)实现过 `[type 1B][payload]` 包级分帧(pump 分流 + Write 预置 type),M2 复活其分帧部分,presence/svc/announce 部分保持删除。 -- **反欺骗原语已在手**:`derpclient` 的 `PrivateKey.SealTo(PublicKey, cleartext)` / `OpenFrom(PublicKey, ciphertext)`(nacl box,24B nonce 布局,见 derpclient.go)可直接把候选封到对端公钥、以 src key 验证来源——tailscale disco 反欺骗的同款思路,零新依赖。 - -## 设计 - -### 1. 控制面:包级帧复活(engine.go,derpclient 不动) - -``` -[type 1B][payload] -type 0x00 = 控制帧: [0x00][kind 1B][…] - kind 0x02 = punch-candidates: 明文=[count 1B]([family 1B(4/6)][addr 4/16B][port 2B BE])*;封装=e.priv.SealTo(peerPub, 明文)(24B nonce + nacl box) -type 0x01 = 会话数据块(smux 字节流切片,Write 预置、pump 剥一次) -``` - -- pump 分流看 `pkt[0]`:0x00 → handleControl(进直连状态机);0x01 → 剥 type 入会话队列;未知丢弃(前向兼容)。 -- handleControl 收 kind 0x02 → `e.priv.OpenFrom(src, body)` 解封(24B nonce 在前,见 [[p2p-derp-relay-done]] 的 SealTo/OpenFrom 布局);解封成功即证明候选来自 src key(反欺骗)、失败静默丢弃。候选经 derper 转发但内容只有两端可见、第三方无法伪造——tailscale disco 反欺骗 nonce 的同款思路,复用 derpclient 现有原语,非新密码学。 -- 两端须同版本引擎(线路格式变更,宿主是新产物,可接受)。 - -### 2. STUN 客户端(新包 p2p/internal/stun,~100 行,仅 stdlib) - -RFC 5389 binding request:20B 头(type 0x0001、magic cookie 0x2112A442、随机 12B transaction ID)→ 从**给定 UDP socket** 发往 STUN server → 解析 XOR-MAPPED-ADDRESS(0x0020,XOR cookie)→ 返回 `netip.AddrPort`。 - -- API:`stun.Lookup(ctx, stunAddr string, conn *net.UDPConn) (netip.AddrPort, error)`。 -- 关键约束:必须从 punch socket 发出(同 socket 才保证 NAT 映射一致)。 - -### 3. 打洞状态机(新文件 p2p/direct.go,package main) - -per-peer 直连状态:`none / attempting / up / backoff`。触发:relay smux 会话建立(`ensureSessionLocked` 成功后调 `maybeStartDirect(peer)`,两端各自触发、幂等收敛)。 - -一次尝试流程(A/B 双方,角色由公钥序决定,与 relay 会话同逻辑): - -1. 双方各建 `net.ListenUDP("udp4", :0)` punch socket。 -2. 各用**自己的 socket** 发 STUN 得公网 ep;发候选帧 `[公网ep, 本地ep]` 给对方(本地候选服务同 NAT/同机直连)。 -3. 收齐对方候选后:**key 小者 = KCP client** → `kcp.NewConn3(conv, peer公网ep, nil, shards, socket)`,SYN 重传即打洞探测;**key 大者 = KCP server** → `kcp.ServeConn(nil, shards, socket)` + 从同 socket 周期性发 dummy 探测包到对方候选(打开己方 NAT 映射、让 client 的 SYN 能进来;dummy 被对方 kcp Input 当垃圾丢弃)。 -4. `conv` 由密钥对确定性派生(sha256(排序后的两公钥) 前 4 字节)——重试复用同 conv,无协商。 -5. KCP 会话建立 → 其上起 smux(角色逻辑同 relay 会话:`roleIsClient` → smux.Client/Server(kcpConn))→ `startAccept`。 -6. 超时(punch 10s)未建成 → 关 socket、标记 backoff(30s)重试;期间新流全走 relay。 - -会话升级与回退: - -- `OpenStream`:优先 `pc.direct` 的 smux 会话(存活则用),否则 relay 会话。升级对旧流无影响(旧流继续跑 relay 直到关闭)。 -- 正常运行期 relay 会话**不拆**(fallback 保活);直连断(kcp/smux close)→ 新流自动回退 relay + 安排重新打洞。 -- **直连会话不依赖 DERP,derper 掉线不回收直连**:`teardown`(DERP 传输断开)只拆 relay 会话(client=nil、relay smux close),直连继续服务——这是 e2e「kill derper 直连不断」的前提。relay 重连成功后恢复 fallback;重新打洞需等控制面(relay)回来,故直连建立后、derper 掉线期间无法 re-punch(可接受)。 -- 直连回收仅在:`engine.Close`(整机关闭)或直连会话自身 close(kcp/smux 死)。`pc.kill`/`teardown` 不回收直连。 - -### 4. 接线 - -- `main.go`:`--stun `(缺省 = `--derp` URL 的 host + `:3478`;derper 默认开 STUN)。 -- `engine.go`:分帧(T1)、`maybeStartDirect` 触发、handleControl → direct 状态机、OpenStream 优先直连。 -- go.mod:`github.com/xtaci/kcp-go/v5 v5.6.5`(与 x/ 对齐)。 - -## 改动清单 - -### T1 控制帧复活(engine.go) -pump 分流 + `peerConn.Write` 预置 `0x01` + `handleControl`(kind 0x02,发侧 SealTo / 收侧 OpenFrom)——从 e28becd 照搬分帧,presence/svc/announce 不复活,候选封装/解封为本里程碑新增。 - -### T2 p2p/internal/stun/stun.go(新包) -binding request + XOR-MAPPED-ADDRESS 解析;`stun_test.go` 配进程内最小 STUN responder。 - -### T3 p2p/direct.go(新文件) -directState 状态机 + KCP client/server 角色 + dummy 探测 + smux-over-kcp + 回退/重试 + 回收。 - -### T4 接线 -`--stun` flag、触发点、OpenStream 优先序、go.mod 加 kcp-go/v5。 - -### T5 测试(全部 -race) -- stun:进程内 fake STUN server 往返。 -- direct:fake relay(现有 relayServer 扩展"可切换丢弃 0x01 数据帧")+ fake STUN(返回回环候选):直连建立后令 relay 丢弃数据帧 → 流仍通(证明走直连);直连关闭 → 新流回退 relay 仍通;punch 超时路径(fake STUN 给不可达候选)→ 停留 relay 不崩。 -- 回归:现有 8 用例全绿(分帧改动经过 pump/Write)。 - -### T6 e2e + 文档 -- e2e:真 derper **开 `-stun`**(去掉 `-stun=false`),A/B 双宿主 + gost,curl 打通后 **kill derper** → curl 仍通(直连连续性证明);`-stun=false` 跑现有 derp e2e 脚本(relay-only 回归)。 -- 文档:p2p/CLAUDE.md(flag 表、架构、milestones 更新)、p2p/README.md、本 spec 文件。 - -## 已知边界(记录,不修) - -- IPv4 打洞优先(候选帧带 family 字节为 v6 留位,本期不实现 v6 打洞)。 -- 对称 NAT 打洞失败 → 永久 relay(周期重试,常量 30s)。 -- KCP 链路无内建加密(透明性原则:内层 dialer mtls/tls/wss 才是保密门槛,与 DERP 同信任模型)。 -- derper 部署须开 STUN(默认开);`-stun=false` 部署静默保持 relay-only。 -- 每次尝试新建 socket + STUN 查询(无缓存,规模小时无所谓;peer 多了再加共享 STUN 缓存)。 -- 直连会话与 relay 会话并存:smux keepalive 流量在两条链路上都会持续。 - -## 验证 - -```bash -cd p2p && go build ./... && go vet ./... && GOWORK=off go build ./... -cd p2p && CGO_ENABLED=1 go test -race -count=1 ./... # 新增 stun/direct 用例 + 8 既有全绿 -# e2e:derper 开 -stun;B 宿主 + A 宿主 + gost;curl 打通 → kill derper → curl 仍通(直连证明) -# 回归:现有 /tmp/p2p-derp-e2e.sh(-stun=false,relay-only 路径) -cd x && go build ./... && git status --short # x/ 零 diff 核验 -```