bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

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();