基于 UDP 通信的洋葱网络设计:服务器、Android 客户端与 P2P 控制总结
- 约 19 分钟读完 - 全文 7581 字基于 UDP 通信的洋葱网络设计:服务器、Android 客户端与 P2P 控制总结
最近要实现端到端的加密通信,现在把设计方案记录下来。目标读者:刚接触洋葱网络、P2P 或 UDP 信令设计,想了解「怎么用 UDP 搭一个匿名转发网络,再基于它做 P2P」的同学。
阅读目标:建立整体架构图、理解关键模块职责、记住常见踩坑点。
0. 写在前面:为什么用 UDP 做洋葱网络 + P2P 信令?
洋葱网络(Onion Network)的核心思想是:数据像剥洋葱一样,一层一层加密转发。每一跳节点只知道上一跳和下一跳,不知道完整路径,从而保护通信双方的隐私。
传统洋葱路由(如 Tor)多跑在 TCP 上,因为 TCP 可靠、有序。但在移动场景或 P2P 场景里,UDP 有几个独特优势:
- 开销小:UDP 头只有 8 字节,没有连接建立/拆除开销,适合移动弱网。
- NAT 友好:UDP 是 NAT 打洞(Hole Punching)最常用的传输层协议,STUN/TURN/ICE 都优先用 UDP。
- 适合信令:P2P 建立连接前的「打招呼」消息很短,UDP 足够用;真正的大数据再切到 P2P 直连或中继。
- 抗审查更灵活:无连接特性让流量更像普通 DNS/QUIC/游戏报文,便于混淆。
但 UDP 也带来问题:
- 不保证可靠、不保证顺序,需要自己在应用层做重传、去重、拥塞控制。
- 报文可能被分片、被防火墙限制、被运营商 QoS。
这篇文章会带你从「一个 UDP 报文怎么进洋葱网络」开始,逐步拆解:
- 洋葱网络的层次结构与加密过程。
- 服务器(目录服务 + 中继 + 信令服务)应该怎么设计。
- Android 客户端的结构与关键代码思路。
- 怎么用这套 UDP 信令网络做 P2P 控制(NAT 穿透、打洞、直连、中继回退)。
- 新人最容易踩的坑与规避方法。
1. 洋葱网络核心概念:先建立「知识地图」
1.1 洋葱路由 vs 普通代理的区别
| 对比项 | 普通代理 | 洋葱网络 |
|---|---|---|
| 路径 | 单跳 | 多跳(通常 3 跳) |
| 加密 | 端到端一次加密 | 每跳一层加密 |
| 节点知情范围 | 代理知道你是谁、你访问哪 | 每跳只知道上一跳和下一跳 |
| 代表 | HTTP/SOCKS 代理 | Tor、I2P、自定义洋葱网络 |
1.2 一次洋葱转发的过程
假设 Alice 要给 Bob 发消息,路径为:
flowchart LR
A[Alice] --> E["入口节点 (Entry)"] --> M["中间节点 (Middle)"] --> X["出口节点 (Exit)"] --> B[Bob]
Alice 会先把报文从后往前逐层加密:
- 用 Exit 的公钥加密「Exit → Bob 的真实数据」,得到
C3。 - 用 Middle 的公钥加密「下一站是 Exit + C3」,得到
C2。 - 用 Entry 的公钥加密「下一站是 Middle + C2」,得到
C1。
然后 Alice 把 C1 发给 Entry。Entry 用自己的私钥解密后,看到「下一站是 Middle」,把 C2 转发给 Middle。Middle 解密后转发 C3 给 Exit。Exit 解密后拿到真实数据,发给 Bob。
flowchart LR
A[Alice] -->|C1| E[Entry<br/>解密: 下一站 Middle] -->|C2| M[Middle<br/>解密: 下一站 Exit] -->|C3| X[Exit<br/>解密: 真实数据] -->|明文| B[Bob]
新人记忆点:洋葱网络的数据是「加密好的俄罗斯套娃」,每到一个节点就剥掉一层外衣。
1.3 UDP 洋葱报文的基本结构
一个 UDP 洋葱报文可以简单设计为:
+----------------+----------------+----------------+----------------+
| 版本 (1B) | 命令 (1B) | 跳数 (1B) | 预留 (1B) |
+----------------+----------------+----------------+----------------+
| 会话 ID / 电路 ID (4B) |
+----------------+----------------+----------------+----------------+
| 下一跳洋葱层数据 ... |
+------------------------------------------------------------------+- 版本:协议版本,方便后续升级。
- 命令:路由、数据、建立、销毁、心跳等。
- 跳数 / TTL:防止报文无限循环,每经过一跳减 1。
- 会话 ID / 电路 ID:标识一条逻辑链路,让节点知道该用哪把密钥解密。
- 下一跳洋葱层数据:被当前节点私钥解密后,才能得到真正的下一跳地址和载荷。
新人容易混淆的点:洋葱网络里的「加密」不是端到端 TLS,而是逐跳加密。每一跳都能解密自己最外层,但看不到内层。
2. 系统整体架构
flowchart TB
subgraph 公网服务层
DS[Directory Server<br/>目录服务:发布可信节点]
SS[Signaling Server<br/>信令服务:NAT 信息交换]
RS[Relay Server<br/>中继服务:UDP 洋葱转发]
end
subgraph Android 客户端
AC[App 业务层]
CM[Connection Manager<br/>管理 Circuits / Peers]
OC[Onion Core<br/>分层加密 / 解密]
NC[Network Core<br/>UDP Socket / NAT / ICE]
end
subgraph 对端
P2P[Peer B<br/>Android / Server / IoT]
end
AC --> CM
CM --> OC
CM --> NC
OC --> NC
NC -->|UDP| RS
NC -->|UDP| SS
NC -.->|查询节点列表| DS
NC -.->|P2P 直连或中继| P2P
2.1 三类服务器职责
| 服务 | 职责 | 协议 |
|---|---|---|
| Directory Server(目录服务) | 维护可信中继节点列表、公钥、负载、版本 | HTTPS / UDP 查询 |
| Signaling Server(信令服务) | 帮助两个 Peer 交换公网 IP:Port、NAT 类型、候选地址 | UDP + 可靠信令协议 |
| Relay Server(中继服务) | 当 P2P 打洞失败时,转发 UDP 洋葱流量 | UDP 洋葱路由 |
实际部署中,目录服务可以用 HTTPS 更稳;信令和中继为了速度和 NAT 友好,优先用 UDP。
3. 服务器端设计
3.1 目录服务(Directory Server)
目录服务就像「黄页」,让客户端知道哪些中继节点在线、可信、负载低。
核心数据模型
Node {
node_id: 节点唯一 ID
public_key: Ed25519/X25519 公钥
endpoint: 公网 IP:UDP 端口
bandwidth: 声明带宽(Mbps)
load: 当前负载(连接数 / 带宽占用)
version: 协议版本
flags: 入口 / 中间 / 出口 / 中继 标志位
last_seen: 最后一次心跳时间
signature: 节点用私钥对以上字段签名
}客户端获取节点列表的流程
sequenceDiagram
participant C as Android Client
participant D as Directory Server
participant N1 as Node Entry
participant N2 as Node Middle
participant N3 as Node Exit
C->>D: HTTPS GET /nodes?count=3&flags=entry|middle|exit
D-->>C: 返回节点列表 + 签名
C->>C: 验证签名、选择路径
C->>N1: UDP 建立 Circuit 请求
N1-->>C: 返回 Circuit ID
C->>N1: 经过 N1 转发到 N2 建立
N2-->>C: 返回确认
C->>N2: 经过 N1→N2 转发到 N3 建立
N3-->>C: 返回确认
新人避坑:目录服务返回的节点列表必须带签名,客户端要校验,否则会被恶意节点钓鱼。
3.2 中继服务(Relay Server)
中继服务是洋葱网络里的「快递员」:收到一个 UDP 报文,解密最外层,转发给下一跳。
核心状态
Circuit {
circuit_id: 本节点分配的 32 位 ID
prev_node: 上一跳地址(IP:Port)
next_node: 下一跳地址(IP:Port)
shared_key: 与创建者协商的共享密钥
created_at: 创建时间
last_active: 最后活跃时间
bytes_forward: 转发字节数
}转发流程
flowchart TD
A[收到 UDP 报文] --> B{报文格式合法?}
B -->|否| C[直接丢弃]
B -->|是| D[查找 circuit_id]
D --> E{存在?}
E -->|否| C
E -->|是| F[用 shared_key 解密洋葱层]
F --> G{解密成功?}
G -->|否| C
G -->|是| H[读取下一跳地址 + 内层数据]
H --> I[TTL - 1]
I --> J{TTL > 0?}
J -->|否| K[发送销毁通知]
J -->|是| L[转发给下一跳]
关键设计点
- 限速与防滥用:单 Circuit 限制带宽,防止被当免费代理滥用。
- TTL 与环检测:每跳 TTL 减 1,到 0 丢弃,防止恶意构造环路。
- 密钥派生:每个 Circuit 用 X25519 ECDH 协商出共享密钥,再用 HKDF 派出发送/接收两个方向的对称密钥。
- 无日志策略:中继节点不记录明文内容、不记录完整路径(实际上也看不到)。
3.3 信令服务(Signaling Server)
信令服务是 P2P 的「媒人」:两个 Peer 无法直接找到对方公网地址时,通过信令服务交换信息。
数据模型
PeerInfo {
peer_id: 用户/设备唯一 ID
public_key: 用于加密信令的公钥
nat_type: 全锥型 / 限制型 / 端口限制型 / 对称型
local_candidates: [局域网 IP:Port, ...]
srflx_candidates: [STUN 映射后的公网 IP:Port, ...]
relay_candidates: [TURN 中继地址, ...]
timestamp: 更新时间
signature: 防止伪造
}典型信令流程
sequenceDiagram
participant A as Peer A
participant S as Signaling Server
participant B as Peer B
A->>S: 注册/上报 PeerInfo(UDP 心跳包)
B->>S: 注册/上报 PeerInfo
A->>S: 请求连接 peer_B
S->>S: 检查 A、B 是否在线
S-->>A: 返回 B 的候选地址列表
S-->>B: 转发 A 的连接请求 + A 的候选地址
A->>A: 按优先级尝试直连 / 打洞
B->>B: 同时尝试直连 / 打洞
A-->>B: UDP 打洞成功,建立 P2P
B-->>A: 业务数据互通
alt 打洞失败
A->>S: 请求中继
S-->>A: 分配 Relay 地址
S-->>B: 通知 B 通过 Relay 通信
A->>R: 经中继与 B 通信
end
新人避坑:信令服务本身不传输业务数据,只传「怎么找到对方」。不要把它当成聊天服务器用。
4. Android 客户端设计
Android 客户端需要同时处理:业务逻辑、洋葱路由、UDP 网络、NAT 穿透、后台保活。建议按层拆分。
4.1 客户端分层架构
flowchart TB
subgraph 应用层
UI[UI / ViewModel]
API[业务 API]
end
subgraph 网络中间层
CM[ConnectionManager<br/>管理多 Circuit / 多 Peer]
P2P[P2PController<br/>打洞 / 直连 / 中继决策]
SM[SignalingClient<br/>与信令服务通信]
end
subgraph 核心引擎层
OC[OnionCore<br/>加密 / 解密 / 报文封装]
NC[NetworkCore<br/>UDP Socket / ICE / STUN]
CR[CircuitStore<br/>持久化 Circuit 状态]
end
UI --> API
API --> CM
CM --> P2P
CM --> SM
CM --> OC
P2P --> OC
P2P --> NC
SM --> NC
OC --> NC
CM --> CR
4.2 关键模块说明
| 模块 | 职责 | 技术要点 |
|---|---|---|
ConnectionManager |
管理所有活跃 Circuit 和 Peer 连接 | 连接池、复用、超时清理 |
P2PController |
决定是否直连、打洞、还是走中继 | ICE 候选排序、NAT 类型判断 |
SignalingClient |
与信令服务器通信 | UDP 心跳、请求/响应匹配、重传 |
OnionCore |
分层加密/解密、报文打包 | X25519、ChaCha20-Poly1305、HKDF |
NetworkCore |
UDP Socket、STUN/ICE、NAT 类型探测 | DatagramChannel、线程模型、电池优化 |
CircuitStore |
本地缓存 Circuit 信息 | SQLite / MMKV、加密存储 |
4.3 NetworkCore 线程模型
UDP 网络层要避免在主线程做 IO,也避免一个线程处理所有事导致卡顿。
flowchart LR
subgraph 网络线程
RT[ReceiveThread<br/>阻塞 recvFrom]
RT -->|Raw Packet| DP[DecoderPipeline<br/>STUN / 洋葱 / 业务]
DP -->|业务消息| MQ[MessageQueue]
DP -->|信令响应| SR[SignalingResponseMap]
end
subgraph 发送线程
SQ[SendQueue<br/>优先级队列]
ST[SendThread<br/>定时 flush]
SQ --> ST -->|sendTo| Socket
end
subgraph 业务线程
BH[BusinessHandler]
MQ --> BH
end
Socket --> RT
4.4 Android 端 NAT 类型探测
客户端启动时,先用 STUN 服务器探测自己的 NAT 类型:
1. 向 STUN Server A 发 Binding Request,得到映射地址 M1。
2. 向 STUN Server B 发 Binding Request,得到映射地址 M2。
3. 比较 M1 和 M2:
- 相同 → 可能全锥型 / 限制型
- 不同 → 对称型 NAT(打洞困难)
4. 再测试其他 IP/Port 是否能把包送进本机,判断是限制型还是端口限制型。flowchart TD
A[开始探测] --> B[STUN A 请求]
B --> C[得到 M1]
C --> D[STUN B 请求]
D --> E[得到 M2]
E --> F{M1 == M2?}
F -->|是| G[全锥 / 限制 / 端口限制]
F -->|否| H[对称型 NAT]
G --> I[进一步测试过滤规则]
H --> J[P2P 大概率失败<br/>优先准备 Relay]
4.5 Android 后台保活与省电
P2P 需要客户端能被找到,但 Android 对后台限制越来越严。
| 方案 | 适用场景 | 注意事项 |
|---|---|---|
| Foreground Service | 需要长期在线 | 必须显示通知,用户可能反感 |
| WorkManager 周期性心跳 | 非实时场景 | 最小间隔 15 分钟,无法保证即时 |
| FCM / 厂商推送 | 唤醒 App 建立连接 | 国内厂商通道需要适配 |
| 保活 socket + 心跳 | 与信令服务维持 NAT 映射 | 心跳间隔建议 15-30s, balancing 电量 |
新人避坑:不要试图用「双进程守护」「1 像素 Activity」等旧保活手段,Android 高版本会被杀,且应用商店可能下架。
5. 通过 UDP 信令网络实现 P2P 控制
5.1 P2P 连接的三种结果
flowchart TD
A[Peer A 想连 Peer B] --> B[通过信令服务获取 B 的候选地址]
B --> C{尝试直连}
C -->|成功| D[UDP P2P 直连]
C -->|失败| E{尝试 NAT 打洞}
E -->|成功| D
E -->|失败| F[通过 Relay 中继]
D --> G[业务通信]
F --> G
5.2 ICE-like 候选地址优先级
graph LR
A[直连候选<br/>局域网 IP] -->|优先级最高| B[srflx 候选<br/>STUN 映射公网地址]
B -->|次高| C[prflx 候选<br/>对端看到的地址]
C -->|再次| D[relay 候选<br/>TURN/中继服务器]
优先级规则:
- host 候选:同一局域网直接连,延迟最低。
- server reflexive(srflx):STUN 映射后的公网地址,适合普通 NAT。
- peer reflexive(prflx):对端实际看到的地址,可能在打洞过程中发现。
- relay 候选:最后兜底,走服务器转发。
5.3 NAT 打洞详细流程
假设 A 和 B 都在普通家用路由器后,NAT 类型为「端口限制锥型」。
sequenceDiagram
participant A as Peer A
participant SA as STUN/Signaling Server
participant B as Peer B
A->>SA: 注册:我的 srflx 是 1.1.1.1:10000
B->>SA: 注册:我的 srflx 是 2.2.2.2:20000
A->>SA: 请求连接 B
SA-->>A: 返回 B 的地址 2.2.2.2:20000
SA-->>B: 转发 A 的地址 1.1.1.1:10000
A->>B: 发送 UDP 打洞包(可能到达或被过滤)
B->>A: 发送 UDP 打洞包
Note over A,B: 双方都先发包,让各自 NAT 建立映射
A->>B: 收到 B 的包,回确认
B->>A: 收到 A 的包,回确认
Note over A,B: 打洞成功,后续直接通信
新人记忆点:NAT 打洞的本质是让双方路由器先记录一条「允许对方包进来」的映射。所以必须双方同时主动发一个包到对方公网地址。
5.4 基于洋葱网络的 P2P 控制通道
除了直接 P2P,还可以把「P2P 控制信令」本身也套在洋葱网络里,增强隐私:
flowchart LR
A[Alice] -->|洋葱隧道| X[出口节点] --> S[信令服务器]
B[Bob] -->|洋葱隧道| X
控制消息包括:
- 请求连接、交换候选地址。
- 协商加密密钥(用于 P2P 直连后的端到端加密)。
- 心跳、断开通知。
- 中继切换通知。
这样即使信令服务器被监控,攻击者也只能看到「某个洋葱出口在发信令」,不知道 Alice 和 Bob 的真实身份。
5.5 P2P 直连后的加密
P2P 直连本身不保证安全,还需要端到端加密:
sequenceDiagram
participant A as Peer A
participant S as Signaling Server(洋葱保护下)
participant B as Peer B
A->>S: 发送 X25519 公钥 PA
S->>B: 转发 PA
B->>S: 发送 X25519 公钥 PB
S->>A: 转发 PB
A->>A: ECDH 计算共享密钥 K
B->>B: ECDH 计算共享密钥 K
A->>B: 用 K 加密业务数据,UDP P2P 直连发送
B->>A: 用 K 加密业务数据,UDP P2P 直连发送
新人避坑:洋葱网络的逐跳加密 ≠ P2P 端到端加密。如果数据离开出口节点后是明文,出口节点仍然能看到内容。真正保密需要在 P2P 直连后再做一次端到端加密。
6. 协议报文设计示例
6.1 洋葱路由报文
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Version | Command | TTL | Reserved |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Circuit ID |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
+ Encrypted Onion Payload (variable) +
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+Command 定义示例:
| 值 | 命令 | 说明 |
|---|---|---|
| 0x01 | CREATE | 建立 Circuit |
| 0x02 | CREATED | 建立成功响应 |
| 0x03 | RELAY | 中继数据 |
| 0x04 | DESTROY | 销毁 Circuit |
| 0x05 | HEARTBEAT | 保活心跳 |
6.2 信令报文
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Magic (4B) | Version (1B) | Command (1B) | Flags (2B) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Timestamp |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
+ Encrypted Payload +
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+信令 Command 示例:
| 值 | 命令 | 说明 |
|---|---|---|
| 0x10 | REGISTER | 客户端注册 |
| 0x11 | OFFER | 发起连接请求 |
| 0x12 | ANSWER | 响应连接请求 |
| 0x13 | CANDIDATE | 交换候选地址 |
| 0x14 | HEARTBEAT | 保活 |
| 0x15 | ERROR | 错误通知 |
新人避坑:UDP 报文要加 Magic 数和版本号,防止收到错乱的广播/扫描包时解析出错。
7. 关键算法与数据结构
7.1 Circuit 选择算法
客户端从目录服务拿到节点列表后,选择一条路径:
def choose_path(nodes, desired_hops=3):
# 1. 过滤在线、版本兼容、flags 符合要求的节点
valid = [n for n in nodes if n.is_online and n.version == PROTO_VERSION]
# 2. 按负载和带宽评分,负载越低、带宽越高分越高
scored = [(n, score(n)) for n in valid]
scored.sort(key=lambda x: x[1], reverse=True)
# 3. 随机选择前 K 个中的节点,避免总是选同一批
candidates = scored[:max(len(scored)//3, desired_hops * 2)]
# 4. 确保 Entry、Middle、Exit 来自不同运营商 / 网段,增加路径多样性
path = []
for role in ['entry', 'middle', 'exit']:
chosen = pick_diverse(path, candidates, role)
path.append(chosen)
return path7.2 消息可靠性保证
UDP 不保证可靠,应用层需要自己实现:
flowchart TD
A[发送消息] --> B[分配 Seq]
B --> C[加入重传队列]
C --> D[UDP 发送]
D --> E{收到 ACK?}
E -->|是| F[从队列移除]
E -->|否| G{超时?}
G -->|否| E
G -->|是| H[重传 N 次]
H --> I{达到最大重试?}
I -->|否| C
I -->|是| J[通知上层失败]
重传策略建议:
- 初始超时:200ms(RTT 估计)。
- 退避:指数退避,最多 5 次。
- ACK 去重:用滑动窗口或位图记录已确认 Seq。
7.3 连接状态机
P2P 连接可以建模为状态机:
stateDiagram-v2
[*] --> Idle: 用户发起连接
Idle --> Gathering: 收集 ICE 候选
Gathering --> Connecting: 开始打洞/直连
Connecting --> Connected: 收到对端包
Connecting --> Relaying: 打洞失败,切中继
Relaying --> Connected: 中继链路建立
Connected --> Disconnected: 超时未收到心跳
Disconnected --> [*]
8. 新人避坑技巧大全
8.1 UDP 层面的坑
| 坑 | 现象 | 规避方法 |
|---|---|---|
| UDP 报文丢失 | 部分消息收不到 | 应用层 ACK + 重传;关键信令走可靠通道 |
| UDP 乱序 | 消息顺序错乱 | 按 Seq 排序或丢弃过期包;业务层幂等 |
| UDP 分片 | 大报文丢失率陡增 | 控制报文大小 < 1200 字节;必要时分片重组 |
| 运营商 QoS | 高峰丢包严重 | 使用常见端口(如 443/8080)或混淆成 QUIC/DNS |
| 防火墙拦截 | 某些网络 UDP 不通 | 准备 TCP 回退或 Relay 兜底 |
8.2 洋葱网络层面的坑
| 坑 | 现象 | 规避方法 |
|---|---|---|
| Circuit 被重用 | 旧连接和新连接混用 | 每个 Circuit 带唯一 ID,过期销毁 |
| 时序攻击 | 入口和出口流量时间关联 | 引入填充(padding)和混合延迟 |
| 恶意出口节点 | 出口监听或篡改明文 | P2P 必须端到端加密,不依赖出口 |
| 节点列表被污染 | 连上恶意节点 | 目录服务签名 + 节点身份固定 + 信誉系统 |
| 密钥管理混乱 | 加密失败或性能差 | 一个 Circuit 一对密钥,用 HKDF 派生 |
8.3 Android 层面的坑
| 坑 | 现象 | 规避方法 |
|---|---|---|
| 主线程网络 | ANR / 卡顿 | 所有 UDP IO 放子线程或用协程 |
| Doze 模式心跳中断 | 后台连接断开 | 使用 JobScheduler / WorkManager 补心跳 |
| IPv4/IPv6 双栈 | 一端 v4 一端 v6 连不上 | 同时收集 v4/v6 候选地址 |
| 端口复用失败 | BindException |
Socket 设置 SO_REUSEADDR / SO_REUSEPORT |
| 电量消耗高 | 用户卸载 | 动态心跳间隔,网络空闲时延长 |
8.4 P2P / NAT 层面的坑
| 坑 | 现象 | 规避方法 |
|---|---|---|
| 对称型 NAT 打洞失败 | 两边都收不到包 | 提前探测 NAT 类型,准备 Relay |
| ** hairpin NAT 不支持** | 同一局域网却走公网 | 优先尝试 host 候选 |
| NAT 映射超时 | 长时间没通信后连不上 | 定期发送心跳保活 |
| 双方不同时打洞 | 一方先发可能被防火墙拦 | 设计让 A、B 同时主动发包 |
| 信令与数据通道混淆 | 安全边界不清 | 信令用短期密钥,数据用长期会话密钥 |
8.5 安全与隐私层面的坑
| 坑 | 现象 | 规避方法 |
|---|---|---|
| 只做了洋葱加密,没做端到端加密 | 出口节点可读明文 | P2P 直连后再做一次 ECDH |
| 时间戳不校验 | 重放攻击 | 每个报文带时间戳 + 滑动窗口 |
| 随机数可预测 | Circuit ID 被猜中 | 用 SecureRandom / crypto.random |
| 元数据泄露 | 包大小暴露内容类型 | 固定包长或随机填充 |
| 证书/公钥不校验 | 中间人攻击 | 目录服务签名 + 证书锁定 |
9. 一个最小可运行流程串起来
为了让新人有整体感,下面把一次「Alice 通过 UDP 洋葱网络控制 P2P 连接 Bob」的完整流程串起来。
sequenceDiagram
participant A as Android Alice
participant D as Directory Server
participant S as Signaling Server
participant R as Relay Server
participant B as Android Bob
%% 阶段 1:准备节点
A->>D: HTTPS 获取节点列表
D-->>A: 返回 Entry/Middle/Exit + 签名
A->>A: 校验签名,选择路径
A->>Entry: UDP CREATE Circuit
Entry-->>A: CREATED
A->>Middle: 经 Entry 转发 CREATE
Middle-->>A: CREATED
A->>Exit: 经 Entry→Middle 转发 CREATE
Exit-->>A: CREATED
%% 阶段 2:注册信令
A->>S: 经洋葱隧道 REGISTER Alice
B->>S: 经洋葱隧道 REGISTER Bob
%% 阶段 3:发起 P2P
A->>S: OFFER(请求连接 Bob)
S->>B: 转发 OFFER + Alice 候选地址
B->>S: ANSWER + Bob 候选地址
S->>A: 转发 ANSWER
%% 阶段 4:打洞
A->>B: UDP 打洞包
B->>A: UDP 打洞包
A-->>B: 直连成功
%% 阶段 5:业务通信
A->>B: 端到端加密业务数据
B->>A: 端到端加密业务数据
%% 阶段 6:失败兜底
alt 打洞失败
A->>S: 请求 Relay
S-->>A: 分配 Relay 地址
S-->>B: 通知走 Relay
A->>R: 经洋葱→Relay→Bob
end
10. 总结:一张图记住全文
flowchart TB
A[UDP 洋葱网络 + P2P] --> B[目录服务<br/>给节点]
A --> C[信令服务<br/>撮合 Peer]
A --> D[中继服务<br/>兜底转发]
A --> E[Android 客户端<br/>洋葱 + NAT + P2P]
E --> F[OnionCore<br/>逐跳加密]
E --> G[NetworkCore<br/>UDP / STUN / ICE]
E --> H[P2PController<br/>直连 / 打洞 / 中继]
F --> I[隐私保护]
G --> J[NAT 穿透]
H --> K[去中心化通信]
A --> L[避坑:可靠传输 / 端到端加密 / 后台保活]
核心要点回顾:
- 洋葱网络用 UDP 是为了轻量、NAT 友好、适合信令;但可靠性和顺序要自己补。
- 服务器分三类:目录服务(给节点)、信令服务(撮合 P2P)、中继服务(兜底转发)。
- Android 客户端分层:业务层、连接管理层、核心引擎层、网络层,不要混在一处。
- P2P 控制靠 UDP 信令交换地址,再用 ICE 候选优先级尝试直连、打洞、中继。
- 隐私不等于安全:洋葱网络保护路径隐私,P2P 业务数据还要端到端加密。
- 新人最容易踩的坑:UDP 当 TCP 用、忽略 NAT 类型、不做端到端加密、Android 主线程 IO。
参考与延伸阅读
- Tor Project: https://www.torproject.org/
- ICE / STUN / TURN RFCs: RFC 5245, RFC 5389, RFC 5766
- WebRTC NAT Traversal 实践
- Android
DatagramSocket、WorkManager、Foreground Service官方文档
如果你正在动手实现,建议先做一个「最小闭环」:一个目录服务 + 一个中继节点 + 两个 Android 客户端,先跑通「Alice 经洋葱网络发一条消息给 Bob」,再逐步加入 P2P 直连和 NAT 打洞。先跑通,再优化。