#!/usr/bin/env node /** * E2E: Valida que la URL WHIP generada para OBS funcione correctamente. * * Pruebas: * 1. Crear Ingress en LiveKit (Twirp HTTP API, igual que el browser) * 2. Construir la URL pública (djmaster.../w/) * 3. OPTIONS al endpoint WHIP — OBS hace esto primero para verificar compatibilidad * 4. POST al endpoint WHIP — simula el inicio de sesión de OBS * 5. Verificar que el proxy Caddy /w/* reenvía al livekit-ingress real * * Uso: * node scripts/test-whip-obs-e2e.js */ 'use strict'; const http = require('http'); const https = require('https'); const crypto = require('crypto'); // ── Config ───────────────────────────────────────────────────────────────── const LK_API_KEY = process.env.LIVEKIT_API_KEY || 'APIBTqTGxf9htMK'; const LK_API_SECRET = process.env.LIVEKIT_API_SECRET || '0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW'; const LK_WS_URL = process.env.LIVEKIT_WS_URL || 'wss://livekit-server.nextream.sytes.net'; const LK_HTTP_URL = LK_WS_URL.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://'); const UI_BASE_URL = process.env.UI_BASE_URL || 'https://djmaster.nextream.sytes.net'; const ROOM = 'e2e-obs-' + Date.now(); // ── Helpers ──────────────────────────────────────────────────────────────── let failures = 0; const ok = (msg) => console.log(` ✓ ${msg}`); const fail = (msg) => { console.error(` ✗ ${msg}`); failures++; }; const info = (msg) => console.log(` ℹ ${msg}`); const section = (t) => console.log(`\n━━━━ ${t} ━━━━`); function request(url, { method = 'GET', headers = {}, body } = {}) { return new Promise((resolve, reject) => { const u = new URL(url); const lib = u.protocol === 'https:' ? https : http; const raw = body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined; const opts = { hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), path: u.pathname + (u.search || ''), method, headers: { ...headers, ...(raw ? { 'Content-Length': Buffer.byteLength(raw) } : {}), }, rejectUnauthorized: false, timeout: 10000, }; const req = lib.request(opts, (res) => { let buf = ''; res.on('data', (c) => (buf += c)); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: buf })); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); }); if (raw) req.write(raw); req.end(); }); } // Genera JWT HS256 con node:crypto (solo para el script Node — no para el browser) function buildToken() { const b64url = (o) => Buffer.from(JSON.stringify(o)) .toString('base64') .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); const now = Math.floor(Date.now() / 1000); const header = b64url({ alg: 'HS256', typ: 'JWT' }); const payload = b64url({ iss: LK_API_KEY, sub: 'e2e-test', iat: now, exp: now + 3600, video: { roomCreate: true, roomList: true, roomAdmin: true, ingressAdmin: true }, }); const sig = crypto .createHmac('sha256', LK_API_SECRET) .update(`${header}.${payload}`) .digest('base64') .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); return `${header}.${payload}.${sig}`; } // ── Test 1: Crear Ingress via Twirp (mismo path que el browser) ──────────── async function testCreateIngress() { section('Test 1 — Crear Ingress (Twirp HTTP API)'); info(`LiveKit Server: ${LK_HTTP_URL}`); info(`Room: ${ROOM}`); const token = buildToken(); let ingress; try { const r = await request(`${LK_HTTP_URL}/twirp/livekit.Ingress/CreateIngress`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: { inputType: 1, name: 'e2e-obs', roomName: ROOM, participantIdentity: 'obs-whip', participantName: 'OBS' }, }); if (r.status !== 200) { fail(`Twirp CreateIngress → HTTP ${r.status}: ${r.body.slice(0, 200)}`); if (r.status === 401) info('→ JWT inválido o credenciales incorrectas'); return null; } ingress = JSON.parse(r.body); ok(`Ingress creado → ingress_id: ${ingress.ingress_id}`); ok(`stream_key: ${ingress.stream_key}`); ok(`url interna reportada: ${ingress.url}`); } catch (err) { fail(`Twirp request falló: ${err.message}`); return null; } return ingress; } // ── Test 2: Construir URL pública ────────────────────────────────────────── function testBuildUrl(ingress) { section('Test 2 — Construir URL pública WHIP'); if (!ingress) { fail('Sin ingress (test anterior falló)'); return null; } const whipUrl = `${UI_BASE_URL}/w/${ingress.stream_key}`; info(`UI_BASE_URL: ${UI_BASE_URL}`); info(`stream_key: ${ingress.stream_key}`); ok(`WHIP URL → ${whipUrl}`); try { const u = new URL(whipUrl); ok(`URL válida (protocol: ${u.protocol}, host: ${u.hostname})`); if (whipUrl.includes('192.168.') || whipUrl.includes('localhost')) { fail('La URL contiene IP/host privado — OBS externo NO puede conectar'); } else { ok('URL no contiene IPs privadas'); } if (u.pathname.endsWith(ingress.stream_key)) { ok(`Pathname termina en stream_key (/w/${ingress.stream_key})`); } else { fail(`Pathname inesperado: ${u.pathname}`); } } catch { fail(`URL malformada: ${whipUrl}`); return null; } return whipUrl; } // ── Test 3: OPTIONS al endpoint WHIP (OBS discovery) ────────────────────── async function testWhipOptions(whipUrl) { section('Test 3 — OPTIONS al endpoint WHIP (OBS discovery)'); if (!whipUrl) { fail('Sin URL (test anterior falló)'); return; } info(`URL: ${whipUrl}`); try { const r = await request(whipUrl, { method: 'OPTIONS', headers: { 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'content-type', Origin: UI_BASE_URL, }, }); info(`HTTP ${r.status}`); info(`Allow: ${r.headers['allow'] || r.headers['Access-Control-Allow-Methods'] || '(no Allow header)'}`); info(`Content-Type: ${r.headers['content-type'] || '(none)'}`); // livekit-ingress responde 200 o 204 a OPTIONS if ([200, 204, 405].includes(r.status)) { ok(`OPTIONS respondió ${r.status} — el proxy Caddy /w/* está activo`); } else if (r.status === 404) { fail(`404 — Caddy no tiene el bloque "handle /w/*" o livekit-ingress no responde`); } else if (r.status === 502 || r.status === 503) { fail(`${r.status} — Caddy no puede alcanzar livekit-ingress (${r.body.slice(0,100)})`); } else { info(`Respuesta no estándar ${r.status} — puede ser normal si livekit-ingress no implementa OPTIONS`); } // CORS check (necesario si el browser llama directamente) const acao = r.headers['access-control-allow-origin']; if (acao) { ok(`CORS: Access-Control-Allow-Origin: ${acao}`); } else { info('Sin header CORS (esperado si OBS no usa CORS)'); } } catch (err) { fail(`OPTIONS request falló: ${err.message}`); if (err.message === 'timeout') { info('→ El host no responde en 10s — ¿está Caddy corriendo? ¿el dominio resuelve?'); } } } // ── Test 4: POST al endpoint WHIP (simula inicio de sesión de OBS) ───────── async function testWhipPost(whipUrl) { section('Test 4 — POST al endpoint WHIP (SDP offer simulado)'); if (!whipUrl) { fail('Sin URL (test anterior falló)'); return; } info(`URL: ${whipUrl}`); // SDP mínimo válido — suficiente para que livekit-ingress procese la petición const sdpOffer = [ 'v=0', 'o=- 0 0 IN IP4 127.0.0.1', 's=-', 't=0 0', 'a=group:BUNDLE 0', 'm=video 9 UDP/TLS/RTP/SAVPF 96', 'c=IN IP4 0.0.0.0', 'a=sendonly', 'a=rtpmap:96 H264/90000', 'a=mid:0', ].join('\r\n') + '\r\n'; try { const r = await request(whipUrl, { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: sdpOffer, }); info(`HTTP ${r.status}`); info(`Content-Type: ${r.headers['content-type'] || '(none)'}`); info(`Location: ${r.headers['location'] || '(none)'}`); if (r.status === 201) { ok('201 Created — OBS puede conectar correctamente ✅'); info(`SDP answer (primeros 200 chars): ${r.body.slice(0, 200)}`); } else if (r.status === 422 || r.status === 400) { // Error de SDP esperado con nuestro SDP mínimo — pero confirma que el endpoint existe ok(`${r.status} — Endpoint WHIP alcanzado (SDP inválido esperado en e2e)`); info('OBS enviará un SDP real completo — esto es normal en tests'); } else if (r.status === 404) { fail('404 — La ruta /w/ no existe en livekit-ingress'); info('→ ¿El stream_key es válido? ¿El Ingress fue creado con éxito?'); } else if (r.status === 405) { fail('405 — El endpoint no acepta POST (Caddy file_server interviniendo?)'); } else if (r.status === 502 || r.status === 503) { fail(`${r.status} — Caddy no alcanza livekit-ingress`); info('→ Verifica LIVEKIT_INGRESS_HOST en docker-compose.yml'); info('→ Verifica que livekit-ingress esté corriendo en 192.168.1.20:8088'); } else { info(`Respuesta ${r.status}: ${r.body.slice(0, 200)}`); } } catch (err) { fail(`POST request falló: ${err.message}`); } } // ── Test 5: Limpiar ingress de prueba ────────────────────────────────────── async function cleanupIngress(ingressId) { section('Cleanup — Eliminar Ingress de prueba'); if (!ingressId) { info('Sin ingress que limpiar'); return; } const token = buildToken(); try { const r = await request(`${LK_HTTP_URL}/twirp/livekit.Ingress/DeleteIngress`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: { ingress_id: ingressId }, }); if (r.status === 200) { ok(`Ingress ${ingressId} eliminado`); } else { info(`Delete respondió ${r.status} (no crítico)`); } } catch (err) { info(`No se pudo eliminar: ${err.message}`); } } // ── Main ─────────────────────────────────────────────────────────────────── (async () => { console.log('╔════════════════════════════════════════════════════╗'); console.log('║ WHIP OBS End-to-End Test Suite ║'); console.log('╚════════════════════════════════════════════════════╝'); console.log(`\n LK Server: ${LK_HTTP_URL}`); console.log(` UI Base: ${UI_BASE_URL}`); console.log(` API Key: ${LK_API_KEY}`); const ingress = await testCreateIngress(); const whipUrl = testBuildUrl(ingress); await testWhipOptions(whipUrl); await testWhipPost(whipUrl); await cleanupIngress(ingress?.ingress_id); console.log('\n' + '─'.repeat(54)); if (failures === 0) { console.log(' ✅ Todos los tests pasaron'); } else { console.log(` ❌ ${failures} test(s) fallaron`); } console.log('─'.repeat(54) + '\n'); process.exit(failures > 0 ? 1 : 0); })();