@bitcall/webrtc-sip-gateway 0.2.8 → 0.3.1

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,16 +2,24 @@
2
2
 
3
3
  Linux-only CLI to install and operate the Bitcall WebRTC-to-SIP gateway.
4
4
 
5
+ Latest updates:
6
+ - `init` and `reconfigure` stop an existing stack before preflight checks so
7
+ 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
+ - Docker image includes `sngrep` and `tcpdump` for SIP troubleshooting.
11
+ - `sip-trace` opens a live SIP message viewer using `sngrep` in the container.
12
+
5
13
  ## Install
6
14
 
7
15
  ```bash
8
- sudo npm i -g @bitcall/webrtc-sip-gateway@0.2.8
16
+ sudo npm i -g @bitcall/webrtc-sip-gateway
9
17
  ```
10
18
 
11
19
  ## Main workflow
12
20
 
13
21
  ```bash
14
- sudo bitcall-gateway init --dev
22
+ sudo bitcall-gateway init
15
23
  sudo bitcall-gateway status
16
24
  sudo bitcall-gateway logs -f
17
25
  sudo bitcall-gateway media status
@@ -22,14 +30,13 @@ ports only. Host IPv6 remains enabled for signaling and non-media traffic.
22
30
  Backend selection prefers nftables on non-UFW hosts and uses ip6tables when UFW
23
31
  is active.
24
32
 
25
- Default `init` and `init --dev` run in dev profile:
26
- - `BITCALL_ENV=dev`
33
+ Default `init` runs in production profile with universal routing:
34
+ - `BITCALL_ENV=production`
27
35
  - `ROUTING_MODE=universal`
28
- - provider allowlist/origin/source IPs are permissive by default (with warnings)
36
+ - provider allowlist/origin/source IPs are permissive by default
29
37
 
30
- Use `sudo bitcall-gateway init --production` for strict input validation and
31
- hardening checks. Production universal routing requires explicit
32
- `ALLOWED_SIP_DOMAINS`.
38
+ Use `sudo bitcall-gateway init --advanced` for full security/provider controls.
39
+ Use `sudo bitcall-gateway init --dev` for local testing only.
33
40
  Use `--verbose` to stream apt/docker output during install. Default mode keeps
34
41
  console output concise and writes command details to
35
42
  `/var/log/bitcall-gateway-install.log`.
@@ -44,8 +51,14 @@ console output concise and writes command details to
44
51
  - `sudo bitcall-gateway up`
45
52
  - `sudo bitcall-gateway down`
46
53
  - `sudo bitcall-gateway restart`
54
+ - `sudo bitcall-gateway pause`
55
+ - `sudo bitcall-gateway resume`
56
+ - `sudo bitcall-gateway enable`
57
+ - `sudo bitcall-gateway disable`
58
+ - `sudo bitcall-gateway reconfigure`
47
59
  - `sudo bitcall-gateway status`
48
60
  - `sudo bitcall-gateway logs [-f] [service]`
61
+ - `sudo bitcall-gateway sip-trace`
49
62
  - `sudo bitcall-gateway cert status`
50
63
  - `sudo bitcall-gateway cert renew`
51
64
  - `sudo bitcall-gateway cert install --cert /path/cert.pem --key /path/key.pem`
package/lib/constants.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const path = require("path");
4
+ const PKG_VERSION = require("../package.json").version;
4
5
 
5
6
  const GATEWAY_DIR = "/opt/bitcall-gateway";
6
7
  const SERVICE_NAME = "bitcall-gateway";
@@ -14,7 +15,7 @@ module.exports = {
14
15
  SSL_DIR: path.join(GATEWAY_DIR, "ssl"),
15
16
  ENV_PATH: path.join(GATEWAY_DIR, ".env"),
16
17
  COMPOSE_PATH: path.join(GATEWAY_DIR, "docker-compose.yml"),
17
- DEFAULT_GATEWAY_IMAGE: "ghcr.io/bitcallio/webrtc-sip-gateway:0.2.8",
18
+ DEFAULT_GATEWAY_IMAGE: `ghcr.io/bitcallio/webrtc-sip-gateway:${PKG_VERSION}`,
18
19
  DEFAULT_PROVIDER_HOST: "sip.example.com",
19
20
  DEFAULT_WEBPHONE_ORIGIN: "*",
20
21
  RENEW_HOOK_PATH: "/etc/letsencrypt/renewal-hooks/deploy/bitcall-gateway.sh",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitcall/webrtc-sip-gateway",
3
- "version": "0.2.8",
3
+ "version": "0.3.1",
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,
@@ -40,15 +39,66 @@ const {
40
39
  isMediaIpv4OnlyRulesPresent,
41
40
  } = require("../lib/firewall");
42
41
 
43
- const PACKAGE_VERSION = "0.2.8";
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
  }
@@ -327,36 +377,35 @@ function isOriginWildcard(originPattern) {
327
377
  }
328
378
 
329
379
  function validateProductionConfig(config) {
330
- const hasAllowedDomains = countAllowedDomains(config.allowedDomains) > 0;
331
- const hasSingleProvider = isSingleProviderConfigured(config);
332
- if (!hasAllowedDomains && !hasSingleProvider) {
333
- throw new Error(
334
- "Production requires ALLOWED_SIP_DOMAINS or single-provider routing with SIP_PROVIDER_URI. Re-run: bitcall-gateway init --advanced --production"
335
- );
380
+ // In production, validate that the config is internally consistent.
381
+ // Universal mode (open domains, open origin) is a valid production config.
382
+
383
+ // Single-provider mode requires a valid SIP_PROVIDER_URI
384
+ if (config.routingMode === "single-provider") {
385
+ if (!(config.sipProviderUri || "").trim()) {
386
+ throw new Error(
387
+ "Single-provider mode requires SIP_PROVIDER_URI. " +
388
+ "Re-run: bitcall-gateway init --advanced --production"
389
+ );
390
+ }
336
391
  }
337
392
 
338
- const originPattern = toOriginPattern(config.webphoneOrigin);
339
- const hasStrictOrigin = !isOriginWildcard(originPattern);
340
- const hasTurnToken = Boolean((config.turnApiToken || "").trim());
341
- if (!hasStrictOrigin && !hasTurnToken) {
342
- throw new Error(
343
- "Production requires a strict WEBPHONE_ORIGIN_PATTERN or TURN_API_TOKEN. Re-run: bitcall-gateway init --advanced --production"
344
- );
345
- }
393
+ // If custom cert mode, paths are required (already validated elsewhere)
394
+ // No other hard requirements for production.
346
395
  }
347
396
 
348
- function buildDevWarnings(config) {
349
- const warnings = [];
397
+ function buildSecurityNotes(config) {
398
+ const notes = [];
350
399
  if (countAllowedDomains(config.allowedDomains) === 0) {
351
- warnings.push("Provider allowlist: any (DEV mode)");
400
+ notes.push("SIP domains: unrestricted (universal mode)");
352
401
  }
353
402
  if (isOriginWildcard(toOriginPattern(config.webphoneOrigin))) {
354
- warnings.push("Webphone origin: any (DEV mode)");
403
+ notes.push("Webphone origin: unrestricted");
355
404
  }
356
405
  if (!(config.sipTrustedIps || "").trim()) {
357
- warnings.push("Trusted SIP source IPs: any (DEV mode)");
406
+ notes.push("SIP source IPs: unrestricted");
358
407
  }
359
- return warnings;
408
+ return notes;
360
409
  }
361
410
 
362
411
  function buildQuickFlowDefaults(initProfile, existing = {}) {
@@ -371,12 +420,15 @@ function buildQuickFlowDefaults(initProfile, existing = {}) {
371
420
  sipTrustedIps: existing.SIP_TRUSTED_IPS || "",
372
421
  };
373
422
 
423
+ // Dev mode: explicitly open everything (ignore existing restrictions)
374
424
  if (initProfile === "dev") {
375
425
  defaults.allowedDomains = "";
376
426
  defaults.webphoneOrigin = "*";
377
427
  defaults.sipTrustedIps = "";
378
428
  }
379
429
 
430
+ // Production mode: use existing values or defaults (already universal)
431
+ // Do not force restrictions here.
380
432
  return defaults;
381
433
  }
382
434
 
@@ -545,7 +597,6 @@ function toOriginPattern(origin) {
545
597
  }
546
598
 
547
599
  function normalizeInitProfile(initOptions = {}, existing = {}) {
548
- void existing;
549
600
  if (initOptions.dev && initOptions.production) {
550
601
  throw new Error("Use only one mode: --dev or --production.");
551
602
  }
@@ -555,7 +606,7 @@ function normalizeInitProfile(initOptions = {}, existing = {}) {
555
606
  if (initOptions.dev) {
556
607
  return "dev";
557
608
  }
558
- return "dev";
609
+ return existing.BITCALL_ENV || "production";
559
610
  }
560
611
 
561
612
  async function runPreflight(ctx) {
@@ -601,45 +652,35 @@ async function runPreflight(ctx) {
601
652
  };
602
653
  }
603
654
 
604
- function printSummary(config, devWarnings) {
655
+ function printSummary(config, notes) {
605
656
  const allowedCount = countAllowedDomains(config.allowedDomains);
606
- const showDevWarnings = config.bitcallEnv === "dev";
607
- const providerAllowlistSummary =
608
- allowedCount > 0
609
- ? config.allowedDomains
610
- : config.bitcallEnv === "production"
611
- ? isSingleProviderConfigured(config)
612
- ? "(single-provider mode)"
613
- : "(missing)"
614
- : "(any)";
615
- console.log("\nSummary:");
616
- console.log(` Domain: ${config.domain}`);
617
- console.log(` Environment: ${config.bitcallEnv}`);
618
- console.log(` Routing: ${config.routingMode}`);
619
- console.log(
620
- ` Provider allowlist: ${providerAllowlistSummary}${showDevWarnings && allowedCount === 0 ? " [DEV WARNING]" : ""}`
621
- );
622
- console.log(
623
- ` Webphone origin: ${config.webphoneOrigin === "*" ? `(any)${showDevWarnings ? " [DEV WARNING]" : ""}` : config.webphoneOrigin}`
624
- );
625
- console.log(` SIP source IPs: ${(config.sipTrustedIps || "").trim() ? config.sipTrustedIps : "(any)"}`);
626
- console.log(` TLS: ${config.tlsMode === "letsencrypt" ? "Let's Encrypt" : config.tlsMode}`);
627
- console.log(` TURN: ${config.turnMode === "coturn" ? "enabled (coturn)" : config.turnMode}`);
628
- console.log(
629
- ` Media: ${config.mediaIpv4Only === "1" ? "IPv4-only (IPv6 media blocked)" : "dual-stack"}`
630
- );
631
- console.log(` Firewall: ${config.configureUfw ? "UFW enabled" : "manual setup"}`);
632
-
633
- if (devWarnings.length > 0) {
634
- console.log("\nWarnings:");
635
- for (const warning of devWarnings) {
636
- console.log(` - ${warning}`);
657
+ console.log(`\n${clr(_c.bold + _c.cyan, " ┌─ Configuration ──────────────────────────────┐")}`);
658
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "Domain:")} ${config.domain}`);
659
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "Environment:")} ${config.bitcallEnv}`);
660
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "Routing:")} ${config.routingMode}`);
661
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "Allowlist:")} ${allowedCount > 0 ? config.allowedDomains : "(any)"}`);
662
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "Origin:")} ${config.webphoneOrigin === "*" ? "(any)" : config.webphoneOrigin}`);
663
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "SIP source IPs:")} ${(config.sipTrustedIps || "").trim() ? config.sipTrustedIps : "(any)"}`);
664
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "TLS:")} ${config.tlsMode === "letsencrypt" ? "Let's Encrypt" : config.tlsMode}`);
665
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "TURN:")} ${config.turnMode === "coturn" ? "enabled (coturn)" : config.turnMode}`);
666
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "Media:")} ${config.mediaIpv4Only === "1" ? "IPv4-only (IPv6 media blocked)" : "dual-stack"}`);
667
+ if (config.configureUfw) {
668
+ console.log(`${clr(_c.cyan, " │")} ${clr(_c.dim, "Firewall:")} UFW enabled`);
669
+ }
670
+ console.log(`${clr(_c.bold + _c.cyan, " └─────────────────────────────────────────────┘")}`);
671
+
672
+ if (notes.length > 0) {
673
+ console.log("");
674
+ for (const note of notes) {
675
+ console.log(` ${clr(_c.dim, "ℹ")} ${clr(_c.dim, note)}`);
637
676
  }
638
677
  }
639
678
  }
640
679
 
641
680
  function shouldRequireAllowlist(bitcallEnv, routingMode) {
642
- return bitcallEnv === "production" && routingMode === "universal";
681
+ void bitcallEnv;
682
+ void routingMode;
683
+ return false;
643
684
  }
644
685
 
645
686
  function parseProviderFromUri(uri = "") {
@@ -662,12 +703,7 @@ async function runWizard(existing = {}, preflight = {}, initOptions = {}) {
662
703
 
663
704
  try {
664
705
  const initProfile = normalizeInitProfile(initOptions, existing);
665
- let advanced = Boolean(initOptions.advanced);
666
- if (initOptions.production && !initOptions.advanced) {
667
- advanced = true;
668
- } else if (!initOptions.dev && !initOptions.production && !initOptions.advanced) {
669
- advanced = await prompt.askYesNo("Advanced setup", false);
670
- }
706
+ const advanced = Boolean(initOptions.advanced);
671
707
 
672
708
  const detectedIp = detectPublicIp();
673
709
  const domainDefault = initOptions.domain || existing.DOMAIN || "";
@@ -680,45 +716,55 @@ async function runWizard(existing = {}, preflight = {}, initOptions = {}) {
680
716
  }
681
717
 
682
718
  const resolved = resolveDomainIpv4(domain);
683
- if (resolved.length > 0 && !resolved.includes(publicIp)) {
719
+ if (initProfile !== "dev" && resolved.length > 0 && !resolved.includes(publicIp)) {
684
720
  console.log(`Warning: DNS for ${domain} resolves to ${resolved.join(", ")}, not ${publicIp}.`);
685
721
  }
686
722
 
687
- const autoDeployMode =
723
+ const detectedDeployMode =
688
724
  (preflight.p80 && preflight.p80.inUse) || (preflight.p443 && preflight.p443.inUse)
689
725
  ? "reverse-proxy"
690
726
  : "standalone";
691
727
 
692
- let deployMode = autoDeployMode;
728
+ const existingDeployMode =
729
+ existing.DEPLOY_MODE === "standalone" || existing.DEPLOY_MODE === "reverse-proxy"
730
+ ? existing.DEPLOY_MODE
731
+ : "";
732
+ let deployMode = existingDeployMode || detectedDeployMode;
693
733
  let tlsMode = "letsencrypt";
694
734
  let acmeEmail = initOptions.email || existing.ACME_EMAIL || "";
695
735
  let customCertPath = "";
696
736
  let customKeyPath = "";
697
- let bitcallEnv = initProfile;
698
- let routingMode = existing.ROUTING_MODE || "universal";
737
+ const quickDefaults = buildQuickFlowDefaults(initProfile, existing);
738
+ let bitcallEnv = quickDefaults.bitcallEnv;
739
+ let routingMode = "universal";
699
740
  const providerFromEnv = parseProviderFromUri(existing.SIP_PROVIDER_URI || "");
700
741
  let sipProviderHost = providerFromEnv.host || DEFAULT_PROVIDER_HOST;
701
- let sipTransport = providerFromEnv.transport || "udp";
702
- let sipPort = providerFromEnv.port || "5060";
742
+ let sipTransport = "udp";
743
+ let sipPort = "5060";
703
744
  let sipProviderUri = "";
704
- let allowedDomains = existing.ALLOWED_SIP_DOMAINS || "";
705
- let sipTrustedIps = existing.SIP_TRUSTED_IPS || "";
745
+ let allowedDomains = "";
746
+ let sipTrustedIps = "";
706
747
  let turnMode = "coturn";
707
748
  let turnSecret = "";
708
749
  let turnTtl = existing.TURN_TTL || "86400";
709
- let turnApiToken = existing.TURN_API_TOKEN || "";
750
+ let turnApiToken = "";
710
751
  let turnExternalUrls = "";
711
752
  let turnExternalUsername = "";
712
753
  let turnExternalCredential = "";
713
- let webphoneOrigin = existing.WEBPHONE_ORIGIN || DEFAULT_WEBPHONE_ORIGIN;
714
- let configureUfw = await prompt.askYesNo("Configure UFW firewall rules now?", true);
754
+ let webphoneOrigin = "*";
755
+ let configureUfw = true;
715
756
  let mediaIpv4Only = existing.MEDIA_IPV4_ONLY ? existing.MEDIA_IPV4_ONLY === "1" : true;
716
-
717
- if (!advanced) {
718
- acmeEmail = acmeEmail || (await prompt.askText("Let's Encrypt email", "", { required: true }));
719
- turnMode = await prompt.askYesNo("Enable built-in TURN (coturn)?", true) ? "coturn" : "none";
720
- const quickDefaults = buildQuickFlowDefaults(initProfile, existing);
721
- bitcallEnv = quickDefaults.bitcallEnv;
757
+ let rtpMin = existing.RTPENGINE_MIN_PORT || "10000";
758
+ let rtpMax = existing.RTPENGINE_MAX_PORT || "20000";
759
+ let turnUdpPort = existing.TURN_UDP_PORT || "3478";
760
+ let turnsTcpPort = existing.TURNS_TCP_PORT || "5349";
761
+ let turnRelayMinPort = existing.TURN_RELAY_MIN_PORT || "49152";
762
+ let turnRelayMaxPort = existing.TURN_RELAY_MAX_PORT || "49252";
763
+
764
+ if (initProfile === "dev") {
765
+ tlsMode = "dev-self-signed";
766
+ acmeEmail = "";
767
+ turnMode = "coturn";
722
768
  routingMode = quickDefaults.routingMode;
723
769
  sipProviderUri = quickDefaults.sipProviderUri;
724
770
  sipTransport = quickDefaults.sipTransport;
@@ -726,17 +772,13 @@ async function runWizard(existing = {}, preflight = {}, initOptions = {}) {
726
772
  allowedDomains = quickDefaults.allowedDomains;
727
773
  webphoneOrigin = quickDefaults.webphoneOrigin;
728
774
  sipTrustedIps = quickDefaults.sipTrustedIps;
775
+ configureUfw = true;
776
+ mediaIpv4Only = true;
729
777
  } else {
730
- deployMode = await prompt.askChoice(
731
- "Deployment mode",
732
- ["standalone", "reverse-proxy"],
733
- autoDeployMode === "reverse-proxy" ? 1 : 0
734
- );
735
-
736
778
  tlsMode = await prompt.askChoice(
737
779
  "TLS certificate mode",
738
- ["letsencrypt", "custom", "dev-self-signed"],
739
- 0
780
+ ["letsencrypt", "custom"],
781
+ existing.TLS_MODE === "custom" ? 1 : 0
740
782
  );
741
783
 
742
784
  if (tlsMode === "custom") {
@@ -746,104 +788,79 @@ async function runWizard(existing = {}, preflight = {}, initOptions = {}) {
746
788
  customKeyPath = await prompt.askText("Path to TLS private key (PEM)", existing.TLS_KEY || "", {
747
789
  required: true,
748
790
  });
749
- }
750
-
751
- if (tlsMode === "letsencrypt") {
752
- acmeEmail = await prompt.askText("Let's Encrypt email", acmeEmail, { required: true });
753
- } else {
754
791
  acmeEmail = "";
792
+ } else {
793
+ acmeEmail = await prompt.askText("Let's Encrypt email", acmeEmail, { required: true });
755
794
  }
756
795
 
757
- if (!initOptions.dev && !initOptions.production) {
758
- bitcallEnv = await prompt.askChoice("Environment", ["production", "dev"], bitcallEnv === "dev" ? 1 : 0);
759
- }
760
- routingMode = await prompt.askChoice(
761
- "Routing mode",
762
- ["universal", "single-provider"],
763
- routingMode === "single-provider" ? 1 : 0
764
- );
765
-
766
- if (routingMode === "single-provider") {
767
- sipProviderHost = await prompt.askText(
768
- "SIP provider host",
769
- existing.SIP_PROVIDER_URI
770
- ? existing.SIP_PROVIDER_URI.replace(/^sip:/, "").split(":")[0]
771
- : DEFAULT_PROVIDER_HOST,
772
- { required: true }
773
- );
774
-
775
- const sipTransportChoice = await prompt.askChoice(
776
- "SIP provider transport",
777
- ["udp", "tcp", "tls", "custom"],
778
- 0
796
+ turnMode = await prompt.askYesNo("Enable built-in TURN (coturn)?", true) ? "coturn" : "none";
797
+ routingMode = "universal";
798
+ sipProviderUri = "";
799
+ sipTransport = "udp";
800
+ sipPort = "5060";
801
+ allowedDomains = "";
802
+ webphoneOrigin = "*";
803
+ sipTrustedIps = "";
804
+ turnApiToken = "";
805
+
806
+ if (advanced) {
807
+ const restrictToSingleProvider = await prompt.askYesNo(
808
+ "Restrict to single SIP provider?",
809
+ existing.ROUTING_MODE === "single-provider"
779
810
  );
780
811
 
781
- sipTransport = sipTransportChoice;
782
- if (sipTransportChoice === "tcp" || sipTransportChoice === "udp") {
783
- sipPort = "5060";
784
- } else if (sipTransportChoice === "tls") {
785
- sipPort = "5061";
786
- } else {
787
- sipTransport = await prompt.askChoice("Custom transport", ["udp", "tcp", "tls"], 0);
788
- sipPort = await prompt.askText(
789
- "Custom SIP port",
790
- sipTransport === "tls" ? "5061" : "5060",
812
+ if (restrictToSingleProvider) {
813
+ routingMode = "single-provider";
814
+ sipProviderHost = await prompt.askText(
815
+ "SIP provider host",
816
+ providerFromEnv.host || DEFAULT_PROVIDER_HOST,
791
817
  { required: true }
792
818
  );
819
+ sipTransport = await prompt.askChoice(
820
+ "SIP provider transport",
821
+ ["udp", "tcp", "tls"],
822
+ providerFromEnv.transport === "tcp" ? 1 : providerFromEnv.transport === "tls" ? 2 : 0
823
+ );
824
+ const defaultProviderPort =
825
+ providerFromEnv.port || (sipTransport === "tls" ? "5061" : "5060");
826
+ sipPort = await prompt.askText("SIP provider port", defaultProviderPort, { required: true });
827
+ sipProviderUri = `sip:${sipProviderHost}:${sipPort};transport=${sipTransport}`;
793
828
  }
794
829
 
795
- sipProviderUri = `sip:${sipProviderHost}:${sipPort};transport=${sipTransport}`;
796
- }
797
-
798
- const requireAllowlist = shouldRequireAllowlist(bitcallEnv, routingMode);
799
- allowedDomains = await prompt.askText(
800
- requireAllowlist
801
- ? "Allowed SIP domains (comma-separated; required in production universal mode)"
802
- : "Allowed SIP domains (comma-separated)",
803
- allowedDomains,
804
- { required: requireAllowlist }
805
- );
806
-
807
- sipTrustedIps = await prompt.askText(
808
- "Trusted SIP source IPs (optional, comma-separated)",
809
- sipTrustedIps
810
- );
811
-
812
- const existingTurn = existing.TURN_MODE || "coturn";
813
- const turnDefaultIndex = existingTurn === "external" ? 2 : existingTurn === "coturn" ? 1 : 0;
814
- turnMode = await prompt.askChoice("TURN mode", ["none", "coturn", "external"], turnDefaultIndex);
815
- if (turnMode === "coturn") {
816
- turnSecret = crypto.randomBytes(32).toString("hex");
817
- turnTtl = await prompt.askText("TURN credential TTL seconds", turnTtl, { required: true });
818
- turnApiToken = await prompt.askText("TURN API token (optional)", turnApiToken);
819
- } else if (turnMode === "external") {
820
- turnSecret = "";
821
- turnApiToken = "";
822
- turnExternalUrls = await prompt.askText("External TURN urls", existing.TURN_EXTERNAL_URLS || "", {
823
- required: true,
824
- });
825
- turnExternalUsername = await prompt.askText(
826
- "External TURN username",
827
- existing.TURN_EXTERNAL_USERNAME || ""
830
+ allowedDomains = await prompt.askText(
831
+ "SIP domain allowlist (optional, comma-separated)",
832
+ existing.ALLOWED_SIP_DOMAINS || ""
828
833
  );
829
- turnExternalCredential = await prompt.askText(
830
- "External TURN credential",
831
- existing.TURN_EXTERNAL_CREDENTIAL || ""
834
+ webphoneOrigin = await prompt.askText(
835
+ "Webphone origin restriction (optional, * for any)",
836
+ existing.WEBPHONE_ORIGIN || "*"
837
+ );
838
+ webphoneOrigin = webphoneOrigin.trim() ? webphoneOrigin : "*";
839
+ sipTrustedIps = await prompt.askText(
840
+ "SIP trusted source IPs (optional, comma-separated)",
841
+ existing.SIP_TRUSTED_IPS || ""
832
842
  );
833
- } else {
834
- turnSecret = "";
835
- turnApiToken = "";
836
- }
837
843
 
838
- webphoneOrigin = await prompt.askText(
839
- "Allowed webphone origin (* for any)",
840
- webphoneOrigin
841
- );
844
+ if (turnMode === "coturn") {
845
+ turnApiToken = await prompt.askText("TURN API token (optional)", existing.TURN_API_TOKEN || "");
846
+ }
842
847
 
843
- mediaIpv4Only = await prompt.askYesNo(
844
- "Media IPv4-only mode (block IPv6 RTP/TURN on host firewall)",
845
- mediaIpv4Only
846
- );
848
+ const customizePorts = await prompt.askYesNo("Customize RTP/TURN ports?", false);
849
+ if (customizePorts) {
850
+ rtpMin = await prompt.askText("RTP minimum port", rtpMin, { required: true });
851
+ rtpMax = await prompt.askText("RTP maximum port", rtpMax, { required: true });
852
+ if (turnMode === "coturn") {
853
+ turnUdpPort = await prompt.askText("TURN UDP/TCP port", turnUdpPort, { required: true });
854
+ turnsTcpPort = await prompt.askText("TURNS TCP port", turnsTcpPort, { required: true });
855
+ turnRelayMinPort = await prompt.askText("TURN relay minimum port", turnRelayMinPort, {
856
+ required: true,
857
+ });
858
+ turnRelayMaxPort = await prompt.askText("TURN relay maximum port", turnRelayMaxPort, {
859
+ required: true,
860
+ });
861
+ }
862
+ }
863
+ }
847
864
  }
848
865
 
849
866
  if (turnMode === "coturn") {
@@ -881,12 +898,12 @@ async function runWizard(existing = {}, preflight = {}, initOptions = {}) {
881
898
  turnExternalCredential,
882
899
  webphoneOrigin,
883
900
  mediaIpv4Only: mediaIpv4Only ? "1" : "0",
884
- rtpMin: existing.RTPENGINE_MIN_PORT || "10000",
885
- rtpMax: existing.RTPENGINE_MAX_PORT || "20000",
886
- turnUdpPort: existing.TURN_UDP_PORT || "3478",
887
- turnsTcpPort: existing.TURNS_TCP_PORT || "5349",
888
- turnRelayMinPort: existing.TURN_RELAY_MIN_PORT || "49152",
889
- turnRelayMaxPort: existing.TURN_RELAY_MAX_PORT || "49252",
901
+ rtpMin,
902
+ rtpMax,
903
+ turnUdpPort,
904
+ turnsTcpPort,
905
+ turnRelayMinPort,
906
+ turnRelayMaxPort,
890
907
  acmeListenPort: deployMode === "reverse-proxy" ? "8080" : "80",
891
908
  wssListenPort: deployMode === "reverse-proxy" ? "8443" : "443",
892
909
  internalWssPort: "8443",
@@ -898,12 +915,14 @@ async function runWizard(existing = {}, preflight = {}, initOptions = {}) {
898
915
  validateProductionConfig(config);
899
916
  }
900
917
 
901
- const devWarnings = config.bitcallEnv === "dev" ? buildDevWarnings(config) : [];
902
- printSummary(config, devWarnings);
918
+ const securityNotes = buildSecurityNotes(config);
919
+ printSummary(config, securityNotes);
903
920
 
904
- const proceed = await prompt.askYesNo("Proceed with provisioning", true);
905
- if (!proceed) {
906
- throw new Error("Initialization canceled.");
921
+ if (initProfile !== "dev") {
922
+ const proceed = await prompt.askYesNo("Proceed with provisioning", true);
923
+ if (!proceed) {
924
+ throw new Error("Initialization canceled.");
925
+ }
907
926
  }
908
927
 
909
928
  return config;
@@ -1039,6 +1058,18 @@ function installGatewayService(exec = run) {
1039
1058
 
1040
1059
  async function initCommand(initOptions = {}) {
1041
1060
  printBanner();
1061
+
1062
+ // If re-running init on an existing installation, stop the current
1063
+ // gateway so our own ports don't fail the preflight check.
1064
+ if (fs.existsSync(ENV_PATH) && fs.existsSync(COMPOSE_PATH)) {
1065
+ console.log("Existing gateway detected. Stopping for re-initialization...\n");
1066
+ try {
1067
+ runCompose(["down"], { stdio: "pipe" });
1068
+ } catch (_e) {
1069
+ // Ignore — gateway may already be stopped or config may be corrupted
1070
+ }
1071
+ }
1072
+
1042
1073
  const ctx = createInstallContext(initOptions);
1043
1074
 
1044
1075
  let preflight;
@@ -1100,6 +1131,9 @@ async function initCommand(initOptions = {}) {
1100
1131
  tlsCertPath = path.join(SSL_DIR, "dev-fullchain.pem");
1101
1132
  tlsKeyPath = path.join(SSL_DIR, "dev-privkey.pem");
1102
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");
1103
1137
  } else {
1104
1138
  tlsCertPath = path.join(SSL_DIR, "bootstrap-fullchain.pem");
1105
1139
  tlsKeyPath = path.join(SSL_DIR, "bootstrap-privkey.pem");
@@ -1121,14 +1155,127 @@ async function initCommand(initOptions = {}) {
1121
1155
 
1122
1156
  await ctx.step("Done", async () => {});
1123
1157
 
1124
- console.log("\nGateway initialized.");
1125
- console.log(`WSS URL: wss://${config.domain}`);
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}`)}`);
1169
+ }
1170
+ console.log("");
1171
+ }
1172
+
1173
+ async function reconfigureCommand(options) {
1174
+ ensureInitialized();
1175
+ printBanner();
1176
+
1177
+ // Stop the running gateway so ports are free for preflight.
1178
+ if (fs.existsSync(ENV_PATH) && fs.existsSync(COMPOSE_PATH)) {
1179
+ console.log("Stopping current gateway for reconfiguration...\n");
1180
+ try {
1181
+ runCompose(["down"], { stdio: "pipe" });
1182
+ } catch (_e) {
1183
+ // Ignore — gateway may already be stopped or config may be corrupted
1184
+ }
1185
+ }
1186
+
1187
+ const ctx = createInstallContext(options);
1188
+
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) : {};
1195
+
1196
+ let config;
1197
+ await ctx.step("Config", async () => {
1198
+ config = await runWizard(existingEnv, preflight, options);
1199
+ });
1200
+
1201
+ ensureInstallLayout();
1202
+
1203
+ let tlsCertPath;
1204
+ let tlsKeyPath;
1205
+
1206
+ await ctx.step("Firewall", async () => {
1207
+ if (config.configureUfw) {
1208
+ const ufwReady = await ensureUfwAvailable(ctx);
1209
+ if (ufwReady) {
1210
+ configureFirewall(config, ctx.exec);
1211
+ } else {
1212
+ config.configureUfw = false;
1213
+ printRequiredPorts(config);
1214
+ }
1215
+ } else {
1216
+ printRequiredPorts(config);
1217
+ }
1218
+
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";
1226
+ }
1227
+ }
1228
+ });
1229
+
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;
1245
+ } 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);
1249
+ }
1250
+ }
1251
+
1252
+ const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
1253
+ writeFileWithMode(ENV_PATH, envContent, 0o600);
1254
+ writeComposeTemplate();
1255
+ });
1256
+
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
+ });
1263
+
1264
+ await ctx.step("Done", async () => {});
1265
+
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}`)}`);
1126
1272
  if (config.turnMode !== "none") {
1127
- console.log(`TURN credentials URL: https://${config.domain}/turn-credentials`);
1273
+ console.log(` ${clr(_c.bold, "TURN")} ${clr(_c.cyan, `https://${config.domain}/turn-credentials`)}`);
1128
1274
  }
1129
1275
  if (!ctx.verbose) {
1130
- console.log(`Install log: ${ctx.logPath}`);
1276
+ console.log(` ${clr(_c.dim, `Log: ${ctx.logPath}`)}`);
1131
1277
  }
1278
+ console.log("");
1132
1279
  }
1133
1280
 
1134
1281
  function runSystemctl(args, fallbackComposeArgs) {
@@ -1154,6 +1301,30 @@ function restartCommand() {
1154
1301
  runSystemctl(["reload", SERVICE_NAME], ["restart"]);
1155
1302
  }
1156
1303
 
1304
+ function pauseCommand() {
1305
+ ensureInitialized();
1306
+ runCompose(["pause"], { stdio: "inherit" });
1307
+ console.log("Gateway paused.");
1308
+ }
1309
+
1310
+ function resumeCommand() {
1311
+ ensureInitialized();
1312
+ runCompose(["unpause"], { stdio: "inherit" });
1313
+ console.log("Gateway resumed.");
1314
+ }
1315
+
1316
+ function enableCommand() {
1317
+ ensureInitialized();
1318
+ run("systemctl", ["enable", SERVICE_NAME], { stdio: "inherit" });
1319
+ console.log("Gateway will start on boot.");
1320
+ }
1321
+
1322
+ function disableCommand() {
1323
+ ensureInitialized();
1324
+ run("systemctl", ["disable", SERVICE_NAME], { stdio: "inherit" });
1325
+ console.log("Gateway will NOT start on boot.");
1326
+ }
1327
+
1157
1328
  function logsCommand(service, options) {
1158
1329
  ensureInitialized();
1159
1330
  const args = ["logs", "--tail", String(options.tail || 200)];
@@ -1166,8 +1337,36 @@ function logsCommand(service, options) {
1166
1337
  runCompose(args, { stdio: "inherit" });
1167
1338
  }
1168
1339
 
1340
+ function sipTraceCommand() {
1341
+ ensureInitialized();
1342
+ const compose = detectComposeCommand();
1343
+ const containerName = "bitcall-gateway";
1344
+
1345
+ // 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" }
1350
+ );
1351
+
1352
+ if (check.status !== 0) {
1353
+ console.error("sngrep is not available in this gateway image.");
1354
+ console.error("Update to the latest image: sudo bitcall-gateway update");
1355
+ process.exit(1);
1356
+ }
1357
+
1358
+ // 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
+ );
1364
+
1365
+ process.exit(result.status || 0);
1366
+ }
1367
+
1169
1368
  function formatMark(state) {
1170
- return state ? "✓" : "✗";
1369
+ return state ? clr(_c.green, "✓") : clr(_c.red, "✗");
1171
1370
  }
1172
1371
 
1173
1372
  function statusCommand() {
@@ -1184,6 +1383,7 @@ function statusCommand() {
1184
1383
  "-lc",
1185
1384
  "docker ps --filter name=^bitcall-gateway$ --format '{{.Status}}'",
1186
1385
  ]);
1386
+ const containerUp = Boolean(containerStatus);
1187
1387
 
1188
1388
  const p80 = portInUse(Number.parseInt(envMap.ACME_LISTEN_PORT || "80", 10));
1189
1389
  const p443 = portInUse(Number.parseInt(envMap.WSS_LISTEN_PORT || "443", 10));
@@ -1205,10 +1405,10 @@ function statusCommand() {
1205
1405
 
1206
1406
  const mediaStatus = isMediaIpv4OnlyRulesPresent();
1207
1407
 
1208
- console.log("Bitcall Gateway Status\n");
1408
+ console.log(`\n${clr(_c.bold + _c.cyan, " Bitcall Gateway Status")}\n`);
1209
1409
  console.log(`Gateway service: ${formatMark(active)} ${active ? "running" : "stopped"}`);
1210
1410
  console.log(`Auto-start: ${formatMark(enabled)} ${enabled ? "enabled" : "disabled"}`);
1211
- console.log(`Container: ${containerStatus || "not running"}`);
1411
+ console.log(`Container: ${formatMark(containerUp)} ${containerStatus || "not running"}`);
1212
1412
  console.log("");
1213
1413
  console.log(`Port ${envMap.ACME_LISTEN_PORT || "80"}: ${formatMark(p80.inUse)} listening`);
1214
1414
  console.log(`Port ${envMap.WSS_LISTEN_PORT || "443"}: ${formatMark(p443.inUse)} listening`);
@@ -1238,7 +1438,7 @@ function statusCommand() {
1238
1438
 
1239
1439
  console.log("\nConfig summary:");
1240
1440
  console.log(`DOMAIN=${envMap.DOMAIN || ""}`);
1241
- console.log(`BITCALL_ENV=${envMap.BITCALL_ENV || "dev"}`);
1441
+ console.log(`BITCALL_ENV=${envMap.BITCALL_ENV || "production"}`);
1242
1442
  console.log(`ROUTING_MODE=${envMap.ROUTING_MODE || "universal"}`);
1243
1443
  console.log(`SIP_PROVIDER_URI=${envMap.SIP_PROVIDER_URI || ""}`);
1244
1444
  console.log(`DEPLOY_MODE=${envMap.DEPLOY_MODE || ""}`);
@@ -1318,8 +1518,23 @@ async function certInstallCommand(options) {
1318
1518
 
1319
1519
  function updateCommand() {
1320
1520
  ensureInitialized();
1521
+ const envMap = loadEnvFile(ENV_PATH);
1522
+ const currentImage = envMap.BITCALL_GATEWAY_IMAGE || "";
1523
+ const targetImage = DEFAULT_GATEWAY_IMAGE;
1524
+
1525
+ if (currentImage === targetImage) {
1526
+ console.log(`${clr(_c.green, "✓")} Image is current: ${clr(_c.dim, targetImage)}`);
1527
+ console.log("Checking for newer image layers...");
1528
+ } else {
1529
+ console.log(`${clr(_c.yellow, "→")} Updating: ${clr(_c.dim, currentImage || "(none)")} → ${clr(_c.bold, targetImage)}`);
1530
+ updateGatewayEnv({ BITCALL_GATEWAY_IMAGE: targetImage });
1531
+ }
1532
+
1321
1533
  runCompose(["pull"], { stdio: "inherit" });
1322
1534
  runSystemctl(["reload", SERVICE_NAME], ["up", "-d", "--remove-orphans"]);
1535
+
1536
+ console.log(`\n${clr(_c.green, "✓")} Gateway updated to ${clr(_c.bold, PACKAGE_VERSION)}.`);
1537
+ console.log(clr(_c.dim, " To update the CLI: sudo npm i -g @bitcall/webrtc-sip-gateway@latest"));
1323
1538
  }
1324
1539
 
1325
1540
  function mediaStatusCommand() {
@@ -1402,11 +1617,40 @@ async function uninstallCommand(options) {
1402
1617
  fs.rmSync(RENEW_HOOK_PATH, { force: true });
1403
1618
  }
1404
1619
 
1620
+ // Remove Bitcall-tagged UFW rules.
1621
+ if (commandExists("ufw")) {
1622
+ const ufwOutput = output("ufw", ["status", "numbered"]);
1623
+ const lines = (ufwOutput || "").split("\n");
1624
+ const ruleNumbers = [];
1625
+ for (const line of lines) {
1626
+ const match = line.match(/^\[\s*(\d+)\]/);
1627
+ if (match && line.includes("Bitcall")) {
1628
+ ruleNumbers.push(match[1]);
1629
+ }
1630
+ }
1631
+
1632
+ // Delete in reverse order so indices do not shift.
1633
+ for (const num of ruleNumbers.reverse()) {
1634
+ run("ufw", ["--force", "delete", num], { check: false, stdio: "inherit" });
1635
+ }
1636
+ if (ruleNumbers.length > 0) {
1637
+ run("ufw", ["reload"], { check: false, stdio: "inherit" });
1638
+ console.log(`Removed ${ruleNumbers.length} UFW rule(s).`);
1639
+ }
1640
+ }
1641
+
1405
1642
  if (fs.existsSync(GATEWAY_DIR)) {
1406
1643
  fs.rmSync(GATEWAY_DIR, { recursive: true, force: true });
1407
1644
  }
1408
1645
 
1409
- console.log("Gateway uninstalled.");
1646
+ console.log(`\n${clr(_c.green, "✓")} ${clr(_c.bold, "Gateway uninstalled.")}`);
1647
+ const domain = uninstallEnv.DOMAIN || "";
1648
+ console.log(`\n${clr(_c.dim, "To remove the CLI:")}`);
1649
+ console.log(" sudo npm uninstall -g @bitcall/webrtc-sip-gateway");
1650
+ if (domain && uninstallEnv.TLS_MODE === "letsencrypt") {
1651
+ console.log(`\n${clr(_c.dim, "To remove Let's Encrypt certificate:")}`);
1652
+ console.log(` sudo certbot delete --cert-name ${domain}`);
1653
+ }
1410
1654
  }
1411
1655
 
1412
1656
  function configCommand() {
@@ -1420,6 +1664,7 @@ function configCommand() {
1420
1664
  }
1421
1665
 
1422
1666
  function buildProgram() {
1667
+ const { Command } = require("commander");
1423
1668
  const program = new Command();
1424
1669
 
1425
1670
  program
@@ -1432,7 +1677,7 @@ function buildProgram() {
1432
1677
  .description("Run setup wizard and provision gateway")
1433
1678
  .option("--advanced", "Show advanced configuration prompts")
1434
1679
  .option("--dev", "Quick dev setup (minimal prompts, permissive defaults)")
1435
- .option("--production", "Production setup (strict validation enabled)")
1680
+ .option("--production", "Production setup profile")
1436
1681
  .option("--domain <domain>", "Gateway domain")
1437
1682
  .option("--email <email>", "Let's Encrypt email")
1438
1683
  .option("--verbose", "Stream full installer command output")
@@ -1450,6 +1695,10 @@ function buildProgram() {
1450
1695
  .option("--no-follow", "Print and exit")
1451
1696
  .option("--tail <lines>", "Number of lines", "200")
1452
1697
  .action(logsCommand);
1698
+ program
1699
+ .command("sip-trace")
1700
+ .description("Live SIP message viewer (sngrep)")
1701
+ .action(sipTraceCommand);
1453
1702
 
1454
1703
  const cert = program.command("cert").description("Certificate operations");
1455
1704
  cert.command("status").description("Show current certificate info").action(certStatusCommand);
@@ -1463,6 +1712,18 @@ function buildProgram() {
1463
1712
 
1464
1713
  program.command("update").description("Pull latest image and restart service").action(updateCommand);
1465
1714
  program.command("config").description("Print active configuration (secrets hidden)").action(configCommand);
1715
+ program.command("pause").description("Pause gateway containers").action(pauseCommand);
1716
+ program.command("resume").description("Resume paused gateway containers").action(resumeCommand);
1717
+ program.command("enable").description("Enable auto-start on boot").action(enableCommand);
1718
+ program.command("disable").description("Disable auto-start on boot").action(disableCommand);
1719
+ program
1720
+ .command("reconfigure")
1721
+ .description("Re-run wizard with current values as defaults")
1722
+ .option("--advanced", "Show advanced configuration prompts")
1723
+ .option("--dev", "Dev mode")
1724
+ .option("--production", "Production mode")
1725
+ .option("--verbose", "Verbose output")
1726
+ .action(reconfigureCommand);
1466
1727
 
1467
1728
  const media = program.command("media").description("Media firewall operations");
1468
1729
  media.command("status").description("Show media IPv4-only firewall state").action(mediaStatusCommand);
@@ -1488,7 +1749,7 @@ module.exports = {
1488
1749
  main,
1489
1750
  normalizeInitProfile,
1490
1751
  validateProductionConfig,
1491
- buildDevWarnings,
1752
+ buildSecurityNotes,
1492
1753
  buildQuickFlowDefaults,
1493
1754
  shouldRequireAllowlist,
1494
1755
  isOriginWildcard,