@bitcall/webrtc-sip-gateway 0.3.0 → 0.3.3

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
@@ -2,12 +2,28 @@
2
2
 
3
3
  Linux-only CLI to install and operate the Bitcall WebRTC-to-SIP gateway.
4
4
 
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.
8
+ - `init` and `reconfigure` stop an existing stack before preflight checks so
9
+ the gateway's own `:5060` listener does not trigger false port conflicts.
10
+ - `update` now syncs `BITCALL_GATEWAY_IMAGE` to the CLI target image tag
11
+ before pulling and restarting.
12
+ - Docker image includes `sngrep` and `tcpdump` for SIP troubleshooting.
13
+ - `sip-trace` opens a live SIP message viewer using `sngrep` in the container.
14
+ - `TURN_MODE=coturn` now generates a compose stack with a dedicated coturn
15
+ container.
16
+
5
17
  ## Install
6
18
 
7
19
  ```bash
8
20
  sudo npm i -g @bitcall/webrtc-sip-gateway
9
21
  ```
10
22
 
23
+ Host requirement:
24
+ - Use Docker Engine with `docker compose` plugin.
25
+ - Snap `docker-compose` is not supported (cannot access `/opt/bitcall-gateway`).
26
+
11
27
  ## Main workflow
12
28
 
13
29
  ```bash
@@ -50,6 +66,7 @@ console output concise and writes command details to
50
66
  - `sudo bitcall-gateway reconfigure`
51
67
  - `sudo bitcall-gateway status`
52
68
  - `sudo bitcall-gateway logs [-f] [service]`
69
+ - `sudo bitcall-gateway sip-trace`
53
70
  - `sudo bitcall-gateway cert status`
54
71
  - `sudo bitcall-gateway cert renew`
55
72
  - `sudo bitcall-gateway cert install --cert /path/cert.pem --key /path/key.pem`
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.0",
3
+ "version": "0.3.3",
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
@@ -3,7 +3,6 @@
3
3
  const crypto = require("crypto");
4
4
  const fs = require("fs");
5
5
  const path = require("path");
6
- const { Command } = require("commander");
7
6
 
8
7
  const {
9
8
  GATEWAY_DIR,
@@ -41,14 +40,65 @@ const {
41
40
  } = require("../lib/firewall");
42
41
 
43
42
  const PACKAGE_VERSION = require("../package.json").version;
43
+ // -- ANSI color helpers (zero dependencies) --
44
+ const _c = {
45
+ reset: "\x1b[0m",
46
+ bold: "\x1b[1m",
47
+ dim: "\x1b[2m",
48
+ red: "\x1b[31m",
49
+ green: "\x1b[32m",
50
+ yellow: "\x1b[33m",
51
+ cyan: "\x1b[36m",
52
+ white: "\x1b[37m",
53
+ };
54
+ const _color = process.stdout.isTTY && !process.env.NO_COLOR;
55
+ function clr(style, text) {
56
+ return _color ? `${style}${text}${_c.reset}` : text;
57
+ }
58
+
44
59
  const INSTALL_LOG_PATH = "/var/log/bitcall-gateway-install.log";
45
60
 
46
61
  function printBanner() {
47
- console.log("========================================");
48
- console.log(" BITCALL WEBRTC-SIP GATEWAY INSTALLER");
49
- console.log(` version ${PACKAGE_VERSION}`);
50
- console.log(" one-line deploy for browser SIP");
51
- console.log("========================================\n");
62
+ const v = `v${PACKAGE_VERSION}`;
63
+
64
+ if (!_color) {
65
+ console.log("\n BITCALL\n WebRTC SIP Gateway " + v);
66
+ console.log(" one-line deploy · any provider\n");
67
+ return;
68
+ }
69
+
70
+ // Block-letter BITCALL — each line is one string, do not modify
71
+ const art = [
72
+ "██████ ██ ████████ ██████ █████ ██ ██",
73
+ "██ ██ ██ ██ ██ ██ ██ ██ ██",
74
+ "██████ ██ ██ ██ ███████ ██ ██",
75
+ "██ ██ ██ ██ ██ ██ ██ ██ ██",
76
+ "██████ ██ ██ ██████ ██ ██ ███████ ███████",
77
+ ];
78
+
79
+ const W = 55; // inner box width
80
+ const top = ` ${_c.cyan}╔${"═".repeat(W)}╗${_c.reset}`;
81
+ const bot = ` ${_c.cyan}╚${"═".repeat(W)}╝${_c.reset}`;
82
+ const blank = ` ${_c.cyan}║${_c.reset}${" ".repeat(W)}${_c.cyan}║${_c.reset}`;
83
+
84
+ function row(content, pad) {
85
+ const visible = content.replace(/\x1b\[[0-9;]*m/g, "");
86
+ const fill = Math.max(0, W - (pad || 0) - visible.length);
87
+ return ` ${_c.cyan}║${_c.reset}${"".padEnd(pad || 0)}${content}${" ".repeat(fill)}${_c.cyan}║${_c.reset}`;
88
+ }
89
+
90
+ console.log("");
91
+ console.log(top);
92
+ console.log(blank);
93
+ for (const line of art) {
94
+ console.log(row(`${_c.bold}${_c.white}${line}${_c.reset}`, 3));
95
+ }
96
+ console.log(blank);
97
+ console.log(row(`${_c.dim}WebRTC → SIP Gateway${_c.reset}`, 3));
98
+ console.log(row(`${_c.dim}one-line deploy · any provider${_c.reset} ${_c.dim}${v}${_c.reset}`, 3));
99
+ console.log(blank);
100
+ console.log(bot);
101
+ console.log("");
52
102
  }
53
103
 
54
104
  function createInstallContext(options = {}) {
@@ -104,15 +154,15 @@ function createInstallContext(options = {}) {
104
154
 
105
155
  async function step(label, fn) {
106
156
  state.index += 1;
107
- console.log(`[${state.index}/${steps.length}] ${label}...`);
157
+ console.log(` ${clr(_c.dim, `[${state.index}/${steps.length}]`)} ${clr(_c.bold, label)}...`);
108
158
  const started = Date.now();
109
159
  try {
110
160
  const value = await fn();
111
161
  const elapsed = ((Date.now() - started) / 1000).toFixed(1);
112
- console.log(`✓ ${label} (${elapsed}s)\n`);
162
+ console.log(` ${clr(_c.green, "✓")} ${clr(_c.bold, label)} ${clr(_c.dim, `(${elapsed}s)`)}\n`);
113
163
  return value;
114
164
  } catch (error) {
115
- console.error(`✗ ${label} failed: ${error.message}`);
165
+ console.error(` ${clr(_c.red, "✗")} ${clr(_c.bold + _c.red, label + " failed:")} ${error.message}`);
116
166
  if (!verbose) {
117
167
  console.error(`Install log: ${INSTALL_LOG_PATH}`);
118
168
  }
@@ -135,6 +185,13 @@ function detectComposeCommand(exec = run) {
135
185
  return { command: "docker", prefixArgs: ["compose"] };
136
186
  }
137
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
+ }
138
195
  return { command: "docker-compose", prefixArgs: [] };
139
196
  }
140
197
  throw new Error("docker compose not found. Install Docker compose plugin.");
@@ -142,7 +199,7 @@ function detectComposeCommand(exec = run) {
142
199
 
143
200
  function runCompose(args, options = {}, exec = run) {
144
201
  const compose = detectComposeCommand(exec);
145
- return exec(compose.command, [...compose.prefixArgs, ...args], {
202
+ return exec(compose.command, [...compose.prefixArgs, "-f", COMPOSE_PATH, ...args], {
146
203
  cwd: GATEWAY_DIR,
147
204
  ...options,
148
205
  });
@@ -602,32 +659,27 @@ async function runPreflight(ctx) {
602
659
  };
603
660
  }
604
661
 
605
- function printSummary(config, securityNotes) {
662
+ function printSummary(config, notes) {
606
663
  const allowedCount = countAllowedDomains(config.allowedDomains);
607
- const providerAllowlistSummary =
608
- allowedCount > 0
609
- ? config.allowedDomains
610
- : isSingleProviderConfigured(config)
611
- ? "(single-provider mode)"
612
- : "(any)";
613
- console.log("\nSummary:");
614
- console.log(` Domain: ${config.domain}`);
615
- console.log(` Environment: ${config.bitcallEnv}`);
616
- console.log(` Routing: ${config.routingMode}`);
617
- console.log(` Provider allowlist: ${providerAllowlistSummary}`);
618
- console.log(` Webphone origin: ${config.webphoneOrigin === "*" ? "(any)" : config.webphoneOrigin}`);
619
- console.log(` SIP source IPs: ${(config.sipTrustedIps || "").trim() ? config.sipTrustedIps : "(any)"}`);
620
- console.log(` TLS: ${config.tlsMode === "letsencrypt" ? "Let's Encrypt" : config.tlsMode}`);
621
- console.log(` TURN: ${config.turnMode === "coturn" ? "enabled (coturn)" : config.turnMode}`);
622
- console.log(
623
- ` Media: ${config.mediaIpv4Only === "1" ? "IPv4-only (IPv6 media blocked)" : "dual-stack"}`
624
- );
625
- console.log(` Firewall: ${config.configureUfw ? "UFW enabled" : "manual setup"}`);
626
-
627
- if (securityNotes.length > 0) {
628
- console.log("\nInfo:");
629
- for (const note of securityNotes) {
630
- console.log(` ℹ ${note}`);
664
+ console.log(`\n${clr(_c.bold + _c.cyan, " ┌─ Configuration ──────────────────────────────┐")}`);
665
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "Domain:")} ${config.domain}`);
666
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "Environment:")} ${config.bitcallEnv}`);
667
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "Routing:")} ${config.routingMode}`);
668
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "Allowlist:")} ${allowedCount > 0 ? config.allowedDomains : "(any)"}`);
669
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "Origin:")} ${config.webphoneOrigin === "*" ? "(any)" : config.webphoneOrigin}`);
670
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "SIP source IPs:")} ${(config.sipTrustedIps || "").trim() ? config.sipTrustedIps : "(any)"}`);
671
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "TLS:")} ${config.tlsMode === "letsencrypt" ? "Let's Encrypt" : config.tlsMode}`);
672
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "TURN:")} ${config.turnMode === "coturn" ? "enabled (coturn)" : config.turnMode}`);
673
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "Media:")} ${config.mediaIpv4Only === "1" ? "IPv4-only (IPv6 media blocked)" : "dual-stack"}`);
674
+ if (config.configureUfw) {
675
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "Firewall:")} UFW enabled`);
676
+ }
677
+ console.log(`${clr(_c.bold + _c.cyan, " └─────────────────────────────────────────────┘")}`);
678
+
679
+ if (notes.length > 0) {
680
+ console.log("");
681
+ for (const note of notes) {
682
+ console.log(` ${clr(_c.dim, "")} ${clr(_c.dim, note)}`);
631
683
  }
632
684
  }
633
685
  }
@@ -950,9 +1002,13 @@ function renderEnvContent(config, tlsCert, tlsKey) {
950
1002
  return content + extra;
951
1003
  }
952
1004
 
953
- function writeComposeTemplate() {
954
- const compose = readTemplate("docker-compose.yml.template");
955
- 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);
956
1012
  }
957
1013
 
958
1014
  function provisionCustomCert(config) {
@@ -1011,187 +1067,300 @@ function installGatewayService(exec = run) {
1011
1067
  exec("systemctl", ["enable", "--now", SERVICE_NAME]);
1012
1068
  }
1013
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
+
1014
1097
  async function initCommand(initOptions = {}) {
1015
1098
  printBanner();
1016
- const ctx = createInstallContext(initOptions);
1099
+ const previousConfig = backupExistingGatewayConfig();
1017
1100
 
1018
- let preflight;
1019
- await ctx.step("Checks", async () => {
1020
- preflight = await runPreflight(ctx);
1021
- });
1022
-
1023
- let existingEnv = {};
1024
- if (fs.existsSync(ENV_PATH)) {
1025
- existingEnv = loadEnvFile(ENV_PATH);
1101
+ // If re-running init on an existing installation, stop the current
1102
+ // gateway so our own ports don't fail the preflight check.
1103
+ if (previousConfig) {
1104
+ console.log("Existing gateway detected. Stopping for re-initialization...\n");
1105
+ try {
1106
+ runCompose(["down"], { stdio: "pipe" });
1107
+ } catch (_e) {
1108
+ // Ignore — gateway may already be stopped or config may be corrupted
1109
+ }
1026
1110
  }
1027
1111
 
1028
- let config;
1029
- await ctx.step("Config", async () => {
1030
- config = await runWizard(existingEnv, preflight, initOptions);
1031
- });
1112
+ const ctx = createInstallContext(initOptions);
1113
+ try {
1114
+ let preflight;
1115
+ await ctx.step("Checks", async () => {
1116
+ preflight = await runPreflight(ctx);
1117
+ });
1118
+
1119
+ let existingEnv = {};
1120
+ if (fs.existsSync(ENV_PATH)) {
1121
+ existingEnv = loadEnvFile(ENV_PATH);
1122
+ }
1032
1123
 
1033
- ensureInstallLayout();
1124
+ let config;
1125
+ await ctx.step("Config", async () => {
1126
+ config = await runWizard(existingEnv, preflight, initOptions);
1127
+ });
1128
+
1129
+ ensureInstallLayout();
1034
1130
 
1035
- let tlsCertPath;
1036
- let tlsKeyPath;
1131
+ let tlsCertPath;
1132
+ let tlsKeyPath;
1037
1133
 
1038
- await ctx.step("Firewall", async () => {
1039
- if (config.configureUfw) {
1040
- const ufwReady = await ensureUfwAvailable(ctx);
1041
- if (ufwReady) {
1042
- 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
+ }
1043
1144
  } else {
1044
- console.log("Skipping UFW configuration by request.");
1045
- config.configureUfw = false;
1046
1145
  printRequiredPorts(config);
1047
1146
  }
1048
- } else {
1049
- printRequiredPorts(config);
1050
- }
1051
1147
 
1052
- if (config.mediaIpv4Only === "1") {
1053
- try {
1054
- const applied = applyMediaIpv4OnlyRules(mediaFirewallOptionsFromConfig(config));
1055
- console.log(`Applied IPv6 media block rules (${applied.backend}).`);
1056
- } catch (error) {
1057
- console.error(`IPv6 media block setup failed: ${error.message}`);
1058
- const proceed = await confirmContinueWithoutMediaBlock();
1059
- if (!proceed) {
1060
- 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.");
1061
1162
  }
1062
- config.mediaIpv4Only = "0";
1063
- console.log("Continuing without IPv6 media block rules.");
1064
1163
  }
1065
- }
1066
- });
1164
+ });
1067
1165
 
1068
- await ctx.step("TLS", async () => {
1069
- if (config.tlsMode === "custom") {
1070
- const custom = provisionCustomCert(config);
1071
- tlsCertPath = custom.certPath;
1072
- tlsKeyPath = custom.keyPath;
1073
- } else if (config.tlsMode === "dev-self-signed") {
1074
- tlsCertPath = path.join(SSL_DIR, "dev-fullchain.pem");
1075
- tlsKeyPath = path.join(SSL_DIR, "dev-privkey.pem");
1076
- generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 30, ctx.exec);
1077
- console.log("WARNING: Self-signed certificates do not work with browsers.");
1078
- console.log("WebSocket connections will be rejected. Use --dev for local testing only.");
1079
- console.log("For browser access, re-run with Let's Encrypt: sudo bitcall-gateway init");
1080
- } else {
1081
- tlsCertPath = path.join(SSL_DIR, "bootstrap-fullchain.pem");
1082
- tlsKeyPath = path.join(SSL_DIR, "bootstrap-privkey.pem");
1083
- generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 1, ctx.exec);
1084
- }
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
+ }
1085
1183
 
1086
- const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
1087
- writeFileWithMode(ENV_PATH, envContent, 0o600);
1088
- writeComposeTemplate();
1089
- });
1184
+ const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
1185
+ writeFileWithMode(ENV_PATH, envContent, 0o600);
1186
+ writeComposeTemplate(config);
1187
+ });
1090
1188
 
1091
- await ctx.step("Start", async () => {
1092
- startGatewayStack(ctx.exec);
1093
- if (config.tlsMode === "letsencrypt") {
1094
- runLetsEncrypt(config, ctx.exec);
1095
- }
1096
- installGatewayService(ctx.exec);
1097
- });
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
+ });
1098
1196
 
1099
- await ctx.step("Done", async () => {});
1197
+ await ctx.step("Done", async () => {});
1100
1198
 
1101
- console.log("\nGateway initialized.");
1102
- console.log(`WSS URL: wss://${config.domain}`);
1103
- if (config.turnMode !== "none") {
1104
- console.log(`TURN credentials URL: https://${config.domain}/turn-credentials`);
1105
- }
1106
- if (!ctx.verbose) {
1107
- console.log(`Install 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;
1108
1228
  }
1109
1229
  }
1110
1230
 
1111
1231
  async function reconfigureCommand(options) {
1112
1232
  ensureInitialized();
1113
1233
  printBanner();
1114
- const ctx = createInstallContext(options);
1234
+ const previousConfig = backupExistingGatewayConfig();
1115
1235
 
1116
- let preflight;
1117
- await ctx.step("Checks", async () => {
1118
- preflight = await runPreflight(ctx);
1119
- });
1236
+ // Stop the running gateway so ports are free for preflight.
1237
+ if (previousConfig) {
1238
+ console.log("Stopping current gateway for reconfiguration...\n");
1239
+ try {
1240
+ runCompose(["down"], { stdio: "pipe" });
1241
+ } catch (_e) {
1242
+ // Ignore — gateway may already be stopped or config may be corrupted
1243
+ }
1244
+ }
1120
1245
 
1121
- const existingEnv = fs.existsSync(ENV_PATH) ? loadEnvFile(ENV_PATH) : {};
1246
+ const ctx = createInstallContext(options);
1247
+ try {
1248
+ let preflight;
1249
+ await ctx.step("Checks", async () => {
1250
+ preflight = await runPreflight(ctx);
1251
+ });
1122
1252
 
1123
- let config;
1124
- await ctx.step("Config", async () => {
1125
- config = await runWizard(existingEnv, preflight, options);
1126
- });
1253
+ const existingEnv = fs.existsSync(ENV_PATH) ? loadEnvFile(ENV_PATH) : {};
1127
1254
 
1128
- ensureInstallLayout();
1255
+ let config;
1256
+ await ctx.step("Config", async () => {
1257
+ config = await runWizard(existingEnv, preflight, options);
1258
+ });
1129
1259
 
1130
- let tlsCertPath;
1131
- let tlsKeyPath;
1260
+ ensureInstallLayout();
1132
1261
 
1133
- await ctx.step("Firewall", async () => {
1134
- if (config.configureUfw) {
1135
- const ufwReady = await ensureUfwAvailable(ctx);
1136
- if (ufwReady) {
1137
- configureFirewall(config, ctx.exec);
1262
+ let tlsCertPath;
1263
+ let tlsKeyPath;
1264
+
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
+ }
1138
1274
  } else {
1139
- config.configureUfw = false;
1140
1275
  printRequiredPorts(config);
1141
1276
  }
1142
- } else {
1143
- printRequiredPorts(config);
1144
- }
1145
1277
 
1146
- if (config.mediaIpv4Only === "1") {
1147
- try {
1148
- const applied = applyMediaIpv4OnlyRules(mediaFirewallOptionsFromConfig(config));
1149
- console.log(`Applied IPv6 media block rules (${applied.backend}).`);
1150
- } catch (error) {
1151
- console.error(`IPv6 media block setup failed: ${error.message}`);
1152
- 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
+ }
1153
1286
  }
1154
- }
1155
- });
1287
+ });
1156
1288
 
1157
- await ctx.step("TLS", async () => {
1158
- if (config.tlsMode === "custom") {
1159
- const custom = provisionCustomCert(config);
1160
- tlsCertPath = custom.certPath;
1161
- tlsKeyPath = custom.keyPath;
1162
- } else if (config.tlsMode === "dev-self-signed") {
1163
- tlsCertPath = path.join(SSL_DIR, "dev-fullchain.pem");
1164
- tlsKeyPath = path.join(SSL_DIR, "dev-privkey.pem");
1165
- generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 30, ctx.exec);
1166
- } else {
1167
- // Keep existing LE cert if domain matches, otherwise re-provision.
1168
- const envMap = loadEnvFile(ENV_PATH);
1169
- if (envMap.TLS_MODE === "letsencrypt" && envMap.TLS_CERT && fs.existsSync(envMap.TLS_CERT)) {
1170
- tlsCertPath = envMap.TLS_CERT;
1171
- 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);
1172
1298
  } else {
1173
- tlsCertPath = path.join(SSL_DIR, "bootstrap-fullchain.pem");
1174
- tlsKeyPath = path.join(SSL_DIR, "bootstrap-privkey.pem");
1175
- 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
+ }
1176
1318
  }
1177
- }
1178
1319
 
1179
- const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
1180
- writeFileWithMode(ENV_PATH, envContent, 0o600);
1181
- writeComposeTemplate();
1182
- });
1320
+ const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
1321
+ writeFileWithMode(ENV_PATH, envContent, 0o600);
1322
+ writeComposeTemplate(config);
1323
+ });
1183
1324
 
1184
- await ctx.step("Start", async () => {
1185
- startGatewayStack(ctx.exec);
1186
- if (config.tlsMode === "letsencrypt" && (!tlsCertPath || !tlsCertPath.includes("letsencrypt"))) {
1187
- runLetsEncrypt(config, ctx.exec);
1188
- }
1189
- });
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
+ });
1190
1331
 
1191
- await ctx.step("Done", async () => {});
1332
+ await ctx.step("Done", async () => {});
1192
1333
 
1193
- console.log("\nGateway reconfigured.");
1194
- console.log(`WSS URL: wss://${config.domain}`);
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;
1363
+ }
1195
1364
  }
1196
1365
 
1197
1366
  function runSystemctl(args, fallbackComposeArgs) {
@@ -1253,8 +1422,36 @@ function logsCommand(service, options) {
1253
1422
  runCompose(args, { stdio: "inherit" });
1254
1423
  }
1255
1424
 
1425
+ function sipTraceCommand() {
1426
+ ensureInitialized();
1427
+ const compose = detectComposeCommand();
1428
+ const containerName = "bitcall-gateway";
1429
+
1430
+ // Check if sngrep is available in the container
1431
+ const check = run(
1432
+ compose.command,
1433
+ [...compose.prefixArgs, "exec", "-T", containerName, "which", "sngrep"],
1434
+ { cwd: GATEWAY_DIR, check: false, stdio: "pipe" }
1435
+ );
1436
+
1437
+ if (check.status !== 0) {
1438
+ console.error("sngrep is not available in this gateway image.");
1439
+ console.error("Update to the latest image: sudo bitcall-gateway update");
1440
+ process.exit(1);
1441
+ }
1442
+
1443
+ // Run sngrep interactively inside the container
1444
+ const result = run(
1445
+ compose.command,
1446
+ [...compose.prefixArgs, "exec", containerName, "sngrep"],
1447
+ { cwd: GATEWAY_DIR, stdio: "inherit" }
1448
+ );
1449
+
1450
+ process.exit(result.status || 0);
1451
+ }
1452
+
1256
1453
  function formatMark(state) {
1257
- return state ? "✓" : "✗";
1454
+ return state ? clr(_c.green, "✓") : clr(_c.red, "✗");
1258
1455
  }
1259
1456
 
1260
1457
  function statusCommand() {
@@ -1271,6 +1468,12 @@ function statusCommand() {
1271
1468
  "-lc",
1272
1469
  "docker ps --filter name=^bitcall-gateway$ --format '{{.Status}}'",
1273
1470
  ]);
1471
+ const containerUp = Boolean(containerStatus);
1472
+ const coturnStatus = output("sh", [
1473
+ "-lc",
1474
+ "docker ps --filter name=^bitcall-coturn$ --format '{{.Status}}'",
1475
+ ]);
1476
+ const coturnUp = Boolean(coturnStatus);
1274
1477
 
1275
1478
  const p80 = portInUse(Number.parseInt(envMap.ACME_LISTEN_PORT || "80", 10));
1276
1479
  const p443 = portInUse(Number.parseInt(envMap.WSS_LISTEN_PORT || "443", 10));
@@ -1292,10 +1495,13 @@ function statusCommand() {
1292
1495
 
1293
1496
  const mediaStatus = isMediaIpv4OnlyRulesPresent();
1294
1497
 
1295
- console.log("Bitcall Gateway Status\n");
1498
+ console.log(`\n${clr(_c.bold + _c.cyan, " Bitcall Gateway Status")}\n`);
1296
1499
  console.log(`Gateway service: ${formatMark(active)} ${active ? "running" : "stopped"}`);
1297
1500
  console.log(`Auto-start: ${formatMark(enabled)} ${enabled ? "enabled" : "disabled"}`);
1298
- console.log(`Container: ${containerStatus || "not running"}`);
1501
+ console.log(`Container: ${formatMark(containerUp)} ${containerStatus || "not running"}`);
1502
+ if (envMap.TURN_MODE === "coturn") {
1503
+ console.log(`Coturn container: ${formatMark(coturnUp)} ${coturnStatus || "not running"}`);
1504
+ }
1299
1505
  console.log("");
1300
1506
  console.log(`Port ${envMap.ACME_LISTEN_PORT || "80"}: ${formatMark(p80.inUse)} listening`);
1301
1507
  console.log(`Port ${envMap.WSS_LISTEN_PORT || "443"}: ${formatMark(p443.inUse)} listening`);
@@ -1410,19 +1616,18 @@ function updateCommand() {
1410
1616
  const targetImage = DEFAULT_GATEWAY_IMAGE;
1411
1617
 
1412
1618
  if (currentImage === targetImage) {
1413
- console.log(`Image is current: ${targetImage}`);
1619
+ console.log(`${clr(_c.green, "✓")} Image is current: ${clr(_c.dim, targetImage)}`);
1414
1620
  console.log("Checking for newer image layers...");
1415
1621
  } else {
1416
- console.log(`Updating image: ${currentImage || "(none)"} → ${targetImage}`);
1622
+ console.log(`${clr(_c.yellow, "→")} Updating: ${clr(_c.dim, currentImage || "(none)")} → ${clr(_c.bold, targetImage)}`);
1417
1623
  updateGatewayEnv({ BITCALL_GATEWAY_IMAGE: targetImage });
1418
1624
  }
1419
1625
 
1420
1626
  runCompose(["pull"], { stdio: "inherit" });
1421
1627
  runSystemctl(["reload", SERVICE_NAME], ["up", "-d", "--remove-orphans"]);
1422
1628
 
1423
- console.log(`\nGateway updated to ${PACKAGE_VERSION}.`);
1424
- console.log("To update the CLI itself:");
1425
- console.log(" sudo npm i -g @bitcall/webrtc-sip-gateway@latest");
1629
+ console.log(`\n${clr(_c.green, "✓")} Gateway updated to ${clr(_c.bold, PACKAGE_VERSION)}.`);
1630
+ console.log(clr(_c.dim, " To update the CLI: sudo npm i -g @bitcall/webrtc-sip-gateway@latest"));
1426
1631
  }
1427
1632
 
1428
1633
  function mediaStatusCommand() {
@@ -1531,12 +1736,12 @@ async function uninstallCommand(options) {
1531
1736
  fs.rmSync(GATEWAY_DIR, { recursive: true, force: true });
1532
1737
  }
1533
1738
 
1534
- console.log("Gateway uninstalled.");
1739
+ console.log(`\n${clr(_c.green, "✓")} ${clr(_c.bold, "Gateway uninstalled.")}`);
1535
1740
  const domain = uninstallEnv.DOMAIN || "";
1536
- console.log("\nTo remove the CLI:");
1741
+ console.log(`\n${clr(_c.dim, "To remove the CLI:")}`);
1537
1742
  console.log(" sudo npm uninstall -g @bitcall/webrtc-sip-gateway");
1538
1743
  if (domain && uninstallEnv.TLS_MODE === "letsencrypt") {
1539
- console.log("\nTo remove Let's Encrypt certificate:");
1744
+ console.log(`\n${clr(_c.dim, "To remove Let's Encrypt certificate:")}`);
1540
1745
  console.log(` sudo certbot delete --cert-name ${domain}`);
1541
1746
  }
1542
1747
  }
@@ -1552,6 +1757,7 @@ function configCommand() {
1552
1757
  }
1553
1758
 
1554
1759
  function buildProgram() {
1760
+ const { Command } = require("commander");
1555
1761
  const program = new Command();
1556
1762
 
1557
1763
  program
@@ -1582,6 +1788,10 @@ function buildProgram() {
1582
1788
  .option("--no-follow", "Print and exit")
1583
1789
  .option("--tail <lines>", "Number of lines", "200")
1584
1790
  .action(logsCommand);
1791
+ program
1792
+ .command("sip-trace")
1793
+ .description("Live SIP message viewer (sngrep)")
1794
+ .action(sipTraceCommand);
1585
1795
 
1586
1796
  const cert = program.command("cert").description("Certificate operations");
1587
1797
  cert.command("status").description("Show current certificate info").action(certStatusCommand);
@@ -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"