From 62a6697d7d38cd80cf7d1f7b24e246c0ee754908 Mon Sep 17 00:00:00 2001 From: Cesar Mendivil Date: Fri, 20 Mar 2026 16:21:44 -0700 Subject: [PATCH] feat: add environment configuration files and update WHIP server URL handling --- .env.example | 21 +++ .env.local | 6 +- nginx-examples/djmaster.http.conf.template | 121 ++++++++++++++ public/config.js | 42 ++++- scripts/check-whip-obs-live.js | 186 +++++++++++++++++++++ src/index.js | 8 +- src/utils/api.js | 6 +- src/utils/restreamer.js | 33 +++- src/views/Edit/Sources/WebRTCRoom.js | 12 ++ 9 files changed, 420 insertions(+), 15 deletions(-) create mode 100644 .env.example create mode 100644 nginx-examples/djmaster.http.conf.template create mode 100644 scripts/check-whip-obs-live.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0a21226 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Environment variables for local development +# Set your YouTube Data API v3 key here (do NOT commit real keys to public repos) +YOUTUBE_API_KEY="AIzaSyABiXKk-1tcoR0wQnccZfutBDi0ijTr0Ko" +REACT_APP_CORE_URL=https://restreamer.nextream.sytes.net +#REACT_APP_YTDLP_URL=http://100.73.244.28:8080 +REACT_APP_YTDLP_URL=http://144.217.82.82:8282 +REACT_APP_YTDLP_URL_TITLES=http://100.73.244.28:8080 +# URL que la UI usa para construir el input WHEP del Core. +# Apunta al mismo dominio del UI — Caddy proxea /whep/* → egress server. +REACT_APP_WHIP_SERVER_URL=https://djmaster.nextream.sytes.net +# Vars REACT_APP_ expuestas al browser (CRA las inyecta en el bundle). +# La UI llama a IngressClient directamente sin intermediario Node. +REACT_APP_LIVEKIT_API_KEY=APIBTqTGxf9htMK +REACT_APP_LIVEKIT_API_SECRET=0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW +REACT_APP_LIVEKIT_WS_URL=wss://livekit-server.nextream.sytes.net +LIVEKIT_INGRESS_INTERNAL_URL=http://192.168.1.20:8088 +LIVEKIT_INGRESS_HOST=192.168.1.20:8088 +UI_BASE_URL=https://djmaster.nextream.sytes.net +# Destino local del proxy de desarrollo para /api/whip/* y /whep/* (CRA setupProxy). +# NO confundir con REACT_APP_WHIP_SERVER_URL (esa es la URL pública para el frontend). +WHIP_API_TARGET=http://localhost:3005 \ No newline at end of file diff --git a/.env.local b/.env.local index c674fa0..4bc8984 100644 --- a/.env.local +++ b/.env.local @@ -1,8 +1,8 @@ # Local overrides (gitignored). Put real secrets here on your development machine. REACT_APP_YOUTUBE_API_KEY="AIzaSyABiXKk-1tcoR0wQnccZfutBDi0ijTr0Ko" -REACT_APP_CORE_URL=https://restreamer.nextream.sytes.net -REACT_APP_WHIP_BASE_URL=http://192.168.1.15:8555 -REACT_APP_YTDLP_URL=http://144.217.82.82:8282 +REACT_APP_CORE_URL=http://192.168.1.20:8080 +REACT_APP_WHIP_BASE_URL=http://192.168.1.20:8555 +REACT_APP_YTDLP_URL=http://192.168.1.20:8282 REACT_APP_YTDLP_URL_TITLES=http://100.73.244.28:8080 REACT_APP_FB_SERVER_URL=http://localhost:3002 REACT_APP_LIVEKIT_API_KEY=APIBTqTGxf9htMK diff --git a/nginx-examples/djmaster.http.conf.template b/nginx-examples/djmaster.http.conf.template new file mode 100644 index 0000000..7b5fbda --- /dev/null +++ b/nginx-examples/djmaster.http.conf.template @@ -0,0 +1,121 @@ +# djmaster HTTP-only Nginx template +# Use this when another front-facing proxy/terminator handles TLS and you want +# Nginx to listen on port 80 (cleartext) and proxy to local backends. + +# Map for websocket upgrade handling +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name ${UI_HOST}; + + client_max_body_size 512M; + client_body_timeout 300s; + keepalive_timeout 65; + + # Frontend (UI) — proxy to your UI backend (webpack dev or static server) + # Set UI_BACKEND_HOST to 192.168.1.15 (or 127.0.0.1) when rendering + location / { + proxy_pass http://${UI_BACKEND_HOST}:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_buffering off; + proxy_read_timeout 120s; + } + + # WebSocket endpoint (HMR / app ws) — ensure upgrades are forwarded + location /ws { + proxy_pass http://${UI_BACKEND_HOST}:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 3600s; + proxy_buffering off; + } + + # Microserver for OAuth and config persistence + location /fb-server/ { + proxy_pass http://${MICROSERVER_HOST}:3002/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 120s; + } + + # WHIP Ingest API and WHEP relay (egress). If your egress listens elsewhere, + # adjust ${WHIP_HOST} and ports accordingly. + location /api/whip/ { + proxy_pass http://${WHIP_HOST}:3005/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_buffering off; + proxy_read_timeout 300s; + } + + location /whep/ { + proxy_pass http://${WHIP_HOST}:3005/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_buffering off; + proxy_read_timeout 300s; + } + + # WHIP ingest (POST with SDP) — forward to livekit-ingress/internal target + location /w/ { + proxy_pass http://${LIVEKIT_INGRESS_HOST}/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_buffering off; + proxy_read_timeout 300s; + } + + # yt-dlp stream extractor and metadata proxies + location /yt-stream/ { + proxy_pass http://${YTDLP_HOST}/; + proxy_set_header Host ${YTDLP_HOST}; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 300s; + } + + location /yt-titles/ { + proxy_pass http://${YTDLP_TITLES_HOST}/; + proxy_set_header Host ${YTDLP_TITLES_HOST}; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 120s; + } + + # Basic security headers + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + add_header Referrer-Policy "no-referrer-when-downgrade"; + add_header X-XSS-Protection "1; mode=block"; +} diff --git a/public/config.js b/public/config.js index fcdf68e..beb01b6 100644 --- a/public/config.js +++ b/public/config.js @@ -21,13 +21,51 @@ * FB_SERVER_URL = '' (leave empty — Caddy proxies /fb-server → localhost:3002) */ window.__RESTREAMER_CONFIG__ = { - CORE_ADDRESS: '', + CORE_ADDRESS: 'http://192.168.1.20:8080', YTDLP_URL: '', YTDLP_TITLES_URL: '', FB_SERVER_URL: '', // URL pública del servidor egress (WHIP ingest + WHEP relay). // Ej: 'https://llmchats-whep.zuqtxy.easypanel.host' - WHIP_SERVER_URL: '', + // Se fija explícitamente para respetar las variables locales. + WHIP_SERVER_URL: 'http://192.168.1.20:8555', + // Optional: override the WHIP port used when deriving from CORE_ADDRESS + WHIP_PORT: '', }; +// Derive WHIP_SERVER_URL at runtime if not explicitly provided. +(function () { + try { + var cfg = window.__RESTREAMER_CONFIG__ || {}; + + // helper to derive from a base URL and a port + function deriveFrom(base, port) { + if (!base) return ''; + try { + var u = new URL(base); + var p = port && port.length ? port : '8555'; + return u.protocol + '//' + u.hostname + (p ? ':' + p : ''); + } catch (e) { + return ''; + } + } + + if ((!cfg.WHIP_SERVER_URL || cfg.WHIP_SERVER_URL.length === 0)) { + // prefer explicit CORE_ADDRESS runtime value + if (cfg.CORE_ADDRESS && cfg.CORE_ADDRESS.length) { + var derived = deriveFrom(cfg.CORE_ADDRESS, cfg.WHIP_PORT); + if (derived) cfg.WHIP_SERVER_URL = derived; + } else if (window && window.location && window.location.origin) { + // fallback to current origin + var derived2 = deriveFrom(window.location.origin, cfg.WHIP_PORT); + if (derived2) cfg.WHIP_SERVER_URL = derived2; + } + } + + window.__RESTREAMER_CONFIG__ = cfg; + } catch (e) { + // no-op + } +})(); + diff --git a/scripts/check-whip-obs-live.js b/scripts/check-whip-obs-live.js new file mode 100644 index 0000000..1ba6605 --- /dev/null +++ b/scripts/check-whip-obs-live.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node +/** + * check-whip-obs-live.js + * + * Verifica de forma E2E que un publisher WHIP (ej. OBS) está enviando al + * endpoint proporcionado. Realiza: + * - OPTIONS al endpoint WHIP (comprobación básica) + * - GET al endpoint /whip//sdp (si está disponible) + * - Polling a Core: GET /api/v3/whip para detectar el stream activo + * + * Uso: + * node scripts/check-whip-obs-live.js --url "http://192.168.1.15:8555/whip/06a2..." --token heavy666 --core http://192.168.1.15:8080 --timeout 30 + */ + +'use strict'; + +const http = require('http'); +const https = require('https'); + +function parseArgs() { + const out = {}; + const argv = process.argv.slice(2); + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { + const k = a.replace(/^--/, ''); + const v = (argv[i+1] && !argv[i+1].startsWith('--')) ? argv[++i] : 'true'; + out[k] = v; + } + } + return out; +} + +function request(url, { method = 'GET', headers = {}, body, timeout = 15000 } = {}) { + return new Promise((resolve, reject) => { + try { + const u = new URL(url); + const lib = u.protocol === 'https:' ? https : http; + const raw = body; + const opts = { + hostname: u.hostname, + port: u.port || (u.protocol === 'https:' ? 443 : 80), + path: u.pathname + (u.search || ''), + method, + headers: { ...headers }, + timeout, + rejectUnauthorized: false, + }; + + 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(new Error('timeout')); }); + if (raw) req.write(raw); + req.end(); + } catch (e) { + reject(e); + } + }); +} + +async function main() { + const args = parseArgs(); + + const WHIP_URL = args.url || process.env.WHIP_URL; + const TOKEN = args.token || process.env.WHIP_TOKEN || ''; + const CORE_URL = args.core || process.env.CORE_URL || process.env.REACT_APP_CORE_URL || 'http://127.0.0.1:8080'; + const TIMEOUT = parseInt(args.timeout || process.env.WHIP_CHECK_TIMEOUT || '30', 10); + const INTERVAL = parseInt(args.interval || process.env.WHIP_CHECK_INTERVAL || '3', 10); + + if (!WHIP_URL) { + console.error('Usage: node scripts/check-whip-obs-live.js --url [--token ] [--core ]'); + process.exit(2); + } + + console.log('\nWHIP E2E live check'); + console.log(` WHIP URL: ${WHIP_URL}`); + if (TOKEN) console.log(` token: ${TOKEN}`); + console.log(` Core: ${CORE_URL}`); + console.log(` Poll timeout: ${TIMEOUT}s (interval ${INTERVAL}s)`); + + // Extract stream key + let streamKey = null; + try { + const u = new URL(WHIP_URL); + const parts = u.pathname.split('/').filter(Boolean); + streamKey = parts[parts.length - 1]; + } catch (e) { + console.error('Invalid WHIP_URL:', e.message); + process.exit(2); + } + + // OPTIONS (OBS does this first) + try { + const urlWithToken = TOKEN ? (WHIP_URL + (WHIP_URL.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN)) : WHIP_URL; + console.log('\n1) OPTIONS → WHIP endpoint (discovery)'); + const opt = await request(urlWithToken, { + method: 'OPTIONS', + headers: { + 'Origin': args.origin || 'http://localhost:3000', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'content-type', + }, + timeout: 10000, + }); + console.log(` HTTP ${opt.status}`); + console.log(' Allow:', opt.headers['allow'] || opt.headers['access-control-allow-methods'] || '(none)'); + } catch (e) { + console.error(' OPTIONS failed:', e.message); + } + + // GET /whip//sdp (relay SDP) — may or may not be implemented + try { + const base = (() => { const u = new URL(WHIP_URL); return u.origin; })(); + const sdpUrl = base + '/whip/' + encodeURIComponent(streamKey) + '/sdp' + (TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : ''); + console.log('\n2) GET /whip//sdp (relay SDP)'); + const r = await request(sdpUrl, { method: 'GET', timeout: 8000 }); + console.log(` GET ${sdpUrl} → HTTP ${r.status}`); + if (r.body && r.body.length) console.log(' Body length:', r.body.length); + } catch (e) { + console.error(' GET /sdp failed:', e.message); + } + + // Poll Core /api/v3/whip for active publishers + console.log('\n3) Poll Core /api/v3/whip looking for active stream key'); + const start = Date.now(); + let found = false; + const whipApi = (CORE_URL.replace(/\/$/, '') + '/api/v3/whip'); + // Build optional auth header + const headers = {}; + if (process.env.CORE_AUTH) { + headers['Authorization'] = process.env.CORE_AUTH; + } else if (process.env.CORE_USER && process.env.CORE_PASS) { + const token = Buffer.from(process.env.CORE_USER + ':' + process.env.CORE_PASS).toString('base64'); + headers['Authorization'] = 'Basic ' + token; + } + + while ((Date.now() - start) / 1000 < TIMEOUT) { + try { + const res = await request(whipApi, { method: 'GET', headers, timeout: 5000 }); + if (res.status === 200) { + let list = []; + try { list = JSON.parse(res.body); } catch (e) { /* ignore */ } + if (Array.isArray(list)) { + const hit = list.find((it) => String(it.name) === String(streamKey)); + if (hit) { + console.log(` ✅ Stream detected in Core /api/v3/whip: ${streamKey}`); + console.log(' → published_at:', hit.published_at || '(unknown)'); + found = true; + break; + } else { + process.stdout.write('.'); + } + } else { + process.stdout.write('?'); + } + } else { + process.stdout.write('x'); + } + } catch (e) { + process.stdout.write('!'); + } + + await new Promise((r) => setTimeout(r, INTERVAL * 1000)); + } + + console.log(''); + if (!found) { + console.error(`\n✗ Stream not detected within ${TIMEOUT}s. Si OBS ya está enviando, verifica:`); + console.error('- Que el stream key usado en OBS coincida exactamente'); + console.error('- Que Core esté exponiendo /api/v3/whip sin autenticación o proveas credenciales'); + console.error('- Logs del Core/egress para errores de ingest'); + process.exit(1); + } + + console.log('\nE2E check completo — stream activo.'); + process.exit(0); +} + +main().catch((e) => { + console.error('Fatal:', e.message || e); + process.exit(2); +}); diff --git a/src/index.js b/src/index.js index 15e8715..d4e5c45 100644 --- a/src/index.js +++ b/src/index.js @@ -37,9 +37,11 @@ if (urlParams.has('address')) { window.location.pathname.replace(/\/ui\/.*$/, ''); } else if (process.env.REACT_APP_CORE_URL && process.env.REACT_APP_CORE_URL.trim() !== '') { // 4. .env / .env.local — CRA injects at build time. - // Use window.location.origin so all /api/* and /memfs/* calls go through - // the CRA dev proxy (setupProxy.js) which forwards them to REACT_APP_CORE_URL. - address = window.location.origin; + // Prefer the explicit REACT_APP_CORE_URL build-time value so the UI + // connects to the configured Core directly instead of relying on + // window.location.origin. This ensures the UI respects the user's + // REACT_APP_CORE_URL setting from .env.local or the build environment. + address = process.env.REACT_APP_CORE_URL.trim().replace(/\/$/, ''); } else { // 5. Same-origin production (Core and UI on the same host/port) address = window.location.origin; diff --git a/src/utils/api.js b/src/utils/api.js index 9215386..b1b448a 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -142,7 +142,11 @@ class API { } } - this._error(res.err.message); + // Avoid noisy console.error for expected 404 responses (e.g. probe cleanup). + // Keep logging for other HTTP errors. + if (response.status !== 404) { + this._error(res.err.message); + } return res; } diff --git a/src/utils/restreamer.js b/src/utils/restreamer.js index c4104d0..ecf6451 100644 --- a/src/utils/restreamer.js +++ b/src/utils/restreamer.js @@ -15,6 +15,28 @@ import * as Version from '../version'; import API from './api'; import { anonymize } from './anonymizer'; +// Helper: derive WHIP base URL +function computeWhipBaseFromEnv() { + // If explicit override provided, use it + try { + const envOverride = (process && process.env && process.env.REACT_APP_WHIP_BASE_URL) ? process.env.REACT_APP_WHIP_BASE_URL : ''; + if (envOverride && envOverride.length) return envOverride.replace(/\/$/, ''); + + // Otherwise derive from REACT_APP_CORE_URL by reusing hostname and swapping port + const core = (process && process.env && process.env.REACT_APP_CORE_URL) ? process.env.REACT_APP_CORE_URL : ''; + if (core && core.length) { + const coreUrl = new URL(core); + const whipPort = (process && process.env && process.env.REACT_APP_WHIP_PORT) ? process.env.REACT_APP_WHIP_PORT : '8555'; + const portSuffix = whipPort ? ':' + whipPort : ''; + return coreUrl.protocol + '//' + coreUrl.hostname + portSuffix; + } + } catch (e) { + // fallthrough + } + + return ''; +} + class Restreamer { constructor(address) { try { @@ -990,10 +1012,9 @@ class Restreamer { return null; } - // Allow overriding the public host:port via REACT_APP_WHIP_BASE_URL. - // Useful when the Core reports the wrong public IP (e.g. behind NAT) - // and you want the OBS URL to show a specific host, e.g. a LAN IP. - const override = process.env.REACT_APP_WHIP_BASE_URL; + // Allow overriding the public host:port via REACT_APP_WHIP_BASE_URL + // or derive it from REACT_APP_CORE_URL (reuse domain, change port). + const override = computeWhipBaseFromEnv(); if (override && val) { const base = override.replace(/\/$/, '') + '/whip/'; val.base_publish_url = base; @@ -1010,8 +1031,8 @@ class Restreamer { return null; } - // Same host override as WhipUrl(). - const override = process.env.REACT_APP_WHIP_BASE_URL; + // Same host override as WhipUrl() — consider env override or derive from CORE_URL. + const override = computeWhipBaseFromEnv(); if (override && val) { const base = override.replace(/\/$/, '') + '/whip/'; val.publish_url = base + name; diff --git a/src/views/Edit/Sources/WebRTCRoom.js b/src/views/Edit/Sources/WebRTCRoom.js index a3108f6..70f829a 100644 --- a/src/views/Edit/Sources/WebRTCRoom.js +++ b/src/views/Edit/Sources/WebRTCRoom.js @@ -118,6 +118,18 @@ function getWhipServerUrl() { if (process.env.REACT_APP_WHIP_SERVER_URL) { return String(process.env.REACT_APP_WHIP_SERVER_URL).replace(/\/+$/, ''); } + + // If WHIP server URL not provided, derive from REACT_APP_CORE_URL by reusing + // the core's hostname and swapping the port (default WHIP port 8555). + if (process.env.REACT_APP_CORE_URL) { + try { + const coreUrl = new URL(process.env.REACT_APP_CORE_URL); + const whipPort = process.env.REACT_APP_WHIP_PORT || '8555'; + return coreUrl.protocol + '//' + coreUrl.hostname + (whipPort ? ':' + whipPort : ''); + } catch (e) { + // ignore and fallback + } + } // Fallback: mismo origen del UI (útil si egress corre detrás del mismo proxy) const loc = window.location; return (loc.origin || `${loc.protocol}//${loc.host}`);