client server connection loop

This commit is contained in:
2026-08-06 19:17:03 +08:00
parent 5c5f4dbf01
commit eae86c73f7
13 changed files with 528 additions and 15 deletions
Generated
+119 -8
View File
@@ -241,6 +241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@@ -259,8 +260,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite 0.29.0",
"tower",
"tower-layer",
"tower-service",
@@ -823,6 +826,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dbus"
version = "0.9.11"
@@ -2451,7 +2460,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand",
"rand 0.8.7",
"smallvec",
"zeroize",
]
@@ -3130,8 +3139,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
@@ -3141,7 +3160,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
@@ -3153,6 +3182,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3310,7 +3348,7 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"rand_core 0.6.4",
"signature 2.2.0",
"spki",
"subtle",
@@ -3723,7 +3761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest 0.10.7",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@@ -3996,7 +4034,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand",
"rand 0.8.7",
"rsa",
"serde",
"sha1",
@@ -4034,7 +4072,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
"rand",
"rand 0.8.7",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -4681,6 +4719,30 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite 0.28.0",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite 0.29.0",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4931,6 +4993,39 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"sha1",
"thiserror 2.0.18",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -5958,6 +6053,7 @@ dependencies = [
"base64 0.22.1",
"device_query",
"ed25519-dalek",
"futures-util",
"getrandom 0.4.3",
"lazy_static",
"objc2",
@@ -5973,8 +6069,17 @@ dependencies = [
"tauri-plugin-opener",
"tauri-specta",
"tokio",
"tokio-tungstenite 0.28.0",
"uuid",
"windows 0.58.0",
"wyd-common",
]
[[package]]
name = "wyd-common"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
@@ -5982,7 +6087,13 @@ name = "wyd-server"
version = "0.1.0"
dependencies = [
"axum",
"base64 0.22.1",
"ed25519-dalek",
"futures-util",
"serde_json",
"tokio",
"uuid",
"wyd-common",
]
[[package]]
+1 -1
View File
@@ -1,3 +1,3 @@
[workspace]
members = ["src-tauri", "src-server"]
members = ["src-common", "src-tauri", "src-server"]
resolver = "2"
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "wyd-common"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
serde = { version = "1", features = ["derive"] }
+36
View File
@@ -0,0 +1,36 @@
use serde::{Deserialize, Serialize};
pub const VERSION: u8 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Profile {
pub id: String,
pub display_name: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ClientMessage {
Register { profile: Profile, signature: String },
Signed { payload: String, signature: String },
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ServerMessage {
Challenge { version: u8, challenge: String },
Registered,
}
pub fn register_bytes(challenge: &str, profile: &Profile) -> Vec<u8> {
format!(
"wyd-register-v{VERSION}\n{challenge}\n{}\n{}",
profile.id, profile.display_name
)
.into_bytes()
}
pub fn message_bytes(payload: &str) -> Vec<u8> {
format!("wyd-message-v{VERSION}\n{payload}").into_bytes()
}
+8 -2
View File
@@ -5,5 +5,11 @@ edition = "2024"
publish = false
[dependencies]
axum = "0.8"
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread"] }
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "sync", "time"] }
base64 = "0.22.1"
ed25519-dalek = "3.0.0"
futures-util = "0.3"
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
wyd-common = { path = "../src-common" }
+3
View File
@@ -1,5 +1,7 @@
use axum::{Router, http::StatusCode, routing::get};
mod network;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let bind_addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:3000".to_string());
@@ -7,6 +9,7 @@ async fn main() -> std::io::Result<()> {
let app = Router::new()
.route("/", get("ok"))
.route("/health", get("ok"))
.merge(network::routes())
.fallback((StatusCode::NOT_FOUND, "not found"));
println!("wyd-server listening on http://{bind_addr}");
+185
View File
@@ -0,0 +1,185 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use axum::extract::State;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::response::Response;
use axum::routing::get;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::{Mutex, mpsc};
use uuid::Uuid;
use wyd_common::{ClientMessage, Profile, ServerMessage, message_bytes, register_bytes};
type Clients = Arc<Mutex<HashMap<String, Client>>>;
struct Client {
connection_id: Uuid,
key: VerifyingKey,
#[allow(dead_code)] // Used when presence and profile lookup are exposed.
profile: Profile,
#[allow(dead_code)] // Used when server-side message routing is added.
sender: mpsc::Sender<Message>,
}
pub fn routes() -> Router {
Router::new()
.route("/v1/ws", get(upgrade))
.with_state(Clients::default())
}
async fn upgrade(ws: WebSocketUpgrade, State(clients): State<Clients>) -> Response {
ws.on_upgrade(move |socket| connected(socket, clients))
}
async fn connected(mut socket: WebSocket, clients: Clients) {
let challenge = Uuid::new_v4().to_string();
if send(
&mut socket,
&ServerMessage::Challenge {
version: wyd_common::VERSION,
challenge: challenge.clone(),
},
)
.await
.is_err()
{
return;
}
let Some(Ok(Message::Text(text))) = socket.recv().await else {
return;
};
let Ok(ClientMessage::Register { profile, signature }) = serde_json::from_str(&text) else {
return;
};
let Ok(key) = key(&profile.id) else { return };
if !verify(&key, &register_bytes(&challenge, &profile), &signature) {
return;
}
let connection_id = Uuid::new_v4();
let public_key = profile.id.clone();
let (sender, mut outgoing) = mpsc::channel(32);
clients.lock().await.insert(
public_key.clone(),
Client {
connection_id,
key,
profile,
sender,
},
);
if send(&mut socket, &ServerMessage::Registered).await.is_err() {
remove(&clients, &public_key, connection_id).await;
return;
}
let (mut writer, mut reader) = socket.split();
let mut ping = tokio::time::interval(Duration::from_secs(20));
loop {
tokio::select! {
message = outgoing.recv() => {
let Some(message) = message else { break };
if writer.send(message).await.is_err() { break; }
}
_ = ping.tick() => {
if writer.send(Message::Ping(Vec::new().into())).await.is_err() { break; }
}
message = reader.next() => match message {
Some(Ok(Message::Text(text))) => {
let Ok(ClientMessage::Signed { payload, signature }) = serde_json::from_str(&text) else { break };
let registered_key = clients.lock().await.get(&public_key)
.filter(|client| client.connection_id == connection_id)
.map(|client| client.key);
let Some(registered_key) = registered_key else { break };
if !verify(&registered_key, &message_bytes(&payload), &signature) { break; }
// The message is authenticated. Domain routing comes next.
}
Some(Ok(Message::Ping(data))) => {
if writer.send(Message::Pong(data)).await.is_err() { break; }
}
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
_ => {}
}
}
}
remove(&clients, &public_key, connection_id).await;
}
async fn remove(clients: &Clients, public_key: &str, connection_id: Uuid) {
let mut clients = clients.lock().await;
if clients
.get(public_key)
.is_some_and(|client| client.connection_id == connection_id)
{
clients.remove(public_key);
}
}
async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> {
socket
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
}
fn key(public_key: &str) -> Result<VerifyingKey, ()> {
let bytes = URL_SAFE_NO_PAD.decode(public_key).map_err(|_| ())?;
let bytes: [u8; 32] = bytes.try_into().map_err(|_| ())?;
VerifyingKey::from_bytes(&bytes).map_err(|_| ())
}
fn verify(key: &VerifyingKey, bytes: &[u8], signature: &str) -> bool {
let Ok(signature) = URL_SAFE_NO_PAD.decode(signature) else {
return false;
};
let Ok(signature) = Signature::from_slice(&signature) else {
return false;
};
key.verify(bytes, &signature).is_ok()
}
#[cfg(test)]
mod tests {
use ed25519_dalek::{Signer, SigningKey};
use super::*;
#[test]
fn verifies_registration_and_message_signatures() {
let signing_key = SigningKey::from_bytes(&[7; 32]);
let profile = Profile {
id: URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()),
display_name: "Wind".to_owned(),
};
let registration = register_bytes("challenge", &profile);
let signature = URL_SAFE_NO_PAD.encode(signing_key.sign(&registration).to_bytes());
assert!(verify(
&signing_key.verifying_key(),
&registration,
&signature
));
assert!(!verify(
&signing_key.verifying_key(),
&register_bytes("another challenge", &profile),
&signature,
));
let message = message_bytes("hello");
let signature = URL_SAFE_NO_PAD.encode(signing_key.sign(&message).to_bytes());
assert!(verify(&signing_key.verifying_key(), &message, &signature));
assert!(!verify(
&signing_key.verifying_key(),
&message_bytes("tampered"),
&signature,
));
}
}
+4 -1
View File
@@ -27,12 +27,15 @@ lazy_static = "1.5.0"
ed25519-dalek = "3.0.0"
getrandom = "0.4.3"
device_query = "4.0.1"
tokio = "1.53.1"
tokio = { version = "1.53.1", features = ["macros", "sync", "time"] }
tokio-tungstenite = "0.28"
futures-util = "0.3"
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "migrate"] }
tauri-specta = { version = "2.0.0-rc.21", features = ["derive", "typescript"] }
specta = "2.0.0-rc.21"
specta-typescript = { version = "0.0.9" }
uuid = { version = "1", features = ["v4"] }
wyd-common = { path = "../src-common" }
[dev-dependencies]
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
+1 -1
View File
@@ -114,7 +114,7 @@ impl StoredKeypair {
}
}
#[allow(dead_code)] // The signing key will be read by the pending WebSocket sender.
#[derive(Clone)]
pub struct AppKeypair {
signing_key: SigningKey,
public_key: String,
+2
View File
@@ -2,6 +2,7 @@ mod cursor;
mod db;
mod friends;
mod keypair;
mod network;
mod profile;
mod remotes;
mod ufa;
@@ -12,6 +13,7 @@ async fn launch_app(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>>
let handle = app.handle();
db::init(handle).await?;
keypair::init(handle).await?;
network::init(handle).await?;
ufa::init();
cursor::init(handle);
windowing::init(handle);
+159
View File
@@ -0,0 +1,159 @@
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use tauri::{AppHandle, Manager};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use wyd_common::{ClientMessage, Profile, ServerMessage, message_bytes, register_bytes};
use crate::db::AppDatabase;
use crate::keypair::AppKeypair;
use crate::remotes::{self, Remote};
pub struct Network {
senders: Mutex<HashMap<String, mpsc::Sender<String>>>,
}
impl Network {
#[allow(dead_code)] // Ready for the first domain message sender.
pub fn send(&self, remote_id: &str, payload: String) -> Result<(), String> {
let senders = self.senders.lock().map_err(|error| error.to_string())?;
let sender = senders
.get(remote_id)
.ok_or_else(|| "remote is not configured".to_string())?;
sender
.try_send(payload)
.map_err(|error| format!("remote is not ready: {error}"))
}
}
pub async fn init(handle: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
let database = handle.state::<AppDatabase>();
let keypair = handle.state::<AppKeypair>().inner().clone();
let profile = crate::profile::get(&database, keypair.public_key()).await?;
let remotes = remotes::all(&database).await?;
let mut senders = HashMap::new();
for remote in remotes {
let (sender, receiver) = mpsc::channel(32);
senders.insert(remote.id.clone(), sender);
tauri::async_runtime::spawn(run(remote, profile.clone(), keypair.clone(), receiver));
}
handle.manage(Network {
senders: Mutex::new(senders),
});
Ok(())
}
async fn run(
remote: Remote,
profile: crate::user::User,
keypair: AppKeypair,
mut outgoing: mpsc::Receiver<String>,
) {
loop {
if let Err(error) = connect(&remote, &profile, &keypair, &mut outgoing).await {
eprintln!("remote {} disconnected: {error}", remote.id);
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
async fn connect(
remote: &Remote,
profile: &crate::user::User,
keypair: &AppKeypair,
outgoing: &mut mpsc::Receiver<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (socket, _) = tokio_tungstenite::connect_async(url(remote)).await?;
let (mut writer, mut reader) = socket.split();
let challenge = match recv(&mut reader).await? {
ServerMessage::Challenge { version, challenge } if version == wyd_common::VERSION => {
challenge
}
_ => return Err("server did not send a compatible challenge".into()),
};
let profile = Profile {
id: profile.id.clone(),
display_name: profile.display_name.clone(),
};
send(
&mut writer,
&ClientMessage::Register {
signature: keypair.sign(&register_bytes(&challenge, &profile)),
profile,
},
)
.await?;
if !matches!(recv(&mut reader).await?, ServerMessage::Registered) {
return Err("server rejected registration".into());
}
loop {
tokio::select! {
payload = outgoing.recv() => {
let payload = payload.ok_or("network sender closed")?;
send(&mut writer, &ClientMessage::Signed {
signature: keypair.sign(&message_bytes(&payload)),
payload,
}).await?;
}
message = reader.next() => match message.ok_or("server closed the socket")?? {
Message::Ping(data) => writer.send(Message::Pong(data)).await?,
Message::Close(_) => return Ok(()),
_ => {}
}
}
}
}
async fn send<S>(
writer: &mut S,
message: &ClientMessage,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
S: futures_util::Sink<Message> + Unpin,
S::Error: std::error::Error + Send + Sync + 'static,
{
writer
.send(Message::Text(serde_json::to_string(message)?.into()))
.await?;
Ok(())
}
async fn recv<S>(reader: &mut S) -> Result<ServerMessage, Box<dyn std::error::Error + Send + Sync>>
where
S: futures_util::Stream<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
{
while let Some(message) = reader.next().await {
if let Message::Text(text) = message? {
return Ok(serde_json::from_str(&text)?);
}
}
Err("server closed the socket".into())
}
fn url(remote: &Remote) -> String {
let address = remote.address.trim_end_matches('/');
let address = address
.strip_prefix("http://")
.or_else(|| address.strip_prefix("https://"))
.or_else(|| address.strip_prefix("ws://"))
.or_else(|| address.strip_prefix("wss://"))
.unwrap_or(address);
let scheme = if remote.address.starts_with("https://") || remote.address.starts_with("wss://") {
"wss"
} else {
"ws"
};
let port = remote
.port
.map(|port| format!(":{port}"))
.unwrap_or_default();
format!("{scheme}://{address}{port}/v1/ws")
}
+1 -1
View File
@@ -14,7 +14,7 @@ pub struct ProfileChanged {
pub profile: User,
}
async fn get(database: &AppDatabase, public_key: &str) -> Result<User, sqlx::Error> {
pub async fn get(database: &AppDatabase, public_key: &str) -> Result<User, sqlx::Error> {
let display_name =
sqlx::query_scalar::<_, String>("SELECT display_name FROM profile WHERE id = ?1")
.bind(PROFILE_ID)
+1 -1
View File
@@ -28,7 +28,7 @@ pub struct RemotesChanged {
pub remotes: Vec<Remote>,
}
async fn all(database: &AppDatabase) -> Result<Vec<Remote>, sqlx::Error> {
pub async fn all(database: &AppDatabase) -> Result<Vec<Remote>, sqlx::Error> {
sqlx::query_as::<_, Remote>(
"SELECT id, address, name, port FROM remotes \
ORDER BY COALESCE(name, address) COLLATE NOCASE, id",