mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-05-12 12:20:52 +00:00
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:
parent
8169c9031b
commit
dec5b03487
@ -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.
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user