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)
Changes
@@@ -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@@@ -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();}});nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -67,7 +67,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
@@ -67,7 +67,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
## WebSocket Endpoints
## 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
## Testing
@@ -1180,11 +1180,15 @@ app.use((err, req, res, _next) => {
@@ -1180,11 +1180,15 @@ app.use((err, req, res, _next) => {
});
});
// ── WebSocket Upgrade Handler ────────────────────────────────
// ── WebSocket Upgrade Handler ────────────────────────────────
+const wsPaths = require('./net/ws-paths');
server.on('upgrade', async (req, socket, head) => {
server.on('upgrade', async (req, socket, head) => {
// A restore-drill instance has no WebSocket servers: chat, broadcast, calls and controls are live
// A restore-drill instance has no WebSocket servers: chat, broadcast, calls and controls are live
// traffic, and a drill only answers reads.
// traffic, and a drill only answers reads.
if (drill.enabled) return drill.refuseUpgrade(socket);
if (drill.enabled) return drill.refuseUpgrade(socket);
const url = req.url || '';
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);
const origin = normalizeOrigin(req.headers.origin);
if (origin && !allowedOrigins.has(origin)) {
if (origin && !allowedOrigins.has(origin)) {
@@ -1211,8 +1215,6 @@ server.on('upgrade', async (req, socket, head) => {
@@ -1211,8 +1215,6 @@ server.on('upgrade', async (req, socket, head) => {
broadcastServer.handleUpgrade(req, socket, head);
broadcastServer.handleUpgrade(req, socket, head);
} else if (url.startsWith('/ws/robotstreamer-publish')) {
} else if (url.startsWith('/ws/robotstreamer-publish')) {
robotStreamerService.handleUpgrade(req, socket, head);
robotStreamerService.handleUpgrade(req, socket, head);
- } else {
- socket.destroy();
}
}
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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); });