openvibers/openvibe.live Commits

WebSocket paths Live does not serve get a plain HTTP answer (410 for the retired /ws/control, else 404) instead of a dropped socket nginx logged as a 502 (#338)

OP OpenVibers committed 21 days ago · 977e078892 · 4 files, +83 −3

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex f6023224..7de5f9d4 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -67,7 +67,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
## WebSocket Endpoints
--`/ws/broadcast`, `/ws/call`, `/ws/robotstreamer-publish` — upgraded via the handler in `server/index.js` with origin checks and IP bans. `/ws/chat` is OpenVibe.Chat's (nginx routes it to 127.0.0.1:4400); Live runs no chat WebSocket server.
++`/ws/broadcast`, `/ws/call`, `/ws/robotstreamer-publish` — upgraded via the handler in `server/index.js` with origin checks and IP bans. `/ws/chat` is OpenVibe.Chat's (nginx routes it to 127.0.0.1:4400); Live runs no chat WebSocket server. Any other path gets a plain HTTP answer, not a dropped socket (nginx logs a drop as a 502): 404, or 410 naming OpenVibe.Bot for the retired robot control socket `/ws/control` ([server/net/ws-paths.js](server/net/ws-paths.js)); a new WebSocket server adds its path there.
## Testing
ddiff --git a/server/index.js b/server/index.js
iindex 3b81f1f9..20802f34 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1180,11 +1180,15 @@ app.use((err, req, res, _next) => {
});
// ── WebSocket Upgrade Handler ────────────────────────────────
++const wsPaths = require('./net/ws-paths');
server.on('upgrade', async (req, socket, head) => {
// A restore-drill instance has no WebSocket servers: chat, broadcast, calls and controls are live
// traffic, and a drill only answers reads.
if (drill.enabled) return drill.refuseUpgrade(socket);
const url = req.url || '';
++ // A path no server here answers (a retired one says where it went): a plain HTTP answer, before any lookup.
++ const target = wsPaths.classify(url);
++ if (target !== 'served') return wsPaths.refuse(socket, target);
const origin = normalizeOrigin(req.headers.origin);
if (origin && !allowedOrigins.has(origin)) {
@@@ -1211,8 +1215,6 @@ server.on('upgrade', async (req, socket, head) => {
broadcastServer.handleUpgrade(req, socket, head);
} else if (url.startsWith('/ws/robotstreamer-publish')) {
robotStreamerService.handleUpgrade(req, socket, head);
-- } else {
-- socket.destroy();
}
});
ddiff --git a/server/net/ws-paths.js b/server/net/ws-paths.js
nnew file mode 100644
iindex 00000000..b22950a0
---- /dev/null
++++ b/server/net/ws-paths.js
@@@ -0,0 +1,31 @@
++'use strict';
++/**
++ * Which WebSocket paths Live upgrades, and the answer for the rest (server/index.js's upgrade handler).
++ *
++ * A path Live never served, or no longer serves, gets a real HTTP answer instead of a dropped socket: nginx logged
++ * every drop as a 502. Old Pi robot clients still reconnect to /ws/control every few seconds (robots moved to
++ * OpenVibe.Bot, plan T15): 2,137 502s from two of them in three hours on 2026-10-23. They now get a 410 that says
++ * where control went, and neither answer costs a ban lookup.
++ */
++const SERVED = ['/ws/vibe-coding/publish', '/ws/broadcast', '/ws/robotstreamer-publish'];
++const GONE = { '/ws/control': 'Robot control moved to OpenVibe.Bot: https://openvibe.bot' };
++
++/** 'served' for a path a WebSocket server here answers, else { status, text } to answer with. */
++function classify(url) {
++ const path = String(url || '').split('?')[0];
++ if (SERVED.some((p) => path.startsWith(p))) return 'served';
++ if (GONE[path]) return { status: '410 Gone', text: GONE[path] };
++ return { status: '404 Not Found', text: 'No WebSocket here' };
++}
++
++/** Answer an upgrade request with a plain HTTP status and close the socket. */
++function refuse(socket, { status, text }) {
++ const body = `${text}\n`;
++ try {
++ socket.write(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`, () => socket.destroy());
++ } catch {
++ try { socket.destroy(); } catch { /* */ }
++ }
++}
++
++module.exports = { classify, refuse, SERVED };
ddiff --git a/test/ws-paths.test.js b/test/ws-paths.test.js
nnew file mode 100644
iindex 00000000..852f67bf
---- /dev/null
++++ b/test/ws-paths.test.js
@@@ -0,0 +1,47 @@
++'use strict';
++// WebSocket paths Live does not serve get a real HTTP answer, not a dropped socket (server/net/ws-paths.js): nginx
++// logged every drop as a 502, 2,137 of them in three hours on 2026-10-23 from two old Pi clients reconnecting to
++// the retired /ws/control. /ws/control answers 410 naming OpenVibe.Bot, any other unknown path 404, and both before
++// the handler's origin check and ban lookup. The served list is exactly what server/index.js dispatches.
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const http = require('http');
++const path = require('path');
++const { classify, refuse, SERVED } = require('../server/net/ws-paths');
++
++(async () => {
++ assert.strictEqual(classify('/ws/broadcast?stream=1'), 'served');
++ assert.strictEqual(classify('/ws/vibe-coding/publish'), 'served');
++ assert.strictEqual(classify('/ws/robotstreamer-publish?key=x'), 'served');
++ assert.deepStrictEqual(classify('/ws/control'), { status: '410 Gone', text: 'Robot control moved to OpenVibe.Bot: https://openvibe.bot' });
++ assert.deepStrictEqual(classify('/ws/control?robot=7'), classify('/ws/control'));
++ assert.strictEqual(classify('/ws/nope').status, '404 Not Found');
++ assert.strictEqual(classify('').status, '404 Not Found');
++
++ // A real client reads the refusal as an HTTP response.
++ const server = http.createServer((_req, res) => res.end());
++ server.on('upgrade', (req, socket) => { const t = classify(req.url); if (t !== 'served') refuse(socket, t); });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const ask = (p) => new Promise((resolve, reject) => {
++ const req = http.request({ host: '127.0.0.1', port: server.address().port, path: p, headers: { Connection: 'Upgrade', Upgrade: 'websocket', 'Sec-WebSocket-Version': '13', 'Sec-WebSocket-Key': crypto.randomBytes(16).toString('base64') } });
++ req.on('response', (res) => { let body = ''; res.on('data', (c) => { body += c; }); res.on('end', () => resolve({ status: res.statusCode, body })); });
++ req.on('upgrade', () => reject(new Error('upgraded')));
++ req.on('error', reject);
++ req.end();
++ });
++ assert.deepStrictEqual(await ask('/ws/control'), { status: 410, body: 'Robot control moved to OpenVibe.Bot: https://openvibe.bot\n' });
++ assert.strictEqual((await ask('/ws/whatever')).status, 404);
++ server.close();
++
++ // server/index.js classifies first (before the origin check and the ban lookup) and dispatches exactly SERVED.
++ const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'index.js'), 'utf8');
++ const handler = src.slice(src.indexOf("server.on('upgrade'"), src.indexOf('// ── Initialize & Start'));
++ assert.ok(handler.indexOf('wsPaths.classify(url)') > 0 && handler.indexOf('wsPaths.classify(url)') < handler.indexOf('isIpBanned'), 'classified before the ban lookup');
++ assert.ok(handler.indexOf('wsPaths.classify(url)') < handler.indexOf('allowedOrigins.has'), 'classified before the origin check');
++ const dispatched = [...handler.matchAll(/url\.startsWith\('([^']+)'\)/g)].map((m) => m[1]);
++ assert.deepStrictEqual(dispatched.sort(), [...SERVED].sort(), 'SERVED is what the handler dispatches');
++ assert.ok(!/socket\.destroy\(\);\s*\}\s*\}\);/.test(handler), 'no silent drop for an unknown path');
++ console.log('ws-paths: ok');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });