62 lines
3.3 KiB
JavaScript
62 lines
3.3 KiB
JavaScript
/* 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);
|