Damit Anrufe auch in Netzen zustande kommen, die keine direkte Verbindung zulassen (15-25 % der Faelle). coturn ist installiert, steht aber still, bis die Konfiguration liegt -- ein coturn mit Werkseinstellung ist ein offenes Relais. KEIN FESTES PASSWORT. Es laege dauerhaft im Browser jedes Team-Mitglieds und liesse sich nie entziehen. Der Server rechnet stattdessen bei jeder Abfrage Zugangsdaten, die nach zwoelf Stunden verfallen (server/workspace-turn.js, coturns `use-auth-secret`). Das Geheimnis liegt in einer Datei, nicht in der Datenbank: einstellung- Setzen() schreibt Werte ins Protokoll, und coturn braucht denselben Wert ohnehin in /etc. Die Oberflaeche frischt die Daten vor jedem Anruf auf. Der Chat ist eine App, die tagelang offen bleibt -- wer nur beim Laden holt, telefoniert am zweiten Tag ohne Vermittlung, und es faellt nicht auf: Es scheitern nur die, die sie gebraucht haetten. DIE WICHTIGSTE ZEILE DER KONFIGURATION ist die Sperrliste. Gemessen: dreizehn Dienste lauschen auf diesem Server nur oertlich, darunter Caddys Verwaltung auf 127.0.0.1:2019 -- wer sie erreicht, kann jede Website umleiten. Ohne Sperrliste waere der Vermittlungsserver die Tuer dorthin, und die Anfrage saehe fuer Caddy aus wie von localhost. Geprueft: pruef-anruf.mjs 51 -> 73 Pruefungen. Gegenprobe (Geheimnis als Passwort ausliefern + fremde Zugangsdaten ueberschreiben) macht genau 5 rot, darunter "das Geheimnis steht NIRGENDS in der Antwort". tools/turn-probelauf.sh beweist am echten coturn, was ein fester Vergleichswert nicht kann: dass coturn unsere Rechnung akzeptiert. Beide Seiten koennten sonst konsequent falsch rechnen und jede Pruefung waere gruen. Seine eigene Gegenprobe war zweimal zu Recht rot -- der erste Aufbau mass wegen `-y` gar nicht das Ziel, das er zu messen behauptete, sondern coturns eingebauten Loopback-Schutz. Co-Authored-By: Claude Opus 5 <[email protected]>
184 lines
7.8 KiB
JavaScript
184 lines
7.8 KiB
JavaScript
/* =====================================================================
|
|
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 = <Ablaufzeitpunkt>:<Kennung>
|
|
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:<CODE>`.
|
|
*/
|
|
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: "",
|
|
};
|
|
}
|