@bitcall/webrtc-sip-gateway 0.2.6 → 0.2.7

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.7
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,19 @@ 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
+ Use `sudo bitcall-gateway init --production` for strict input validation and
26
+ hardening checks.
27
+ Use `--verbose` to stream apt/docker output during install. Default mode keeps
28
+ console output concise and writes command details to
29
+ `/var/log/bitcall-gateway-install.log`.
30
+
25
31
  ## Commands
26
32
 
27
33
  - `sudo bitcall-gateway init`
34
+ - `sudo bitcall-gateway init --dev`
35
+ - `sudo bitcall-gateway init --production`
36
+ - `sudo bitcall-gateway init --advanced`
37
+ - `sudo bitcall-gateway init --verbose`
28
38
  - `sudo bitcall-gateway up`
29
39
  - `sudo bitcall-gateway down`
30
40
  - `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.7",
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.7",
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.7";
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,20 @@ function toOriginPattern(origin) {
353
544
  return `^${escapeRegex(origin)}$`;
354
545
  }
355
546
 
356
- async function runPreflight() {
547
+ function normalizeInitProfile(initOptions = {}, existing = {}) {
548
+ if (initOptions.dev && initOptions.production) {
549
+ throw new Error("Use only one mode: --dev or --production.");
550
+ }
551
+ if (initOptions.production) {
552
+ return "production";
553
+ }
554
+ if (initOptions.dev) {
555
+ return "dev";
556
+ }
557
+ return existing.BITCALL_ENV || "dev";
558
+ }
559
+
560
+ async function runPreflight(ctx) {
357
561
  if (process.platform !== "linux") {
358
562
  throw new Error("bitcall-gateway supports Linux hosts only.");
359
563
  }
@@ -365,13 +569,11 @@ async function runPreflight() {
365
569
  );
366
570
  }
367
571
 
368
- run("apt-get", ["update"], { stdio: "inherit" });
369
- run("apt-get", ["install", "-y", "curl", "ca-certificates", "lsof", "openssl", "gnupg"], {
370
- stdio: "inherit",
371
- });
572
+ ctx.exec("apt-get", ["update"]);
573
+ ctx.exec("apt-get", ["install", "-y", "curl", "ca-certificates", "lsof", "openssl", "gnupg"]);
372
574
 
373
- ensureDockerInstalled();
374
- ensureComposePlugin();
575
+ ensureDockerInstalled({ exec: ctx.exec, shell: ctx.shell });
576
+ ensureComposePlugin({ exec: ctx.exec });
375
577
 
376
578
  const freeGb = diskFreeGb("/");
377
579
  if (freeGb < 2) {
@@ -392,17 +594,73 @@ async function runPreflight() {
392
594
  }
393
595
 
394
596
  return {
597
+ os,
395
598
  p80,
396
599
  p443,
397
600
  };
398
601
  }
399
602
 
400
- async function runWizard(existing = {}, preflight = {}) {
603
+ function printSummary(config, devWarnings) {
604
+ const allowedCount = countAllowedDomains(config.allowedDomains);
605
+ const showDevWarnings = config.bitcallEnv === "dev";
606
+ console.log("\nSummary:");
607
+ console.log(` Domain: ${config.domain}`);
608
+ console.log(` Environment: ${config.bitcallEnv}`);
609
+ console.log(` Routing: ${config.routingMode}`);
610
+ console.log(
611
+ ` Provider allowlist: ${allowedCount > 0 ? config.allowedDomains : "(any)"}${showDevWarnings && allowedCount === 0 ? " [DEV WARNING]" : ""}`
612
+ );
613
+ console.log(
614
+ ` Webphone origin: ${config.webphoneOrigin === "*" ? `(any)${showDevWarnings ? " [DEV WARNING]" : ""}` : config.webphoneOrigin}`
615
+ );
616
+ console.log(` SIP source IPs: ${(config.sipTrustedIps || "").trim() ? config.sipTrustedIps : "(any)"}`);
617
+ console.log(` TLS: ${config.tlsMode === "letsencrypt" ? "Let's Encrypt" : config.tlsMode}`);
618
+ console.log(` TURN: ${config.turnMode === "coturn" ? "enabled (coturn)" : config.turnMode}`);
619
+ console.log(
620
+ ` Media: ${config.mediaIpv4Only === "1" ? "IPv4-only (IPv6 media blocked)" : "dual-stack"}`
621
+ );
622
+ console.log(` Firewall: ${config.configureUfw ? "UFW enabled" : "manual setup"}`);
623
+
624
+ if (devWarnings.length > 0) {
625
+ console.log("\nWarnings:");
626
+ for (const warning of devWarnings) {
627
+ console.log(` - ${warning}`);
628
+ }
629
+ }
630
+ }
631
+
632
+ function parseProviderFromUri(uri = "") {
633
+ const clean = uri.replace(/^sip:/, "");
634
+ const [hostPort, transportPart] = clean.split(";");
635
+ const [host, port] = hostPort.split(":");
636
+ let transport = "udp";
637
+ if (transportPart && transportPart.includes("transport=")) {
638
+ transport = transportPart.split("transport=")[1] || "udp";
639
+ }
640
+ return {
641
+ host: host || DEFAULT_PROVIDER_HOST,
642
+ port: port || (transport === "tls" ? "5061" : "5060"),
643
+ transport,
644
+ };
645
+ }
646
+
647
+ async function runWizard(existing = {}, preflight = {}, initOptions = {}) {
401
648
  const prompt = new Prompter();
402
649
 
403
650
  try {
651
+ const initProfile = normalizeInitProfile(initOptions, existing);
652
+ let advanced = Boolean(initOptions.advanced);
653
+ if (initOptions.production && !initOptions.advanced) {
654
+ advanced = true;
655
+ } else if (!initOptions.dev && !initOptions.production && !initOptions.advanced) {
656
+ advanced = await prompt.askYesNo("Advanced setup", false);
657
+ }
658
+
404
659
  const detectedIp = detectPublicIp();
405
- const domain = await prompt.askText("Gateway domain", existing.DOMAIN || "", { required: true });
660
+ const domainDefault = initOptions.domain || existing.DOMAIN || "";
661
+ const domain = domainDefault
662
+ ? domainDefault
663
+ : await prompt.askText("Gateway domain", existing.DOMAIN || "", { required: true });
406
664
  let publicIp = existing.PUBLIC_IP || detectedIp || "";
407
665
  if (!publicIp) {
408
666
  publicIp = await prompt.askText("Public IPv4 (auto-detect failed)", "", { required: true });
@@ -418,22 +676,21 @@ async function runWizard(existing = {}, preflight = {}) {
418
676
  ? "reverse-proxy"
419
677
  : "standalone";
420
678
 
421
- const advanced = await prompt.askYesNo("Advanced setup", false);
422
-
423
679
  let deployMode = autoDeployMode;
424
680
  let tlsMode = "letsencrypt";
425
- let acmeEmail = existing.ACME_EMAIL || "";
681
+ let acmeEmail = initOptions.email || existing.ACME_EMAIL || "";
426
682
  let customCertPath = "";
427
683
  let customKeyPath = "";
428
- let bitcallEnv = existing.BITCALL_ENV || "production";
684
+ let bitcallEnv = initProfile;
429
685
  let routingMode = existing.ROUTING_MODE || "universal";
430
- let sipProviderHost = DEFAULT_PROVIDER_HOST;
431
- let sipTransport = "udp";
432
- let sipPort = "5060";
686
+ const providerFromEnv = parseProviderFromUri(existing.SIP_PROVIDER_URI || "");
687
+ let sipProviderHost = providerFromEnv.host || DEFAULT_PROVIDER_HOST;
688
+ let sipTransport = providerFromEnv.transport || "udp";
689
+ let sipPort = providerFromEnv.port || "5060";
433
690
  let sipProviderUri = "";
434
691
  let allowedDomains = existing.ALLOWED_SIP_DOMAINS || "";
435
692
  let sipTrustedIps = existing.SIP_TRUSTED_IPS || "";
436
- let turnMode = await prompt.askYesNo("Enable built-in TURN (coturn)?", true) ? "coturn" : "none";
693
+ let turnMode = "coturn";
437
694
  let turnSecret = "";
438
695
  let turnTtl = existing.TURN_TTL || "86400";
439
696
  let turnApiToken = existing.TURN_API_TOKEN || "";
@@ -441,14 +698,26 @@ async function runWizard(existing = {}, preflight = {}) {
441
698
  let turnExternalUsername = "";
442
699
  let turnExternalCredential = "";
443
700
  let webphoneOrigin = existing.WEBPHONE_ORIGIN || DEFAULT_WEBPHONE_ORIGIN;
444
- let configureUfw = true;
701
+ let configureUfw = initOptions.dev ? true : await prompt.askYesNo("Configure UFW firewall rules now?", true);
445
702
  let mediaIpv4Only = existing.MEDIA_IPV4_ONLY ? existing.MEDIA_IPV4_ONLY === "1" : true;
446
703
 
447
- if (turnMode === "coturn") {
448
- turnSecret = crypto.randomBytes(32).toString("hex");
449
- }
450
-
451
- if (advanced) {
704
+ if (!advanced) {
705
+ acmeEmail = acmeEmail || (await prompt.askText("Let's Encrypt email", "", { required: true }));
706
+ if (!initOptions.dev) {
707
+ turnMode = await prompt.askYesNo("Enable built-in TURN (coturn)?", true) ? "coturn" : "none";
708
+ } else {
709
+ turnMode = "coturn";
710
+ }
711
+ const quickDefaults = buildQuickFlowDefaults(initProfile, existing);
712
+ bitcallEnv = quickDefaults.bitcallEnv;
713
+ routingMode = quickDefaults.routingMode;
714
+ sipProviderUri = quickDefaults.sipProviderUri;
715
+ sipTransport = quickDefaults.sipTransport;
716
+ sipPort = quickDefaults.sipPort;
717
+ allowedDomains = quickDefaults.allowedDomains;
718
+ webphoneOrigin = quickDefaults.webphoneOrigin;
719
+ sipTrustedIps = quickDefaults.sipTrustedIps;
720
+ } else {
452
721
  deployMode = await prompt.askChoice(
453
722
  "Deployment mode",
454
723
  ["standalone", "reverse-proxy"],
@@ -476,9 +745,11 @@ async function runWizard(existing = {}, preflight = {}) {
476
745
  acmeEmail = "";
477
746
  }
478
747
 
479
- bitcallEnv = await prompt.askChoice("Environment", ["production", "dev"], bitcallEnv === "dev" ? 1 : 0);
748
+ if (!initOptions.dev && !initOptions.production) {
749
+ bitcallEnv = await prompt.askChoice("Environment", ["production", "dev"], bitcallEnv === "dev" ? 1 : 0);
750
+ }
480
751
  allowedDomains = await prompt.askText(
481
- "Allowed SIP domains (comma-separated; required in production)",
752
+ "Allowed SIP domains (comma-separated)",
482
753
  allowedDomains
483
754
  );
484
755
 
@@ -525,7 +796,8 @@ async function runWizard(existing = {}, preflight = {}) {
525
796
  sipTrustedIps
526
797
  );
527
798
 
528
- const turnDefaultIndex = turnMode === "external" ? 2 : turnMode === "coturn" ? 1 : 0;
799
+ const existingTurn = existing.TURN_MODE || "coturn";
800
+ const turnDefaultIndex = existingTurn === "external" ? 2 : existingTurn === "coturn" ? 1 : 0;
529
801
  turnMode = await prompt.askChoice("TURN mode", ["none", "coturn", "external"], turnDefaultIndex);
530
802
  if (turnMode === "coturn") {
531
803
  turnSecret = crypto.randomBytes(32).toString("hex");
@@ -559,16 +831,10 @@ async function runWizard(existing = {}, preflight = {}) {
559
831
  "Media IPv4-only mode (block IPv6 RTP/TURN on host firewall)",
560
832
  mediaIpv4Only
561
833
  );
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
834
  }
567
835
 
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
- );
836
+ if (turnMode === "coturn") {
837
+ turnSecret = crypto.randomBytes(32).toString("hex");
572
838
  }
573
839
 
574
840
  if (deployMode === "reverse-proxy") {
@@ -615,23 +881,18 @@ async function runWizard(existing = {}, preflight = {}) {
615
881
  configureUfw,
616
882
  };
617
883
 
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
- );
884
+ if (config.bitcallEnv === "production") {
885
+ validateProductionConfig(config);
886
+ }
887
+
888
+ const devWarnings = config.bitcallEnv === "dev" ? buildDevWarnings(config) : [];
889
+ printSummary(config, devWarnings);
631
890
 
632
- const proceed = await prompt.askYesNo("Proceed with provisioning", true);
633
- if (!proceed) {
634
- throw new Error("Initialization canceled.");
891
+ if (!(initOptions.dev && !advanced)) {
892
+ const proceed = await prompt.askYesNo("Proceed with provisioning", true);
893
+ if (!proceed) {
894
+ throw new Error("Initialization canceled.");
895
+ }
635
896
  }
636
897
 
637
898
  return config;
@@ -722,16 +983,16 @@ function provisionCustomCert(config) {
722
983
  return { certPath: certOut, keyPath: keyOut };
723
984
  }
724
985
 
725
- function startGatewayStack() {
726
- runCompose(["pull"], { stdio: "inherit" });
727
- runCompose(["up", "-d", "--remove-orphans"], { stdio: "inherit" });
986
+ function startGatewayStack(exec = run) {
987
+ runCompose(["pull"], {}, exec);
988
+ runCompose(["up", "-d", "--remove-orphans"], {}, exec);
728
989
  }
729
990
 
730
- function runLetsEncrypt(config) {
731
- run("apt-get", ["update"], { stdio: "inherit" });
732
- run("apt-get", ["install", "-y", "certbot"], { stdio: "inherit" });
991
+ function runLetsEncrypt(config, exec = run) {
992
+ exec("apt-get", ["update"]);
993
+ exec("apt-get", ["install", "-y", "certbot"]);
733
994
 
734
- run(
995
+ exec(
735
996
  "certbot",
736
997
  [
737
998
  "certonly",
@@ -745,7 +1006,7 @@ function runLetsEncrypt(config) {
745
1006
  "--agree-tos",
746
1007
  "--non-interactive",
747
1008
  ],
748
- { stdio: "inherit" }
1009
+ {}
749
1010
  );
750
1011
 
751
1012
  const liveCert = `/etc/letsencrypt/live/${config.domain}/fullchain.pem`;
@@ -757,80 +1018,106 @@ function runLetsEncrypt(config) {
757
1018
  });
758
1019
 
759
1020
  installRenewHook();
760
- runCompose(["up", "-d", "--remove-orphans"], { stdio: "inherit" });
1021
+ runCompose(["up", "-d", "--remove-orphans"], {}, exec);
761
1022
  }
762
1023
 
763
- function installGatewayService() {
764
- installSystemdService();
765
- run("systemctl", ["enable", "--now", SERVICE_NAME], { stdio: "inherit" });
1024
+ function installGatewayService(exec = run) {
1025
+ installSystemdService(exec);
1026
+ exec("systemctl", ["enable", "--now", SERVICE_NAME]);
766
1027
  }
767
1028
 
768
- async function initCommand() {
769
- const preflight = await runPreflight();
1029
+ async function initCommand(initOptions = {}) {
1030
+ printBanner();
1031
+ const ctx = createInstallContext(initOptions);
1032
+
1033
+ let preflight;
1034
+ await ctx.step("Checks", async () => {
1035
+ preflight = await runPreflight(ctx);
1036
+ });
770
1037
 
771
1038
  let existingEnv = {};
772
1039
  if (fs.existsSync(ENV_PATH)) {
773
1040
  existingEnv = loadEnvFile(ENV_PATH);
774
1041
  }
775
1042
 
776
- const config = await runWizard(existingEnv, preflight);
1043
+ let config;
1044
+ await ctx.step("Config", async () => {
1045
+ config = await runWizard(existingEnv, preflight, initOptions);
1046
+ });
777
1047
 
778
1048
  ensureInstallLayout();
779
1049
 
780
1050
  let tlsCertPath;
781
1051
  let tlsKeyPath;
782
1052
 
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
- }
1053
+ await ctx.step("Firewall", async () => {
1054
+ if (config.configureUfw) {
1055
+ const ufwReady = await ensureUfwAvailable(ctx);
1056
+ if (ufwReady) {
1057
+ configureFirewall(config, ctx.exec);
1058
+ } else {
1059
+ console.log("Skipping UFW configuration by request.");
1060
+ config.configureUfw = false;
1061
+ printRequiredPorts(config);
1062
+ }
1063
+ } else {
1064
+ printRequiredPorts(config);
1065
+ }
804
1066
 
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.");
1067
+ if (config.mediaIpv4Only === "1") {
1068
+ try {
1069
+ const applied = applyMediaIpv4OnlyRules(mediaFirewallOptionsFromConfig(config));
1070
+ console.log(`Applied IPv6 media block rules (${applied.backend}).`);
1071
+ } catch (error) {
1072
+ console.error(`IPv6 media block setup failed: ${error.message}`);
1073
+ const proceed = await confirmContinueWithoutMediaBlock();
1074
+ if (!proceed) {
1075
+ throw new Error("Initialization stopped because media IPv4-only firewall rules were not applied.");
1076
+ }
1077
+ config.mediaIpv4Only = "0";
1078
+ console.log("Continuing without IPv6 media block rules.");
814
1079
  }
815
- updateGatewayEnv({ MEDIA_IPV4_ONLY: "0" });
816
- config.mediaIpv4Only = "0";
817
- console.log("Continuing without IPv6 media block rules.");
818
1080
  }
819
- }
1081
+ });
820
1082
 
821
- startGatewayStack();
1083
+ await ctx.step("TLS", async () => {
1084
+ if (config.tlsMode === "custom") {
1085
+ const custom = provisionCustomCert(config);
1086
+ tlsCertPath = custom.certPath;
1087
+ tlsKeyPath = custom.keyPath;
1088
+ } else if (config.tlsMode === "dev-self-signed") {
1089
+ tlsCertPath = path.join(SSL_DIR, "dev-fullchain.pem");
1090
+ tlsKeyPath = path.join(SSL_DIR, "dev-privkey.pem");
1091
+ generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 30, ctx.exec);
1092
+ } else {
1093
+ tlsCertPath = path.join(SSL_DIR, "bootstrap-fullchain.pem");
1094
+ tlsKeyPath = path.join(SSL_DIR, "bootstrap-privkey.pem");
1095
+ generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 1, ctx.exec);
1096
+ }
822
1097
 
823
- if (config.tlsMode === "letsencrypt") {
824
- runLetsEncrypt(config);
825
- }
1098
+ const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
1099
+ writeFileWithMode(ENV_PATH, envContent, 0o600);
1100
+ writeComposeTemplate();
1101
+ });
826
1102
 
827
- installGatewayService();
1103
+ await ctx.step("Start", async () => {
1104
+ startGatewayStack(ctx.exec);
1105
+ if (config.tlsMode === "letsencrypt") {
1106
+ runLetsEncrypt(config, ctx.exec);
1107
+ }
1108
+ installGatewayService(ctx.exec);
1109
+ });
1110
+
1111
+ await ctx.step("Done", async () => {});
828
1112
 
829
1113
  console.log("\nGateway initialized.");
830
1114
  console.log(`WSS URL: wss://${config.domain}`);
831
1115
  if (config.turnMode !== "none") {
832
1116
  console.log(`TURN credentials URL: https://${config.domain}/turn-credentials`);
833
1117
  }
1118
+ if (!ctx.verbose) {
1119
+ console.log(`Install log: ${ctx.logPath}`);
1120
+ }
834
1121
  }
835
1122
 
836
1123
  function runSystemctl(args, fallbackComposeArgs) {
@@ -940,7 +1227,7 @@ function statusCommand() {
940
1227
 
941
1228
  console.log("\nConfig summary:");
942
1229
  console.log(`DOMAIN=${envMap.DOMAIN || ""}`);
943
- console.log(`BITCALL_ENV=${envMap.BITCALL_ENV || "production"}`);
1230
+ console.log(`BITCALL_ENV=${envMap.BITCALL_ENV || "dev"}`);
944
1231
  console.log(`ROUTING_MODE=${envMap.ROUTING_MODE || "universal"}`);
945
1232
  console.log(`SIP_PROVIDER_URI=${envMap.SIP_PROVIDER_URI || ""}`);
946
1233
  console.log(`DEPLOY_MODE=${envMap.DEPLOY_MODE || ""}`);
@@ -1129,7 +1416,16 @@ function buildProgram() {
1129
1416
  .description("Install and operate Bitcall WebRTC-to-SIP gateway")
1130
1417
  .version(PACKAGE_VERSION);
1131
1418
 
1132
- program.command("init").description("Run setup wizard and provision gateway").action(initCommand);
1419
+ program
1420
+ .command("init")
1421
+ .description("Run setup wizard and provision gateway")
1422
+ .option("--advanced", "Show advanced configuration prompts")
1423
+ .option("--dev", "Quick dev setup (minimal prompts, permissive defaults)")
1424
+ .option("--production", "Production setup (strict validation enabled)")
1425
+ .option("--domain <domain>", "Gateway domain")
1426
+ .option("--email <email>", "Let's Encrypt email")
1427
+ .option("--verbose", "Stream full installer command output")
1428
+ .action(initCommand);
1133
1429
  program.command("up").description("Start gateway services").action(upCommand);
1134
1430
  program.command("down").description("Stop gateway services").action(downCommand);
1135
1431
  program.command("stop").description("Alias for down").action(downCommand);
@@ -1179,6 +1475,13 @@ async function main(argv = process.argv) {
1179
1475
 
1180
1476
  module.exports = {
1181
1477
  main,
1478
+ normalizeInitProfile,
1479
+ validateProductionConfig,
1480
+ buildDevWarnings,
1481
+ buildQuickFlowDefaults,
1482
+ isOriginWildcard,
1483
+ isSingleProviderConfigured,
1484
+ printRequiredPorts,
1182
1485
  };
1183
1486
 
1184
1487
  if (require.main === module) {