Vermittlungsserver: coturn mit Zugangsdaten, die verfallen

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]>
This commit is contained in:
2026-09-18 15:22:14 +02:00
co-authored by Claude Opus 5
parent 5bbf1f2019
commit 775c4b4207
40 changed files with 1348 additions and 413 deletions
+161 -1
View File
@@ -16,7 +16,7 @@
Aufrufen mit: node server/pruef-anruf.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
@@ -28,6 +28,11 @@ process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
/* Der Vermittlungsserver holt sein Geheimnis aus einer Datei. Hier muss
der Pfad UMGEBOGEN werden, bevor index.js geladen wird -- sonst
zeigte er auf die echte Datei unter /etc, und die Pruefung wuerde
entweder am echten Betrieb messen oder gar nichts sehen koennen. */
process.env.TURN_GEHEIMNIS_DATEI = join(ordner, "turn.geheimnis");
const express = (await import("express")).default;
const ec = express.response.cookie;
@@ -268,6 +273,161 @@ melde("=== 6. Die Verbindungsadressen ===");
"danach ist die Liste wieder leer -- der naechste Abschnitt faengt sauber an");
}
/* =======================================================================
6b. DIE ZUGANGSDATEN ZUM VERMITTLUNGSSERVER
Hier steht die Frage, an der sich entscheidet, ob coturn ein Dienst
fuer uns ist oder eine offene Weiterleitung fuer jeden: Das
Geheimnis, aus dem die Zugangsdaten gerechnet werden, darf den
Browser NIE erreichen. Wer es haette, koennte sich selbst beliebig
viele Zugaenge ausstellen -- dauerhaft, ohne Konto, ueber unsere
IP-Adresse.
Deshalb wird hier nicht nur geprueft, dass Zugangsdaten ANKOMMEN,
sondern vor allem, dass das Geheimnis es NICHT tut -- und zwar im
rohen Antworttext, nicht im ausgewerteten Objekt. Ein Feld, an das
niemand denkt, faellt beim Auswerten nicht auf.
======================================================================= */
melde("");
melde("=== 6b. Zugangsdaten fuer die Vermittlung ===");
{
const turn = await import("./workspace-turn.js");
const datei = process.env.TURN_GEHEIMNIS_DATEI;
/* Erfunden, nur fuer diesen Lauf, und am Ende wieder weg. Lang genug,
dass die Laengenpruefung nicht zufaellig anschlaegt. */
const GEHEIM = "pruef-geheimnis-nur-fuer-diesen-lauf-0123456789";
/* --- Die drei Ausgaenge beim Lesen ------------------------------- */
{
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert === "" && g.grund === "fehlt",
`ohne Datei sagt es "fehlt" statt stillschweigend nichts (${g.grund})`);
}
{
writeFileSync(datei, "zu-kurz");
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert === "" && g.grund === "zu_kurz",
`ein zu kurzes Geheimnis wird abgelehnt, nicht benutzt (${g.grund})`);
}
{
writeFileSync(datei, " \n");
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert === "" && g.grund === "leer", `eine leere Datei meldet "leer" (${g.grund})`);
}
{
writeFileSync(datei, GEHEIM + "\n");
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert === GEHEIM && g.grund === "",
"mit Datei kommt das Geheimnis an (Zeilenumbruch abgeschnitten)");
}
/* --- Ein WECHSEL muss auffallen ---------------------------------- */
{
writeFileSync(datei, GEHEIM + "-zweite-fassung-mit-anderer-laenge");
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert.endsWith("-zweite-fassung-mit-anderer-laenge"),
"wird die Datei getauscht, wird der neue Wert gelesen (kein Festhaengen)");
writeFileSync(datei, GEHEIM + "\n");
}
/* --- Die Rechnung ------------------------------------------------ */
{
/* Eine FESTE Zeit als Eingabe, nicht die Wanduhr: Sonst waere die
Pruefung eine Zeitbombe, die irgendwann etwas anderes misst. */
const JETZT = 1_700_000_000_000;
const z = turn.turnZugang("w7", GEHEIM, JETZT);
const [ablaufStr, kennung] = z.username.split(":");
ok(kennung === "w7", `im Benutzernamen steht die Kennung (${kennung})`);
ok(Number(ablaufStr) === Math.floor(JETZT / 1000) + turn.TURN_GUELTIG_STUNDEN * 3600,
`und davor der Ablauf, genau ${turn.TURN_GUELTIG_STUNDEN} Stunden spaeter`);
/* FESTER VERGLEICHSWERT, ausgerechnet und nicht behauptet. Er
beweist NICHT, dass coturn ihn akzeptiert -- das beweist nur ein
Versuch am laufenden coturn (turnutils_uclient). Er beweist,
dass sich die Rechenvorschrift nicht unbemerkt aendert; genau
das wuerde jeden Anruf ueber die Vermittlung scheitern lassen,
ohne dass irgendwo ein Fehler steht. */
ok(z.credential === "9AbMBJeTDL6uebcR6KRpYVTFwt8=",
`das Passwort ist die bekannte HMAC-SHA1-Rechnung (${z.credential})`);
const nochmal = turn.turnZugang("w7", GEHEIM, JETZT);
ok(nochmal.credential === z.credential, "dieselbe Eingabe ergibt dasselbe Passwort");
const anderer = turn.turnZugang("w8", GEHEIM, JETZT);
ok(anderer.credential !== z.credential,
"eine andere Person bekommt ein anderes Passwort");
const spaeter = turn.turnZugang("w7", GEHEIM, JETZT + 60_000);
ok(spaeter.credential !== z.credential,
"und eine Minute spaeter ein anderes -- die Daten verfallen wirklich");
}
/* --- Wer bekommt Zugangsdaten und wer nicht ---------------------- */
{
const liste = [
{ urls: "stun:turn.dogfather-universe.com:3478" },
{ urls: "turn:turn.dogfather-universe.com:3478" },
{ urls: "turn:fremder-dienst.example:3478", username: "fremd", credential: "eigenes" },
];
const e = turn.mitZugangsdaten(liste, "w7", 1_700_000_000_000, datei);
ok(!e.adressen[0].credential, "ein STUN-Eintrag bekommt keine Zugangsdaten");
ok(!!e.adressen[1].credential && e.adressen[1].username.includes(":w7"),
"unser TURN-Eintrag bekommt gerechnete");
ok(e.adressen[2].credential === "eigenes" && e.adressen[2].username === "fremd",
"ein fremder Dienst behaelt seine eigenen -- wir ueberschreiben nichts");
ok(e.turn_gesamt === 2 && e.turn_bereit === 2,
`und die Zahlen stimmen (${e.turn_bereit} von ${e.turn_gesamt} bereit)`);
}
{
/* OHNE Geheimnis: Der Eintrag bleibt stehen, aber es wird gesagt. */
unlinkSync(datei);
const e = turn.mitZugangsdaten(
[{ urls: "turn:turn.dogfather-universe.com:3478" }], "w7", Date.now(), datei);
ok(e.turn_gesamt === 1 && e.turn_bereit === 0 && e.grund === "fehlt",
`ohne Geheimnis ist nichts bereit, und der Grund steht dabei (${e.grund})`);
writeFileSync(datei, GEHEIM + "\n");
}
/* --- Und jetzt ueber die Schnittstelle, wie der Browser sie sieht - */
{
await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }, {
adressen: [{ urls: "turn:turn.dogfather-universe.com:3478" }],
}, "PUT");
const a = await roh("/workspace/api/anruf/adressen", CREW, { cookie: kModi });
const r = json(a);
ok(r.turn_gesamt === 1 && r.turn_bereit === 1,
`der Modi bekommt einen einsatzbereiten Eintrag (${r.turn_bereit}/${r.turn_gesamt})`);
ok(!!r.adressen?.[0]?.credential && !!r.adressen?.[0]?.username,
"mit Benutzername und Passwort");
/* DIE WICHTIGSTE PRUEFUNG DES ABSCHNITTS. Im ROHEN Text gesucht,
nicht im ausgewerteten Objekt. */
ok(!a.text.includes(GEHEIM),
"und das Geheimnis selbst steht NIRGENDS in der Antwort");
/* Jeder bekommt EIGENE Zugangsdaten -- im coturn-Protokoll steht
damit die Nummer, nicht ein Sammelkonto. */
const rDogi = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
ok(rDogi.adressen?.[0]?.username !== r.adressen?.[0]?.username,
"DogFather und der Modi bekommen verschiedene Zugangsdaten");
/* Der Ablauf muss fuer den Browser LESBAR sein -- darauf beruht das
Auffrischen vor jedem Anruf in anruf.js. */
const ablauf = Number(String(r.adressen?.[0]?.username || "").split(":")[0]);
ok(ablauf > Math.floor(Date.now() / 1000) + 3600,
`der Ablauf steht vorn im Benutzernamen und liegt in der Zukunft (${ablauf})`);
}
/* --- Aufraeumen: der naechste Abschnitt faengt sauber an ---------- */
{
await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi },
{ adressen: [] }, "PUT");
try { unlinkSync(datei); } catch { /* schon weg */ }
const zurueck = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
ok(zurueck.adressen?.length === 0 && !zurueck.turn_grund,
"danach ist wieder alles leer -- keine erfundene Adresse bleibt stehen");
}
}
/* =======================================================================
7. IM BROWSER
======================================================================= */
+27 -1
View File
@@ -72,6 +72,7 @@ import {
istDogFather,
} from "./workspace.js";
import { chatEreignis, istDrinFuerAnruf, teilnehmerFuerAnruf } from "./workspace-chat.js";
import { mitZugangsdaten } from "./workspace-turn.js";
export const anrufRouter = express.Router();
@@ -161,13 +162,38 @@ anrufRouter.get("/workspace/api/anruf/adressen", (req, res) => {
try {
let liste = [];
try { liste = JSON.parse(einstellung("anruf_adressen") || "[]"); } catch { liste = []; }
/* ZUGANGSDATEN WERDEN GERECHNET, NICHT GESPEICHERT.
Warum das so ist, steht ausfuehrlich in workspace-turn.js. Kurz:
Ein festes TURN-Passwort laege dauerhaft im Browser jedes
Teilnehmers und liesse sich nie wieder entziehen. Die hier
erzeugten verfallen von selbst.
Als Kennung die PERSONENNUMMER, nicht der Name -- sie landet im
Protokoll von coturn, und das ist kein Ort fuer Klarnamen. */
const { adressen, turn_gesamt, turn_bereit, grund } =
mitZugangsdaten(liste, `w${req.person.id}`);
if (grund) {
/* DER DRITTE AUSGANG. „Kann nicht nachsehen" ist etwas anderes
als „ist nicht eingerichtet" -- und wer das verschweigt, sucht
den Fehler spaeter beim Anrufer. */
console.warn(`[anruf] TURN-Geheimnis nicht verwendbar: ${grund}`);
}
res.json({
adressen: Array.isArray(liste) ? liste : [],
adressen,
gruppe_max: GRUPPE_MAX,
/* WAS DIE OBERFLAECHE WISSEN MUSS, um ehrlich zu sein: Ohne
Adressen klappt der Anruf nur im selben Netz. Das gehoert
DAVOR gesagt, nicht als Fehlermeldung danach. */
nur_gleiches_netz: !Array.isArray(liste) || liste.length === 0,
/* Zahlen statt Ja/Nein: Wer zaehlen kann, kann pruefen. Ein
eingetragener TURN-Server, der mangels Geheimnis niemanden
durchlaesst, sieht sonst genauso aus wie ein funktionierender. */
turn_gesamt,
turn_bereit,
...(grund ? { turn_grund: grund } : {}),
});
} catch (f) {
console.error("[anruf] Adressen:", f?.message);
+183
View File
@@ -0,0 +1,183 @@
/* =====================================================================
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: "",
};
}