Add signal-call-tunnel Rust binary crate

Create the signal-call-tunnel binary that uses RingRTC's native Rust
API for ICE/SRTP/call state management. Includes:
- config.rs: Config struct read from stdin JSON
- control.rs: Unix socket JSON-lines control channel with auth
- platform.rs: RingRTC SignalingSender/CallStateHandler/GroupUpdateHandler impls
- main.rs: Entry point with CallManager setup and event loop

Supports --host-audio flag for system audio, defaults to socket
audio mode using the new AudioDeviceModule::new_socket().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shaheen Gandhi 2026-02-13 20:49:42 -08:00
parent f9cbfa6d6c
commit 37ace0ff30
10 changed files with 4492 additions and 0 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "third-party/ringrtc"]
path = third-party/ringrtc
url = https://github.com/signalapp/ringrtc

2858
signal-call-tunnel/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
[package]
name = "signal-call-tunnel"
version = "0.1.0"
edition = "2024"
[dependencies]
ringrtc = { path = "../third-party/ringrtc/src/rust", features = ["prebuilt_webrtc", "virtual_audio"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
env_logger = "0.11"
base64 = "0.22"
anyhow = "1"
subtle = "2"
[patch.crates-io]
# Use Signal's fork of curve25519-dalek for zkgroup compatibility (matches ringrtc workspace).
curve25519-dalek = { git = 'https://github.com/signalapp/curve25519-dalek', tag = 'signal-curve25519-4.1.3' }

View File

@ -0,0 +1,66 @@
use std::path::Path;
use std::process::Command;
fn main() {
// Apply the VPIO-disable patch to ringrtc if it hasn't been applied yet.
// This is a build-time patch: cargo re-runs build.rs when the patch file
// or the target source file changes.
let ringrtc_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../third-party/ringrtc");
let patch_file = Path::new(env!("CARGO_MANIFEST_DIR")).join("patches/ringrtc-disable-vpio.patch");
let adm_file = ringrtc_dir.join("src/rust/src/webrtc/audio_device_module.rs");
println!("cargo::rerun-if-changed={}", patch_file.display());
println!("cargo::rerun-if-changed={}", adm_file.display());
if !patch_file.exists() {
return;
}
// Check if the patch is already applied by looking for the marker function.
if let Ok(content) = std::fs::read_to_string(&adm_file) {
if content.contains("RINGRTC_NO_VOICE_PROCESSING") {
// Already applied
return;
}
}
// Canonicalize paths so git apply works regardless of how cargo sets cwd.
// Run from within the ringrtc directory to avoid parent-repo submodule issues.
let ringrtc_canonical = match ringrtc_dir.canonicalize() {
Ok(p) => p,
Err(e) => {
eprintln!("cargo:warning=Cannot resolve ringrtc path: {e}");
return;
}
};
let patch_canonical = match patch_file.canonicalize() {
Ok(p) => p,
Err(e) => {
eprintln!("cargo:warning=Cannot resolve patch path: {e}");
return;
}
};
let status = Command::new("git")
.arg("apply")
.arg(&patch_canonical)
.current_dir(&ringrtc_canonical)
.status();
match status {
Ok(s) if s.success() => {
eprintln!("cargo:warning=Applied ringrtc VPIO-disable patch for virtual audio support");
}
Ok(s) => {
eprintln!(
"cargo:warning=Failed to apply ringrtc patch (exit {}); \
VPIO may hang with virtual audio devices",
s
);
}
Err(e) => {
eprintln!("cargo:warning=Could not run git apply: {e}");
}
}
}

View File

@ -0,0 +1,41 @@
diff --git a/src/rust/src/webrtc/audio_device_module.rs b/src/rust/src/webrtc/audio_device_module.rs
index 5e3a6ecf..a9b76c12 100644
--- a/src/rust/src/webrtc/audio_device_module.rs
+++ b/src/rust/src/webrtc/audio_device_module.rs
@@ -265,6 +265,18 @@ impl Worker {
}
}
+ /// Returns the stream preferences for cubeb audio streams.
+ ///
+ /// When `RINGRTC_NO_VOICE_PROCESSING` is set, returns `StreamPrefs::NONE`
+ /// to skip macOS VoiceProcessingIO which hangs with virtual audio drivers.
+ fn stream_prefs() -> StreamPrefs {
+ if std::env::var("RINGRTC_NO_VOICE_PROCESSING").is_ok() {
+ StreamPrefs::NONE
+ } else {
+ StreamPrefs::VOICE
+ }
+ }
+
fn init_playout(&mut self) -> anyhow::Result<()> {
let out_device = if let Some(device) = self.playout_device {
device
@@ -276,7 +288,7 @@ impl Worker {
.rate(SAMPLE_FREQUENCY)
.channels(2)
.layout(cubeb::ChannelLayout::STEREO)
- .prefs(StreamPrefs::VOICE)
+ .prefs(Self::stream_prefs())
.take();
let mut builder = cubeb::StreamBuilder::<OutFrame>::new();
let transport = Arc::clone(&self.audio_transport);
@@ -411,7 +423,7 @@ impl Worker {
.rate(SAMPLE_FREQUENCY)
.channels(NUM_CHANNELS)
.layout(cubeb::ChannelLayout::MONO)
- .prefs(StreamPrefs::VOICE)
+ .prefs(Self::stream_prefs())
.take();
let mut builder = cubeb::StreamBuilder::<Frame>::new();

View File

@ -0,0 +1,92 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Config {
pub call_id: u64,
pub is_outgoing: bool,
pub control_socket_path: String,
pub control_token: String,
pub local_device_id: u32,
pub input_device_name: Option<String>,
pub output_device_name: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_valid_config() {
let json = r#"{
"call_id": 12345678,
"is_outgoing": true,
"control_socket_path": "/tmp/sc-abc/ctrl.sock",
"control_token": "dG9rZW4=",
"local_device_id": 1
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.call_id, 12345678);
assert!(config.is_outgoing);
assert_eq!(config.control_socket_path, "/tmp/sc-abc/ctrl.sock");
assert_eq!(config.control_token, "dG9rZW4=");
assert_eq!(config.local_device_id, 1);
assert!(config.input_device_name.is_none());
assert!(config.output_device_name.is_none());
}
#[test]
fn deserialize_with_device_names() {
let json = r#"{
"call_id": 99,
"is_outgoing": false,
"control_socket_path": "/tmp/ctrl.sock",
"control_token": "tok",
"local_device_id": 2,
"input_device_name": "signal_input",
"output_device_name": "signal_output"
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.call_id, 99);
assert!(!config.is_outgoing);
assert_eq!(config.local_device_id, 2);
assert_eq!(config.input_device_name.as_deref(), Some("signal_input"));
assert_eq!(config.output_device_name.as_deref(), Some("signal_output"));
}
#[test]
fn deserialize_missing_field_fails() {
let json = r#"{
"call_id": 1,
"is_outgoing": true
}"#;
let result: Result<Config, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn deserialize_wrong_type_fails() {
let json = r#"{
"call_id": "not_a_number",
"is_outgoing": true,
"control_socket_path": "/tmp/ctrl.sock",
"control_token": "tok",
"local_device_id": 1
}"#;
let result: Result<Config, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn deserialize_extra_fields_ok() {
let json = r#"{
"call_id": 1,
"is_outgoing": true,
"control_socket_path": "/tmp/ctrl.sock",
"control_token": "tok",
"local_device_id": 1,
"extra_field": "ignored"
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.call_id, 1);
}
}

View File

@ -0,0 +1,521 @@
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixListener;
use std::sync::mpsc;
use anyhow::{Context, Result, bail};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use log::{error, info, warn};
use serde_json::Value;
use subtle::ConstantTimeEq;
use crate::platform::PlatformEvent;
/// Messages parsed from the parent process.
#[derive(Debug)]
pub enum ControlMessage {
Auth { token: String },
CreateOutgoingCall { call_id: u64, peer_id: String },
Proceed { call_id: u64, ice_servers: Vec<IceServerConfig>, hide_ip: bool },
ReceivedOffer {
call_id: u64,
peer_id: String,
sender_device_id: u32,
opaque: Vec<u8>,
age_ms: u64,
sender_identity_key: Vec<u8>,
receiver_identity_key: Vec<u8>,
},
ReceivedAnswer {
opaque: Vec<u8>,
sender_device_id: u32,
sender_identity_key: Vec<u8>,
receiver_identity_key: Vec<u8>,
},
ReceivedIce { candidates: Vec<Vec<u8>> },
Accept,
Hangup,
}
#[derive(Debug, Clone)]
pub struct IceServerConfig {
pub username: String,
pub password: String,
pub urls: Vec<String>,
}
/// Parse a JSON line into a ControlMessage.
pub fn parse_message(line: &str) -> Result<ControlMessage> {
let v: Value = serde_json::from_str(line).context("invalid JSON")?;
let msg_type = v["type"].as_str().unwrap_or("");
match msg_type {
"auth" => Ok(ControlMessage::Auth {
token: v["token"].as_str().unwrap_or("").to_string(),
}),
"createOutgoingCall" => Ok(ControlMessage::CreateOutgoingCall {
call_id: v["callId"].as_u64().unwrap_or(0),
peer_id: v["peerId"].as_str().unwrap_or("").to_string(),
}),
"proceed" => {
let ice_servers = if let Some(servers) = v["iceServers"].as_array() {
servers
.iter()
.map(|s| IceServerConfig {
username: s["username"].as_str().unwrap_or("").to_string(),
password: s["password"].as_str().unwrap_or("").to_string(),
urls: s["urls"]
.as_array()
.map(|urls| {
urls.iter()
.filter_map(|u| u.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
})
.collect()
} else {
Vec::new()
};
Ok(ControlMessage::Proceed {
call_id: v["callId"].as_u64().unwrap_or(0),
ice_servers,
hide_ip: v["hideIp"].as_bool().unwrap_or(false),
})
}
"receivedOffer" => Ok(ControlMessage::ReceivedOffer {
call_id: v["callId"].as_u64().unwrap_or(0),
peer_id: v["peerId"].as_str().unwrap_or("remote").to_string(),
sender_device_id: v["senderDeviceId"].as_u64().unwrap_or(1) as u32,
opaque: BASE64
.decode(v["opaque"].as_str().unwrap_or(""))
.unwrap_or_default(),
age_ms: v["age"].as_u64().unwrap_or(0),
sender_identity_key: BASE64
.decode(v["senderIdentityKey"].as_str().unwrap_or(""))
.unwrap_or_default(),
receiver_identity_key: BASE64
.decode(v["receiverIdentityKey"].as_str().unwrap_or(""))
.unwrap_or_default(),
}),
"receivedAnswer" => Ok(ControlMessage::ReceivedAnswer {
opaque: BASE64
.decode(v["opaque"].as_str().unwrap_or(""))
.unwrap_or_default(),
sender_device_id: v["senderDeviceId"].as_u64().unwrap_or(1) as u32,
sender_identity_key: BASE64
.decode(v["senderIdentityKey"].as_str().unwrap_or(""))
.unwrap_or_default(),
receiver_identity_key: BASE64
.decode(v["receiverIdentityKey"].as_str().unwrap_or(""))
.unwrap_or_default(),
}),
"receivedIce" => {
let candidates = if let Some(arr) = v["candidates"].as_array() {
arr.iter()
.filter_map(|c| {
let b64 = c.as_str()?;
BASE64.decode(b64).ok()
})
.collect()
} else {
Vec::new()
};
Ok(ControlMessage::ReceivedIce { candidates })
}
"accept" => Ok(ControlMessage::Accept),
"hangup" => Ok(ControlMessage::Hangup),
_ => bail!("unknown message type: {}", msg_type),
}
}
/// Validate the auth token using constant-time comparison.
pub fn validate_token(received: &str, expected: &str) -> bool {
let received_bytes = received.as_bytes();
let expected_bytes = expected.as_bytes();
if received_bytes.len() != expected_bytes.len() {
return false;
}
received_bytes.ct_eq(expected_bytes).into()
}
/// Runs the control channel server. Binds a Unix socket, accepts one connection,
/// validates auth, then reads messages and sends events.
///
/// Returns a channel receiver for incoming control messages and a writer for
/// sending events to the parent.
pub struct ControlChannel {
pub msg_receiver: mpsc::Receiver<ControlMessage>,
pub writer: ControlWriter,
}
#[derive(Clone)]
pub struct ControlWriter {
sender: mpsc::Sender<String>,
}
impl ControlWriter {
pub fn send_line(&self, line: &str) {
if let Err(e) = self.sender.send(line.to_string()) {
error!("Failed to send to control writer: {}", e);
}
}
pub fn send_event(&self, event: &PlatformEvent) {
self.send_line(&event.to_json());
}
}
#[cfg(test)]
mod tests {
use super::*;
// --- parse_message tests ---
#[test]
fn parse_auth() {
let msg = parse_message(r#"{"type":"auth","token":"secret123"}"#).unwrap();
match msg {
ControlMessage::Auth { token } => assert_eq!(token, "secret123"),
_ => panic!("expected Auth, got {:?}", msg),
}
}
#[test]
fn parse_create_outgoing_call() {
let msg = parse_message(
r#"{"type":"createOutgoingCall","callId":42,"peerId":"abc-def"}"#,
)
.unwrap();
match msg {
ControlMessage::CreateOutgoingCall { call_id, peer_id } => {
assert_eq!(call_id, 42);
assert_eq!(peer_id, "abc-def");
}
_ => panic!("expected CreateOutgoingCall, got {:?}", msg),
}
}
#[test]
fn parse_proceed_with_ice_servers() {
let json = r#"{
"type": "proceed",
"callId": 99,
"hideIp": true,
"iceServers": [
{
"username": "user1",
"password": "pass1",
"urls": ["turn:example.com:3478", "stun:example.com:3478"]
},
{
"username": "user2",
"password": "pass2",
"urls": ["turn:other.com:443"]
}
]
}"#;
let msg = parse_message(json).unwrap();
match msg {
ControlMessage::Proceed {
call_id,
ice_servers,
hide_ip,
} => {
assert_eq!(call_id, 99);
assert!(hide_ip);
assert_eq!(ice_servers.len(), 2);
assert_eq!(ice_servers[0].username, "user1");
assert_eq!(ice_servers[0].password, "pass1");
assert_eq!(ice_servers[0].urls.len(), 2);
assert_eq!(ice_servers[0].urls[0], "turn:example.com:3478");
assert_eq!(ice_servers[1].username, "user2");
assert_eq!(ice_servers[1].urls.len(), 1);
}
_ => panic!("expected Proceed, got {:?}", msg),
}
}
#[test]
fn parse_proceed_no_ice_servers() {
let msg = parse_message(r#"{"type":"proceed","callId":1}"#).unwrap();
match msg {
ControlMessage::Proceed {
call_id,
ice_servers,
hide_ip,
} => {
assert_eq!(call_id, 1);
assert!(!hide_ip);
assert!(ice_servers.is_empty());
}
_ => panic!("expected Proceed, got {:?}", msg),
}
}
#[test]
fn parse_received_offer() {
// "aGVsbG8=" is base64 for "hello"
let json = r#"{
"type": "receivedOffer",
"callId": 100,
"peerId": "0b949a17-dc53-41b1-9ebc-dea99cb93920",
"senderDeviceId": 3,
"opaque": "aGVsbG8=",
"age": 500,
"senderIdentityKey": "AQID",
"receiverIdentityKey": "BAUG"
}"#;
let msg = parse_message(json).unwrap();
match msg {
ControlMessage::ReceivedOffer {
call_id,
peer_id,
sender_device_id,
opaque,
age_ms,
sender_identity_key,
receiver_identity_key,
} => {
assert_eq!(call_id, 100);
assert_eq!(peer_id, "0b949a17-dc53-41b1-9ebc-dea99cb93920");
assert_eq!(sender_device_id, 3);
assert_eq!(opaque, b"hello");
assert_eq!(age_ms, 500);
assert_eq!(sender_identity_key, vec![1, 2, 3]);
assert_eq!(receiver_identity_key, vec![4, 5, 6]);
}
_ => panic!("expected ReceivedOffer, got {:?}", msg),
}
}
#[test]
fn parse_received_answer() {
let json = r#"{
"type": "receivedAnswer",
"opaque": "AQID",
"senderDeviceId": 2,
"senderIdentityKey": "BAUG",
"receiverIdentityKey": "BwgJ"
}"#;
let msg = parse_message(json).unwrap();
match msg {
ControlMessage::ReceivedAnswer {
opaque,
sender_device_id,
sender_identity_key,
receiver_identity_key,
} => {
assert_eq!(opaque, vec![1, 2, 3]);
assert_eq!(sender_device_id, 2);
assert_eq!(sender_identity_key, vec![4, 5, 6]);
assert_eq!(receiver_identity_key, vec![7, 8, 9]);
}
_ => panic!("expected ReceivedAnswer, got {:?}", msg),
}
}
#[test]
fn parse_received_ice() {
// Two base64-encoded candidates
let json = r#"{"type":"receivedIce","candidates":["AQID","BAUG"]}"#;
let msg = parse_message(json).unwrap();
match msg {
ControlMessage::ReceivedIce { candidates } => {
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0], vec![1, 2, 3]);
assert_eq!(candidates[1], vec![4, 5, 6]);
}
_ => panic!("expected ReceivedIce, got {:?}", msg),
}
}
#[test]
fn parse_received_ice_empty() {
let msg = parse_message(r#"{"type":"receivedIce","candidates":[]}"#).unwrap();
match msg {
ControlMessage::ReceivedIce { candidates } => {
assert!(candidates.is_empty());
}
_ => panic!("expected ReceivedIce, got {:?}", msg),
}
}
#[test]
fn parse_accept() {
let msg = parse_message(r#"{"type":"accept"}"#).unwrap();
assert!(matches!(msg, ControlMessage::Accept));
}
#[test]
fn parse_hangup() {
let msg = parse_message(r#"{"type":"hangup"}"#).unwrap();
assert!(matches!(msg, ControlMessage::Hangup));
}
#[test]
fn parse_unknown_type_fails() {
let result = parse_message(r#"{"type":"foobar"}"#);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unknown message type"));
}
#[test]
fn parse_invalid_json_fails() {
let result = parse_message("not json at all");
assert!(result.is_err());
}
#[test]
fn parse_missing_type_fails() {
let result = parse_message(r#"{"callId":1}"#);
assert!(result.is_err());
}
// --- validate_token tests ---
#[test]
fn validate_token_matching() {
assert!(validate_token("my-secret-token", "my-secret-token"));
}
#[test]
fn validate_token_mismatch() {
assert!(!validate_token("wrong-token", "my-secret-token"));
}
#[test]
fn validate_token_different_lengths() {
assert!(!validate_token("short", "a-much-longer-token"));
}
#[test]
fn validate_token_empty() {
assert!(validate_token("", ""));
}
#[test]
fn validate_token_one_empty() {
assert!(!validate_token("", "notempty"));
assert!(!validate_token("notempty", ""));
}
}
pub fn start_control_channel(
control_socket_path: &str,
expected_token: &str,
input_device_name: &str,
output_device_name: &str,
) -> Result<ControlChannel> {
// Remove stale socket file
let _ = std::fs::remove_file(control_socket_path);
let listener = UnixListener::bind(control_socket_path)
.with_context(|| format!("failed to bind control socket at {}", control_socket_path))?;
info!("Control channel listening on {}", control_socket_path);
let (msg_sender, msg_receiver) = mpsc::channel::<ControlMessage>();
let (write_sender, write_receiver) = mpsc::channel::<String>();
let writer = ControlWriter {
sender: write_sender,
};
// Send ready message immediately (parent can connect after this)
let ready_msg = format!(
r#"{{"type":"ready","inputDeviceName":"{}","outputDeviceName":"{}"}}"#,
input_device_name, output_device_name
);
let expected_token = expected_token.to_string();
// Spawn reader thread
std::thread::spawn(move || {
// Accept one connection
let (stream, _) = match listener.accept() {
Ok(s) => s,
Err(e) => {
error!("Failed to accept control connection: {}", e);
return;
}
};
info!("Control channel: parent connected");
let mut writer_stream = match stream.try_clone() {
Ok(s) => s,
Err(e) => {
error!("Failed to clone control stream: {}", e);
return;
}
};
// Spawn writer thread
std::thread::spawn(move || {
for line in write_receiver {
if let Err(e) = writeln!(writer_stream, "{}", line) {
error!("Failed to write to control channel: {}", e);
break;
}
if let Err(e) = writer_stream.flush() {
error!("Failed to flush control channel: {}", e);
break;
}
}
});
let reader = BufReader::new(stream);
let mut authenticated = false;
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
info!("Control channel read ended: {}", e);
break;
}
};
if line.trim().is_empty() {
continue;
}
let msg = match parse_message(&line) {
Ok(m) => m,
Err(e) => {
warn!("Failed to parse control message: {} (line: {})", e, line);
continue;
}
};
// First message must be auth
if !authenticated {
if let ControlMessage::Auth { ref token } = msg {
if validate_token(token, &expected_token) {
authenticated = true;
info!("Control channel: authenticated");
continue;
} else {
error!("Control channel: auth failed");
break;
}
} else {
error!("Control channel: first message must be auth");
break;
}
}
if let Err(e) = msg_sender.send(msg) {
info!("Control message receiver dropped: {}", e);
break;
}
}
});
// Send the ready message through the writer channel
// (it will be sent once the writer thread starts)
writer.send_line(&ready_msg);
Ok(ControlChannel {
msg_receiver,
writer,
})
}

View File

@ -0,0 +1,410 @@
mod config;
mod control;
mod platform;
use std::io::Read;
use std::sync::mpsc;
use std::time::Duration;
use anyhow::{Context, Result};
use log::{debug, error, info};
use ringrtc::common::{CallConfig, CallId, CallMediaType, DataMode, DeviceId};
use ringrtc::core::{call_manager::CallManager, signaling};
use ringrtc::lite::http;
use ringrtc::native::{NativeCallContext, NativePlatform, PeerId};
use ringrtc::virtual_audio::VirtualAudioDevicePair;
use ringrtc::webrtc::{
media::{VideoFrame, VideoSink},
peer_connection_factory::{AudioConfig, IceServer, PeerConnectionFactory},
};
use crate::config::Config;
use crate::control::{ControlMessage, start_control_channel};
use crate::platform::{
PlatformEvent, TunnelGroupHandler, TunnelSignalingSender, TunnelStateHandler,
};
/// Dummy video sink that discards all frames.
#[derive(Debug)]
struct NullVideoSink;
impl VideoSink for NullVideoSink {
fn on_video_frame(&self, _track_id: u32, _frame: VideoFrame) {}
fn box_clone(&self) -> Box<dyn VideoSink> {
Box::new(NullVideoSink)
}
}
/// Dummy HTTP client for CallManager (no SFU needed for 1:1 calls).
#[derive(Clone)]
struct NullHttpClient;
impl http::Delegate for NullHttpClient {
fn send_request(&self, _request_id: u32, _request: http::Request) {
// No-op -- no group call SFU requests
}
}
fn main() -> Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.format_timestamp_millis()
.init();
// Read config from stdin
let mut config_str = String::new();
std::io::stdin()
.read_to_string(&mut config_str)
.context("failed to read config from stdin")?;
let config: Config =
serde_json::from_str(&config_str).context("failed to parse config JSON")?;
info!(
"signal-call-tunnel starting: call_id={}, is_outgoing={}",
config.call_id, config.is_outgoing
);
// Create virtual audio devices (signal-call-tunnel owns their lifecycle).
// On macOS, BlackHole drivers must be pre-installed with matching names (requires
// root), so we default to fixed names. On Linux, PulseAudio virtual sinks are
// created dynamically, so per-call unique names avoid collisions.
let input_name = config.input_device_name.clone().unwrap_or_else(|| {
if cfg!(target_os = "macos") {
"signal_input".to_string()
} else {
format!("signal_input_{}", config.call_id)
}
});
let output_name = config.output_device_name.clone().unwrap_or_else(|| {
if cfg!(target_os = "macos") {
"signal_output".to_string()
} else {
format!("signal_output_{}", config.call_id)
}
});
let virtual_audio = VirtualAudioDevicePair::new(&input_name, &output_name)?;
info!(
"Virtual audio devices: input={}, output={}",
virtual_audio.input_source(),
virtual_audio.output_sink()
);
// Channel for platform events (signaling + state changes)
let (event_sender, event_receiver) = mpsc::channel::<PlatformEvent>();
// Start control channel
let control = start_control_channel(
&config.control_socket_path,
&config.control_token,
virtual_audio.input_source(),
virtual_audio.output_sink(),
)?;
// Show WebRTC logs while debugging
#[cfg(debug_assertions)]
ringrtc::webrtc::logging::set_logger(log::LevelFilter::Debug);
#[cfg(not(debug_assertions))]
ringrtc::webrtc::logging::set_logger(log::LevelFilter::Warn);
// Disable macOS VPIO (VoiceProcessingIO) for cubeb audio streams.
// VPIO creates an aggregate device that hangs with BlackHole virtual audio
// drivers. Voice processing (AEC/AGC/NS) is unnecessary for virtual audio.
// Safety: called before any threads are spawned, single-threaded at this point.
unsafe { std::env::set_var("RINGRTC_NO_VOICE_PROCESSING", "1") };
let audio_config = AudioConfig::default();
let mut pcf = PeerConnectionFactory::new(&audio_config, false, "", None)?;
// Wait for cubeb to enumerate the virtual devices
loop {
std::thread::sleep(Duration::from_millis(100));
if pcf
.get_audio_playout_devices()
.is_ok_and(|d| !d.is_empty())
&& pcf
.get_audio_recording_devices()
.is_ok_and(|d| !d.is_empty())
{
break;
}
}
// Select virtual devices by name.
//
// We can't use set_audio_*_device_by_id() because the ADM matches on the
// cubeb unique_id (e.g. "signal_input2ch_UID"), not the friendly name we
// know ("signal_input"). Instead, enumerate and find the index by name.
let input_name = virtual_audio.input_source();
let recording_devices = pcf.get_audio_recording_devices()?;
let recording_index = recording_devices
.iter()
.position(|d| d.name == input_name)
.ok_or_else(|| anyhow::anyhow!("recording device '{}' not found", input_name))?
as u16;
pcf.set_audio_recording_device(recording_index)?;
info!("Selected recording device: index={}, name={}", recording_index, input_name);
let output_name = virtual_audio.output_sink();
let playout_devices = pcf.get_audio_playout_devices()?;
let playout_index = playout_devices
.iter()
.position(|d| d.name == output_name)
.ok_or_else(|| anyhow::anyhow!("playout device '{}' not found", output_name))?
as u16;
pcf.set_audio_playout_device(playout_index)?;
info!("Selected playout device: index={}, name={}", playout_index, output_name);
// Create platform with our trait implementations
let signaling_sender = Box::new(TunnelSignalingSender {
event_sender: event_sender.clone(),
});
let state_handler = Box::new(TunnelStateHandler {
event_sender: event_sender.clone(),
});
let group_handler = Box::new(TunnelGroupHandler);
let platform = NativePlatform::new(
pcf.clone(),
signaling_sender,
true, // should_assume_messages_sent
state_handler,
group_handler,
);
let http_client = http::DelegatingClient::new(NullHttpClient);
let mut call_manager = CallManager::new(platform, http_client)?;
// Peer ID is set by the first createOutgoingCall or receivedOffer message.
let mut active_peer_id = PeerId::from("remote");
let call_id = CallId::from(config.call_id);
let local_device_id = config.local_device_id as DeviceId;
info!("CallManager initialized, entering event loop");
// Spawn a thread to forward platform events to control channel
let control_writer = control.writer.clone();
std::thread::spawn(move || {
for event in event_receiver {
control_writer.send_event(&event);
}
});
// Main event loop: process control messages
loop {
let msg = match control.msg_receiver.recv_timeout(Duration::from_millis(100)) {
Ok(msg) => msg,
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => {
info!("Control channel disconnected, exiting");
break;
}
};
match msg {
ControlMessage::Auth { .. } => {
// Already handled by control channel
}
ControlMessage::CreateOutgoingCall {
call_id: cid,
peer_id: pid,
} => {
let call_id = CallId::from(cid);
let peer_id = PeerId::from(pid.as_str());
active_peer_id = peer_id.clone();
info!("Creating outgoing call: call_id={}, peer_id={}", cid, pid);
if let Err(e) = call_manager.create_outgoing_call(
peer_id,
call_id,
CallMediaType::Audio,
local_device_id,
) {
error!("Failed to create outgoing call: {}", e);
control.writer.send_line(&format!(
r#"{{"type":"error","message":"failed to create outgoing call: {}"}}"#,
e
));
}
}
ControlMessage::Proceed {
call_id: cid,
ice_servers,
hide_ip,
} => {
let call_id = CallId::from(cid);
info!("Proceeding with call: call_id={}", cid);
let ice_server_list: Vec<IceServer> = ice_servers
.iter()
.map(|s| IceServer::new(
s.username.clone(),
s.password.clone(),
String::new(),
s.urls.clone(),
))
.collect();
let outgoing_audio_track = match pcf.create_outgoing_audio_track() {
Ok(t) => t,
Err(e) => {
error!("Failed to create audio track: {}", e);
continue;
}
};
let outgoing_video_source = match pcf.create_outgoing_video_source() {
Ok(s) => s,
Err(e) => {
error!("Failed to create video source: {}", e);
continue;
}
};
let outgoing_video_track =
match pcf.create_outgoing_video_track(&outgoing_video_source) {
Ok(t) => t,
Err(e) => {
error!("Failed to create video track: {}", e);
continue;
}
};
let call_context = NativeCallContext::new(
hide_ip,
ice_server_list,
outgoing_audio_track,
outgoing_video_track,
Box::new(NullVideoSink),
);
let call_config = CallConfig {
data_mode: DataMode::Low,
..Default::default()
};
if let Err(e) =
call_manager.proceed(call_id, call_context, call_config, None)
{
error!("Failed to proceed: {}", e);
control.writer.send_line(&format!(
r#"{{"type":"error","message":"failed to proceed: {}"}}"#,
e
));
}
}
ControlMessage::ReceivedOffer {
call_id: cid,
peer_id: pid,
sender_device_id,
opaque,
age_ms,
sender_identity_key,
receiver_identity_key,
} => {
let call_id = CallId::from(cid);
let peer_id = PeerId::from(pid.as_str());
active_peer_id = peer_id.clone();
info!(
"Received offer: call_id={}, peer_id={}, sender_device={}",
cid, pid, sender_device_id
);
let offer = match signaling::Offer::new(CallMediaType::Audio, opaque) {
Ok(o) => o,
Err(e) => {
error!("Failed to parse offer: {}", e);
continue;
}
};
let received = signaling::ReceivedOffer {
offer,
age: Duration::from_millis(age_ms),
sender_device_id: sender_device_id as DeviceId,
receiver_device_id: local_device_id,
sender_identity_key,
receiver_identity_key,
};
if let Err(e) = call_manager.received_offer(
peer_id,
call_id,
received,
) {
error!("Failed to process received offer: {}", e);
}
}
ControlMessage::ReceivedAnswer {
opaque,
sender_device_id,
sender_identity_key,
receiver_identity_key,
} => {
info!("Received answer from device {}", sender_device_id);
let answer = match signaling::Answer::new(opaque) {
Ok(a) => a,
Err(e) => {
error!("Failed to parse answer: {}", e);
continue;
}
};
let received = signaling::ReceivedAnswer {
answer,
sender_device_id: sender_device_id as DeviceId,
sender_identity_key,
receiver_identity_key,
};
if let Err(e) = call_manager.received_answer(
active_peer_id.clone(),
call_id,
received,
) {
error!("Failed to process received answer: {}", e);
}
}
ControlMessage::ReceivedIce { candidates } => {
debug!("Received {} ICE candidates", candidates.len());
let ice_candidates: Vec<signaling::IceCandidate> = candidates
.into_iter()
.map(signaling::IceCandidate::new)
.collect();
let received = signaling::ReceivedIce {
ice: signaling::Ice {
candidates: ice_candidates,
},
sender_device_id: 1 as DeviceId,
};
if let Err(e) = call_manager.received_ice(
active_peer_id.clone(),
call_id,
received,
) {
error!("Failed to process received ICE: {}", e);
}
}
ControlMessage::Accept => {
info!("Accepting call");
if let Err(e) = call_manager.accept_call(call_id) {
error!("Failed to accept call: {}", e);
}
}
ControlMessage::Hangup => {
info!("Hanging up");
if let Err(e) = call_manager.hangup() {
error!("Failed to hangup: {}", e);
}
// Give time for hangup to be sent
std::thread::sleep(Duration::from_millis(500));
break;
}
}
}
info!("signal-call-tunnel exiting");
Ok(())
}

View File

@ -0,0 +1,481 @@
use std::collections::{HashMap, HashSet};
use std::sync::mpsc;
use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use log::{debug, error, info, warn};
use ringrtc::common::{CallId, CallMediaType, DeviceId};
use ringrtc::core::{group_call, signaling};
use ringrtc::lite::sfu::UserId;
use ringrtc::native::{
CallState, CallStateHandler, GroupUpdate, GroupUpdateHandler, SignalingSender,
};
use ringrtc::webrtc::peer_connection::AudioLevel;
use ringrtc::webrtc::peer_connection_observer::NetworkRoute;
/// Events sent from the platform callbacks to the control channel writer.
#[derive(Debug)]
pub enum PlatformEvent {
/// A signaling message to send to the parent process.
SendSignaling {
call_id: CallId,
message: SignalingEvent,
},
/// A call state change.
StateChange {
state: String,
reason: Option<String>,
},
}
#[derive(Debug)]
pub enum SignalingEvent {
SendOffer {
opaque: Vec<u8>,
call_media_type: CallMediaType,
},
SendAnswer {
opaque: Vec<u8>,
},
SendIce {
candidates: Vec<Vec<u8>>,
},
SendHangup {
hangup_type: String,
},
SendBusy,
}
impl PlatformEvent {
pub fn to_json(&self) -> String {
match self {
PlatformEvent::SendSignaling { call_id, message } => match message {
SignalingEvent::SendOffer {
opaque,
call_media_type,
} => {
let media_type = match call_media_type {
CallMediaType::Audio => "audio",
CallMediaType::Video => "video",
};
format!(
r#"{{"type":"sendOffer","callId":{},"opaque":"{}","callMediaType":"{}"}}"#,
u64::from(*call_id),
BASE64.encode(opaque),
media_type,
)
}
SignalingEvent::SendAnswer { opaque } => {
format!(
r#"{{"type":"sendAnswer","callId":{},"opaque":"{}"}}"#,
u64::from(*call_id),
BASE64.encode(opaque),
)
}
SignalingEvent::SendIce { candidates } => {
let candidates_json: Vec<String> = candidates
.iter()
.map(|c| format!(r#"{{"opaque":"{}"}}"#, BASE64.encode(c)))
.collect();
format!(
r#"{{"type":"sendIce","callId":{},"candidates":[{}]}}"#,
u64::from(*call_id),
candidates_json.join(","),
)
}
SignalingEvent::SendHangup { hangup_type } => {
format!(
r#"{{"type":"sendHangup","callId":{},"hangupType":"{}"}}"#,
u64::from(*call_id),
hangup_type,
)
}
SignalingEvent::SendBusy => {
format!(
r#"{{"type":"sendBusy","callId":{}}}"#,
u64::from(*call_id),
)
}
},
PlatformEvent::StateChange { state, reason } => {
if let Some(reason) = reason {
format!(
r#"{{"type":"stateChange","state":"{}","reason":"{}"}}"#,
state, reason,
)
} else {
format!(r#"{{"type":"stateChange","state":"{}"}}"#, state)
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn parse_event_json(event: &PlatformEvent) -> Value {
serde_json::from_str(&event.to_json()).expect("event JSON should be valid")
}
#[test]
fn send_offer_json() {
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(42u64),
message: SignalingEvent::SendOffer {
opaque: vec![1, 2, 3],
call_media_type: CallMediaType::Audio,
},
};
let json = parse_event_json(&event);
assert_eq!(json["type"], "sendOffer");
assert_eq!(json["callId"], 42);
assert_eq!(json["callMediaType"], "audio");
// Verify opaque is valid base64 that decodes back
let opaque_b64 = json["opaque"].as_str().unwrap();
let decoded = BASE64.decode(opaque_b64).unwrap();
assert_eq!(decoded, vec![1, 2, 3]);
}
#[test]
fn send_offer_video_json() {
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(1u64),
message: SignalingEvent::SendOffer {
opaque: vec![],
call_media_type: CallMediaType::Video,
},
};
let json = parse_event_json(&event);
assert_eq!(json["callMediaType"], "video");
}
#[test]
fn send_answer_json() {
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(99u64),
message: SignalingEvent::SendAnswer {
opaque: vec![4, 5, 6],
},
};
let json = parse_event_json(&event);
assert_eq!(json["type"], "sendAnswer");
assert_eq!(json["callId"], 99);
let decoded = BASE64.decode(json["opaque"].as_str().unwrap()).unwrap();
assert_eq!(decoded, vec![4, 5, 6]);
}
#[test]
fn send_ice_json() {
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(7u64),
message: SignalingEvent::SendIce {
candidates: vec![vec![10, 20], vec![30, 40]],
},
};
let json = parse_event_json(&event);
assert_eq!(json["type"], "sendIce");
assert_eq!(json["callId"], 7);
let candidates = json["candidates"].as_array().unwrap();
assert_eq!(candidates.len(), 2);
let c0 = BASE64
.decode(candidates[0]["opaque"].as_str().unwrap())
.unwrap();
assert_eq!(c0, vec![10, 20]);
let c1 = BASE64
.decode(candidates[1]["opaque"].as_str().unwrap())
.unwrap();
assert_eq!(c1, vec![30, 40]);
}
#[test]
fn send_ice_empty_candidates_json() {
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(1u64),
message: SignalingEvent::SendIce {
candidates: vec![],
},
};
let json = parse_event_json(&event);
assert_eq!(json["candidates"].as_array().unwrap().len(), 0);
}
#[test]
fn send_hangup_json() {
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(50u64),
message: SignalingEvent::SendHangup {
hangup_type: "normal".to_string(),
},
};
let json = parse_event_json(&event);
assert_eq!(json["type"], "sendHangup");
assert_eq!(json["callId"], 50);
assert_eq!(json["hangupType"], "normal");
}
#[test]
fn send_busy_json() {
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(8u64),
message: SignalingEvent::SendBusy,
};
let json = parse_event_json(&event);
assert_eq!(json["type"], "sendBusy");
assert_eq!(json["callId"], 8);
}
#[test]
fn state_change_with_reason_json() {
let event = PlatformEvent::StateChange {
state: "Ended".to_string(),
reason: Some("Timeout".to_string()),
};
let json = parse_event_json(&event);
assert_eq!(json["type"], "stateChange");
assert_eq!(json["state"], "Ended");
assert_eq!(json["reason"], "Timeout");
}
#[test]
fn state_change_without_reason_json() {
let event = PlatformEvent::StateChange {
state: "Connected".to_string(),
reason: None,
};
let json = parse_event_json(&event);
assert_eq!(json["type"], "stateChange");
assert_eq!(json["state"], "Connected");
assert!(json.get("reason").is_none());
}
// --- Round-trip tests: platform event -> JSON -> parse as control message ---
#[test]
fn round_trip_offer() {
let opaque = vec![0xDE, 0xAD, 0xBE, 0xEF];
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(123u64),
message: SignalingEvent::SendOffer {
opaque: opaque.clone(),
call_media_type: CallMediaType::Audio,
},
};
let json_str = event.to_json();
// The parent would receive this JSON and could extract the opaque
let parsed: Value = serde_json::from_str(&json_str).unwrap();
let decoded = BASE64
.decode(parsed["opaque"].as_str().unwrap())
.unwrap();
assert_eq!(decoded, opaque);
}
#[test]
fn round_trip_ice() {
let candidates = vec![vec![1, 2, 3], vec![4, 5, 6, 7, 8]];
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(456u64),
message: SignalingEvent::SendIce {
candidates: candidates.clone(),
},
};
let json_str = event.to_json();
let parsed: Value = serde_json::from_str(&json_str).unwrap();
let arr = parsed["candidates"].as_array().unwrap();
assert_eq!(arr.len(), 2);
for (i, c) in arr.iter().enumerate() {
let decoded = BASE64.decode(c["opaque"].as_str().unwrap()).unwrap();
assert_eq!(decoded, candidates[i]);
}
}
}
/// Implements SignalingSender -- relays signaling messages to the control channel.
pub struct TunnelSignalingSender {
pub event_sender: mpsc::Sender<PlatformEvent>,
}
impl SignalingSender for TunnelSignalingSender {
fn send_signaling(
&self,
_recipient_id: &str,
call_id: CallId,
_receiver_device_id: Option<DeviceId>,
message: signaling::Message,
) -> Result<()> {
let event = match message {
signaling::Message::Offer(offer) => SignalingEvent::SendOffer {
opaque: offer.opaque,
call_media_type: offer.call_media_type,
},
signaling::Message::Answer(answer) => SignalingEvent::SendAnswer {
opaque: answer.opaque,
},
signaling::Message::Ice(ice) => SignalingEvent::SendIce {
candidates: ice.candidates.into_iter().map(|c| c.opaque).collect(),
},
signaling::Message::Hangup(hangup) => {
let (hangup_type, _device_id) = hangup.to_type_and_device_id();
SignalingEvent::SendHangup {
hangup_type: format!("{:?}", hangup_type).to_lowercase(),
}
}
signaling::Message::Busy => SignalingEvent::SendBusy,
};
if let Err(e) = self.event_sender.send(PlatformEvent::SendSignaling {
call_id,
message: event,
}) {
error!("Failed to send signaling event: {}", e);
}
Ok(())
}
fn send_call_message(
&self,
_recipient_id: UserId,
_message: Vec<u8>,
_urgency: group_call::SignalingMessageUrgency,
) -> Result<()> {
// No-op for 1:1 calls
Ok(())
}
fn send_call_message_to_group(
&self,
_group_id: group_call::GroupId,
_message: Vec<u8>,
_urgency: group_call::SignalingMessageUrgency,
_recipients_override: HashSet<UserId>,
) -> Result<()> {
// No-op for 1:1 calls
Ok(())
}
fn send_call_message_to_adhoc_group(
&self,
_message: Vec<u8>,
_urgency: group_call::SignalingMessageUrgency,
_expiration: u64,
_recipients_to_endorsements: HashMap<UserId, Vec<u8>>,
) -> Result<()> {
// No-op for 1:1 calls
Ok(())
}
}
/// Implements CallStateHandler -- relays state changes to the control channel.
pub struct TunnelStateHandler {
pub event_sender: mpsc::Sender<PlatformEvent>,
}
impl CallStateHandler for TunnelStateHandler {
fn handle_call_state(
&self,
_remote_peer_id: &str,
_call_id: CallId,
call_state: CallState,
) -> Result<()> {
let (state, reason) = match call_state {
CallState::Incoming(media_type) => {
(format!("Incoming({:?})", media_type), None)
}
CallState::Outgoing(media_type) => {
(format!("Outgoing({:?})", media_type), None)
}
CallState::Ringing => ("Ringing".to_string(), None),
CallState::Connected => ("Connected".to_string(), None),
CallState::Connecting => ("Connecting".to_string(), None),
CallState::Ended(reason, _summary) => {
("Ended".to_string(), Some(format!("{:?}", reason)))
}
CallState::Rejected(reason) => {
("Rejected".to_string(), Some(format!("{:?}", reason)))
}
CallState::Concluded => ("Concluded".to_string(), None),
};
info!("Call state: {} (reason: {:?})", state, reason);
if let Err(e) = self
.event_sender
.send(PlatformEvent::StateChange { state, reason })
{
error!("Failed to send state change event: {}", e);
}
Ok(())
}
fn handle_remote_audio_state(
&self,
_remote_peer_id: &str,
enabled: bool,
) -> Result<()> {
debug!("Remote audio state: {}", enabled);
Ok(())
}
fn handle_remote_video_state(
&self,
_remote_peer_id: &str,
enabled: bool,
) -> Result<()> {
debug!("Remote video state: {}", enabled);
Ok(())
}
fn handle_remote_sharing_screen(
&self,
_remote_peer_id: &str,
enabled: bool,
) -> Result<()> {
debug!("Remote sharing screen: {}", enabled);
Ok(())
}
fn handle_network_route(
&self,
_remote_peer_id: &str,
network_route: NetworkRoute,
) -> Result<()> {
info!("Network route: {:?}", network_route);
Ok(())
}
fn handle_audio_levels(
&self,
_remote_peer_id: &str,
_captured_level: AudioLevel,
_received_level: AudioLevel,
) -> Result<()> {
// Don't log -- too noisy
Ok(())
}
fn handle_low_bandwidth_for_video(
&self,
_remote_peer_id: &str,
recovered: bool,
) -> Result<()> {
if recovered {
info!("Low bandwidth for video: recovered");
} else {
warn!("Low bandwidth for video");
}
Ok(())
}
}
/// Implements GroupUpdateHandler -- all no-ops for 1:1 calls.
pub struct TunnelGroupHandler;
impl GroupUpdateHandler for TunnelGroupHandler {
fn handle_group_update(&self, _update: GroupUpdate) -> Result<()> {
Ok(())
}
}

1
third-party/ringrtc vendored Submodule

@ -0,0 +1 @@
Subproject commit a86f8a6832cec291ef4f77e4ee9b941e5b91a01f