Replace Unix socket with stdin/stdout for tunnel communication

Use the tunnel subprocess' stdin for sending control messages and
stdout for receiving control events, instead of a separate Unix
domain socket. This eliminates:
- Temporary directory creation (/tmp/sc-<random>/)
- Socket path and auth token in config JSON
- Connection retry loop (50x at 200ms)
- Auth message handshake
- Socket cleanup on call end

The tunnel's stderr is captured separately for logging. Config JSON
is written as the first line on stdin, followed by control messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Shaheen Gandhi 2026-03-18 09:39:05 -07:00
parent 8169c9031b
commit dec5b03487
3 changed files with 80 additions and 190 deletions

View File

@ -4,24 +4,26 @@
signal-cli supports voice calls by spawning a subprocess called signal-cli supports voice calls by spawning a subprocess called
`signal-call-tunnel` for each call. The tunnel handles WebRTC negotiation and `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 audio transport. signal-cli communicates with the tunnel over its stdin/stdout
newline-delimited JSON messages, relaying signaling between the tunnel and the using newline-delimited JSON messages, relaying signaling between the tunnel
Signal protocol. and the Signal protocol.
``` ```
signal-cli signal-call-tunnel signal-cli signal-call-tunnel
| | | |
|-- spawn (config on stdin) --------->| |-- spawn --------------------------->|
|-- config JSON on stdin ------------>|
| | | |
|<======= ctrl.sock (JSON) ==========>| |-- commands on stdin --------------->|
| signaling relay | WebRTC |<-- events on stdout ----------------|
| | audio I/O | | 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 Each call gets its own tunnel process. When the call ends, signal-cli closes
directory (`/tmp/sc-<random>/`). When the call ends, signal-cli kills the stdin and destroys the process.
process and deletes the directory.
Audio device names (`inputDeviceName`, `outputDeviceName`) are opaque strings Audio device names (`inputDeviceName`, `outputDeviceName`) are opaque strings
returned by the tunnel in its `ready` message. signal-cli passes them through 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: For each call, signal-cli:
1. Creates a temporary directory `/tmp/sc-<random>/` (mode `0700`) 1. Spawns `signal-call-tunnel`
2. Generates a random 32-byte auth token 2. Writes config JSON followed by a newline to stdin
3. Spawns `signal-call-tunnel` with config JSON on stdin 3. Keeps stdin open for subsequent control messages
4. Connects to the control socket (retries up to 50x at 200 ms intervals) 4. Reads control events from stdout
5. Authenticates with the auth token 5. Captures stderr for logging
The `signal-call-tunnel` binary is located by searching (in order): 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 ### Config JSON
Written to the tunnel's stdin before it starts: The first line written to the tunnel's stdin:
```json ```json
{ {
"call_id": 12345, "call_id": 12345,
"is_outgoing": true, "is_outgoing": true,
"control_socket_path": "/tmp/sc-a1b2c3/ctrl.sock",
"control_token": "dG9rZW4...",
"local_device_id": 1, "local_device_id": 1,
"input_device_name": "signal_input", "input_device_name": "signal_input",
"output_device_name": "signal_output" "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) | | `call_id` | unsigned 64-bit integer | Call identifier (use unsigned representation) |
| `is_outgoing` | boolean | Whether this is an outgoing call | | `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 | | `local_device_id` | integer | Signal device ID |
| `input_device_name` | string (optional) | Requested input audio device name | | `input_device_name` | string (optional) | Requested input audio device name |
| `output_device_name` | string (optional) | Requested output 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 ### signal-cli -> Tunnel (stdin)
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":"<base64-encoded token>"}
```
### signal-cli -> Tunnel
| Type | When | Fields | | Type | When | Fields |
|------|------|--------| |------|------|--------|
| `auth` | First message | `token` |
| `createOutgoingCall` | Outgoing call setup | `callId`, `peerId` | | `createOutgoingCall` | Outgoing call setup | `callId`, `peerId` |
| `proceed` | After offer/receivedOffer | `callId`, `hideIp`, `iceServers` | | `proceed` | After offer/receivedOffer | `callId`, `hideIp`, `iceServers` |
| `receivedOffer` | Incoming call | `callId`, `peerId`, `opaque`, `age`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` | | `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)* | | `accept` | User accepts incoming call | *(none)* |
| `hangup` | End the call | *(none)* | | `hangup` | End the call | *(none)* |
### Tunnel -> signal-cli ### Tunnel -> signal-cli (stdout)
| Type | When | Fields | | 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 signal-cli signal-call-tunnel
| | | |
|-- spawn process ------------------> | |-- spawn process ------------------> |
| (config JSON on stdin) | |-- config JSON + newline on stdin ---->|
| | initialize | | parse config
| | bind ctrl.sock | | initialize audio
| | | |
|-- connect to ctrl.sock -------------->| |<-------- ready (on stdout) -----------|
| (retries: 50x @ 200ms) |
|<-------- ready -----------------------|
| {"type":"ready", | | {"type":"ready", |
| "inputDeviceName":"...", | | "inputDeviceName":"...", |
| "outputDeviceName":"..."} | | "outputDeviceName":"..."} |
|-- auth ------------------------------>|
| {"type":"auth","token":"<b64>"} |
| | 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 ------->| | |-- spawn + config ------->| |
|<-- ready ----------------| | |<-- ready ----------------| |
|-- auth ----------------->| |
|-- createOutgoingCall --->| | |-- createOutgoingCall --->| |
|-- proceed (TURN) ------->| | |-- proceed (TURN) ------->| |
| | create offer | | | create offer |
@ -183,7 +168,6 @@ signal-cli signal-call-tunnel Remote Phone
|<-- offer via Signal --------------------------------| |<-- offer via Signal --------------------------------|
|-- spawn + config ------->| | |-- spawn + config ------->| |
|<-- ready ----------------| | |<-- ready ----------------| |
|-- auth ----------------->| |
|-- receivedOffer -------->| (+ identity keys) | |-- receivedOffer -------->| (+ identity keys) |
|-- proceed (TURN) ------->| | |-- proceed (TURN) ------->| |
| | process offer | | | process offer |
@ -311,10 +295,9 @@ during ICE restarts (not during initial connection).
Manages the call lifecycle from the Java side: Manages the call lifecycle from the Java side:
1. Creates a temp directory and generates a random auth token 1. Spawns `signal-call-tunnel` and writes config JSON to stdin
2. Spawns `signal-call-tunnel` with config JSON on stdin 2. Keeps stdin open as the control write channel; reads stdout for control events
3. Connects to the control socket (retries up to 50x at 200 ms intervals), 3. Captures stderr for tunnel logging
authenticates, and relays signaling between the tunnel and the Signal protocol
4. Parses `inputDeviceName` and `outputDeviceName` from the tunnel's `ready` 4. Parses `inputDeviceName` and `outputDeviceName` from the tunnel's `ready`
message and includes them in `CallInfo` message and includes them in `CallInfo`
5. Translates tunnel state changes into `CallInfo.State` values and fires 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 6. Defers the `accept` message for incoming calls until the tunnel reports
`Ringing` state (sending earlier causes the tunnel to drop it) `Ringing` state (sending earlier causes the tunnel to drop it)
7. Schedules a 60-second ring timeout for both incoming and outgoing calls 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 33-byte serialized form is used instead, SRTP key derivation produces different
keys on each side, causing authentication failures. keys on each side, causing authentication failures.
---
## File Layout
```
/tmp/sc-<random>/
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.

View File

@ -19,14 +19,9 @@ import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.OutputStreamWriter; import java.io.OutputStreamWriter;
import java.io.PrintWriter; 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.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.ArrayList; import java.util.ArrayList;
@ -96,18 +91,11 @@ public class CallManager implements AutoCloseable {
.resolveRecipientAddress(recipientId) .resolveRecipientAddress(recipientId)
.toApiRecipientAddress(); .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, var state = new CallState(callId,
CallInfo.State.RINGING_OUTGOING, CallInfo.State.RINGING_OUTGOING,
recipientApiAddress, recipientApiAddress,
recipient, recipient,
true, true);
controlSocketPath,
callDir);
activeCalls.put(callId, state); activeCalls.put(callId, state);
fireCallEvent(state, null); fireCallEvent(state, null);
@ -238,23 +226,11 @@ public class CallManager implements AutoCloseable {
logger.debug("Incoming offer opaque ({} bytes)", opaque == null ? 0 : opaque.length); 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, var state = new CallState(callId,
CallInfo.State.RINGING_INCOMING, CallInfo.State.RINGING_INCOMING,
senderAddress, senderAddress,
senderIdentifier, senderIdentifier,
false, false);
controlSocketPath,
callDir);
state.rawOfferOpaque = opaque; state.rawOfferOpaque = opaque;
activeCalls.put(callId, state); activeCalls.put(callId, state);
@ -387,25 +363,43 @@ public class CallManager implements AutoCloseable {
private void spawnMediaTunnel(CallState state) { private void spawnMediaTunnel(CallState state) {
try { try {
var command = new ArrayList<>(List.of(findTunnelBinary())); var command = new ArrayList<>(List.of(findTunnelBinary()));
// Config is sent via stdin; no --host-audio by default
var processBuilder = new ProcessBuilder(command); 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(); 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; state.tunnelProcess = process;
// Drain subprocess stdout/stderr to prevent pipe buffer deadlock // Write config JSON to stdin, then keep stdin open for control messages
Thread.ofVirtual().name("tunnel-output-" + state.callId).start(() -> { 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( try (var reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) {
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
logger.debug("[tunnel-{}] {}", state.callId, line); 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 // Monitor process exit
process.onExit().thenAcceptAsync(p -> { process.onExit().thenAcceptAsync(p -> {
logger.info("Tunnel for call {} exited with code {}", state.callId, p.exitValue()); 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) { 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(); var config = mapper.createObjectNode();
config.put("call_id", callIdUnsigned(state.callId)); config.put("call_id", callIdUnsigned(state.callId));
config.put("is_outgoing", state.isOutgoing); 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); config.put("local_device_id", 1);
return writeJson(config); return writeJson(config);
} }
private void connectToControlSocket(CallState state) { private void readControlEvents(CallState state, java.io.InputStream inputStream) {
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) {
try (var reader = new BufferedReader( try (var reader = new BufferedReader(
new InputStreamReader(Channels.newInputStream(state.controlChannel), StandardCharsets.UTF_8))) { new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
line = line.trim(); line = line.trim();
@ -617,6 +556,7 @@ public class CallManager implements AutoCloseable {
state.state = CallInfo.State.RINGING_OUTGOING; state.state = CallInfo.State.RINGING_OUTGOING;
} else if ("Ringing".equals(ringrtcState)) { } else if ("Ringing".equals(ringrtcState)) {
// Tunnel is now ready to accept flush deferred accept if pending // Tunnel is now ready to accept flush deferred accept if pending
state.tunnelRinging = true;
sendAcceptIfReady(state); sendAcceptIfReady(state);
return; return;
} else if ("Connected".equals(ringrtcState)) { } else if ("Connected".equals(ringrtcState)) {
@ -634,7 +574,7 @@ public class CallManager implements AutoCloseable {
} }
private void sendAcceptIfReady(CallState state) { private void sendAcceptIfReady(CallState state) {
if (state.acceptPending && state.controlWriter != null) { if (state.acceptPending && state.tunnelRinging && state.controlWriter != null) {
state.acceptPending = false; state.acceptPending = false;
logger.debug("Sending deferred accept for call {}", state.callId); logger.debug("Sending deferred accept for call {}", state.callId);
var acceptMsg = mapper.createObjectNode(); 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) { if (state.controlWriter != null) {
try { 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) { } catch (Exception e) {
logger.debug("Failed to send hangup via control channel", 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 // Kill tunnel process
if (state.tunnelProcess != null && state.tunnelProcess.isAlive()) { if (state.tunnelProcess != null && state.tunnelProcess.isAlive()) {
state.tunnelProcess.destroy(); 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) { private void handleRingTimeout(final long callId) {
@ -855,37 +781,31 @@ public class CallManager implements AutoCloseable {
final org.asamk.signal.manager.api.RecipientAddress recipientAddress; final org.asamk.signal.manager.api.RecipientAddress recipientAddress;
final RecipientIdentifier.Single recipientIdentifier; final RecipientIdentifier.Single recipientIdentifier;
final boolean isOutgoing; final boolean isOutgoing;
final String controlSocketPath;
final Path socketDir;
volatile String inputDeviceName; volatile String inputDeviceName;
volatile String outputDeviceName; volatile String outputDeviceName;
volatile Process tunnelProcess; volatile Process tunnelProcess;
volatile SocketChannel controlChannel;
volatile PrintWriter controlWriter; volatile PrintWriter controlWriter;
volatile String controlToken;
// Raw offer opaque for incoming calls (forwarded to subprocess) // Raw offer opaque for incoming calls (forwarded to subprocess)
volatile byte[] rawOfferOpaque; volatile byte[] rawOfferOpaque;
// Control messages queued before the control channel connects // Control messages queued before the tunnel process starts
final List<String> pendingControlMessages = java.util.Collections.synchronizedList(new ArrayList<>()); final List<String> pendingControlMessages = java.util.Collections.synchronizedList(new ArrayList<>());
// Accept deferred until tunnel reports Ringing state // Accept deferred until tunnel reports Ringing state
volatile boolean acceptPending = false; volatile boolean acceptPending = false;
// True once the tunnel has reported "Ringing" (ready to accept)
volatile boolean tunnelRinging = false;
CallState( CallState(
long callId, long callId,
CallInfo.State state, CallInfo.State state,
org.asamk.signal.manager.api.RecipientAddress recipientAddress, org.asamk.signal.manager.api.RecipientAddress recipientAddress,
RecipientIdentifier.Single recipientIdentifier, RecipientIdentifier.Single recipientIdentifier,
boolean isOutgoing, boolean isOutgoing
String controlSocketPath,
Path socketDir
) { ) {
this.callId = callId; this.callId = callId;
this.state = state; this.state = state;
this.recipientAddress = recipientAddress; this.recipientAddress = recipientAddress;
this.recipientIdentifier = recipientIdentifier; this.recipientIdentifier = recipientIdentifier;
this.isOutgoing = isOutgoing; this.isOutgoing = isOutgoing;
this.controlSocketPath = controlSocketPath;
this.socketDir = socketDir;
} }
CallInfo toCallInfo() { CallInfo toCallInfo() {

View File

@ -68,9 +68,7 @@ class CallManagerTest {
initialState, initialState,
address, address,
new org.asamk.signal.manager.api.RecipientIdentifier.Number("+15551234567"), new org.asamk.signal.manager.api.RecipientIdentifier.Number("+15551234567"),
true, true
"/tmp/sc-test/ctrl.sock",
Path.of("/tmp/sc-test")
); );
} }