@bitcall/webrtc-sip-gateway 0.2.5 → 0.2.6
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 +9 -5
- package/lib/constants.js +1 -1
- package/lib/firewall.js +357 -0
- package/package.json +2 -2
- package/src/index.js +315 -126
- package/templates/.env.template +7 -2
- package/templates/docker-compose.yml.template +0 -3
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ 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.
|
|
8
|
+
sudo npm i -g @bitcall/webrtc-sip-gateway@0.2.6
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Main workflow
|
|
@@ -14,12 +14,13 @@ sudo npm i -g @bitcall/webrtc-sip-gateway@0.2.5
|
|
|
14
14
|
sudo bitcall-gateway init
|
|
15
15
|
sudo bitcall-gateway status
|
|
16
16
|
sudo bitcall-gateway logs -f
|
|
17
|
+
sudo bitcall-gateway media status
|
|
17
18
|
```
|
|
18
19
|
|
|
19
|
-
Default media policy is IPv4-only
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
Default media policy is IPv4-only via IPv6 media firewall drops on RTP/TURN
|
|
21
|
+
ports only. Host IPv6 remains enabled for signaling and non-media traffic.
|
|
22
|
+
Backend selection prefers nftables on non-UFW hosts and uses ip6tables when UFW
|
|
23
|
+
is active.
|
|
23
24
|
|
|
24
25
|
## Commands
|
|
25
26
|
|
|
@@ -33,6 +34,9 @@ IPv6 candidates.
|
|
|
33
34
|
- `sudo bitcall-gateway cert renew`
|
|
34
35
|
- `sudo bitcall-gateway cert install --cert /path/cert.pem --key /path/key.pem`
|
|
35
36
|
- `sudo bitcall-gateway update`
|
|
37
|
+
- `sudo bitcall-gateway media status`
|
|
38
|
+
- `sudo bitcall-gateway media ipv4-only on`
|
|
39
|
+
- `sudo bitcall-gateway media ipv4-only off`
|
|
36
40
|
- `sudo bitcall-gateway uninstall`
|
|
37
41
|
|
|
38
42
|
## Files created by init
|
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.
|
|
17
|
+
DEFAULT_GATEWAY_IMAGE: "ghcr.io/bitcallio/webrtc-sip-gateway:0.2.6",
|
|
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/firewall.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
const { run, runShell, output, commandExists } = require("./shell");
|
|
7
|
+
|
|
8
|
+
const MARKER = "bitcall-gateway media ipv6 block";
|
|
9
|
+
const NFT_TABLE = "bitcall_gateway_media_ipv6";
|
|
10
|
+
const NFT_RULE_FILE = "/etc/nftables.d/bitcall-gateway.nft";
|
|
11
|
+
const NFT_MAIN_CONF = "/etc/nftables.conf";
|
|
12
|
+
const NFT_INCLUDE_LINE = `include "${NFT_RULE_FILE}"`;
|
|
13
|
+
|
|
14
|
+
function withDeps(deps = {}) {
|
|
15
|
+
return {
|
|
16
|
+
fs,
|
|
17
|
+
run,
|
|
18
|
+
runShell,
|
|
19
|
+
output,
|
|
20
|
+
commandExists,
|
|
21
|
+
...deps,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toInt(value, fallback) {
|
|
26
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
27
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeOptions(options = {}) {
|
|
31
|
+
const rtpMin = toInt(options.rtpMin, 10000);
|
|
32
|
+
const rtpMax = toInt(options.rtpMax, 20000);
|
|
33
|
+
const turnEnabled = Boolean(options.turnEnabled);
|
|
34
|
+
const turnUdpPort = toInt(options.turnUdpPort, 3478);
|
|
35
|
+
const turnsTcpPort = toInt(options.turnsTcpPort, 5349);
|
|
36
|
+
const turnRelayMin = toInt(options.turnRelayMin, 49152);
|
|
37
|
+
const turnRelayMax = toInt(options.turnRelayMax, 49252);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
rtpMin,
|
|
41
|
+
rtpMax,
|
|
42
|
+
turnEnabled,
|
|
43
|
+
turnUdpPort,
|
|
44
|
+
turnsTcpPort,
|
|
45
|
+
turnRelayMin,
|
|
46
|
+
turnRelayMax,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function detectFirewallBackend(deps = {}) {
|
|
51
|
+
const d = withDeps(deps);
|
|
52
|
+
let ufwActive = false;
|
|
53
|
+
if (d.commandExists("ufw")) {
|
|
54
|
+
const ufwStatus = d.run("ufw", ["status"], { check: false, stdio: "pipe" });
|
|
55
|
+
ufwActive = ufwStatus.status === 0 && ((ufwStatus.stdout || "").toLowerCase().includes("status: active"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (ufwActive && d.commandExists("ip6tables")) {
|
|
59
|
+
return "ip6tables";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (d.commandExists("nft")) {
|
|
63
|
+
return "nft";
|
|
64
|
+
}
|
|
65
|
+
if (d.commandExists("ip6tables")) {
|
|
66
|
+
return "ip6tables";
|
|
67
|
+
}
|
|
68
|
+
throw new Error(
|
|
69
|
+
"No IPv6 firewall backend found. Install nftables or ip6tables/netfilter-persistent."
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildIp6tablesRules(options = {}) {
|
|
74
|
+
const cfg = normalizeOptions(options);
|
|
75
|
+
const rules = [
|
|
76
|
+
{ proto: "udp", dport: `${cfg.rtpMin}:${cfg.rtpMax}` },
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
if (cfg.turnEnabled) {
|
|
80
|
+
rules.push({ proto: "udp", dport: String(cfg.turnUdpPort) });
|
|
81
|
+
rules.push({ proto: "tcp", dport: String(cfg.turnsTcpPort) });
|
|
82
|
+
rules.push({ proto: "udp", dport: `${cfg.turnRelayMin}:${cfg.turnRelayMax}` });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return rules;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildNftRuleset(options = {}) {
|
|
89
|
+
const rules = buildIp6tablesRules(options);
|
|
90
|
+
const lines = [
|
|
91
|
+
`table inet ${NFT_TABLE} {`,
|
|
92
|
+
" chain input {",
|
|
93
|
+
" type filter hook input priority 0; policy accept;",
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
for (const rule of rules) {
|
|
97
|
+
lines.push(
|
|
98
|
+
` meta nfproto ipv6 ${rule.proto} dport ${rule.dport} comment \"${MARKER}\" drop`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
lines.push(" }");
|
|
103
|
+
lines.push("}");
|
|
104
|
+
lines.push("");
|
|
105
|
+
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function persistIp6tables(d) {
|
|
110
|
+
if (!d.commandExists("netfilter-persistent")) {
|
|
111
|
+
d.run("apt-get", ["update"], { stdio: "inherit" });
|
|
112
|
+
d.run("apt-get", ["install", "-y", "netfilter-persistent", "iptables-persistent"], {
|
|
113
|
+
check: false,
|
|
114
|
+
stdio: "inherit",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!d.commandExists("netfilter-persistent")) {
|
|
119
|
+
throw new Error("Unable to persist ip6tables rules: netfilter-persistent not available.");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const saved = d.run("netfilter-persistent", ["save"], {
|
|
123
|
+
check: false,
|
|
124
|
+
stdio: "inherit",
|
|
125
|
+
});
|
|
126
|
+
if (saved.status !== 0) {
|
|
127
|
+
throw new Error("Failed to persist ip6tables rules with netfilter-persistent save.");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ensureNftInclude(d) {
|
|
132
|
+
let content = "";
|
|
133
|
+
if (d.fs.existsSync(NFT_MAIN_CONF)) {
|
|
134
|
+
content = d.fs.readFileSync(NFT_MAIN_CONF, "utf8");
|
|
135
|
+
} else {
|
|
136
|
+
content = "#!/usr/sbin/nft -f\n\n";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!content.includes(NFT_INCLUDE_LINE)) {
|
|
140
|
+
const suffix = content.endsWith("\n") ? "" : "\n";
|
|
141
|
+
content = `${content}${suffix}${NFT_INCLUDE_LINE}\n`;
|
|
142
|
+
d.fs.writeFileSync(NFT_MAIN_CONF, content, { mode: 0o644 });
|
|
143
|
+
d.fs.chmodSync(NFT_MAIN_CONF, 0o644);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function removeNftInclude(d) {
|
|
148
|
+
if (!d.fs.existsSync(NFT_MAIN_CONF)) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const content = d.fs.readFileSync(NFT_MAIN_CONF, "utf8");
|
|
153
|
+
const lines = content.split("\n").filter((line) => line.trim() !== NFT_INCLUDE_LINE);
|
|
154
|
+
const next = `${lines.join("\n").replace(/\n+$/g, "")}\n`;
|
|
155
|
+
d.fs.writeFileSync(NFT_MAIN_CONF, next, { mode: 0o644 });
|
|
156
|
+
d.fs.chmodSync(NFT_MAIN_CONF, 0o644);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function applyNftRules(options, d) {
|
|
160
|
+
d.fs.mkdirSync(path.dirname(NFT_RULE_FILE), { recursive: true, mode: 0o755 });
|
|
161
|
+
d.fs.writeFileSync(NFT_RULE_FILE, buildNftRuleset(options), { mode: 0o644 });
|
|
162
|
+
d.fs.chmodSync(NFT_RULE_FILE, 0o644);
|
|
163
|
+
|
|
164
|
+
d.run("nft", ["delete", "table", "inet", NFT_TABLE], {
|
|
165
|
+
check: false,
|
|
166
|
+
stdio: "ignore",
|
|
167
|
+
});
|
|
168
|
+
d.run("nft", ["-f", NFT_RULE_FILE], { stdio: "inherit" });
|
|
169
|
+
|
|
170
|
+
ensureNftInclude(d);
|
|
171
|
+
|
|
172
|
+
const enabled = d.run("systemctl", ["enable", "--now", "nftables"], {
|
|
173
|
+
check: false,
|
|
174
|
+
stdio: "inherit",
|
|
175
|
+
});
|
|
176
|
+
if (enabled.status !== 0) {
|
|
177
|
+
throw new Error("Failed to enable nftables service for persistent IPv6 media block rules.");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function removeNftRules(d) {
|
|
182
|
+
d.run("nft", ["delete", "table", "inet", NFT_TABLE], {
|
|
183
|
+
check: false,
|
|
184
|
+
stdio: "ignore",
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (d.fs.existsSync(NFT_RULE_FILE)) {
|
|
188
|
+
d.fs.rmSync(NFT_RULE_FILE, { force: true });
|
|
189
|
+
}
|
|
190
|
+
removeNftInclude(d);
|
|
191
|
+
|
|
192
|
+
d.run("systemctl", ["reload", "nftables"], {
|
|
193
|
+
check: false,
|
|
194
|
+
stdio: "ignore",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function ip6tableRuleArgs(rule) {
|
|
199
|
+
return [
|
|
200
|
+
"-p",
|
|
201
|
+
rule.proto,
|
|
202
|
+
"--dport",
|
|
203
|
+
rule.dport,
|
|
204
|
+
"-m",
|
|
205
|
+
"comment",
|
|
206
|
+
"--comment",
|
|
207
|
+
MARKER,
|
|
208
|
+
"-j",
|
|
209
|
+
"DROP",
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function applyIp6tablesRules(options, d) {
|
|
214
|
+
const rules = buildIp6tablesRules(options);
|
|
215
|
+
|
|
216
|
+
for (const rule of rules) {
|
|
217
|
+
const args = ip6tableRuleArgs(rule);
|
|
218
|
+
const exists = d.run("ip6tables", ["-C", "INPUT", ...args], {
|
|
219
|
+
check: false,
|
|
220
|
+
stdio: "ignore",
|
|
221
|
+
}).status === 0;
|
|
222
|
+
|
|
223
|
+
if (!exists) {
|
|
224
|
+
d.run("ip6tables", ["-I", "INPUT", "1", ...args], {
|
|
225
|
+
stdio: "inherit",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
persistIp6tables(d);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function removeIp6tablesRules(options, d) {
|
|
234
|
+
const rules = buildIp6tablesRules(options);
|
|
235
|
+
|
|
236
|
+
for (const rule of rules) {
|
|
237
|
+
const args = ip6tableRuleArgs(rule);
|
|
238
|
+
for (;;) {
|
|
239
|
+
const exists = d.run("ip6tables", ["-C", "INPUT", ...args], {
|
|
240
|
+
check: false,
|
|
241
|
+
stdio: "ignore",
|
|
242
|
+
}).status === 0;
|
|
243
|
+
|
|
244
|
+
if (!exists) {
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
d.run("ip6tables", ["-D", "INPUT", ...args], {
|
|
249
|
+
stdio: "inherit",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
persistIp6tables(d);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isNftPresent(d) {
|
|
258
|
+
const res = d.run("nft", ["list", "table", "inet", NFT_TABLE], {
|
|
259
|
+
check: false,
|
|
260
|
+
stdio: "pipe",
|
|
261
|
+
});
|
|
262
|
+
if (res.status !== 0) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
return (res.stdout || "").includes(MARKER);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isIp6tablesPresent(d) {
|
|
269
|
+
const rules = d.output("sh", ["-lc", "ip6tables-save 2>/dev/null || true"]);
|
|
270
|
+
return rules.includes(MARKER);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function applyMediaIpv4OnlyRules(options = {}, runtime = {}) {
|
|
274
|
+
const d = withDeps(runtime.deps);
|
|
275
|
+
const backend = runtime.backend || detectFirewallBackend(d);
|
|
276
|
+
|
|
277
|
+
if (backend === "nft") {
|
|
278
|
+
applyNftRules(options, d);
|
|
279
|
+
return { backend };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (backend === "ip6tables") {
|
|
283
|
+
applyIp6tablesRules(options, d);
|
|
284
|
+
return { backend };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
throw new Error(`Unsupported firewall backend: ${backend}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function removeMediaIpv4OnlyRules(options = {}, runtime = {}) {
|
|
291
|
+
const d = withDeps(runtime.deps);
|
|
292
|
+
const backend = runtime.backend || detectFirewallBackend(d);
|
|
293
|
+
|
|
294
|
+
if (backend === "nft") {
|
|
295
|
+
removeNftRules(d);
|
|
296
|
+
return { backend };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (backend === "ip6tables") {
|
|
300
|
+
removeIp6tablesRules(options, d);
|
|
301
|
+
return { backend };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
throw new Error(`Unsupported firewall backend: ${backend}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function isMediaIpv4OnlyRulesPresent(runtime = {}) {
|
|
308
|
+
const d = withDeps(runtime.deps);
|
|
309
|
+
let backend = runtime.backend;
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
backend = backend || detectFirewallBackend(d);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
return {
|
|
315
|
+
enabled: false,
|
|
316
|
+
backend: null,
|
|
317
|
+
error: error.message,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (backend === "nft") {
|
|
322
|
+
return {
|
|
323
|
+
enabled: isNftPresent(d),
|
|
324
|
+
backend,
|
|
325
|
+
marker: MARKER,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (backend === "ip6tables") {
|
|
330
|
+
return {
|
|
331
|
+
enabled: isIp6tablesPresent(d),
|
|
332
|
+
backend,
|
|
333
|
+
marker: MARKER,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
enabled: false,
|
|
339
|
+
backend,
|
|
340
|
+
marker: MARKER,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = {
|
|
345
|
+
MARKER,
|
|
346
|
+
NFT_TABLE,
|
|
347
|
+
NFT_RULE_FILE,
|
|
348
|
+
NFT_MAIN_CONF,
|
|
349
|
+
NFT_INCLUDE_LINE,
|
|
350
|
+
normalizeOptions,
|
|
351
|
+
detectFirewallBackend,
|
|
352
|
+
buildIp6tablesRules,
|
|
353
|
+
buildNftRuleset,
|
|
354
|
+
applyMediaIpv4OnlyRules,
|
|
355
|
+
removeMediaIpv4OnlyRules,
|
|
356
|
+
isMediaIpv4OnlyRulesPresent,
|
|
357
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitcall/webrtc-sip-gateway",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
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",
|
|
25
|
+
"test": "node test/smoke.test.js && node test/firewall.test.js",
|
|
26
26
|
"pack:dry": "npm pack --dry-run"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
package/src/index.js
CHANGED
|
@@ -33,8 +33,14 @@ const {
|
|
|
33
33
|
ensureComposePlugin,
|
|
34
34
|
} = require("../lib/system");
|
|
35
35
|
const { readTemplate, renderTemplate } = require("../lib/template");
|
|
36
|
+
const {
|
|
37
|
+
detectFirewallBackend,
|
|
38
|
+
applyMediaIpv4OnlyRules,
|
|
39
|
+
removeMediaIpv4OnlyRules,
|
|
40
|
+
isMediaIpv4OnlyRulesPresent,
|
|
41
|
+
} = require("../lib/firewall");
|
|
36
42
|
|
|
37
|
-
const PACKAGE_VERSION = "0.2.
|
|
43
|
+
const PACKAGE_VERSION = "0.2.6";
|
|
38
44
|
|
|
39
45
|
function detectComposeCommand() {
|
|
40
46
|
if (run("docker", ["compose", "version"], { check: false }).status === 0) {
|
|
@@ -81,9 +87,11 @@ function envOrder() {
|
|
|
81
87
|
return [
|
|
82
88
|
"GATEWAY_VERSION",
|
|
83
89
|
"BITCALL_GATEWAY_IMAGE",
|
|
90
|
+
"BITCALL_ENV",
|
|
84
91
|
"DOMAIN",
|
|
85
92
|
"PUBLIC_IP",
|
|
86
93
|
"DEPLOY_MODE",
|
|
94
|
+
"ROUTING_MODE",
|
|
87
95
|
"SIP_PROVIDER_URI",
|
|
88
96
|
"SIP_UPSTREAM_TRANSPORT",
|
|
89
97
|
"SIP_UPSTREAM_PORT",
|
|
@@ -103,8 +111,11 @@ function envOrder() {
|
|
|
103
111
|
"WSS_LISTEN_PORT",
|
|
104
112
|
"INTERNAL_WSS_PORT",
|
|
105
113
|
"INTERNAL_WS_PORT",
|
|
106
|
-
"
|
|
107
|
-
"
|
|
114
|
+
"MEDIA_IPV4_ONLY",
|
|
115
|
+
"TURN_UDP_PORT",
|
|
116
|
+
"TURNS_TCP_PORT",
|
|
117
|
+
"TURN_RELAY_MIN_PORT",
|
|
118
|
+
"TURN_RELAY_MAX_PORT",
|
|
108
119
|
"RTPENGINE_MIN_PORT",
|
|
109
120
|
"RTPENGINE_MAX_PORT",
|
|
110
121
|
"WITH_REVERSE_PROXY",
|
|
@@ -202,6 +213,52 @@ function configureFirewall(config) {
|
|
|
202
213
|
run("ufw", ["reload"], { check: false, stdio: "ignore" });
|
|
203
214
|
}
|
|
204
215
|
|
|
216
|
+
function countAllowedDomains(raw) {
|
|
217
|
+
if (!raw) {
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
return raw
|
|
221
|
+
.split(",")
|
|
222
|
+
.map((item) => item.trim())
|
|
223
|
+
.filter(Boolean).length;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function mediaFirewallOptionsFromConfig(config) {
|
|
227
|
+
return {
|
|
228
|
+
rtpMin: Number.parseInt(config.rtpMin || "10000", 10),
|
|
229
|
+
rtpMax: Number.parseInt(config.rtpMax || "20000", 10),
|
|
230
|
+
turnEnabled: config.turnMode === "coturn",
|
|
231
|
+
turnUdpPort: Number.parseInt(config.turnUdpPort || "3478", 10),
|
|
232
|
+
turnsTcpPort: Number.parseInt(config.turnsTcpPort || "5349", 10),
|
|
233
|
+
turnRelayMin: Number.parseInt(config.turnRelayMinPort || "49152", 10),
|
|
234
|
+
turnRelayMax: Number.parseInt(config.turnRelayMaxPort || "49252", 10),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function mediaFirewallOptionsFromEnv(envMap) {
|
|
239
|
+
return {
|
|
240
|
+
rtpMin: Number.parseInt(envMap.RTPENGINE_MIN_PORT || "10000", 10),
|
|
241
|
+
rtpMax: Number.parseInt(envMap.RTPENGINE_MAX_PORT || "20000", 10),
|
|
242
|
+
turnEnabled: (envMap.TURN_MODE || "none") === "coturn",
|
|
243
|
+
turnUdpPort: Number.parseInt(envMap.TURN_UDP_PORT || "3478", 10),
|
|
244
|
+
turnsTcpPort: Number.parseInt(envMap.TURNS_TCP_PORT || "5349", 10),
|
|
245
|
+
turnRelayMin: Number.parseInt(envMap.TURN_RELAY_MIN_PORT || "49152", 10),
|
|
246
|
+
turnRelayMax: Number.parseInt(envMap.TURN_RELAY_MAX_PORT || "49252", 10),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function confirmContinueWithoutMediaBlock() {
|
|
251
|
+
const prompt = new Prompter();
|
|
252
|
+
try {
|
|
253
|
+
return await prompt.askYesNo(
|
|
254
|
+
"Continue without IPv6 media block (may cause no-audio on some networks)?",
|
|
255
|
+
false
|
|
256
|
+
);
|
|
257
|
+
} finally {
|
|
258
|
+
prompt.close();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
205
262
|
function generateSelfSigned(certPath, keyPath, domain, days = 1) {
|
|
206
263
|
ensureDir(path.dirname(certPath), 0o700);
|
|
207
264
|
run(
|
|
@@ -346,143 +403,179 @@ async function runWizard(existing = {}, preflight = {}) {
|
|
|
346
403
|
try {
|
|
347
404
|
const detectedIp = detectPublicIp();
|
|
348
405
|
const domain = await prompt.askText("Gateway domain", existing.DOMAIN || "", { required: true });
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
406
|
+
let publicIp = existing.PUBLIC_IP || detectedIp || "";
|
|
407
|
+
if (!publicIp) {
|
|
408
|
+
publicIp = await prompt.askText("Public IPv4 (auto-detect failed)", "", { required: true });
|
|
409
|
+
}
|
|
352
410
|
|
|
353
411
|
const resolved = resolveDomainIpv4(domain);
|
|
354
412
|
if (resolved.length > 0 && !resolved.includes(publicIp)) {
|
|
355
413
|
console.log(`Warning: DNS for ${domain} resolves to ${resolved.join(", ")}, not ${publicIp}.`);
|
|
356
414
|
}
|
|
357
415
|
|
|
358
|
-
const
|
|
416
|
+
const autoDeployMode =
|
|
359
417
|
(preflight.p80 && preflight.p80.inUse) || (preflight.p443 && preflight.p443.inUse)
|
|
360
|
-
?
|
|
361
|
-
:
|
|
362
|
-
const deployMode = await prompt.askChoice(
|
|
363
|
-
"Deployment mode",
|
|
364
|
-
["standalone", "reverse-proxy"],
|
|
365
|
-
conflictModeDefault
|
|
366
|
-
);
|
|
418
|
+
? "reverse-proxy"
|
|
419
|
+
: "standalone";
|
|
367
420
|
|
|
368
|
-
|
|
369
|
-
console.log("Note: forward /turn-credentials and websocket upgrade to 127.0.0.1:8443, and ACME path to 127.0.0.1:8080.");
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const tlsMode = await prompt.askChoice(
|
|
373
|
-
"TLS certificate mode",
|
|
374
|
-
["letsencrypt", "custom", "dev-self-signed"],
|
|
375
|
-
0
|
|
376
|
-
);
|
|
421
|
+
const advanced = await prompt.askYesNo("Advanced setup", false);
|
|
377
422
|
|
|
423
|
+
let deployMode = autoDeployMode;
|
|
424
|
+
let tlsMode = "letsencrypt";
|
|
378
425
|
let acmeEmail = existing.ACME_EMAIL || "";
|
|
379
426
|
let customCertPath = "";
|
|
380
427
|
let customKeyPath = "";
|
|
428
|
+
let bitcallEnv = existing.BITCALL_ENV || "production";
|
|
429
|
+
let routingMode = existing.ROUTING_MODE || "universal";
|
|
430
|
+
let sipProviderHost = DEFAULT_PROVIDER_HOST;
|
|
431
|
+
let sipTransport = "udp";
|
|
432
|
+
let sipPort = "5060";
|
|
433
|
+
let sipProviderUri = "";
|
|
434
|
+
let allowedDomains = existing.ALLOWED_SIP_DOMAINS || "";
|
|
435
|
+
let sipTrustedIps = existing.SIP_TRUSTED_IPS || "";
|
|
436
|
+
let turnMode = await prompt.askYesNo("Enable built-in TURN (coturn)?", true) ? "coturn" : "none";
|
|
437
|
+
let turnSecret = "";
|
|
438
|
+
let turnTtl = existing.TURN_TTL || "86400";
|
|
439
|
+
let turnApiToken = existing.TURN_API_TOKEN || "";
|
|
440
|
+
let turnExternalUrls = "";
|
|
441
|
+
let turnExternalUsername = "";
|
|
442
|
+
let turnExternalCredential = "";
|
|
443
|
+
let webphoneOrigin = existing.WEBPHONE_ORIGIN || DEFAULT_WEBPHONE_ORIGIN;
|
|
444
|
+
let configureUfw = true;
|
|
445
|
+
let mediaIpv4Only = existing.MEDIA_IPV4_ONLY ? existing.MEDIA_IPV4_ONLY === "1" : true;
|
|
381
446
|
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
} else if (tlsMode === "custom") {
|
|
385
|
-
customCertPath = await prompt.askText("Path to TLS certificate (PEM)", existing.TLS_CERT || "", {
|
|
386
|
-
required: true,
|
|
387
|
-
});
|
|
388
|
-
customKeyPath = await prompt.askText("Path to TLS private key (PEM)", existing.TLS_KEY || "", {
|
|
389
|
-
required: true,
|
|
390
|
-
});
|
|
447
|
+
if (turnMode === "coturn") {
|
|
448
|
+
turnSecret = crypto.randomBytes(32).toString("hex");
|
|
391
449
|
}
|
|
392
450
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
:
|
|
398
|
-
|
|
399
|
-
);
|
|
400
|
-
|
|
401
|
-
const sipTransportChoice = await prompt.askChoice(
|
|
402
|
-
"SIP provider transport",
|
|
403
|
-
["udp", "tcp", "tls", "custom"],
|
|
404
|
-
0
|
|
405
|
-
);
|
|
451
|
+
if (advanced) {
|
|
452
|
+
deployMode = await prompt.askChoice(
|
|
453
|
+
"Deployment mode",
|
|
454
|
+
["standalone", "reverse-proxy"],
|
|
455
|
+
autoDeployMode === "reverse-proxy" ? 1 : 0
|
|
456
|
+
);
|
|
406
457
|
|
|
407
|
-
|
|
408
|
-
|
|
458
|
+
tlsMode = await prompt.askChoice(
|
|
459
|
+
"TLS certificate mode",
|
|
460
|
+
["letsencrypt", "custom", "dev-self-signed"],
|
|
461
|
+
0
|
|
462
|
+
);
|
|
409
463
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
464
|
+
if (tlsMode === "custom") {
|
|
465
|
+
customCertPath = await prompt.askText("Path to TLS certificate (PEM)", existing.TLS_CERT || "", {
|
|
466
|
+
required: true,
|
|
467
|
+
});
|
|
468
|
+
customKeyPath = await prompt.askText("Path to TLS private key (PEM)", existing.TLS_KEY || "", {
|
|
469
|
+
required: true,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
419
472
|
|
|
420
|
-
|
|
473
|
+
if (tlsMode === "letsencrypt") {
|
|
474
|
+
acmeEmail = await prompt.askText("Let's Encrypt email", acmeEmail, { required: true });
|
|
475
|
+
} else {
|
|
476
|
+
acmeEmail = "";
|
|
477
|
+
}
|
|
421
478
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
479
|
+
bitcallEnv = await prompt.askChoice("Environment", ["production", "dev"], bitcallEnv === "dev" ? 1 : 0);
|
|
480
|
+
allowedDomains = await prompt.askText(
|
|
481
|
+
"Allowed SIP domains (comma-separated; required in production)",
|
|
482
|
+
allowedDomains
|
|
483
|
+
);
|
|
426
484
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
485
|
+
routingMode = await prompt.askChoice(
|
|
486
|
+
"Routing mode",
|
|
487
|
+
["universal", "single-provider"],
|
|
488
|
+
routingMode === "single-provider" ? 1 : 0
|
|
489
|
+
);
|
|
430
490
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
491
|
+
if (routingMode === "single-provider") {
|
|
492
|
+
sipProviderHost = await prompt.askText(
|
|
493
|
+
"SIP provider host",
|
|
494
|
+
existing.SIP_PROVIDER_URI
|
|
495
|
+
? existing.SIP_PROVIDER_URI.replace(/^sip:/, "").split(":")[0]
|
|
496
|
+
: DEFAULT_PROVIDER_HOST,
|
|
497
|
+
{ required: true }
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
const sipTransportChoice = await prompt.askChoice(
|
|
501
|
+
"SIP provider transport",
|
|
502
|
+
["udp", "tcp", "tls", "custom"],
|
|
503
|
+
0
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
sipTransport = sipTransportChoice;
|
|
507
|
+
if (sipTransportChoice === "tcp" || sipTransportChoice === "udp") {
|
|
508
|
+
sipPort = "5060";
|
|
509
|
+
} else if (sipTransportChoice === "tls") {
|
|
510
|
+
sipPort = "5061";
|
|
511
|
+
} else {
|
|
512
|
+
sipTransport = await prompt.askChoice("Custom transport", ["udp", "tcp", "tls"], 0);
|
|
513
|
+
sipPort = await prompt.askText(
|
|
514
|
+
"Custom SIP port",
|
|
515
|
+
sipTransport === "tls" ? "5061" : "5060",
|
|
516
|
+
{ required: true }
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
sipProviderUri = `sip:${sipProviderHost}:${sipPort};transport=${sipTransport}`;
|
|
521
|
+
}
|
|
435
522
|
|
|
436
|
-
|
|
523
|
+
sipTrustedIps = await prompt.askText(
|
|
524
|
+
"Trusted SIP source IPs (optional, comma-separated)",
|
|
525
|
+
sipTrustedIps
|
|
526
|
+
);
|
|
437
527
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
528
|
+
const turnDefaultIndex = turnMode === "external" ? 2 : turnMode === "coturn" ? 1 : 0;
|
|
529
|
+
turnMode = await prompt.askChoice("TURN mode", ["none", "coturn", "external"], turnDefaultIndex);
|
|
530
|
+
if (turnMode === "coturn") {
|
|
531
|
+
turnSecret = crypto.randomBytes(32).toString("hex");
|
|
532
|
+
turnTtl = await prompt.askText("TURN credential TTL seconds", turnTtl, { required: true });
|
|
533
|
+
turnApiToken = await prompt.askText("TURN API token (optional)", turnApiToken);
|
|
534
|
+
} else if (turnMode === "external") {
|
|
535
|
+
turnSecret = "";
|
|
536
|
+
turnApiToken = "";
|
|
537
|
+
turnExternalUrls = await prompt.askText("External TURN urls", existing.TURN_EXTERNAL_URLS || "", {
|
|
538
|
+
required: true,
|
|
539
|
+
});
|
|
540
|
+
turnExternalUsername = await prompt.askText(
|
|
541
|
+
"External TURN username",
|
|
542
|
+
existing.TURN_EXTERNAL_USERNAME || ""
|
|
543
|
+
);
|
|
544
|
+
turnExternalCredential = await prompt.askText(
|
|
545
|
+
"External TURN credential",
|
|
546
|
+
existing.TURN_EXTERNAL_CREDENTIAL || ""
|
|
547
|
+
);
|
|
548
|
+
} else {
|
|
549
|
+
turnSecret = "";
|
|
550
|
+
turnApiToken = "";
|
|
551
|
+
}
|
|
444
552
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
});
|
|
449
|
-
turnExternalUsername = await prompt.askText(
|
|
450
|
-
"External TURN username",
|
|
451
|
-
existing.TURN_EXTERNAL_USERNAME || ""
|
|
553
|
+
webphoneOrigin = await prompt.askText(
|
|
554
|
+
"Allowed webphone origin (* for any)",
|
|
555
|
+
webphoneOrigin
|
|
452
556
|
);
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
557
|
+
|
|
558
|
+
mediaIpv4Only = await prompt.askYesNo(
|
|
559
|
+
"Media IPv4-only mode (block IPv6 RTP/TURN on host firewall)",
|
|
560
|
+
mediaIpv4Only
|
|
456
561
|
);
|
|
457
|
-
}
|
|
458
562
|
|
|
459
|
-
|
|
460
|
-
turnSecret = crypto.randomBytes(32).toString("hex");
|
|
461
|
-
turnTtl = await prompt.askText("TURN credential TTL seconds", turnTtl, { required: true });
|
|
462
|
-
turnApiToken = await prompt.askText("TURN API token (optional)", turnApiToken);
|
|
563
|
+
configureUfw = await prompt.askYesNo("Configure ufw firewall rules now", true);
|
|
463
564
|
} else {
|
|
464
|
-
|
|
465
|
-
turnApiToken = "";
|
|
565
|
+
acmeEmail = await prompt.askText("Let's Encrypt email", acmeEmail, { required: true });
|
|
466
566
|
}
|
|
467
567
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const mediaIpv6Enabled = await prompt.askYesNo(
|
|
474
|
-
"Enable IPv6 media candidates? (default: No)",
|
|
475
|
-
existing.MEDIA_IPV6 === "1"
|
|
476
|
-
);
|
|
477
|
-
|
|
478
|
-
const mediaForceIpv4Enabled = await prompt.askYesNo(
|
|
479
|
-
"Force IPv4-only SDP candidates? (default: Yes)",
|
|
480
|
-
existing.MEDIA_FORCE_IPV4
|
|
481
|
-
? existing.MEDIA_FORCE_IPV4 === "1"
|
|
482
|
-
: !mediaIpv6Enabled
|
|
483
|
-
);
|
|
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
|
+
);
|
|
572
|
+
}
|
|
484
573
|
|
|
485
|
-
|
|
574
|
+
if (deployMode === "reverse-proxy") {
|
|
575
|
+
console.log(
|
|
576
|
+
"Note: forward /turn-credentials and websocket upgrade to 127.0.0.1:8443, and ACME path to 127.0.0.1:8080."
|
|
577
|
+
);
|
|
578
|
+
}
|
|
486
579
|
|
|
487
580
|
const config = {
|
|
488
581
|
domain,
|
|
@@ -492,6 +585,8 @@ async function runWizard(existing = {}, preflight = {}) {
|
|
|
492
585
|
acmeEmail,
|
|
493
586
|
customCertPath,
|
|
494
587
|
customKeyPath,
|
|
588
|
+
bitcallEnv,
|
|
589
|
+
routingMode,
|
|
495
590
|
sipProviderHost,
|
|
496
591
|
sipTransport,
|
|
497
592
|
sipPort,
|
|
@@ -506,10 +601,13 @@ async function runWizard(existing = {}, preflight = {}) {
|
|
|
506
601
|
turnExternalUsername,
|
|
507
602
|
turnExternalCredential,
|
|
508
603
|
webphoneOrigin,
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
604
|
+
mediaIpv4Only: mediaIpv4Only ? "1" : "0",
|
|
605
|
+
rtpMin: existing.RTPENGINE_MIN_PORT || "10000",
|
|
606
|
+
rtpMax: existing.RTPENGINE_MAX_PORT || "20000",
|
|
607
|
+
turnUdpPort: existing.TURN_UDP_PORT || "3478",
|
|
608
|
+
turnsTcpPort: existing.TURNS_TCP_PORT || "5349",
|
|
609
|
+
turnRelayMinPort: existing.TURN_RELAY_MIN_PORT || "49152",
|
|
610
|
+
turnRelayMaxPort: existing.TURN_RELAY_MAX_PORT || "49252",
|
|
513
611
|
acmeListenPort: deployMode === "reverse-proxy" ? "8080" : "80",
|
|
514
612
|
wssListenPort: deployMode === "reverse-proxy" ? "8443" : "443",
|
|
515
613
|
internalWssPort: "8443",
|
|
@@ -517,16 +615,19 @@ async function runWizard(existing = {}, preflight = {}) {
|
|
|
517
615
|
configureUfw,
|
|
518
616
|
};
|
|
519
617
|
|
|
618
|
+
const allowedCount = countAllowedDomains(config.allowedDomains);
|
|
520
619
|
console.log("\nSummary:");
|
|
521
620
|
console.log(` Domain: ${config.domain}`);
|
|
522
621
|
console.log(` Public IP: ${config.publicIp}`);
|
|
523
|
-
console.log(`
|
|
524
|
-
console.log(`
|
|
525
|
-
console.log(`
|
|
526
|
-
console.log(
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
console.log(
|
|
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
|
+
);
|
|
530
631
|
|
|
531
632
|
const proceed = await prompt.askYesNo("Proceed with provisioning", true);
|
|
532
633
|
if (!proceed) {
|
|
@@ -553,9 +654,11 @@ function renderEnvContent(config, tlsCert, tlsKey) {
|
|
|
553
654
|
const content = renderTemplate(".env.template", {
|
|
554
655
|
GATEWAY_VERSION: PACKAGE_VERSION,
|
|
555
656
|
BITCALL_GATEWAY_IMAGE: DEFAULT_GATEWAY_IMAGE,
|
|
657
|
+
BITCALL_ENV: config.bitcallEnv,
|
|
556
658
|
DOMAIN: config.domain,
|
|
557
659
|
PUBLIC_IP: config.publicIp,
|
|
558
660
|
DEPLOY_MODE: config.deployMode,
|
|
661
|
+
ROUTING_MODE: config.routingMode,
|
|
559
662
|
SIP_PROVIDER_URI: config.sipProviderUri,
|
|
560
663
|
SIP_UPSTREAM_TRANSPORT: config.sipTransport,
|
|
561
664
|
SIP_UPSTREAM_PORT: config.sipPort,
|
|
@@ -575,8 +678,11 @@ function renderEnvContent(config, tlsCert, tlsKey) {
|
|
|
575
678
|
WSS_LISTEN_PORT: config.wssListenPort,
|
|
576
679
|
INTERNAL_WSS_PORT: config.internalWssPort,
|
|
577
680
|
INTERNAL_WS_PORT: config.internalWsPort,
|
|
578
|
-
|
|
579
|
-
|
|
681
|
+
MEDIA_IPV4_ONLY: config.mediaIpv4Only,
|
|
682
|
+
TURN_UDP_PORT: config.turnUdpPort,
|
|
683
|
+
TURNS_TCP_PORT: config.turnsTcpPort,
|
|
684
|
+
TURN_RELAY_MIN_PORT: config.turnRelayMinPort,
|
|
685
|
+
TURN_RELAY_MAX_PORT: config.turnRelayMaxPort,
|
|
580
686
|
});
|
|
581
687
|
|
|
582
688
|
let extra = "";
|
|
@@ -696,6 +802,22 @@ async function initCommand() {
|
|
|
696
802
|
configureFirewall(config);
|
|
697
803
|
}
|
|
698
804
|
|
|
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.");
|
|
814
|
+
}
|
|
815
|
+
updateGatewayEnv({ MEDIA_IPV4_ONLY: "0" });
|
|
816
|
+
config.mediaIpv4Only = "0";
|
|
817
|
+
console.log("Continuing without IPv6 media block rules.");
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
699
821
|
startGatewayStack();
|
|
700
822
|
|
|
701
823
|
if (config.tlsMode === "letsencrypt") {
|
|
@@ -783,6 +905,8 @@ function statusCommand() {
|
|
|
783
905
|
}).status === 0;
|
|
784
906
|
}
|
|
785
907
|
|
|
908
|
+
const mediaStatus = isMediaIpv4OnlyRulesPresent();
|
|
909
|
+
|
|
786
910
|
console.log("Bitcall Gateway Status\n");
|
|
787
911
|
console.log(`Gateway service: ${formatMark(active)} ${active ? "running" : "stopped"}`);
|
|
788
912
|
console.log(`Auto-start: ${formatMark(enabled)} ${enabled ? "enabled" : "disabled"}`);
|
|
@@ -792,8 +916,17 @@ function statusCommand() {
|
|
|
792
916
|
console.log(`Port ${envMap.WSS_LISTEN_PORT || "443"}: ${formatMark(p443.inUse)} listening`);
|
|
793
917
|
console.log(`Port 5060: ${formatMark(p5060.inUse)} listening`);
|
|
794
918
|
console.log(`rtpengine control: ${formatMark(rtpReady)} reachable`);
|
|
795
|
-
|
|
796
|
-
|
|
919
|
+
if (mediaStatus.backend) {
|
|
920
|
+
console.log(`Media IPv4-only: ${mediaStatus.enabled ? "enabled" : "disabled"}`);
|
|
921
|
+
console.log(`Media firewall backend: ${mediaStatus.backend}`);
|
|
922
|
+
} else {
|
|
923
|
+
console.log(`Media IPv4-only: disabled (backend unavailable)`);
|
|
924
|
+
}
|
|
925
|
+
if (!mediaStatus.enabled) {
|
|
926
|
+
console.log(
|
|
927
|
+
"Warning: IPv6 media may cause no-audio on some clients; enable via `bitcall-gateway media ipv4-only on`"
|
|
928
|
+
);
|
|
929
|
+
}
|
|
797
930
|
if (envMap.TURN_MODE && envMap.TURN_MODE !== "none") {
|
|
798
931
|
console.log(`/turn-credentials: ${formatMark(turnReady)} reachable`);
|
|
799
932
|
}
|
|
@@ -807,12 +940,13 @@ function statusCommand() {
|
|
|
807
940
|
|
|
808
941
|
console.log("\nConfig summary:");
|
|
809
942
|
console.log(`DOMAIN=${envMap.DOMAIN || ""}`);
|
|
943
|
+
console.log(`BITCALL_ENV=${envMap.BITCALL_ENV || "production"}`);
|
|
944
|
+
console.log(`ROUTING_MODE=${envMap.ROUTING_MODE || "universal"}`);
|
|
810
945
|
console.log(`SIP_PROVIDER_URI=${envMap.SIP_PROVIDER_URI || ""}`);
|
|
811
946
|
console.log(`DEPLOY_MODE=${envMap.DEPLOY_MODE || ""}`);
|
|
812
947
|
console.log(`ALLOWED_SIP_DOMAINS=${envMap.ALLOWED_SIP_DOMAINS || ""}`);
|
|
813
948
|
console.log(`TURN_MODE=${envMap.TURN_MODE || "none"}`);
|
|
814
|
-
console.log(`
|
|
815
|
-
console.log(`MEDIA_FORCE_IPV4=${envMap.MEDIA_FORCE_IPV4 || "1"}`);
|
|
949
|
+
console.log(`MEDIA_IPV4_ONLY=${envMap.MEDIA_IPV4_ONLY || "1"}`);
|
|
816
950
|
}
|
|
817
951
|
|
|
818
952
|
function certStatusCommand() {
|
|
@@ -890,6 +1024,42 @@ function updateCommand() {
|
|
|
890
1024
|
runSystemctl(["reload", SERVICE_NAME], ["up", "-d", "--remove-orphans"]);
|
|
891
1025
|
}
|
|
892
1026
|
|
|
1027
|
+
function mediaStatusCommand() {
|
|
1028
|
+
ensureInitialized();
|
|
1029
|
+
const envMap = loadEnvFile(ENV_PATH);
|
|
1030
|
+
const desired = (envMap.MEDIA_IPV4_ONLY || "1") === "1";
|
|
1031
|
+
const state = isMediaIpv4OnlyRulesPresent();
|
|
1032
|
+
|
|
1033
|
+
console.log("Media firewall status\n");
|
|
1034
|
+
console.log(`Configured mode: ${desired ? "IPv4-only" : "dual-stack"}`);
|
|
1035
|
+
if (!state.backend) {
|
|
1036
|
+
console.log(`Backend: unavailable (${state.error || "not detected"})`);
|
|
1037
|
+
console.log("Rules active: no");
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
console.log(`Backend: ${state.backend}`);
|
|
1042
|
+
console.log(`Rules active: ${state.enabled ? "yes" : "no"}`);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function mediaIpv4OnlyOnCommand() {
|
|
1046
|
+
ensureInitialized();
|
|
1047
|
+
const envMap = loadEnvFile(ENV_PATH);
|
|
1048
|
+
const options = mediaFirewallOptionsFromEnv(envMap);
|
|
1049
|
+
const applied = applyMediaIpv4OnlyRules(options);
|
|
1050
|
+
updateGatewayEnv({ MEDIA_IPV4_ONLY: "1" });
|
|
1051
|
+
console.log(`Enabled IPv6 media block rules (${applied.backend}).`);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function mediaIpv4OnlyOffCommand() {
|
|
1055
|
+
ensureInitialized();
|
|
1056
|
+
const envMap = loadEnvFile(ENV_PATH);
|
|
1057
|
+
const options = mediaFirewallOptionsFromEnv(envMap);
|
|
1058
|
+
const removed = removeMediaIpv4OnlyRules(options);
|
|
1059
|
+
updateGatewayEnv({ MEDIA_IPV4_ONLY: "0" });
|
|
1060
|
+
console.log(`Disabled IPv6 media block rules (${removed.backend}).`);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
893
1063
|
async function uninstallCommand(options) {
|
|
894
1064
|
if (!options.yes) {
|
|
895
1065
|
const prompt = new Prompter();
|
|
@@ -903,6 +1073,18 @@ async function uninstallCommand(options) {
|
|
|
903
1073
|
}
|
|
904
1074
|
}
|
|
905
1075
|
|
|
1076
|
+
let uninstallEnv = {};
|
|
1077
|
+
if (fs.existsSync(ENV_PATH)) {
|
|
1078
|
+
uninstallEnv = loadEnvFile(ENV_PATH);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
try {
|
|
1082
|
+
const backend = detectFirewallBackend();
|
|
1083
|
+
removeMediaIpv4OnlyRules(mediaFirewallOptionsFromEnv(uninstallEnv), { backend });
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
console.log(`Warning: failed to remove IPv6 media firewall rules automatically: ${error.message}`);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
906
1088
|
run("systemctl", ["stop", SERVICE_NAME], { check: false, stdio: "inherit" });
|
|
907
1089
|
run("systemctl", ["disable", SERVICE_NAME], { check: false, stdio: "inherit" });
|
|
908
1090
|
|
|
@@ -974,6 +1156,13 @@ function buildProgram() {
|
|
|
974
1156
|
|
|
975
1157
|
program.command("update").description("Pull latest image and restart service").action(updateCommand);
|
|
976
1158
|
program.command("config").description("Print active configuration (secrets hidden)").action(configCommand);
|
|
1159
|
+
|
|
1160
|
+
const media = program.command("media").description("Media firewall operations");
|
|
1161
|
+
media.command("status").description("Show media IPv4-only firewall state").action(mediaStatusCommand);
|
|
1162
|
+
const mediaIpv4Only = media.command("ipv4-only").description("Toggle IPv6 media block");
|
|
1163
|
+
mediaIpv4Only.command("on").description("Enable IPv4-only media mode").action(mediaIpv4OnlyOnCommand);
|
|
1164
|
+
mediaIpv4Only.command("off").description("Disable IPv4-only media mode").action(mediaIpv4OnlyOffCommand);
|
|
1165
|
+
|
|
977
1166
|
program
|
|
978
1167
|
.command("uninstall")
|
|
979
1168
|
.description("Remove gateway service and files")
|
package/templates/.env.template
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# Generated by bitcall-gateway init
|
|
2
2
|
GATEWAY_VERSION=__GATEWAY_VERSION__
|
|
3
3
|
BITCALL_GATEWAY_IMAGE=__BITCALL_GATEWAY_IMAGE__
|
|
4
|
+
BITCALL_ENV=__BITCALL_ENV__
|
|
4
5
|
DOMAIN=__DOMAIN__
|
|
5
6
|
PUBLIC_IP=__PUBLIC_IP__
|
|
6
7
|
DEPLOY_MODE=__DEPLOY_MODE__
|
|
8
|
+
ROUTING_MODE=__ROUTING_MODE__
|
|
7
9
|
SIP_PROVIDER_URI=__SIP_PROVIDER_URI__
|
|
8
10
|
SIP_UPSTREAM_TRANSPORT=__SIP_UPSTREAM_TRANSPORT__
|
|
9
11
|
SIP_UPSTREAM_PORT=__SIP_UPSTREAM_PORT__
|
|
@@ -23,5 +25,8 @@ ACME_LISTEN_PORT=__ACME_LISTEN_PORT__
|
|
|
23
25
|
WSS_LISTEN_PORT=__WSS_LISTEN_PORT__
|
|
24
26
|
INTERNAL_WSS_PORT=__INTERNAL_WSS_PORT__
|
|
25
27
|
INTERNAL_WS_PORT=__INTERNAL_WS_PORT__
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
MEDIA_IPV4_ONLY=__MEDIA_IPV4_ONLY__
|
|
29
|
+
TURN_UDP_PORT=__TURN_UDP_PORT__
|
|
30
|
+
TURNS_TCP_PORT=__TURNS_TCP_PORT__
|
|
31
|
+
TURN_RELAY_MIN_PORT=__TURN_RELAY_MIN_PORT__
|
|
32
|
+
TURN_RELAY_MAX_PORT=__TURN_RELAY_MAX_PORT__
|