/* ===================================================================== ZUGANGSDATEN FÜR DEN VERMITTLUNGSSERVER (TURN) -- 18.09.2026 Ein TURN-Server leitet Ton und Bild weiter, wenn zwei Browser sich nicht direkt erreichen (strenge Firmennetze, manche Mobilfunknetze). Er will wissen, wer ihn benutzen darf -- also Benutzer und Passwort. --------------------------------------------------------------------- WARUM HIER GERECHNET WIRD, STATT EIN PASSWORT EINZUTRAGEN Der naheliegende Weg wäre ein festes Passwort in den Einstellungen. Es wäre in zwei Minuten fertig -- und es läge danach dauerhaft im Browser jedes Team-Mitglieds, sichtbar in jedem Netzwerk-Fenster, gültig ohne Ende. Wer einmal dabei war, könnte den Server für immer als Weiterleitung benutzen: fremden Verkehr über unsere IP-Adresse schicken, unsere Bandbreite verbrauchen. Ein Zugang, den man nicht entziehen kann, ist kein Zugang, sondern ein Loch. Deshalb der Weg, den coturn selbst dafür vorsieht (`use-auth-secret`): Der Server hier kennt ein GEHEIMNIS, das den Browser nie erreicht. Aus ihm rechnet er bei jeder Abfrage frische Zugangsdaten, die nach TURN_GUELTIG_STUNDEN von selbst verfallen. coturn rechnet dieselbe Zahl nach und kennt dafür nur das Geheimnis -- es muss keine Benutzerliste geben, und nichts muss abgeglichen werden. Benutzername = : Passwort = base64( HMAC-SHA1( Geheimnis, Benutzername ) ) --------------------------------------------------------------------- WARUM DAS GEHEIMNIS IN EINER DATEI LIEGT UND NICHT IN DER DATENBANK Zwei Gründe, beide im Haus schon einmal teuer gewesen: 1. `einstellungSetzen()` schreibt den WERT in das Protokoll (`detail: "schluessel = wert"`). Ein Geheimnis dort wäre im Klartext in der Prüfspur -- und in jeder nächtlichen Sicherung. 2. coturn braucht denselben Wert in `/etc/turnserver.conf`. Eine Datei, die beide Seiten aus derselben Quelle bekommen, kann nicht auseinanderlaufen; zwei Eingabefelder können es immer. Die Datei gehört root und ist für die Gruppe des Dienstes lesbar (640). Der Browser bekommt sie nie zu sehen -- das ist der Punkt, an dem `pruef-anruf.mjs` nachmisst. ===================================================================== */ import { createHmac } from "node:crypto"; import { readFileSync, statSync } from "node:fs"; /** Wie lange erzeugte Zugangsdaten gelten. * * Zwölf Stunden ist die Spanne, die einen Arbeitstag überdeckt, ohne * dass jemand mittendrin herausfliegt -- und kurz genug, dass ein * Zugang, der nicht mehr gelten soll, am nächsten Tag nicht mehr * gilt. Die Oberfläche holt die Adressen zusätzlich bei jedem Anruf * neu; deshalb ist diese Zahl eine Obergrenze, kein Takt. */ export const TURN_GUELTIG_STUNDEN = 12; /** Wo das gemeinsame Geheimnis liegt. * * Über eine Umgebungsvariable umstellbar -- nicht aus Vorliebe für * Einstellbarkeit, sondern weil die Prüfung sonst entweder gar nicht * messen könnte oder an die echte Datei müsste. */ export const TURN_GEHEIMNIS_DATEI = process.env.TURN_GEHEIMNIS_DATEI || "/etc/coturn-workspace.geheimnis"; /* Gelesenes Geheimnis, gemerkt über einen ÄNDERUNGSSTEMPEL statt über eine Laufzeit: Wer die Datei austauscht, soll nicht bis zum Ablauf einer Frist warten. Ein `stat` je Abfrage kostet nichts, und die Abfrage kommt ein paar Mal am Tag, nicht ein paar Mal je Sekunde. */ let gemerkt = { stempel: "", wert: "" }; /** Liest das Geheimnis. * * DREI AUSGÄNGE, nicht zwei -- „ich kann nicht nachsehen" ist etwas * anderes als „es ist keins da". Der Grund wandert bis in die Antwort * der Schnittstelle, damit an der Oberfläche nicht geraten wird. * * @returns {{wert: string, grund: string}} `grund` ist leer, wenn * alles in Ordnung ist; sonst `fehlt`, `leer`, `zu_kurz` oder * `unlesbar:`. */ export function turnGeheimnisLesen(datei = TURN_GEHEIMNIS_DATEI) { let s; try { s = statSync(datei); } catch (f) { gemerkt = { stempel: "", wert: "" }; return { wert: "", grund: f?.code === "ENOENT" ? "fehlt" : `unlesbar:${f?.code || "?"}` }; } const stempel = `${s.mtimeMs}:${s.size}`; if (stempel !== gemerkt.stempel) { try { gemerkt = { stempel, wert: readFileSync(datei, "utf8").trim() }; } catch (f) { gemerkt = { stempel: "", wert: "" }; return { wert: "", grund: `unlesbar:${f?.code || "?"}` }; } } if (!gemerkt.wert) return { wert: "", grund: "leer" }; /* Ein zu kurzes Geheimnis ist schlimmer als keins: Es sieht aus wie Sicherheit und ist in Minuten geraten. 32 Zeichen entsprechen den 16 Bytes, die der Einrichtungsbefehl erzeugt. */ if (gemerkt.wert.length < 32) return { wert: "", grund: "zu_kurz" }; return { wert: gemerkt.wert, grund: "" }; } /** Rechnet ein Paar Zugangsdaten aus. * * @param kennung Wer fragt -- landet im Benutzernamen und damit im * Protokoll von coturn. Keine Namen, nur die Nummer: Das Protokoll * eines Vermittlungsservers ist kein Ort für Klarnamen. * @param geheimnis Der gemeinsame Wert aus der Datei. * @param jetzt Millisekunden. Als Eingabe übergeben, nicht aus der * Wanduhr gelesen -- sonst wäre die Prüfung eine Zeitbombe. */ export function turnZugang(kennung, geheimnis, jetzt = Date.now()) { const ablauf = Math.floor(jetzt / 1000) + TURN_GUELTIG_STUNDEN * 3600; const username = `${ablauf}:${kennung}`; return { username, credential: createHmac("sha1", geheimnis).update(username).digest("base64"), ablauf, }; } /** Ergänzt eine Adressliste um frische Zugangsdaten. * * WELCHER EINTRAG BEKOMMT WELCHE BEHANDLUNG: * * stun:… nichts -- STUN kennt keine Anmeldung. * turn:… ohne eigenes Passwort gerechnete Zugangsdaten (unser * coturn). * turn:… MIT username+credential unverändert -- das ist ein * fremder Dienst, den jemand bewusst * eingetragen hat. Wir überschreiben * keine Zugangsdaten, die wir nicht * ausgestellt haben. * * @returns {{adressen: object[], turn_gesamt: number, * turn_bereit: number, grund: string}} * Die ZAHLEN gehören zurückgegeben, nicht nur die Liste: Eine * Prüfung, die „ist grün" sagen soll, muss zählen können, worüber * sie redet -- sonst ist sie auch bei einer leeren Liste grün. */ export function mitZugangsdaten(liste, kennung, jetzt = Date.now(), datei = TURN_GEHEIMNIS_DATEI) { const rein = Array.isArray(liste) ? liste : []; const brauchtZugang = (e) => /^turns?:/.test(String(e?.urls || "")) && !e?.credential; const turn_gesamt = rein.filter((e) => /^turns?:/.test(String(e?.urls || ""))).length; if (!rein.some(brauchtZugang)) { return { adressen: rein, turn_gesamt, turn_bereit: turn_gesamt, grund: "" }; } const { wert, grund } = turnGeheimnisLesen(datei); if (!wert) { /* KEIN STILLES WEGLASSEN. Ohne Zugangsdaten weist coturn jede Anfrage ab -- der Anruf fällt dann auf die direkte Verbindung zurück und scheitert genau bei den Leuten, für die der Server gebaut wurde. Der Eintrag bleibt trotzdem in der Liste: Ein `turn:` ohne Passwort schadet nicht, und der Grund steht dabei. */ return { adressen: rein, turn_gesamt, turn_bereit: rein.filter((e) => /^turns?:/.test(String(e?.urls || "")) && e?.credential).length, grund, }; } const zugang = turnZugang(kennung, wert, jetzt); return { adressen: rein.map((e) => brauchtZugang(e) ? { ...e, username: zugang.username, credential: zugang.credential } : e), turn_gesamt, turn_bereit: turn_gesamt, grund: "", }; }