@bitcall/webrtc-sip-gateway 0.2.6 → 0.2.8

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,13 +5,13 @@ 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.6
8
+ sudo npm i -g @bitcall/webrtc-sip-gateway@0.2.8
9
9
  ```
10
10
 
11
11
  ## Main workflow
12
12
 
13
13
  ```bash
14
- sudo bitcall-gateway init
14
+ sudo bitcall-gateway init --dev
15
15
  sudo bitcall-gateway status
16
16
  sudo bitcall-gateway logs -f
17
17
  sudo bitcall-gateway media status
@@ -22,9 +22,25 @@ ports only. Host IPv6 remains enabled for signaling and non-media traffic.
22
22
  Backend selection prefers nftables on non-UFW hosts and uses ip6tables when UFW
23
23
  is active.
24
24
 
25
+ Default `init` and `init --dev` run in dev profile:
26
+ - `BITCALL_ENV=dev`
27
+ - `ROUTING_MODE=universal`
28
+ - provider allowlist/origin/source IPs are permissive by default (with warnings)
29
+
30
+ Use `sudo bitcall-gateway init --production` for strict input validation and
31
+ hardening checks. Production universal routing requires explicit
32
+ `ALLOWED_SIP_DOMAINS`.
33
+ Use `--verbose` to stream apt/docker output during install. Default mode keeps
34
+ console output concise and writes command details to
35
+ `/var/log/bitcall-gateway-install.log`.
36
+
25
37
  ## Commands
26
38
 
27
39
  - `sudo bitcall-gateway init`
40
+ - `sudo bitcall-gateway init --dev`
41
+ - `sudo bitcall-gateway init --production`
42
+ - `sudo bitcall-gateway init --advanced`
43
+ - `sudo bitcall-gateway init --verbose`
28
44
  - `sudo bitcall-gateway up`
29
45
  - `sudo bitcall-gateway down`
30
46
  - `sudo bitcall-gateway restart`
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.6",
17
+ DEFAULT_GATEWAY_IMAGE: "ghcr.io/bitcallio/webrtc-sip-gateway:0.2.8",
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",
package/lib/system.js CHANGED
@@ -3,6 +3,20 @@
3
3
  const fs = require("fs");
4
4
  const { output, run, runShell, commandExists } = require("./shell");
5
5
 
6
+ function pickExec(options = {}) {
7
+ if (typeof options.exec === "function") {
8
+ return options.exec;
9
+ }
10
+ return (command, args = [], execOptions = {}) => run(command, args, execOptions);
11
+ }
12
+
13
+ function pickShell(options = {}) {
14
+ if (typeof options.shell === "function") {
15
+ return options.shell;
16
+ }
17
+ return (script, execOptions = {}) => runShell(script, execOptions);
18
+ }
19
+
6
20
  function parseOsRelease() {
7
21
  const info = {};
8
22
  const content = fs.readFileSync("/etc/os-release", "utf8");
@@ -112,28 +126,31 @@ function portInUse(port) {
112
126
  };
113
127
  }
114
128
 
115
- function ensureDockerInstalled() {
129
+ function ensureDockerInstalled(options = {}) {
130
+ const exec = pickExec(options);
131
+ const shell = pickShell(options);
132
+
116
133
  if (commandExists("docker") && run("docker", ["info"], { check: false }).status === 0) {
117
134
  return;
118
135
  }
119
136
 
120
- run("apt-get", ["update"], { stdio: "inherit" });
121
- run("apt-get", ["install", "-y", "curl", "ca-certificates", "gnupg"], {
122
- stdio: "inherit",
123
- });
124
- runShell("curl -fsSL https://get.docker.com | sh", { stdio: "inherit" });
125
- run("systemctl", ["enable", "docker.service"], { stdio: "inherit" });
126
- run("systemctl", ["enable", "containerd.service"], { stdio: "inherit" });
127
- run("systemctl", ["start", "docker"], { stdio: "inherit" });
137
+ exec("apt-get", ["update"]);
138
+ exec("apt-get", ["install", "-y", "curl", "ca-certificates", "gnupg"]);
139
+ shell("curl -fsSL https://get.docker.com | sh");
140
+ exec("systemctl", ["enable", "docker.service"]);
141
+ exec("systemctl", ["enable", "containerd.service"]);
142
+ exec("systemctl", ["start", "docker"]);
128
143
  }
129
144
 
130
- function ensureComposePlugin() {
145
+ function ensureComposePlugin(options = {}) {
146
+ const exec = pickExec(options);
147
+
131
148
  if (run("docker", ["compose", "version"], { check: false }).status === 0) {
132
149
  return;
133
150
  }
134
151
 
135
- run("apt-get", ["update"], { stdio: "inherit" });
136
- run("apt-get", ["install", "-y", "docker-compose-plugin"], { stdio: "inherit" });
152
+ exec("apt-get", ["update"]);
153
+ exec("apt-get", ["install", "-y", "docker-compose-plugin"]);
137
154
  }
138
155
 
139
156
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitcall/webrtc-sip-gateway",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
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 && node test/firewall.test.js",
25
+ "test": "node test/smoke.test.js && node test/firewall.test.js && node test/init-config.test.js",
26
26
  "pack:dry": "npm pack --dry-run"
27
27
  },
28
28
  "dependencies": {
package/src/index.js CHANGED
@@ -19,7 +19,7 @@ const {
19
19
  RENEW_HOOK_PATH,
20
20
  } = require("../lib/constants");
21
21
  const { run, runShell, output, commandExists } = require("../lib/shell");
22
- const { loadEnvFile, parseEnv, writeEnvFile } = require("../lib/envfile");
22
+ const { loadEnvFile, writeEnvFile } = require("../lib/envfile");
23
23
  const { Prompter } = require("../lib/prompt");
24
24
  const {
25
25
  parseOsRelease,
@@ -40,21 +40,109 @@ const {
40
40
  isMediaIpv4OnlyRulesPresent,
41
41
  } = require("../lib/firewall");
42
42
 
43
- const PACKAGE_VERSION = "0.2.6";
43
+ const PACKAGE_VERSION = "0.2.8";
44
+ const INSTALL_LOG_PATH = "/var/log/bitcall-gateway-install.log";
44
45
 
45
- function detectComposeCommand() {
46
- if (run("docker", ["compose", "version"], { check: false }).status === 0) {
46
+ 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");
52
+ }
53
+
54
+ function createInstallContext(options = {}) {
55
+ const verbose = Boolean(options.verbose);
56
+ const steps = ["Checks", "Config", "Firewall", "TLS", "Start", "Done"];
57
+ const state = { index: 0 };
58
+
59
+ if (!verbose) {
60
+ fs.writeFileSync(INSTALL_LOG_PATH, "", { mode: 0o600 });
61
+ fs.chmodSync(INSTALL_LOG_PATH, 0o600);
62
+ }
63
+
64
+ function logLine(line) {
65
+ if (!verbose) {
66
+ fs.appendFileSync(INSTALL_LOG_PATH, `${line}\n`);
67
+ }
68
+ }
69
+
70
+ function formatCommand(command, args) {
71
+ const joined = [command, ...(args || [])].join(" ").trim();
72
+ return joined;
73
+ }
74
+
75
+ function exec(command, args = [], commandOptions = {}) {
76
+ const stdio = verbose ? "inherit" : "pipe";
77
+ const result = run(command, args, {
78
+ ...commandOptions,
79
+ check: false,
80
+ stdio,
81
+ });
82
+
83
+ if (!verbose) {
84
+ logLine(`$ ${formatCommand(command, args)}`);
85
+ if (result.stdout) {
86
+ fs.appendFileSync(INSTALL_LOG_PATH, result.stdout);
87
+ }
88
+ if (result.stderr) {
89
+ fs.appendFileSync(INSTALL_LOG_PATH, result.stderr);
90
+ }
91
+ logLine("");
92
+ }
93
+
94
+ if (commandOptions.check !== false && result.status !== 0) {
95
+ throw new Error(`Command failed: ${formatCommand(command, args)}`);
96
+ }
97
+
98
+ return result;
99
+ }
100
+
101
+ function shell(script, commandOptions = {}) {
102
+ return exec("sh", ["-lc", script], commandOptions);
103
+ }
104
+
105
+ async function step(label, fn) {
106
+ state.index += 1;
107
+ console.log(`[${state.index}/${steps.length}] ${label}...`);
108
+ const started = Date.now();
109
+ try {
110
+ const value = await fn();
111
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1);
112
+ console.log(`✓ ${label} (${elapsed}s)\n`);
113
+ return value;
114
+ } catch (error) {
115
+ console.error(`✗ ${label} failed: ${error.message}`);
116
+ if (!verbose) {
117
+ console.error(`Install log: ${INSTALL_LOG_PATH}`);
118
+ }
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ return {
124
+ verbose,
125
+ logPath: INSTALL_LOG_PATH,
126
+ exec,
127
+ shell,
128
+ step,
129
+ logLine,
130
+ };
131
+ }
132
+
133
+ function detectComposeCommand(exec = run) {
134
+ if (exec("docker", ["compose", "version"], { check: false }).status === 0) {
47
135
  return { command: "docker", prefixArgs: ["compose"] };
48
136
  }
49
- if (run("docker-compose", ["version"], { check: false }).status === 0) {
137
+ if (exec("docker-compose", ["version"], { check: false }).status === 0) {
50
138
  return { command: "docker-compose", prefixArgs: [] };
51
139
  }
52
140
  throw new Error("docker compose not found. Install Docker compose plugin.");
53
141
  }
54
142
 
55
- function runCompose(args, options = {}) {
56
- const compose = detectComposeCommand();
57
- return run(compose.command, [...compose.prefixArgs, ...args], {
143
+ function runCompose(args, options = {}, exec = run) {
144
+ const compose = detectComposeCommand(exec);
145
+ return exec(compose.command, [...compose.prefixArgs, ...args], {
58
146
  cwd: GATEWAY_DIR,
59
147
  ...options,
60
148
  });
@@ -178,17 +266,13 @@ WantedBy=multi-user.target
178
266
  `;
179
267
  }
180
268
 
181
- function installSystemdService() {
269
+ function installSystemdService(exec = run) {
182
270
  writeFileWithMode(SERVICE_FILE, buildSystemdService(), 0o644);
183
- run("systemctl", ["daemon-reload"], { stdio: "inherit" });
184
- run("systemctl", ["enable", SERVICE_NAME], { stdio: "inherit" });
271
+ exec("systemctl", ["daemon-reload"]);
272
+ exec("systemctl", ["enable", SERVICE_NAME]);
185
273
  }
186
274
 
187
- function configureFirewall(config) {
188
- if (!commandExists("ufw")) {
189
- return;
190
- }
191
-
275
+ function buildUfwRules(config) {
192
276
  const rules = [
193
277
  ["22/tcp", "SSH"],
194
278
  ["80/tcp", "Bitcall ACME"],
@@ -202,15 +286,98 @@ function configureFirewall(config) {
202
286
  rules.push(["5061/tcp", "Bitcall SIP TLS"]);
203
287
  }
204
288
 
205
- for (const [port, label] of rules) {
206
- run("ufw", ["allow", port, "comment", label], {
289
+ if (config.turnMode === "coturn") {
290
+ rules.push([`${config.turnUdpPort}/udp`, "Bitcall TURN UDP"]);
291
+ rules.push([`${config.turnUdpPort}/tcp`, "Bitcall TURN TCP"]);
292
+ rules.push([`${config.turnsTcpPort}/tcp`, "Bitcall TURNS TCP"]);
293
+ rules.push([
294
+ `${config.turnRelayMinPort}:${config.turnRelayMaxPort}/udp`,
295
+ "Bitcall TURN relay UDP",
296
+ ]);
297
+ }
298
+
299
+ return rules;
300
+ }
301
+
302
+ function printRequiredPorts(config) {
303
+ console.log("Open these ports in your host/cloud firewall:");
304
+ for (const [port, label] of buildUfwRules(config)) {
305
+ console.log(` - ${port} (${label})`);
306
+ }
307
+ }
308
+
309
+ function configureFirewall(config, exec = run) {
310
+ for (const [port, label] of buildUfwRules(config)) {
311
+ exec("ufw", ["allow", port, "comment", label], {
207
312
  check: false,
208
- stdio: "ignore",
209
313
  });
210
314
  }
211
315
 
212
- run("ufw", ["--force", "enable"], { check: false, stdio: "ignore" });
213
- run("ufw", ["reload"], { check: false, stdio: "ignore" });
316
+ exec("ufw", ["--force", "enable"], { check: false });
317
+ exec("ufw", ["reload"], { check: false });
318
+ }
319
+
320
+ function isSingleProviderConfigured(config) {
321
+ return config.routingMode === "single-provider" && Boolean((config.sipProviderUri || "").trim());
322
+ }
323
+
324
+ function isOriginWildcard(originPattern) {
325
+ const value = (originPattern || "").trim();
326
+ return value === ".*" || value === "^.*$" || value === "*";
327
+ }
328
+
329
+ 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
+ );
336
+ }
337
+
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
+ }
346
+ }
347
+
348
+ function buildDevWarnings(config) {
349
+ const warnings = [];
350
+ if (countAllowedDomains(config.allowedDomains) === 0) {
351
+ warnings.push("Provider allowlist: any (DEV mode)");
352
+ }
353
+ if (isOriginWildcard(toOriginPattern(config.webphoneOrigin))) {
354
+ warnings.push("Webphone origin: any (DEV mode)");
355
+ }
356
+ if (!(config.sipTrustedIps || "").trim()) {
357
+ warnings.push("Trusted SIP source IPs: any (DEV mode)");
358
+ }
359
+ return warnings;
360
+ }
361
+
362
+ function buildQuickFlowDefaults(initProfile, existing = {}) {
363
+ const defaults = {
364
+ bitcallEnv: initProfile,
365
+ routingMode: "universal",
366
+ sipProviderUri: "",
367
+ sipTransport: "udp",
368
+ sipPort: "5060",
369
+ allowedDomains: existing.ALLOWED_SIP_DOMAINS || "",
370
+ webphoneOrigin: existing.WEBPHONE_ORIGIN || DEFAULT_WEBPHONE_ORIGIN,
371
+ sipTrustedIps: existing.SIP_TRUSTED_IPS || "",
372
+ };
373
+
374
+ if (initProfile === "dev") {
375
+ defaults.allowedDomains = "";
376
+ defaults.webphoneOrigin = "*";
377
+ defaults.sipTrustedIps = "";
378
+ }
379
+
380
+ return defaults;
214
381
  }
215
382
 
216
383
  function countAllowedDomains(raw) {
@@ -259,9 +426,33 @@ async function confirmContinueWithoutMediaBlock() {
259
426
  }
260
427
  }
261
428
 
262
- function generateSelfSigned(certPath, keyPath, domain, days = 1) {
429
+ async function confirmInstallUfw() {
430
+ const prompt = new Prompter();
431
+ try {
432
+ return await prompt.askYesNo("ufw is not installed. Install ufw now?", true);
433
+ } finally {
434
+ prompt.close();
435
+ }
436
+ }
437
+
438
+ async function ensureUfwAvailable(ctx) {
439
+ if (commandExists("ufw")) {
440
+ return true;
441
+ }
442
+
443
+ const installNow = await confirmInstallUfw();
444
+ if (!installNow) {
445
+ return false;
446
+ }
447
+
448
+ ctx.exec("apt-get", ["update"]);
449
+ ctx.exec("apt-get", ["install", "-y", "ufw"]);
450
+ return commandExists("ufw");
451
+ }
452
+
453
+ function generateSelfSigned(certPath, keyPath, domain, days = 1, exec = run) {
263
454
  ensureDir(path.dirname(certPath), 0o700);
264
- run(
455
+ exec(
265
456
  "openssl",
266
457
  [
267
458
  "req",
@@ -278,7 +469,7 @@ function generateSelfSigned(certPath, keyPath, domain, days = 1) {
278
469
  "-subj",
279
470
  `/CN=${domain}`,
280
471
  ],
281
- { stdio: "inherit" }
472
+ {}
282
473
  );
283
474
  fs.chmodSync(certPath, 0o644);
284
475
  fs.chmodSync(keyPath, 0o600);
@@ -353,7 +544,21 @@ function toOriginPattern(origin) {
353
544
  return `^${escapeRegex(origin)}$`;
354
545
  }
355
546
 
356
- async function runPreflight() {
547
+ function normalizeInitProfile(initOptions = {}, existing = {}) {
548
+ void existing;
549
+ if (initOptions.dev && initOptions.production) {
550
+ throw new Error("Use only one mode: --dev or --production.");
551
+ }
552
+ if (initOptions.production) {
553
+ return "production";
554
+ }
555
+ if (initOptions.dev) {
556
+ return "dev";
557
+ }
558
+ return "dev";
559
+ }
560
+
561
+ async function runPreflight(ctx) {
357
562
  if (process.platform !== "linux") {
358
563
  throw new Error("bitcall-gateway supports Linux hosts only.");
359
564
  }
@@ -365,13 +570,11 @@ async function runPreflight() {
365
570
  );
366
571
  }
367
572
 
368
- run("apt-get", ["update"], { stdio: "inherit" });
369
- run("apt-get", ["install", "-y", "curl", "ca-certificates", "lsof", "openssl", "gnupg"], {
370
- stdio: "inherit",
371
- });
573
+ ctx.exec("apt-get", ["update"]);
574
+ ctx.exec("apt-get", ["install", "-y", "curl", "ca-certificates", "lsof", "openssl", "gnupg"]);
372
575
 
373
- ensureDockerInstalled();
374
- ensureComposePlugin();
576
+ ensureDockerInstalled({ exec: ctx.exec, shell: ctx.shell });
577
+ ensureComposePlugin({ exec: ctx.exec });
375
578
 
376
579
  const freeGb = diskFreeGb("/");
377
580
  if (freeGb < 2) {
@@ -392,17 +595,85 @@ async function runPreflight() {
392
595
  }
393
596
 
394
597
  return {
598
+ os,
395
599
  p80,
396
600
  p443,
397
601
  };
398
602
  }
399
603
 
400
- async function runWizard(existing = {}, preflight = {}) {
604
+ function printSummary(config, devWarnings) {
605
+ 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}`);
637
+ }
638
+ }
639
+ }
640
+
641
+ function shouldRequireAllowlist(bitcallEnv, routingMode) {
642
+ return bitcallEnv === "production" && routingMode === "universal";
643
+ }
644
+
645
+ function parseProviderFromUri(uri = "") {
646
+ const clean = uri.replace(/^sip:/, "");
647
+ const [hostPort, transportPart] = clean.split(";");
648
+ const [host, port] = hostPort.split(":");
649
+ let transport = "udp";
650
+ if (transportPart && transportPart.includes("transport=")) {
651
+ transport = transportPart.split("transport=")[1] || "udp";
652
+ }
653
+ return {
654
+ host: host || DEFAULT_PROVIDER_HOST,
655
+ port: port || (transport === "tls" ? "5061" : "5060"),
656
+ transport,
657
+ };
658
+ }
659
+
660
+ async function runWizard(existing = {}, preflight = {}, initOptions = {}) {
401
661
  const prompt = new Prompter();
402
662
 
403
663
  try {
664
+ 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
+ }
671
+
404
672
  const detectedIp = detectPublicIp();
405
- const domain = await prompt.askText("Gateway domain", existing.DOMAIN || "", { required: true });
673
+ const domainDefault = initOptions.domain || existing.DOMAIN || "";
674
+ const domain = domainDefault
675
+ ? domainDefault
676
+ : await prompt.askText("Gateway domain", existing.DOMAIN || "", { required: true });
406
677
  let publicIp = existing.PUBLIC_IP || detectedIp || "";
407
678
  if (!publicIp) {
408
679
  publicIp = await prompt.askText("Public IPv4 (auto-detect failed)", "", { required: true });
@@ -418,22 +689,21 @@ async function runWizard(existing = {}, preflight = {}) {
418
689
  ? "reverse-proxy"
419
690
  : "standalone";
420
691
 
421
- const advanced = await prompt.askYesNo("Advanced setup", false);
422
-
423
692
  let deployMode = autoDeployMode;
424
693
  let tlsMode = "letsencrypt";
425
- let acmeEmail = existing.ACME_EMAIL || "";
694
+ let acmeEmail = initOptions.email || existing.ACME_EMAIL || "";
426
695
  let customCertPath = "";
427
696
  let customKeyPath = "";
428
- let bitcallEnv = existing.BITCALL_ENV || "production";
697
+ let bitcallEnv = initProfile;
429
698
  let routingMode = existing.ROUTING_MODE || "universal";
430
- let sipProviderHost = DEFAULT_PROVIDER_HOST;
431
- let sipTransport = "udp";
432
- let sipPort = "5060";
699
+ const providerFromEnv = parseProviderFromUri(existing.SIP_PROVIDER_URI || "");
700
+ let sipProviderHost = providerFromEnv.host || DEFAULT_PROVIDER_HOST;
701
+ let sipTransport = providerFromEnv.transport || "udp";
702
+ let sipPort = providerFromEnv.port || "5060";
433
703
  let sipProviderUri = "";
434
704
  let allowedDomains = existing.ALLOWED_SIP_DOMAINS || "";
435
705
  let sipTrustedIps = existing.SIP_TRUSTED_IPS || "";
436
- let turnMode = await prompt.askYesNo("Enable built-in TURN (coturn)?", true) ? "coturn" : "none";
706
+ let turnMode = "coturn";
437
707
  let turnSecret = "";
438
708
  let turnTtl = existing.TURN_TTL || "86400";
439
709
  let turnApiToken = existing.TURN_API_TOKEN || "";
@@ -441,14 +711,22 @@ async function runWizard(existing = {}, preflight = {}) {
441
711
  let turnExternalUsername = "";
442
712
  let turnExternalCredential = "";
443
713
  let webphoneOrigin = existing.WEBPHONE_ORIGIN || DEFAULT_WEBPHONE_ORIGIN;
444
- let configureUfw = true;
714
+ let configureUfw = await prompt.askYesNo("Configure UFW firewall rules now?", true);
445
715
  let mediaIpv4Only = existing.MEDIA_IPV4_ONLY ? existing.MEDIA_IPV4_ONLY === "1" : true;
446
716
 
447
- if (turnMode === "coturn") {
448
- turnSecret = crypto.randomBytes(32).toString("hex");
449
- }
450
-
451
- if (advanced) {
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;
722
+ routingMode = quickDefaults.routingMode;
723
+ sipProviderUri = quickDefaults.sipProviderUri;
724
+ sipTransport = quickDefaults.sipTransport;
725
+ sipPort = quickDefaults.sipPort;
726
+ allowedDomains = quickDefaults.allowedDomains;
727
+ webphoneOrigin = quickDefaults.webphoneOrigin;
728
+ sipTrustedIps = quickDefaults.sipTrustedIps;
729
+ } else {
452
730
  deployMode = await prompt.askChoice(
453
731
  "Deployment mode",
454
732
  ["standalone", "reverse-proxy"],
@@ -476,12 +754,9 @@ async function runWizard(existing = {}, preflight = {}) {
476
754
  acmeEmail = "";
477
755
  }
478
756
 
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
- );
484
-
757
+ if (!initOptions.dev && !initOptions.production) {
758
+ bitcallEnv = await prompt.askChoice("Environment", ["production", "dev"], bitcallEnv === "dev" ? 1 : 0);
759
+ }
485
760
  routingMode = await prompt.askChoice(
486
761
  "Routing mode",
487
762
  ["universal", "single-provider"],
@@ -520,12 +795,22 @@ async function runWizard(existing = {}, preflight = {}) {
520
795
  sipProviderUri = `sip:${sipProviderHost}:${sipPort};transport=${sipTransport}`;
521
796
  }
522
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
+
523
807
  sipTrustedIps = await prompt.askText(
524
808
  "Trusted SIP source IPs (optional, comma-separated)",
525
809
  sipTrustedIps
526
810
  );
527
811
 
528
- const turnDefaultIndex = turnMode === "external" ? 2 : turnMode === "coturn" ? 1 : 0;
812
+ const existingTurn = existing.TURN_MODE || "coturn";
813
+ const turnDefaultIndex = existingTurn === "external" ? 2 : existingTurn === "coturn" ? 1 : 0;
529
814
  turnMode = await prompt.askChoice("TURN mode", ["none", "coturn", "external"], turnDefaultIndex);
530
815
  if (turnMode === "coturn") {
531
816
  turnSecret = crypto.randomBytes(32).toString("hex");
@@ -559,16 +844,10 @@ async function runWizard(existing = {}, preflight = {}) {
559
844
  "Media IPv4-only mode (block IPv6 RTP/TURN on host firewall)",
560
845
  mediaIpv4Only
561
846
  );
562
-
563
- configureUfw = await prompt.askYesNo("Configure ufw firewall rules now", true);
564
- } else {
565
- acmeEmail = await prompt.askText("Let's Encrypt email", acmeEmail, { required: true });
566
847
  }
567
848
 
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
- );
849
+ if (turnMode === "coturn") {
850
+ turnSecret = crypto.randomBytes(32).toString("hex");
572
851
  }
573
852
 
574
853
  if (deployMode === "reverse-proxy") {
@@ -615,19 +894,12 @@ async function runWizard(existing = {}, preflight = {}) {
615
894
  configureUfw,
616
895
  };
617
896
 
618
- const allowedCount = countAllowedDomains(config.allowedDomains);
619
- console.log("\nSummary:");
620
- console.log(` Domain: ${config.domain}`);
621
- console.log(` Public IP: ${config.publicIp}`);
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
- );
897
+ if (config.bitcallEnv === "production") {
898
+ validateProductionConfig(config);
899
+ }
900
+
901
+ const devWarnings = config.bitcallEnv === "dev" ? buildDevWarnings(config) : [];
902
+ printSummary(config, devWarnings);
631
903
 
632
904
  const proceed = await prompt.askYesNo("Proceed with provisioning", true);
633
905
  if (!proceed) {
@@ -722,16 +994,16 @@ function provisionCustomCert(config) {
722
994
  return { certPath: certOut, keyPath: keyOut };
723
995
  }
724
996
 
725
- function startGatewayStack() {
726
- runCompose(["pull"], { stdio: "inherit" });
727
- runCompose(["up", "-d", "--remove-orphans"], { stdio: "inherit" });
997
+ function startGatewayStack(exec = run) {
998
+ runCompose(["pull"], {}, exec);
999
+ runCompose(["up", "-d", "--remove-orphans"], {}, exec);
728
1000
  }
729
1001
 
730
- function runLetsEncrypt(config) {
731
- run("apt-get", ["update"], { stdio: "inherit" });
732
- run("apt-get", ["install", "-y", "certbot"], { stdio: "inherit" });
1002
+ function runLetsEncrypt(config, exec = run) {
1003
+ exec("apt-get", ["update"]);
1004
+ exec("apt-get", ["install", "-y", "certbot"]);
733
1005
 
734
- run(
1006
+ exec(
735
1007
  "certbot",
736
1008
  [
737
1009
  "certonly",
@@ -745,7 +1017,7 @@ function runLetsEncrypt(config) {
745
1017
  "--agree-tos",
746
1018
  "--non-interactive",
747
1019
  ],
748
- { stdio: "inherit" }
1020
+ {}
749
1021
  );
750
1022
 
751
1023
  const liveCert = `/etc/letsencrypt/live/${config.domain}/fullchain.pem`;
@@ -757,80 +1029,106 @@ function runLetsEncrypt(config) {
757
1029
  });
758
1030
 
759
1031
  installRenewHook();
760
- runCompose(["up", "-d", "--remove-orphans"], { stdio: "inherit" });
1032
+ runCompose(["up", "-d", "--remove-orphans"], {}, exec);
761
1033
  }
762
1034
 
763
- function installGatewayService() {
764
- installSystemdService();
765
- run("systemctl", ["enable", "--now", SERVICE_NAME], { stdio: "inherit" });
1035
+ function installGatewayService(exec = run) {
1036
+ installSystemdService(exec);
1037
+ exec("systemctl", ["enable", "--now", SERVICE_NAME]);
766
1038
  }
767
1039
 
768
- async function initCommand() {
769
- const preflight = await runPreflight();
1040
+ async function initCommand(initOptions = {}) {
1041
+ printBanner();
1042
+ const ctx = createInstallContext(initOptions);
1043
+
1044
+ let preflight;
1045
+ await ctx.step("Checks", async () => {
1046
+ preflight = await runPreflight(ctx);
1047
+ });
770
1048
 
771
1049
  let existingEnv = {};
772
1050
  if (fs.existsSync(ENV_PATH)) {
773
1051
  existingEnv = loadEnvFile(ENV_PATH);
774
1052
  }
775
1053
 
776
- const config = await runWizard(existingEnv, preflight);
1054
+ let config;
1055
+ await ctx.step("Config", async () => {
1056
+ config = await runWizard(existingEnv, preflight, initOptions);
1057
+ });
777
1058
 
778
1059
  ensureInstallLayout();
779
1060
 
780
1061
  let tlsCertPath;
781
1062
  let tlsKeyPath;
782
1063
 
783
- if (config.tlsMode === "custom") {
784
- const custom = provisionCustomCert(config);
785
- tlsCertPath = custom.certPath;
786
- tlsKeyPath = custom.keyPath;
787
- } else if (config.tlsMode === "dev-self-signed") {
788
- tlsCertPath = path.join(SSL_DIR, "dev-fullchain.pem");
789
- tlsKeyPath = path.join(SSL_DIR, "dev-privkey.pem");
790
- generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 30);
791
- } else {
792
- tlsCertPath = path.join(SSL_DIR, "bootstrap-fullchain.pem");
793
- tlsKeyPath = path.join(SSL_DIR, "bootstrap-privkey.pem");
794
- generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 1);
795
- }
796
-
797
- const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
798
- writeFileWithMode(ENV_PATH, envContent, 0o600);
799
- writeComposeTemplate();
800
-
801
- if (config.configureUfw) {
802
- configureFirewall(config);
803
- }
1064
+ await ctx.step("Firewall", async () => {
1065
+ if (config.configureUfw) {
1066
+ const ufwReady = await ensureUfwAvailable(ctx);
1067
+ if (ufwReady) {
1068
+ configureFirewall(config, ctx.exec);
1069
+ } else {
1070
+ console.log("Skipping UFW configuration by request.");
1071
+ config.configureUfw = false;
1072
+ printRequiredPorts(config);
1073
+ }
1074
+ } else {
1075
+ printRequiredPorts(config);
1076
+ }
804
1077
 
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.");
1078
+ if (config.mediaIpv4Only === "1") {
1079
+ try {
1080
+ const applied = applyMediaIpv4OnlyRules(mediaFirewallOptionsFromConfig(config));
1081
+ console.log(`Applied IPv6 media block rules (${applied.backend}).`);
1082
+ } catch (error) {
1083
+ console.error(`IPv6 media block setup failed: ${error.message}`);
1084
+ const proceed = await confirmContinueWithoutMediaBlock();
1085
+ if (!proceed) {
1086
+ throw new Error("Initialization stopped because media IPv4-only firewall rules were not applied.");
1087
+ }
1088
+ config.mediaIpv4Only = "0";
1089
+ console.log("Continuing without IPv6 media block rules.");
814
1090
  }
815
- updateGatewayEnv({ MEDIA_IPV4_ONLY: "0" });
816
- config.mediaIpv4Only = "0";
817
- console.log("Continuing without IPv6 media block rules.");
818
1091
  }
819
- }
1092
+ });
820
1093
 
821
- startGatewayStack();
1094
+ await ctx.step("TLS", async () => {
1095
+ if (config.tlsMode === "custom") {
1096
+ const custom = provisionCustomCert(config);
1097
+ tlsCertPath = custom.certPath;
1098
+ tlsKeyPath = custom.keyPath;
1099
+ } else if (config.tlsMode === "dev-self-signed") {
1100
+ tlsCertPath = path.join(SSL_DIR, "dev-fullchain.pem");
1101
+ tlsKeyPath = path.join(SSL_DIR, "dev-privkey.pem");
1102
+ generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 30, ctx.exec);
1103
+ } else {
1104
+ tlsCertPath = path.join(SSL_DIR, "bootstrap-fullchain.pem");
1105
+ tlsKeyPath = path.join(SSL_DIR, "bootstrap-privkey.pem");
1106
+ generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 1, ctx.exec);
1107
+ }
822
1108
 
823
- if (config.tlsMode === "letsencrypt") {
824
- runLetsEncrypt(config);
825
- }
1109
+ const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
1110
+ writeFileWithMode(ENV_PATH, envContent, 0o600);
1111
+ writeComposeTemplate();
1112
+ });
826
1113
 
827
- installGatewayService();
1114
+ await ctx.step("Start", async () => {
1115
+ startGatewayStack(ctx.exec);
1116
+ if (config.tlsMode === "letsencrypt") {
1117
+ runLetsEncrypt(config, ctx.exec);
1118
+ }
1119
+ installGatewayService(ctx.exec);
1120
+ });
1121
+
1122
+ await ctx.step("Done", async () => {});
828
1123
 
829
1124
  console.log("\nGateway initialized.");
830
1125
  console.log(`WSS URL: wss://${config.domain}`);
831
1126
  if (config.turnMode !== "none") {
832
1127
  console.log(`TURN credentials URL: https://${config.domain}/turn-credentials`);
833
1128
  }
1129
+ if (!ctx.verbose) {
1130
+ console.log(`Install log: ${ctx.logPath}`);
1131
+ }
834
1132
  }
835
1133
 
836
1134
  function runSystemctl(args, fallbackComposeArgs) {
@@ -940,7 +1238,7 @@ function statusCommand() {
940
1238
 
941
1239
  console.log("\nConfig summary:");
942
1240
  console.log(`DOMAIN=${envMap.DOMAIN || ""}`);
943
- console.log(`BITCALL_ENV=${envMap.BITCALL_ENV || "production"}`);
1241
+ console.log(`BITCALL_ENV=${envMap.BITCALL_ENV || "dev"}`);
944
1242
  console.log(`ROUTING_MODE=${envMap.ROUTING_MODE || "universal"}`);
945
1243
  console.log(`SIP_PROVIDER_URI=${envMap.SIP_PROVIDER_URI || ""}`);
946
1244
  console.log(`DEPLOY_MODE=${envMap.DEPLOY_MODE || ""}`);
@@ -1129,7 +1427,16 @@ function buildProgram() {
1129
1427
  .description("Install and operate Bitcall WebRTC-to-SIP gateway")
1130
1428
  .version(PACKAGE_VERSION);
1131
1429
 
1132
- program.command("init").description("Run setup wizard and provision gateway").action(initCommand);
1430
+ program
1431
+ .command("init")
1432
+ .description("Run setup wizard and provision gateway")
1433
+ .option("--advanced", "Show advanced configuration prompts")
1434
+ .option("--dev", "Quick dev setup (minimal prompts, permissive defaults)")
1435
+ .option("--production", "Production setup (strict validation enabled)")
1436
+ .option("--domain <domain>", "Gateway domain")
1437
+ .option("--email <email>", "Let's Encrypt email")
1438
+ .option("--verbose", "Stream full installer command output")
1439
+ .action(initCommand);
1133
1440
  program.command("up").description("Start gateway services").action(upCommand);
1134
1441
  program.command("down").description("Stop gateway services").action(downCommand);
1135
1442
  program.command("stop").description("Alias for down").action(downCommand);
@@ -1179,6 +1486,14 @@ async function main(argv = process.argv) {
1179
1486
 
1180
1487
  module.exports = {
1181
1488
  main,
1489
+ normalizeInitProfile,
1490
+ validateProductionConfig,
1491
+ buildDevWarnings,
1492
+ buildQuickFlowDefaults,
1493
+ shouldRequireAllowlist,
1494
+ isOriginWildcard,
1495
+ isSingleProviderConfigured,
1496
+ printRequiredPorts,
1182
1497
  };
1183
1498
 
1184
1499
  if (require.main === module) {