Signaling server
server/ - crate omnicall-signaling, ~240 lines of Rust across two files.
server/
├── Cargo.toml # axum 0.8, tokio (rt-multi-thread), tower-http, serde, tracing
├── src/
│ ├── main.rs # process bootstrap: tracing, router, bind, graceful shutdown
│ └── signaling.rs # rooms + relay: all the protocol logic
└── public/ # minimal 2-person web client (test tool)Dependencies
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "fs"] }
tower-http = { version = "0.6", features = ["fs", "trace"] }
futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[profile.release]
lto = true
codegen-units = 1
strip = trueBootstrap (main.rs)
let state = Arc::new(AppState::default());
let app = Router::new()
.route("/", any(root_handler))
.layer(TraceLayer::new_for_http())
.with_state(state);
let host = env::var("HOST")... .unwrap_or(IpAddr::from([0, 0, 0, 0]));
let port = env::var("PORT")... .unwrap_or(8080);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal()) // Ctrl-C
.awaitHOST/PORTare the only configuration. Defaults:0.0.0.0:8080.- One route.
root_handlerchecks forUpgrade: websocket. If present it performsWebSocketUpgrade::from_request_partsand hands the socket tosignaling::handle_socket. If absent it returns404 Not Found. - Both the Android app and the web client open their socket against
/, so the root has to serve the upgrade.
TLS terminates in front
The binary speaks plain HTTP/WS. In production a reverse proxy (Caddy,
nginx, or the platform’s load balancer) terminates TLS and forwards to
127.0.0.1:8080. See deployment.
State model (signaling.rs)
pub struct AppState {
rooms: RwLock<HashMap<String, HashMap<String, Peer>>>,
}
struct Peer {
name: String,
tx: mpsc::UnboundedSender<Message>, // into this peer's writer task
}- Outer key: room code (any string the client sends).
- Inner key: peer id (the client generates a stable id, e.g.
android_063ece02). Peer.tx: every task that wants to send to a peer pushes into this channel. A single dedicated writer task per connection drains it and owns the socket sink, so relaying tasks never contend for the sink.
Per-connection task
handle_socket splits the socket, spawns the writer task, then loops on
tokio::select! between “next inbound frame” and “the writer task finished.”
loop {
tokio::select! {
incoming = ws_stream.next() => match incoming {
Some(Ok(Message::Text(text))) => handle_text(&text, &state, &tx, &mut session).await,
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
Some(Ok(_)) => {} // ping/pong/binary ignored
},
_ = &mut writer => break,
}
}
handle_leave(&state, &session).await; // always runs on disconnectA Session { room: Option<String>, peer: Option<String> } records the identity
this connection claimed in its join. It is the source of truth for senderPeerId
on every relayed frame - a client cannot spoof another peer because the server
ignores peerId on relay messages and uses the session instead.
Message handling
match data.msg_type.as_str() {
"join" => join(state, tx, session, data).await,
"offer" | "answer" | "candidate" | "chat" | "screen-share" | "video"
=> relay(state, session, data).await,
"leave" => { handle_leave(state, session).await; session.room = None; session.peer = None; }
other => warn!("Unknown message type: {other}"),
}join
- Read
roomId,peerId, andpayload.name(default"Anonymous"). - Set
session.room/session.peer. - Take the write lock,
rooms.entry(room_id).or_default(). - Snapshot the existing peers before inserting the newcomer.
- Insert
{ name, tx }. - Send
room-joinedto the newcomer with the snapshot (peers: [{peerId, name}]). - Broadcast
user-joinedto everyone already in the room.
The snapshot order matters: the newcomer learns about existing peers via
room-joined and becomes the offerer toward them; existing peers learn about
the newcomer via user-joined and wait for the offer. This is the glare-avoidance
rule - see call lifecycle.
relay
let frame = json!({
"type": data.msg_type,
"roomId": room_id,
"senderPeerId": session.peer, // authoritative
"payload": data.payload,
});
match data.target_peer_id.and_then(|t| room.get(&t)) {
Some(target) => target.tx.send(frame), // unicast
None => for (id, peer) in room { if id != sender { peer.tx.send(frame.clone()) } }, // broadcast
}offer/answercarrytargetPeerId→ unicast.chat,video,screen-shareare sent without a target → broadcast to the rest of the room.candidatetoday is sent without a target (broadcast). With one remote peer this is harmless; the multi-party design makes it targeted because eachPeerConnectionhas its own ICE credentials.
leave / disconnect
handle_leave removes the peer, broadcasts user-left to the remaining peers,
and if the room is now empty, removes the room entirely. It runs on an explicit
leave frame and unconditionally when the socket closes for any reason.
What the server deliberately does not do
- No authentication or room passwords - a room code is the shared secret.
- No rate limiting or message-size caps.
- No TURN - it is signaling only.
- No history, no read receipts, no typing indicators.
- No participant cap yet - the multi-party work
adds
const MAX_PEERS: usize = 5and aroom-fullrejection.
Building locally
The Windows dev box has a link.exe collision (GNU coreutils link shadows the
MSVC linker), so the server is built and run on the Linux host, not the dev
laptop. On Linux/macOS:
cd server
cargo run # 0.0.0.0:8080
HOST=127.0.0.1 PORT=9000 cargo run
RUST_LOG=debug cargo run # verbose signaling logs
cargo build --release # ./target/release/omnicall-signaling