diff --git a/docs/CALL_TUNNEL.md b/docs/CALL_TUNNEL.md index b60d188a..b116c0b9 100644 --- a/docs/CALL_TUNNEL.md +++ b/docs/CALL_TUNNEL.md @@ -4,24 +4,26 @@ signal-cli supports voice calls by spawning a subprocess called `signal-call-tunnel` for each call. The tunnel handles WebRTC negotiation and -audio transport. signal-cli communicates with it over a Unix domain socket using -newline-delimited JSON messages, relaying signaling between the tunnel and the -Signal protocol. +audio transport. signal-cli communicates with the tunnel over its stdin/stdout +using newline-delimited JSON messages, relaying signaling between the tunnel +and the Signal protocol. ``` signal-cli signal-call-tunnel | | - |-- spawn (config on stdin) --------->| + |-- spawn --------------------------->| + |-- config JSON on stdin ------------>| | | - |<======= ctrl.sock (JSON) ==========>| - | signaling relay | WebRTC - | | audio I/O + |-- commands on stdin --------------->| + |<-- events on stdout ----------------| + | | WebRTC + | signaling relay | audio I/O | | + | (stderr: tunnel logging) -------->| (captured by signal-cli) ``` -Each call gets its own tunnel process and control socket inside a temporary -directory (`/tmp/sc-/`). When the call ends, signal-cli kills the -process and deletes the directory. +Each call gets its own tunnel process. When the call ends, signal-cli closes +stdin and destroys the process. Audio device names (`inputDeviceName`, `outputDeviceName`) are opaque strings returned by the tunnel in its `ready` message. signal-cli passes them through @@ -33,11 +35,11 @@ to JSON-RPC clients, which use them to connect audio via platform APIs. For each call, signal-cli: -1. Creates a temporary directory `/tmp/sc-/` (mode `0700`) -2. Generates a random 32-byte auth token -3. Spawns `signal-call-tunnel` with config JSON on stdin -4. Connects to the control socket (retries up to 50x at 200 ms intervals) -5. Authenticates with the auth token +1. Spawns `signal-call-tunnel` +2. Writes config JSON followed by a newline to stdin +3. Keeps stdin open for subsequent control messages +4. Reads control events from stdout +5. Captures stderr for logging The `signal-call-tunnel` binary is located by searching (in order): @@ -47,14 +49,12 @@ The `signal-call-tunnel` binary is located by searching (in order): ### Config JSON -Written to the tunnel's stdin before it starts: +The first line written to the tunnel's stdin: ```json { "call_id": 12345, "is_outgoing": true, - "control_socket_path": "/tmp/sc-a1b2c3/ctrl.sock", - "control_token": "dG9rZW4...", "local_device_id": 1, "input_device_name": "signal_input", "output_device_name": "signal_output" @@ -65,8 +65,6 @@ Written to the tunnel's stdin before it starts: |-------|------|-------------| | `call_id` | unsigned 64-bit integer | Call identifier (use unsigned representation) | | `is_outgoing` | boolean | Whether this is an outgoing call | -| `control_socket_path` | string | Path where the tunnel creates its control socket | -| `control_token` | string | Base64-encoded 32-byte auth token | | `local_device_id` | integer | Signal device ID | | `input_device_name` | string (optional) | Requested input audio device name | | `output_device_name` | string (optional) | Requested output audio device name | @@ -78,25 +76,16 @@ and `signal_output`, which must match the pre-installed BlackHole drivers. --- -## Control Socket Protocol +## Control Protocol -Unix SOCK_STREAM at `ctrl.sock`. Newline-delimited JSON messages. +Newline-delimited JSON messages over stdin (signal-cli to tunnel) and stdout +(tunnel to signal-cli). The first line on stdin is the config JSON. Subsequent +lines are control messages. -### Authentication - -The first message from signal-cli **must** be an auth message. The token is -a random 32-byte value generated per call and passed in the startup config. -The tunnel performs constant-time comparison. - -```json -{"type":"auth","token":""} -``` - -### signal-cli -> Tunnel +### signal-cli -> Tunnel (stdin) | Type | When | Fields | |------|------|--------| -| `auth` | First message | `token` | | `createOutgoingCall` | Outgoing call setup | `callId`, `peerId` | | `proceed` | After offer/receivedOffer | `callId`, `hideIp`, `iceServers` | | `receivedOffer` | Incoming call | `callId`, `peerId`, `opaque`, `age`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` | @@ -105,7 +94,7 @@ The tunnel performs constant-time comparison. | `accept` | User accepts incoming call | *(none)* | | `hangup` | End the call | *(none)* | -### Tunnel -> signal-cli +### Tunnel -> signal-cli (stdout) | Type | When | Fields | |------|------|--------| @@ -132,20 +121,17 @@ Opaque blobs and identity keys are base64-encoded. ICE servers use the format: signal-cli signal-call-tunnel | | |-- spawn process ------------------> | - | (config JSON on stdin) | - | | initialize - | | bind ctrl.sock + |-- config JSON + newline on stdin ---->| + | | parse config + | | initialize audio | | - |-- connect to ctrl.sock -------------->| - | (retries: 50x @ 200ms) | - |<-------- ready -----------------------| + |<-------- ready (on stdout) -----------| | {"type":"ready", | | "inputDeviceName":"...", | | "outputDeviceName":"..."} | - |-- auth ------------------------------>| - | {"type":"auth","token":""} | - | | constant-time token verify | | + |-- control messages on stdin --------->| + |<-- control events on stdout ----------| ``` --- @@ -159,7 +145,6 @@ signal-cli signal-call-tunnel Remote Phone | | | |-- spawn + config ------->| | |<-- ready ----------------| | - |-- auth ----------------->| | |-- createOutgoingCall --->| | |-- proceed (TURN) ------->| | | | create offer | @@ -183,7 +168,6 @@ signal-cli signal-call-tunnel Remote Phone |<-- offer via Signal --------------------------------| |-- spawn + config ------->| | |<-- ready ----------------| | - |-- auth ----------------->| | |-- receivedOffer -------->| (+ identity keys) | |-- proceed (TURN) ------->| | | | process offer | @@ -311,10 +295,9 @@ during ICE restarts (not during initial connection). Manages the call lifecycle from the Java side: -1. Creates a temp directory and generates a random auth token -2. Spawns `signal-call-tunnel` with config JSON on stdin -3. Connects to the control socket (retries up to 50x at 200 ms intervals), - authenticates, and relays signaling between the tunnel and the Signal protocol +1. Spawns `signal-call-tunnel` and writes config JSON to stdin +2. Keeps stdin open as the control write channel; reads stdout for control events +3. Captures stderr for tunnel logging 4. Parses `inputDeviceName` and `outputDeviceName` from the tunnel's `ready` message and includes them in `CallInfo` 5. Translates tunnel state changes into `CallInfo.State` values and fires @@ -322,7 +305,7 @@ Manages the call lifecycle from the Java side: 6. Defers the `accept` message for incoming calls until the tunnel reports `Ringing` state (sending earlier causes the tunnel to drop it) 7. Schedules a 60-second ring timeout for both incoming and outgoing calls -8. On hangup: sends hangup message, kills the process, deletes the control socket +8. On hangup: sends hangup message, closes stdin, and destroys the process --- @@ -369,14 +352,3 @@ Identity keys in `senderIdentityKey` and `receiverIdentityKey` must be **raw 33-byte serialized form is used instead, SRTP key derivation produces different keys on each side, causing authentication failures. ---- - -## File Layout - -``` -/tmp/sc-/ - ctrl.sock control socket (signal-cli <-> tunnel) -``` - -The control socket is created with mode `0700` on the parent directory. The -directory and its contents are deleted when the call ends. diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java index d876dcd8..b5d4eda5 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java @@ -19,14 +19,9 @@ import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.PrintWriter; -import java.net.StandardProtocolFamily; -import java.net.UnixDomainSocketAddress; -import java.nio.channels.Channels; -import java.nio.channels.SocketChannel; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.attribute.PosixFilePermissions; import java.security.SecureRandom; import java.math.BigInteger; import java.util.ArrayList; @@ -96,18 +91,11 @@ public class CallManager implements AutoCloseable { .resolveRecipientAddress(recipientId) .toApiRecipientAddress(); - // Create per-call socket directory - var callDir = Files.createTempDirectory(Path.of("/tmp"), "sc-"); - Files.setPosixFilePermissions(callDir, PosixFilePermissions.fromString("rwx------")); - var controlSocketPath = callDir.resolve("ctrl.sock").toString(); - var state = new CallState(callId, CallInfo.State.RINGING_OUTGOING, recipientApiAddress, recipient, - true, - controlSocketPath, - callDir); + true); activeCalls.put(callId, state); fireCallEvent(state, null); @@ -238,23 +226,11 @@ public class CallManager implements AutoCloseable { logger.debug("Incoming offer opaque ({} bytes)", opaque == null ? 0 : opaque.length); - Path callDir; - try { - callDir = Files.createTempDirectory(Path.of("/tmp"), "sc-"); - Files.setPosixFilePermissions(callDir, PosixFilePermissions.fromString("rwx------")); - } catch (IOException e) { - logger.warn("Failed to create socket directory for incoming call {}", callId, e); - return; - } - var controlSocketPath = callDir.resolve("ctrl.sock").toString(); - var state = new CallState(callId, CallInfo.State.RINGING_INCOMING, senderAddress, senderIdentifier, - false, - controlSocketPath, - callDir); + false); state.rawOfferOpaque = opaque; activeCalls.put(callId, state); @@ -387,25 +363,43 @@ public class CallManager implements AutoCloseable { private void spawnMediaTunnel(CallState state) { try { var command = new ArrayList<>(List.of(findTunnelBinary())); - // Config is sent via stdin; no --host-audio by default var processBuilder = new ProcessBuilder(command); - processBuilder.redirectErrorStream(true); + // Keep stdout and stderr separate: stdout = control protocol, stderr = logging + processBuilder.redirectErrorStream(false); var process = processBuilder.start(); - // Write config JSON to stdin - var config = buildConfig(state); - try (var stdin = process.getOutputStream()) { - stdin.write(config.getBytes(StandardCharsets.UTF_8)); - stdin.flush(); - } - state.tunnelProcess = process; - // Drain subprocess stdout/stderr to prevent pipe buffer deadlock - Thread.ofVirtual().name("tunnel-output-" + state.callId).start(() -> { + // Write config JSON to stdin, then keep stdin open for control messages + var config = buildConfig(state); + var stdinStream = process.getOutputStream(); + stdinStream.write(config.getBytes(StandardCharsets.UTF_8)); + stdinStream.write('\n'); + stdinStream.flush(); + + // stdin is the control write channel + state.controlWriter = new PrintWriter( + new OutputStreamWriter(stdinStream, StandardCharsets.UTF_8), true); + + // Flush any pending control messages + for (var msg : state.pendingControlMessages) { + state.controlWriter.println(msg); + } + state.pendingControlMessages.clear(); + + // If accept was deferred, send it now + sendAcceptIfReady(state); + + // Read control events from subprocess stdout + Thread.ofVirtual().name("control-read-" + state.callId).start(() -> { + readControlEvents(state, process.getInputStream()); + }); + + // Drain subprocess stderr to prevent pipe buffer deadlock + Thread.ofVirtual().name("tunnel-stderr-" + state.callId).start(() -> { try (var reader = new BufferedReader( - new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { logger.debug("[tunnel-{}] {}", state.callId, line); @@ -414,11 +408,6 @@ public class CallManager implements AutoCloseable { } }); - // Connect to control socket in background - Thread.ofVirtual().name("control-connect-" + state.callId).start(() -> { - connectToControlSocket(state); - }); - // Monitor process exit process.onExit().thenAcceptAsync(p -> { logger.info("Tunnel for call {} exited with code {}", state.callId, p.exitValue()); @@ -471,66 +460,16 @@ public class CallManager implements AutoCloseable { } private String buildConfig(CallState state) { - // Generate control channel authentication token - var tokenBytes = new byte[32]; - new SecureRandom().nextBytes(tokenBytes); - state.controlToken = java.util.Base64.getEncoder().encodeToString(tokenBytes); - var config = mapper.createObjectNode(); config.put("call_id", callIdUnsigned(state.callId)); config.put("is_outgoing", state.isOutgoing); - config.put("control_socket_path", state.controlSocketPath); - config.put("control_token", state.controlToken); config.put("local_device_id", 1); return writeJson(config); } - private void connectToControlSocket(CallState state) { - var socketPath = Path.of(state.controlSocketPath); - var addr = UnixDomainSocketAddress.of(socketPath); - - for (int attempt = 0; attempt < 50; attempt++) { - try { - Thread.sleep(200); - if (!Files.exists(socketPath)) continue; - - var channel = SocketChannel.open(StandardProtocolFamily.UNIX); - channel.connect(addr); - state.controlChannel = channel; - state.controlWriter = new PrintWriter( - new OutputStreamWriter(Channels.newOutputStream(channel), StandardCharsets.UTF_8), true); - - // Send authentication token - var authMsg = mapper.createObjectNode(); - authMsg.put("type", "auth"); - authMsg.put("token", state.controlToken); - state.controlWriter.println(writeJson(authMsg)); - logger.info("Connected to control socket for call {}", state.callId); - - // Flush any pending control messages - for (var msg : state.pendingControlMessages) { - state.controlWriter.println(msg); - } - state.pendingControlMessages.clear(); - - // Start reading control events - Thread.ofVirtual().name("control-read-" + state.callId).start(() -> { - readControlEvents(state); - }); - return; - } catch (IOException e) { - logger.debug("Control socket connect attempt {} failed: {}", attempt, e.getMessage()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - } - logger.warn("Failed to connect to control socket for call {} after retries", state.callId); - } - - private void readControlEvents(CallState state) { + private void readControlEvents(CallState state, java.io.InputStream inputStream) { try (var reader = new BufferedReader( - new InputStreamReader(Channels.newInputStream(state.controlChannel), StandardCharsets.UTF_8))) { + new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { line = line.trim(); @@ -617,6 +556,7 @@ public class CallManager implements AutoCloseable { state.state = CallInfo.State.RINGING_OUTGOING; } else if ("Ringing".equals(ringrtcState)) { // Tunnel is now ready to accept — flush deferred accept if pending + state.tunnelRinging = true; sendAcceptIfReady(state); return; } else if ("Connected".equals(ringrtcState)) { @@ -634,7 +574,7 @@ public class CallManager implements AutoCloseable { } private void sendAcceptIfReady(CallState state) { - if (state.acceptPending && state.controlWriter != null) { + if (state.acceptPending && state.tunnelRinging && state.controlWriter != null) { state.acceptPending = false; logger.debug("Sending deferred accept for call {}", state.callId); var acceptMsg = mapper.createObjectNode(); @@ -792,36 +732,22 @@ public class CallManager implements AutoCloseable { } } - // Send hangup via control channel before killing process + // Send hangup via control channel (stdin) before killing process if (state.controlWriter != null) { try { - state.controlWriter.println("{\"type\":\"hangup\"}"); + var hangupMsg = mapper.createObjectNode(); + hangupMsg.put("type", "hangup"); + state.controlWriter.println(writeJson(hangupMsg)); + state.controlWriter.close(); } catch (Exception e) { logger.debug("Failed to send hangup via control channel", e); } } - // Close control channel - if (state.controlChannel != null) { - try { - state.controlChannel.close(); - } catch (IOException e) { - logger.debug("Failed to close control channel for call {}", callId, e); - } - } - // Kill tunnel process if (state.tunnelProcess != null && state.tunnelProcess.isAlive()) { state.tunnelProcess.destroy(); } - - // Clean up socket directory - try { - Files.deleteIfExists(Path.of(state.controlSocketPath)); - Files.deleteIfExists(state.socketDir); - } catch (IOException e) { - logger.debug("Failed to clean up socket directory for call {}", callId, e); - } } private void handleRingTimeout(final long callId) { @@ -855,37 +781,31 @@ public class CallManager implements AutoCloseable { final org.asamk.signal.manager.api.RecipientAddress recipientAddress; final RecipientIdentifier.Single recipientIdentifier; final boolean isOutgoing; - final String controlSocketPath; - final Path socketDir; volatile String inputDeviceName; volatile String outputDeviceName; volatile Process tunnelProcess; - volatile SocketChannel controlChannel; volatile PrintWriter controlWriter; - volatile String controlToken; // Raw offer opaque for incoming calls (forwarded to subprocess) volatile byte[] rawOfferOpaque; - // Control messages queued before the control channel connects + // Control messages queued before the tunnel process starts final List pendingControlMessages = java.util.Collections.synchronizedList(new ArrayList<>()); // Accept deferred until tunnel reports Ringing state volatile boolean acceptPending = false; + // True once the tunnel has reported "Ringing" (ready to accept) + volatile boolean tunnelRinging = false; CallState( long callId, CallInfo.State state, org.asamk.signal.manager.api.RecipientAddress recipientAddress, RecipientIdentifier.Single recipientIdentifier, - boolean isOutgoing, - String controlSocketPath, - Path socketDir + boolean isOutgoing ) { this.callId = callId; this.state = state; this.recipientAddress = recipientAddress; this.recipientIdentifier = recipientIdentifier; this.isOutgoing = isOutgoing; - this.controlSocketPath = controlSocketPath; - this.socketDir = socketDir; } CallInfo toCallInfo() { diff --git a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java index 70cc45ff..5d009aec 100644 --- a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java +++ b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java @@ -68,9 +68,7 @@ class CallManagerTest { initialState, address, new org.asamk.signal.manager.api.RecipientIdentifier.Number("+15551234567"), - true, - "/tmp/sc-test/ctrl.sock", - Path.of("/tmp/sc-test") + true ); }