@bitcall/webrtc-sip-gateway 0.2.4 → 0.2.6

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
@@ -5,7 +5,7 @@ Linux-only CLI to install and operate the Bitcall WebRTC-to-SIP gateway.
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- sudo npm i -g @bitcall/webrtc-sip-gateway@0.2.4
8
+ sudo npm i -g @bitcall/webrtc-sip-gateway@0.2.6
9
9
  ```
10
10
 
11
11
  ## Main workflow
@@ -14,10 +14,13 @@ sudo npm i -g @bitcall/webrtc-sip-gateway@0.2.4
14
14
  sudo bitcall-gateway init
15
15
  sudo bitcall-gateway status
16
16
  sudo bitcall-gateway logs -f
17
+ sudo bitcall-gateway media status
17
18
  ```
18
19
 
19
- Default media policy is IPv4-only candidates (`MEDIA_IPV6=0`). Set
20
- `MEDIA_IPV6=1` in `/opt/bitcall-gateway/.env` only if you want IPv6 candidates.
20
+ Default media policy is IPv4-only via IPv6 media firewall drops on RTP/TURN
21
+ ports only. Host IPv6 remains enabled for signaling and non-media traffic.
22
+ Backend selection prefers nftables on non-UFW hosts and uses ip6tables when UFW
23
+ is active.
21
24
 
22
25
  ## Commands
23
26
 
@@ -31,6 +34,9 @@ Default media policy is IPv4-only candidates (`MEDIA_IPV6=0`). Set
31
34
  - `sudo bitcall-gateway cert renew`
32
35
  - `sudo bitcall-gateway cert install --cert /path/cert.pem --key /path/key.pem`
33
36
  - `sudo bitcall-gateway update`
37
+ - `sudo bitcall-gateway media status`
38
+ - `sudo bitcall-gateway media ipv4-only on`
39
+ - `sudo bitcall-gateway media ipv4-only off`
34
40
  - `sudo bitcall-gateway uninstall`
35
41
 
36
42
  ## Files created by init
package/lib/constants.js CHANGED
@@ -14,7 +14,7 @@ module.exports = {
14
14
  SSL_DIR: path.join(GATEWAY_DIR, "ssl"),
15
15
  ENV_PATH: path.join(GATEWAY_DIR, ".env"),
16
16
  COMPOSE_PATH: path.join(GATEWAY_DIR, "docker-compose.yml"),
17
- DEFAULT_GATEWAY_IMAGE: "ghcr.io/bitcallio/webrtc-sip-gateway:0.2.4",
17
+ DEFAULT_GATEWAY_IMAGE: "ghcr.io/bitcallio/webrtc-sip-gateway:0.2.6",
18
18
  DEFAULT_PROVIDER_HOST: "sip.example.com",
19
19
  DEFAULT_WEBPHONE_ORIGIN: "*",
20
20
  RENEW_HOOK_PATH: "/etc/letsencrypt/renewal-hooks/deploy/bitcall-gateway.sh",
@@ -0,0 +1,357 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ const { run, runShell, output, commandExists } = require("./shell");
7
+
8
+ const MARKER = "bitcall-gateway media ipv6 block";
9
+ const NFT_TABLE = "bitcall_gateway_media_ipv6";
10
+ const NFT_RULE_FILE = "/etc/nftables.d/bitcall-gateway.nft";
11
+ const NFT_MAIN_CONF = "/etc/nftables.conf";
12
+ const NFT_INCLUDE_LINE = `include "${NFT_RULE_FILE}"`;
13
+
14
+ function withDeps(deps = {}) {
15
+ return {
16
+ fs,
17
+ run,
18
+ runShell,
19
+ output,
20
+ commandExists,
21
+ ...deps,
22
+ };
23
+ }
24
+
25
+ function toInt(value, fallback) {
26
+ const parsed = Number.parseInt(String(value), 10);
27
+ return Number.isFinite(parsed) ? parsed : fallback;
28
+ }
29
+
30
+ function normalizeOptions(options = {}) {
31
+ const rtpMin = toInt(options.rtpMin, 10000);
32
+ const rtpMax = toInt(options.rtpMax, 20000);
33
+ const turnEnabled = Boolean(options.turnEnabled);
34
+ const turnUdpPort = toInt(options.turnUdpPort, 3478);
35
+ const turnsTcpPort = toInt(options.turnsTcpPort, 5349);
36
+ const turnRelayMin = toInt(options.turnRelayMin, 49152);
37
+ const turnRelayMax = toInt(options.turnRelayMax, 49252);
38
+
39
+ return {
40
+ rtpMin,
41
+ rtpMax,
42
+ turnEnabled,
43
+ turnUdpPort,
44
+ turnsTcpPort,
45
+ turnRelayMin,
46
+ turnRelayMax,
47
+ };
48
+ }
49
+
50
+ function detectFirewallBackend(deps = {}) {
51
+ const d = withDeps(deps);
52
+ let ufwActive = false;
53
+ if (d.commandExists("ufw")) {
54
+ const ufwStatus = d.run("ufw", ["status"], { check: false, stdio: "pipe" });
55
+ ufwActive = ufwStatus.status === 0 && ((ufwStatus.stdout || "").toLowerCase().includes("status: active"));
56
+ }
57
+
58
+ if (ufwActive && d.commandExists("ip6tables")) {
59
+ return "ip6tables";
60
+ }
61
+
62
+ if (d.commandExists("nft")) {
63
+ return "nft";
64
+ }
65
+ if (d.commandExists("ip6tables")) {
66
+ return "ip6tables";
67
+ }
68
+ throw new Error(
69
+ "No IPv6 firewall backend found. Install nftables or ip6tables/netfilter-persistent."
70
+ );
71
+ }
72
+
73
+ function buildIp6tablesRules(options = {}) {
74
+ const cfg = normalizeOptions(options);
75
+ const rules = [
76
+ { proto: "udp", dport: `${cfg.rtpMin}:${cfg.rtpMax}` },
77
+ ];
78
+
79
+ if (cfg.turnEnabled) {
80
+ rules.push({ proto: "udp", dport: String(cfg.turnUdpPort) });
81
+ rules.push({ proto: "tcp", dport: String(cfg.turnsTcpPort) });
82
+ rules.push({ proto: "udp", dport: `${cfg.turnRelayMin}:${cfg.turnRelayMax}` });
83
+ }
84
+
85
+ return rules;
86
+ }
87
+
88
+ function buildNftRuleset(options = {}) {
89
+ const rules = buildIp6tablesRules(options);
90
+ const lines = [
91
+ `table inet ${NFT_TABLE} {`,
92
+ " chain input {",
93
+ " type filter hook input priority 0; policy accept;",
94
+ ];
95
+
96
+ for (const rule of rules) {
97
+ lines.push(
98
+ ` meta nfproto ipv6 ${rule.proto} dport ${rule.dport} comment \"${MARKER}\" drop`
99
+ );
100
+ }
101
+
102
+ lines.push(" }");
103
+ lines.push("}");
104
+ lines.push("");
105
+
106
+ return lines.join("\n");
107
+ }
108
+
109
+ function persistIp6tables(d) {
110
+ if (!d.commandExists("netfilter-persistent")) {
111
+ d.run("apt-get", ["update"], { stdio: "inherit" });
112
+ d.run("apt-get", ["install", "-y", "netfilter-persistent", "iptables-persistent"], {
113
+ check: false,
114
+ stdio: "inherit",
115
+ });
116
+ }
117
+
118
+ if (!d.commandExists("netfilter-persistent")) {
119
+ throw new Error("Unable to persist ip6tables rules: netfilter-persistent not available.");
120
+ }
121
+
122
+ const saved = d.run("netfilter-persistent", ["save"], {
123
+ check: false,
124
+ stdio: "inherit",
125
+ });
126
+ if (saved.status !== 0) {
127
+ throw new Error("Failed to persist ip6tables rules with netfilter-persistent save.");
128
+ }
129
+ }
130
+
131
+ function ensureNftInclude(d) {
132
+ let content = "";
133
+ if (d.fs.existsSync(NFT_MAIN_CONF)) {
134
+ content = d.fs.readFileSync(NFT_MAIN_CONF, "utf8");
135
+ } else {
136
+ content = "#!/usr/sbin/nft -f\n\n";
137
+ }
138
+
139
+ if (!content.includes(NFT_INCLUDE_LINE)) {
140
+ const suffix = content.endsWith("\n") ? "" : "\n";
141
+ content = `${content}${suffix}${NFT_INCLUDE_LINE}\n`;
142
+ d.fs.writeFileSync(NFT_MAIN_CONF, content, { mode: 0o644 });
143
+ d.fs.chmodSync(NFT_MAIN_CONF, 0o644);
144
+ }
145
+ }
146
+
147
+ function removeNftInclude(d) {
148
+ if (!d.fs.existsSync(NFT_MAIN_CONF)) {
149
+ return;
150
+ }
151
+
152
+ const content = d.fs.readFileSync(NFT_MAIN_CONF, "utf8");
153
+ const lines = content.split("\n").filter((line) => line.trim() !== NFT_INCLUDE_LINE);
154
+ const next = `${lines.join("\n").replace(/\n+$/g, "")}\n`;
155
+ d.fs.writeFileSync(NFT_MAIN_CONF, next, { mode: 0o644 });
156
+ d.fs.chmodSync(NFT_MAIN_CONF, 0o644);
157
+ }
158
+
159
+ function applyNftRules(options, d) {
160
+ d.fs.mkdirSync(path.dirname(NFT_RULE_FILE), { recursive: true, mode: 0o755 });
161
+ d.fs.writeFileSync(NFT_RULE_FILE, buildNftRuleset(options), { mode: 0o644 });
162
+ d.fs.chmodSync(NFT_RULE_FILE, 0o644);
163
+
164
+ d.run("nft", ["delete", "table", "inet", NFT_TABLE], {
165
+ check: false,
166
+ stdio: "ignore",
167
+ });
168
+ d.run("nft", ["-f", NFT_RULE_FILE], { stdio: "inherit" });
169
+
170
+ ensureNftInclude(d);
171
+
172
+ const enabled = d.run("systemctl", ["enable", "--now", "nftables"], {
173
+ check: false,
174
+ stdio: "inherit",
175
+ });
176
+ if (enabled.status !== 0) {
177
+ throw new Error("Failed to enable nftables service for persistent IPv6 media block rules.");
178
+ }
179
+ }
180
+
181
+ function removeNftRules(d) {
182
+ d.run("nft", ["delete", "table", "inet", NFT_TABLE], {
183
+ check: false,
184
+ stdio: "ignore",
185
+ });
186
+
187
+ if (d.fs.existsSync(NFT_RULE_FILE)) {
188
+ d.fs.rmSync(NFT_RULE_FILE, { force: true });
189
+ }
190
+ removeNftInclude(d);
191
+
192
+ d.run("systemctl", ["reload", "nftables"], {
193
+ check: false,
194
+ stdio: "ignore",
195
+ });
196
+ }
197
+
198
+ function ip6tableRuleArgs(rule) {
199
+ return [
200
+ "-p",
201
+ rule.proto,
202
+ "--dport",
203
+ rule.dport,
204
+ "-m",
205
+ "comment",
206
+ "--comment",
207
+ MARKER,
208
+ "-j",
209
+ "DROP",
210
+ ];
211
+ }
212
+
213
+ function applyIp6tablesRules(options, d) {
214
+ const rules = buildIp6tablesRules(options);
215
+
216
+ for (const rule of rules) {
217
+ const args = ip6tableRuleArgs(rule);
218
+ const exists = d.run("ip6tables", ["-C", "INPUT", ...args], {
219
+ check: false,
220
+ stdio: "ignore",
221
+ }).status === 0;
222
+
223
+ if (!exists) {
224
+ d.run("ip6tables", ["-I", "INPUT", "1", ...args], {
225
+ stdio: "inherit",
226
+ });
227
+ }
228
+ }
229
+
230
+ persistIp6tables(d);
231
+ }
232
+
233
+ function removeIp6tablesRules(options, d) {
234
+ const rules = buildIp6tablesRules(options);
235
+
236
+ for (const rule of rules) {
237
+ const args = ip6tableRuleArgs(rule);
238
+ for (;;) {
239
+ const exists = d.run("ip6tables", ["-C", "INPUT", ...args], {
240
+ check: false,
241
+ stdio: "ignore",
242
+ }).status === 0;
243
+
244
+ if (!exists) {
245
+ break;
246
+ }
247
+
248
+ d.run("ip6tables", ["-D", "INPUT", ...args], {
249
+ stdio: "inherit",
250
+ });
251
+ }
252
+ }
253
+
254
+ persistIp6tables(d);
255
+ }
256
+
257
+ function isNftPresent(d) {
258
+ const res = d.run("nft", ["list", "table", "inet", NFT_TABLE], {
259
+ check: false,
260
+ stdio: "pipe",
261
+ });
262
+ if (res.status !== 0) {
263
+ return false;
264
+ }
265
+ return (res.stdout || "").includes(MARKER);
266
+ }
267
+
268
+ function isIp6tablesPresent(d) {
269
+ const rules = d.output("sh", ["-lc", "ip6tables-save 2>/dev/null || true"]);
270
+ return rules.includes(MARKER);
271
+ }
272
+
273
+ function applyMediaIpv4OnlyRules(options = {}, runtime = {}) {
274
+ const d = withDeps(runtime.deps);
275
+ const backend = runtime.backend || detectFirewallBackend(d);
276
+
277
+ if (backend === "nft") {
278
+ applyNftRules(options, d);
279
+ return { backend };
280
+ }
281
+
282
+ if (backend === "ip6tables") {
283
+ applyIp6tablesRules(options, d);
284
+ return { backend };
285
+ }
286
+
287
+ throw new Error(`Unsupported firewall backend: ${backend}`);
288
+ }
289
+
290
+ function removeMediaIpv4OnlyRules(options = {}, runtime = {}) {
291
+ const d = withDeps(runtime.deps);
292
+ const backend = runtime.backend || detectFirewallBackend(d);
293
+
294
+ if (backend === "nft") {
295
+ removeNftRules(d);
296
+ return { backend };
297
+ }
298
+
299
+ if (backend === "ip6tables") {
300
+ removeIp6tablesRules(options, d);
301
+ return { backend };
302
+ }
303
+
304
+ throw new Error(`Unsupported firewall backend: ${backend}`);
305
+ }
306
+
307
+ function isMediaIpv4OnlyRulesPresent(runtime = {}) {
308
+ const d = withDeps(runtime.deps);
309
+ let backend = runtime.backend;
310
+
311
+ try {
312
+ backend = backend || detectFirewallBackend(d);
313
+ } catch (error) {
314
+ return {
315
+ enabled: false,
316
+ backend: null,
317
+ error: error.message,
318
+ };
319
+ }
320
+
321
+ if (backend === "nft") {
322
+ return {
323
+ enabled: isNftPresent(d),
324
+ backend,
325
+ marker: MARKER,
326
+ };
327
+ }
328
+
329
+ if (backend === "ip6tables") {
330
+ return {
331
+ enabled: isIp6tablesPresent(d),
332
+ backend,
333
+ marker: MARKER,
334
+ };
335
+ }
336
+
337
+ return {
338
+ enabled: false,
339
+ backend,
340
+ marker: MARKER,
341
+ };
342
+ }
343
+
344
+ module.exports = {
345
+ MARKER,
346
+ NFT_TABLE,
347
+ NFT_RULE_FILE,
348
+ NFT_MAIN_CONF,
349
+ NFT_INCLUDE_LINE,
350
+ normalizeOptions,
351
+ detectFirewallBackend,
352
+ buildIp6tablesRules,
353
+ buildNftRuleset,
354
+ applyMediaIpv4OnlyRules,
355
+ removeMediaIpv4OnlyRules,
356
+ isMediaIpv4OnlyRulesPresent,
357
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitcall/webrtc-sip-gateway",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Linux CLI for bootstrapping and managing the Bitcall WebRTC-to-SIP Gateway",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "scripts": {
24
24
  "lint": "node --check src/index.js && node --check bin/bitcall-gateway.js && for f in lib/*.js; do node --check \"$f\"; done",
25
- "test": "node test/smoke.test.js",
25
+ "test": "node test/smoke.test.js && node test/firewall.test.js",
26
26
  "pack:dry": "npm pack --dry-run"
27
27
  },
28
28
  "dependencies": {
package/src/index.js CHANGED
@@ -33,8 +33,14 @@ const {
33
33
  ensureComposePlugin,
34
34
  } = require("../lib/system");
35
35
  const { readTemplate, renderTemplate } = require("../lib/template");
36
+ const {
37
+ detectFirewallBackend,
38
+ applyMediaIpv4OnlyRules,
39
+ removeMediaIpv4OnlyRules,
40
+ isMediaIpv4OnlyRulesPresent,
41
+ } = require("../lib/firewall");
36
42
 
37
- const PACKAGE_VERSION = "0.2.4";
43
+ const PACKAGE_VERSION = "0.2.6";
38
44
 
39
45
  function detectComposeCommand() {
40
46
  if (run("docker", ["compose", "version"], { check: false }).status === 0) {
@@ -81,9 +87,11 @@ function envOrder() {
81
87
  return [
82
88
  "GATEWAY_VERSION",
83
89
  "BITCALL_GATEWAY_IMAGE",
90
+ "BITCALL_ENV",
84
91
  "DOMAIN",
85
92
  "PUBLIC_IP",
86
93
  "DEPLOY_MODE",
94
+ "ROUTING_MODE",
87
95
  "SIP_PROVIDER_URI",
88
96
  "SIP_UPSTREAM_TRANSPORT",
89
97
  "SIP_UPSTREAM_PORT",
@@ -103,7 +111,11 @@ function envOrder() {
103
111
  "WSS_LISTEN_PORT",
104
112
  "INTERNAL_WSS_PORT",
105
113
  "INTERNAL_WS_PORT",
106
- "MEDIA_IPV6",
114
+ "MEDIA_IPV4_ONLY",
115
+ "TURN_UDP_PORT",
116
+ "TURNS_TCP_PORT",
117
+ "TURN_RELAY_MIN_PORT",
118
+ "TURN_RELAY_MAX_PORT",
107
119
  "RTPENGINE_MIN_PORT",
108
120
  "RTPENGINE_MAX_PORT",
109
121
  "WITH_REVERSE_PROXY",
@@ -201,6 +213,52 @@ function configureFirewall(config) {
201
213
  run("ufw", ["reload"], { check: false, stdio: "ignore" });
202
214
  }
203
215
 
216
+ function countAllowedDomains(raw) {
217
+ if (!raw) {
218
+ return 0;
219
+ }
220
+ return raw
221
+ .split(",")
222
+ .map((item) => item.trim())
223
+ .filter(Boolean).length;
224
+ }
225
+
226
+ function mediaFirewallOptionsFromConfig(config) {
227
+ return {
228
+ rtpMin: Number.parseInt(config.rtpMin || "10000", 10),
229
+ rtpMax: Number.parseInt(config.rtpMax || "20000", 10),
230
+ turnEnabled: config.turnMode === "coturn",
231
+ turnUdpPort: Number.parseInt(config.turnUdpPort || "3478", 10),
232
+ turnsTcpPort: Number.parseInt(config.turnsTcpPort || "5349", 10),
233
+ turnRelayMin: Number.parseInt(config.turnRelayMinPort || "49152", 10),
234
+ turnRelayMax: Number.parseInt(config.turnRelayMaxPort || "49252", 10),
235
+ };
236
+ }
237
+
238
+ function mediaFirewallOptionsFromEnv(envMap) {
239
+ return {
240
+ rtpMin: Number.parseInt(envMap.RTPENGINE_MIN_PORT || "10000", 10),
241
+ rtpMax: Number.parseInt(envMap.RTPENGINE_MAX_PORT || "20000", 10),
242
+ turnEnabled: (envMap.TURN_MODE || "none") === "coturn",
243
+ turnUdpPort: Number.parseInt(envMap.TURN_UDP_PORT || "3478", 10),
244
+ turnsTcpPort: Number.parseInt(envMap.TURNS_TCP_PORT || "5349", 10),
245
+ turnRelayMin: Number.parseInt(envMap.TURN_RELAY_MIN_PORT || "49152", 10),
246
+ turnRelayMax: Number.parseInt(envMap.TURN_RELAY_MAX_PORT || "49252", 10),
247
+ };
248
+ }
249
+
250
+ async function confirmContinueWithoutMediaBlock() {
251
+ const prompt = new Prompter();
252
+ try {
253
+ return await prompt.askYesNo(
254
+ "Continue without IPv6 media block (may cause no-audio on some networks)?",
255
+ false
256
+ );
257
+ } finally {
258
+ prompt.close();
259
+ }
260
+ }
261
+
204
262
  function generateSelfSigned(certPath, keyPath, domain, days = 1) {
205
263
  ensureDir(path.dirname(certPath), 0o700);
206
264
  run(
@@ -345,136 +403,179 @@ async function runWizard(existing = {}, preflight = {}) {
345
403
  try {
346
404
  const detectedIp = detectPublicIp();
347
405
  const domain = await prompt.askText("Gateway domain", existing.DOMAIN || "", { required: true });
348
- const publicIp = await prompt.askText("Public IP", existing.PUBLIC_IP || detectedIp, {
349
- required: true,
350
- });
406
+ let publicIp = existing.PUBLIC_IP || detectedIp || "";
407
+ if (!publicIp) {
408
+ publicIp = await prompt.askText("Public IPv4 (auto-detect failed)", "", { required: true });
409
+ }
351
410
 
352
411
  const resolved = resolveDomainIpv4(domain);
353
412
  if (resolved.length > 0 && !resolved.includes(publicIp)) {
354
413
  console.log(`Warning: DNS for ${domain} resolves to ${resolved.join(", ")}, not ${publicIp}.`);
355
414
  }
356
415
 
357
- const conflictModeDefault =
416
+ const autoDeployMode =
358
417
  (preflight.p80 && preflight.p80.inUse) || (preflight.p443 && preflight.p443.inUse)
359
- ? 1
360
- : 0;
361
- const deployMode = await prompt.askChoice(
362
- "Deployment mode",
363
- ["standalone", "reverse-proxy"],
364
- conflictModeDefault
365
- );
418
+ ? "reverse-proxy"
419
+ : "standalone";
366
420
 
367
- if (deployMode === "reverse-proxy") {
368
- console.log("Note: forward /turn-credentials and websocket upgrade to 127.0.0.1:8443, and ACME path to 127.0.0.1:8080.");
369
- }
370
-
371
- const tlsMode = await prompt.askChoice(
372
- "TLS certificate mode",
373
- ["letsencrypt", "custom", "dev-self-signed"],
374
- 0
375
- );
421
+ const advanced = await prompt.askYesNo("Advanced setup", false);
376
422
 
423
+ let deployMode = autoDeployMode;
424
+ let tlsMode = "letsencrypt";
377
425
  let acmeEmail = existing.ACME_EMAIL || "";
378
426
  let customCertPath = "";
379
427
  let customKeyPath = "";
428
+ let bitcallEnv = existing.BITCALL_ENV || "production";
429
+ let routingMode = existing.ROUTING_MODE || "universal";
430
+ let sipProviderHost = DEFAULT_PROVIDER_HOST;
431
+ let sipTransport = "udp";
432
+ let sipPort = "5060";
433
+ let sipProviderUri = "";
434
+ let allowedDomains = existing.ALLOWED_SIP_DOMAINS || "";
435
+ let sipTrustedIps = existing.SIP_TRUSTED_IPS || "";
436
+ let turnMode = await prompt.askYesNo("Enable built-in TURN (coturn)?", true) ? "coturn" : "none";
437
+ let turnSecret = "";
438
+ let turnTtl = existing.TURN_TTL || "86400";
439
+ let turnApiToken = existing.TURN_API_TOKEN || "";
440
+ let turnExternalUrls = "";
441
+ let turnExternalUsername = "";
442
+ let turnExternalCredential = "";
443
+ let webphoneOrigin = existing.WEBPHONE_ORIGIN || DEFAULT_WEBPHONE_ORIGIN;
444
+ let configureUfw = true;
445
+ let mediaIpv4Only = existing.MEDIA_IPV4_ONLY ? existing.MEDIA_IPV4_ONLY === "1" : true;
380
446
 
381
- if (tlsMode === "letsencrypt") {
382
- acmeEmail = await prompt.askText("Let's Encrypt email", acmeEmail, { required: true });
383
- } else if (tlsMode === "custom") {
384
- customCertPath = await prompt.askText("Path to TLS certificate (PEM)", existing.TLS_CERT || "", {
385
- required: true,
386
- });
387
- customKeyPath = await prompt.askText("Path to TLS private key (PEM)", existing.TLS_KEY || "", {
388
- required: true,
389
- });
447
+ if (turnMode === "coturn") {
448
+ turnSecret = crypto.randomBytes(32).toString("hex");
390
449
  }
391
450
 
392
- const sipProviderHost = await prompt.askText(
393
- "SIP provider host",
394
- existing.SIP_PROVIDER_URI
395
- ? existing.SIP_PROVIDER_URI.replace(/^sip:/, "").split(":")[0]
396
- : DEFAULT_PROVIDER_HOST,
397
- { required: true }
398
- );
399
-
400
- const sipTransportChoice = await prompt.askChoice(
401
- "SIP provider transport",
402
- ["udp", "tcp", "tls", "custom"],
403
- 0
404
- );
451
+ if (advanced) {
452
+ deployMode = await prompt.askChoice(
453
+ "Deployment mode",
454
+ ["standalone", "reverse-proxy"],
455
+ autoDeployMode === "reverse-proxy" ? 1 : 0
456
+ );
405
457
 
406
- let sipTransport = sipTransportChoice;
407
- let sipPort = "5060";
458
+ tlsMode = await prompt.askChoice(
459
+ "TLS certificate mode",
460
+ ["letsencrypt", "custom", "dev-self-signed"],
461
+ 0
462
+ );
408
463
 
409
- if (sipTransportChoice === "tcp" || sipTransportChoice === "udp") {
410
- sipPort = "5060";
411
- } else if (sipTransportChoice === "tls") {
412
- sipPort = "5061";
413
- } else {
414
- sipTransport = await prompt.askChoice("Custom transport", ["udp", "tcp", "tls"], 0);
415
- const defaultPort = sipTransport === "tls" ? "5061" : "5060";
416
- sipPort = await prompt.askText("Custom SIP port", defaultPort, { required: true });
417
- }
464
+ if (tlsMode === "custom") {
465
+ customCertPath = await prompt.askText("Path to TLS certificate (PEM)", existing.TLS_CERT || "", {
466
+ required: true,
467
+ });
468
+ customKeyPath = await prompt.askText("Path to TLS private key (PEM)", existing.TLS_KEY || "", {
469
+ required: true,
470
+ });
471
+ }
418
472
 
419
- const sipProviderUri = `sip:${sipProviderHost}:${sipPort};transport=${sipTransport}`;
473
+ if (tlsMode === "letsencrypt") {
474
+ acmeEmail = await prompt.askText("Let's Encrypt email", acmeEmail, { required: true });
475
+ } else {
476
+ acmeEmail = "";
477
+ }
420
478
 
421
- const allowedDomains = await prompt.askText(
422
- "Allowed SIP domains (comma-separated; blank enables dev mode)",
423
- existing.ALLOWED_SIP_DOMAINS || sipProviderHost
424
- );
479
+ bitcallEnv = await prompt.askChoice("Environment", ["production", "dev"], bitcallEnv === "dev" ? 1 : 0);
480
+ allowedDomains = await prompt.askText(
481
+ "Allowed SIP domains (comma-separated; required in production)",
482
+ allowedDomains
483
+ );
425
484
 
426
- if (!allowedDomains.trim()) {
427
- console.log("Warning: ALLOWED_SIP_DOMAINS is empty (dev mode, open relay risk).");
428
- }
485
+ routingMode = await prompt.askChoice(
486
+ "Routing mode",
487
+ ["universal", "single-provider"],
488
+ routingMode === "single-provider" ? 1 : 0
489
+ );
429
490
 
430
- const sipTrustedIps = await prompt.askText(
431
- "Trusted SIP source IPs (optional, comma-separated)",
432
- existing.SIP_TRUSTED_IPS || ""
433
- );
491
+ if (routingMode === "single-provider") {
492
+ sipProviderHost = await prompt.askText(
493
+ "SIP provider host",
494
+ existing.SIP_PROVIDER_URI
495
+ ? existing.SIP_PROVIDER_URI.replace(/^sip:/, "").split(":")[0]
496
+ : DEFAULT_PROVIDER_HOST,
497
+ { required: true }
498
+ );
499
+
500
+ const sipTransportChoice = await prompt.askChoice(
501
+ "SIP provider transport",
502
+ ["udp", "tcp", "tls", "custom"],
503
+ 0
504
+ );
505
+
506
+ sipTransport = sipTransportChoice;
507
+ if (sipTransportChoice === "tcp" || sipTransportChoice === "udp") {
508
+ sipPort = "5060";
509
+ } else if (sipTransportChoice === "tls") {
510
+ sipPort = "5061";
511
+ } else {
512
+ sipTransport = await prompt.askChoice("Custom transport", ["udp", "tcp", "tls"], 0);
513
+ sipPort = await prompt.askText(
514
+ "Custom SIP port",
515
+ sipTransport === "tls" ? "5061" : "5060",
516
+ { required: true }
517
+ );
518
+ }
519
+
520
+ sipProviderUri = `sip:${sipProviderHost}:${sipPort};transport=${sipTransport}`;
521
+ }
434
522
 
435
- const turnMode = await prompt.askChoice("TURN mode", ["none", "external", "coturn"], 0);
523
+ sipTrustedIps = await prompt.askText(
524
+ "Trusted SIP source IPs (optional, comma-separated)",
525
+ sipTrustedIps
526
+ );
436
527
 
437
- let turnSecret = existing.TURN_SECRET || "";
438
- let turnTtl = existing.TURN_TTL || "86400";
439
- let turnApiToken = existing.TURN_API_TOKEN || "";
440
- let turnExternalUrls = "";
441
- let turnExternalUsername = "";
442
- let turnExternalCredential = "";
528
+ const turnDefaultIndex = turnMode === "external" ? 2 : turnMode === "coturn" ? 1 : 0;
529
+ turnMode = await prompt.askChoice("TURN mode", ["none", "coturn", "external"], turnDefaultIndex);
530
+ if (turnMode === "coturn") {
531
+ turnSecret = crypto.randomBytes(32).toString("hex");
532
+ turnTtl = await prompt.askText("TURN credential TTL seconds", turnTtl, { required: true });
533
+ turnApiToken = await prompt.askText("TURN API token (optional)", turnApiToken);
534
+ } else if (turnMode === "external") {
535
+ turnSecret = "";
536
+ turnApiToken = "";
537
+ turnExternalUrls = await prompt.askText("External TURN urls", existing.TURN_EXTERNAL_URLS || "", {
538
+ required: true,
539
+ });
540
+ turnExternalUsername = await prompt.askText(
541
+ "External TURN username",
542
+ existing.TURN_EXTERNAL_USERNAME || ""
543
+ );
544
+ turnExternalCredential = await prompt.askText(
545
+ "External TURN credential",
546
+ existing.TURN_EXTERNAL_CREDENTIAL || ""
547
+ );
548
+ } else {
549
+ turnSecret = "";
550
+ turnApiToken = "";
551
+ }
443
552
 
444
- if (turnMode === "external") {
445
- turnExternalUrls = await prompt.askText("External TURN urls", existing.TURN_EXTERNAL_URLS || "", {
446
- required: true,
447
- });
448
- turnExternalUsername = await prompt.askText(
449
- "External TURN username",
450
- existing.TURN_EXTERNAL_USERNAME || ""
553
+ webphoneOrigin = await prompt.askText(
554
+ "Allowed webphone origin (* for any)",
555
+ webphoneOrigin
451
556
  );
452
- turnExternalCredential = await prompt.askText(
453
- "External TURN credential",
454
- existing.TURN_EXTERNAL_CREDENTIAL || ""
557
+
558
+ mediaIpv4Only = await prompt.askYesNo(
559
+ "Media IPv4-only mode (block IPv6 RTP/TURN on host firewall)",
560
+ mediaIpv4Only
455
561
  );
456
- }
457
562
 
458
- if (turnMode === "coturn") {
459
- turnSecret = crypto.randomBytes(32).toString("hex");
460
- turnTtl = await prompt.askText("TURN credential TTL seconds", turnTtl, { required: true });
461
- turnApiToken = await prompt.askText("TURN API token (optional)", turnApiToken);
563
+ configureUfw = await prompt.askYesNo("Configure ufw firewall rules now", true);
462
564
  } else {
463
- turnSecret = "";
464
- turnApiToken = "";
565
+ acmeEmail = await prompt.askText("Let's Encrypt email", acmeEmail, { required: true });
465
566
  }
466
567
 
467
- const webphoneOrigin = await prompt.askText(
468
- "Allowed webphone origin (* for any)",
469
- existing.WEBPHONE_ORIGIN || DEFAULT_WEBPHONE_ORIGIN
470
- );
471
-
472
- const mediaIpv6Enabled = await prompt.askYesNo(
473
- "Enable IPv6 media candidates? (default: No)",
474
- existing.MEDIA_IPV6 === "1"
475
- );
568
+ if (bitcallEnv === "production" && !allowedDomains.trim()) {
569
+ throw new Error(
570
+ "Production mode requires ALLOWED_SIP_DOMAINS. Re-run init with Advanced=Yes and provide allowlisted SIP domains, or switch Environment to dev."
571
+ );
572
+ }
476
573
 
477
- const configureUfw = await prompt.askYesNo("Configure ufw firewall rules now", true);
574
+ if (deployMode === "reverse-proxy") {
575
+ console.log(
576
+ "Note: forward /turn-credentials and websocket upgrade to 127.0.0.1:8443, and ACME path to 127.0.0.1:8080."
577
+ );
578
+ }
478
579
 
479
580
  const config = {
480
581
  domain,
@@ -484,6 +585,8 @@ async function runWizard(existing = {}, preflight = {}) {
484
585
  acmeEmail,
485
586
  customCertPath,
486
587
  customKeyPath,
588
+ bitcallEnv,
589
+ routingMode,
487
590
  sipProviderHost,
488
591
  sipTransport,
489
592
  sipPort,
@@ -498,9 +601,13 @@ async function runWizard(existing = {}, preflight = {}) {
498
601
  turnExternalUsername,
499
602
  turnExternalCredential,
500
603
  webphoneOrigin,
501
- mediaIpv6: mediaIpv6Enabled ? "1" : "0",
502
- rtpMin: "10000",
503
- rtpMax: "20000",
604
+ mediaIpv4Only: mediaIpv4Only ? "1" : "0",
605
+ rtpMin: existing.RTPENGINE_MIN_PORT || "10000",
606
+ rtpMax: existing.RTPENGINE_MAX_PORT || "20000",
607
+ turnUdpPort: existing.TURN_UDP_PORT || "3478",
608
+ turnsTcpPort: existing.TURNS_TCP_PORT || "5349",
609
+ turnRelayMinPort: existing.TURN_RELAY_MIN_PORT || "49152",
610
+ turnRelayMaxPort: existing.TURN_RELAY_MAX_PORT || "49252",
504
611
  acmeListenPort: deployMode === "reverse-proxy" ? "8080" : "80",
505
612
  wssListenPort: deployMode === "reverse-proxy" ? "8443" : "443",
506
613
  internalWssPort: "8443",
@@ -508,15 +615,19 @@ async function runWizard(existing = {}, preflight = {}) {
508
615
  configureUfw,
509
616
  };
510
617
 
618
+ const allowedCount = countAllowedDomains(config.allowedDomains);
511
619
  console.log("\nSummary:");
512
620
  console.log(` Domain: ${config.domain}`);
513
621
  console.log(` Public IP: ${config.publicIp}`);
514
- console.log(` Deploy mode: ${config.deployMode}`);
515
- console.log(` TLS mode: ${config.tlsMode}`);
516
- console.log(` SIP provider URI: ${config.sipProviderUri}`);
517
- console.log(` Allowed SIP domains: ${config.allowedDomains || "(empty/dev-mode)"}`);
518
- console.log(` TURN mode: ${config.turnMode}`);
519
- console.log(` IPv6 media candidates: ${config.mediaIpv6 === "1" ? "enabled" : "disabled (IPv4-only)"}`);
622
+ console.log(` TLS: ${config.tlsMode === "letsencrypt" ? "Let's Encrypt" : config.tlsMode}`);
623
+ console.log(` Deploy mode: ${config.deployMode}${advanced ? "" : " (auto)"}`);
624
+ console.log(` TURN: ${config.turnMode}`);
625
+ console.log(
626
+ ` Media: ${config.mediaIpv4Only === "1" ? "IPv4-only (IPv6 signaling allowed; IPv6 media blocked)" : "dual-stack"}`
627
+ );
628
+ console.log(
629
+ ` Security: Allowed SIP domains ${allowedCount > 0 ? `${allowedCount} entries` : "not set"}`
630
+ );
520
631
 
521
632
  const proceed = await prompt.askYesNo("Proceed with provisioning", true);
522
633
  if (!proceed) {
@@ -543,9 +654,11 @@ function renderEnvContent(config, tlsCert, tlsKey) {
543
654
  const content = renderTemplate(".env.template", {
544
655
  GATEWAY_VERSION: PACKAGE_VERSION,
545
656
  BITCALL_GATEWAY_IMAGE: DEFAULT_GATEWAY_IMAGE,
657
+ BITCALL_ENV: config.bitcallEnv,
546
658
  DOMAIN: config.domain,
547
659
  PUBLIC_IP: config.publicIp,
548
660
  DEPLOY_MODE: config.deployMode,
661
+ ROUTING_MODE: config.routingMode,
549
662
  SIP_PROVIDER_URI: config.sipProviderUri,
550
663
  SIP_UPSTREAM_TRANSPORT: config.sipTransport,
551
664
  SIP_UPSTREAM_PORT: config.sipPort,
@@ -565,7 +678,11 @@ function renderEnvContent(config, tlsCert, tlsKey) {
565
678
  WSS_LISTEN_PORT: config.wssListenPort,
566
679
  INTERNAL_WSS_PORT: config.internalWssPort,
567
680
  INTERNAL_WS_PORT: config.internalWsPort,
568
- MEDIA_IPV6: config.mediaIpv6,
681
+ MEDIA_IPV4_ONLY: config.mediaIpv4Only,
682
+ TURN_UDP_PORT: config.turnUdpPort,
683
+ TURNS_TCP_PORT: config.turnsTcpPort,
684
+ TURN_RELAY_MIN_PORT: config.turnRelayMinPort,
685
+ TURN_RELAY_MAX_PORT: config.turnRelayMaxPort,
569
686
  });
570
687
 
571
688
  let extra = "";
@@ -685,6 +802,22 @@ async function initCommand() {
685
802
  configureFirewall(config);
686
803
  }
687
804
 
805
+ if (config.mediaIpv4Only === "1") {
806
+ try {
807
+ const applied = applyMediaIpv4OnlyRules(mediaFirewallOptionsFromConfig(config));
808
+ console.log(`Applied IPv6 media block rules (${applied.backend}).`);
809
+ } catch (error) {
810
+ console.error(`IPv6 media block setup failed: ${error.message}`);
811
+ const proceed = await confirmContinueWithoutMediaBlock();
812
+ if (!proceed) {
813
+ throw new Error("Initialization stopped because media IPv4-only firewall rules were not applied.");
814
+ }
815
+ updateGatewayEnv({ MEDIA_IPV4_ONLY: "0" });
816
+ config.mediaIpv4Only = "0";
817
+ console.log("Continuing without IPv6 media block rules.");
818
+ }
819
+ }
820
+
688
821
  startGatewayStack();
689
822
 
690
823
  if (config.tlsMode === "letsencrypt") {
@@ -772,6 +905,8 @@ function statusCommand() {
772
905
  }).status === 0;
773
906
  }
774
907
 
908
+ const mediaStatus = isMediaIpv4OnlyRulesPresent();
909
+
775
910
  console.log("Bitcall Gateway Status\n");
776
911
  console.log(`Gateway service: ${formatMark(active)} ${active ? "running" : "stopped"}`);
777
912
  console.log(`Auto-start: ${formatMark(enabled)} ${enabled ? "enabled" : "disabled"}`);
@@ -781,7 +916,17 @@ function statusCommand() {
781
916
  console.log(`Port ${envMap.WSS_LISTEN_PORT || "443"}: ${formatMark(p443.inUse)} listening`);
782
917
  console.log(`Port 5060: ${formatMark(p5060.inUse)} listening`);
783
918
  console.log(`rtpengine control: ${formatMark(rtpReady)} reachable`);
784
- console.log(`IPv6 media candidates: ${(envMap.MEDIA_IPV6 || "0") === "1" ? "enabled" : "disabled (IPv4-only)"}`);
919
+ if (mediaStatus.backend) {
920
+ console.log(`Media IPv4-only: ${mediaStatus.enabled ? "enabled" : "disabled"}`);
921
+ console.log(`Media firewall backend: ${mediaStatus.backend}`);
922
+ } else {
923
+ console.log(`Media IPv4-only: disabled (backend unavailable)`);
924
+ }
925
+ if (!mediaStatus.enabled) {
926
+ console.log(
927
+ "Warning: IPv6 media may cause no-audio on some clients; enable via `bitcall-gateway media ipv4-only on`"
928
+ );
929
+ }
785
930
  if (envMap.TURN_MODE && envMap.TURN_MODE !== "none") {
786
931
  console.log(`/turn-credentials: ${formatMark(turnReady)} reachable`);
787
932
  }
@@ -795,11 +940,13 @@ function statusCommand() {
795
940
 
796
941
  console.log("\nConfig summary:");
797
942
  console.log(`DOMAIN=${envMap.DOMAIN || ""}`);
943
+ console.log(`BITCALL_ENV=${envMap.BITCALL_ENV || "production"}`);
944
+ console.log(`ROUTING_MODE=${envMap.ROUTING_MODE || "universal"}`);
798
945
  console.log(`SIP_PROVIDER_URI=${envMap.SIP_PROVIDER_URI || ""}`);
799
946
  console.log(`DEPLOY_MODE=${envMap.DEPLOY_MODE || ""}`);
800
947
  console.log(`ALLOWED_SIP_DOMAINS=${envMap.ALLOWED_SIP_DOMAINS || ""}`);
801
948
  console.log(`TURN_MODE=${envMap.TURN_MODE || "none"}`);
802
- console.log(`MEDIA_IPV6=${envMap.MEDIA_IPV6 || "0"}`);
949
+ console.log(`MEDIA_IPV4_ONLY=${envMap.MEDIA_IPV4_ONLY || "1"}`);
803
950
  }
804
951
 
805
952
  function certStatusCommand() {
@@ -877,6 +1024,42 @@ function updateCommand() {
877
1024
  runSystemctl(["reload", SERVICE_NAME], ["up", "-d", "--remove-orphans"]);
878
1025
  }
879
1026
 
1027
+ function mediaStatusCommand() {
1028
+ ensureInitialized();
1029
+ const envMap = loadEnvFile(ENV_PATH);
1030
+ const desired = (envMap.MEDIA_IPV4_ONLY || "1") === "1";
1031
+ const state = isMediaIpv4OnlyRulesPresent();
1032
+
1033
+ console.log("Media firewall status\n");
1034
+ console.log(`Configured mode: ${desired ? "IPv4-only" : "dual-stack"}`);
1035
+ if (!state.backend) {
1036
+ console.log(`Backend: unavailable (${state.error || "not detected"})`);
1037
+ console.log("Rules active: no");
1038
+ return;
1039
+ }
1040
+
1041
+ console.log(`Backend: ${state.backend}`);
1042
+ console.log(`Rules active: ${state.enabled ? "yes" : "no"}`);
1043
+ }
1044
+
1045
+ function mediaIpv4OnlyOnCommand() {
1046
+ ensureInitialized();
1047
+ const envMap = loadEnvFile(ENV_PATH);
1048
+ const options = mediaFirewallOptionsFromEnv(envMap);
1049
+ const applied = applyMediaIpv4OnlyRules(options);
1050
+ updateGatewayEnv({ MEDIA_IPV4_ONLY: "1" });
1051
+ console.log(`Enabled IPv6 media block rules (${applied.backend}).`);
1052
+ }
1053
+
1054
+ function mediaIpv4OnlyOffCommand() {
1055
+ ensureInitialized();
1056
+ const envMap = loadEnvFile(ENV_PATH);
1057
+ const options = mediaFirewallOptionsFromEnv(envMap);
1058
+ const removed = removeMediaIpv4OnlyRules(options);
1059
+ updateGatewayEnv({ MEDIA_IPV4_ONLY: "0" });
1060
+ console.log(`Disabled IPv6 media block rules (${removed.backend}).`);
1061
+ }
1062
+
880
1063
  async function uninstallCommand(options) {
881
1064
  if (!options.yes) {
882
1065
  const prompt = new Prompter();
@@ -890,6 +1073,18 @@ async function uninstallCommand(options) {
890
1073
  }
891
1074
  }
892
1075
 
1076
+ let uninstallEnv = {};
1077
+ if (fs.existsSync(ENV_PATH)) {
1078
+ uninstallEnv = loadEnvFile(ENV_PATH);
1079
+ }
1080
+
1081
+ try {
1082
+ const backend = detectFirewallBackend();
1083
+ removeMediaIpv4OnlyRules(mediaFirewallOptionsFromEnv(uninstallEnv), { backend });
1084
+ } catch (error) {
1085
+ console.log(`Warning: failed to remove IPv6 media firewall rules automatically: ${error.message}`);
1086
+ }
1087
+
893
1088
  run("systemctl", ["stop", SERVICE_NAME], { check: false, stdio: "inherit" });
894
1089
  run("systemctl", ["disable", SERVICE_NAME], { check: false, stdio: "inherit" });
895
1090
 
@@ -961,6 +1156,13 @@ function buildProgram() {
961
1156
 
962
1157
  program.command("update").description("Pull latest image and restart service").action(updateCommand);
963
1158
  program.command("config").description("Print active configuration (secrets hidden)").action(configCommand);
1159
+
1160
+ const media = program.command("media").description("Media firewall operations");
1161
+ media.command("status").description("Show media IPv4-only firewall state").action(mediaStatusCommand);
1162
+ const mediaIpv4Only = media.command("ipv4-only").description("Toggle IPv6 media block");
1163
+ mediaIpv4Only.command("on").description("Enable IPv4-only media mode").action(mediaIpv4OnlyOnCommand);
1164
+ mediaIpv4Only.command("off").description("Disable IPv4-only media mode").action(mediaIpv4OnlyOffCommand);
1165
+
964
1166
  program
965
1167
  .command("uninstall")
966
1168
  .description("Remove gateway service and files")
@@ -1,9 +1,11 @@
1
1
  # Generated by bitcall-gateway init
2
2
  GATEWAY_VERSION=__GATEWAY_VERSION__
3
3
  BITCALL_GATEWAY_IMAGE=__BITCALL_GATEWAY_IMAGE__
4
+ BITCALL_ENV=__BITCALL_ENV__
4
5
  DOMAIN=__DOMAIN__
5
6
  PUBLIC_IP=__PUBLIC_IP__
6
7
  DEPLOY_MODE=__DEPLOY_MODE__
8
+ ROUTING_MODE=__ROUTING_MODE__
7
9
  SIP_PROVIDER_URI=__SIP_PROVIDER_URI__
8
10
  SIP_UPSTREAM_TRANSPORT=__SIP_UPSTREAM_TRANSPORT__
9
11
  SIP_UPSTREAM_PORT=__SIP_UPSTREAM_PORT__
@@ -23,4 +25,8 @@ ACME_LISTEN_PORT=__ACME_LISTEN_PORT__
23
25
  WSS_LISTEN_PORT=__WSS_LISTEN_PORT__
24
26
  INTERNAL_WSS_PORT=__INTERNAL_WSS_PORT__
25
27
  INTERNAL_WS_PORT=__INTERNAL_WS_PORT__
26
- MEDIA_IPV6=__MEDIA_IPV6__
28
+ MEDIA_IPV4_ONLY=__MEDIA_IPV4_ONLY__
29
+ TURN_UDP_PORT=__TURN_UDP_PORT__
30
+ TURNS_TCP_PORT=__TURNS_TCP_PORT__
31
+ TURN_RELAY_MIN_PORT=__TURN_RELAY_MIN_PORT__
32
+ TURN_RELAY_MAX_PORT=__TURN_RELAY_MAX_PORT__
@@ -10,8 +10,6 @@ services:
10
10
  security_opt:
11
11
  - no-new-privileges:true
12
12
  env_file: .env
13
- environment:
14
- - MEDIA_IPV6=${MEDIA_IPV6:-0}
15
13
  volumes:
16
14
  - ${TLS_CERT}:/etc/ssl/cert.pem:ro
17
15
  - ${TLS_KEY}:/etc/ssl/key.pem:ro