import { createServer } from 'node:http'; import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { isIP } from 'node:net'; import { pathToFileURL } from 'node:url'; import { open, lstat } from 'node:fs/promises'; import { resolve } from 'node:path'; const BODY_LIMIT = 2048, LEASE_MS = 5000, COMMAND_MS = 3000, QUEUE_LIMIT = 16; const fail = (status, message) => Object.assign(new Error(message), { status }); const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); const fields = (value, names) => object(value) && Object.keys(value).length === names.length && names.every(name => Object.hasOwn(value, name)); const ipv4 = address => address?.startsWith('::ffff:') ? address.slice(7) : address; const privateIPv4 = address => { if (isIP(address) !== 4) return false; const [a, b] = address.split('.').map(Number); return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168; }; const loopback = address => ipv4(address) === '127.0.0.1'; const safeEqual = (a, b) => typeof a === 'string' && Buffer.byteLength(a) === Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a), Buffer.from(b)); const key = value => { if (value === undefined) return randomBytes(32).toString('hex'); if (typeof value !== 'string' || !/^[a-fA-F0-9]{32,128}$/.test(value)) throw new Error('Bridge keys require 32–128 hexadecimal characters.'); return value; }; const validateKeys = value => { if (!fields(value, ['studioToken', 'controlToken', 'statusToken'])) throw new Error('Credential file must contain exactly the three bridge keys.'); const keys = Object.fromEntries(Object.entries(value).map(([name, value]) => [name, key(value)])); if (new Set(Object.values(keys)).size !== 3) throw new Error('Studio, control and status keys must be different.'); return Object.freeze(keys); }; /** Owner-chosen connector credentials only; never creates parent directories or replaces a file. */ export async function loadBridgeKeys(path) { if (typeof path !== 'string' || !path.trim()) throw new Error('Choose a bridge credential file path.'); const absolute = resolve(path); let exists = true; try { const info = await lstat(absolute); if (!info.isFile() || info.size > BODY_LIMIT) throw new Error('Credential path must be a small regular file.'); } catch (error) { if (error.code !== 'ENOENT') throw error; exists = false; } if (exists) { const file = await open(absolute, 'r'); try { const info = await file.stat(); if (!info.isFile() || info.size > BODY_LIMIT) throw new Error('Credential path must be a small regular file.'); let parsed; try { parsed = JSON.parse(await file.readFile('utf8')); } catch { throw new Error('Credential file contains invalid JSON. Existing keys were kept.'); } return validateKeys(parsed); } finally { await file.close(); } } const keys = validateKeys({ studioToken: key(), controlToken: key(), statusToken: key() }); // wx refuses an existing leaf, including a symlink. A missing parent remains an error. const file = await open(absolute, 'wx', 0o600); try { await file.writeFile(JSON.stringify(keys, null, 2) + '\n', 'utf8'); await file.sync(); } finally { await file.close(); } return keys; } const port = (value, fallback) => { const result = value ?? fallback; if (!Number.isInteger(result) || result < 0 || result > 65535) throw new Error('Invalid bridge port.'); return result; }; const bearer = (request, expected) => { const match = /^Bearer ([a-fA-F0-9]{32,128})$/i.exec(request.headers.authorization ?? ''); return !!match && safeEqual(match[1], expected); }; const statusAuth = (request, tokens) => { if (bearer(request, tokens.controlToken)) return true; const match = /^Basic ([A-Za-z0-9+/]+={0,2})$/i.exec(request.headers.authorization ?? ''); if (!match || match[1].length > 256) return false; const encoded = Buffer.from(match[1], 'base64'); if (encoded.toString('base64') !== match[1]) return false; return safeEqual(encoded.toString('utf8'), `phasevoid:${tokens.statusToken}`); }; function command(value) { if (!object(value)) throw fail(400, 'Invalid room command.'); if (value.action === 'mode' && fields(value, ['action', 'mode']) && ['ambient', 'party', 'techno'].includes(value.mode)) return { action: 'mode', mode: value.mode }; if (value.action === 'brightness' && fields(value, ['action', 'value']) && typeof value.value === 'number' && Number.isFinite(value.value) && value.value >= 0 && value.value <= 100) return { action: 'brightness', value: value.value }; if (['blackout', 'freeze'].includes(value.action) && fields(value, ['action', 'value']) && typeof value.value === 'boolean') return { action: value.action, value: value.value }; if (value.action === 'stop' && fields(value, ['action'])) return { action: 'stop' }; throw fail(400, 'Invalid room command.'); } function readJSON(request) { if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(request.headers['content-type'] ?? '') || request.headers['content-encoding']) { request.resume(); return Promise.reject(fail(415, 'Use uncompressed application/json.')); } if (Number(request.headers['content-length']) > BODY_LIMIT) { request.resume(); return Promise.reject(fail(413, 'Request body is too large.')); } return new Promise((resolve, reject) => { let size = 0, settled = false; const chunks = []; const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); request.removeListener('data', data); request.removeListener('end', end); request.removeListener('error', errored); request.removeListener('aborted', aborted); if (error) { request.resume(); reject(error); } else resolve(value); }; const data = chunk => { size += chunk.length; if (size > BODY_LIMIT) finish(fail(413, 'Request body is too large.')); else chunks.push(chunk); }; const end = () => { try { finish(null, JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)))); } catch { finish(fail(400, 'Invalid JSON body.')); } }; const errored = () => finish(fail(400, 'Request interrupted.')); const aborted = () => finish(fail(400, 'Request interrupted.')); const timer = setTimeout(() => finish(fail(408, 'Request body timed out.')), 2000); timer.unref(); request.on('data', data); request.on('end', end); request.on('error', errored); request.on('aborted', aborted); }); } /** Two narrowly scoped listeners. No shell, file serving, upstream fetch or device credentials. */ export async function createRoomBridge(options = {}) { const tokens = Object.freeze({ studioToken: key(options.studioToken), controlToken: key(options.controlToken), statusToken: key(options.statusToken) }); if (new Set(Object.values(tokens)).size !== 3) throw new Error('Studio, control and status keys must be different.'); const studioPort = port(options.studioPort, 43120), controlPort = port(options.controlPort, 43121); const lan = options.lan, miniserver = options.miniserver; if (lan !== undefined && (!privateIPv4(lan) && lan !== '127.0.0.1')) throw new Error('Choose an exact private IPv4 LAN interface, or 127.0.0.1 for an emulator.'); if (lan !== undefined && (!privateIPv4(miniserver) && miniserver !== '127.0.0.1')) throw new Error('An explicit private Miniserver IPv4 address is required.'); if (lan === undefined && miniserver !== undefined) throw new Error('Choose the LAN interface before specifying a Miniserver.'); const origins = new Set(['https://phasevoid.com']); if (options.devOrigin !== undefined) { let url; try { url = new URL(options.devOrigin); } catch { throw new Error('Invalid development origin.'); } if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(url.hostname) || url.origin !== options.devOrigin || url.username || url.password) throw new Error('Development origin must be an exact loopback HTTP origin.'); origins.add(url.origin); } const now = options.now ?? Date.now; if (typeof now !== 'function') throw new Error('Invalid bridge clock.'); let lease = null, lastResult = null, closed = false; const queueSize = () => lease ? lease.queue.length + Number(!!lease.inflight) : 0; const result = (item, ok, message) => { lastResult = { id: item.id, ok, message, at: now() }; }; const wipe = message => { if (!lease) return; const last = lease.inflight ?? lease.queue.at(-1); if (last) result(last, false, message); lease.inflight = null; lease.queue = []; }; const expire = () => { if (lease && now() - lease.lastPollAt >= LEASE_MS) { wipe('Studio connection expired.'); lease = null; } if (!lease) return; if (lease.inflight && lease.inflight.expiresAt <= now()) { result(lease.inflight, false, 'Command expired.'); lease.inflight = null; } lease.queue = lease.queue.filter(item => { if (item.expiresAt > now()) return true; result(item, false, 'Command expired.'); return false; }); }; const requireLease = body => { expire(); if (!lease || body.lease !== lease.id) throw fail(409, 'Pair Studio again.'); return lease; }; const studioStatus = () => ({ version: 1, leaseLive: !!lease, armed: !!lease?.armed, pending: queueSize(), lastResult: lastResult ? { ...lastResult } : null }); const lanStatus = () => ({ version: 1, online: lease ? 1 : 0, armed: lease?.armed ? 1 : 0, pending: queueSize(), lastOk: lastResult ? Number(lastResult.ok) : -1, lastId: lastResult?.id ?? '', genericMessage: lastResult?.message ?? '' }); const send = (response, status, body) => { if (response.destroyed || response.writableEnded) return; response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }); response.end(JSON.stringify(body)); }; let studioServer, controlServer = null; const endpointHost = (server, host) => `${host}:${server.address().port}`; const handle = studio => async (request, response) => { request.setTimeout(5000, () => request.destroy()); try { const expectedHost = endpointHost(studio ? studioServer : controlServer, studio ? '127.0.0.1' : lan); if (request.headers.host !== expectedHost || (studio ? !loopback(request.socket.remoteAddress) : ipv4(request.socket.remoteAddress) !== miniserver)) throw fail(403, 'Endpoint not allowed.'); if (studio) { if (!origins.has(request.headers.origin)) throw fail(403, 'Origin not allowed.'); response.setHeader('Access-Control-Allow-Origin', request.headers.origin); response.setHeader('Vary', 'Origin'); } else if (request.headers.origin !== undefined) throw fail(403, 'Browser requests are not accepted here.'); const path = request.url; const paths = studio ? ['/v1/pair', '/v1/arm', '/v1/poll', '/v1/ack', '/v1/unpair', '/v1/status'] : ['/v1/command', '/v1/status']; if (!paths.includes(path)) throw fail(404, 'Route not found.'); if (studio && request.method === 'OPTIONS') { const method = path === '/v1/status' ? 'GET' : 'POST'; const requestedHeaders = (request.headers['access-control-request-headers'] ?? '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean); if (request.headers['access-control-request-method'] !== method || requestedHeaders.some(header => !['authorization', 'content-type'].includes(header))) throw fail(403, 'Preflight not allowed.'); response.writeHead(204, { 'Access-Control-Allow-Methods': method, 'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Cache-Control': 'no-store' }); response.end(); return; } const authorized = studio ? bearer(request, tokens.studioToken) : path === '/v1/status' ? statusAuth(request, tokens) : bearer(request, tokens.controlToken); if (!authorized) { if (!studio && path === '/v1/status') response.setHeader('WWW-Authenticate', 'Basic realm="PHASEVOID status"'); throw fail(401, 'Bridge authentication required.'); } expire(); if (path === '/v1/status') { if (request.method !== 'GET') throw fail(405, 'Use GET for status.'); send(response, 200, studio ? studioStatus() : lanStatus()); return; } if (request.method !== 'POST') throw fail(405, 'Use POST for this route.'); const body = await readJSON(request); // A body can take seconds to arrive: lease checks must happen again after it. expire(); if (!studio) { const validated = command(body); if (!lease) throw fail(409, 'No live Studio is paired.'); if (!lease.armed) throw fail(403, 'Studio remote control is disarmed.'); if (validated.action === 'stop') wipe('Superseded by Stop.'); if (queueSize() >= QUEUE_LIMIT) throw fail(429, 'Room command queue is full.'); const item = { id: randomUUID(), command: validated, expiresAt: now() + COMMAND_MS }; lease.queue.push(item); send(response, 202, { version: 1, id: item.id, state: 'queued' }); return; } if (path === '/v1/pair') { if (!fields(body, [])) throw fail(400, 'Pair requires an empty object.'); if (lease) throw fail(409, 'A Studio is already paired.'); lease = { id: randomBytes(24).toString('hex'), armed: false, lastPollAt: now(), queue: [], inflight: null, confirmed: new Map() }; send(response, 200, { version: 1, lease: lease.id, armed: false }); return; } if (path === '/v1/arm') { if (!fields(body, ['lease', 'armed']) || typeof body.lease !== 'string' || typeof body.armed !== 'boolean') throw fail(400, 'Invalid arming request.'); const live = requireLease(body); if (!body.armed) wipe('Remote control disarmed.'); live.armed = body.armed; send(response, 200, { version: 1, armed: live.armed }); return; } if (path === '/v1/poll' || path === '/v1/unpair') { if (!fields(body, ['lease']) || typeof body.lease !== 'string') throw fail(400, 'Invalid Studio lease.'); const live = requireLease(body); if (path === '/v1/unpair') { wipe('Studio disconnected.'); lease = null; send(response, 200, { version: 1, paired: false }); return; } live.lastPollAt = now(); if (live.armed && !live.inflight) live.inflight = live.queue.shift() ?? null; send(response, 200, { version: 1, armed: live.armed, command: live.inflight }); return; } if (path === '/v1/ack') { if (!fields(body, ['lease', 'id', 'ok', 'message']) || typeof body.lease !== 'string' || typeof body.id !== 'string' || body.id.length > 64 || typeof body.ok !== 'boolean' || typeof body.message !== 'string' || body.message.length > 160) throw fail(400, 'Invalid acknowledgement.'); const live = requireLease(body), confirmed = live.confirmed.get(body.id); if (confirmed) { if (confirmed.ok !== body.ok) throw fail(409, 'Acknowledgement conflicts with the recorded result.'); send(response, 200, { version: 1, id: body.id, duplicate: true, result: confirmed }); return; } if (!live.inflight || live.inflight.id !== body.id) throw fail(409, 'Command is not awaiting acknowledgement.'); result(live.inflight, body.ok, body.ok ? 'Command applied.' : 'Command was not applied.'); live.inflight = null; live.confirmed.set(body.id, { ...lastResult }); if (live.confirmed.size > QUEUE_LIMIT) live.confirmed.delete(live.confirmed.keys().next().value); send(response, 200, { version: 1, id: body.id, duplicate: false, result: lastResult }); return; } } catch (error) { send(response, error.status ?? 500, { version: 1, error: error.status ? error.message : 'Bridge request failed.' }); request.resume(); } }; const server = handler => { const result = createServer({ maxHeaderSize: 8192 }, handler); result.maxConnections = 64; result.headersTimeout = 5000; result.requestTimeout = 5000; result.keepAliveTimeout = 1000; result.maxRequestsPerSocket = 100; result.on('clientError', (_error, socket) => { if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); }); return result; }; const listen = (server, port, host) => new Promise((resolve, reject) => { const errored = error => { server.removeListener('listening', ready); reject(error); }; const ready = () => { server.removeListener('error', errored); resolve(); }; server.once('error', errored); server.once('listening', ready); server.listen(port, host); }); const closeServer = server => new Promise(resolve => { if (!server?.listening) { resolve(); return; } server.close(() => resolve()); server.closeAllConnections(); }); studioServer = server(handle(true)); try { await listen(studioServer, studioPort, '127.0.0.1'); if (lan !== undefined) { controlServer = server(handle(false)); await listen(controlServer, controlPort, lan); } } catch (error) { await closeServer(studioServer); await closeServer(controlServer); throw error; } const timer = setInterval(expire, 500); timer.unref(); return { studioServer, controlServer, tokens, get studioAddress() { return studioServer.address(); }, get controlAddress() { return controlServer?.address() ?? null; }, get studioURL() { const address = studioServer.address(); return address ? `http://127.0.0.1:${address.port}` : null; }, get controlURL() { const address = controlServer?.address(); return address ? `http://${lan}:${address.port}` : null; }, async close() { if (closed) return; closed = true; clearInterval(timer); wipe('Bridge stopped.'); lease = null; await Promise.all([closeServer(studioServer), closeServer(controlServer)]); }, }; } async function cli() { const args = process.argv.slice(2), options = {}, names = { '--lan': 'lan', '--miniserver': 'miniserver', '--studio-port': 'studioPort', '--control-port': 'controlPort', '--dev-origin': 'devOrigin', '--keys-file': 'keysFile' }; if (args.length === 1 && args[0] === '--help') { process.stdout.write('PHASE//VOID room bridge (Node 22+)\nnode bridge.mjs [--lan PRIVATE_INTERFACE_IP --miniserver PRIVATE_MINISERVER_IP] [--studio-port 43120] [--control-port 43121] [--dev-origin http://127.0.0.1:5174] [--keys-file OWNER_CHOSEN_FILE]\nWithout --lan, only the Studio loopback listener starts. Use 127.0.0.1 for a local emulator.\nUse --keys-file for stable installation keys; its parent must exist. Without it, keys are temporary and change after restart.\n'); return; } for (let i = 0; i < args.length; i += 2) { const name = names[args[i]], value = args[i + 1]; if (!name || !value || value.startsWith('--') || Object.hasOwn(options, name)) throw new Error('Unknown, repeated or incomplete bridge option. Use --help.'); if (name.endsWith('Port')) { if (!/^\d+$/.test(value) || Number(value) < 1) throw new Error('CLI ports must be between 1 and 65535.'); options[name] = Number(value); } else options[name] = value; } const keysFile = options.keysFile === undefined ? null : resolve(options.keysFile); if (keysFile !== null) { Object.assign(options, await loadBridgeKeys(keysFile)); delete options.keysFile; } const bridge = await createRoomBridge(options); // Deliberate local startup output only. These keys are never put in URLs or request logs. process.stdout.write(`PHASE//VOID room bridge\nStudio: ${bridge.studioURL}\nStudio pairing key: ${bridge.tokens.studioToken}\n`); process.stdout.write(keysFile === null ? 'Credentials: temporary; restart generates new keys.\n' : `Credential file: ${keysFile}\n`); if (bridge.controlURL) process.stdout.write(`Loxone control: ${bridge.controlURL}\nControl Bearer key: ${bridge.tokens.controlToken}\nStatus Basic username: phasevoid\nStatus Basic password: ${bridge.tokens.statusToken}\nAllow only the configured Miniserver through your automation-LAN firewall.\n`); process.stdout.write('Studio must pair and arm. Pairing does not start music or projection. Ctrl+C closes the bridge.\n'); const stop = () => { void bridge.close().then(() => process.exit(0)); }; process.once('SIGINT', stop); process.once('SIGTERM', stop); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) cli().catch(error => { process.stderr.write(`Room bridge could not start: ${error.message}\n`); process.exitCode = 1; });