stream-pack-init.js (10547B)
1 const fs = require("fs"); 2 const path = require("path"); 3 const crypto = require("crypto"); 4 const https = require("https"); 5 6 const ROOT = path.join(__dirname, ".."); 7 const PACK_DIR = path.join(ROOT, "stream_pack"); 8 9 function log(msg) { 10 console.log(`[stream-pack] ${msg}`); 11 } 12 13 function fail(msg) { 14 console.error(`[stream-pack] ERROR: ${msg}`); 15 process.exit(1); 16 } 17 18 function parseArgs() { 19 const args = process.argv.slice(2); 20 const out = {}; 21 for (const a of args) { 22 const m = String(a || "").match(/^--([^=]+)=(.*)$/); 23 if (m) out[m[1]] = m[2]; 24 } 25 return out; 26 } 27 28 function randHex(bytes) { 29 return crypto.randomBytes(bytes).toString("hex"); 30 } 31 32 function randToken(bytes) { 33 return crypto.randomBytes(bytes).toString("base64url"); 34 } 35 36 function normalizeDomain(s) { 37 const v = String(s || "").trim().toLowerCase(); 38 if (!v) return ""; 39 if (!/^[a-z0-9.-]+$/.test(v)) return ""; 40 if (!v.includes(".")) return ""; 41 return v; 42 } 43 44 function getJson(url, { timeoutMs = 2500 } = {}) { 45 return new Promise((resolve) => { 46 let settled = false; 47 const done = (v) => { 48 if (settled) return; 49 settled = true; 50 resolve(v); 51 }; 52 try { 53 const u = new URL(url); 54 const req = https.request( 55 { 56 method: "GET", 57 protocol: u.protocol, 58 hostname: u.hostname, 59 port: u.port, 60 path: `${u.pathname || "/"}${u.search || ""}`, 61 headers: { Accept: "application/json", "Cache-Control": "no-store" } 62 }, 63 (res) => { 64 let buf = ""; 65 res.setEncoding("utf8"); 66 res.on("data", (c) => (buf += c)); 67 res.on("end", () => { 68 try { 69 done({ ok: true, status: res.statusCode || 0, json: JSON.parse(buf || "{}") }); 70 } catch { 71 done({ ok: true, status: res.statusCode || 0, json: {} }); 72 } 73 }); 74 } 75 ); 76 req.on("error", (e) => done({ ok: false, error: e?.message || String(e) })); 77 req.setTimeout(timeoutMs, () => { 78 try { 79 req.destroy(new Error("timeout")); 80 } catch { 81 // ignore 82 } 83 done({ ok: false, timeout: true }); 84 }); 85 req.end(); 86 } catch (e) { 87 done({ ok: false, error: e?.message || String(e) }); 88 } 89 }); 90 } 91 92 function writeFileIfMissing(filePath, content) { 93 if (fs.existsSync(filePath)) return false; 94 fs.writeFileSync(filePath, content, "utf8"); 95 return true; 96 } 97 98 function writeFileAlways(filePath, content) { 99 fs.writeFileSync(filePath, content, "utf8"); 100 } 101 102 function readEnvFile(envPath) { 103 try { 104 const raw = fs.readFileSync(envPath, "utf8"); 105 const out = {}; 106 for (const line of raw.split(/\r?\n/)) { 107 const s = line.trim(); 108 if (!s || s.startsWith("#")) continue; 109 const m = s.match(/^([A-Z0-9_]+)=(.*)$/); 110 if (!m) continue; 111 let v = m[2] || ""; 112 v = v.trim(); 113 if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); 114 out[m[1]] = v; 115 } 116 return out; 117 } catch { 118 return {}; 119 } 120 } 121 122 function main() { 123 const args = parseArgs(); 124 const domain = normalizeDomain(args.domain); 125 const email = String(args.email || "").trim(); 126 127 if (!domain) { 128 fail('Missing/invalid `--domain=...` (example: --domain=stream.example.com)'); 129 } 130 if (!email || !email.includes("@")) { 131 fail('Missing/invalid `--email=...` (needed for HTTPS certs; example: --email=you@example.com)'); 132 } 133 134 fs.mkdirSync(PACK_DIR, { recursive: true }); 135 136 const envPath = path.join(PACK_DIR, ".env"); 137 const existing = readEnvFile(envPath); 138 139 const API_KEY = existing.LIVEKIT_API_KEY || randToken(8); 140 const API_SECRET = existing.LIVEKIT_API_SECRET || randToken(24); 141 const TURN_USER = existing.TURN_USER || "bzl"; 142 const TURN_PASS = existing.TURN_PASS || randToken(18); 143 const TURN_REALM = existing.TURN_REALM || domain; 144 const TURN_EXTERNAL_IP = existing.TURN_EXTERNAL_IP || ""; 145 const RTC_UDP_START = existing.RTC_UDP_START || "50000"; 146 const RTC_UDP_END = existing.RTC_UDP_END || "50100"; 147 const TURN_RELAY_START = existing.TURN_RELAY_START || "49160"; 148 const TURN_RELAY_END = existing.TURN_RELAY_END || "49200"; 149 150 const envBody = `# Stream Pack (optional) 151 # 152 # This stack is separate from core Bzl. Core stays runnable without any domain. 153 # Streaming requires HTTPS and a public server. 154 155 STREAM_DOMAIN=${domain} 156 STREAM_EMAIL=${email} 157 158 # LiveKit auth (Bzl will mint JWTs with these) 159 LIVEKIT_API_KEY=${API_KEY} 160 LIVEKIT_API_SECRET=${API_SECRET} 161 162 # TURN (coturn) credentials for LiveKit clients 163 TURN_USER=${TURN_USER} 164 TURN_PASS=${TURN_PASS} 165 TURN_REALM=${TURN_REALM} 166 TURN_EXTERNAL_IP=${TURN_EXTERNAL_IP} 167 168 # WebRTC UDP port range exposed by livekit-server (keep small for firewall sanity) 169 RTC_UDP_START=${RTC_UDP_START} 170 RTC_UDP_END=${RTC_UDP_END} 171 172 # TURN relay UDP ports (coturn will allocate from this range) 173 TURN_RELAY_START=${TURN_RELAY_START} 174 TURN_RELAY_END=${TURN_RELAY_END} 175 `; 176 177 if (!fs.existsSync(envPath)) { 178 writeFileAlways(envPath, envBody); 179 log(`Wrote ${path.relative(ROOT, envPath)}`); 180 } else { 181 // Keep user edits; only ensure required keys exist. 182 const merged = { ...existing }; 183 const ensure = (k, v) => { 184 if (!merged[k]) merged[k] = v; 185 }; 186 ensure("STREAM_DOMAIN", domain); 187 ensure("STREAM_EMAIL", email); 188 ensure("LIVEKIT_API_KEY", API_KEY); 189 ensure("LIVEKIT_API_SECRET", API_SECRET); 190 ensure("TURN_USER", TURN_USER); 191 ensure("TURN_PASS", TURN_PASS); 192 ensure("TURN_REALM", TURN_REALM); 193 ensure("TURN_EXTERNAL_IP", TURN_EXTERNAL_IP); 194 ensure("RTC_UDP_START", RTC_UDP_START); 195 ensure("RTC_UDP_END", RTC_UDP_END); 196 ensure("TURN_RELAY_START", TURN_RELAY_START); 197 ensure("TURN_RELAY_END", TURN_RELAY_END); 198 199 const lines = [ 200 "# Stream Pack (optional)", 201 "#", 202 "# This stack is separate from core Bzl. Core stays runnable without any domain.", 203 "# Streaming requires HTTPS and a public server.", 204 "", 205 `STREAM_DOMAIN=${merged.STREAM_DOMAIN}`, 206 `STREAM_EMAIL=${merged.STREAM_EMAIL}`, 207 "", 208 "# LiveKit auth (Bzl will mint JWTs with these)", 209 `LIVEKIT_API_KEY=${merged.LIVEKIT_API_KEY}`, 210 `LIVEKIT_API_SECRET=${merged.LIVEKIT_API_SECRET}`, 211 "", 212 "# TURN (coturn) credentials for LiveKit clients", 213 `TURN_USER=${merged.TURN_USER}`, 214 `TURN_PASS=${merged.TURN_PASS}`, 215 `TURN_REALM=${merged.TURN_REALM}`, 216 `TURN_EXTERNAL_IP=${merged.TURN_EXTERNAL_IP || ""}`, 217 "", 218 "# WebRTC UDP port range exposed by livekit-server (keep small for firewall sanity)", 219 `RTC_UDP_START=${merged.RTC_UDP_START}`, 220 `RTC_UDP_END=${merged.RTC_UDP_END}`, 221 "", 222 "# TURN relay UDP ports (coturn will allocate from this range)", 223 `TURN_RELAY_START=${merged.TURN_RELAY_START}`, 224 `TURN_RELAY_END=${merged.TURN_RELAY_END}`, 225 "" 226 ]; 227 writeFileAlways(envPath, lines.join("\n")); 228 log(`Updated ${path.relative(ROOT, envPath)}`); 229 } 230 231 const livekitYamlPath = path.join(PACK_DIR, "livekit.yaml"); 232 const livekitYaml = `port: 7880 233 log_level: info 234 235 rtc: 236 tcp_port: 7881 237 port_range_start: ${RTC_UDP_START} 238 port_range_end: ${RTC_UDP_END} 239 use_external_ip: true 240 turn_servers: 241 - host: ${domain} 242 port: 3478 243 protocol: udp 244 username: ${TURN_USER} 245 credential: ${TURN_PASS} 246 247 keys: 248 ${API_KEY}: ${API_SECRET} 249 `; 250 writeFileAlways(livekitYamlPath, livekitYaml); 251 log(`Wrote ${path.relative(ROOT, livekitYamlPath)}`); 252 253 const composePath = path.join(PACK_DIR, "docker-compose.yml"); 254 const composeBody = `services: 255 livekit: 256 image: livekit/livekit-server:latest 257 container_name: bzl_livekit 258 restart: unless-stopped 259 command: --config /etc/livekit.yaml 260 volumes: 261 - ./livekit.yaml:/etc/livekit.yaml:ro 262 ports: 263 - "127.0.0.1:7880:7880" 264 - "7881:7881" 265 - "${RTC_UDP_START}-${RTC_UDP_END}:${RTC_UDP_START}-${RTC_UDP_END}/udp" 266 267 turn: 268 image: coturn/coturn:latest 269 container_name: bzl_turn 270 restart: unless-stopped 271 network_mode: host 272 command: 273 - -n 274 - --log-file=stdout 275 - --realm=${TURN_REALM} 276 - --fingerprint 277 - --lt-cred-mech 278 - --no-tls 279 - --no-dtls 280 - --no-cli 281 - --no-multicast-peers 282 - --no-loopback-peers 283 - --listening-port=3478 284 - --min-port=${TURN_RELAY_START} 285 - --max-port=${TURN_RELAY_END} 286 - --user=${TURN_USER}:${TURN_PASS} 287 - --external-ip=${TURN_EXTERNAL_IP} 288 `; 289 writeFileAlways(composePath, composeBody); 290 log(`Wrote ${path.relative(ROOT, composePath)}`); 291 292 const caddySnippetPath = path.join(PACK_DIR, "Caddyfile.snippet"); 293 const caddySnippet = `${domain} { 294 encode zstd gzip 295 reverse_proxy 127.0.0.1:7880 296 } 297 `; 298 writeFileAlways(caddySnippetPath, caddySnippet); 299 log(`Wrote ${path.relative(ROOT, caddySnippetPath)}`); 300 301 const readmePath = path.join(PACK_DIR, "README.md"); 302 writeFileAlways( 303 readmePath, 304 `# Stream Pack (LiveKit + TURN)\n\nThis folder is generated by \`node scripts/stream-pack-init.js\`.\n\n## What this is\n- Optional infrastructure for the future Bzl streaming plugin.\n- Keeps core Bzl launchable without any domain name.\n- Uses LiveKit (SFU) + coturn for NAT traversal.\n\n## Requirements\n- Dedicated server with Docker + Docker Compose\n- HTTPS reverse proxy (Caddy recommended)\n- DNS A record: \`${domain}\` → your server\n - Important: set this record to **DNS-only** (no Cloudflare proxy), otherwise UDP will break.\n\n## 1) Set TURN external IP\nEdit \`.env\` and set:\n\n\`\`\nTURN_EXTERNAL_IP=<your_server_public_ipv4>\n\`\`\n\n## 2) Reverse proxy (HTTPS)\nAdd \`Caddyfile.snippet\` to your Caddy config, then reload Caddy.\n\n## 3) Open firewall ports\nMinimum ports:\n- TCP 80/443 (Caddy)\n- TCP 7881 (LiveKit TCP fallback)\n- UDP \`${RTC_UDP_START}-${RTC_UDP_END}\` (LiveKit media)\n- UDP 3478 (TURN)\n- UDP \`${TURN_RELAY_START}-${TURN_RELAY_END}\` (TURN relays)\n\n## 4) Start services\nFrom this folder:\n\n\`\`\ncd stream_pack\ndocker compose up -d\n\`\`\n\n## Notes\n- LiveKit signaling is kept on localhost:7880 and exposed via Caddy.\n- TURN uses host networking (best reliability).\n` 305 ); 306 log(`Wrote ${path.relative(ROOT, readmePath)}`); 307 308 log("Done."); 309 log("Next: edit stream_pack/.env (TURN_EXTERNAL_IP), add the Caddy snippet, open firewall ports, then run `docker compose up -d`."); 310 311 if (!existing.TURN_EXTERNAL_IP) { 312 log("Optional: auto-detect public IP:"); 313 log(" node scripts/stream-pack-detect-ip.js"); 314 } 315 } 316 317 main();