@bitcall/webrtc-sip-gateway 0.2.4 → 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 -3
- package/lib/constants.js +1 -1
- package/lib/firewall.js +357 -0
- package/package.json +2 -2
- package/src/index.js +315 -113
- package/templates/.env.template +7 -1
- package/templates/docker-compose.yml.template +0 -2
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,10 +14,13 @@ sudo npm i -g @bitcall/webrtc-sip-gateway@0.2.4
|
|
|
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
|
-
|
|
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.
|
|
21
24
|
|
|
22
25
|
## Commands
|
|
23
26
|
|
|
@@ -31,6 +34,9 @@ Default media policy is IPv4-only candidates (`MEDIA_IPV6=0`). Set
|
|
|
31
34
|
- `sudo bitcall-gateway cert renew`
|
|
32
35
|
- `sudo bitcall-gateway cert install --cert /path/cert.pem --key /path/key.pem`
|
|
33
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`
|
|
34
40
|
- `sudo bitcall-gateway uninstall`
|
|
35
41
|
|
|
36
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,7 +111,11 @@ function envOrder() {
|
|
|
103
111
|
"WSS_LISTEN_PORT",
|
|
104
112
|
"INTERNAL_WSS_PORT",
|
|
105
113
|
"INTERNAL_WS_PORT",
|
|
106
|
-
"
|
|
114
|
+
"MEDIA_IPV4_ONLY",
|
|
115
|
+
"TURN_UDP_PORT",
|
|
116
|
+
"TURNS_TCP_PORT",
|
|
117
|
+
"TURN_RELAY_MIN_PORT",
|
|
118
|
+
"TURN_RELAY_MAX_PORT",
|
|
107
119
|
"RTPENGINE_MIN_PORT",
|
|
108
120
|
"RTPENGINE_MAX_PORT",
|
|
109
121
|
"WITH_REVERSE_PROXY",
|
|
@@ -201,6 +213,52 @@ function configureFirewall(config) {
|
|
|
201
213
|
run("ufw", ["reload"], { check: false, stdio: "ignore" });
|
|
202
214
|
}
|
|
203
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
|
+
|
|
204
262
|
function generateSelfSigned(certPath, keyPath, domain, days = 1) {
|
|
205
263
|
ensureDir(path.dirname(certPath), 0o700);
|
|
206
264
|
run(
|
|
@@ -345,136 +403,179 @@ async function runWizard(existing = {}, preflight = {}) {
|
|
|
345
403
|
try {
|
|
346
404
|
const detectedIp = detectPublicIp();
|
|
347
405
|
const domain = await prompt.askText("Gateway domain", existing.DOMAIN || "", { required: true });
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
406
|
+
let publicIp = existing.PUBLIC_IP || detectedIp || "";
|
|
407
|
+
if (!publicIp) {
|
|
408
|
+
publicIp = await prompt.askText("Public IPv4 (auto-detect failed)", "", { required: true });
|
|
409
|
+
}
|
|
351
410
|
|
|
352
411
|
const resolved = resolveDomainIpv4(domain);
|
|
353
412
|
if (resolved.length > 0 && !resolved.includes(publicIp)) {
|
|
354
413
|
console.log(`Warning: DNS for ${domain} resolves to ${resolved.join(", ")}, not ${publicIp}.`);
|
|
355
414
|
}
|
|
356
415
|
|
|
357
|
-
const
|
|
416
|
+
const autoDeployMode =
|
|
358
417
|
(preflight.p80 && preflight.p80.inUse) || (preflight.p443 && preflight.p443.inUse)
|
|
359
|
-
?
|
|
360
|
-
:
|
|
361
|
-
const deployMode = await prompt.askChoice(
|
|
362
|
-
"Deployment mode",
|
|
363
|
-
["standalone", "reverse-proxy"],
|
|
364
|
-
conflictModeDefault
|
|
365
|
-
);
|
|
418
|
+
? "reverse-proxy"
|
|
419
|
+
: "standalone";
|
|
366
420
|
|
|
367
|
-
|
|
368
|
-
console.log("Note: forward /turn-credentials and websocket upgrade to 127.0.0.1:8443, and ACME path to 127.0.0.1:8080.");
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const tlsMode = await prompt.askChoice(
|
|
372
|
-
"TLS certificate mode",
|
|
373
|
-
["letsencrypt", "custom", "dev-self-signed"],
|
|
374
|
-
0
|
|
375
|
-
);
|
|
421
|
+
const advanced = await prompt.askYesNo("Advanced setup", false);
|
|
376
422
|
|
|
423
|
+
let deployMode = autoDeployMode;
|
|
424
|
+
let tlsMode = "letsencrypt";
|
|
377
425
|
let acmeEmail = existing.ACME_EMAIL || "";
|
|
378
426
|
let customCertPath = "";
|
|
379
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;
|
|
380
446
|
|
|
381
|
-
if (
|
|
382
|
-
|
|
383
|
-
} else if (tlsMode === "custom") {
|
|
384
|
-
customCertPath = await prompt.askText("Path to TLS certificate (PEM)", existing.TLS_CERT || "", {
|
|
385
|
-
required: true,
|
|
386
|
-
});
|
|
387
|
-
customKeyPath = await prompt.askText("Path to TLS private key (PEM)", existing.TLS_KEY || "", {
|
|
388
|
-
required: true,
|
|
389
|
-
});
|
|
447
|
+
if (turnMode === "coturn") {
|
|
448
|
+
turnSecret = crypto.randomBytes(32).toString("hex");
|
|
390
449
|
}
|
|
391
450
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
:
|
|
397
|
-
|
|
398
|
-
);
|
|
399
|
-
|
|
400
|
-
const sipTransportChoice = await prompt.askChoice(
|
|
401
|
-
"SIP provider transport",
|
|
402
|
-
["udp", "tcp", "tls", "custom"],
|
|
403
|
-
0
|
|
404
|
-
);
|
|
451
|
+
if (advanced) {
|
|
452
|
+
deployMode = await prompt.askChoice(
|
|
453
|
+
"Deployment mode",
|
|
454
|
+
["standalone", "reverse-proxy"],
|
|
455
|
+
autoDeployMode === "reverse-proxy" ? 1 : 0
|
|
456
|
+
);
|
|
405
457
|
|
|
406
|
-
|
|
407
|
-
|
|
458
|
+
tlsMode = await prompt.askChoice(
|
|
459
|
+
"TLS certificate mode",
|
|
460
|
+
["letsencrypt", "custom", "dev-self-signed"],
|
|
461
|
+
0
|
|
462
|
+
);
|
|
408
463
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
}
|
|
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
|
+
}
|
|
418
472
|
|
|
419
|
-
|
|
473
|
+
if (tlsMode === "letsencrypt") {
|
|
474
|
+
acmeEmail = await prompt.askText("Let's Encrypt email", acmeEmail, { required: true });
|
|
475
|
+
} else {
|
|
476
|
+
acmeEmail = "";
|
|
477
|
+
}
|
|
420
478
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
+
);
|
|
425
484
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
485
|
+
routingMode = await prompt.askChoice(
|
|
486
|
+
"Routing mode",
|
|
487
|
+
["universal", "single-provider"],
|
|
488
|
+
routingMode === "single-provider" ? 1 : 0
|
|
489
|
+
);
|
|
429
490
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
+
}
|
|
434
522
|
|
|
435
|
-
|
|
523
|
+
sipTrustedIps = await prompt.askText(
|
|
524
|
+
"Trusted SIP source IPs (optional, comma-separated)",
|
|
525
|
+
sipTrustedIps
|
|
526
|
+
);
|
|
436
527
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
+
}
|
|
443
552
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
});
|
|
448
|
-
turnExternalUsername = await prompt.askText(
|
|
449
|
-
"External TURN username",
|
|
450
|
-
existing.TURN_EXTERNAL_USERNAME || ""
|
|
553
|
+
webphoneOrigin = await prompt.askText(
|
|
554
|
+
"Allowed webphone origin (* for any)",
|
|
555
|
+
webphoneOrigin
|
|
451
556
|
);
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
557
|
+
|
|
558
|
+
mediaIpv4Only = await prompt.askYesNo(
|
|
559
|
+
"Media IPv4-only mode (block IPv6 RTP/TURN on host firewall)",
|
|
560
|
+
mediaIpv4Only
|
|
455
561
|
);
|
|
456
|
-
}
|
|
457
562
|
|
|
458
|
-
|
|
459
|
-
turnSecret = crypto.randomBytes(32).toString("hex");
|
|
460
|
-
turnTtl = await prompt.askText("TURN credential TTL seconds", turnTtl, { required: true });
|
|
461
|
-
turnApiToken = await prompt.askText("TURN API token (optional)", turnApiToken);
|
|
563
|
+
configureUfw = await prompt.askYesNo("Configure ufw firewall rules now", true);
|
|
462
564
|
} else {
|
|
463
|
-
|
|
464
|
-
turnApiToken = "";
|
|
565
|
+
acmeEmail = await prompt.askText("Let's Encrypt email", acmeEmail, { required: true });
|
|
465
566
|
}
|
|
466
567
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
const mediaIpv6Enabled = await prompt.askYesNo(
|
|
473
|
-
"Enable IPv6 media candidates? (default: No)",
|
|
474
|
-
existing.MEDIA_IPV6 === "1"
|
|
475
|
-
);
|
|
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
|
+
}
|
|
476
573
|
|
|
477
|
-
|
|
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
|
+
}
|
|
478
579
|
|
|
479
580
|
const config = {
|
|
480
581
|
domain,
|
|
@@ -484,6 +585,8 @@ async function runWizard(existing = {}, preflight = {}) {
|
|
|
484
585
|
acmeEmail,
|
|
485
586
|
customCertPath,
|
|
486
587
|
customKeyPath,
|
|
588
|
+
bitcallEnv,
|
|
589
|
+
routingMode,
|
|
487
590
|
sipProviderHost,
|
|
488
591
|
sipTransport,
|
|
489
592
|
sipPort,
|
|
@@ -498,9 +601,13 @@ async function runWizard(existing = {}, preflight = {}) {
|
|
|
498
601
|
turnExternalUsername,
|
|
499
602
|
turnExternalCredential,
|
|
500
603
|
webphoneOrigin,
|
|
501
|
-
|
|
502
|
-
rtpMin: "10000",
|
|
503
|
-
rtpMax: "20000",
|
|
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",
|
|
504
611
|
acmeListenPort: deployMode === "reverse-proxy" ? "8080" : "80",
|
|
505
612
|
wssListenPort: deployMode === "reverse-proxy" ? "8443" : "443",
|
|
506
613
|
internalWssPort: "8443",
|
|
@@ -508,15 +615,19 @@ async function runWizard(existing = {}, preflight = {}) {
|
|
|
508
615
|
configureUfw,
|
|
509
616
|
};
|
|
510
617
|
|
|
618
|
+
const allowedCount = countAllowedDomains(config.allowedDomains);
|
|
511
619
|
console.log("\nSummary:");
|
|
512
620
|
console.log(` Domain: ${config.domain}`);
|
|
513
621
|
console.log(` Public IP: ${config.publicIp}`);
|
|
514
|
-
console.log(`
|
|
515
|
-
console.log(`
|
|
516
|
-
console.log(`
|
|
517
|
-
console.log(
|
|
518
|
-
|
|
519
|
-
|
|
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
|
+
);
|
|
520
631
|
|
|
521
632
|
const proceed = await prompt.askYesNo("Proceed with provisioning", true);
|
|
522
633
|
if (!proceed) {
|
|
@@ -543,9 +654,11 @@ function renderEnvContent(config, tlsCert, tlsKey) {
|
|
|
543
654
|
const content = renderTemplate(".env.template", {
|
|
544
655
|
GATEWAY_VERSION: PACKAGE_VERSION,
|
|
545
656
|
BITCALL_GATEWAY_IMAGE: DEFAULT_GATEWAY_IMAGE,
|
|
657
|
+
BITCALL_ENV: config.bitcallEnv,
|
|
546
658
|
DOMAIN: config.domain,
|
|
547
659
|
PUBLIC_IP: config.publicIp,
|
|
548
660
|
DEPLOY_MODE: config.deployMode,
|
|
661
|
+
ROUTING_MODE: config.routingMode,
|
|
549
662
|
SIP_PROVIDER_URI: config.sipProviderUri,
|
|
550
663
|
SIP_UPSTREAM_TRANSPORT: config.sipTransport,
|
|
551
664
|
SIP_UPSTREAM_PORT: config.sipPort,
|
|
@@ -565,7 +678,11 @@ function renderEnvContent(config, tlsCert, tlsKey) {
|
|
|
565
678
|
WSS_LISTEN_PORT: config.wssListenPort,
|
|
566
679
|
INTERNAL_WSS_PORT: config.internalWssPort,
|
|
567
680
|
INTERNAL_WS_PORT: config.internalWsPort,
|
|
568
|
-
|
|
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,
|
|
569
686
|
});
|
|
570
687
|
|
|
571
688
|
let extra = "";
|
|
@@ -685,6 +802,22 @@ async function initCommand() {
|
|
|
685
802
|
configureFirewall(config);
|
|
686
803
|
}
|
|
687
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
|
+
|
|
688
821
|
startGatewayStack();
|
|
689
822
|
|
|
690
823
|
if (config.tlsMode === "letsencrypt") {
|
|
@@ -772,6 +905,8 @@ function statusCommand() {
|
|
|
772
905
|
}).status === 0;
|
|
773
906
|
}
|
|
774
907
|
|
|
908
|
+
const mediaStatus = isMediaIpv4OnlyRulesPresent();
|
|
909
|
+
|
|
775
910
|
console.log("Bitcall Gateway Status\n");
|
|
776
911
|
console.log(`Gateway service: ${formatMark(active)} ${active ? "running" : "stopped"}`);
|
|
777
912
|
console.log(`Auto-start: ${formatMark(enabled)} ${enabled ? "enabled" : "disabled"}`);
|
|
@@ -781,7 +916,17 @@ function statusCommand() {
|
|
|
781
916
|
console.log(`Port ${envMap.WSS_LISTEN_PORT || "443"}: ${formatMark(p443.inUse)} listening`);
|
|
782
917
|
console.log(`Port 5060: ${formatMark(p5060.inUse)} listening`);
|
|
783
918
|
console.log(`rtpengine control: ${formatMark(rtpReady)} reachable`);
|
|
784
|
-
|
|
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
|
+
}
|
|
785
930
|
if (envMap.TURN_MODE && envMap.TURN_MODE !== "none") {
|
|
786
931
|
console.log(`/turn-credentials: ${formatMark(turnReady)} reachable`);
|
|
787
932
|
}
|
|
@@ -795,11 +940,13 @@ function statusCommand() {
|
|
|
795
940
|
|
|
796
941
|
console.log("\nConfig summary:");
|
|
797
942
|
console.log(`DOMAIN=${envMap.DOMAIN || ""}`);
|
|
943
|
+
console.log(`BITCALL_ENV=${envMap.BITCALL_ENV || "production"}`);
|
|
944
|
+
console.log(`ROUTING_MODE=${envMap.ROUTING_MODE || "universal"}`);
|
|
798
945
|
console.log(`SIP_PROVIDER_URI=${envMap.SIP_PROVIDER_URI || ""}`);
|
|
799
946
|
console.log(`DEPLOY_MODE=${envMap.DEPLOY_MODE || ""}`);
|
|
800
947
|
console.log(`ALLOWED_SIP_DOMAINS=${envMap.ALLOWED_SIP_DOMAINS || ""}`);
|
|
801
948
|
console.log(`TURN_MODE=${envMap.TURN_MODE || "none"}`);
|
|
802
|
-
console.log(`
|
|
949
|
+
console.log(`MEDIA_IPV4_ONLY=${envMap.MEDIA_IPV4_ONLY || "1"}`);
|
|
803
950
|
}
|
|
804
951
|
|
|
805
952
|
function certStatusCommand() {
|
|
@@ -877,6 +1024,42 @@ function updateCommand() {
|
|
|
877
1024
|
runSystemctl(["reload", SERVICE_NAME], ["up", "-d", "--remove-orphans"]);
|
|
878
1025
|
}
|
|
879
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
|
+
|
|
880
1063
|
async function uninstallCommand(options) {
|
|
881
1064
|
if (!options.yes) {
|
|
882
1065
|
const prompt = new Prompter();
|
|
@@ -890,6 +1073,18 @@ async function uninstallCommand(options) {
|
|
|
890
1073
|
}
|
|
891
1074
|
}
|
|
892
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
|
+
|
|
893
1088
|
run("systemctl", ["stop", SERVICE_NAME], { check: false, stdio: "inherit" });
|
|
894
1089
|
run("systemctl", ["disable", SERVICE_NAME], { check: false, stdio: "inherit" });
|
|
895
1090
|
|
|
@@ -961,6 +1156,13 @@ function buildProgram() {
|
|
|
961
1156
|
|
|
962
1157
|
program.command("update").description("Pull latest image and restart service").action(updateCommand);
|
|
963
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
|
+
|
|
964
1166
|
program
|
|
965
1167
|
.command("uninstall")
|
|
966
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,4 +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
|
-
|
|
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__
|