Lumi/scripts/verify-stream-testing.js
2026-07-25 20:57:22 +02:00

404 lines
19 KiB
JavaScript

const assert = require("assert");
const crypto = require("crypto");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { EventEmitter } = require("events");
const { spawn } = require("child_process");
const AdmZip = require("adm-zip");
const root = path.join(__dirname, "..");
const {
ArtifactManager,
safeArchivePath,
validateManifestEntry
} = require("../src/services/artifact-manager");
const {
MediaMtxRuntime,
parseMetrics,
readResponseText,
renderConfig,
sha256Credential,
validateHlsAsset
} = require("../src/services/mediamtx-runtime");
const {
StreamTestingService,
INACTIVITY_MS,
isPrivateAddress,
resolveIngestConfiguration,
rewriteManifest,
sourceFor,
validateHostname
} = require("../src/services/stream-testing");
const protocol = require("../plugins/lumi_transcription/backend/companion/protocol");
class FakeRuntime extends EventEmitter {
constructor() {
super();
this.session = null;
this.running = false;
this.cleared = 0;
this.pathReady = true;
this.inboundBytes = 4096;
}
status() {
return {
available: true,
installed: true,
valid: true,
version: "v1.18.2",
tested_version: "v1.18.2",
source: "managed by Lumi",
managed_install_supported: true,
detail: "MediaMTX v1.18.2 is ready.",
process: { state: this.running ? "ready" : "stopped", running: this.running, restart_count: 0, log_tail: "" }
};
}
async configureSession(session) { this.session = session; this.running = true; }
async clearSession() { this.session = null; this.cleared += 1; }
async diagnostics(sessionPath) {
return {
path: { name: sessionPath, ready: this.pathReady, tracks: ["H264", "MPEG4Audio"], bytes_received: this.inboundBytes, bytes_sent: 2048, readers: 1 },
metrics: { paths_inbound_bytes: this.inboundBytes, paths_outbound_bytes: 2048, hls_sessions: 1 },
process: { state: "ready", running: true }
};
}
async install() { return this.status(); }
async repair() { return this.status(); }
async healthCheck() { return { ok: true, version: "v1.18.2", api_status: 200, metrics_status: 200, hls_status: 404 }; }
async close() { this.running = false; }
}
async function verifyArtifactManager(tempRoot) {
assert.throws(() => validateManifestEntry({ id: "bad", url: "http://example.invalid/a.zip", sha256: "0".repeat(64) }), /invalid/);
assert.throws(() => safeArchivePath("../escape.exe"), /unsafe path/);
const zip = new AdmZip();
zip.addFile("LICENSE", Buffer.from("MIT"));
zip.addFile("mediamtx.exe", Buffer.from("fake executable"));
zip.addFile("mediamtx.yml", Buffer.from("paths: {}"));
const archive = zip.toBuffer();
const entry = {
id: "fake-mediamtx",
install_id: "mediamtx",
backend: "mediamtx",
version: "v1.18.2",
url: "https://example.invalid/mediamtx.zip",
filename: "mediamtx.zip",
sha256: crypto.createHash("sha256").update(archive).digest("hex"),
bytes: archive.length,
max_bytes: archive.length + 1,
max_extracted_bytes: 1024,
expected_entries: ["LICENSE", "mediamtx.exe", "mediamtx.yml"]
};
const manager = new ArtifactManager(path.join(tempRoot, "artifacts"), {
fetch: async () => new Response(archive, {
status: 200,
headers: { "Content-Length": String(archive.length) }
})
});
await assert.rejects(() => manager.download(entry), /explicit setup confirmation/);
const downloaded = await manager.download(entry, { confirmed: true });
assert(downloaded.valid, "downloaded artifact must match its pinned SHA-256");
const installed = manager.installZip(entry, downloaded.path, {
verify: (staged) => assert(fs.existsSync(path.join(staged, "mediamtx.exe")))
});
assert(fs.existsSync(path.join(installed.path, "mediamtx.exe")), "validated archive must install atomically");
const originalExecutable = fs.readFileSync(path.join(installed.path, "mediamtx.exe"), "utf8");
const replacementZip = new AdmZip();
replacementZip.addFile("LICENSE", Buffer.from("MIT replacement"));
replacementZip.addFile("mediamtx.exe", Buffer.from("broken replacement"));
replacementZip.addFile("mediamtx.yml", Buffer.from("paths: {}"));
const replacementArchive = replacementZip.toBuffer();
const replacementPath = path.join(tempRoot, "replacement.zip");
fs.writeFileSync(replacementPath, replacementArchive);
const replacementEntry = {
...entry,
sha256: crypto.createHash("sha256").update(replacementArchive).digest("hex"),
bytes: replacementArchive.length
};
assert.throws(
() => manager.installZip(replacementEntry, replacementPath, {
verifyInstalled: () => { throw new Error("replacement probe failed"); }
}),
/replacement probe failed/
);
assert.equal(
fs.readFileSync(path.join(installed.path, "mediamtx.exe"), "utf8"),
originalExecutable,
"a failed post-install probe must restore the last known-good runtime"
);
assert.throws(
() => manager.installZip({ ...entry, sha256: "0".repeat(64) }, downloaded.path),
/Checksum mismatch/
);
}
async function verifyManagedRuntime(tempRoot) {
const runtimeRoot = path.join(tempRoot, "runtime");
const executable = path.join(runtimeRoot, "mediamtx", "mediamtx.exe");
fs.mkdirSync(path.dirname(executable), { recursive: true });
fs.writeFileSync(executable, "fake");
const fixture = path.join(root, "scripts", "fixtures", "fake-mediamtx.js");
const runtime = new MediaMtxRuntime({
runtimeRoot,
configRoot: path.join(tempRoot, "config"),
platform: "win32",
arch: "x64",
spawn: (_executable, args, options) => spawn(process.execPath, [fixture, args[0]], options),
spawnSync: () => ({ status: 0, stdout: "v1.18.2\n", stderr: "" })
});
await assert.rejects(() => runtime.install(), /administrator confirmation/);
await assert.rejects(() => runtime.repair(), /administrator confirmation/);
assert.equal(runtime.probeExecutable(executable).version, "v1.18.2", "manual executable probes must remain bounded and versioned");
const health = await runtime.healthCheck();
assert(health.ok && health.restart_verified && health.api_status === 200 && health.metrics_status === 200, "fake MediaMTX listeners and supervised restart must pass the runtime health check");
const session = {
path: "lumi-test/00000000-0000-4000-8000-000000000001",
username: "private-user",
password: "private-password",
transport: "rtmp",
ingestPort: 29350
};
await runtime.configureSession(session);
const generated = fs.readFileSync(runtime.configPath, "utf8");
assert(!generated.includes(session.username) && !generated.includes(session.password), "generated MediaMTX config must not persist plaintext publisher credentials");
assert(generated.includes(sha256Credential(session.username)) && generated.includes(sha256Credential(session.password)));
assert.match(generated, /rtsp: false/);
assert.match(generated, /webrtc: false/);
assert.match(generated, /srt: false/);
assert.match(generated, /hlsVariant: lowLatency/);
assert.match(generated, /maxReaders: 4/);
assert.match(generated, /rtmpAddress: "127\.0\.0\.1:29350"/);
const diagnostic = await runtime.diagnostics(session.path);
assert.equal(diagnostic.path.name, session.path);
const playlist = await runtime.fetchHls(session.path, "index.m3u8");
assert.equal(playlist.status, 200);
assert.match(await playlist.text(), /segment0\.mp4/);
assert.throws(() => validateHlsAsset("../secret"), /Invalid media path/);
await runtime.clearSession();
const denyConfig = fs.readFileSync(runtime.configPath, "utf8");
assert.match(denyConfig, /paths: \{\}/);
assert(!denyConfig.includes("private-user"));
await runtime.close();
}
async function verifyStreamService() {
const old = {
host: process.env.LUMI_STREAM_TEST_INGEST_HOST,
transport: process.env.LUMI_STREAM_TEST_TRANSPORT,
insecure: process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE
};
delete process.env.LUMI_STREAM_TEST_INGEST_HOST;
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmp";
delete process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE;
const runtime = new FakeRuntime();
const service = new StreamTestingService({ runtime, timer: false });
const sent = [];
try {
const created = await service.create(
{ id: "device-1", name: "Studio PC", pairing_host: "http://localhost:3000" },
{ source: { width: 2560, height: 1440, fps: 60 } },
(type, payload) => sent.push({ type, payload })
);
assert.match(created.ingest.server, /^rtmp:\/\/localhost:19350\/lumi-test$/);
assert.match(created.ingest.key, /^[0-9a-f-]{36}\?user=/);
assert.equal(created.source.variants.length, 1, "MediaMTX handoff must preserve source quality without a synthetic ladder");
assert.equal(runtime.session.path, `lumi-test/${created.id}`);
assert(runtime.session.username && runtime.session.password);
assert.equal(runtime.session.bindHost, "127.0.0.1", "LAN-only RTMP must bind to the resolved private interface");
await assert.rejects(
() => service.create({ id: "device-2", pairing_host: "http://localhost:3000" }, {}),
(error) => error.code === "STREAM_TEST_ACTIVE"
);
await service.sweep();
const status = service.publicStatus();
assert.equal(status.session.state, "receiving");
assert.equal(status.session.metrics.receiver.path_ready, true);
assert(Object.hasOwn(status.receiver.process, "log_tail"), "admin diagnostics must include a bounded sanitized MediaMTX log tail");
assert(!JSON.stringify(status).includes("127.0.0.1:"), "browser status must not expose loopback MediaMTX origins");
service.updateObs("device-1", {
session_id: created.id,
bitrate_kbps: 6000,
dropped_frames: 2,
total_frames: 10000,
congestion: 0
});
service.addCaption("device-1", { session_id: created.id, text: "Private caption", delay_ms: 120 });
assert.throws(
() => service.addCaption("device-2", { session_id: created.id, text: "Wrong device" }),
/not active for this Companion/
);
service.reportPlayer({ session_id: created.id, latency_seconds: 1.2, buffer_seconds: 1.5, stalls: 0, errors: 0 });
assert.match(service.captionFile(created.id), /Private caption/);
const endedSession = service.active;
await service.stop({ session_id: created.id, reason: "Verification complete." });
assert.equal(runtime.cleared, 1);
assert.equal(endedSession.username, null);
assert.equal(endedSession.password, null);
assert.equal(sent.at(-1).type, "stream_test_ended");
await assert.rejects(
() => service.create({ id: "device-1", pairing_host: "http://localhost:3000" }, {}),
(error) => error.code === "STREAM_TEST_RATE_LIMIT"
);
const expiring = await service.create(
{ id: "device-2", pairing_host: "http://localhost:3000" },
{}
);
service.active.expiresAt = Date.now() - 1;
await service.sweep();
assert.equal(service.active, null, "session expiry must revoke the MediaMTX path and end the session");
const inactive = await service.create(
{ id: "device-3", pairing_host: "http://localhost:3000" },
{}
);
service.active.lastInboundBytes = runtime.inboundBytes;
service.active.lastMediaAt = Date.now() - INACTIVITY_MS - 1;
await service.sweep();
assert.equal(service.active, null, "stalled inbound bytes must trigger the inactivity recovery path");
const failed = await service.create(
{ id: "device-4", pairing_host: "http://localhost:3000" },
{}
);
runtime.emit("failed", new Error("fixture process exited"));
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(service.active, null, "an unexpected MediaMTX exit must end the session and request OBS recovery");
assert([created.id, expiring.id, inactive.id, failed.id].every(Boolean));
} finally {
await service.close();
if (old.host === undefined) delete process.env.LUMI_STREAM_TEST_INGEST_HOST; else process.env.LUMI_STREAM_TEST_INGEST_HOST = old.host;
if (old.transport === undefined) delete process.env.LUMI_STREAM_TEST_TRANSPORT; else process.env.LUMI_STREAM_TEST_TRANSPORT = old.transport;
if (old.insecure === undefined) delete process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE; else process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE = old.insecure;
}
}
async function main() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-stream-testing-"));
try {
assert.equal(validateHostname("stream.example.com"), "stream.example.com");
assert.equal(validateHostname("https://stream.example.com"), "");
assert.equal(validateHostname("[::1]"), "[::1]");
assert(isPrivateAddress("127.0.0.1") && isPrivateAddress("192.168.1.20") && !isPrivateAddress("8.8.8.8"));
assert.deepStrictEqual(sourceFor({ width: 1920, height: 1080, fps: 60 }).variants.map((item) => item.name), ["source"]);
assert.match(rewriteManifest(
"#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\nsegment0.mp4\n",
"/admin/stream-testing/media/test/",
"lumi-test/test"
), /URI="\/admin\/stream-testing\/media\/test\/init\.mp4"[\s\S]*\/admin\/stream-testing\/media\/test\/segment0\.mp4/);
assert.throws(() => rewriteManifest("../secret", "/proxy/", "lumi-test/test"), /unsafe playlist URI/);
assert.throws(() => rewriteManifest("https://foreign.invalid/segment.mp4", "/proxy/", "lumi-test/test"), /foreign playlist URI/);
assert.deepStrictEqual(
parseMetrics('paths_inbound_bytes{name="lumi-test/id"} 42\nunapproved_metric 9000\n', "lumi-test/id"),
{ paths_inbound_bytes: 42 }
);
await assert.rejects(
() => readResponseText(new Response("x".repeat(32), { headers: { "Content-Length": "32" } }), 16),
/oversized diagnostic response/
);
const config = renderConfig({
apiPort: 10001,
metricsPort: 10002,
hlsPort: 10003,
session: { path: "lumi-test/id", username: "user", password: "pass", transport: "rtmp", ingestPort: 19350 }
});
assert(!config.includes('\n pass: "pass"') && config.includes("sha256:"));
assert(!config.includes("ffmpeg"));
assert.match(config, /record: false/);
assert.match(config, /maxReaders: 4/);
assert.match(config, /paths:\n "lumi-test\/id":/);
assert(!/action: publish[\s\S]*path: all/.test(config));
const oldNetwork = {
host: process.env.LUMI_STREAM_TEST_INGEST_HOST,
transport: process.env.LUMI_STREAM_TEST_TRANSPORT,
cert: process.env.LUMI_STREAM_TEST_TLS_CERT,
key: process.env.LUMI_STREAM_TEST_TLS_KEY,
insecure: process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE
};
try {
delete process.env.LUMI_STREAM_TEST_INGEST_HOST;
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmp";
delete process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE;
await assert.rejects(
() => resolveIngestConfiguration({ pairing_host: "https://8.8.8.8" }),
(error) => error.code === "STREAM_TEST_INSECURE_REMOTE"
);
const cert = path.join(tempRoot, "cert.pem");
const key = path.join(tempRoot, "key.pem");
fs.writeFileSync(cert, "test certificate");
fs.writeFileSync(key, "test key");
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmps";
process.env.LUMI_STREAM_TEST_TLS_CERT = cert;
process.env.LUMI_STREAM_TEST_TLS_KEY = key;
const secure = await resolveIngestConfiguration({ pairing_host: "https://lumi.example.test" });
assert.equal(secure.transport, "rtmps");
assert.equal(secure.host, "lumi.example.test");
} finally {
for (const [key, value] of Object.entries({
LUMI_STREAM_TEST_INGEST_HOST: oldNetwork.host,
LUMI_STREAM_TEST_TRANSPORT: oldNetwork.transport,
LUMI_STREAM_TEST_TLS_CERT: oldNetwork.cert,
LUMI_STREAM_TEST_TLS_KEY: oldNetwork.key,
LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE: oldNetwork.insecure
})) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
}
for (const type of ["stream_test_create", "stream_test_obs_metrics", "stream_test_caption", "stream_test_stop"]) {
const parsed = protocol.parseEnvelope(Buffer.from(JSON.stringify(protocol.envelope(type, {}, null))));
assert.strictEqual(parsed.type, type);
}
await verifyArtifactManager(tempRoot);
await verifyManagedRuntime(tempRoot);
await verifyStreamService();
const server = fs.readFileSync(path.join(root, "src/web/server.js"), "utf8");
const service = fs.readFileSync(path.join(root, "src/services/stream-testing.js"), "utf8");
const runtime = fs.readFileSync(path.join(root, "src/services/mediamtx-runtime.js"), "utf8");
const gateway = fs.readFileSync(path.join(root, "plugins/lumi_transcription/backend/companion/gateway.js"), "utf8");
const nativeBridge = fs.readFileSync(path.join(root, "companion/native/obs-bridge/src/plugin.cpp"), "utf8");
const companion = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/CompanionRuntime.cs"), "utf8");
const webUi = fs.readFileSync(path.join(root, "src/web/views/admin-stream-testing.ejs"), "utf8");
assert.match(server, /admin\/stream-testing\/runtime\/install/);
assert.match(server, /admin\/stream-testing\/media\/:id\/\*/);
assert.match(server, /admin\/stream-testing\/media\/:id\/\*[\s\S]{0,500}requireRole\("admin"\)/);
assert.match(server, /streamTestingService\.proxyMedia/);
assert.match(service, /INACTIVITY_MS/);
assert.match(service, /caption_delay/);
assert.match(service, /LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE/);
assert(!/FFMPEG|ffmpegArgs|h264_nvenc|libx264/.test(service));
assert(!/shell:\s*true/.test(runtime));
assert.match(gateway, /await service\.create/);
assert.match(gateway, /Companion disconnected; the private receiver was stopped/);
assert.match(nativeBridge, /stream_test_snapshot/);
assert.match(nativeBridge, /stream_test_restore/);
assert.match(nativeBridge, /obs_process_id/);
assert.match(nativeBridge, /obs_output_start\(output\)/);
assert(!nativeBridge.includes("obs_frontend_streaming_start();"));
assert(!nativeBridge.includes("obs_encoder_release(encoder);"));
assert.match(companion, /StreamTestRecoveryStore/);
assert.match(companion, /RestoreObsAfterStreamTestAsync/);
assert.match(companion, /StreamTestObsRestartRequired/);
assert.match(companion, /LastObsProcessId/);
assert.match(companion, /Restart OBS before trying again/);
assert.match(webUi, /data-runtime-install/);
assert.match(webUi, /data-runtime-health/);
assert.match(webUi, /layout-bottom[\s\S]*admin\/stream-testing\/hls\.js[\s\S]*stream-testing\.js/);
assert(!webUi.includes("Run test pattern"));
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
const { streamTestingService } = require("../src/services/stream-testing");
await streamTestingService.close();
}
console.log("Managed MediaMTX Stream Testing verification passed.");
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});