Add deployable QUIC P2P hole-punch coordination server
This commit is contained in:
commit
328aad2b00
13 files changed
+4290
No files matched your search
@@ -0,0 +1,5 @@
|
||||
target
|
||||
data
|
||||
.git
|
||||
.env
|
||||
*.key
|
||||
@@ -0,0 +1,4 @@
|
||||
/target/
|
||||
/data/
|
||||
*.key
|
||||
.env
|
||||
Generated
+3596
File diff suppressed because it is too large.
Load diff
+25
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "chatmask-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.94"
|
||||
autotests = false
|
||||
|
||||
[[test]]
|
||||
name = "coordination"
|
||||
path = "tests/coordination.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
futures = "0.3"
|
||||
libp2p = { version = "=0.57.0", default-features = false, features = ["tokio", "tcp", "noise", "yamux", "ed25519", "request-response", "cbor", "macros", "quic"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time", "net"] }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
FROM rust:1.94-bookworm AS build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends cmake && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /build
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY src ./src
|
||||
RUN cargo build --locked --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN groupadd --gid 10001 chatmask && useradd --uid 10001 --gid chatmask --no-create-home chatmask && mkdir /data && chown chatmask:chatmask /data
|
||||
COPY --from=build /build/target/release/chatmask-server /usr/local/bin/chatmask-server
|
||||
USER chatmask
|
||||
EXPOSE 7001/udp
|
||||
STOPSIGNAL SIGTERM
|
||||
ENTRYPOINT ["chatmask-server"]
|
||||
CMD ["--key", "/data/server.key"]
|
||||
@@ -0,0 +1,29 @@
|
||||
# ChatMaskServer 后续开发提示词
|
||||
|
||||
请基于当前只交换打洞信令的协调服务器实现以下未来需求。以下仅为计划,本阶段不要创建占位接口、后台服务或复杂架构。按实际需求逐项实现、测试并从本文件删除已完成项。
|
||||
|
||||
## 节点探测
|
||||
|
||||
- 支持节点可达性及地址有效性探测,区分已认证身份、当前观察地址与短期可达结果。
|
||||
- 使用有期限的探测结果,处理断线、地址变化和重复登记;失败不代表身份不存在。
|
||||
- 设定探测并发、频率、超时和包大小上限,防止被利用为任意地址扫描或 UDP 放大工具。
|
||||
|
||||
## 节点发现
|
||||
|
||||
- 让节点能找到共同的协调服务器及对端最新可用地址候选,明确可信来源、有效期和选择规则。
|
||||
- 如需要服务器间发现或联合查询,先定义协议及身份核验,再加入有限范围的节点互通;避免全网广播及无限递归查询。
|
||||
- 支持节点退出、服务器故障、重复结果去重和版本兼容。
|
||||
|
||||
## Tracker 提供 peer 列表
|
||||
|
||||
- 实现轻量 tracker,按明确范围提供已登记且允许被发现的 peer 列表。
|
||||
- 返回 PeerId、必要的短期地址候选和有效期,不传播用户本地名称、聊天内容或消息历史。
|
||||
- 支持分页、结果数量上限、查询速率限制和过期清理,不一次返回全部在线节点。
|
||||
- 明确公开发现的选择加入机制与地址披露规则;获知 PeerId 不等于有权限获得其所有地址。
|
||||
- 列表只反映短期登记,不承诺节点一定可达或双方一定能打洞。
|
||||
|
||||
## 多服务器与验证
|
||||
|
||||
- 定义共同协调节点的选择和故障切换;保持服务器间及客户端消息去重和资源上限。
|
||||
- 验证多服务器、不同 NAT、网络变化及恶意登记/查询场景;没有实际公网验证时明确标注限制。
|
||||
- 所有功能保持无聊天数据中继、无离线代收、无消息内容存储。只有实际容量和持久化需求达到必要程度时才评估数据库。
|
||||
@@ -0,0 +1,76 @@
|
||||
# ChatMask 打洞协调服务器
|
||||
|
||||
只进行短期身份登记、来源地址观察和双端打洞信令。没有中继、聊天内容转发或离线代收。客户端与服务器通过 QUIC 建立经过 PeerId 认证的加密控制连接;服务器从该连接获取 UDP 来源地址,无需单独的探测套接字。
|
||||
|
||||
## 部署
|
||||
|
||||
服务器需有公网 IP,并放行 UDP 7001(云服务器同时开放安全组)。
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
Compose 使用非 root 用户、只读根目录、持久化 server-data 卷、256 MiB 内存限制和日志轮转。服务器密钥位于 `/data/server.key`;重启保持 PeerId。备份密钥,不公开,不用于两个并行实例。不要用 `docker compose down -v` 删除身份卷。
|
||||
|
||||
直接构建运行(Rust 1.94 或更新版本;本机还需要 C 编译器和 CMake 构建 QUIC 加密依赖):
|
||||
|
||||
```sh
|
||||
cargo build --locked --release
|
||||
./target/release/chatmask-server
|
||||
```
|
||||
|
||||
Windows 使用 `target\release\chatmask-server.exe`。默认 `--listen /ip4/0.0.0.0/udp/7001/quic-v1`、`--key data/server.key`,可配置 `--max-clients`(256)和 `--ttl-seconds`(60)。IPv6 可用 `--listen /ip6/::/udp/7001/quic-v1`。Ctrl+C 或 Linux SIGTERM 正常退出;身份文件损坏时拒绝启动,不重新生成。
|
||||
|
||||
Linux 后台运行:
|
||||
|
||||
```sh
|
||||
sudo useradd --system --no-create-home --shell /usr/sbin/nologin chatmask-server
|
||||
sudo install -m 755 target/release/chatmask-server /usr/local/bin/chatmask-server
|
||||
sudo install -m 644 deploy/chatmask-server.service /etc/systemd/system/chatmask-server.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now chatmask-server
|
||||
sudo journalctl -u chatmask-server -f
|
||||
```
|
||||
|
||||
日志输出 `Control address: /ip4/.../udp/7001/quic-v1/p2p/服务器PeerId`。分发给用户时将监听地址 `0.0.0.0` 或内网地址换成真实公网 IP,保留端口和服务器 PeerId。
|
||||
|
||||
## ChatMask 客户端接入
|
||||
|
||||
```sh
|
||||
chatmask-client --bind 0.0.0.0 --server /ip4/服务器公网IP/udp/7001/quic-v1/p2p/服务器PeerId
|
||||
```
|
||||
|
||||
双方需登记到至少一个共同服务器;`--server` 可重复指定最多四个带 PeerId 的 IP/QUIC 地址。使用 `/chat 对方PeerId`,无需先保存对方地址。客户端优先尝试已保存直连地址;无地址或拨号失败时请求协调。服务器给双方发送同一轮通知,双方复用原 QUIC UDP 监听端口同时拨号,直连成功后继续现有端到端消息加密、确认和待发补发。
|
||||
|
||||
服务器不会进入联系人或聊天会话。服务器不可用或打洞失败时,消息保持本地 Pending,不自动改走服务器转发。
|
||||
|
||||
## 协议和资源
|
||||
|
||||
协议为 `/chatmask/punch/2.0.0`,CBOR request-response,类型见 `src/lib.rs`:
|
||||
|
||||
1. 客户端建立经过认证的 QUIC 控制连接并发出 Register。
|
||||
2. 服务器回复 Registered(观察到的 UDP 来源 endpoint、有效期)。身份来自连接认证,地址来自实际连接,不接受客户端声称的任意 endpoint。
|
||||
3. A 请求 Connect(B的PeerId)。双方已登记时向双方发出 Punch(对端身份、endpoint、attempt、最长 5000ms),并向 A 回复 Started。Started 仅表示信令已安排,不表示打洞成功。
|
||||
4. 客户端响应 Ack,并从原端口进行有限次数的同时拨号。实际 QUIC 连接必须验证预期 PeerId,聊天继续原有消息加密握手。
|
||||
5. 客户端按服务器有效期刷新登记,默认每20秒;60秒过期或断线时移除登记。登记仅在内存,重启后需重新登记。
|
||||
|
||||
默认最多256条已建立连接/登记、每身份一条控制连接、32个待完成入站连接;控制包2048字节上限、每连接8个并发请求子流、5秒请求超时。每身份发起协调的间隔至少2秒,重复登记不会绕过该限制。
|
||||
|
||||
这不是标准 STUN/ICE、libp2p DCUtR 或 TURN 服务。使用 QUIC 的已认证来源地址和复用端口进行协调打洞。NAT可能针对不同目标产生不同映射,严格 NAT、CGNAT 或防火墙仍可能阻止直连;不保证公网成功率,不提供中继兜底。连接对象会获知彼此 IP,这是直连需要的信息;客户端会拒绝屏蔽目标的打洞通知。
|
||||
|
||||
## 测试与后续计划
|
||||
|
||||
```sh
|
||||
cargo test --locked
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
```
|
||||
|
||||
服务器测试覆盖真实进程上的 QUIC 身份登记、来源地址与双端协调,以及容量、有效期、速率限制和密钥损坏拒绝。客户端项目另有需要本服务器二进制的真实双客户端加密消息测试。公网 NAT 成功率、Docker 和 Linux systemd 尚需实际部署验证。
|
||||
|
||||
未来节点探测、发现和 tracker peer 列表只列入 [PLAN.md](PLAN.md),没有实现占位接口。
|
||||
|
||||
## 旧部署升级
|
||||
|
||||
现在只开放 UDP 7001;不再需要 TCP7000、`--udp-listen`、旧 JSON probe/签名确认或 `/chatmask/punch/1.0.0`。请同时升级客户端与服务器。旧密钥仍可复用,旧中继、旧服务和容器需先停止以避免端口冲突;旧数据卷不自动删除。
|
||||
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
server:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7001:7001/udp"
|
||||
volumes:
|
||||
- server-data:/data
|
||||
read_only: true
|
||||
cap_drop: [ALL]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
mem_limit: 256m
|
||||
cpus: 1.0
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
volumes:
|
||||
server-data:
|
||||
@@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=ChatMask P2P hole-punch coordination
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=chatmask-server
|
||||
Group=chatmask-server
|
||||
StateDirectory=chatmask-server
|
||||
StateDirectoryMode=0700
|
||||
ExecStart=/usr/local/bin/chatmask-server --key /var/lib/chatmask-server/server.key
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=10
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
UMask=0077
|
||||
MemoryMax=256M
|
||||
TasksMax=128
|
||||
LimitNOFILE=2048
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
use clap::Parser;
|
||||
use libp2p::{
|
||||
connection_limits, identity, request_response, swarm::NetworkBehaviour, Multiaddr, PeerId,
|
||||
StreamProtocol, Swarm, SwarmBuilder,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
fs::{self, OpenOptions},
|
||||
io::{Read, Write},
|
||||
net::SocketAddr,
|
||||
path::Path,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
pub type Result<T> = std::result::Result<T, Box<dyn Error + Send + Sync>>;
|
||||
pub const PROTOCOL: &str = "/chatmask/punch/2.0.0";
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about = "ChatMask QUIC hole-punch coordination server")]
|
||||
pub struct Args {
|
||||
#[arg(long, default_value = "/ip4/0.0.0.0/udp/7001/quic-v1")]
|
||||
pub listen: Multiaddr,
|
||||
#[arg(long, default_value = "data/server.key")]
|
||||
pub key: std::path::PathBuf,
|
||||
#[arg(long, default_value_t = 256, value_parser = clap::value_parser!(u32).range(1..))]
|
||||
pub max_clients: u32,
|
||||
#[arg(long, default_value_t = 60, value_parser = clap::value_parser!(u32).range(10..3601))]
|
||||
pub ttl_seconds: u32,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Request {
|
||||
Register,
|
||||
Connect {
|
||||
peer: String,
|
||||
},
|
||||
Punch {
|
||||
peer: String,
|
||||
endpoint: SocketAddr,
|
||||
attempt: String,
|
||||
timeout_ms: u32,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Response {
|
||||
Registered {
|
||||
endpoint: SocketAddr,
|
||||
ttl_seconds: u32,
|
||||
},
|
||||
Started {
|
||||
attempt: String,
|
||||
},
|
||||
Ack,
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
struct Registration {
|
||||
endpoint: SocketAddr,
|
||||
seen: Instant,
|
||||
last_connect: Option<Instant>,
|
||||
}
|
||||
pub struct Registry {
|
||||
clients: HashMap<PeerId, Registration>,
|
||||
max: usize,
|
||||
ttl: Duration,
|
||||
}
|
||||
impl Registry {
|
||||
pub fn new(max: u32, ttl_seconds: u32) -> Self {
|
||||
Self {
|
||||
clients: HashMap::new(),
|
||||
max: max as usize,
|
||||
ttl: Duration::from_secs(ttl_seconds.into()),
|
||||
}
|
||||
}
|
||||
pub fn remove(&mut self, peer: PeerId) {
|
||||
self.clients.remove(&peer);
|
||||
}
|
||||
pub fn expire(&mut self) {
|
||||
let ttl = self.ttl;
|
||||
self.clients.retain(|_, r| r.seen.elapsed() < ttl);
|
||||
}
|
||||
pub fn register(&mut self, peer: PeerId, endpoint: SocketAddr) -> Response {
|
||||
self.expire();
|
||||
if !self.clients.contains_key(&peer) && self.clients.len() >= self.max {
|
||||
return Response::Error {
|
||||
message: "Server full".into(),
|
||||
};
|
||||
}
|
||||
let last_connect = self.clients.get(&peer).and_then(|r| r.last_connect);
|
||||
self.clients.insert(
|
||||
peer,
|
||||
Registration {
|
||||
endpoint,
|
||||
seen: Instant::now(),
|
||||
last_connect,
|
||||
},
|
||||
);
|
||||
Response::Registered {
|
||||
endpoint,
|
||||
ttl_seconds: self.ttl.as_secs() as u32,
|
||||
}
|
||||
}
|
||||
pub fn connect(
|
||||
&mut self,
|
||||
source: PeerId,
|
||||
target: PeerId,
|
||||
) -> Result<(SocketAddr, SocketAddr, String)> {
|
||||
self.expire();
|
||||
if source == target {
|
||||
return Err("Cannot connect to self".into());
|
||||
}
|
||||
let a = self.clients.get(&source).ok_or("Register first")?.endpoint;
|
||||
let b = self
|
||||
.clients
|
||||
.get(&target)
|
||||
.ok_or("Target not available")?
|
||||
.endpoint;
|
||||
let r = self.clients.get_mut(&source).expect("registered source");
|
||||
if r.last_connect
|
||||
.is_some_and(|t| t.elapsed() < Duration::from_secs(2))
|
||||
{
|
||||
return Err("Try again later".into());
|
||||
}
|
||||
r.last_connect = Some(Instant::now());
|
||||
Ok((
|
||||
a,
|
||||
b,
|
||||
identity::Keypair::generate_ed25519()
|
||||
.public()
|
||||
.to_peer_id()
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
pub fn socket_address(address: &Multiaddr) -> Option<SocketAddr> {
|
||||
use libp2p::multiaddr::Protocol;
|
||||
let mut ip = None;
|
||||
let mut port = None;
|
||||
let mut quic = false;
|
||||
for p in address.iter() {
|
||||
match p {
|
||||
Protocol::Ip4(v) => ip = Some(v.into()),
|
||||
Protocol::Ip6(v) => ip = Some(v.into()),
|
||||
Protocol::Udp(v) => port = Some(v),
|
||||
Protocol::QuicV1 => quic = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if quic {
|
||||
Some(SocketAddr::new(ip?, port?))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
#[derive(NetworkBehaviour)]
|
||||
pub struct Behaviour {
|
||||
pub control: request_response::cbor::Behaviour<Request, Response>,
|
||||
limits: connection_limits::Behaviour,
|
||||
}
|
||||
pub fn build(args: &Args, key: identity::Keypair) -> Result<Swarm<Behaviour>> {
|
||||
let mut swarm = SwarmBuilder::with_existing_identity(key)
|
||||
.with_tokio()
|
||||
.with_quic()
|
||||
.with_behaviour(|_| Behaviour {
|
||||
control: request_response::cbor::Behaviour::with_codec(
|
||||
request_response::cbor::codec::Codec::default()
|
||||
.set_request_size_maximum(2048)
|
||||
.set_response_size_maximum(2048),
|
||||
[(
|
||||
StreamProtocol::new(PROTOCOL),
|
||||
request_response::ProtocolSupport::Full,
|
||||
)],
|
||||
request_response::Config::default()
|
||||
.with_request_timeout(Duration::from_secs(5))
|
||||
.with_max_concurrent_streams(8),
|
||||
),
|
||||
limits: connection_limits::Behaviour::new(
|
||||
connection_limits::ConnectionLimits::default()
|
||||
.with_max_pending_incoming(Some(32))
|
||||
.with_max_pending_outgoing(Some(32))
|
||||
.with_max_established(Some(args.max_clients))
|
||||
.with_max_established_per_peer(Some(1)),
|
||||
),
|
||||
})?
|
||||
.with_swarm_config(|c| c.with_idle_connection_timeout(Duration::from_secs(120)))
|
||||
.build();
|
||||
swarm.listen_on(args.listen.clone())?;
|
||||
Ok(swarm)
|
||||
}
|
||||
pub fn load_identity(path: &Path) -> Result<identity::Keypair> {
|
||||
match fs::File::open(path) {
|
||||
Ok(file) => {
|
||||
let mut bytes = Vec::new();
|
||||
file.take(4097).read_to_end(&mut bytes)?;
|
||||
if bytes.len() > 4096 {
|
||||
return Err("Identity file too large".into());
|
||||
}
|
||||
let key = identity::Keypair::from_protobuf_encoding(&bytes)?;
|
||||
if key.key_type() != identity::KeyType::Ed25519 {
|
||||
return Err("Expected an Ed25519 identity".into());
|
||||
}
|
||||
return Ok(key);
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
let mut builder = fs::DirBuilder::new();
|
||||
builder.recursive(true).mode(0o700).create(parent)?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let key = identity::Keypair::generate_ed25519();
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(path)?;
|
||||
// Never replace an existing or damaged identity with a new identity silently.
|
||||
file.write_all(&key.to_protobuf_encoding()?)?;
|
||||
file.sync_all()?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/registry.rs"]
|
||||
mod tests;
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
use chatmask_server::{Args, BehaviourEvent, Registry, Request, Response, Result};
|
||||
use clap::Parser;
|
||||
use futures::StreamExt;
|
||||
use libp2p::{multiaddr::Protocol, request_response, swarm::SwarmEvent};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
let key = chatmask_server::load_identity(&args.key)?;
|
||||
let peer = key.public().to_peer_id();
|
||||
let mut swarm = chatmask_server::build(&args, key)?;
|
||||
let mut registry = Registry::new(args.max_clients, args.ttl_seconds);
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
|
||||
let mut endpoints = HashMap::new();
|
||||
let shutdown = shutdown();
|
||||
tokio::pin!(shutdown);
|
||||
println!("Server PeerId: {peer}");
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut shutdown => { result?; println!("Shutting down"); break; },
|
||||
_ = tick.tick() => registry.expire(),
|
||||
event = swarm.select_next_some() => match event {
|
||||
SwarmEvent::NewListenAddr { address, .. } => println!("Control address: {}", address.with(Protocol::P2p(peer))),
|
||||
SwarmEvent::ConnectionEstablished { peer_id, endpoint, .. } => { if let Some(address) = chatmask_server::socket_address(endpoint.get_remote_address()) { endpoints.insert(peer_id, address); } },
|
||||
SwarmEvent::ConnectionClosed { peer_id, .. } if !swarm.is_connected(&peer_id) => { registry.remove(peer_id); endpoints.remove(&peer_id); },
|
||||
SwarmEvent::Behaviour(BehaviourEvent::Control(request_response::Event::Message { peer, message: request_response::Message::Request { request, channel, .. }, .. })) => {
|
||||
let response = match request {
|
||||
Request::Register => match endpoints.get(&peer) { Some(endpoint) => registry.register(peer, *endpoint), None => Response::Error { message: "QUIC connection required".into() } },
|
||||
Request::Connect { peer: target } => match target.parse().map_err(|_| "Invalid target".into()).and_then(|target| registry.connect(peer, target).map(|pair| (target, pair))) {
|
||||
Ok((target, (a, b, attempt))) => {
|
||||
swarm.behaviour_mut().control.send_request(&target, Request::Punch { peer: peer.to_string(), endpoint: a, attempt: attempt.clone(), timeout_ms: 5000 });
|
||||
swarm.behaviour_mut().control.send_request(&peer, Request::Punch { peer: target.to_string(), endpoint: b, attempt: attempt.clone(), timeout_ms: 5000 });
|
||||
Response::Started { attempt }
|
||||
},
|
||||
Err(error) => Response::Error { message: error.to_string() },
|
||||
},
|
||||
Request::Punch { .. } => Response::Error { message: "Server-only request".into() },
|
||||
};
|
||||
let _ = swarm.behaviour_mut().control.send_response(channel, response);
|
||||
},
|
||||
SwarmEvent::ListenerError { error, .. } => return Err(error.into()),
|
||||
SwarmEvent::ListenerClosed { reason, .. } => { reason?; return Err("Listener closed".into()); },
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn shutdown() -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||
tokio::select! { r = tokio::signal::ctrl_c() => r, _ = term.recv() => Ok(()) }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
tokio::signal::ctrl_c().await
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use chatmask_server::{Request, Response, PROTOCOL};
|
||||
use futures::StreamExt;
|
||||
use libp2p::{
|
||||
identity, noise, request_response, swarm::SwarmEvent, tcp, yamux, StreamProtocol, Swarm,
|
||||
SwarmBuilder,
|
||||
};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, BufReader},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::mpsc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
fn client(key: identity::Keypair) -> Swarm<request_response::cbor::Behaviour<Request, Response>> {
|
||||
SwarmBuilder::with_existing_identity(key)
|
||||
.with_tokio()
|
||||
.with_tcp(
|
||||
tcp::Config::default(),
|
||||
noise::Config::new,
|
||||
yamux::Config::default,
|
||||
)
|
||||
.unwrap()
|
||||
.with_quic()
|
||||
.with_behaviour(|_| {
|
||||
request_response::cbor::Behaviour::new(
|
||||
[(
|
||||
StreamProtocol::new(PROTOCOL),
|
||||
request_response::ProtocolSupport::Full,
|
||||
)],
|
||||
request_response::Config::default(),
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
.build()
|
||||
}
|
||||
struct Server(Child);
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
struct Directory(std::path::PathBuf);
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
// The target is the exact unique temporary directory created by this test.
|
||||
if self.0.starts_with(std::env::temp_dir()) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn real_server_registers_observed_udp_addresses_signals_both_peers_and_clients_send_directly()
|
||||
{
|
||||
let dir = Directory(std::env::temp_dir().join(format!(
|
||||
"chatmask-coordination-{}",
|
||||
libp2p::PeerId::random()
|
||||
)));
|
||||
fs::create_dir(&dir.0).unwrap();
|
||||
let mut server = Server(
|
||||
Command::new(env!("CARGO_BIN_EXE_chatmask-server"))
|
||||
.args(["--listen", "/ip4/127.0.0.1/udp/0/quic-v1"])
|
||||
.arg("--key")
|
||||
.arg(dir.0.join("server.key"))
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let stdout = server.0.stdout.take().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(stdout).lines() {
|
||||
if tx.send(line.unwrap()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let control = loop {
|
||||
let line = rx.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
if let Some(value) = line.strip_prefix("Control address: ") {
|
||||
break value.parse::<libp2p::Multiaddr>().unwrap();
|
||||
}
|
||||
};
|
||||
let key_a = identity::Keypair::generate_ed25519();
|
||||
let key_b = identity::Keypair::generate_ed25519();
|
||||
let a_peer = key_a.public().to_peer_id();
|
||||
let b_peer = key_b.public().to_peer_id();
|
||||
let server_peer = match control.iter().last().unwrap() {
|
||||
libp2p::multiaddr::Protocol::P2p(peer) => peer,
|
||||
_ => panic!(),
|
||||
};
|
||||
let mut a = client(key_a.clone());
|
||||
let mut b = client(key_b.clone());
|
||||
a.listen_on("/ip4/127.0.0.1/udp/0/quic-v1".parse().unwrap())
|
||||
.unwrap();
|
||||
b.listen_on("/ip4/127.0.0.1/udp/0/quic-v1".parse().unwrap())
|
||||
.unwrap();
|
||||
a.dial(control.clone()).unwrap();
|
||||
b.dial(control).unwrap();
|
||||
a.behaviour_mut()
|
||||
.send_request(&server_peer, Request::Register);
|
||||
b.behaviour_mut()
|
||||
.send_request(&server_peer, Request::Register);
|
||||
let (mut endpoint_a, mut endpoint_b) = (None, None);
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while endpoint_a.is_none() || endpoint_b.is_none() {
|
||||
tokio::select! {
|
||||
event = a.select_next_some() => if let SwarmEvent::Behaviour(request_response::Event::Message { message: request_response::Message::Response { response: Response::Registered { endpoint, .. }, .. }, .. }) = event { endpoint_a = Some(endpoint); },
|
||||
event = b.select_next_some() => if let SwarmEvent::Behaviour(request_response::Event::Message { message: request_response::Message::Response { response: Response::Registered { endpoint, .. }, .. }, .. }) = event { endpoint_b = Some(endpoint); },
|
||||
}
|
||||
}
|
||||
}).await.unwrap();
|
||||
let endpoint_a = endpoint_a.unwrap();
|
||||
let endpoint_b = endpoint_b.unwrap();
|
||||
a.behaviour_mut().send_request(
|
||||
&server_peer,
|
||||
Request::Connect {
|
||||
peer: b_peer.to_string(),
|
||||
},
|
||||
);
|
||||
let (mut notice_a, mut notice_b, mut started) = (None, None, None);
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while notice_a.is_none() || notice_b.is_none() || started.is_none() {
|
||||
tokio::select! {
|
||||
event = a.select_next_some() => if let SwarmEvent::Behaviour(request_response::Event::Message { peer, message, .. }) = event {
|
||||
assert_eq!(peer, server_peer);
|
||||
match message {
|
||||
request_response::Message::Request { request: Request::Punch { peer, endpoint, attempt, .. }, channel, .. } => {
|
||||
assert_eq!(peer, b_peer.to_string()); assert_eq!(endpoint, endpoint_b); notice_a = Some(attempt);
|
||||
a.behaviour_mut().send_response(channel, Response::Ack).unwrap();
|
||||
},
|
||||
request_response::Message::Response { response: Response::Started { attempt }, .. } => started = Some(attempt),
|
||||
_ => {},
|
||||
}
|
||||
},
|
||||
event = b.select_next_some() => if let SwarmEvent::Behaviour(request_response::Event::Message { peer: sender, message: request_response::Message::Request { request: Request::Punch { peer, endpoint, attempt, .. }, channel, .. }, .. }) = event {
|
||||
assert_eq!(sender, server_peer); assert_eq!(peer, a_peer.to_string()); assert_eq!(endpoint, endpoint_a); notice_b = Some(attempt);
|
||||
b.behaviour_mut().send_response(channel, Response::Ack).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}).await.unwrap();
|
||||
assert_eq!(notice_a, notice_b);
|
||||
assert_eq!(notice_a, started);
|
||||
assert_ne!(endpoint_a.port(), 0);
|
||||
assert_ne!(endpoint_b.port(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_is_stable_and_corruption_does_not_rotate_it() {
|
||||
let dir =
|
||||
Directory(std::env::temp_dir().join(format!("chatmask-key-{}", libp2p::PeerId::random())));
|
||||
let path = dir.0.join("server.key");
|
||||
let key = chatmask_server::load_identity(&path).unwrap();
|
||||
assert_eq!(
|
||||
key.public().to_peer_id(),
|
||||
chatmask_server::load_identity(&path)
|
||||
.unwrap()
|
||||
.public()
|
||||
.to_peer_id()
|
||||
);
|
||||
fs::write(&path, b"damaged").unwrap();
|
||||
assert!(chatmask_server::load_identity(&path).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), b"damaged");
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use super::*;
|
||||
#[test]
|
||||
fn authenticated_observed_endpoints_expire_and_capacity_and_retry_rates_are_bounded() {
|
||||
let (a, b) = (PeerId::random(), PeerId::random());
|
||||
let mut registry = Registry::new(2, 60);
|
||||
let ep_a = "127.0.0.1:9000".parse().unwrap();
|
||||
let ep_b = "127.0.0.1:9001".parse().unwrap();
|
||||
assert!(registry.connect(a, b).is_err());
|
||||
assert!(
|
||||
matches!(registry.register(a,ep_a),Response::Registered{endpoint,..} if endpoint==ep_a)
|
||||
);
|
||||
registry.register(b, ep_b);
|
||||
assert!(matches!(
|
||||
registry.register(PeerId::random(), ep_a),
|
||||
Response::Error { .. }
|
||||
));
|
||||
let (actual_a, actual_b, _) = registry.connect(a, b).unwrap();
|
||||
assert_eq!((actual_a, actual_b), (ep_a, ep_b));
|
||||
registry.register(a, ep_a);
|
||||
assert!(registry.connect(a, b).is_err());
|
||||
assert!(registry.connect(a, a).is_err());
|
||||
registry.clients.get_mut(&b).unwrap().seen -= Duration::from_secs(61);
|
||||
registry.expire();
|
||||
assert!(registry.connect(a, b).is_err());
|
||||
registry.remove(a);
|
||||
assert!(registry.clients.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn only_quic_addresses_produce_udp_candidates() {
|
||||
assert!(socket_address(&"/ip4/127.0.0.1/tcp/9000".parse().unwrap()).is_none());
|
||||
assert_eq!(
|
||||
socket_address(&"/ip4/127.0.0.1/udp/9000/quic-v1".parse().unwrap()).unwrap(),
|
||||
"127.0.0.1:9000".parse().unwrap()
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user