@bitcall/webrtc-sip-gateway 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,25 +5,36 @@ 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.5
8
+ sudo npm i -g @bitcall/webrtc-sip-gateway@0.2.7
9
9
  ```
10
10
 
11
11
  ## Main workflow
12
12
 
13
13
  ```bash
14
- sudo bitcall-gateway init
14
+ sudo bitcall-gateway init --dev
15
15
  sudo bitcall-gateway status
16
16
  sudo bitcall-gateway logs -f
17
+ sudo bitcall-gateway media status
17
18
  ```
18
19
 
19
- Default media policy is IPv4-only (`MEDIA_IPV6=0` and `MEDIA_FORCE_IPV4=1`),
20
- including IPv6 candidate stripping on SIP->WebRTC SDP. Set `MEDIA_IPV6=1`
21
- and `MEDIA_FORCE_IPV4=0` in `/opt/bitcall-gateway/.env` only if you want
22
- IPv6 candidates.
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.
24
+
25
+ Use `sudo bitcall-gateway init --production` for strict input validation and
26
+ hardening checks.
27
+ Use `--verbose` to stream apt/docker output during install. Default mode keeps
28
+ console output concise and writes command details to
29
+ `/var/log/bitcall-gateway-install.log`.
23
30
 
24
31
  ## Commands
25
32
 
26
33
  - `sudo bitcall-gateway init`
34
+ - `sudo bitcall-gateway init --dev`
35
+ - `sudo bitcall-gateway init --production`
36
+ - `sudo bitcall-gateway init --advanced`
37
+ - `sudo bitcall-gateway init --verbose`
27
38
  - `sudo bitcall-gateway up`
28
39
  - `sudo bitcall-gateway down`
29
40
  - `sudo bitcall-gateway restart`
@@ -33,6 +44,9 @@ IPv6 candidates.
33
44
  - `sudo bitcall-gateway cert renew`
34
45
  - `sudo bitcall-gateway cert install --cert /path/cert.pem --key /path/key.pem`
35
46
  - `sudo bitcall-gateway update`
47
+ - `sudo bitcall-gateway media status`
48
+ - `sudo bitcall-gateway media ipv4-only on`
49
+ - `sudo bitcall-gateway media ipv4-only off`
36
50
  - `sudo bitcall-gateway uninstall`
37
51
 
38
52
  ## 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.5",
17
+ DEFAULT_GATEWAY_IMAGE: "ghcr.io/bitcallio/webrtc-sip-gateway:0.2.7",
18
18
  DEFAULT_PROVIDER_HOST: "sip.example.com",
19
19
  DEFAULT_WEBPHONE_ORIGIN: "*",
20
20
  RENEW_HOOK_PATH: "/etc/letsencrypt/renewal-hooks/deploy/bitcall-gateway.sh",
@@ -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/lib/system.js CHANGED
@@ -3,6 +3,20 @@
3
3
  const fs = require("fs");
4
4
  const { output, run, runShell, commandExists } = require("./shell");
5
5
 
6
+ function pickExec(options = {}) {
7
+ if (typeof options.exec === "function") {
8
+ return options.exec;
9
+ }
10
+ return (command, args = [], execOptions = {}) => run(command, args, execOptions);
11
+ }
12
+
13
+ function pickShell(options = {}) {
14
+ if (typeof options.shell === "function") {
15
+ return options.shell;
16
+ }
17
+ return (script, execOptions = {}) => runShell(script, execOptions);
18
+ }
19
+
6
20
  function parseOsRelease() {
7
21
  const info = {};
8
22
  const content = fs.readFileSync("/etc/os-release", "utf8");
@@ -112,28 +126,31 @@ function portInUse(port) {
112
126
  };
113
127
  }
114
128
 
115
- function ensureDockerInstalled() {
129
+ function ensureDockerInstalled(options = {}) {
130
+ const exec = pickExec(options);
131
+ const shell = pickShell(options);
132
+
116
133
  if (commandExists("docker") && run("docker", ["info"], { check: false }).status === 0) {
117
134
  return;
118
135
  }
119
136
 
120
- run("apt-get", ["update"], { stdio: "inherit" });
121
- run("apt-get", ["install", "-y", "curl", "ca-certificates", "gnupg"], {
122
- stdio: "inherit",
123
- });
124
- runShell("curl -fsSL https://get.docker.com | sh", { stdio: "inherit" });
125
- run("systemctl", ["enable", "docker.service"], { stdio: "inherit" });
126
- run("systemctl", ["enable", "containerd.service"], { stdio: "inherit" });
127
- run("systemctl", ["start", "docker"], { stdio: "inherit" });
137
+ exec("apt-get", ["update"]);
138
+ exec("apt-get", ["install", "-y", "curl", "ca-certificates", "gnupg"]);
139
+ shell("curl -fsSL https://get.docker.com | sh");
140
+ exec("systemctl", ["enable", "docker.service"]);
141
+ exec("systemctl", ["enable", "containerd.service"]);
142
+ exec("systemctl", ["start", "docker"]);
128
143
  }
129
144
 
130
- function ensureComposePlugin() {
145
+ function ensureComposePlugin(options = {}) {
146
+ const exec = pickExec(options);
147
+
131
148
  if (run("docker", ["compose", "version"], { check: false }).status === 0) {
132
149
  return;
133
150
  }
134
151
 
135
- run("apt-get", ["update"], { stdio: "inherit" });
136
- run("apt-get", ["install", "-y", "docker-compose-plugin"], { stdio: "inherit" });
152
+ exec("apt-get", ["update"]);
153
+ exec("apt-get", ["install", "-y", "docker-compose-plugin"]);
137
154
  }
138
155
 
139
156
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitcall/webrtc-sip-gateway",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Linux CLI for bootstrapping and managing the Bitcall WebRTC-to-SIP Gateway",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "scripts": {
24
24
  "lint": "node --check src/index.js && node --check bin/bitcall-gateway.js && for f in lib/*.js; do node --check \"$f\"; done",
25
- "test": "node test/smoke.test.js",
25
+ "test": "node test/smoke.test.js && node test/firewall.test.js && node test/init-config.test.js",
26
26
  "pack:dry": "npm pack --dry-run"
27
27
  },
28
28
  "dependencies": {