/* ===================================================================== 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: "", }; } /* ===================================================================== EIN EINTRAG, DREI WEGE — 19.09.2026 Filipe: „es klingelt erscheint auch alles bei jedem aber telefonieren klappt immer noch nicht." --------------------------------------------------------------------- WAS GEMESSEN WURDE, BEVOR ETWAS GEÄNDERT WURDE Der Reihe nach, und jeder Schritt war grün: coturn läuft aktiv STUN von außen antwortet, nennt meine Adresse echte Zuteilung von außen 7 von 7 Prüfungen, Paket kam an Dreier-Anruf im Prüflauf verbindet, alle hören alle Es war also weder der Server noch die Logik. Der Unterschied zwischen Prüflauf und Wirklichkeit ist das NETZ: Im Prüflauf sitzen beide Seiten auf demselben Rechner und finden sich über die direkte Adresse. Draußen sitzen sie in verschiedenen Netzen. --------------------------------------------------------------------- DER FUND Eingetragen war genau eine Adresse: turn:159.195.212.167:3478 Ein `turn:` OHNE `?transport=` heißt **UDP**. Nachgemessen hört coturn aber auf beidem: 3478/UDP offen, 3478/TCP offen (5349/TLS ist zu). Angeboten wurde nur der UDP-Weg. Wer in einem Netz sitzt, das UDP nach draußen sperrt — Mobilfunk mit strengem Profil, Gast-WLAN, Firmennetz, manche Hotspots —, bekommt damit KEINEN Vermittlungsweg. Es klingelt (das läuft über die Website, also über 443), und danach passiert nichts. Genau das Bild, das Filipe beschreibt. --------------------------------------------------------------------- WARUM ABGELEITET UND NICHT EINGETRAGEN Man könnte die drei Zeilen von Hand in die Einstellung schreiben. Dann stünden dort drei Adressen, die denselben Host meinen, und beim nächsten Serverumzug müsste jemand an drei Stellen denken — die abgeschriebene Liste, die dieses Haus schon zweimal teuer bezahlt hat. Stattdessen: Aus einem eingetragenen `turn:host:port` entstehen automatisch stun:host:port damit jeder seine öffentliche Adresse findet (das ist der Weg, der in den meisten Fällen reicht und KEINE Vermittlung kostet) turn:host:port?transport=udp der schnelle Weg turn:host:port?transport=tcp der Weg, der durch fast jede Sperre kommt Wer eine Adresse MIT `?transport=` einträgt, meint sie genau so — die bleibt unangetastet. Und ein fremder Dienst mit eigenem Passwort sowieso (siehe mitZugangsdaten). DIE REIHENFOLGE IST ABSICHT: STUN zuerst, dann UDP, dann TCP. Der Browser probiert alle gleichzeitig und nimmt den ersten, der trägt — aber bei Gleichstand gewinnt der frühere, und TCP-Vermittlung ist der langsamste der drei. ===================================================================== */ /** Aus jedem schlichten `turn:`-Eintrag drei Wege machen. * * @param liste wie eingetragen * @returns {object[]} die erweiterte Liste */ export function wegeAusfaechern(liste) { const rein = Array.isArray(liste) ? liste : []; const raus = []; const schon = new Set(); const dazu = (e) => { const s = String(e?.urls || ""); if (!s || schon.has(s)) return; schon.add(s); raus.push(e); }; for (const e of rein) { const url = String(e?.urls || ""); const m = /^turn:([^?]+)$/.exec(url); /* Kein schlichter turn:-Eintrag -- unverändert übernehmen. Das trifft stun:, turns:, alles mit ?transport= und jeden fremden Dienst mit eigenen Zugangsdaten. */ if (!m || e?.credential) { dazu(e); continue; } const ziel = m[1]; /* STUN braucht keine Anmeldung und bekommt deshalb auch keine -- mitZugangsdaten lässt stun:-Einträge ohnehin in Ruhe. */ dazu({ urls: `stun:${ziel}` }); dazu({ ...e, urls: `turn:${ziel}?transport=udp` }); dazu({ ...e, urls: `turn:${ziel}?transport=tcp` }); } return raus; }