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