Pult: Stream-PC per Tipp freigeben, Sound-Quelle legt er selbst in OBS an
Paket bauen und veroeffentlichen / paket (push) Successful in 42s

- ingest wuerfelt beim ersten Start einen Schluessel und meldet sich beim
  Hub; der antwortet 202, bis im Pult jemand auf Freigeben tippt
- nach dem Verbinden schickt der Hub die Adresse von /sounds, ingest legt
  Szene "abdulive Ton" mit der Browser-Source an und haengt sie in jede Szene
- fern:verbinden bleibt als Weg von Hand

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZeasyAlexandClaude Opus 5.5 committed 2026-10-26 13:03:06 +01:00
1 parent 32416d1916
commit 54dbe1265b
15 files changed
+675 -50

No files matched your search

+5
View File
@@ -39,6 +39,8 @@ export type Zugang = {
darfFern: (req: http.IncomingMessage) => boolean;
fernKey: () => string;
neuerFernKey: () => string;
/** Den Schluessel eines im Pult freigegebenen PCs eintragen. */
fernKeySetzen: (key: string) => void;
/** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */
hatPasswort: () => boolean;
};
@@ -184,6 +186,9 @@ export function createZugang(): Zugang {
return Boolean(gegeben && gleich(gegeben, fernKey()));
},
fernKey,
fernKeySetzen(k) {
updateSecrets((s) => { s.web = { ...s.web, fernKey: k }; });
},
neuerFernKey() {
const neu = crypto.randomBytes(24).toString('base64url');
updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
+6
View File
@@ -132,6 +132,12 @@ export type OverlayEvent =
| { kind: 'sound-stopp' }
/** Die Liste der Sounds hat sich geaendert - offene Pulte laden neu. */
| { kind: 'sounds-geaendert' }
/**
* Ein Stream-PC wartet auf Freigabe, oder die Liste hat sich geaendert.
* Ohne Inhalt: wer und von wo, holt sich das Pult mit Anmeldung - der
* Ereignisstrom geht auch an die Browser-Sources.
*/
| { kind: 'fern-anfrage' }
| ObsStandEvent
| { kind: 'clear'; userId?: string; messageId?: string }
| { kind: 'beat'; t: number }
+36
View File
@@ -114,3 +114,39 @@ test('nach der Anmeldung geht es nur auf bekannte Seiten', () => {
assert.equal(sichererPfad('//boese.example'), '/');
assert.equal(sichererPfad(null), '/');
});
test('ein unbekannter PC wartet auf Freigabe und bekommt dann seinen Schluessel eingetragen', () => {
const bus = createBus();
const ereignisse: string[] = [];
bus.subscribe((e) => ereignisse.push(e.kind));
const fern = createFernsteuerung(bus);
const key = 'k'.repeat(32);
fern.anfragen(key, 'ABDU-PC', '203.0.113.7');
fern.anfragen(key, 'ABDU-PC', '203.0.113.7');
const [a, ...rest] = fern.offeneAnfragen();
assert.equal(rest.length, 0, 'derselbe PC fragt alle paar Sekunden, das ist eine Anfrage');
assert.equal(a?.name, 'ABDU-PC');
assert.equal('key' in (a as object), false, 'der Schluessel geht nie ans Pult');
assert.ok(ereignisse.includes('fern-anfrage'));
assert.equal(fern.freigeben(a!.id), key);
assert.deepEqual(fern.offeneAnfragen(), []);
assert.equal(fern.freigeben(a!.id), null, 'zweimal freigeben geht nicht');
});
test('mehr als drei fremde Anfragen verdraengen die aeltesten', () => {
const fern = createFernsteuerung(createBus());
for (let i = 0; i < 5; i++) fern.anfragen(`schluessel-${i}`.padEnd(30, 'x'), `PC${i}`, '');
assert.deepEqual(fern.offeneAnfragen().map((a) => a.name).sort(), ['PC2', 'PC3', 'PC4']);
});
test('beim Verbinden erfaehrt der PC die Adresse der Sound-Quelle', () => {
const fern = createFernsteuerung(createBus(), {
willkommen: () => ({ soundsUrl: 'https://hub.example/sounds?key=abc' }),
});
const l = leitung();
fern.annehmen(l.req, l.res);
const [hallo] = l.befehle();
assert.deepEqual(hallo, { art: 'einrichten', soundsUrl: 'https://hub.example/sounds?key=abc' });
});
+84 -1
View File
@@ -32,9 +32,28 @@ export type FernBefehl =
export type FernAntwort = { ok: boolean; text: string };
/**
* Ein Stream-PC, der sich mit einem noch unbekannten Schluessel meldet. Er
* wartet, bis jemand im Pult auf "Freigeben" tippt - damit Abdu keinen Befehl
* abtippen muss. Der Schluessel selbst geht nie ans Pult zurueck.
*/
export type FernAnfrage = { id: string; name: string; adresse: string; seit: string };
/** So viele Anfragen gleichzeitig, so lange gueltig. Mehr ist Laerm oder ein Angriff. */
const MAX_ANFRAGEN = 3;
const ANFRAGE_MS = 10 * 60_000;
export type Fernsteuerung = {
/** Die SSE-Verbindung des Stream-PCs. Eine neue ersetzt die alte. */
annehmen: (req: http.IncomingMessage, res: http.ServerResponse) => void;
/** Ein unbekannter Schluessel klopft an. */
anfragen: (key: string, name: string, adresse: string) => void;
offeneAnfragen: () => FernAnfrage[];
/** Gibt den Schluessel der Anfrage zurueck, damit er eingetragen werden kann. */
freigeben: (id: string) => string | null;
ablehnen: (id: string) => boolean;
/** Leitung schliessen, z. B. nachdem der Schluessel neu gewuerfelt wurde. */
trennen: () => void;
/** Stand von OBS, wie ihn der PC meldet. */
melden: (roh: unknown) => void;
antwort: (roh: unknown) => void;
@@ -66,15 +85,77 @@ export function standAus(roh: unknown): Omit<ObsStandEvent, 'kind' | 'pc'> {
};
}
export function createFernsteuerung(bus: Bus): Fernsteuerung {
export type FernOptionen = {
/**
* Was der PC gleich nach dem Verbinden erfaehrt, z. B. die Adresse der
* Sound-Quelle mit Schluessel. Damit richtet er OBS selbst ein.
*/
willkommen?: () => Record<string, unknown> | null;
};
export function createFernsteuerung(bus: Bus, fo: FernOptionen = {}): Fernsteuerung {
let leitung: http.ServerResponse | null = null;
const offen = new Map<string, { fertig: (a: FernAntwort) => void; timer: NodeJS.Timeout }>();
const anfragen = new Map<string, FernAnfrage & { key: string; zuletzt: number }>();
const veroeffentlichen = (stand: Omit<ObsStandEvent, 'kind'>): void => {
bus.publish({ kind: 'obs', ...stand });
};
const aufraeumen = (): void => {
const jetzt = Date.now();
for (const [id, a] of anfragen) if (jetzt - a.zuletzt > ANFRAGE_MS) anfragen.delete(id);
};
return {
anfragen(key, name, adresse) {
aufraeumen();
// Derselbe PC klopft alle paar Sekunden erneut an. Das ist dieselbe
// Anfrage, keine neue - sonst stuende er nach einer Minute zehnmal da.
for (const a of anfragen.values()) {
if (a.key === key) { a.zuletzt = Date.now(); return; }
}
if (anfragen.size >= MAX_ANFRAGEN) {
const aelteste = [...anfragen.values()].sort((x, y) => x.zuletzt - y.zuletzt)[0];
if (aelteste) anfragen.delete(aelteste.id);
}
const id = crypto.randomBytes(6).toString('hex');
const sauber = (s: string, n: number): string =>
s.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, n) || 'unbekannt';
anfragen.set(id, {
id, key, name: sauber(name, 40), adresse: sauber(adresse, 60),
seit: new Date().toISOString(), zuletzt: Date.now(),
});
log.info(`Stream-PC "${sauber(name, 40)}" (${sauber(adresse, 60)}) wartet auf Freigabe im Pult`);
bus.publish({ kind: 'fern-anfrage' });
},
offeneAnfragen() {
aufraeumen();
return [...anfragen.values()].map(({ id, name, adresse, seit }) => ({ id, name, adresse, seit }));
},
freigeben(id) {
aufraeumen();
const a = anfragen.get(id);
if (!a) return null;
// Es gibt nur einen Stream-PC. Wer freigibt, verwirft die anderen.
anfragen.clear();
log.info(`Stream-PC "${a.name}" freigegeben`);
bus.publish({ kind: 'fern-anfrage' });
return a.key;
},
ablehnen(id) {
const weg = anfragen.delete(id);
if (weg) bus.publish({ kind: 'fern-anfrage' });
return weg;
},
trennen() {
leitung?.end();
},
annehmen(req, res) {
if (leitung) {
// Zwei PCs zugleich hiesse: zwei OBS schalten auf denselben Knopf.
@@ -92,6 +173,8 @@ export function createFernsteuerung(bus: Bus): Fernsteuerung {
leitung = res;
log.info('Stream-PC verbunden');
veroeffentlichen({ ...LEER, pc: true });
const hallo = fo.willkommen?.();
if (hallo) res.write(`data: ${JSON.stringify({ art: 'einrichten', ...hallo })}\n\n`);
const beat = setInterval(
() => res.write(`data: ${JSON.stringify({ art: 'beat', t: Date.now() })}\n\n`),
+70 -7
View File
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url';
import { paths } from '#core/config.ts';
import { logger } from '#core/logger.ts';
import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts';
import { createZugang, type Zugang } from './auth.ts';
import { bearer, createZugang, type Zugang } from './auth.ts';
import { createBus, type Bus, type OverlayEvent } from './bus.ts';
import { startDemo } from './demo.ts';
import { createFernsteuerung, type FernBefehl, type Fernsteuerung } from './fern.ts';
@@ -130,7 +130,14 @@ export function startOverlayServer(opts: OverlayOptions): OverlayServer {
const lz: Laufzeit = {
vorfuehrung: { abbruch: null },
sounds: createSoundboard(opts.soundsDir ?? path.join(paths.db, 'sounds')),
fern: createFernsteuerung(bus),
// Der PC bekommt beim Verbinden die Adresse der Sound-Quelle und legt sie
// damit selbst in OBS an. Der Overlay-Schluessel darf nur lesen, und der
// PC haette ihn ohnehin - er steht in jeder Browser-Source in OBS.
fern: createFernsteuerung(bus, {
willkommen: () => ({
soundsUrl: `${opts.publicUrl || `http://127.0.0.1:${opts.port}`}/sounds?key=${zugang.overlayKey()}`,
}),
}),
quellen: new Map(),
};
// Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne
@@ -210,7 +217,18 @@ async function handle(
// ---- Die Leitung zum Stream-PC. Eigener Schluessel im Kopf, nie in der
// Adresse: wer ihn hat, schaltet Szenen um.
if (p.startsWith('/fern/')) {
if (!zugang.darfFern(req)) return send(res, 403, 'text/plain; charset=utf-8', 'Falscher Schluessel.');
if (!zugang.darfFern(req)) {
// Ein unbekannter Schluessel an der Leitung ist ein PC, der sich
// anmelden will. Er wartet auf "Freigeben" im Pult und fragt so lange
// nach. Alles andere unter /fern/ bleibt ohne gueltigen Schluessel zu.
const key = bearer(req.headers.authorization);
if (p === '/fern/befehle' && req.method === 'GET' && key && key.length >= 24 && key.length <= 128) {
fern.anfragen(key, String(req.headers['x-abdulive-rechner'] ?? ''), absender(req));
return send(res, 202, 'application/json; charset=utf-8',
JSON.stringify({ text: 'Wartet auf Freigabe im Pult.' }));
}
return send(res, 403, 'text/plain; charset=utf-8', 'Falscher Schluessel.');
}
if (p === '/fern/befehle' && req.method === 'GET') return fern.annehmen(req, res);
if ((p === '/fern/stand' || p === '/fern/antwort') && req.method === 'POST') {
let roh: unknown;
@@ -285,6 +303,32 @@ async function handle(
tts: Boolean(opts.tts), liegestuetze: opts.liegestuetze?.stand() ?? null,
}));
}
if (p === '/pult/fern') {
const liste = (code: number, text: string): void =>
send(res, code, 'application/json; charset=utf-8', JSON.stringify({
text, anfragen: fern.offeneAnfragen(), verbunden: fern.verbunden(),
}));
if (req.method === 'GET') return liste(200, '');
const form = await readForm(req);
const was = form.get('was');
if (was === 'freigeben') {
const key = fern.freigeben(form.get('id') ?? '');
if (!key) return liste(404, 'Die Anfrage ist abgelaufen. Der PC fragt gleich neu an.');
zugang.fernKeySetzen(key);
// Die alte Leitung (falls es eine gab) gehoert zu einem anderen Schluessel.
fern.trennen();
return liste(200, 'Freigegeben. Der Stream-PC verbindet sich in ein paar Sekunden.');
}
if (was === 'ablehnen') {
return liste(200, fern.ablehnen(form.get('id') ?? '') ? 'Abgelehnt.' : 'Schon weg.');
}
if (was === 'trennen') {
zugang.neuerFernKey();
fern.trennen();
return liste(200, 'Getrennt. Der PC muss neu freigegeben werden.');
}
return liste(400, 'Unbekannt.');
}
if (p === '/pult/obs' && req.method === 'POST') {
const form = await readForm(req);
const befehl = befehlAus(form);
@@ -410,10 +454,15 @@ async function handle(
if (p === '/schluessel/neu' && req.method === 'POST') {
zugang.neuerOverlayKey();
// Der PC verbindet sich neu, bekommt die neue Adresse der Sound-Quelle
// und traegt sie in OBS ein. Die uebrigen Browser-Sources nicht - die hat
// jemand von Hand angelegt, und nur der weiss, wo sie liegen.
fern.trennen();
return redirect(res, '/');
}
if (p === '/fernschluessel/neu' && req.method === 'POST') {
zugang.neuerFernKey();
fern.trennen();
return redirect(res, '/#stream-pc');
}
@@ -588,6 +637,16 @@ export function sichererPfad(roh: string | null | undefined): string {
return roh === '/pult' ? '/pult' : '/';
}
/**
* Woher eine Anfrage kommt. Hinter dem Proxy steht die echte Adresse im Kopf,
* die eigene Verbindung kommt immer vom Proxy. Nur zur Anzeige bei der
* Freigabe - nichts haengt daran, ob sie stimmt.
*/
function absender(req: http.IncomingMessage): string {
const kopf = req.headers['x-real-ip'] ?? String(req.headers['x-forwarded-for'] ?? '').split(',')[0];
return String(kopf || req.socket.remoteAddress || '').trim();
}
function readBody(req: http.IncomingMessage, max: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
@@ -856,12 +915,16 @@ ${lzFeld}
<h2 id="stream-pc">Stream-PC verbinden</h2>
<p class="lead">Damit das Pult Szenen umschalten, Clips speichern und Ton stummschalten
kann, wählt sich der Stream-PC hier ein. Einmal auf dem Stream-PC in PowerShell
ausführen, danach <code>abdulive ingest</code> neu starten:</p>
<p><code id="fernbefehl">${escape(fernBefehl)}</code>
<button data-copy="fernbefehl">kopieren</button></p>
kann, wählt sich der Stream-PC hier ein. Das macht er von selbst: beim ersten
Start meldet er sich, und im Pult unter <a href="/pult#szenen">Szenen</a> erscheint
„Freigeben“. Danach legt er in OBS auch die Sound-Quelle selbst an.</p>
<p class="status"><span class="dot${fernInfo.verbunden ? ' on' : ''}"></span>${
fernInfo.verbunden ? 'Stream-PC ist verbunden.' : 'Stream-PC ist gerade nicht verbunden.'}</p>
<details><summary class="hint">Von Hand verbinden, falls die Freigabe nicht klappt</summary>
<p><code id="fernbefehl">${escape(fernBefehl)}</code>
<button data-copy="fernbefehl">kopieren</button></p>
<p class="hint">Auf dem Stream-PC in PowerShell, danach <code>abdulive ingest</code> neu starten.</p>
</details>
<form method="post" action="/fernschluessel/neu">
<button type="submit" style="margin-left:0">Schlüssel neu würfeln</button>
<span class="hint">Dieser Schlüssel schaltet OBS. Nie in eine Browser-Source eintragen,