Phase 7+11: token-based publishing — MediaMTX authMethod http -> dumpd /mtx-auth (bridge-only port 8081); dumpd mints 12 h HMAC tokens for Discord-authenticated streamers; dock rewritten (no passwords, naming rules with live preview, prefix/suffix, manual-setup accordion with hidden token); portal uses tokens; configure.py drops streamers; MediaMTX hot-reload, Caddy reload — no stream interruption

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 15:22:15 -05:00
parent 2a74cb7b40
commit dcc347e1ea
11 changed files with 301 additions and 144 deletions

View File

@@ -5,29 +5,41 @@
<title>Vanguard Stream</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { --bg:#1e1e1e; --fg:#e6e6e6; --mut:#9a9a9a; --acc:#3d8bfd; --ok:#2ecc71; --bad:#e74c3c; --box:#2a2a2a; }
:root { --bg:#1e1e1e; --fg:#e6e6e6; --mut:#9a9a9a; --acc:#3d8bfd; --ok:#2ecc71; --bad:#e74c3c; --warn:#f1c40f; --box:#2a2a2a; }
html,body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.4 system-ui,sans-serif}
main{padding:12px;max-width:420px}
main{padding:12px;max-width:440px}
h1{font-size:15px;margin:0 0 10px;color:var(--mut);font-weight:600}
h1 small{font-weight:400}
label{display:block;margin:8px 0 2px;color:var(--mut);font-size:12px}
input{width:100%;box-sizing:border-box;padding:8px;border:1px solid #444;border-radius:6px;background:var(--box);color:var(--fg);font-size:14px}
input[readonly]{color:var(--mut)}
button{width:100%;margin-top:10px;padding:11px;border:0;border-radius:6px;font-size:15px;font-weight:600;cursor:pointer;color:#fff;background:var(--acc)}
button.live{background:var(--ok);color:#000}
button.dump{background:var(--bad)}
button.small{width:auto;padding:6px 10px;font-size:13px;margin:6px 6px 0 0;background:#3a3a3a}
button:disabled{opacity:.5;cursor:default}
#status{margin-top:10px;padding:8px;border-radius:6px;background:var(--box);color:var(--mut);min-height:1.4em;white-space:pre-wrap}
details{margin-top:14px}
details{margin-top:14px;border-top:1px solid #333;padding-top:8px}
summary{color:var(--mut);cursor:pointer;font-size:12px}
.row{display:flex;gap:8px}
.row button{margin-top:10px}
.preview{font-family:ui-monospace,monospace;font-size:12px;color:var(--mut);margin-top:4px;word-break:break-all}
.preview b{color:var(--fg)}
.rule{color:var(--bad);font-size:12px;margin-top:4px}
.kv{font-family:ui-monospace,monospace;font-size:12px;background:var(--box);border-radius:6px;padding:8px;margin-top:6px;word-break:break-all}
.kv .k{color:var(--mut)}
.tok{letter-spacing:1px}
.who{color:var(--mut);font-size:12px}
</style>
</head>
<body>
<main>
<h1>Vanguard Stream</h1>
<h1>Vanguard Stream <small id="who" class="who"></small></h1>
<label for="title">Stream title</label>
<input id="title" placeholder="VanguardTraining10-10-2025" autocomplete="off">
<input id="title" placeholder="VanguardTraining" autocomplete="off">
<div class="preview">stream name: <b id="preview">—</b></div>
<div class="rule" id="rule"></div>
<button id="golive" class="live">Go Live</button>
<div class="row">
<button id="stop">Stop Stream</button>
@@ -37,58 +49,115 @@
<details>
<summary>Settings (saved in this dock)</summary>
<label>Ingest host (RTMP server)</label><input id="host" placeholder="media.example.com">
<label>Your streamer name</label><input id="user" placeholder="techy">
<label>Your stream password</label><input id="pass" type="password">
<label>Ingest host</label><input id="host" placeholder="media.example.com">
<label>OBS WebSocket password</label><input id="obspw" type="password">
<label>OBS WebSocket port</label><input id="obsport" value="4455">
<label>Title prefix (optional, e.g. <code>HelmetCam-</code>)</label><input id="prefix" placeholder="">
<label>Title suffix (optional; <code>{date}</code> becomes YYYY-MM-DD)</label><input id="suffix" placeholder="-{date}">
<button id="save">Save settings</button>
</details>
<details id="manual">
<summary>Manual OBS setup (endpoints + token)</summary>
<div class="kv"><span class="k">RTMP server </span><span id="m_rtmp">—</span></div>
<div class="kv"><span class="k">Stream key </span><span id="m_key">—</span></div>
<div class="kv"><span class="k">SRT URL </span><span id="m_srt">—</span></div>
<div class="kv"><span class="k">Token </span><span id="m_tok" class="tok">•••••••••••• (hidden)</span><br><span class="k" id="m_exp"></span></div>
<button class="small" id="m_show">Show token</button>
<button class="small" id="m_copy_key">Copy stream key</button>
<button class="small" id="m_copy_srt">Copy SRT URL</button>
<button class="small" id="m_fresh">Fresh token (12 h)</button>
<div class="who" style="margin-top:6px">Tokens are minted for your Discord login and expire; the Go Live button does all of this automatically.</div>
</details>
</main>
<script>
// ---- settings --------------------------------------------------------------
const KEYS = ["host","user","pass","obspw","obsport"];
const KEYS = ["host","obspw","obsport","prefix","suffix"];
const $ = id => document.getElementById(id);
const status = (msg, cls) => { $("status").textContent = msg; $("status").style.color = cls==="bad"?"#e74c3c":cls==="ok"?"#2ecc71":"#9a9a9a"; };
function load(){ try{ KEYS.forEach(k => { const v = localStorage.getItem("vg."+k); if (v!==null) $(k).value = v; }); }catch(e){} }
function save(){ try{ KEYS.forEach(k => localStorage.setItem("vg."+k, $(k).value.trim())); status("settings saved"); }catch(e){ status("could not save settings (storage blocked)", "bad"); } }
load();
$("save").onclick = save;
function load(){ try{ KEYS.forEach(k => { const v = localStorage.getItem("vg."+k); if (v!==null) $(k).value = v; }); }catch(e){} if(!$("host").value) $("host").value = location.hostname; }
function save(){ try{ KEYS.forEach(k => localStorage.setItem("vg."+k, $(k).value.trim())); status("settings saved"); }catch(e){ status("could not save settings (storage blocked)", "bad"); } renderManual(); }
load(); $("save").onclick = save;
const cfg = () => Object.fromEntries(KEYS.map(k => [k, $(k).value.trim()]));
const cleanTitle = t => t.trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9_.-]/g, "");
// ---- naming rules (must match the server: the sweep publishes <Title>__<timestamp>.mp4) ----------------
const today = () => { const d = new Date(); return new Date(d.getTime() - d.getTimezoneOffset()*60000).toISOString().slice(0,10); };
function buildTitle(raw){
const c = cfg();
let t = (c.prefix||"") + raw.trim() + (c.suffix||"").replace("{date}", today());
t = t.replace(/\s+/g, "-") // spaces -> dashes
.replace(/[^A-Za-z0-9_.-]/g, "") // allowed characters only
.replace(/_{2,}/g, "_") // "__" is reserved: it separates title from timestamp in filenames
.replace(/^[.-]+/, "") // no leading dot (hidden) or dash
.slice(0, 64);
return t;
}
function ruleText(raw){
const r = [];
if (/__/.test(raw)) r.push("double underscore is reserved (becomes single)");
if (/[^A-Za-z0-9_.\- ]/.test(raw)) r.push("only letters, digits, space, _ . - are kept");
if (raw.trim().startsWith(".")) r.push("cannot start with a dot");
if ((cfg().prefix + raw + cfg().suffix).length > 64) r.push("longer than 64 characters: trimmed");
return r.join(" · ");
}
let me = null; // {user, token, expires}
function renderPreview(){
const t = buildTitle($("title").value);
$("preview").textContent = t ? `${me ? me.user : "<you>"}/${t}` : "—";
$("rule").textContent = ruleText($("title").value);
renderManual(t);
}
$("title").addEventListener("input", renderPreview);
["prefix","suffix"].forEach(k => $(k).addEventListener("input", renderPreview));
// ---- identity + tokens (the dock is behind Authentik: same-origin fetch carries the Discord session) -----
async function whoami(){
try {
const r = await fetch("/whoami", {credentials:"same-origin"});
const m = (await r.text()).match(/user:\s*(\S+)/); if (m) $("who").textContent = "— " + m[1];
} catch(e){}
}
async function mint(){
const r = await fetch("/me/stream-token", {credentials:"same-origin"});
const b = await r.json().catch(()=>({}));
if (!r.ok) throw new Error(b.error || `token ${r.status}`);
me = b; renderPreview(); return b;
}
let showTok = false;
function renderManual(t){
const c = cfg(); const title = t ?? buildTitle($("title").value) || "TITLE";
const user = me ? me.user : "<you>", tok = me ? me.token : "<token>";
$("m_rtmp").textContent = `rtmp://${c.host||location.hostname}:1935/`;
$("m_key").textContent = `${user}/${title}?user=${user}&pass=${showTok ? tok : "••••••••"}`;
$("m_srt").textContent = `srt://${c.host||location.hostname}:8890?streamid=publish:${user}/${title}:${user}:${showTok ? tok : "••••••••"}`;
$("m_tok").textContent = me ? (showTok ? me.token : "•••••••••••• (hidden)") : "— click “Fresh token”";
$("m_exp").textContent = me ? `valid until ${new Date(me.expires*1000).toLocaleString()}` : "";
}
$("m_show").onclick = () => { showTok = !showTok; $("m_show").textContent = showTok ? "Hide token" : "Show token"; renderManual(); };
$("m_fresh").onclick = async () => { try { await mint(); status("fresh token minted (12 h)", "ok"); } catch(e){ status(e.message, "bad"); } };
async function copy(text, what){ try { await navigator.clipboard.writeText(text); status(`${what} copied`, "ok"); } catch(e){ status("clipboard blocked — select the text manually", "bad"); } }
$("m_copy_key").onclick = async () => { if (!me) await mint().catch(e=>status(e.message,"bad")); if (me) copy(`${me.user}/${buildTitle($("title").value)||"TITLE"}?user=${me.user}&pass=${me.token}`, "stream key"); };
$("m_copy_srt").onclick = async () => { if (!me) await mint().catch(e=>status(e.message,"bad")); if (me) copy(`srt://${cfg().host}:8890?streamid=publish:${me.user}/${buildTitle($("title").value)||"TITLE"}:${me.user}:${me.token}`, "SRT URL"); };
$("manual").addEventListener("toggle", () => { if ($("manual").open) renderManual(); });
// ---- obs-websocket v5 ------------------------------------------------------
async function sha256b64(s){
const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
return btoa(String.fromCharCode(...new Uint8Array(h)));
}
function obs(cfg){
async function sha256b64(s){ const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); return btoa(String.fromCharCode(...new Uint8Array(h))); }
function obs(c){
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${cfg.obsport||4455}`);
const ws = new WebSocket(`ws://127.0.0.1:${c.obsport||4455}`);
const pending = new Map(); let n = 0;
ws.onerror = () => reject(new Error("cannot reach OBS WebSocket (enable it in Tools > WebSocket Server Settings)"));
ws.onmessage = async ev => {
const m = JSON.parse(ev.data);
if (m.op === 0) { // Hello
if (m.op === 0) {
let auth;
if (m.d.authentication) {
const secret = await sha256b64(cfg.obspw + m.d.authentication.salt);
auth = await sha256b64(secret + m.d.authentication.challenge);
}
if (m.d.authentication) { const secret = await sha256b64(c.obspw + m.d.authentication.salt); auth = await sha256b64(secret + m.d.authentication.challenge); }
ws.send(JSON.stringify({op:1, d:{rpcVersion:1, authentication:auth, eventSubscriptions:0}}));
} else if (m.op === 2) { // Identified
resolve({
call(type, data){ return new Promise((res, rej) => {
const id = String(++n); pending.set(id, {res, rej});
ws.send(JSON.stringify({op:6, d:{requestType:type, requestId:id, requestData:data||{}}}));
}); },
close(){ ws.close(); }
});
} else if (m.op === 7) { // RequestResponse
const p = pending.get(m.d.requestId); pending.delete(m.d.requestId);
if (!p) return;
} else if (m.op === 2) {
resolve({ call(type, data){ return new Promise((res, rej) => { const id = String(++n); pending.set(id, {res, rej}); ws.send(JSON.stringify({op:6, d:{requestType:type, requestId:id, requestData:data||{}}})); }); }, close(){ ws.close(); } });
} else if (m.op === 7) {
const p = pending.get(m.d.requestId); pending.delete(m.d.requestId); if (!p) return;
m.d.requestStatus.result ? p.res(m.d.responseData) : p.rej(new Error(m.d.requestStatus.comment || m.d.requestType + " failed"));
}
};
@@ -98,44 +167,37 @@ function obs(cfg){
// ---- actions ---------------------------------------------------------------
$("golive").onclick = async () => {
const c = cfg(), title = cleanTitle($("title").value);
const c = cfg(), title = buildTitle($("title").value);
if (!title) return status("type a title first", "bad");
if (!c.host || !c.user || !c.pass) return status("fill in settings first", "bad");
if (!c.host) return status("set the ingest host in settings", "bad");
$("golive").disabled = true;
try {
status("getting a stream token…");
const t = await mint(); // fresh token every Go Live; nothing stored
status("connecting to OBS…");
const o = await obs(c);
const key = `${c.user}/${title}?user=${encodeURIComponent(c.user)}&pass=${encodeURIComponent(c.pass)}`;
await o.call("SetStreamServiceSettings", {
streamServiceType: "rtmp_custom",
streamServiceSettings: { server: `rtmp://${c.host}:1935/`, key, use_auth: false }
});
await o.call("SetStreamServiceSettings", { streamServiceType: "rtmp_custom",
streamServiceSettings: { server: `rtmp://${c.host}:1935/`, key: `${t.user}/${title}?user=${encodeURIComponent(t.user)}&pass=${encodeURIComponent(t.token)}`, use_auth: false } });
const st = await o.call("GetStreamStatus");
if (st.outputActive) { await o.call("StopStream"); await new Promise(r => setTimeout(r, 1500)); }
await o.call("StartStream");
o.close();
status(`LIVE → ${c.user}/${title}`, "ok");
await o.call("StartStream"); o.close();
status(`LIVE → ${t.user}/${title}`, "ok");
} catch (e) { status(e.message, "bad"); }
$("golive").disabled = false;
};
$("stop").onclick = async () => {
try { const o = await obs(cfg()); await o.call("StopStream"); o.close(); status("stream stopped"); }
catch (e) { status(e.message, "bad"); }
};
$("stop").onclick = async () => { try { const o = await obs(cfg()); await o.call("StopStream"); o.close(); status("stream stopped"); } catch (e) { status(e.message, "bad"); } };
let armed = 0;
$("dump").onclick = async () => {
try {
if (Date.now() - armed > 10000) { // first click: show what would go, arm for 10 s
if (Date.now() - armed > 10000) {
status("checking what tonight contains…");
const dry = await (await fetch("/dump", {credentials:"same-origin"})).json(); // identity = your Discord login (Authentik)
const dry = await (await fetch("/dump", {credentials:"same-origin"})).json();
if (dry.error) throw new Error(dry.error);
if (!dry.files.length) return status(`nothing recorded for ${dry.night}`);
armed = Date.now();
$("dump").textContent = "Click again to confirm";
armed = Date.now(); $("dump").textContent = "Click again to confirm";
setTimeout(() => { $("dump").textContent = "Dump tonight"; armed = 0; }, 10000);
return status(`${dry.files.length} file(s), ${dry.gb} GB from the night of ${dry.night}.\nClick Dump again within 10 s to confirm. Undo window: 3 days (ask admin).`);
return status(`${dry.files.length} file(s), ${dry.gb} GB from the night of ${dry.night}.\nClick Dump again within 10 s to confirm. Undo window: 3 days (ask an admin).`);
}
armed = 0; $("dump").textContent = "Dump tonight";
const r = await (await fetch("/dump", {method:"POST", headers:{"Content-Type":"application/json"}, body:"{}", credentials:"same-origin"})).json();
@@ -143,6 +205,8 @@ $("dump").onclick = async () => {
status(`dumped ${r.moved} file(s), ${r.gb} GB (recoverable until ${r.undo_until})`, "ok");
} catch (e) { status("dump failed: " + e.message, "bad"); }
};
whoami(); renderPreview();
</script>
</body>
</html>