@bitcall/webrtc-sip-gateway 0.2.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/src/index.js ADDED
@@ -0,0 +1,976 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const { Command } = require("commander");
7
+
8
+ const {
9
+ GATEWAY_DIR,
10
+ SERVICE_NAME,
11
+ SERVICE_FILE,
12
+ ACME_WEBROOT,
13
+ SSL_DIR,
14
+ ENV_PATH,
15
+ COMPOSE_PATH,
16
+ DEFAULT_GATEWAY_IMAGE,
17
+ DEFAULT_PROVIDER_HOST,
18
+ DEFAULT_WEBPHONE_ORIGIN,
19
+ RENEW_HOOK_PATH,
20
+ } = require("../lib/constants");
21
+ const { run, runShell, output, commandExists } = require("../lib/shell");
22
+ const { loadEnvFile, parseEnv, writeEnvFile } = require("../lib/envfile");
23
+ const { Prompter } = require("../lib/prompt");
24
+ const {
25
+ parseOsRelease,
26
+ isSupportedDistro,
27
+ detectPublicIp,
28
+ resolveDomainIpv4,
29
+ diskFreeGb,
30
+ memoryTotalMb,
31
+ portInUse,
32
+ ensureDockerInstalled,
33
+ ensureComposePlugin,
34
+ } = require("../lib/system");
35
+ const { readTemplate, renderTemplate } = require("../lib/template");
36
+
37
+ const PACKAGE_VERSION = "0.2.1";
38
+
39
+ function detectComposeCommand() {
40
+ if (run("docker", ["compose", "version"], { check: false }).status === 0) {
41
+ return { command: "docker", prefixArgs: ["compose"] };
42
+ }
43
+ if (run("docker-compose", ["version"], { check: false }).status === 0) {
44
+ return { command: "docker-compose", prefixArgs: [] };
45
+ }
46
+ throw new Error("docker compose not found. Install Docker compose plugin.");
47
+ }
48
+
49
+ function runCompose(args, options = {}) {
50
+ const compose = detectComposeCommand();
51
+ return run(compose.command, [...compose.prefixArgs, ...args], {
52
+ cwd: GATEWAY_DIR,
53
+ ...options,
54
+ });
55
+ }
56
+
57
+ function ensureInitialized() {
58
+ if (!fs.existsSync(ENV_PATH) || !fs.existsSync(COMPOSE_PATH)) {
59
+ throw new Error("Gateway is not initialized. Run: sudo bitcall-gateway init");
60
+ }
61
+ }
62
+
63
+ function ensureDir(target, mode) {
64
+ fs.mkdirSync(target, { recursive: true, mode });
65
+ fs.chmodSync(target, mode);
66
+ }
67
+
68
+ function writeFileWithMode(filePath, content, mode) {
69
+ fs.writeFileSync(filePath, content, { mode });
70
+ fs.chmodSync(filePath, mode);
71
+ }
72
+
73
+ function maskValue(key, value) {
74
+ if (/secret|token|password|key/i.test(key)) {
75
+ return value ? "***" : "";
76
+ }
77
+ return value;
78
+ }
79
+
80
+ function envOrder() {
81
+ return [
82
+ "GATEWAY_VERSION",
83
+ "BITCALL_GATEWAY_IMAGE",
84
+ "DOMAIN",
85
+ "PUBLIC_IP",
86
+ "DEPLOY_MODE",
87
+ "SIP_PROVIDER_URI",
88
+ "SIP_UPSTREAM_TRANSPORT",
89
+ "SIP_UPSTREAM_PORT",
90
+ "ALLOWED_SIP_DOMAINS",
91
+ "SIP_TRUSTED_IPS",
92
+ "TURN_MODE",
93
+ "TURN_SECRET",
94
+ "TURN_TTL",
95
+ "TURN_API_TOKEN",
96
+ "WEBPHONE_ORIGIN",
97
+ "WEBPHONE_ORIGIN_PATTERN",
98
+ "TLS_MODE",
99
+ "TLS_CERT",
100
+ "TLS_KEY",
101
+ "ACME_EMAIL",
102
+ "ACME_LISTEN_PORT",
103
+ "WSS_LISTEN_PORT",
104
+ "INTERNAL_WSS_PORT",
105
+ "INTERNAL_WS_PORT",
106
+ "RTPENGINE_MIN_PORT",
107
+ "RTPENGINE_MAX_PORT",
108
+ "WITH_REVERSE_PROXY",
109
+ "WITH_SIP_TLS",
110
+ ];
111
+ }
112
+
113
+ function asEnvEntries(envMap) {
114
+ const keys = Object.keys(envMap);
115
+ const order = envOrder();
116
+ const sorted = [];
117
+
118
+ for (const key of order) {
119
+ if (Object.prototype.hasOwnProperty.call(envMap, key)) {
120
+ sorted.push({ key, value: envMap[key] });
121
+ }
122
+ }
123
+
124
+ const extra = keys
125
+ .filter((key) => !order.includes(key))
126
+ .sort((a, b) => a.localeCompare(b));
127
+
128
+ for (const key of extra) {
129
+ sorted.push({ key, value: envMap[key] });
130
+ }
131
+
132
+ return sorted;
133
+ }
134
+
135
+ function saveGatewayEnv(envMap) {
136
+ writeEnvFile(ENV_PATH, asEnvEntries(envMap), 0o600);
137
+ fs.chmodSync(ENV_PATH, 0o600);
138
+ }
139
+
140
+ function updateGatewayEnv(changes) {
141
+ const envMap = loadEnvFile(ENV_PATH);
142
+ Object.assign(envMap, changes);
143
+ saveGatewayEnv(envMap);
144
+ }
145
+
146
+ function buildSystemdService() {
147
+ const dockerPath = output("sh", ["-lc", "command -v docker"]);
148
+ return `[Unit]
149
+ Description=Bitcall WebRTC-to-SIP Gateway
150
+ After=docker.service
151
+ Requires=docker.service
152
+
153
+ [Service]
154
+ Type=oneshot
155
+ RemainAfterExit=yes
156
+ WorkingDirectory=${GATEWAY_DIR}
157
+ ExecStart=${dockerPath} compose up -d --remove-orphans
158
+ ExecStop=${dockerPath} compose down
159
+ ExecReload=${dockerPath} compose restart
160
+ Restart=on-failure
161
+ RestartSec=10
162
+
163
+ [Install]
164
+ WantedBy=multi-user.target
165
+ `;
166
+ }
167
+
168
+ function installSystemdService() {
169
+ writeFileWithMode(SERVICE_FILE, buildSystemdService(), 0o644);
170
+ run("systemctl", ["daemon-reload"], { stdio: "inherit" });
171
+ run("systemctl", ["enable", SERVICE_NAME], { stdio: "inherit" });
172
+ }
173
+
174
+ function configureFirewall(config) {
175
+ if (!commandExists("ufw")) {
176
+ return;
177
+ }
178
+
179
+ const rules = [
180
+ ["22/tcp", "SSH"],
181
+ ["80/tcp", "Bitcall ACME"],
182
+ ["443/tcp", "Bitcall WSS"],
183
+ ["5060/udp", "Bitcall SIP UDP"],
184
+ ["5060/tcp", "Bitcall SIP TCP"],
185
+ [`${config.rtpMin}:${config.rtpMax}/udp`, "Bitcall RTP"],
186
+ ];
187
+
188
+ if (config.sipTransport === "tls") {
189
+ rules.push(["5061/tcp", "Bitcall SIP TLS"]);
190
+ }
191
+
192
+ for (const [port, label] of rules) {
193
+ run("ufw", ["allow", port, "comment", label], {
194
+ check: false,
195
+ stdio: "ignore",
196
+ });
197
+ }
198
+
199
+ run("ufw", ["--force", "enable"], { check: false, stdio: "ignore" });
200
+ run("ufw", ["reload"], { check: false, stdio: "ignore" });
201
+ }
202
+
203
+ function generateSelfSigned(certPath, keyPath, domain, days = 1) {
204
+ ensureDir(path.dirname(certPath), 0o700);
205
+ run(
206
+ "openssl",
207
+ [
208
+ "req",
209
+ "-x509",
210
+ "-newkey",
211
+ "rsa:2048",
212
+ "-keyout",
213
+ keyPath,
214
+ "-out",
215
+ certPath,
216
+ "-days",
217
+ String(days),
218
+ "-nodes",
219
+ "-subj",
220
+ `/CN=${domain}`,
221
+ ],
222
+ { stdio: "inherit" }
223
+ );
224
+ fs.chmodSync(certPath, 0o644);
225
+ fs.chmodSync(keyPath, 0o600);
226
+ }
227
+
228
+ function validateCustomCert(certPath, keyPath, domain) {
229
+ if (!fs.existsSync(certPath)) {
230
+ throw new Error(`Certificate file not found: ${certPath}`);
231
+ }
232
+ if (!fs.existsSync(keyPath)) {
233
+ throw new Error(`Private key file not found: ${keyPath}`);
234
+ }
235
+
236
+ const certMod = output("sh", ["-lc", `openssl x509 -noout -modulus -in '${certPath}' | md5sum | awk '{print $1}'`]);
237
+ const keyMod = output("sh", ["-lc", `openssl rsa -noout -modulus -in '${keyPath}' | md5sum | awk '{print $1}'`]);
238
+
239
+ if (!certMod || certMod !== keyMod) {
240
+ throw new Error("Certificate and private key do not match.");
241
+ }
242
+
243
+ const certCheck = run("openssl", ["x509", "-checkend", "0", "-noout", "-in", certPath], {
244
+ check: false,
245
+ });
246
+ if (certCheck.status !== 0) {
247
+ throw new Error("Certificate is expired.");
248
+ }
249
+
250
+ const sans = output("sh", ["-lc", `openssl x509 -noout -text -in '${certPath}' | grep -o 'DNS:[^,]*' | tr '\n' ',' || true`]);
251
+ if (domain && sans && !sans.includes(`DNS:${domain}`)) {
252
+ console.log(`Warning: certificate SANs do not explicitly include ${domain}`);
253
+ }
254
+ }
255
+
256
+ function certStatusFromPath(certPath) {
257
+ if (!certPath || !fs.existsSync(certPath)) {
258
+ return null;
259
+ }
260
+
261
+ const issuer = output("openssl", ["x509", "-in", certPath, "-noout", "-issuer"]);
262
+ const subject = output("openssl", ["x509", "-in", certPath, "-noout", "-subject"]);
263
+ const endDateRaw = output("openssl", ["x509", "-in", certPath, "-noout", "-enddate"]);
264
+ const endDate = endDateRaw.replace(/^notAfter=/, "").trim();
265
+
266
+ const endMs = Date.parse(endDate);
267
+ const days = Number.isFinite(endMs) ? Math.floor((endMs - Date.now()) / 86400000) : null;
268
+
269
+ return {
270
+ issuer,
271
+ subject,
272
+ endDate,
273
+ days,
274
+ };
275
+ }
276
+
277
+ function installRenewHook() {
278
+ ensureDir(path.dirname(RENEW_HOOK_PATH), 0o755);
279
+ const script = `#!/bin/sh
280
+ set -eu
281
+ systemctl reload ${SERVICE_NAME}
282
+ `;
283
+ writeFileWithMode(RENEW_HOOK_PATH, script, 0o755);
284
+ }
285
+
286
+ function escapeRegex(text) {
287
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
288
+ }
289
+
290
+ function toOriginPattern(origin) {
291
+ if (!origin || origin === "*") {
292
+ return ".*";
293
+ }
294
+ return `^${escapeRegex(origin)}$`;
295
+ }
296
+
297
+ async function runPreflight() {
298
+ if (process.platform !== "linux") {
299
+ throw new Error("bitcall-gateway supports Linux hosts only.");
300
+ }
301
+
302
+ const os = parseOsRelease();
303
+ if (!isSupportedDistro()) {
304
+ console.log(
305
+ `Warning: unsupported distro ${os.PRETTY_NAME || "unknown"}. Expected Ubuntu 22+ or Debian 12+.`
306
+ );
307
+ }
308
+
309
+ run("apt-get", ["update"], { stdio: "inherit" });
310
+ run("apt-get", ["install", "-y", "curl", "ca-certificates", "lsof", "openssl", "gnupg"], {
311
+ stdio: "inherit",
312
+ });
313
+
314
+ ensureDockerInstalled();
315
+ ensureComposePlugin();
316
+
317
+ const freeGb = diskFreeGb("/");
318
+ if (freeGb < 2) {
319
+ throw new Error(`At least 2GB free disk space required. Available: ${freeGb}GB`);
320
+ }
321
+
322
+ const ramMb = memoryTotalMb();
323
+ if (ramMb < 1024) {
324
+ console.log(`Warning: low memory detected (${ramMb}MB).`);
325
+ }
326
+
327
+ const p80 = portInUse(80);
328
+ const p443 = portInUse(443);
329
+ const p5060 = portInUse(5060);
330
+
331
+ if (p5060.inUse) {
332
+ throw new Error(`Port 5060 is already in use:\n${p5060.detail}`);
333
+ }
334
+
335
+ return {
336
+ p80,
337
+ p443,
338
+ };
339
+ }
340
+
341
+ async function runWizard(existing = {}, preflight = {}) {
342
+ const prompt = new Prompter();
343
+
344
+ try {
345
+ const detectedIp = detectPublicIp();
346
+ const domain = await prompt.askText("Gateway domain", existing.DOMAIN || "", { required: true });
347
+ const publicIp = await prompt.askText("Public IP", existing.PUBLIC_IP || detectedIp, {
348
+ required: true,
349
+ });
350
+
351
+ const resolved = resolveDomainIpv4(domain);
352
+ if (resolved.length > 0 && !resolved.includes(publicIp)) {
353
+ console.log(`Warning: DNS for ${domain} resolves to ${resolved.join(", ")}, not ${publicIp}.`);
354
+ }
355
+
356
+ const conflictModeDefault =
357
+ (preflight.p80 && preflight.p80.inUse) || (preflight.p443 && preflight.p443.inUse)
358
+ ? 1
359
+ : 0;
360
+ const deployMode = await prompt.askChoice(
361
+ "Deployment mode",
362
+ ["standalone", "reverse-proxy"],
363
+ conflictModeDefault
364
+ );
365
+
366
+ if (deployMode === "reverse-proxy") {
367
+ console.log("Note: forward /turn-credentials and websocket upgrade to 127.0.0.1:8443, and ACME path to 127.0.0.1:8080.");
368
+ }
369
+
370
+ const tlsMode = await prompt.askChoice(
371
+ "TLS certificate mode",
372
+ ["letsencrypt", "custom", "dev-self-signed"],
373
+ 0
374
+ );
375
+
376
+ let acmeEmail = existing.ACME_EMAIL || "";
377
+ let customCertPath = "";
378
+ let customKeyPath = "";
379
+
380
+ if (tlsMode === "letsencrypt") {
381
+ acmeEmail = await prompt.askText("Let's Encrypt email", acmeEmail, { required: true });
382
+ } else if (tlsMode === "custom") {
383
+ customCertPath = await prompt.askText("Path to TLS certificate (PEM)", existing.TLS_CERT || "", {
384
+ required: true,
385
+ });
386
+ customKeyPath = await prompt.askText("Path to TLS private key (PEM)", existing.TLS_KEY || "", {
387
+ required: true,
388
+ });
389
+ }
390
+
391
+ const sipProviderHost = await prompt.askText(
392
+ "SIP provider host",
393
+ existing.SIP_PROVIDER_URI
394
+ ? existing.SIP_PROVIDER_URI.replace(/^sip:/, "").split(":")[0]
395
+ : DEFAULT_PROVIDER_HOST,
396
+ { required: true }
397
+ );
398
+
399
+ const sipTransportChoice = await prompt.askChoice(
400
+ "SIP provider transport",
401
+ ["udp", "tcp", "tls", "custom"],
402
+ 0
403
+ );
404
+
405
+ let sipTransport = sipTransportChoice;
406
+ let sipPort = "5060";
407
+
408
+ if (sipTransportChoice === "tcp" || sipTransportChoice === "udp") {
409
+ sipPort = "5060";
410
+ } else if (sipTransportChoice === "tls") {
411
+ sipPort = "5061";
412
+ } else {
413
+ sipTransport = await prompt.askChoice("Custom transport", ["udp", "tcp", "tls"], 0);
414
+ const defaultPort = sipTransport === "tls" ? "5061" : "5060";
415
+ sipPort = await prompt.askText("Custom SIP port", defaultPort, { required: true });
416
+ }
417
+
418
+ const sipProviderUri = `sip:${sipProviderHost}:${sipPort};transport=${sipTransport}`;
419
+
420
+ const allowedDomains = await prompt.askText(
421
+ "Allowed SIP domains (comma-separated; blank enables dev mode)",
422
+ existing.ALLOWED_SIP_DOMAINS || sipProviderHost
423
+ );
424
+
425
+ if (!allowedDomains.trim()) {
426
+ console.log("Warning: ALLOWED_SIP_DOMAINS is empty (dev mode, open relay risk).");
427
+ }
428
+
429
+ const sipTrustedIps = await prompt.askText(
430
+ "Trusted SIP source IPs (optional, comma-separated)",
431
+ existing.SIP_TRUSTED_IPS || ""
432
+ );
433
+
434
+ const turnMode = await prompt.askChoice("TURN mode", ["none", "external", "coturn"], 0);
435
+
436
+ let turnSecret = existing.TURN_SECRET || "";
437
+ let turnTtl = existing.TURN_TTL || "86400";
438
+ let turnApiToken = existing.TURN_API_TOKEN || "";
439
+ let turnExternalUrls = "";
440
+ let turnExternalUsername = "";
441
+ let turnExternalCredential = "";
442
+
443
+ if (turnMode === "external") {
444
+ turnExternalUrls = await prompt.askText("External TURN urls", existing.TURN_EXTERNAL_URLS || "", {
445
+ required: true,
446
+ });
447
+ turnExternalUsername = await prompt.askText(
448
+ "External TURN username",
449
+ existing.TURN_EXTERNAL_USERNAME || ""
450
+ );
451
+ turnExternalCredential = await prompt.askText(
452
+ "External TURN credential",
453
+ existing.TURN_EXTERNAL_CREDENTIAL || ""
454
+ );
455
+ }
456
+
457
+ if (turnMode === "coturn") {
458
+ turnSecret = crypto.randomBytes(32).toString("hex");
459
+ turnTtl = await prompt.askText("TURN credential TTL seconds", turnTtl, { required: true });
460
+ turnApiToken = await prompt.askText("TURN API token (optional)", turnApiToken);
461
+ } else {
462
+ turnSecret = "";
463
+ turnApiToken = "";
464
+ }
465
+
466
+ const webphoneOrigin = await prompt.askText(
467
+ "Allowed webphone origin (* for any)",
468
+ existing.WEBPHONE_ORIGIN || DEFAULT_WEBPHONE_ORIGIN
469
+ );
470
+
471
+ const configureUfw = await prompt.askYesNo("Configure ufw firewall rules now", true);
472
+
473
+ const config = {
474
+ domain,
475
+ publicIp,
476
+ deployMode,
477
+ tlsMode,
478
+ acmeEmail,
479
+ customCertPath,
480
+ customKeyPath,
481
+ sipProviderHost,
482
+ sipTransport,
483
+ sipPort,
484
+ sipProviderUri,
485
+ allowedDomains,
486
+ sipTrustedIps,
487
+ turnMode,
488
+ turnSecret,
489
+ turnTtl,
490
+ turnApiToken,
491
+ turnExternalUrls,
492
+ turnExternalUsername,
493
+ turnExternalCredential,
494
+ webphoneOrigin,
495
+ rtpMin: "10000",
496
+ rtpMax: "20000",
497
+ acmeListenPort: deployMode === "reverse-proxy" ? "8080" : "80",
498
+ wssListenPort: deployMode === "reverse-proxy" ? "8443" : "443",
499
+ internalWssPort: "8443",
500
+ internalWsPort: "8080",
501
+ configureUfw,
502
+ };
503
+
504
+ console.log("\nSummary:");
505
+ console.log(` Domain: ${config.domain}`);
506
+ console.log(` Public IP: ${config.publicIp}`);
507
+ console.log(` Deploy mode: ${config.deployMode}`);
508
+ console.log(` TLS mode: ${config.tlsMode}`);
509
+ console.log(` SIP provider URI: ${config.sipProviderUri}`);
510
+ console.log(` Allowed SIP domains: ${config.allowedDomains || "(empty/dev-mode)"}`);
511
+ console.log(` TURN mode: ${config.turnMode}`);
512
+
513
+ const proceed = await prompt.askYesNo("Proceed with provisioning", true);
514
+ if (!proceed) {
515
+ throw new Error("Initialization canceled.");
516
+ }
517
+
518
+ return config;
519
+ } finally {
520
+ prompt.close();
521
+ }
522
+ }
523
+
524
+ function ensureInstallLayout() {
525
+ ensureDir(GATEWAY_DIR, 0o700);
526
+ ensureDir(ACME_WEBROOT, 0o755);
527
+ ensureDir(path.join(ACME_WEBROOT, ".well-known/acme-challenge"), 0o755);
528
+ ensureDir(SSL_DIR, 0o700);
529
+
530
+ const marker = path.join(ACME_WEBROOT, ".well-known/acme-challenge/healthcheck");
531
+ writeFileWithMode(marker, "ok\n", 0o644);
532
+ }
533
+
534
+ function renderEnvContent(config, tlsCert, tlsKey) {
535
+ const content = renderTemplate(".env.template", {
536
+ GATEWAY_VERSION: PACKAGE_VERSION,
537
+ BITCALL_GATEWAY_IMAGE: DEFAULT_GATEWAY_IMAGE,
538
+ DOMAIN: config.domain,
539
+ PUBLIC_IP: config.publicIp,
540
+ DEPLOY_MODE: config.deployMode,
541
+ SIP_PROVIDER_URI: config.sipProviderUri,
542
+ SIP_UPSTREAM_TRANSPORT: config.sipTransport,
543
+ SIP_UPSTREAM_PORT: config.sipPort,
544
+ ALLOWED_SIP_DOMAINS: config.allowedDomains,
545
+ SIP_TRUSTED_IPS: config.sipTrustedIps,
546
+ TURN_MODE: config.turnMode,
547
+ TURN_SECRET: config.turnSecret,
548
+ TURN_TTL: config.turnTtl,
549
+ TURN_API_TOKEN: config.turnApiToken,
550
+ WEBPHONE_ORIGIN: config.webphoneOrigin,
551
+ WEBPHONE_ORIGIN_PATTERN: toOriginPattern(config.webphoneOrigin),
552
+ TLS_MODE: config.tlsMode,
553
+ TLS_CERT: tlsCert,
554
+ TLS_KEY: tlsKey,
555
+ ACME_EMAIL: config.acmeEmail,
556
+ ACME_LISTEN_PORT: config.acmeListenPort,
557
+ WSS_LISTEN_PORT: config.wssListenPort,
558
+ INTERNAL_WSS_PORT: config.internalWssPort,
559
+ INTERNAL_WS_PORT: config.internalWsPort,
560
+ });
561
+
562
+ let extra = "";
563
+ extra += `RTPENGINE_MIN_PORT=${config.rtpMin}\n`;
564
+ extra += `RTPENGINE_MAX_PORT=${config.rtpMax}\n`;
565
+
566
+ if (config.deployMode === "reverse-proxy") {
567
+ extra += "WITH_REVERSE_PROXY=1\n";
568
+ }
569
+ if (config.sipTransport === "tls") {
570
+ extra += "WITH_SIP_TLS=1\n";
571
+ }
572
+ if (config.turnMode === "external") {
573
+ extra += `TURN_EXTERNAL_URLS=${config.turnExternalUrls}\n`;
574
+ extra += `TURN_EXTERNAL_USERNAME=${config.turnExternalUsername}\n`;
575
+ extra += `TURN_EXTERNAL_CREDENTIAL=${config.turnExternalCredential}\n`;
576
+ }
577
+
578
+ return content + extra;
579
+ }
580
+
581
+ function writeComposeTemplate() {
582
+ const compose = readTemplate("docker-compose.yml.template");
583
+ writeFileWithMode(COMPOSE_PATH, compose, 0o644);
584
+ }
585
+
586
+ function provisionCustomCert(config) {
587
+ validateCustomCert(config.customCertPath, config.customKeyPath, config.domain);
588
+
589
+ const certOut = path.join(SSL_DIR, "custom-cert.pem");
590
+ const keyOut = path.join(SSL_DIR, "custom-key.pem");
591
+ fs.copyFileSync(config.customCertPath, certOut);
592
+ fs.copyFileSync(config.customKeyPath, keyOut);
593
+ fs.chmodSync(certOut, 0o644);
594
+ fs.chmodSync(keyOut, 0o600);
595
+
596
+ return { certPath: certOut, keyPath: keyOut };
597
+ }
598
+
599
+ function startGatewayStack() {
600
+ runCompose(["pull"], { stdio: "inherit" });
601
+ runCompose(["up", "-d", "--remove-orphans"], { stdio: "inherit" });
602
+ }
603
+
604
+ function runLetsEncrypt(config) {
605
+ run("apt-get", ["update"], { stdio: "inherit" });
606
+ run("apt-get", ["install", "-y", "certbot"], { stdio: "inherit" });
607
+
608
+ run(
609
+ "certbot",
610
+ [
611
+ "certonly",
612
+ "--webroot",
613
+ "-w",
614
+ ACME_WEBROOT,
615
+ "-d",
616
+ config.domain,
617
+ "--email",
618
+ config.acmeEmail,
619
+ "--agree-tos",
620
+ "--non-interactive",
621
+ ],
622
+ { stdio: "inherit" }
623
+ );
624
+
625
+ const liveCert = `/etc/letsencrypt/live/${config.domain}/fullchain.pem`;
626
+ const liveKey = `/etc/letsencrypt/live/${config.domain}/privkey.pem`;
627
+
628
+ updateGatewayEnv({
629
+ TLS_CERT: liveCert,
630
+ TLS_KEY: liveKey,
631
+ });
632
+
633
+ installRenewHook();
634
+ runCompose(["up", "-d", "--remove-orphans"], { stdio: "inherit" });
635
+ }
636
+
637
+ function installGatewayService() {
638
+ installSystemdService();
639
+ run("systemctl", ["enable", "--now", SERVICE_NAME], { stdio: "inherit" });
640
+ }
641
+
642
+ async function initCommand() {
643
+ const preflight = await runPreflight();
644
+
645
+ let existingEnv = {};
646
+ if (fs.existsSync(ENV_PATH)) {
647
+ existingEnv = loadEnvFile(ENV_PATH);
648
+ }
649
+
650
+ const config = await runWizard(existingEnv, preflight);
651
+
652
+ ensureInstallLayout();
653
+
654
+ let tlsCertPath;
655
+ let tlsKeyPath;
656
+
657
+ if (config.tlsMode === "custom") {
658
+ const custom = provisionCustomCert(config);
659
+ tlsCertPath = custom.certPath;
660
+ tlsKeyPath = custom.keyPath;
661
+ } else if (config.tlsMode === "dev-self-signed") {
662
+ tlsCertPath = path.join(SSL_DIR, "dev-fullchain.pem");
663
+ tlsKeyPath = path.join(SSL_DIR, "dev-privkey.pem");
664
+ generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 30);
665
+ } else {
666
+ tlsCertPath = path.join(SSL_DIR, "bootstrap-fullchain.pem");
667
+ tlsKeyPath = path.join(SSL_DIR, "bootstrap-privkey.pem");
668
+ generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 1);
669
+ }
670
+
671
+ const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
672
+ writeFileWithMode(ENV_PATH, envContent, 0o600);
673
+ writeComposeTemplate();
674
+
675
+ if (config.configureUfw) {
676
+ configureFirewall(config);
677
+ }
678
+
679
+ startGatewayStack();
680
+
681
+ if (config.tlsMode === "letsencrypt") {
682
+ runLetsEncrypt(config);
683
+ }
684
+
685
+ installGatewayService();
686
+
687
+ console.log("\nGateway initialized.");
688
+ console.log(`WSS URL: wss://${config.domain}`);
689
+ if (config.turnMode !== "none") {
690
+ console.log(`TURN credentials URL: https://${config.domain}/turn-credentials`);
691
+ }
692
+ }
693
+
694
+ function runSystemctl(args, fallbackComposeArgs) {
695
+ const result = run("systemctl", args, { check: false, stdio: "inherit" });
696
+ if (result.status !== 0 && fallbackComposeArgs) {
697
+ ensureInitialized();
698
+ runCompose(fallbackComposeArgs, { stdio: "inherit" });
699
+ }
700
+ }
701
+
702
+ function upCommand() {
703
+ ensureInitialized();
704
+ runSystemctl(["start", SERVICE_NAME], ["up", "-d", "--remove-orphans"]);
705
+ }
706
+
707
+ function downCommand() {
708
+ ensureInitialized();
709
+ runSystemctl(["stop", SERVICE_NAME], ["down"]);
710
+ }
711
+
712
+ function restartCommand() {
713
+ ensureInitialized();
714
+ runSystemctl(["reload", SERVICE_NAME], ["restart"]);
715
+ }
716
+
717
+ function logsCommand(service, options) {
718
+ ensureInitialized();
719
+ const args = ["logs", "--tail", String(options.tail || 200)];
720
+ if (options.follow) {
721
+ args.push("-f");
722
+ }
723
+ if (service) {
724
+ args.push(service);
725
+ }
726
+ runCompose(args, { stdio: "inherit" });
727
+ }
728
+
729
+ function formatMark(state) {
730
+ return state ? "✓" : "✗";
731
+ }
732
+
733
+ function statusCommand() {
734
+ ensureInitialized();
735
+ const envMap = loadEnvFile(ENV_PATH);
736
+
737
+ const active = run("systemctl", ["is-active", "--quiet", SERVICE_NAME], {
738
+ check: false,
739
+ }).status === 0;
740
+ const enabled = run("systemctl", ["is-enabled", "--quiet", SERVICE_NAME], {
741
+ check: false,
742
+ }).status === 0;
743
+ const containerStatus = output("sh", [
744
+ "-lc",
745
+ "docker ps --filter name=^bitcall-gateway$ --format '{{.Status}}'",
746
+ ]);
747
+
748
+ const p80 = portInUse(Number.parseInt(envMap.ACME_LISTEN_PORT || "80", 10));
749
+ const p443 = portInUse(Number.parseInt(envMap.WSS_LISTEN_PORT || "443", 10));
750
+ const p5060 = portInUse(5060);
751
+
752
+ const cert = certStatusFromPath(envMap.TLS_CERT);
753
+ const rtpReady =
754
+ run("docker", ["exec", "bitcall-gateway", "rtpengine-ctl", "-ip", "127.0.0.1", "-port", "7722", "list", "numsessions"], {
755
+ check: false,
756
+ }).status === 0;
757
+
758
+ let turnReady = false;
759
+ if (envMap.TURN_MODE && envMap.TURN_MODE !== "none") {
760
+ turnReady =
761
+ run("docker", ["exec", "bitcall-gateway", "curl", "-fsS", "http://127.0.0.1:8880/turn-credentials"], {
762
+ check: false,
763
+ }).status === 0;
764
+ }
765
+
766
+ console.log("Bitcall Gateway Status\n");
767
+ console.log(`Gateway service: ${formatMark(active)} ${active ? "running" : "stopped"}`);
768
+ console.log(`Auto-start: ${formatMark(enabled)} ${enabled ? "enabled" : "disabled"}`);
769
+ console.log(`Container: ${containerStatus || "not running"}`);
770
+ console.log("");
771
+ console.log(`Port ${envMap.ACME_LISTEN_PORT || "80"}: ${formatMark(p80.inUse)} listening`);
772
+ console.log(`Port ${envMap.WSS_LISTEN_PORT || "443"}: ${formatMark(p443.inUse)} listening`);
773
+ console.log(`Port 5060: ${formatMark(p5060.inUse)} listening`);
774
+ console.log(`rtpengine control: ${formatMark(rtpReady)} reachable`);
775
+ if (envMap.TURN_MODE && envMap.TURN_MODE !== "none") {
776
+ console.log(`/turn-credentials: ${formatMark(turnReady)} reachable`);
777
+ }
778
+
779
+ if (cert) {
780
+ console.log("");
781
+ console.log(`Certificate: ${cert.subject}`);
782
+ console.log(`${cert.issuer}`);
783
+ console.log(`Expires: ${cert.endDate}${cert.days !== null ? ` (${cert.days} days)` : ""}`);
784
+ }
785
+
786
+ console.log("\nConfig summary:");
787
+ console.log(`DOMAIN=${envMap.DOMAIN || ""}`);
788
+ console.log(`SIP_PROVIDER_URI=${envMap.SIP_PROVIDER_URI || ""}`);
789
+ console.log(`DEPLOY_MODE=${envMap.DEPLOY_MODE || ""}`);
790
+ console.log(`ALLOWED_SIP_DOMAINS=${envMap.ALLOWED_SIP_DOMAINS || ""}`);
791
+ console.log(`TURN_MODE=${envMap.TURN_MODE || "none"}`);
792
+ }
793
+
794
+ function certStatusCommand() {
795
+ ensureInitialized();
796
+ const envMap = loadEnvFile(ENV_PATH);
797
+ const certPath = envMap.TLS_CERT;
798
+
799
+ if (!certPath) {
800
+ throw new Error("TLS_CERT not set in .env");
801
+ }
802
+
803
+ const status = certStatusFromPath(certPath);
804
+ if (!status) {
805
+ throw new Error(`Certificate not found at ${certPath}`);
806
+ }
807
+
808
+ console.log(`Certificate path: ${certPath}`);
809
+ console.log(status.subject);
810
+ console.log(status.issuer);
811
+ console.log(`Expires: ${status.endDate}`);
812
+ if (status.days !== null) {
813
+ console.log(`Days remaining: ${status.days}`);
814
+ }
815
+ }
816
+
817
+ function certRenewCommand() {
818
+ ensureInitialized();
819
+ const envMap = loadEnvFile(ENV_PATH);
820
+ if (envMap.TLS_MODE !== "letsencrypt") {
821
+ throw new Error("cert renew is only supported when TLS_MODE=letsencrypt");
822
+ }
823
+
824
+ run("certbot", ["renew"], { stdio: "inherit" });
825
+ run("systemctl", ["reload", SERVICE_NAME], { stdio: "inherit" });
826
+ }
827
+
828
+ async function certInstallCommand(options) {
829
+ ensureInitialized();
830
+
831
+ const prompt = new Prompter();
832
+ try {
833
+ const certIn = options.cert || (await prompt.askText("Certificate path", "", { required: true }));
834
+ const keyIn = options.key || (await prompt.askText("Private key path", "", { required: true }));
835
+
836
+ const envMap = loadEnvFile(ENV_PATH);
837
+ const domain = envMap.DOMAIN || "";
838
+
839
+ validateCustomCert(certIn, keyIn, domain);
840
+
841
+ ensureDir(SSL_DIR, 0o700);
842
+ const certOut = path.join(SSL_DIR, "custom-cert.pem");
843
+ const keyOut = path.join(SSL_DIR, "custom-key.pem");
844
+ fs.copyFileSync(certIn, certOut);
845
+ fs.copyFileSync(keyIn, keyOut);
846
+ fs.chmodSync(certOut, 0o644);
847
+ fs.chmodSync(keyOut, 0o600);
848
+
849
+ updateGatewayEnv({
850
+ TLS_MODE: "custom",
851
+ TLS_CERT: certOut,
852
+ TLS_KEY: keyOut,
853
+ ACME_EMAIL: "",
854
+ });
855
+
856
+ runSystemctl(["reload", SERVICE_NAME], ["up", "-d", "--remove-orphans"]);
857
+ console.log("Custom certificate installed.");
858
+ } finally {
859
+ prompt.close();
860
+ }
861
+ }
862
+
863
+ function updateCommand() {
864
+ ensureInitialized();
865
+ runCompose(["pull"], { stdio: "inherit" });
866
+ runSystemctl(["reload", SERVICE_NAME], ["up", "-d", "--remove-orphans"]);
867
+ }
868
+
869
+ async function uninstallCommand(options) {
870
+ if (!options.yes) {
871
+ const prompt = new Prompter();
872
+ try {
873
+ const token = await prompt.askText("Type 'uninstall' to confirm", "", { required: true });
874
+ if (token !== "uninstall") {
875
+ throw new Error("Uninstall canceled.");
876
+ }
877
+ } finally {
878
+ prompt.close();
879
+ }
880
+ }
881
+
882
+ run("systemctl", ["stop", SERVICE_NAME], { check: false, stdio: "inherit" });
883
+ run("systemctl", ["disable", SERVICE_NAME], { check: false, stdio: "inherit" });
884
+
885
+ if (fs.existsSync(SERVICE_FILE)) {
886
+ fs.rmSync(SERVICE_FILE, { force: true });
887
+ run("systemctl", ["daemon-reload"], { check: false, stdio: "inherit" });
888
+ }
889
+
890
+ if (fs.existsSync(COMPOSE_PATH)) {
891
+ runCompose(["down", "--rmi", "all", "--volumes"], {
892
+ check: false,
893
+ stdio: "inherit",
894
+ });
895
+ }
896
+
897
+ if (fs.existsSync(RENEW_HOOK_PATH)) {
898
+ fs.rmSync(RENEW_HOOK_PATH, { force: true });
899
+ }
900
+
901
+ if (fs.existsSync(GATEWAY_DIR)) {
902
+ fs.rmSync(GATEWAY_DIR, { recursive: true, force: true });
903
+ }
904
+
905
+ console.log("Gateway uninstalled.");
906
+ }
907
+
908
+ function configCommand() {
909
+ ensureInitialized();
910
+ const envMap = loadEnvFile(ENV_PATH);
911
+ for (const key of envOrder()) {
912
+ if (Object.prototype.hasOwnProperty.call(envMap, key)) {
913
+ console.log(`${key}=${maskValue(key, envMap[key])}`);
914
+ }
915
+ }
916
+ }
917
+
918
+ function buildProgram() {
919
+ const program = new Command();
920
+
921
+ program
922
+ .name("bitcall-gateway")
923
+ .description("Install and operate Bitcall WebRTC-to-SIP gateway")
924
+ .version(PACKAGE_VERSION);
925
+
926
+ program.command("init").description("Run setup wizard and provision gateway").action(initCommand);
927
+ program.command("up").description("Start gateway services").action(upCommand);
928
+ program.command("down").description("Stop gateway services").action(downCommand);
929
+ program.command("stop").description("Alias for down").action(downCommand);
930
+ program.command("restart").description("Restart gateway services").action(restartCommand);
931
+ program.command("status").description("Show diagnostic status").action(statusCommand);
932
+
933
+ program
934
+ .command("logs [service]")
935
+ .description("Show container logs")
936
+ .option("-f, --follow", "Follow logs", true)
937
+ .option("--no-follow", "Print and exit")
938
+ .option("--tail <lines>", "Number of lines", "200")
939
+ .action(logsCommand);
940
+
941
+ const cert = program.command("cert").description("Certificate operations");
942
+ cert.command("status").description("Show current certificate info").action(certStatusCommand);
943
+ cert.command("renew").description("Run certbot renew and reload gateway").action(certRenewCommand);
944
+ cert
945
+ .command("install")
946
+ .description("Install custom certificate and restart gateway")
947
+ .option("--cert <path>", "Certificate path")
948
+ .option("--key <path>", "Private key path")
949
+ .action(certInstallCommand);
950
+
951
+ program.command("update").description("Pull latest image and restart service").action(updateCommand);
952
+ program.command("config").description("Print active configuration (secrets hidden)").action(configCommand);
953
+ program
954
+ .command("uninstall")
955
+ .description("Remove gateway service and files")
956
+ .option("-y, --yes", "Skip confirmation prompt")
957
+ .action(uninstallCommand);
958
+
959
+ return program;
960
+ }
961
+
962
+ async function main(argv = process.argv) {
963
+ const program = buildProgram();
964
+ await program.parseAsync(argv);
965
+ }
966
+
967
+ module.exports = {
968
+ main,
969
+ };
970
+
971
+ if (require.main === module) {
972
+ main().catch((error) => {
973
+ console.error(error.message);
974
+ process.exit(1);
975
+ });
976
+ }