Skip to content

Commit c564257

Browse files
Oliver Baerclaude
andcommitted
feat: Phase 5c — cross-network WebRTC sync with signaling relay
Add WebRTC data channel transport for cross-network device sync, complementing the existing local network (mDNS/WebSocket) transport. Joiner tries mDNS for 5s then falls back to WebRTC; creator registers on both transports in parallel — first connection wins. New files: - signaling-server/: standalone axum WebSocket relay (~200 lines) - src-tauri/src/sync/signaling.rs: signaling client (SHA-256 room IDs) - src-tauri/src/sync/webrtc.rs: datachannel-rs Stream/Sink adapters Security: SPAKE2 exchange + encrypted SDP/ICE through untrusted relay. Room IDs are SHA-256 of pairing codes. All Phase 5d security features (heartbeat, timeout, key rotation) carry over unchanged via run_sync_loop. 43 Rust tests, 53 frontend tests — all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 140b81a commit c564257

8 files changed

Lines changed: 1511 additions & 12 deletions

File tree

signaling-server/Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "aurus-signaling-server"
3+
version = "0.1.0"
4+
edition = "2021"
5+
description = "Opaque WebSocket relay for Aurus cross-network sync signaling"
6+
7+
[dependencies]
8+
axum = { version = "0.8", features = ["ws"] }
9+
tokio = { version = "1", features = ["full"] }
10+
serde = { version = "1", features = ["derive"] }
11+
serde_json = "1"
12+
tracing = "0.1"
13+
tracing-subscriber = "0.3"

signaling-server/src/main.rs

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
//! Aurus Signaling Server — opaque WebSocket relay for cross-network sync.
2+
//!
3+
//! This server never sees plaintext sync data. It only relays encrypted blobs
4+
//! between paired devices. Room IDs are SHA-256 hashes of pairing codes, so
5+
//! the server cannot reverse them to the original codes.
6+
//!
7+
//! Wire protocol (JSON text frames):
8+
//! - Client→Server: { "type": "join", "room": "<sha256>", "from": "<device_id>" }
9+
//! - Client→Server: { "type": "relay", "room": "...", "from": "...", "payload": "<base64>" }
10+
//! - Server→Client: { "type": "relay", "room": "...", "from": "<other>", "payload": "..." }
11+
//! - Server→Client: { "type": "peer_joined", "room": "...", "from": "<other>" }
12+
//! - Server→Client: { "type": "peer_left", "room": "...", "from": "<other>" }
13+
14+
use axum::{
15+
Router,
16+
extract::{
17+
State,
18+
ws::{Message, WebSocket, WebSocketUpgrade},
19+
},
20+
response::IntoResponse,
21+
};
22+
use serde::{Deserialize, Serialize};
23+
use std::collections::HashMap;
24+
use std::sync::Arc;
25+
use tokio::sync::{RwLock, mpsc};
26+
27+
/// Max clients per room (1:1 sync).
28+
const MAX_CLIENTS_PER_ROOM: usize = 2;
29+
30+
// ---------------------------------------------------------------------------
31+
// Types
32+
// ---------------------------------------------------------------------------
33+
34+
type Rooms = Arc<RwLock<HashMap<String, Room>>>;
35+
36+
struct Room {
37+
clients: HashMap<String, mpsc::UnboundedSender<String>>,
38+
}
39+
40+
#[derive(Debug, Deserialize)]
41+
#[serde(tag = "type")]
42+
enum ClientMessage {
43+
#[serde(rename = "join")]
44+
Join { room: String, from: String },
45+
#[serde(rename = "relay")]
46+
Relay {
47+
room: String,
48+
from: String,
49+
payload: String,
50+
},
51+
}
52+
53+
#[derive(Debug, Serialize)]
54+
#[serde(tag = "type")]
55+
enum ServerMessage {
56+
#[serde(rename = "relay")]
57+
Relay {
58+
room: String,
59+
from: String,
60+
payload: String,
61+
},
62+
#[serde(rename = "peer_joined")]
63+
PeerJoined { room: String, from: String },
64+
#[serde(rename = "peer_left")]
65+
PeerLeft { room: String, from: String },
66+
}
67+
68+
// ---------------------------------------------------------------------------
69+
// Main
70+
// ---------------------------------------------------------------------------
71+
72+
#[tokio::main]
73+
async fn main() {
74+
tracing_subscriber::fmt::init();
75+
76+
let rooms: Rooms = Arc::new(RwLock::new(HashMap::new()));
77+
78+
let app = Router::new()
79+
.route("/ws", axum::routing::get(ws_handler))
80+
.with_state(rooms);
81+
82+
let addr = "0.0.0.0:8765";
83+
tracing::info!("Signaling server listening on {}", addr);
84+
85+
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
86+
axum::serve(listener, app).await.unwrap();
87+
}
88+
89+
async fn ws_handler(ws: WebSocketUpgrade, State(rooms): State<Rooms>) -> impl IntoResponse {
90+
ws.on_upgrade(|socket| handle_socket(socket, rooms))
91+
}
92+
93+
async fn handle_socket(mut socket: WebSocket, rooms: Rooms) {
94+
let (relay_tx, mut relay_rx) = mpsc::unbounded_channel::<String>();
95+
96+
let mut client_room: Option<String> = None;
97+
let mut client_id: Option<String> = None;
98+
99+
loop {
100+
tokio::select! {
101+
// Outbound: relay messages to this client
102+
Some(msg) = relay_rx.recv() => {
103+
if socket.send(Message::Text(msg.into())).await.is_err() {
104+
break;
105+
}
106+
}
107+
108+
// Inbound: read messages from this client
109+
result = socket.recv() => {
110+
match result {
111+
Some(Ok(Message::Text(text))) => {
112+
let text_str = text.to_string();
113+
let parsed: ClientMessage = match serde_json::from_str(&text_str) {
114+
Ok(m) => m,
115+
Err(_) => continue,
116+
};
117+
118+
match parsed {
119+
ClientMessage::Join { room, from } => {
120+
let mut rooms_guard = rooms.write().await;
121+
let r = rooms_guard.entry(room.clone()).or_insert_with(|| Room {
122+
clients: HashMap::new(),
123+
});
124+
125+
if r.clients.len() >= MAX_CLIENTS_PER_ROOM {
126+
tracing::warn!("Room {} is full, rejecting {}", room, from);
127+
continue;
128+
}
129+
130+
// Notify existing peers
131+
let peer_joined = ServerMessage::PeerJoined {
132+
room: room.clone(),
133+
from: from.clone(),
134+
};
135+
let peer_json = serde_json::to_string(&peer_joined).unwrap();
136+
for (_, tx) in r.clients.iter() {
137+
let _ = tx.send(peer_json.clone());
138+
}
139+
140+
r.clients.insert(from.clone(), relay_tx.clone());
141+
client_room = Some(room.clone());
142+
client_id = Some(from.clone());
143+
144+
tracing::info!("Client {} joined room {}", from, room);
145+
}
146+
ClientMessage::Relay {
147+
room,
148+
from,
149+
payload,
150+
} => {
151+
let rooms_guard = rooms.read().await;
152+
if let Some(r) = rooms_guard.get(&room) {
153+
let relay_msg = ServerMessage::Relay {
154+
room: room.clone(),
155+
from: from.clone(),
156+
payload,
157+
};
158+
let json = serde_json::to_string(&relay_msg).unwrap();
159+
for (id, tx) in r.clients.iter() {
160+
if id != &from {
161+
let _ = tx.send(json.clone());
162+
}
163+
}
164+
}
165+
}
166+
}
167+
}
168+
Some(Ok(Message::Close(_))) | None => break,
169+
_ => continue,
170+
}
171+
}
172+
}
173+
}
174+
175+
// Cleanup on disconnect
176+
if let (Some(room), Some(id)) = (client_room, client_id) {
177+
let mut rooms_guard = rooms.write().await;
178+
if let Some(r) = rooms_guard.get_mut(&room) {
179+
r.clients.remove(&id);
180+
181+
// Notify remaining peers
182+
let peer_left = ServerMessage::PeerLeft {
183+
room: room.clone(),
184+
from: id.clone(),
185+
};
186+
let json = serde_json::to_string(&peer_left).unwrap();
187+
for (_, tx) in r.clients.iter() {
188+
let _ = tx.send(json.clone());
189+
}
190+
191+
// Auto-cleanup empty rooms
192+
if r.clients.is_empty() {
193+
rooms_guard.remove(&room);
194+
tracing::info!("Room {} removed (empty)", room);
195+
}
196+
}
197+
198+
tracing::info!("Client {} left room {}", id, room);
199+
}
200+
}

src-tauri/Cargo.lock

Lines changed: 104 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)