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