Implementación Proxy — Node.js
GET /video — MJPEG stream proxy
const http = require('http');
app.get('/video', (req, res) => {
const upstream = http.get(
'http://127.0.0.1:8080/stream',
(mjpeg) => {
res.writeHead(200, {
'Content-Type': mjpeg.headers['content-type'],
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff'
});
mjpeg.pipe(res);
}
);
upstream.on('error', (err) => {
res.status(502).end('upstream down');
});
req.on('close', () => upstream.destroy());
});
req.on('close'): destruir el upstream cuando el browser cierra la pestaña. Sin esto, los sockets se acumulan y µStreamer cuenta "clientes fantasma".
GET /snapshot — JPEG single frame
app.get('/snapshot', (req, res) => {
const upstream = http.get(
'http://127.0.0.1:8080/snapshot',
(snap) => {
res.writeHead(200, {
'Content-Type': 'image/jpeg',
'Cache-Control': 'no-store'
});
snap.pipe(res);
}
);
upstream.on('error', () => res.status(502).end());
req.on('close', () => upstream.destroy());
});
GET /audio — Mumble bridge OGG
app.get('/audio', (req, res) => {
res.writeHead(200, {
'Content-Type': 'audio/ogg',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-store'
});
audioBridge.pipe(res);
req.on('close', () => audioBridge.unpipe(res));
});
Template: webrtc_view (cero JS)
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SNH — Cámara</title>
<style>
body { margin:0; background:#111; color:#eee;
font-family:monospace; text-align:center; }
img { max-width:100%; height:auto; display:block; margin:0 auto; }
audio { margin:12px auto; display:block; }
.meta { font-size:0.7rem; opacity:0.5; margin:8px; }
</style>
</head>
<body>
<img src="/video" alt="MJPEG stream">
<audio src="/audio" controls autoplay></audio>
<p class="meta">
µStreamer MJPEG · Mumble OGG · cero JavaScript
</p>
</body>
</html>
Cero JS: el browser nativo del SNH (Oasis) renderiza HTML/CSS puro. <img> con MJPEG se actualiza automáticamente (el browser lo maneja nativamente). <audio> con OGG chunked hace autoplay sin scripts.
Security Headers — Oasis Middleware
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('Content-Security-Policy',
"default-src 'self'; " +
"img-src 'self'; " +
"media-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"script-src 'none'" // ← cero JS
);
next();
});
script-src 'none': refuerza a nivel de CSP que no se ejecute JS. Doble seguridad: no lo servimos Y el browser no lo aceptaría.
Health Check Pattern
app.get('/status', async (req, res) => {
const [ustreamer, mumble] = await Promise.allSettled([
fetch('http://127.0.0.1:8080/state').then(r => r.json()),
checkMumbleAlive(64738)
]);
res.json({
video: ustreamer.status === 'fulfilled'
? { ok: true, ...ustreamer.value }
: { ok: false },
audio: { ok: mumble.status === 'fulfilled' },
uptime: process.uptime()
});
});
DRY Rules for Oasis Routes
1. Toda upstream es 127.0.0.1 — nunca exponer puertos internos al exterior.
2. Siempre destroy upstream on req close — evitar socket leaks.
3. Siempre Cache-Control: no-store en streams — no cachear datos en vuelo.
4. Un solo puerto público: :3000 — todo lo demás es localhost.
5. CSP con script-src 'none' — garantía doble de cero JS.
6. Para snapshots, considerar guardar como SSB blob (inmutable, direccionable por hash).