/* The operator desk must be unreachable from the internet — page, API and socket — and completely normal on the LAN. */ import { spawn } from 'child_process'; import { WebSocket } from 'ws'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; const DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ct-oplocal-')); const PORT = 3198; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const srv = spawn(process.execPath, [path.join(__dirname, 'server', 'server.js')], { env: { ...process.env, PORT: String(PORT), DATA_DIR: DIR }, stdio: ['ignore', 'pipe', 'pipe'] }); const logs = []; srv.stdout.on('data', d => logs.push(String(d))); srv.stderr.on('data', d => logs.push(String(d))); const sleep = ms => new Promise(r => setTimeout(r, ms)); const B = 'http://127.0.0.1:' + PORT; const CFG = path.join(DIR, 'config.json'); for (let i = 0; i < 80; i++) { if (fs.existsSync(CFG)) { try { await fetch(B + '/healthz'); break; } catch (_) {} } await sleep(150); } const cfg = JSON.parse(fs.readFileSync(CFG, 'utf8')); const PIN = cfg.operatorPin, KEY = cfg.controlToken; console.log('new-install PIN: ' + PIN.length + ' digits (' + PIN + ')\n'); let pass = 0, fail = 0; const check = (l, ok, d) => { console.log((ok ? ' ok ' : ' FAIL ') + l + (d ? ' ' + d : '')); ok ? pass++ : fail++; }; const TUN = { 'x-forwarded-for': '203.0.113.7' }; const code = async (p, h) => (await fetch(B + p, { headers: h || {} })).status; console.log('from the LAN (correct PIN):'); check('/operator page loads', await code('/operator') === 200); check('/api/config accepts the PIN', await code('/api/config?pin=' + PIN) === 200); console.log('\nfrom the internet (same correct PIN):'); check('/operator page is 404', await code('/operator', TUN) === 404, 'not even the login box'); const c = await code('/api/config?pin=' + PIN, TUN); check('/api/config is refused', c === 401 || c === 429, 'HTTP ' + c); console.log('\nthe listener page must still work from the internet:'); check('/ is served', await code('/', TUN) === 200); const lm = await code('/live/eng_Latn.m3u8', TUN); check('/live/*.m3u8 answers', lm === 200 || lm === 404, 'HTTP ' + lm + ' (404 = nothing playing yet)'); console.log('\noperator WEBSOCKET:'); const tryWs = (headers) => new Promise((res) => { const ws = new WebSocket(B.replace('http','ws') + '/ws?role=operator&pin=' + PIN, { headers }); const t = setTimeout(() => { try { ws.close(); } catch (_) {} res('timeout'); }, 3000); ws.on('message', () => { clearTimeout(t); ws.close(); res('accepted'); }); ws.on('close', (c, r) => { clearTimeout(t); res('closed: ' + String(r || c)); }); ws.on('error', () => {}); }); check('LAN socket is accepted', await tryWs({}) === 'accepted'); const w = await tryWs(TUN); check('tunnel socket is closed', w.startsWith('closed'), w); console.log('\ncontrol API (Stream Deck) is unaffected — it uses a long random token:'); check('control works from the LAN', await code('/api/control/status?key=' + KEY) === 200); console.log('\n--- log ---'); console.log(logs.join('').split('\n').filter(l => /refused|operator/.test(l)).map(l => ' ' + l).join('\n')); console.log('\n' + pass + ' passed, ' + fail + ' failed'); srv.kill(); fs.rmSync(DIR, { recursive: true, force: true }); process.exit(fail ? 1 : 0);