@bitcall/webrtc-sip-gateway 0.3.1 → 0.3.4
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 +12 -3
- package/lib/firewall.js +8 -1
- package/lib/system.js +43 -2
- package/package.json +1 -1
- package/src/index.js +281 -172
- package/templates/coturn-service.yml.template +39 -0
package/README.md
CHANGED
|
@@ -3,12 +3,17 @@
|
|
|
3
3
|
Linux-only CLI to install and operate the Bitcall WebRTC-to-SIP gateway.
|
|
4
4
|
|
|
5
5
|
Latest updates:
|
|
6
|
+
- Gateway container now renders Kamailio `#!substdef` defaults from `.env` at
|
|
7
|
+
startup, so advertise/origin/SIP transport values are runtime-accurate.
|
|
6
8
|
- `init` and `reconfigure` stop an existing stack before preflight checks so
|
|
7
9
|
the gateway's own `:5060` listener does not trigger false port conflicts.
|
|
8
|
-
- `update` now syncs `BITCALL_GATEWAY_IMAGE` to the CLI target image tag
|
|
9
|
-
|
|
10
|
+
- `update` now syncs `BITCALL_GATEWAY_IMAGE` to the CLI target image tag,
|
|
11
|
+
pulls, and force-recreates containers so new image layers are applied.
|
|
10
12
|
- Docker image includes `sngrep` and `tcpdump` for SIP troubleshooting.
|
|
11
|
-
- `sip-trace` opens a live SIP message viewer using `sngrep` in the container
|
|
13
|
+
- `sip-trace` opens a live SIP message viewer using `sngrep` in the container
|
|
14
|
+
via compose service execution.
|
|
15
|
+
- `TURN_MODE=coturn` now generates a compose stack with a dedicated coturn
|
|
16
|
+
container.
|
|
12
17
|
|
|
13
18
|
## Install
|
|
14
19
|
|
|
@@ -16,6 +21,10 @@ Latest updates:
|
|
|
16
21
|
sudo npm i -g @bitcall/webrtc-sip-gateway
|
|
17
22
|
```
|
|
18
23
|
|
|
24
|
+
Host requirement:
|
|
25
|
+
- Use Docker Engine with `docker compose` plugin.
|
|
26
|
+
- Snap `docker-compose` is not supported (cannot access `/opt/bitcall-gateway`).
|
|
27
|
+
|
|
19
28
|
## Main workflow
|
|
20
29
|
|
|
21
30
|
```bash
|
package/lib/firewall.js
CHANGED
|
@@ -107,11 +107,18 @@ function buildNftRuleset(options = {}) {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
function persistIp6tables(d) {
|
|
110
|
+
const aptEnv = {
|
|
111
|
+
...process.env,
|
|
112
|
+
DEBIAN_FRONTEND: "noninteractive",
|
|
113
|
+
NEEDRESTART_MODE: "a",
|
|
114
|
+
};
|
|
115
|
+
|
|
110
116
|
if (!d.commandExists("netfilter-persistent")) {
|
|
111
|
-
d.run("apt-get", ["update"], { stdio: "inherit" });
|
|
117
|
+
d.run("apt-get", ["update"], { stdio: "inherit", env: aptEnv });
|
|
112
118
|
d.run("apt-get", ["install", "-y", "netfilter-persistent", "iptables-persistent"], {
|
|
113
119
|
check: false,
|
|
114
120
|
stdio: "inherit",
|
|
121
|
+
env: aptEnv,
|
|
115
122
|
});
|
|
116
123
|
}
|
|
117
124
|
|
package/lib/system.js
CHANGED
|
@@ -129,8 +129,17 @@ function portInUse(port) {
|
|
|
129
129
|
function ensureDockerInstalled(options = {}) {
|
|
130
130
|
const exec = pickExec(options);
|
|
131
131
|
const shell = pickShell(options);
|
|
132
|
+
const dockerPath = commandExists("docker")
|
|
133
|
+
? output("sh", ["-lc", "command -v docker || true"]).trim()
|
|
134
|
+
: "";
|
|
132
135
|
|
|
133
136
|
if (commandExists("docker") && run("docker", ["info"], { check: false }).status === 0) {
|
|
137
|
+
if (dockerPath.startsWith("/snap/")) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
"Detected Docker installed via snap. This setup is not supported for Bitcall gateway. " +
|
|
140
|
+
"Install Docker Engine (docker-ce) with compose plugin (`docker compose`) and retry."
|
|
141
|
+
);
|
|
142
|
+
}
|
|
134
143
|
return;
|
|
135
144
|
}
|
|
136
145
|
|
|
@@ -144,13 +153,45 @@ function ensureDockerInstalled(options = {}) {
|
|
|
144
153
|
|
|
145
154
|
function ensureComposePlugin(options = {}) {
|
|
146
155
|
const exec = pickExec(options);
|
|
156
|
+
const dockerComposePath = commandExists("docker-compose")
|
|
157
|
+
? output("sh", ["-lc", "command -v docker-compose || true"]).trim()
|
|
158
|
+
: "";
|
|
159
|
+
const snapDockerCompose = dockerComposePath.startsWith("/snap/");
|
|
147
160
|
|
|
148
161
|
if (run("docker", ["compose", "version"], { check: false }).status === 0) {
|
|
149
162
|
return;
|
|
150
163
|
}
|
|
151
164
|
|
|
152
|
-
|
|
153
|
-
|
|
165
|
+
if (commandExists("docker-compose") && run("docker-compose", ["version"], { check: false }).status === 0) {
|
|
166
|
+
if (snapDockerCompose) {
|
|
167
|
+
try {
|
|
168
|
+
exec("apt-get", ["update"]);
|
|
169
|
+
exec("apt-get", ["install", "-y", "docker-compose-plugin"]);
|
|
170
|
+
} catch (_error) {
|
|
171
|
+
// Fall through to explicit compatibility error below.
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (run("docker", ["compose", "version"], { check: false }).status === 0) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
throw new Error(
|
|
179
|
+
"Detected snap docker-compose only. It cannot access /opt/bitcall-gateway due snap confinement. " +
|
|
180
|
+
"Install Docker's compose plugin (`docker compose`) and retry."
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
exec("apt-get", ["update"]);
|
|
188
|
+
exec("apt-get", ["install", "-y", "docker-compose-plugin"]);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (commandExists("docker-compose") && run("docker-compose", ["version"], { check: false }).status === 0) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
154
195
|
}
|
|
155
196
|
|
|
156
197
|
module.exports = {
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -185,6 +185,13 @@ function detectComposeCommand(exec = run) {
|
|
|
185
185
|
return { command: "docker", prefixArgs: ["compose"] };
|
|
186
186
|
}
|
|
187
187
|
if (exec("docker-compose", ["version"], { check: false }).status === 0) {
|
|
188
|
+
const composePath = output("sh", ["-lc", "command -v docker-compose || true"]).trim();
|
|
189
|
+
if (composePath.startsWith("/snap/")) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
"Detected snap docker-compose only. Install Docker compose plugin (`docker compose`) " +
|
|
192
|
+
"because snap docker-compose cannot access /opt/bitcall-gateway."
|
|
193
|
+
);
|
|
194
|
+
}
|
|
188
195
|
return { command: "docker-compose", prefixArgs: [] };
|
|
189
196
|
}
|
|
190
197
|
throw new Error("docker compose not found. Install Docker compose plugin.");
|
|
@@ -192,7 +199,7 @@ function detectComposeCommand(exec = run) {
|
|
|
192
199
|
|
|
193
200
|
function runCompose(args, options = {}, exec = run) {
|
|
194
201
|
const compose = detectComposeCommand(exec);
|
|
195
|
-
return exec(compose.command, [...compose.prefixArgs, ...args], {
|
|
202
|
+
return exec(compose.command, [...compose.prefixArgs, "-f", COMPOSE_PATH, ...args], {
|
|
196
203
|
cwd: GATEWAY_DIR,
|
|
197
204
|
...options,
|
|
198
205
|
});
|
|
@@ -995,9 +1002,13 @@ function renderEnvContent(config, tlsCert, tlsKey) {
|
|
|
995
1002
|
return content + extra;
|
|
996
1003
|
}
|
|
997
1004
|
|
|
998
|
-
function writeComposeTemplate() {
|
|
999
|
-
|
|
1000
|
-
|
|
1005
|
+
function writeComposeTemplate(config = {}) {
|
|
1006
|
+
let compose = readTemplate("docker-compose.yml.template").trimEnd();
|
|
1007
|
+
if (config.turnMode === "coturn") {
|
|
1008
|
+
const coturn = readTemplate("coturn-service.yml.template").trimEnd();
|
|
1009
|
+
compose = `${compose}\n\n${coturn}`;
|
|
1010
|
+
}
|
|
1011
|
+
writeFileWithMode(COMPOSE_PATH, `${compose}\n`, 0o644);
|
|
1001
1012
|
}
|
|
1002
1013
|
|
|
1003
1014
|
function provisionCustomCert(config) {
|
|
@@ -1056,12 +1067,40 @@ function installGatewayService(exec = run) {
|
|
|
1056
1067
|
exec("systemctl", ["enable", "--now", SERVICE_NAME]);
|
|
1057
1068
|
}
|
|
1058
1069
|
|
|
1070
|
+
function backupExistingGatewayConfig() {
|
|
1071
|
+
if (!fs.existsSync(ENV_PATH) || !fs.existsSync(COMPOSE_PATH)) {
|
|
1072
|
+
return null;
|
|
1073
|
+
}
|
|
1074
|
+
return {
|
|
1075
|
+
envContent: fs.readFileSync(ENV_PATH, "utf8"),
|
|
1076
|
+
composeContent: fs.readFileSync(COMPOSE_PATH, "utf8"),
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function restoreGatewayConfigFromBackup(backup, contextLabel = "Operation") {
|
|
1081
|
+
if (!backup || backup.envContent == null || backup.composeContent == null) {
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
try {
|
|
1085
|
+
ensureInstallLayout();
|
|
1086
|
+
writeFileWithMode(ENV_PATH, backup.envContent, 0o600);
|
|
1087
|
+
writeFileWithMode(COMPOSE_PATH, backup.composeContent, 0o644);
|
|
1088
|
+
runCompose(["up", "-d", "--remove-orphans"], { check: false, stdio: "pipe" });
|
|
1089
|
+
console.error(`${contextLabel} failed. Restored previous gateway config and attempted restart.`);
|
|
1090
|
+
} catch (restoreError) {
|
|
1091
|
+
console.error(
|
|
1092
|
+
`${contextLabel} failed and automatic restore also failed: ${restoreError.message}`
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1059
1097
|
async function initCommand(initOptions = {}) {
|
|
1060
1098
|
printBanner();
|
|
1099
|
+
const previousConfig = backupExistingGatewayConfig();
|
|
1061
1100
|
|
|
1062
1101
|
// If re-running init on an existing installation, stop the current
|
|
1063
1102
|
// gateway so our own ports don't fail the preflight check.
|
|
1064
|
-
if (
|
|
1103
|
+
if (previousConfig) {
|
|
1065
1104
|
console.log("Existing gateway detected. Stopping for re-initialization...\n");
|
|
1066
1105
|
try {
|
|
1067
1106
|
runCompose(["down"], { stdio: "pipe" });
|
|
@@ -1071,111 +1110,131 @@ async function initCommand(initOptions = {}) {
|
|
|
1071
1110
|
}
|
|
1072
1111
|
|
|
1073
1112
|
const ctx = createInstallContext(initOptions);
|
|
1113
|
+
try {
|
|
1114
|
+
let preflight;
|
|
1115
|
+
await ctx.step("Checks", async () => {
|
|
1116
|
+
preflight = await runPreflight(ctx);
|
|
1117
|
+
});
|
|
1074
1118
|
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
let existingEnv = {};
|
|
1081
|
-
if (fs.existsSync(ENV_PATH)) {
|
|
1082
|
-
existingEnv = loadEnvFile(ENV_PATH);
|
|
1083
|
-
}
|
|
1119
|
+
let existingEnv = {};
|
|
1120
|
+
if (fs.existsSync(ENV_PATH)) {
|
|
1121
|
+
existingEnv = loadEnvFile(ENV_PATH);
|
|
1122
|
+
}
|
|
1084
1123
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1124
|
+
let config;
|
|
1125
|
+
await ctx.step("Config", async () => {
|
|
1126
|
+
config = await runWizard(existingEnv, preflight, initOptions);
|
|
1127
|
+
});
|
|
1089
1128
|
|
|
1090
|
-
|
|
1129
|
+
ensureInstallLayout();
|
|
1091
1130
|
|
|
1092
|
-
|
|
1093
|
-
|
|
1131
|
+
let tlsCertPath;
|
|
1132
|
+
let tlsKeyPath;
|
|
1094
1133
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1134
|
+
await ctx.step("Firewall", async () => {
|
|
1135
|
+
if (config.configureUfw) {
|
|
1136
|
+
const ufwReady = await ensureUfwAvailable(ctx);
|
|
1137
|
+
if (ufwReady) {
|
|
1138
|
+
configureFirewall(config, ctx.exec);
|
|
1139
|
+
} else {
|
|
1140
|
+
console.log("Skipping UFW configuration by request.");
|
|
1141
|
+
config.configureUfw = false;
|
|
1142
|
+
printRequiredPorts(config);
|
|
1143
|
+
}
|
|
1100
1144
|
} else {
|
|
1101
|
-
console.log("Skipping UFW configuration by request.");
|
|
1102
|
-
config.configureUfw = false;
|
|
1103
1145
|
printRequiredPorts(config);
|
|
1104
1146
|
}
|
|
1105
|
-
} else {
|
|
1106
|
-
printRequiredPorts(config);
|
|
1107
|
-
}
|
|
1108
1147
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1148
|
+
if (config.mediaIpv4Only === "1") {
|
|
1149
|
+
try {
|
|
1150
|
+
const applied = applyMediaIpv4OnlyRules(mediaFirewallOptionsFromConfig(config));
|
|
1151
|
+
console.log(`Applied IPv6 media block rules (${applied.backend}).`);
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
console.error(`IPv6 media block setup failed: ${error.message}`);
|
|
1154
|
+
const proceed = await confirmContinueWithoutMediaBlock();
|
|
1155
|
+
if (!proceed) {
|
|
1156
|
+
throw new Error(
|
|
1157
|
+
"Initialization stopped because media IPv4-only firewall rules were not applied."
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
config.mediaIpv4Only = "0";
|
|
1161
|
+
console.log("Continuing without IPv6 media block rules.");
|
|
1118
1162
|
}
|
|
1119
|
-
config.mediaIpv4Only = "0";
|
|
1120
|
-
console.log("Continuing without IPv6 media block rules.");
|
|
1121
1163
|
}
|
|
1122
|
-
}
|
|
1123
|
-
});
|
|
1164
|
+
});
|
|
1124
1165
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1166
|
+
await ctx.step("TLS", async () => {
|
|
1167
|
+
if (config.tlsMode === "custom") {
|
|
1168
|
+
const custom = provisionCustomCert(config);
|
|
1169
|
+
tlsCertPath = custom.certPath;
|
|
1170
|
+
tlsKeyPath = custom.keyPath;
|
|
1171
|
+
} else if (config.tlsMode === "dev-self-signed") {
|
|
1172
|
+
tlsCertPath = path.join(SSL_DIR, "dev-fullchain.pem");
|
|
1173
|
+
tlsKeyPath = path.join(SSL_DIR, "dev-privkey.pem");
|
|
1174
|
+
generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 30, ctx.exec);
|
|
1175
|
+
console.log("WARNING: Self-signed certificates do not work with browsers.");
|
|
1176
|
+
console.log("WebSocket connections will be rejected. Use --dev for local testing only.");
|
|
1177
|
+
console.log("For browser access, re-run with Let's Encrypt: sudo bitcall-gateway init");
|
|
1178
|
+
} else {
|
|
1179
|
+
tlsCertPath = path.join(SSL_DIR, "bootstrap-fullchain.pem");
|
|
1180
|
+
tlsKeyPath = path.join(SSL_DIR, "bootstrap-privkey.pem");
|
|
1181
|
+
generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 1, ctx.exec);
|
|
1182
|
+
}
|
|
1142
1183
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1184
|
+
const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
|
|
1185
|
+
writeFileWithMode(ENV_PATH, envContent, 0o600);
|
|
1186
|
+
writeComposeTemplate(config);
|
|
1187
|
+
});
|
|
1147
1188
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1189
|
+
await ctx.step("Start", async () => {
|
|
1190
|
+
startGatewayStack(ctx.exec);
|
|
1191
|
+
if (config.tlsMode === "letsencrypt") {
|
|
1192
|
+
runLetsEncrypt(config, ctx.exec);
|
|
1193
|
+
}
|
|
1194
|
+
installGatewayService(ctx.exec);
|
|
1195
|
+
});
|
|
1155
1196
|
|
|
1156
|
-
|
|
1197
|
+
await ctx.step("Done", async () => {});
|
|
1157
1198
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
console.log(
|
|
1199
|
+
console.log("");
|
|
1200
|
+
console.log(clr(_c.bold + _c.green, " ╔══════════════════════════════════════╗"));
|
|
1201
|
+
console.log(
|
|
1202
|
+
clr(_c.bold + _c.green, " ║") +
|
|
1203
|
+
" Gateway is " +
|
|
1204
|
+
clr(_c.bold + _c.green, "READY") +
|
|
1205
|
+
" " +
|
|
1206
|
+
clr(_c.bold + _c.green, "║")
|
|
1207
|
+
);
|
|
1208
|
+
console.log(clr(_c.bold + _c.green, " ╚══════════════════════════════════════╝"));
|
|
1209
|
+
console.log("");
|
|
1210
|
+
console.log(` ${clr(_c.bold, "WSS")} ${clr(_c.cyan, `wss://${config.domain}`)}`);
|
|
1211
|
+
if (config.turnMode === "coturn") {
|
|
1212
|
+
console.log(` ${clr(_c.bold, "TURN")} ${clr(_c.cyan, `https://${config.domain}/turn-credentials`)}`);
|
|
1213
|
+
console.log(` ${clr(_c.bold, "STUN")} ${clr(_c.cyan, `stun:${config.domain}:${config.turnUdpPort}`)}`);
|
|
1214
|
+
console.log(` ${clr(_c.bold, "TURN")} ${clr(_c.cyan, `turn:${config.domain}:${config.turnUdpPort}`)}`);
|
|
1215
|
+
console.log(
|
|
1216
|
+
` ${clr(_c.bold, "TURNS")} ${clr(_c.cyan, `turns:${config.domain}:${config.turnsTcpPort}`)}`
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
if (!ctx.verbose) {
|
|
1220
|
+
console.log(` ${clr(_c.dim, `Log: ${ctx.logPath}`)}`);
|
|
1221
|
+
}
|
|
1222
|
+
console.log("");
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
if (previousConfig) {
|
|
1225
|
+
restoreGatewayConfigFromBackup(previousConfig, "Initialization");
|
|
1226
|
+
}
|
|
1227
|
+
throw error;
|
|
1169
1228
|
}
|
|
1170
|
-
console.log("");
|
|
1171
1229
|
}
|
|
1172
1230
|
|
|
1173
1231
|
async function reconfigureCommand(options) {
|
|
1174
1232
|
ensureInitialized();
|
|
1175
1233
|
printBanner();
|
|
1234
|
+
const previousConfig = backupExistingGatewayConfig();
|
|
1176
1235
|
|
|
1177
1236
|
// Stop the running gateway so ports are free for preflight.
|
|
1178
|
-
if (
|
|
1237
|
+
if (previousConfig) {
|
|
1179
1238
|
console.log("Stopping current gateway for reconfiguration...\n");
|
|
1180
1239
|
try {
|
|
1181
1240
|
runCompose(["down"], { stdio: "pipe" });
|
|
@@ -1185,97 +1244,123 @@ async function reconfigureCommand(options) {
|
|
|
1185
1244
|
}
|
|
1186
1245
|
|
|
1187
1246
|
const ctx = createInstallContext(options);
|
|
1247
|
+
try {
|
|
1248
|
+
let preflight;
|
|
1249
|
+
await ctx.step("Checks", async () => {
|
|
1250
|
+
preflight = await runPreflight(ctx);
|
|
1251
|
+
});
|
|
1188
1252
|
|
|
1189
|
-
|
|
1190
|
-
await ctx.step("Checks", async () => {
|
|
1191
|
-
preflight = await runPreflight(ctx);
|
|
1192
|
-
});
|
|
1193
|
-
|
|
1194
|
-
const existingEnv = fs.existsSync(ENV_PATH) ? loadEnvFile(ENV_PATH) : {};
|
|
1253
|
+
const existingEnv = fs.existsSync(ENV_PATH) ? loadEnvFile(ENV_PATH) : {};
|
|
1195
1254
|
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1255
|
+
let config;
|
|
1256
|
+
await ctx.step("Config", async () => {
|
|
1257
|
+
config = await runWizard(existingEnv, preflight, options);
|
|
1258
|
+
});
|
|
1200
1259
|
|
|
1201
|
-
|
|
1260
|
+
ensureInstallLayout();
|
|
1202
1261
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1262
|
+
let tlsCertPath;
|
|
1263
|
+
let tlsKeyPath;
|
|
1205
1264
|
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1265
|
+
await ctx.step("Firewall", async () => {
|
|
1266
|
+
if (config.configureUfw) {
|
|
1267
|
+
const ufwReady = await ensureUfwAvailable(ctx);
|
|
1268
|
+
if (ufwReady) {
|
|
1269
|
+
configureFirewall(config, ctx.exec);
|
|
1270
|
+
} else {
|
|
1271
|
+
config.configureUfw = false;
|
|
1272
|
+
printRequiredPorts(config);
|
|
1273
|
+
}
|
|
1211
1274
|
} else {
|
|
1212
|
-
config.configureUfw = false;
|
|
1213
1275
|
printRequiredPorts(config);
|
|
1214
1276
|
}
|
|
1215
|
-
} else {
|
|
1216
|
-
printRequiredPorts(config);
|
|
1217
|
-
}
|
|
1218
1277
|
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1278
|
+
if (config.mediaIpv4Only === "1") {
|
|
1279
|
+
try {
|
|
1280
|
+
const applied = applyMediaIpv4OnlyRules(mediaFirewallOptionsFromConfig(config));
|
|
1281
|
+
console.log(`Applied IPv6 media block rules (${applied.backend}).`);
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
console.error(`IPv6 media block setup failed: ${error.message}`);
|
|
1284
|
+
config.mediaIpv4Only = "0";
|
|
1285
|
+
}
|
|
1226
1286
|
}
|
|
1227
|
-
}
|
|
1228
|
-
});
|
|
1287
|
+
});
|
|
1229
1288
|
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
} else {
|
|
1240
|
-
// Keep existing LE cert if domain matches, otherwise re-provision.
|
|
1241
|
-
const envMap = loadEnvFile(ENV_PATH);
|
|
1242
|
-
if (envMap.TLS_MODE === "letsencrypt" && envMap.TLS_CERT && fs.existsSync(envMap.TLS_CERT)) {
|
|
1243
|
-
tlsCertPath = envMap.TLS_CERT;
|
|
1244
|
-
tlsKeyPath = envMap.TLS_KEY;
|
|
1289
|
+
await ctx.step("TLS", async () => {
|
|
1290
|
+
if (config.tlsMode === "custom") {
|
|
1291
|
+
const custom = provisionCustomCert(config);
|
|
1292
|
+
tlsCertPath = custom.certPath;
|
|
1293
|
+
tlsKeyPath = custom.keyPath;
|
|
1294
|
+
} else if (config.tlsMode === "dev-self-signed") {
|
|
1295
|
+
tlsCertPath = path.join(SSL_DIR, "dev-fullchain.pem");
|
|
1296
|
+
tlsKeyPath = path.join(SSL_DIR, "dev-privkey.pem");
|
|
1297
|
+
generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 30, ctx.exec);
|
|
1245
1298
|
} else {
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1299
|
+
// Keep existing LE cert only when domain is unchanged and cert files still exist.
|
|
1300
|
+
const envMap = loadEnvFile(ENV_PATH);
|
|
1301
|
+
const sameDomain =
|
|
1302
|
+
(envMap.DOMAIN || "").trim().toLowerCase() === (config.domain || "").trim().toLowerCase();
|
|
1303
|
+
if (
|
|
1304
|
+
envMap.TLS_MODE === "letsencrypt" &&
|
|
1305
|
+
sameDomain &&
|
|
1306
|
+
envMap.TLS_CERT &&
|
|
1307
|
+
envMap.TLS_KEY &&
|
|
1308
|
+
fs.existsSync(envMap.TLS_CERT) &&
|
|
1309
|
+
fs.existsSync(envMap.TLS_KEY)
|
|
1310
|
+
) {
|
|
1311
|
+
tlsCertPath = envMap.TLS_CERT;
|
|
1312
|
+
tlsKeyPath = envMap.TLS_KEY;
|
|
1313
|
+
} else {
|
|
1314
|
+
tlsCertPath = path.join(SSL_DIR, "bootstrap-fullchain.pem");
|
|
1315
|
+
tlsKeyPath = path.join(SSL_DIR, "bootstrap-privkey.pem");
|
|
1316
|
+
generateSelfSigned(tlsCertPath, tlsKeyPath, config.domain, 1, ctx.exec);
|
|
1317
|
+
}
|
|
1249
1318
|
}
|
|
1250
|
-
}
|
|
1251
1319
|
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1320
|
+
const envContent = renderEnvContent(config, tlsCertPath, tlsKeyPath);
|
|
1321
|
+
writeFileWithMode(ENV_PATH, envContent, 0o600);
|
|
1322
|
+
writeComposeTemplate(config);
|
|
1323
|
+
});
|
|
1256
1324
|
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1325
|
+
await ctx.step("Start", async () => {
|
|
1326
|
+
startGatewayStack(ctx.exec);
|
|
1327
|
+
if (config.tlsMode === "letsencrypt" && (!tlsCertPath || !tlsCertPath.includes("letsencrypt"))) {
|
|
1328
|
+
runLetsEncrypt(config, ctx.exec);
|
|
1329
|
+
}
|
|
1330
|
+
});
|
|
1263
1331
|
|
|
1264
|
-
|
|
1332
|
+
await ctx.step("Done", async () => {});
|
|
1265
1333
|
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
console.log(
|
|
1334
|
+
console.log("");
|
|
1335
|
+
console.log(clr(_c.bold + _c.green, " ╔══════════════════════════════════════╗"));
|
|
1336
|
+
console.log(
|
|
1337
|
+
clr(_c.bold + _c.green, " ║") +
|
|
1338
|
+
" Gateway is " +
|
|
1339
|
+
clr(_c.bold + _c.green, "READY") +
|
|
1340
|
+
" " +
|
|
1341
|
+
clr(_c.bold + _c.green, "║")
|
|
1342
|
+
);
|
|
1343
|
+
console.log(clr(_c.bold + _c.green, " ╚══════════════════════════════════════╝"));
|
|
1344
|
+
console.log("");
|
|
1345
|
+
console.log(` ${clr(_c.bold, "WSS")} ${clr(_c.cyan, `wss://${config.domain}`)}`);
|
|
1346
|
+
if (config.turnMode === "coturn") {
|
|
1347
|
+
console.log(` ${clr(_c.bold, "TURN")} ${clr(_c.cyan, `https://${config.domain}/turn-credentials`)}`);
|
|
1348
|
+
console.log(` ${clr(_c.bold, "STUN")} ${clr(_c.cyan, `stun:${config.domain}:${config.turnUdpPort}`)}`);
|
|
1349
|
+
console.log(` ${clr(_c.bold, "TURN")} ${clr(_c.cyan, `turn:${config.domain}:${config.turnUdpPort}`)}`);
|
|
1350
|
+
console.log(
|
|
1351
|
+
` ${clr(_c.bold, "TURNS")} ${clr(_c.cyan, `turns:${config.domain}:${config.turnsTcpPort}`)}`
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
if (!ctx.verbose) {
|
|
1355
|
+
console.log(` ${clr(_c.dim, `Log: ${ctx.logPath}`)}`);
|
|
1356
|
+
}
|
|
1357
|
+
console.log("");
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
if (previousConfig) {
|
|
1360
|
+
restoreGatewayConfigFromBackup(previousConfig, "Reconfigure");
|
|
1361
|
+
}
|
|
1362
|
+
throw error;
|
|
1277
1363
|
}
|
|
1278
|
-
console.log("");
|
|
1279
1364
|
}
|
|
1280
1365
|
|
|
1281
1366
|
function runSystemctl(args, fallbackComposeArgs) {
|
|
@@ -1339,16 +1424,23 @@ function logsCommand(service, options) {
|
|
|
1339
1424
|
|
|
1340
1425
|
function sipTraceCommand() {
|
|
1341
1426
|
ensureInitialized();
|
|
1342
|
-
const
|
|
1343
|
-
const
|
|
1427
|
+
const serviceName = "gateway";
|
|
1428
|
+
const containerNameFallback = "bitcall-gateway";
|
|
1344
1429
|
|
|
1345
1430
|
// Check if sngrep is available in the container
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
{ cwd: GATEWAY_DIR, check: false, stdio: "pipe" }
|
|
1431
|
+
let check = runCompose(
|
|
1432
|
+
["exec", "-T", serviceName, "which", "sngrep"],
|
|
1433
|
+
{ check: false, stdio: "pipe" }
|
|
1350
1434
|
);
|
|
1351
1435
|
|
|
1436
|
+
// Backward-compatible fallback for stacks where service naming diverged.
|
|
1437
|
+
if (check.status !== 0) {
|
|
1438
|
+
check = run("docker", ["exec", containerNameFallback, "which", "sngrep"], {
|
|
1439
|
+
check: false,
|
|
1440
|
+
stdio: "pipe",
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1352
1444
|
if (check.status !== 0) {
|
|
1353
1445
|
console.error("sngrep is not available in this gateway image.");
|
|
1354
1446
|
console.error("Update to the latest image: sudo bitcall-gateway update");
|
|
@@ -1356,11 +1448,10 @@ function sipTraceCommand() {
|
|
|
1356
1448
|
}
|
|
1357
1449
|
|
|
1358
1450
|
// Run sngrep interactively inside the container
|
|
1359
|
-
const result =
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
);
|
|
1451
|
+
const result = runCompose(["exec", serviceName, "sngrep"], {
|
|
1452
|
+
check: false,
|
|
1453
|
+
stdio: "inherit",
|
|
1454
|
+
});
|
|
1364
1455
|
|
|
1365
1456
|
process.exit(result.status || 0);
|
|
1366
1457
|
}
|
|
@@ -1384,6 +1475,11 @@ function statusCommand() {
|
|
|
1384
1475
|
"docker ps --filter name=^bitcall-gateway$ --format '{{.Status}}'",
|
|
1385
1476
|
]);
|
|
1386
1477
|
const containerUp = Boolean(containerStatus);
|
|
1478
|
+
const coturnStatus = output("sh", [
|
|
1479
|
+
"-lc",
|
|
1480
|
+
"docker ps --filter name=^bitcall-coturn$ --format '{{.Status}}'",
|
|
1481
|
+
]);
|
|
1482
|
+
const coturnUp = Boolean(coturnStatus);
|
|
1387
1483
|
|
|
1388
1484
|
const p80 = portInUse(Number.parseInt(envMap.ACME_LISTEN_PORT || "80", 10));
|
|
1389
1485
|
const p443 = portInUse(Number.parseInt(envMap.WSS_LISTEN_PORT || "443", 10));
|
|
@@ -1409,6 +1505,9 @@ function statusCommand() {
|
|
|
1409
1505
|
console.log(`Gateway service: ${formatMark(active)} ${active ? "running" : "stopped"}`);
|
|
1410
1506
|
console.log(`Auto-start: ${formatMark(enabled)} ${enabled ? "enabled" : "disabled"}`);
|
|
1411
1507
|
console.log(`Container: ${formatMark(containerUp)} ${containerStatus || "not running"}`);
|
|
1508
|
+
if (envMap.TURN_MODE === "coturn") {
|
|
1509
|
+
console.log(`Coturn container: ${formatMark(coturnUp)} ${coturnStatus || "not running"}`);
|
|
1510
|
+
}
|
|
1412
1511
|
console.log("");
|
|
1413
1512
|
console.log(`Port ${envMap.ACME_LISTEN_PORT || "80"}: ${formatMark(p80.inUse)} listening`);
|
|
1414
1513
|
console.log(`Port ${envMap.WSS_LISTEN_PORT || "443"}: ${formatMark(p443.inUse)} listening`);
|
|
@@ -1531,7 +1630,8 @@ function updateCommand() {
|
|
|
1531
1630
|
}
|
|
1532
1631
|
|
|
1533
1632
|
runCompose(["pull"], { stdio: "inherit" });
|
|
1534
|
-
|
|
1633
|
+
runCompose(["up", "-d", "--force-recreate", "--remove-orphans"], { stdio: "inherit" });
|
|
1634
|
+
run("systemctl", ["start", SERVICE_NAME], { check: false, stdio: "ignore" });
|
|
1535
1635
|
|
|
1536
1636
|
console.log(`\n${clr(_c.green, "✓")} Gateway updated to ${clr(_c.bold, PACKAGE_VERSION)}.`);
|
|
1537
1637
|
console.log(clr(_c.dim, " To update the CLI: sudo npm i -g @bitcall/webrtc-sip-gateway@latest"));
|
|
@@ -1742,7 +1842,16 @@ function buildProgram() {
|
|
|
1742
1842
|
|
|
1743
1843
|
async function main(argv = process.argv) {
|
|
1744
1844
|
const program = buildProgram();
|
|
1745
|
-
|
|
1845
|
+
const normalizedArgv = argv.map((value, index) => {
|
|
1846
|
+
if (index <= 1) {
|
|
1847
|
+
return value;
|
|
1848
|
+
}
|
|
1849
|
+
if (value === "--sip-trace" || value === "-sip-trace") {
|
|
1850
|
+
return "sip-trace";
|
|
1851
|
+
}
|
|
1852
|
+
return value;
|
|
1853
|
+
});
|
|
1854
|
+
await program.parseAsync(normalizedArgv);
|
|
1746
1855
|
}
|
|
1747
1856
|
|
|
1748
1857
|
module.exports = {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
coturn:
|
|
2
|
+
image: coturn/coturn:latest
|
|
3
|
+
container_name: bitcall-coturn
|
|
4
|
+
network_mode: host
|
|
5
|
+
read_only: true
|
|
6
|
+
tmpfs:
|
|
7
|
+
- /tmp:rw,nosuid,nodev
|
|
8
|
+
security_opt:
|
|
9
|
+
- no-new-privileges:true
|
|
10
|
+
volumes:
|
|
11
|
+
- ${TLS_CERT}:/etc/ssl/cert.pem:ro
|
|
12
|
+
- ${TLS_KEY}:/etc/ssl/key.pem:ro
|
|
13
|
+
command: >
|
|
14
|
+
-n
|
|
15
|
+
--realm=${DOMAIN}
|
|
16
|
+
--external-ip=${PUBLIC_IP}
|
|
17
|
+
--listening-port=${TURN_UDP_PORT:-3478}
|
|
18
|
+
--tls-listening-port=${TURNS_TCP_PORT:-5349}
|
|
19
|
+
--cert=/etc/ssl/cert.pem
|
|
20
|
+
--pkey=/etc/ssl/key.pem
|
|
21
|
+
--use-auth-secret
|
|
22
|
+
--static-auth-secret=${TURN_SECRET}
|
|
23
|
+
--min-port=${TURN_RELAY_MIN_PORT:-49152}
|
|
24
|
+
--max-port=${TURN_RELAY_MAX_PORT:-49252}
|
|
25
|
+
--no-cli
|
|
26
|
+
--denied-peer-ip=0.0.0.0-0.255.255.255
|
|
27
|
+
--denied-peer-ip=10.0.0.0-10.255.255.255
|
|
28
|
+
--denied-peer-ip=127.0.0.0-127.255.255.255
|
|
29
|
+
--denied-peer-ip=172.16.0.0-172.31.255.255
|
|
30
|
+
--denied-peer-ip=192.168.0.0-192.168.255.255
|
|
31
|
+
--channel-lifetime=600
|
|
32
|
+
--permission-lifetime=300
|
|
33
|
+
--stale-nonce=600
|
|
34
|
+
restart: always
|
|
35
|
+
logging:
|
|
36
|
+
driver: json-file
|
|
37
|
+
options:
|
|
38
|
+
max-size: "10m"
|
|
39
|
+
max-file: "3"
|