@bitcall/webrtc-sip-gateway 0.3.1 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,12 +3,17 @@
3
3
  Linux-only CLI to install and operate the Bitcall WebRTC-to-SIP gateway.
4
4
 
5
5
  Latest updates:
6
+ - Gateway container now renders Kamailio `#!substdef` defaults from `.env` at
7
+ startup, so advertise/origin/SIP transport values are runtime-accurate.
6
8
  - `init` and `reconfigure` stop an existing stack before preflight checks so
7
9
  the gateway's own `:5060` listener does not trigger false port conflicts.
8
- - `update` now syncs `BITCALL_GATEWAY_IMAGE` to the CLI target image tag
9
- before pulling and restarting.
10
+ - `update` now syncs `BITCALL_GATEWAY_IMAGE` to the CLI target image tag,
11
+ pulls, and force-recreates containers so new image layers are applied.
10
12
  - Docker image includes `sngrep` and `tcpdump` for SIP troubleshooting.
11
- - `sip-trace` opens a live SIP message viewer using `sngrep` in the container.
13
+ - `sip-trace` opens a live SIP message viewer using `sngrep` in the container
14
+ via compose service execution.
15
+ - `TURN_MODE=coturn` now generates a compose stack with a dedicated coturn
16
+ container.
12
17
 
13
18
  ## Install
14
19
 
@@ -16,6 +21,10 @@ Latest updates:
16
21
  sudo npm i -g @bitcall/webrtc-sip-gateway
17
22
  ```
18
23
 
24
+ Host requirement:
25
+ - Use Docker Engine with `docker compose` plugin.
26
+ - Snap `docker-compose` is not supported (cannot access `/opt/bitcall-gateway`).
27
+
19
28
  ## Main workflow
20
29
 
21
30
  ```bash
package/lib/firewall.js CHANGED
@@ -107,11 +107,18 @@ function buildNftRuleset(options = {}) {
107
107
  }
108
108
 
109
109
  function persistIp6tables(d) {
110
+ const aptEnv = {
111
+ ...process.env,
112
+ DEBIAN_FRONTEND: "noninteractive",
113
+ NEEDRESTART_MODE: "a",
114
+ };
115
+
110
116
  if (!d.commandExists("netfilter-persistent")) {
111
- d.run("apt-get", ["update"], { stdio: "inherit" });
117
+ d.run("apt-get", ["update"], { stdio: "inherit", env: aptEnv });
112
118
  d.run("apt-get", ["install", "-y", "netfilter-persistent", "iptables-persistent"], {
113
119
  check: false,
114
120
  stdio: "inherit",
121
+ env: aptEnv,
115
122
  });
116
123
  }
117
124
 
package/lib/system.js CHANGED
@@ -129,8 +129,17 @@ function portInUse(port) {
129
129
  function ensureDockerInstalled(options = {}) {
130
130
  const exec = pickExec(options);
131
131
  const shell = pickShell(options);
132
+ const dockerPath = commandExists("docker")
133
+ ? output("sh", ["-lc", "command -v docker || true"]).trim()
134
+ : "";
132
135
 
133
136
  if (commandExists("docker") && run("docker", ["info"], { check: false }).status === 0) {
137
+ if (dockerPath.startsWith("/snap/")) {
138
+ throw new Error(
139
+ "Detected Docker installed via snap. This setup is not supported for Bitcall gateway. " +
140
+ "Install Docker Engine (docker-ce) with compose plugin (`docker compose`) and retry."
141
+ );
142
+ }
134
143
  return;
135
144
  }
136
145
 
@@ -144,13 +153,45 @@ function ensureDockerInstalled(options = {}) {
144
153
 
145
154
  function ensureComposePlugin(options = {}) {
146
155
  const exec = pickExec(options);
156
+ const dockerComposePath = commandExists("docker-compose")
157
+ ? output("sh", ["-lc", "command -v docker-compose || true"]).trim()
158
+ : "";
159
+ const snapDockerCompose = dockerComposePath.startsWith("/snap/");
147
160
 
148
161
  if (run("docker", ["compose", "version"], { check: false }).status === 0) {
149
162
  return;
150
163
  }
151
164
 
152
- exec("apt-get", ["update"]);
153
- exec("apt-get", ["install", "-y", "docker-compose-plugin"]);
165
+ if (commandExists("docker-compose") && run("docker-compose", ["version"], { check: false }).status === 0) {
166
+ if (snapDockerCompose) {
167
+ try {
168
+ exec("apt-get", ["update"]);
169
+ exec("apt-get", ["install", "-y", "docker-compose-plugin"]);
170
+ } catch (_error) {
171
+ // Fall through to explicit compatibility error below.
172
+ }
173
+
174
+ if (run("docker", ["compose", "version"], { check: false }).status === 0) {
175
+ return;
176
+ }
177
+
178
+ throw new Error(
179
+ "Detected snap docker-compose only. It cannot access /opt/bitcall-gateway due snap confinement. " +
180
+ "Install Docker's compose plugin (`docker compose`) and retry."
181
+ );
182
+ }
183
+ return;
184
+ }
185
+
186
+ try {
187
+ exec("apt-get", ["update"]);
188
+ exec("apt-get", ["install", "-y", "docker-compose-plugin"]);
189
+ } catch (error) {
190
+ if (commandExists("docker-compose") && run("docker-compose", ["version"], { check: false }).status === 0) {
191
+ return;
192
+ }
193
+ throw error;
194
+ }
154
195
  }
155
196
 
156
197
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitcall/webrtc-sip-gateway",
3
- "version": "0.3.1",
3
+ "version": "0.3.4",
4
4
  "description": "Linux CLI for bootstrapping and managing the Bitcall WebRTC-to-SIP Gateway",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.js CHANGED
@@ -185,6 +185,13 @@ function detectComposeCommand(exec = run) {
185
185
  return { command: "docker", prefixArgs: ["compose"] };
186
186
  }
187
187
  if (exec("docker-compose", ["version"], { check: false }).status === 0) {
188
+ const composePath = output("sh", ["-lc", "command -v docker-compose || true"]).trim();
189
+ if (composePath.startsWith("/snap/")) {
190
+ throw new Error(
191
+ "Detected snap docker-compose only. Install Docker compose plugin (`docker compose`) " +
192
+ "because snap docker-compose cannot access /opt/bitcall-gateway."
193
+ );
194
+ }
188
195
  return { command: "docker-compose", prefixArgs: [] };
189
196
  }
190
197
  throw new Error("docker compose not found. Install Docker compose plugin.");
@@ -192,7 +199,7 @@ function detectComposeCommand(exec = run) {
192
199
 
193
200
  function runCompose(args, options = {}, exec = run) {
194
201
  const compose = detectComposeCommand(exec);
195
- return exec(compose.command, [...compose.prefixArgs, ...args], {
202
+ return exec(compose.command, [...compose.prefixArgs, "-f", COMPOSE_PATH, ...args], {
196
203
  cwd: GATEWAY_DIR,
197
204
  ...options,
198
205
  });
@@ -995,9 +1002,13 @@ function renderEnvContent(config, tlsCert, tlsKey) {
995
1002
  return content + extra;
996
1003
  }
997
1004
 
998
- function writeComposeTemplate() {
999
- const compose = readTemplate("docker-compose.yml.template");
1000
- writeFileWithMode(COMPOSE_PATH, compose, 0o644);
1005
+ function writeComposeTemplate(config = {}) {
1006
+ let compose = readTemplate("docker-compose.yml.template").trimEnd();
1007
+ if (config.turnMode === "coturn") {
1008
+ const coturn = readTemplate("coturn-service.yml.template").trimEnd();
1009
+ compose = `${compose}\n\n${coturn}`;
1010
+ }
1011
+ writeFileWithMode(COMPOSE_PATH, `${compose}\n`, 0o644);
1001
1012
  }
1002
1013
 
1003
1014
  function provisionCustomCert(config) {
@@ -1056,12 +1067,40 @@ function installGatewayService(exec = run) {
1056
1067
  exec("systemctl", ["enable", "--now", SERVICE_NAME]);
1057
1068
  }
1058
1069
 
1070
+ function backupExistingGatewayConfig() {
1071
+ if (!fs.existsSync(ENV_PATH) || !fs.existsSync(COMPOSE_PATH)) {
1072
+ return null;
1073
+ }
1074
+ return {
1075
+ envContent: fs.readFileSync(ENV_PATH, "utf8"),
1076
+ composeContent: fs.readFileSync(COMPOSE_PATH, "utf8"),
1077
+ };
1078
+ }
1079
+
1080
+ function restoreGatewayConfigFromBackup(backup, contextLabel = "Operation") {
1081
+ if (!backup || backup.envContent == null || backup.composeContent == null) {
1082
+ return;
1083
+ }
1084
+ try {
1085
+ ensureInstallLayout();
1086
+ writeFileWithMode(ENV_PATH, backup.envContent, 0o600);
1087
+ writeFileWithMode(COMPOSE_PATH, backup.composeContent, 0o644);
1088
+ runCompose(["up", "-d", "--remove-orphans"], { check: false, stdio: "pipe" });
1089
+ console.error(`${contextLabel} failed. Restored previous gateway config and attempted restart.`);
1090
+ } catch (restoreError) {
1091
+ console.error(
1092
+ `${contextLabel} failed and automatic restore also failed: ${restoreError.message}`
1093
+ );
1094
+ }
1095
+ }
1096
+
1059
1097
  async function initCommand(initOptions = {}) {
1060
1098
  printBanner();
1099
+ const previousConfig = backupExistingGatewayConfig();
1061
1100
 
1062
1101
  // If re-running init on an existing installation, stop the current
1063
1102
  // gateway so our own ports don't fail the preflight check.
1064
- if (fs.existsSync(ENV_PATH) && fs.existsSync(COMPOSE_PATH)) {
1103
+ if (previousConfig) {
1065
1104
  console.log("Existing gateway detected. Stopping for re-initialization...\n");
1066
1105
  try {
1067
1106
  runCompose(["down"], { stdio: "pipe" });
@@ -1071,111 +1110,131 @@ async function initCommand(initOptions = {}) {
1071
1110
  }
1072
1111
 
1073
1112
  const ctx = createInstallContext(initOptions);
1113
+ try {
1114
+ let preflight;
1115
+ await ctx.step("Checks", async () => {
1116
+ preflight = await runPreflight(ctx);
1117
+ });
1074
1118
 
1075
- let preflight;
1076
- await ctx.step("Checks", async () => {
1077
- preflight = await runPreflight(ctx);
1078
- });
1079
-
1080
- let existingEnv = {};
1081
- if (fs.existsSync(ENV_PATH)) {
1082
- existingEnv = loadEnvFile(ENV_PATH);
1083
- }
1119
+ let existingEnv = {};
1120
+ if (fs.existsSync(ENV_PATH)) {
1121
+ existingEnv = loadEnvFile(ENV_PATH);
1122
+ }
1084
1123
 
1085
- let config;
1086
- await ctx.step("Config", async () => {
1087
- config = await runWizard(existingEnv, preflight, initOptions);
1088
- });
1124
+ let config;
1125
+ await ctx.step("Config", async () => {
1126
+ config = await runWizard(existingEnv, preflight, initOptions);
1127
+ });
1089
1128
 
1090
- ensureInstallLayout();
1129
+ ensureInstallLayout();
1091
1130
 
1092
- let tlsCertPath;
1093
- let tlsKeyPath;
1131
+ let tlsCertPath;
1132
+ let tlsKeyPath;
1094
1133
 
1095
- await ctx.step("Firewall", async () => {
1096
- if (config.configureUfw) {
1097
- const ufwReady = await ensureUfwAvailable(ctx);
1098
- if (ufwReady) {
1099
- configureFirewall(config, ctx.exec);
1134
+ await ctx.step("Firewall", async () => {
1135
+ if (config.configureUfw) {
1136
+ const ufwReady = await ensureUfwAvailable(ctx);
1137
+ if (ufwReady) {
1138
+ configureFirewall(config, ctx.exec);
1139
+ } else {
1140
+ console.log("Skipping UFW configuration by request.");
1141
+ config.configureUfw = false;
1142
+ printRequiredPorts(config);
1143
+ }
1100
1144
  } else {
1101
- console.log("Skipping UFW configuration by request.");
1102
- config.configureUfw = false;
1103
1145
  printRequiredPorts(config);
1104
1146
  }
1105
- } else {
1106
- printRequiredPorts(config);
1107
- }
1108
1147
 
1109
- if (config.mediaIpv4Only === "1") {
1110
- try {
1111
- const applied = applyMediaIpv4OnlyRules(mediaFirewallOptionsFromConfig(config));
1112
- console.log(`Applied IPv6 media block rules (${applied.backend}).`);
1113
- } catch (error) {
1114
- console.error(`IPv6 media block setup failed: ${error.message}`);
1115
- const proceed = await confirmContinueWithoutMediaBlock();
1116
- if (!proceed) {
1117
- throw new Error("Initialization stopped because media IPv4-only firewall rules were not applied.");
1148
+ if (config.mediaIpv4Only === "1") {
1149
+ try {
1150
+ const applied = applyMediaIpv4OnlyRules(mediaFirewallOptionsFromConfig(config));
1151
+ console.log(`Applied IPv6 media block rules (${applied.backend}).`);
1152
+ } catch (error) {
1153
+ console.error(`IPv6 media block setup failed: ${error.message}`);
1154
+ const proceed = await confirmContinueWithoutMediaBlock();
1155
+ if (!proceed) {
1156
+ throw new Error(
1157
+ "Initialization stopped because media IPv4-only firewall rules were not applied."
1158
+ );
1159
+ }
1160
+ config.mediaIpv4Only = "0";
1161
+ console.log("Continuing without IPv6 media block rules.");
1118
1162
  }
1119
- config.mediaIpv4Only = "0";
1120
- console.log("Continuing without IPv6 media block rules.");
1121
1163
  }
1122
- }
1123
- });
1164
+ });
1124
1165
 
1125
- await ctx.step("TLS", async () => {
1126
- if (config.tlsMode === "custom") {
1127
- const custom = provisionCustomCert(config);
1128
- tlsCertPath = custom.certPath;
1129
- tlsKeyPath = custom.keyPath;
1130
- } else if (config.tlsMode === "dev-self-signed") {
1131
- tlsCertPath = path.join(SSL_DIR, "dev-fullchain.pem");
1132
- tlsKeyPath = path.join(SSL_DIR, "dev-privkey.pem");
1133
- generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 30, ctx.exec);
1134
- console.log("WARNING: Self-signed certificates do not work with browsers.");
1135
- console.log("WebSocket connections will be rejected. Use --dev for local testing only.");
1136
- console.log("For browser access, re-run with Let's Encrypt: sudo bitcall-gateway init");
1137
- } else {
1138
- tlsCertPath = path.join(SSL_DIR, "bootstrap-fullchain.pem");
1139
- tlsKeyPath = path.join(SSL_DIR, "bootstrap-privkey.pem");
1140
- generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 1, ctx.exec);
1141
- }
1166
+ await ctx.step("TLS", async () => {
1167
+ if (config.tlsMode === "custom") {
1168
+ const custom = provisionCustomCert(config);
1169
+ tlsCertPath = custom.certPath;
1170
+ tlsKeyPath = custom.keyPath;
1171
+ } else if (config.tlsMode === "dev-self-signed") {
1172
+ tlsCertPath = path.join(SSL_DIR, "dev-fullchain.pem");
1173
+ tlsKeyPath = path.join(SSL_DIR, "dev-privkey.pem");
1174
+ generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 30, ctx.exec);
1175
+ console.log("WARNING: Self-signed certificates do not work with browsers.");
1176
+ console.log("WebSocket connections will be rejected. Use --dev for local testing only.");
1177
+ console.log("For browser access, re-run with Let's Encrypt: sudo bitcall-gateway init");
1178
+ } else {
1179
+ tlsCertPath = path.join(SSL_DIR, "bootstrap-fullchain.pem");
1180
+ tlsKeyPath = path.join(SSL_DIR, "bootstrap-privkey.pem");
1181
+ generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 1, ctx.exec);
1182
+ }
1142
1183
 
1143
- const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
1144
- writeFileWithMode(ENV_PATH, envContent, 0o600);
1145
- writeComposeTemplate();
1146
- });
1184
+ const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
1185
+ writeFileWithMode(ENV_PATH, envContent, 0o600);
1186
+ writeComposeTemplate(config);
1187
+ });
1147
1188
 
1148
- await ctx.step("Start", async () => {
1149
- startGatewayStack(ctx.exec);
1150
- if (config.tlsMode === "letsencrypt") {
1151
- runLetsEncrypt(config, ctx.exec);
1152
- }
1153
- installGatewayService(ctx.exec);
1154
- });
1189
+ await ctx.step("Start", async () => {
1190
+ startGatewayStack(ctx.exec);
1191
+ if (config.tlsMode === "letsencrypt") {
1192
+ runLetsEncrypt(config, ctx.exec);
1193
+ }
1194
+ installGatewayService(ctx.exec);
1195
+ });
1155
1196
 
1156
- await ctx.step("Done", async () => {});
1197
+ await ctx.step("Done", async () => {});
1157
1198
 
1158
- console.log("");
1159
- console.log(clr(_c.bold + _c.green, " ╔══════════════════════════════════════╗"));
1160
- console.log(clr(_c.bold + _c.green, " ║") + " Gateway is " + clr(_c.bold + _c.green, "READY") + " " + clr(_c.bold + _c.green, "║"));
1161
- console.log(clr(_c.bold + _c.green, " ╚══════════════════════════════════════╝"));
1162
- console.log("");
1163
- console.log(` ${clr(_c.bold, "WSS")} ${clr(_c.cyan, `wss://${config.domain}`)}`);
1164
- if (config.turnMode !== "none") {
1165
- console.log(` ${clr(_c.bold, "TURN")} ${clr(_c.cyan, `https://${config.domain}/turn-credentials`)}`);
1166
- }
1167
- if (!ctx.verbose) {
1168
- console.log(` ${clr(_c.dim, `Log: ${ctx.logPath}`)}`);
1199
+ console.log("");
1200
+ console.log(clr(_c.bold + _c.green, " ╔══════════════════════════════════════╗"));
1201
+ console.log(
1202
+ clr(_c.bold + _c.green, " ") +
1203
+ " Gateway is " +
1204
+ clr(_c.bold + _c.green, "READY") +
1205
+ " " +
1206
+ clr(_c.bold + _c.green, "║")
1207
+ );
1208
+ console.log(clr(_c.bold + _c.green, " ╚══════════════════════════════════════╝"));
1209
+ console.log("");
1210
+ console.log(` ${clr(_c.bold, "WSS")} ${clr(_c.cyan, `wss://${config.domain}`)}`);
1211
+ if (config.turnMode === "coturn") {
1212
+ console.log(` ${clr(_c.bold, "TURN")} ${clr(_c.cyan, `https://${config.domain}/turn-credentials`)}`);
1213
+ console.log(` ${clr(_c.bold, "STUN")} ${clr(_c.cyan, `stun:${config.domain}:${config.turnUdpPort}`)}`);
1214
+ console.log(` ${clr(_c.bold, "TURN")} ${clr(_c.cyan, `turn:${config.domain}:${config.turnUdpPort}`)}`);
1215
+ console.log(
1216
+ ` ${clr(_c.bold, "TURNS")} ${clr(_c.cyan, `turns:${config.domain}:${config.turnsTcpPort}`)}`
1217
+ );
1218
+ }
1219
+ if (!ctx.verbose) {
1220
+ console.log(` ${clr(_c.dim, `Log: ${ctx.logPath}`)}`);
1221
+ }
1222
+ console.log("");
1223
+ } catch (error) {
1224
+ if (previousConfig) {
1225
+ restoreGatewayConfigFromBackup(previousConfig, "Initialization");
1226
+ }
1227
+ throw error;
1169
1228
  }
1170
- console.log("");
1171
1229
  }
1172
1230
 
1173
1231
  async function reconfigureCommand(options) {
1174
1232
  ensureInitialized();
1175
1233
  printBanner();
1234
+ const previousConfig = backupExistingGatewayConfig();
1176
1235
 
1177
1236
  // Stop the running gateway so ports are free for preflight.
1178
- if (fs.existsSync(ENV_PATH) && fs.existsSync(COMPOSE_PATH)) {
1237
+ if (previousConfig) {
1179
1238
  console.log("Stopping current gateway for reconfiguration...\n");
1180
1239
  try {
1181
1240
  runCompose(["down"], { stdio: "pipe" });
@@ -1185,97 +1244,123 @@ async function reconfigureCommand(options) {
1185
1244
  }
1186
1245
 
1187
1246
  const ctx = createInstallContext(options);
1247
+ try {
1248
+ let preflight;
1249
+ await ctx.step("Checks", async () => {
1250
+ preflight = await runPreflight(ctx);
1251
+ });
1188
1252
 
1189
- let preflight;
1190
- await ctx.step("Checks", async () => {
1191
- preflight = await runPreflight(ctx);
1192
- });
1193
-
1194
- const existingEnv = fs.existsSync(ENV_PATH) ? loadEnvFile(ENV_PATH) : {};
1253
+ const existingEnv = fs.existsSync(ENV_PATH) ? loadEnvFile(ENV_PATH) : {};
1195
1254
 
1196
- let config;
1197
- await ctx.step("Config", async () => {
1198
- config = await runWizard(existingEnv, preflight, options);
1199
- });
1255
+ let config;
1256
+ await ctx.step("Config", async () => {
1257
+ config = await runWizard(existingEnv, preflight, options);
1258
+ });
1200
1259
 
1201
- ensureInstallLayout();
1260
+ ensureInstallLayout();
1202
1261
 
1203
- let tlsCertPath;
1204
- let tlsKeyPath;
1262
+ let tlsCertPath;
1263
+ let tlsKeyPath;
1205
1264
 
1206
- await ctx.step("Firewall", async () => {
1207
- if (config.configureUfw) {
1208
- const ufwReady = await ensureUfwAvailable(ctx);
1209
- if (ufwReady) {
1210
- configureFirewall(config, ctx.exec);
1265
+ await ctx.step("Firewall", async () => {
1266
+ if (config.configureUfw) {
1267
+ const ufwReady = await ensureUfwAvailable(ctx);
1268
+ if (ufwReady) {
1269
+ configureFirewall(config, ctx.exec);
1270
+ } else {
1271
+ config.configureUfw = false;
1272
+ printRequiredPorts(config);
1273
+ }
1211
1274
  } else {
1212
- config.configureUfw = false;
1213
1275
  printRequiredPorts(config);
1214
1276
  }
1215
- } else {
1216
- printRequiredPorts(config);
1217
- }
1218
1277
 
1219
- if (config.mediaIpv4Only === "1") {
1220
- try {
1221
- const applied = applyMediaIpv4OnlyRules(mediaFirewallOptionsFromConfig(config));
1222
- console.log(`Applied IPv6 media block rules (${applied.backend}).`);
1223
- } catch (error) {
1224
- console.error(`IPv6 media block setup failed: ${error.message}`);
1225
- config.mediaIpv4Only = "0";
1278
+ if (config.mediaIpv4Only === "1") {
1279
+ try {
1280
+ const applied = applyMediaIpv4OnlyRules(mediaFirewallOptionsFromConfig(config));
1281
+ console.log(`Applied IPv6 media block rules (${applied.backend}).`);
1282
+ } catch (error) {
1283
+ console.error(`IPv6 media block setup failed: ${error.message}`);
1284
+ config.mediaIpv4Only = "0";
1285
+ }
1226
1286
  }
1227
- }
1228
- });
1287
+ });
1229
1288
 
1230
- await ctx.step("TLS", async () => {
1231
- if (config.tlsMode === "custom") {
1232
- const custom = provisionCustomCert(config);
1233
- tlsCertPath = custom.certPath;
1234
- tlsKeyPath = custom.keyPath;
1235
- } else if (config.tlsMode === "dev-self-signed") {
1236
- tlsCertPath = path.join(SSL_DIR, "dev-fullchain.pem");
1237
- tlsKeyPath = path.join(SSL_DIR, "dev-privkey.pem");
1238
- generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 30, ctx.exec);
1239
- } else {
1240
- // Keep existing LE cert if domain matches, otherwise re-provision.
1241
- const envMap = loadEnvFile(ENV_PATH);
1242
- if (envMap.TLS_MODE === "letsencrypt" && envMap.TLS_CERT && fs.existsSync(envMap.TLS_CERT)) {
1243
- tlsCertPath = envMap.TLS_CERT;
1244
- tlsKeyPath = envMap.TLS_KEY;
1289
+ await ctx.step("TLS", async () => {
1290
+ if (config.tlsMode === "custom") {
1291
+ const custom = provisionCustomCert(config);
1292
+ tlsCertPath = custom.certPath;
1293
+ tlsKeyPath = custom.keyPath;
1294
+ } else if (config.tlsMode === "dev-self-signed") {
1295
+ tlsCertPath = path.join(SSL_DIR, "dev-fullchain.pem");
1296
+ tlsKeyPath = path.join(SSL_DIR, "dev-privkey.pem");
1297
+ generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 30, ctx.exec);
1245
1298
  } else {
1246
- tlsCertPath = path.join(SSL_DIR, "bootstrap-fullchain.pem");
1247
- tlsKeyPath = path.join(SSL_DIR, "bootstrap-privkey.pem");
1248
- generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 1, ctx.exec);
1299
+ // Keep existing LE cert only when domain is unchanged and cert files still exist.
1300
+ const envMap = loadEnvFile(ENV_PATH);
1301
+ const sameDomain =
1302
+ (envMap.DOMAIN || "").trim().toLowerCase() === (config.domain || "").trim().toLowerCase();
1303
+ if (
1304
+ envMap.TLS_MODE === "letsencrypt" &&
1305
+ sameDomain &&
1306
+ envMap.TLS_CERT &&
1307
+ envMap.TLS_KEY &&
1308
+ fs.existsSync(envMap.TLS_CERT) &&
1309
+ fs.existsSync(envMap.TLS_KEY)
1310
+ ) {
1311
+ tlsCertPath = envMap.TLS_CERT;
1312
+ tlsKeyPath = envMap.TLS_KEY;
1313
+ } else {
1314
+ tlsCertPath = path.join(SSL_DIR, "bootstrap-fullchain.pem");
1315
+ tlsKeyPath = path.join(SSL_DIR, "bootstrap-privkey.pem");
1316
+ generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 1, ctx.exec);
1317
+ }
1249
1318
  }
1250
- }
1251
1319
 
1252
- const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
1253
- writeFileWithMode(ENV_PATH, envContent, 0o600);
1254
- writeComposeTemplate();
1255
- });
1320
+ const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
1321
+ writeFileWithMode(ENV_PATH, envContent, 0o600);
1322
+ writeComposeTemplate(config);
1323
+ });
1256
1324
 
1257
- await ctx.step("Start", async () => {
1258
- startGatewayStack(ctx.exec);
1259
- if (config.tlsMode === "letsencrypt" && (!tlsCertPath || !tlsCertPath.includes("letsencrypt"))) {
1260
- runLetsEncrypt(config, ctx.exec);
1261
- }
1262
- });
1325
+ await ctx.step("Start", async () => {
1326
+ startGatewayStack(ctx.exec);
1327
+ if (config.tlsMode === "letsencrypt" && (!tlsCertPath || !tlsCertPath.includes("letsencrypt"))) {
1328
+ runLetsEncrypt(config, ctx.exec);
1329
+ }
1330
+ });
1263
1331
 
1264
- await ctx.step("Done", async () => {});
1332
+ await ctx.step("Done", async () => {});
1265
1333
 
1266
- console.log("");
1267
- console.log(clr(_c.bold + _c.green, " ╔══════════════════════════════════════╗"));
1268
- console.log(clr(_c.bold + _c.green, " ║") + " Gateway is " + clr(_c.bold + _c.green, "READY") + " " + clr(_c.bold + _c.green, "║"));
1269
- console.log(clr(_c.bold + _c.green, " ╚══════════════════════════════════════╝"));
1270
- console.log("");
1271
- console.log(` ${clr(_c.bold, "WSS")} ${clr(_c.cyan, `wss://${config.domain}`)}`);
1272
- if (config.turnMode !== "none") {
1273
- console.log(` ${clr(_c.bold, "TURN")} ${clr(_c.cyan, `https://${config.domain}/turn-credentials`)}`);
1274
- }
1275
- if (!ctx.verbose) {
1276
- console.log(` ${clr(_c.dim, `Log: ${ctx.logPath}`)}`);
1334
+ console.log("");
1335
+ console.log(clr(_c.bold + _c.green, " ╔══════════════════════════════════════╗"));
1336
+ console.log(
1337
+ clr(_c.bold + _c.green, " ") +
1338
+ " Gateway is " +
1339
+ clr(_c.bold + _c.green, "READY") +
1340
+ " " +
1341
+ clr(_c.bold + _c.green, "║")
1342
+ );
1343
+ console.log(clr(_c.bold + _c.green, " ╚══════════════════════════════════════╝"));
1344
+ console.log("");
1345
+ console.log(` ${clr(_c.bold, "WSS")} ${clr(_c.cyan, `wss://${config.domain}`)}`);
1346
+ if (config.turnMode === "coturn") {
1347
+ console.log(` ${clr(_c.bold, "TURN")} ${clr(_c.cyan, `https://${config.domain}/turn-credentials`)}`);
1348
+ console.log(` ${clr(_c.bold, "STUN")} ${clr(_c.cyan, `stun:${config.domain}:${config.turnUdpPort}`)}`);
1349
+ console.log(` ${clr(_c.bold, "TURN")} ${clr(_c.cyan, `turn:${config.domain}:${config.turnUdpPort}`)}`);
1350
+ console.log(
1351
+ ` ${clr(_c.bold, "TURNS")} ${clr(_c.cyan, `turns:${config.domain}:${config.turnsTcpPort}`)}`
1352
+ );
1353
+ }
1354
+ if (!ctx.verbose) {
1355
+ console.log(` ${clr(_c.dim, `Log: ${ctx.logPath}`)}`);
1356
+ }
1357
+ console.log("");
1358
+ } catch (error) {
1359
+ if (previousConfig) {
1360
+ restoreGatewayConfigFromBackup(previousConfig, "Reconfigure");
1361
+ }
1362
+ throw error;
1277
1363
  }
1278
- console.log("");
1279
1364
  }
1280
1365
 
1281
1366
  function runSystemctl(args, fallbackComposeArgs) {
@@ -1339,16 +1424,23 @@ function logsCommand(service, options) {
1339
1424
 
1340
1425
  function sipTraceCommand() {
1341
1426
  ensureInitialized();
1342
- const compose = detectComposeCommand();
1343
- const containerName = "bitcall-gateway";
1427
+ const serviceName = "gateway";
1428
+ const containerNameFallback = "bitcall-gateway";
1344
1429
 
1345
1430
  // Check if sngrep is available in the container
1346
- const check = run(
1347
- compose.command,
1348
- [...compose.prefixArgs, "exec", "-T", containerName, "which", "sngrep"],
1349
- { cwd: GATEWAY_DIR, check: false, stdio: "pipe" }
1431
+ let check = runCompose(
1432
+ ["exec", "-T", serviceName, "which", "sngrep"],
1433
+ { check: false, stdio: "pipe" }
1350
1434
  );
1351
1435
 
1436
+ // Backward-compatible fallback for stacks where service naming diverged.
1437
+ if (check.status !== 0) {
1438
+ check = run("docker", ["exec", containerNameFallback, "which", "sngrep"], {
1439
+ check: false,
1440
+ stdio: "pipe",
1441
+ });
1442
+ }
1443
+
1352
1444
  if (check.status !== 0) {
1353
1445
  console.error("sngrep is not available in this gateway image.");
1354
1446
  console.error("Update to the latest image: sudo bitcall-gateway update");
@@ -1356,11 +1448,10 @@ function sipTraceCommand() {
1356
1448
  }
1357
1449
 
1358
1450
  // Run sngrep interactively inside the container
1359
- const result = run(
1360
- compose.command,
1361
- [...compose.prefixArgs, "exec", containerName, "sngrep"],
1362
- { cwd: GATEWAY_DIR, stdio: "inherit" }
1363
- );
1451
+ const result = runCompose(["exec", serviceName, "sngrep"], {
1452
+ check: false,
1453
+ stdio: "inherit",
1454
+ });
1364
1455
 
1365
1456
  process.exit(result.status || 0);
1366
1457
  }
@@ -1384,6 +1475,11 @@ function statusCommand() {
1384
1475
  "docker ps --filter name=^bitcall-gateway$ --format '{{.Status}}'",
1385
1476
  ]);
1386
1477
  const containerUp = Boolean(containerStatus);
1478
+ const coturnStatus = output("sh", [
1479
+ "-lc",
1480
+ "docker ps --filter name=^bitcall-coturn$ --format '{{.Status}}'",
1481
+ ]);
1482
+ const coturnUp = Boolean(coturnStatus);
1387
1483
 
1388
1484
  const p80 = portInUse(Number.parseInt(envMap.ACME_LISTEN_PORT || "80", 10));
1389
1485
  const p443 = portInUse(Number.parseInt(envMap.WSS_LISTEN_PORT || "443", 10));
@@ -1409,6 +1505,9 @@ function statusCommand() {
1409
1505
  console.log(`Gateway service: ${formatMark(active)} ${active ? "running" : "stopped"}`);
1410
1506
  console.log(`Auto-start: ${formatMark(enabled)} ${enabled ? "enabled" : "disabled"}`);
1411
1507
  console.log(`Container: ${formatMark(containerUp)} ${containerStatus || "not running"}`);
1508
+ if (envMap.TURN_MODE === "coturn") {
1509
+ console.log(`Coturn container: ${formatMark(coturnUp)} ${coturnStatus || "not running"}`);
1510
+ }
1412
1511
  console.log("");
1413
1512
  console.log(`Port ${envMap.ACME_LISTEN_PORT || "80"}: ${formatMark(p80.inUse)} listening`);
1414
1513
  console.log(`Port ${envMap.WSS_LISTEN_PORT || "443"}: ${formatMark(p443.inUse)} listening`);
@@ -1531,7 +1630,8 @@ function updateCommand() {
1531
1630
  }
1532
1631
 
1533
1632
  runCompose(["pull"], { stdio: "inherit" });
1534
- runSystemctl(["reload", SERVICE_NAME], ["up", "-d", "--remove-orphans"]);
1633
+ runCompose(["up", "-d", "--force-recreate", "--remove-orphans"], { stdio: "inherit" });
1634
+ run("systemctl", ["start", SERVICE_NAME], { check: false, stdio: "ignore" });
1535
1635
 
1536
1636
  console.log(`\n${clr(_c.green, "✓")} Gateway updated to ${clr(_c.bold, PACKAGE_VERSION)}.`);
1537
1637
  console.log(clr(_c.dim, " To update the CLI: sudo npm i -g @bitcall/webrtc-sip-gateway@latest"));
@@ -1742,7 +1842,16 @@ function buildProgram() {
1742
1842
 
1743
1843
  async function main(argv = process.argv) {
1744
1844
  const program = buildProgram();
1745
- await program.parseAsync(argv);
1845
+ const normalizedArgv = argv.map((value, index) => {
1846
+ if (index <= 1) {
1847
+ return value;
1848
+ }
1849
+ if (value === "--sip-trace" || value === "-sip-trace") {
1850
+ return "sip-trace";
1851
+ }
1852
+ return value;
1853
+ });
1854
+ await program.parseAsync(normalizedArgv);
1746
1855
  }
1747
1856
 
1748
1857
  module.exports = {
@@ -0,0 +1,39 @@
1
+ coturn:
2
+ image: coturn/coturn:latest
3
+ container_name: bitcall-coturn
4
+ network_mode: host
5
+ read_only: true
6
+ tmpfs:
7
+ - /tmp:rw,nosuid,nodev
8
+ security_opt:
9
+ - no-new-privileges:true
10
+ volumes:
11
+ - ${TLS_CERT}:/etc/ssl/cert.pem:ro
12
+ - ${TLS_KEY}:/etc/ssl/key.pem:ro
13
+ command: >
14
+ -n
15
+ --realm=${DOMAIN}
16
+ --external-ip=${PUBLIC_IP}
17
+ --listening-port=${TURN_UDP_PORT:-3478}
18
+ --tls-listening-port=${TURNS_TCP_PORT:-5349}
19
+ --cert=/etc/ssl/cert.pem
20
+ --pkey=/etc/ssl/key.pem
21
+ --use-auth-secret
22
+ --static-auth-secret=${TURN_SECRET}
23
+ --min-port=${TURN_RELAY_MIN_PORT:-49152}
24
+ --max-port=${TURN_RELAY_MAX_PORT:-49252}
25
+ --no-cli
26
+ --denied-peer-ip=0.0.0.0-0.255.255.255
27
+ --denied-peer-ip=10.0.0.0-10.255.255.255
28
+ --denied-peer-ip=127.0.0.0-127.255.255.255
29
+ --denied-peer-ip=172.16.0.0-172.31.255.255
30
+ --denied-peer-ip=192.168.0.0-192.168.255.255
31
+ --channel-lifetime=600
32
+ --permission-lifetime=300
33
+ --stale-nonce=600
34
+ restart: always
35
+ logging:
36
+ driver: json-file
37
+ options:
38
+ max-size: "10m"
39
+ max-file: "3"