From 2487e616c2f5b30aae5b9eb7e0b2d0971a204e9b Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 23 Oct 2026 17:30:27 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=89=93=E6=B4=9E=E8=AE=BE=E8=AE=A1/?= =?UTF-8?q?=E5=A4=9A=E5=B9=B3=E5=8F=B0=E6=9E=B6=E6=9E=84/=E5=80=9F?= =?UTF-8?q?=E9=89=B4=E6=98=A0=E5=B0=84/=E9=AA=8C=E8=AF=81=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 打洞与信令设计.md: 无中继方案的流程、成功率边界、Rust 实现约束 - 多平台架构设计.md: 6 层分层 + 平台矩阵 + 六项关键决策 + 分期路线 - 借鉴映射与落地规范.md: 每层看谁/抄什么/不抄什么 + 许可证红线(AGPL 只读) - 打洞验证记录.md: POC 实测结论(锥形 NAT + 端口保留)与 4 个坑 - README: 文档索引补充 --- README.md | 2 +- 借鉴映射与落地规范.md | 301 +++++++++++++++++++++++++++++++++ 多平台架构设计.md | 270 +++++++++++++++++++++++++++++ 打洞与信令设计.md | 122 +++++++++++++ 打洞验证记录.md | 123 ++++++++++++++ 5 files changed, 817 insertions(+), 1 deletion(-) create mode 100644 借鉴映射与落地规范.md create mode 100644 多平台架构设计.md create mode 100644 打洞与信令设计.md create mode 100644 打洞验证记录.md diff --git a/README.md b/README.md index 102eda4..0c803f5 100644 --- a/README.md +++ b/README.md @@ -27,4 +27,4 @@ agent.exe --port 47800 --code 123456 viewer.exe --addr <被控机IP> --port 47800 --code 123456 ``` -详见《构建运行手册.md》与《Git协作规范.md》。 +详见《构建运行手册.md》、《Git协作规范.md》与《打洞与信令设计.md》。 diff --git a/借鉴映射与落地规范.md b/借鉴映射与落地规范.md new file mode 100644 index 0000000..e83138a --- /dev/null +++ b/借鉴映射与落地规范.md @@ -0,0 +1,301 @@ +# 借鉴映射与落地规范 + +> 配套《多平台架构设计.md》。本文回答三个问题:**每个模块怎么设计、看谁、抄什么、不抄什么。** +> 状态:架构部分已定稿;第 2 章的「逆向需求清单」等同事的逆向资料回填。 + +--- + +## 0. 使用方式 + +| 角色 | 看哪几章 | +|---|---| +| A(被控侧) | §3.5 platform、§3.6 agent-core、§4 表中 agent 相关行 | +| B(主控+协议) | §3.1–3.4、§3.7 viewer-core、§5 许可 | +| 移动端(待补人) | §3.4 media(解码后端)、§3.8 apps | +| 全员 | §2 逆向红线(踩了会出大事) | + +--- + +## 1. 两条铁律 + +1. **架构由需求决定,不由参考项目决定。** 参考项目用来解决"这一块业界怎么做的",不是用来决定"我们做成什么样"。 +2. **能抄的是算法和协议思路,抄不到的是平台集成和 UI。** 后者是体力活,任何参考项目都救不了(它们自己也是硬写的)。 + +--- + +## 2. 逆向资料的用法与红线 + +### 2.1 红线(务必转告同事) + +| 可以做 | 不可以做 | +|---|---| +| 记录**功能清单、交互流程、界面布局** | 把反编译产物、反汇编清单提交进仓库 | +| 记录**用户可见的错误提示、状态显示** | 逐字复制对方的 UI 文案、图标、素材 | +| 推断**产品模型**(账号/设备/授权怎么设计) | 抄对方私有协议的具体编码(抄了也不能互通) | +| 记录**网络行为的宏观特征**(是否中转、连接耗时) | 提取并移植对方的算法实现 | + +**仓库里只提交"行为结论",不提交"逆向产物"。** 建议逆向资料单独放一个**不纳入 git** 的目录(或内部网盘),文档里只写结论。这条不是洁癖——商用产品里出现竞品反编译代码是实打实的法律风险。 + +### 2.2 逆向需求清单(直接发给同事) + +**A. 产品功能面(最重要,优先做)** + +1. 主控端完整交互流程截图流:登录 → 设备列表 → 连接(输密码/免密)→ 会话中 → 断开 +2. 每个平台的功能矩阵:Win/Linux/Android/iOS 各有哪些功能、哪些缺失 +3. 会话中工具栏全清单:分辨率切换、多屏、文件传输、剪贴板、音频开关、画质/帧率档位、游戏模式 +4. 设置项全清单(分"被控设置"和"主控设置"两类) +5. 错误提示文案全集 + 断线重连时的用户可见行为 + +**B. 身份与授权模型** + +6. 设备 ID 的形式(长度/字符集)、在哪里查看、能否自定义 +7. 授权方式有几种:临时密码 / 固定密码 / 免密白名单 / 账号直接授权?默认是哪种 +8. 账号体系:是否必须登录、登录后设备列表怎么呈现、一台设备能否被多账号访问 +9. 被控端的"允许谁连"控制(黑名单/白名单/单次确认弹窗) + +**C. 被控端形态** + +10. 是不是系统服务?开机自启吗?锁屏/注销/UAC 提权界面能不能连? +11. 安装包形态、是否需要管理员权限、卸载残留 + +**D. 网络行为(只看宏观,不要深入协议)** + +12. 连接时用户是否能看到"直连 / 中转"标识 +13. 典型连接耗时、转中继时的画质变化 +14. 端口/协议宏观特征:UDP 还是 TCP 为主、监听端口是否固定 + +**E. 商业化(可选但有用)** + +15. 免费额度/收费点/增值功能边界 + +### 2.3 逆向资料回填位置 + +| 资料 | 回填到 | +|---|---| +| A 组 | 本文 §6「产品范围基线」+ 里程碑取舍 | +| B/C 组 | §3.3 session(身份模型影响状态机) | +| D 组 | §3.2 conn(验证我们的打洞/中继策略) | +| E 组 | 不在本文,属于商业决策 | + +--- + +## 3. 模块级设计规范 + +### 3.1 `proto` — 协议 + +**职责**:信令消息、控制消息、媒体容器格式。三端(agent/viewer/信令服务)共享契约。 + +**设计点** +- **双协议分离**:信令协议(`signaling.rs`,文本友好、低频、可 JSON)与媒体控制协议(`lib.rs`,二进制、高频)**不要混**。前者要便于抓包排查,后者要极致紧凑。 +- **版本协商**:`SIGNALING_VERSION` 不一致直接拒绝,不做兼容。媒体协议用"能力协商"而非版本号(见 §3.3)。 +- **消息演进**:现用 bincode。二期若换 protobuf,**必须一次性切换**,不要两套并存。 +- **单帧长度上限**:现有 `ensure!(len < 1<<20)` 这类边界检查要保留,且信令侧对端候选列表必须经 `sanitize_candidates`。 + +**看谁 / 抄什么** + +| 项目 | 抄什么 | 不抄 | +|---|---|---| +| RustDesk `libs/hbb_common` | 消息分类方式(信令/中继/控制分层) | 具体协议编码(AGPL + 抄了不互通) | +| Moonlight `moonlight-common-c` | **控制消息与媒体分离**的成熟做法 | 它的私有控制协议 | + +**产出物**:`crates/proto/src/{lib.rs, signaling.rs}` + +--- + +### 3.2 `conn` — 连接层(最值得借鉴的一块) + +**职责**:信令客户端、打洞编排、路径选择、QUIC 会话、保活。 + +**设计点** +- **`PathProvider` 抽象**(已在《打洞与信令设计.md》定义):现在只实现 Direct,类型上预留 Relay。 +- **打洞编排**:候选收集(v6/Host/UPnP/Reflexive/Predicted)→ 排序 → 信令交换 → 同步打洞 → 落选清理。 +- **同一 socket 双角色**:打洞用的 UDP socket 必须与后续 QUIC 是同一个,否则洞不在同一映射上。 +- **保活**:按双方 `binding_timeout_s` 折半,5–60s。**这是"连一会儿就断"的根治点。** +- **不用 QUIC 握手当探测**:先用轻量 UDP 探测包确认通路(秒级失败),再 `connect`。 + +**看谁 / 抄什么** + +| 项目 | 抄什么 | 许可 | +|---|---|---| +| **iroh** | **整套**:打洞编排、中继回退、地址发现。技术栈与你们重合(Rust + quinn) | Apache-2.0 ✅ **可直接依赖** | +| webrtc-rs / pion/ice | ICE 状态机、候选配对(candidate pairing)策略 | MIT/Apache ✅ | +| Tailscale / headscale | DERP 中继的**回退判定条件**与降级策略 | BSD-3 ✅ | +| RustDesk `rendezvous_mediator` 相关 | 信令 ↔ 打洞的**编排顺序**(读思路) | AGPL ⚠️ 只读 | +| libjuice / libnice | ICE 精简实现(作为对照,不必依赖) | MPL / LGPL | + +> **建议**:`conn` 优先评估**直接依赖 iroh**。它解决的正是你们纠结的"打洞要不要中继",而且给了"直连优先、失败回退"的完整实现。自研仅当 iroh 有硬伤(体积、可控性、特定 NAT 场景)时才考虑。 + +**产出物**:`crates/conn/` + +--- + +### 3.3 `session` — 会话编排("一体程序"的核心) + +**职责**:会话状态机、角色仲裁、能力协商、生命周期管理、被控端准入控制。 + +**设计点** +- **状态机双线**:主控线 / 被控线各自独立,同一进程可并发(被控中也能主控别人)。 +- **角色仲裁**:打洞阶段双方对称,**谁先打通谁定角色**(被控方做 QUIC server),不预设。 +- **能力协商**:被控端上报硬件能力(可用编码器、最大分辨率、帧率上限、是否支持音频),主控端选择。**用能力协商代替版本号**——旧客户端缺字段就是"不支持",天然向后兼容。 +- **准入控制**:单会话/多会话、白名单、单次确认弹窗。**这一条直接来自 §2 逆向需求 B 组第 7/9 项**,等资料回填后定稿。 +- **失败出口**:`PunchFailReason::user_hint()` 必须一路透传到 UI,不允许出现无解释的"连接中"。 + +**看谁 / 抄什么** + +| 项目 | 抄什么 | +|---|---| +| RustDesk `src/server` / `src/client` | 会话生命周期与连接管理的分层方式(读思路) | +| Sunshine + Moonlight | host/client 两侧的**能力协商**与配置下发流程 | +| — | 状态机本身建议自己画,不要套用别人的(各家产品逻辑差异大) | + +**产出物**:`crates/session/` + +--- + +### 3.4 `media` — 编解码抽象 + +**职责**:编码/解码 trait、各平台后端、零拷贝通路、码率自适应闭环。 + +**设计点** +- **不对称策略**:**编码用 FFmpeg**(只在 Win/Linux,桌面端不心疼包体),**解码用平台原生**(四个主控平台都要,移动端必须硬解且要零拷贝)。 +- **trait 收口**(见《多平台架构设计.md》§4 决策 4),上层不允许出现平台判断。 +- **零拷贝**:解码器直接输出到渲染表面(D3D11 纹理 / `SurfaceView` / `CAMetalLayer`),中间不做 CPU 拷贝。 +- **软硬兜底**:硬编失败回落 openh264;硬解失败回落软解(桌面可,移动端应直接提示而非硬撑)。 +- **码率自适应闭环**:接收端统计(丢包/抖动/帧间隔 p95)→ 反馈编码端 → AIMD 调码率。协议已留 `RateReport`(见 B侧设计文档 B2)。 + +**看谁 / 抄什么** + +| 项目 | 抄什么 | 许可 | +|---|---|---| +| **libwebrtc** `modules/congestion_controller/goog_cc` | **GCC 拥塞控制**的完整思路——这是码率自适应的教科书 | BSD-3 ✅ 可参考可移植 | +| **libwebrtc** `modules/video_coding/jitter_buffer` | 抖动缓冲与丢包重排 | BSD-3 ✅ | +| **Sunshine** `src/video` + `src/platform` | **抓屏→编码→打包**的完整管线组织,尤其是多编码器抽象与回落 | GPL-3.0 ⚠️ 只读 | +| **Moonlight** `moonlight-common-c` | 接收端解码→渲染→HUD 的管线,以及**丢包恢复策略** | GPL-3.0 ⚠️ 只读 | +| FFmpeg | NVENC/QSV/AMF/VAAPI 的调用姿势与参数 | LGPL/GPL ⚠️ 注意构建选项 | +| openh264 | 软编兜底(已在用) | BSD ✅ | + +**产出物**:`crates/media/` + +--- + +### 3.5 `platform` — L0 平台层(抄不到,只能自己写) + +**职责**:抓屏、注入、渲染表面、音频 IO、权限、常驻。 + +**为什么抄不到**:这层是各平台 API 的体力活。参考项目能给的是"用哪个 API、踩过什么坑",不是可复用代码。 + +**设计点** + +| 平台 | 关键坑 | +|---|---| +| Win 抓屏 | DXGI DD 在**会话切换/RDP 断开**时失效,必须处理 `DXGI_ERROR_ACCESS_LOST` 并重建 | +| Win 注入 | 服务进程注入到用户会话需要 `CreateProcessAsUser` + 会话隔离处理;UAC 提权界面默认收不到输入(需 `uiAccess` 签名) | +| Win 常驻 | 服务 + 用户会话代理双进程,锁屏可连靠的是**服务侧保持会话** | +| Linux 抓屏 | X11 用 XShm/XComposite;**Wayland 必须走 PipeWire + xdg-desktop-portal**(有授权弹窗,无法静默) | +| Linux 注入 | X11 用 XTest;**Wayland 用 libei/uinput**(uinput 需 root 或 udev 规则) | +| Linux 常驻 | systemd 服务 + 会话切换处理(Wayland 的 portal 授权是最大障碍) | +| 渲染表面 | 四平台各一套;**必须支持"表面尺寸≠视频尺寸"的缩放与拉伸模式** | + +**看谁** + +| 项目 | 看什么 | +|---|---| +| Sunshine `src/platform/{windows,linux}` | 抓屏与注入的分平台组织方式、Wayland 处理 | +| RustDesk `libs/scrap` | 同样的抓屏抽象(多平台统一 trait)——**这是它最有价值的部分** | +| libei / xdg-desktop-portal 官方文档 | Wayland 的正规姿势(别用野路子) | + +**产出物**:`crates/platform/` + +--- + +### 3.6 `agent-core` / `viewer-core` — 两条链路 + +**职责**:把现有 `agent`/`viewer` 的 main 逻辑抽成库,供一体程序按角色调用。 + +**设计点** +- **`agent-core`**:capture → encode → 打包 → 发送;输入接收 → 注入。A 已在进行中(`agent-core/src/lib.rs` 398 行)。 +- **`viewer-core`**:接收 → 解码 → 渲染表面;输入采集 → 发送。 +- **生命周期**:两者都不允许自己决定退出,由 `session` 层驱动。 +- **单槽 latest-wins**:现有做法保留(网络慢时只发最新帧),这是正确设计。 + +**看谁**:RustDesk(抽库方式)、Sunshine/Moonlight(管线组织)。 + +**产出物**:`crates/agent-core/`、`crates/viewer-core/` + +--- + +### 3.7 `apps/*` — UI 与常驻(第二个抄不到的地方) + +**职责**:UI 壳、常驻服务、安装包。 + +**设计点** +- **硬约束**:所有业务逻辑在 Rust,UI 只调一层稳定 API(决定了将来能否换 UI 框架)。 +- **视频走原生子表面**,绝不在 WebView 里逐帧渲染。 +- **一体 = 一个安装包 + 两个进程**:前台 UI + 后台被控服务,本地 IPC。 +- **移动端只有前台进程**(纯主控)。 + +**看谁** + +| 项目 | 抄什么 | +|---|---| +| Tauri v2 官方 mobile 示例 | WKWebView/Android WebView 与原生 surface 的叠加方式 | +| RustDesk `flutter/` | 同一套 UI 适配多端的组织方式(它用 Flutter,可对照取舍) | +| Moonlight `moonlight-android` / `moonlight-ios` | **移动端主控的完整交互**:虚拟鼠标、手势映射、软键盘、性能档位 | + +**产出物**:`apps/desktop/`、`apps/android/`、`apps/ios/`、`apps/daemon-win/`、`apps/daemon-linux/` + +--- + +## 4. 借鉴映射总表 + +| 模块 | 主参考 | 许可 | 用法 | +|---|---|---|---| +| 打洞 / 中继 / 地址发现 | **iroh** | Apache-2.0 | **直接依赖** | +| ICE / STUN / TURN | webrtc-rs、pion、coturn | MIT/BSD | 依赖或参考 | +| 中继回退策略 | Tailscale / headscale | BSD-3 | 读思路 | +| 拥塞控制 / 抖动缓冲 | **libwebrtc** | BSD-3 | 读思路,重写 | +| 抓屏→编码管线 | **Sunshine** | GPL-3.0 | 只读 | +| 接收端解码→渲染 | **Moonlight** | GPL-3.0 | 只读 | +| 整机架构 / 抽库方式 | **RustDesk** | **AGPL-3.0** | **只读,绝不抄代码** | +| 移动端主控交互 | Moonlight mobile | GPL-3.0 | 只读 | +| Rust↔移动端绑定 | UniFFI | MPL-2.0 | 依赖 | + +--- + +## 5. 许可结论与依赖策略 + +| 许可 | 能否进产品 | 项目 | +|---|---|---| +| MIT / Apache-2.0 / BSD | ✅ 可依赖、可移植 | iroh、webrtc-rs、pion、coturn、libwebrtc、UniFFI(MPL 弱) | +| LGPL | ⚠️ 可动态链接,改库要开源 | FFmpeg、GStreamer、libnice | +| **GPL / AGPL** | ❌ **不可进代码** | **RustDesk(AGPL)、Sunshine/Moonlight(GPL)** | + +**AGPL 特别提示**:网络服务也算分发。引入 RustDesk 代码 = 你们整个产品连服务端都要开源。 + +**依赖准入流程**(补充进《Git协作规范.md》的合并规则):新增依赖必须在 PR 描述里写明**许可证 + 是否修改其源码**,由另一人确认。 + +--- + +## 6. 产品范围基线(待逆向资料回填) + +> 拿到 §2.2 的 A 组资料后填这里,用于定"做哪些功能、砍哪些平台"。 + +| 功能 | UU 有 | 我们一期 | 备注 | +|---|---|---|---| +| 桌面画面 | | ✅ | 已有 | +| 键鼠控制 | | ✅ | 已有 | +| 音频 | | ? | | +| 文件传输 | | ? | | +| 剪贴板同步 | | ? | | +| 多屏 | | ? | | +| 移动端主控 | | ? | 人力问题见《多平台架构设计.md》§8 | +| 无人值守 | | ? | 安全含义,需固定密码+白名单 | + +--- + +## 7. 待拍板 + +1. `conn` 是否**直接依赖 iroh**(而不是自研打洞)——建议先花 2 天做技术验证 +2. 逆向资料回来后,"产品范围基线"的取舍(尤其移动端) +3. `media` 编码侧是否引入 FFmpeg(包体/构建成本 vs 省下的适配工作) +4. 依赖准入流程是否写进《Git协作规范.md》 diff --git a/多平台架构设计.md b/多平台架构设计.md new file mode 100644 index 0000000..fa3e069 --- /dev/null +++ b/多平台架构设计.md @@ -0,0 +1,270 @@ +# 多平台架构设计 + +> 需求:**主控端** Windows / Linux / Android / iOS;**被控端** Windows / Linux。 +> 桌面端(Win/Linux)主控+被控合为一体;移动端仅主控。 +> 前置文档:《打洞与信令设计.md》《B侧设计文档.md》《构建运行手册.md》 + +--- + +## 0. 一句话架构 + +**一个 Rust 核心 + 一层薄平台适配 + 可替换的 UI 壳。** +业务逻辑(连接/打洞/会话/协议)100% 在 Rust 里跨平台共享;只有**抓屏、编解码、注入、渲染表面**四件事分平台写;UI 层只管"壳",**永远不碰视频流**。 + +--- + +## 1. 纵向分层 + +``` +┌──────────────────────────────────────────────────────────┐ +│ L4 UI 壳 Win/Linux/Android/iOS 各自实现 │ +│ 设备列表 / 设置 / 连接管理 / 悬浮控件 │ +│ ★ 绝不放视频渲染 │ +├──────────────────────────────────────────────────────────┤ +│ L3 会话层 session 纯 Rust,全平台共享 │ +│ 会话状态机、角色仲裁、能力协商、keepalive 调度 │ +├──────────────────────────────────────────────────────────┤ +│ L2 连接层 conn 纯 Rust,全平台共享 │ +│ 信令客户端、打洞编排、中继接口(预留)、QUIC(quinn)多流 │ +├──────────────────────────────────────────────────────────┤ +│ L1 媒体层 media Rust + 平台后端 │ +│ 编码/解码/色彩转换/缩放/音频(Opus) trait 化 │ +├──────────────────────────────────────────────────────────┤ +│ L0 平台层 platform 唯一必须分平台的地方 │ +│ 抓屏 / 注入 / 音频IO / 渲染表面 / 权限 / 常驻 │ +└──────────────────────────────────────────────────────────┘ +``` + +**分界线原则**:L1 以上任何代码不允许出现 `#[cfg(windows)]`。平台差异只能通过 L0 的 trait 暴露。 + +--- + +## 2. 仓库结构 + +``` +remote/ +├── crates/ +│ ├── proto/ # 协议:信令 + 控制 + 媒体容器(现 common/) +│ ├── conn/ # 信令客户端 / 打洞编排 / QUIC 会话 / 中继接口 +│ ├── session/ # 会话状态机 + 角色仲裁("一体"的核心) +│ ├── agent-core/ # 被控链路:capture → encode → send [A 已在做] +│ ├── viewer-core/ # 主控链路:recv → decode → surface +│ ├── media/ # 编解码 trait + 各平台后端 + openh264 软兜底 +│ └── platform/ # L0 实现,按 cfg 编译各平台 +├── apps/ +│ ├── desktop/ # Tauri v2 壳:Win/Linux 一体程序 +│ ├── android/ # 主控(Tauri mobile 或原生 Kotlin) +│ ├── ios/ # 主控(Tauri mobile 或原生 Swift) +│ ├── daemon-win/ # Windows 服务:被控常驻 +│ └── daemon-linux/ # systemd:被控常驻 +├── bindings/ # UniFFI 生成的 Kotlin/Swift 绑定 +└── tools/ # bench / 信令服务器 +``` + +`agent-core` 与 `viewer-core` 的拆分是**一体化的前提**:现在的 `agent`/`viewer` 是两个 main,必须先变成库,才能被同一个进程按角色调用。A 正在做的 `agent-core` 抽取就是在铺这条路。 + +--- + +## 3. 平台能力矩阵 + +| 能力 | Windows | Linux | Android | iOS | +|---|---|---|---|---| +| 抓屏 | DXGI Desktop Duplication | X11 (XShm) / Wayland (PipeWire portal) | — | — | +| 硬编 | NVENC / QSV / AMF | VAAPI / NVENC | — | — | +| 硬解 | D3D11VA / NVDEC | VAAPI | MediaCodec | VideoToolbox | +| 渲染表面 | D3D11 子窗口 | X11/GL 子窗口 | SurfaceView | UIView + CAMetalLayer | +| 键鼠注入 | SendInput | XTest / uinput / libei | — | — | +| 键鼠采集 | Raw Input | evdev | 触摸→鼠标映射 | 触摸→鼠标映射 | +| 音频出 | WASAPI loopback | PulseAudio monitor | — | — | +| 音频入/放 | WASAPI | PulseAudio | AudioTrack | AVAudioEngine | +| 常驻服务 | Windows Service | systemd | — | — | +| **角色** | **主 + 被** | **主 + 被** | **仅主** | **仅主** | + +移动端没有"被控",所以 Android/iOS **不需要**抓屏、编码、注入、服务化——少了一半工作量,这是需求里最省事的一条。 + +--- + +## 4. 六个关键决策 + +### 决策 1:视频渲染绝不走 UI 框架 + +从 WebView / Compose / Flutter 里逐帧渲染 60fps 视频必然掉帧、增延迟。 +**做法**:UI 框架只画"壳",视频走**原生子表面**叠加在 UI 之上—— +Win: 子 HWND + D3D11;Linux: 子窗口 + GL;Android: `SurfaceView`;iOS: `UIView` + `CAMetalLayer`。 +MediaCodec / VideoToolbox 都能**直接解码到该 Surface,零拷贝**。这是 Parsec/ToDesk 这类产品的通行做法。 + +### 决策 2:UI 框架 —— 推荐 Tauri v2,但必须可替换 + +| 方案 | 覆盖 | 评价 | +|---|---|---| +| **Tauri v2** | Win/Linux/Android/iOS | ✅ 推荐:Rust 原生、一套 UI 四端、IPC 天然 | +| Flutter | 四端 | UI 强,但 Rust 桥(flutter_rust_bridge)+ 平台视图叠加更绕 | +| 各平台原生 ×4 | 四端 | 性能最好,**2-3 人团队不可能维护** | + +选 Tauri v2 的理由:你们本来就写 Rust,视频走原生表面后 WebView 只做表单和列表,性能足够。 +**代价**:Tauri 移动端还不够成熟(WKWebView 与原生 surface 叠加、生命周期)。所以—— + +> **硬性约束:所有业务逻辑必须在 Rust 里,UI 只通过一层稳定 API 调用。** +> 这样移动端若体验不达标,可以单点退化成原生 Kotlin/Swift + UniFFI,桌面仍用 Tauri,核心零改动。 + +### 决策 3:Rust ↔ 移动端绑定用 UniFFI + +6 个平台角色组合下手写 JNI/FFI 会失控。UniFFI 从 Rust 生成 Kotlin/Swift 绑定,含 async、错误类型、对象生命周期,是当前标准解法。 + +### 决策 4:编码用 FFmpeg,解码用平台原生 + +不对称,但都有理由: + +- **编码(只在 Win/Linux 被控端)**:用 FFmpeg 统一接管 NVENC/QSV/AMF/VAAPI,openh264 软编兜底。桌面端不心疼包体,省下大量适配。 +- **解码(四个主控平台都要)**:直接用平台原生 API。 + - 移动端**必须**硬解(软解烧电池、发热、跑不动 1080p60) + - 平台原生能零拷贝直出渲染表面,FFmpeg 反而多一次拷贝 + - 绕开 FFmpeg 在 Android/iOS 上的交叉编译地狱和许可问题 + +`media` crate 用 trait 收口: + +```rust +pub trait VideoDecoder { + fn configure(&mut self, cfg: &VideoConfig, target: SurfaceHandle) -> Result<()>; + fn decode(&mut self, packet: &[u8]) -> Result>; +} + +pub trait VideoEncoder { + fn configure(&mut self, cfg: &EncodeConfig) -> Result<()>; + fn encode(&mut self, frame: &FrameHandle, out: &mut Vec) -> Result<()>; + fn request_keyframe(&mut self); +} +``` + +### 决策 5:"一体程序" = 一个安装包 + 两个进程 + +用户视角是一个 app,实现上职责分离: + +``` + ┌────────────────────────┐ ┌──────────────────────────┐ + │ 前台进程(UI) │ IPC │ 后台常驻(被控服务) │ + │ 主控画面 / 设备列表 │<──────>│ 抓屏·编码·注入·保活 │ + │ 退出即结束 │ 本地管道│ 开机自启 / 锁屏可连 │ + └────────────────────────┘ └──────────────────────────┘ + ▲ ▲ + │ 移动端只有这一个 │ 仅 Win/Linux +``` + +理由:**两种角色的生命周期天然冲突**——被控要"开机就在、没人操作也在",主控要"跟窗口走、关了就走"。硬塞进一个进程会导致主控一退出就掉线。 +IPC 用命名管道(Win)/ Unix socket(Linux),或复用 QUIC 走本地回环。 + +### 决策 6:中继接口必须预留 —— 哪怕现在不实现 + +`conn` 层定义: + +```rust +pub trait PathProvider { + /// 返回一条可用数据通道:直连优先,失败时可回退 + async fn establish(&self, peer: &PeerInfo) -> Result; +} + +pub enum Path { Direct { addr: SocketAddr }, Relay { endpoint: EndpointId } } +``` + +现在只实现 `Direct`。**但类型上留出 `Relay`**,将来补中继不必动会话层。 + +> ⚠️ **这条与"不做中继"的决定直接相关,见 §6。** + +--- + +## 5. 会话状态机(L3 核心) + +一体程序两条线: + +``` +主控线: Idle → Signalling → Punching → Handshaking → Streaming → Closing +被控线: Idle → Registered → Incoming → Punching → Handshaking → Serving → Closing + ↑ 同一进程两线可并发(被控中也能主控别人) +``` + +要点: +- 打洞阶段**双方对称**:谁都不预设客户端/服务端,各自向对端候选发包,谁先打通谁定角色(被控方做 QUIC server) +- 会话能力协商:分辨率、编码器、码率上限、是否带音频——由**被控端**根据硬件能力给候选,主控端选 +- 单进程内用 tokio 一runtime 跑多会话;被控同时只服务一个主控(可配置) + +--- + +## 6. ⚠️ 必须现在拍板:手机主控 vs 不做中继 + +**手机在蜂窝网络下几乎 100% 处于运营商级 NAT(CGNAT),打洞打不通。** +这不是实现问题,是网络事实——`100.64.0.0/10` 那段地址根本没给你可映射的公网端口。 + +后果:**"主控端支持安卓/iOS" + "不做中继" = 手机只能在 WiFi 下用。** +地铁上、外面、用流量,一律连不上。 + +三条出路,必须选一条: + +| 方案 | 代价 | 说明 | +|---|---|---| +| **A. 只靠 IPv6** | 部分用户不可用 | 国内三大运营商 v6 覆盖率高,手机 v6 可用性远好于 v4 公网。**推荐先做,能覆盖大多数** | +| **B. 补一个只为移动端服务的中继** | 视频带宽成本 | 可用率 100%,但账单跟着视频流量走 | +| **C. 接受"手机只能 WiFi"** | 产品力受限 | 零成本,但要写进产品说明,别让用户以为坏了 | + +**我的建议:A 先做(架构里 v6 是一等公民),B 的接口留在 `PathProvider` 里不给实现,等真要推移动端再决定。** +不要现在就把路堵死——留接口的成本几乎为零,推倒重来成本极高。 + +--- + +## 7. 分期路线(不要一次做 6 个组合) + +| 里程碑 | 内容 | 预估 | 依赖 | +|---|---|---|---| +| **M1** | Win↔Win 打通:信令 + 打洞 + 证书钉扎 | 2-3 周 | 协议已就绪 | +| **M2** | Win 一体化:agent-core 抽库、会话状态机、前台/后台双进程 | 2-3 周 | M1 | +| **M3** | Win 被控服务化(开机即在线、锁屏可连、UAC 处理) | 2 周 | M2 | +| **M4** | Linux 被控(X11 先行,Wayland/PipeWire 预留) | 2-3 周 | M2,A 在做 | +| **M5** | 桌面 UI(Tauri v2 壳 + 原生视频表面) | 3 周 | M2 | +| **M6** | **Android 主控**(MediaCodec + UniFFI + 触摸映射) | 3-4 周 | M5 | +| **M7** | **iOS 主控**(VideoToolbox + SwiftUI/UniFFI) | 3-4 周 | M6 | +| **M8** | Linux 主控(复用 M5 的桌面 UI) | 1 周 | M5 | +| 穿插 | 音频(Opus) / 文件传输 / 多屏 | 各 1-2 周 | — | + +**先做 Android 再做 iOS**:不用苹果开发者账号、真机调试方便、热重载快、审核风险低。iOS 放最后。 + +--- + +## 8. ⚠️ 必须说清楚的人力问题 + +现有团队 **2-3 人**,目前连"Win↔Win 打通"都还没做完。而这份需求是: + +- 2 个被控平台(抓屏 + 编码 + 注入 + 服务化)× 各自两套显示服务器(Linux X11/Wayland) +- 4 个主控平台(解码 + 渲染 + 输入 + UI)× 各自一套原生集成 +- 外加信令、打洞、音频、文件、多屏、账号体系 + +**这是 6-10 人年的工作量,2-3 人做不完。** 不是唱衰,是排期事实——Android + iOS 两个平台的 UI 和平台集成,单独拿出来就够一个人全职做半年。 + +可行的收缩方式(选一个): + +1. **砍掉 iOS**(苹果审核 + 真机调试 + 开发者账号成本最高,收益最小) +2. **移动端只做 Android**,iOS 用 Web 端替代(但 Web 端无法硬解,体验差) +3. **桌面优先,做深做透**,移动端放到"产品验证之后",先招人 +4. 移动端主控**外包**,核心协议和桌面端自己掌握 + +我的建议是 **3 或 1**:先把 Win/Linux 两端做成一款真的好用的产品(含打洞、服务化、音频、文件),移动端等有用户了再投入。现在同时铺 6 个平台,最大概率是每个都半成品。 + +--- + +## 9. 给 A / B 的分工调整建议 + +| 人 | 主责 | 说明 | +|---|---|---| +| A | `platform/` 被控侧:Win 抓屏/编码/注入/服务化 + Linux 被控 | 与现状一致 | +| B | `proto/` + `conn/` + `session/` + `viewer-core/` + 桌面 UI | 与现状一致 | +| — | **移动端(Android/iOS)** | **无人** ← 这是缺口 | + +现有分工在"桌面 + 协议"上是自洽的,**移动端是纯增量,必须补人或砍需求**。 + +--- + +## 10. 开放问题(需要拍板) + +1. 中继:确认走 §6 的 A 方案(v6 优先、留接口不实现)? +2. 移动端:先 Android 还是直接砍 iOS? +3. UI 框架:确认 Tauri v2,还是先原生桌面 + 后面再说移动? +4. 账号体系:现在是"输验证码",要支持设备列表就得有账号/设备 ID 体系——什么时候做? +5. 被控服务化后,主控端要不要支持"无人值守开机连接"(有安全含义,需要固定密码 + 白名单)? diff --git a/打洞与信令设计.md b/打洞与信令设计.md new file mode 100644 index 0000000..2b698bc --- /dev/null +++ b/打洞与信令设计.md @@ -0,0 +1,122 @@ +# 打洞与信令设计(无中继方案) + +> 状态:协议契约已落在 `common/src/signaling.rs`,待 A 过审后合入 main。 +> 决策:**不做中继(无 TURN / coturn)**,服务器只保留信令 + STUN 职能。 +> 关联:`构建运行手册.md` §7 第 4 项、`Git协作规范.md` 二·B 第 ③ 项。 + +## 1. 结论先行:信令不是中继 + +| | 信令服务器 | 中继服务器(已砍掉) | +|---|---|---| +| 承载媒体流量 | ❌ 不承载 | ✅ 全部承载 | +| 生命周期 | 只在建连那几秒 | 整个会话期间 | +| 成本 | 一台最低配 VPS 足够 | 视频流带宽,主要成本项 | +| 必要性 | **必须**(无它则无法交换地址) | 可选(可用率兜底) | + +砍掉中继 ≠ 不要服务器。信令必须保留,因为它解决的是死循环:双方都在 NAT 后, +各自只知道自己内网地址,**"自己的公网映射"这个信息只有第三方发包过来时才能看见**。 + +## 2. 无中继的代价:可用率,不是功能 + +打洞成功率由 NAT 类型决定,与代码质量基本无关: + +| NAT 类型 | 典型场景 | 无中继 | +|---|---|---| +| Full cone / Restricted cone | 家用路由器默认 | ✅ 很高 | +| Port-restricted cone | 家用宽带常见 | ✅ 需同时发包 | +| Symmetric NAT | 部分企业网关 | ⚠️ 仅端口可预测时可行 | +| CGNAT(`100.64.0.0/10`) | 移动/社区宽带 | ❌ 无解(IPv6 除外) | + +预期 P2P 成功率 **80–90%**,其余连不上。 + +**产品边界(需写进对外说明)**:目标用户框定为「家用宽带 + IPv6 可用 + 可开 UPnP」; +企业网络 / CGNAT 标注为不支持。这样剩余失败就不是 bug,是已知边界。 + +## 3. 打洞流程(协议见 `common/src/signaling.rs`) + +``` +agent 信令服务器 viewer + |-- Register(info) ------->|<------- Register(info) -----| + |<-- Registered(observed) -|------ Registered(observed) ->| observed = 公网映射(等价一次 STUN binding) + |<-- PeerReady(peer info) -|------ PeerReady(peer info) -->| + 打洞前景 outlook + |<-- PunchPlan(start_in) --|------ PunchPlan(start_in) --->| 两侧 start_in 不同,绝对时刻对齐 + | (双方同时向对端候选列表发包,多轮) | + |<========= 打通:UDP 直连 → QUIC 握手 + 证书钉扎 =========>| + |--- PunchReport(Connected) ->|<---- PunchReport ----------| +``` + +### 3.1 候选地址与优先级 + +`Candidate::score()`:IPv6(+100)> UPnP 显式映射(90) > 本机地址(80) > 公网反射(60) > 端口预测(40)。 + +**IPv6 是无中继策略的最强后盾** —— 双方都有公网 v6 就是真直连,成功率≈100%, +因此候选列表按 v6 → v4 排序,`EndpointInfo` 两类都带。 + +### 3.2 同时发包的时间对齐(不需要时钟同步) + +两侧 RTT 不同,若下发同一个延迟,RTT 大的一方晚发包就打不上。服务器按 +`punch_start_delay_ms(own_rtt, max_rtt, margin)` 给两侧算不同的 `start_in_ms`: + +``` +start_in_ms = max_rtt_ms / 2 + margin_ms - own_rtt_ms / 2 +``` + +`start_in_ms` 是**相对消息送达**的延迟,所以两端时钟不需要同步,误差≈单程抖动。 + +### 3.3 保活 + +NAT 的 UDP 映射默认 30s 左右超时,洞是出站包维持的,超时后静默关闭 +(表现为"连上了一会儿就断")。`keepalive_secs()` 取双方 `binding_timeout_s` +较小值折半,夹在 5–60s。 + +## 4. 提高成功率的实现清单(按性价比) + +1. **IPv6 优先**:候选排序已实现;agent 侧需枚举全局 v6 地址填 `CandidateKind::Host`。 +2. **UPnP / NAT-PMP / PCP**:agent 启动时就尝试为 `PUNCH_UDP_PORT(47800)` 申请映射, + 成功后以 `CandidateKind::Upnp` 补报(走 `ClientMsg::UpdateCandidates`)。 + 一次成功就把对称 NAT 变成可直连,收益最大。 +3. **双方同时发包**:见 §3.2,这是打洞能成的关键。 +4. **端口预测**:`PortAllocation::Sequential/Preserved` 时才值得做,`Predicted` 候选由 + `sanitize_candidates` 统一清洗(去重、丢端口 0、上限 32 个)。 +5. **多轮重试**:`rounds=3 / 200ms`,一次不通不代表不通。 + +**不要走 TCP 打洞**:QUIC 只跑 UDP,TCP simultaneous open 成功率低,性价比不值得。 + +## 5. 实现约束(两端各自注意) + +- **别拿 QUIC 握手当探测**:`quinn::Endpoint::connect` 打不通会持续重试,失败判定被 + idle timeout 拖很久。应先用同一 UDP socket 发轻量探测包确认通路(秒级), + 确认后再 `connect`;端口扫描也放这一层,便宜且可并行。 +- **UDP socket 复用**:打洞用的 socket 要和后续 QUIC 用同一个,否则洞不在同一个映射上。 + `quinn::Endpoint` 支持同时 `accept()` 与 `connect()`,可以在一个 Endpoint 上做完整流程。 +- **固定端口**:`47800` 对端口预测和用户手动端口转发都友好,不要随机端口。 + +## 6. 安全(本次必须同步改,否则公网不可用) + +1. **证书钉扎**:`viewer/src/main.rs` 的 `SkipVerify` 必须替换为对 + `EndpointInfo.cert_fingerprint` 的校验(`CertFingerprint` 已定义 hex 编解码)。 +2. **认证**:`ClientMsg::Register.auth` 必须是一次性验证码(替代固定 `123456`/`888888`), + 带过期与失败限速,否则公网等于没有认证。 +3. **候选列表视为不可信输入**:一律经 `sanitize_candidates` 后再发包。 + +## 7. 失败必须有明确出口(无中继的用户体验下限) + +没有中继兜底,就必须把"为什么连不上"讲清楚,不允许用户停在"连接中"。 +`PunchFailReason::user_hint()` 已给出每种原因对应的可执行建议,例如: + +- `Cgnat` → "当前处于运营商级 NAT(大内网),无法直连。请改用 IPv6 网络……" +- `SymmetricNat` → "请在被控端路由器开启 UPnP 后重试。" +- `UdpBlocked` → "当前网络的防火墙拦截了 UDP 出站,请切换手机热点后重试。" + +`punch_outlook()` 还能在**开打之前**就给出前景(`Likely/Possible/Unlikely/Hopeless`), +`Hopeless`(CGNAT)时可直接跳过打洞轮次并立刻提示,不让用户白等。 + +## 8. 后续动作 + +| 顺序 | 事项 | 负责 | +|---|---|---| +| 1 | `common/src/signaling.rs` 过审合入 main | B 提 PR / A review | +| 2 | 信令服务最小实现(WSS/443,配对 + 回推 observed + 下发 PunchPlan) | B | +| 3 | agent:候选收集(v6/Host/UPnP/Reflexive)、同步打洞、保活 | A | +| 4 | viewer:候选收集、同步打洞、改为证书钉扎 | B | +| 5 | 双机联调:家庭宽带 / 手机热点 / CGNAT 三种网络各跑一轮 | A + B | diff --git a/打洞验证记录.md b/打洞验证记录.md new file mode 100644 index 0000000..7de01f8 --- /dev/null +++ b/打洞验证记录.md @@ -0,0 +1,123 @@ +# 打洞连通性验证记录 + +> 目的:用最小成本确认「自研 P2P 打洞」在本项目目标网络下是否可行,再决定要不要投入实现。 +> 工具:`tools/punch_probe.py`(探针)、`tools/rendezvous.py`(极简信令服务器) +> 结论:**本机线路为锥形 NAT + 端口保留,打洞可行性高。** + +--- + +## 1. 测试环境 + +| 角色 | 地址 | 说明 | +|---|---|---| +| 信令服务器 | `47.98.245.66:47999/udp` | 阿里云 ECS,Ubuntu 24.04.2,python 3.12.3 | +| 客户端 | 出口 `116.3.201.115` | 联通线路,位于 NAT 之后 | +| 工具 | `tools/rendezvous.py` + `tools/punch_probe.py` | 纯 Python 标准库 | + +部署位置:`/root/uu-punch-poc/`(与服务器上其他服务完全隔离) + +--- + +## 2. 验证过程与结果 + +| # | 验证项 | 结果 | +|---|---|---| +| 1 | 信令服务器回环注册 | ✅ 能注册、能记录 | +| 2 | 服务器进程/监听 | ✅ `0.0.0.0:47999`,长期运行稳定 | +| 3 | **跨公网注册** | ✅ 服务器日志出现 `116.3.201.115:47800` | +| 4 | **双方配对与地址回推** | ✅ `配对成功:116.3.201.115:47800 <==> :47801` | +| 5 | 同一 NAT 内两实例互打 | ❌ 失败(见 §4.1,属 hairpinning 限制) | +| 6 | **NAT 映射行为判定** | ✅ **端点无关映射 + 端口保留**(见 §3) | + +--- + +## 3. 关键结论:NAT 类型 = 锥形 + 端口保留 + +用**同一个本地端口 47800** 分别打两个完全不同的目标,观察公网映射: + +| 目标 | 观察到的公网映射 | +|---|---| +| Google STUN(境外) | `116.3.201.115:47800` | +| 自建信令服务器 `47.98.245.66:47999` | `116.3.201.115:47800` | + +**不同目标 → 同一个公网端口 → 端点无关映射(Endpoint-Independent Mapping,即锥形 NAT),且端口原样保留。** + +按打洞成功率排序: + +``` +锥形 + 端口保留 ← 本机(最优) +锥形 + 端口改写 成功率高 +对称 NAT 困难,需端口预测 +CGNAT 基本无解 +``` + +**推论**:信令服务器观察到的地址,对该客户端是**可复用的真实映射**,对端直接向它发包即可打洞,无需端口预测。这是最理想的情形。 + +复现命令: + +```powershell +# 一定要带 --stun-port,用与打洞相同的端口,否则映射不可比 +py tools\punch_probe.py --stun --stun-port 47800 +``` + +--- + +## 4. 踩到的坑(Rust 实现必须处理) + +### 4.1 hairpinning:同一公网 IP 下两实例互打会失败 + +两个客户端都在 `116.3.201.115` 后面,互相打对方的公网地址,需要路由器支持 **NAT 环回(hairpinning)**。本线路不支持。 + +**这不代表真实打洞会失败**——真实场景是两台机器位于**不同**公网 IP 之后。此测试只能验证"信令配对"链路,不能验证穿透能力。 + +### 4.2 Windows:ICMP 端口不可达会打断接收循环 + +对方尚未启动时发包 → 收到 ICMP port unreachable → 下一次 `recv` 抛 `WSAECONNRESET(10054)`。若把它当致命错误 `break`,接收循环就此死掉,之后对方真打进来也收不到。 + +处理方式(两层): + +```python +sock.ioctl(socket.SIO_UDP_CONNRESET, False) # 1) 关闭该行为 +# 2) 收到该错误时 continue,绝不能 break +``` + +**Rust 对应**:`tokio` 的 `recv_from` 会返回 `ConnectionReset`,同样不能 `?` 或 `break`。 + +### 4.3 发完就退出会漏掉晚到的回包 + +对方可能比自己晚启动。发完最后一包立即关闭 socket,会把"晚到的成功"误判为失败。 +→ 发完后保留 grace 期(默认 2.5s)继续监听。 + +### 4.4 部署层的两道防火墙 + +| 层 | 现象 | 处理 | +|---|---|---| +| 主机 ufw | `INPUT DROP`,无规则则静默丢弃 | `ufw allow 47999/udp` | +| 云安全组 | 包到不了网卡(tcpdump 零捕获) | 控制台加 UDP 入方向规则 | + +**排查手法**:`tcpdump -n -i any udp port ` 零捕获 = 包在到达主机前就被丢,问题在云安全组而不在主机防火墙。 + +> 注意:首次配置时误把安全组规则设成了 **TCP**,UDP 包仍被丢弃。协议类型必须选 UDP。 + +--- + +## 5. 尚未验证 + +| 项 | 为什么重要 | 怎么验 | +|---|---|---| +| **两台不同网络的机器互打** | 唯一能证明真实穿透能力的测试 | 一台机器 + 一台连手机热点的笔记本 | +| 手机热点(CGNAT)场景 | 决定"无中继"方案对移动端是否成立 | 同上,热点那端即为 CGNAT | +| IPv6 直连 | 若两端都有公网 v6,可绕开 NAT | `--peer [v6地址]:端口` | +| 对端 NAT 是否同样友好 | 打洞需要两端配合 | 在对方网络重复 §3 的映射判定 | + +**下一步优先级:手机热点组合 > IPv6 > 其他。** + +--- + +## 6. 对实现的影响 + +1. **信令服务器的职责就此确认**:注册、配对、回推观察到的源地址。不承载媒体,建连后即退出。 +2. **信令与打洞必须复用同一个 socket**:服务器看到的映射端口,必须就是打洞时使用的端口,否则映射不可复用。 + - Rust 实现中:该 socket 需继续交给 `quinn::Endpoint` 使用(一次绑定,三处复用:信令 / 打洞 / QUIC)。 +3. **失败判定要快**:先做 NAT 行为探测,若判定为对称 NAT 直接给出结论,不必耗完多轮。 +4. **保活**:NAT 表项会超时,建连后需按 `binding_timeout_s` 折半做 keepalive(协议中已定义 `keepalive_secs()`)。