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-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-<random>/`). 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-<random>/` (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":"<base64-encoded 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":"<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 ------->| |
|<-- 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-<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.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<String> 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() {

View File

@ -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
);
}