Files
dogfather-universe/server/workspace-turn.js
T
DogFatherGitandClaude Opus 5 d6df590224 Telefonieren ging nur ueber UDP -- und die Seite sprang immer hoch
=== 1. WARUM TELEFONIEREN NICHT GING ===

Filipe: "es klingelt erscheint auch alles bei jedem aber telefonieren
klappt immer noch nicht."

GEMESSEN, BEVOR ETWAS GEAENDERT WURDE -- und alles war gruen:

  coturn laeuft                 aktiv
  STUN von aussen               antwortet, nennt meine oeffentliche Adresse
  echte Zuteilung von aussen    7/7, Testpaket kam an (Port 49183)
  Dreier-Anruf im Prueflauf     verbindet, alle hoeren alle

Vier Messungen, vier Mal in Ordnung -- und das Telefon ging trotzdem
nicht. Der Grund: Der Prueflauf faehrt BEIDE Seiten auf demselben
Rechner. Dort finden sie sich ueber die direkte Adresse, und die
Vermittlung wird nie gebraucht. Draussen sitzen sie in verschiedenen
Netzen.

DER FUND: Eingetragen war eine einzige Adresse --

    turn:159.195.212.167:3478

Ein `turn:` OHNE `?transport=` heisst UDP, und nur UDP. Nachgemessen
hoert coturn aber auf beidem: 3478/UDP offen, 3478/TCP offen (5349/TLS
ist zu). Angeboten wurde nur der halbe Server.

Wer in einem Netz sitzt, das UDP nach draussen sperrt -- Mobilfunk mit
strengem Profil, Gast-WLAN, Firmennetz --, bekam damit KEINEN
Vermittlungsweg. Es klingelt (das laeuft ueber die Website, also ueber
443), und danach passiert nichts. Genau das gemeldete Bild.

Aus einem Eintrag werden jetzt drei Wege:
  stun:host:port                die eigene Adresse finden, ohne Vermittlung
  turn:host:port?transport=udp  der schnelle Weg
  turn:host:port?transport=tcp  der Weg durch fast jede Sperre

ABGELEITET, NICHT EINGETRAGEN: Drei Zeilen von Hand waeren drei
Stellen, an die beim naechsten Serverumzug jemand denken muesste --
die abgeschriebene Liste, die hier schon zweimal teuer war. Ein
Eintrag MIT `?transport=` bleibt unangetastet, ein fremder Dienst mit
eigenem Passwort sowieso.

pruef-turn-wege (neu, 15/0) sichert beides: dass jeder Weg herauskommt
UND dass jeder turn:-Weg Zugangsdaten traegt. Das Zweite ist das
wichtigere -- auffaechern ohne anmelden haette den Fehler nur
verschoben.

=== 2. DIE SEITE SPRANG BEIM ZURUECKGEHEN IMMER HOCH ===

Filipe: "das nervt man muss dan immer wieder runter scrollen bis man
da ist wo man vorher war."

Der Browser versucht es sogar -- scrollRestoration steht ab Werk auf
"auto". Nur: Jede Seite hier kommt fast leer an und holt ihren Inhalt
danach per Abruf. In dem Moment, in dem der Browser die alte Position
wiederherstellen will, ist das Dokument ein paar hundert Pixel hoch.
Er kann nicht auf Zeile 900 springen, die es noch nicht gibt -- und er
versucht es kein zweites Mal.

Jetzt macht es kopf.js selbst (gilt damit auf allen 21 Seiten): Stelle
merken beim Verlassen, beim Oeffnen zurueckholen und jeden Bildaufbau
lang versuchen, bis das Dokument hoch genug ist. Nach drei Sekunden
wird aufgegeben.

Drei Dinge sind Absicht:
  - NUR beim Zurueckgehen, nicht bei jedem Oeffnen. Wer eine Kachel
    anklickt, will oben anfangen.
  - WER SELBST SCROLLT, GEWINNT. Rad, Wisch oder Taste beenden das
    Nachspringen sofort.
  - Nach einer Stunde vergessen -- auf Zeile 900 zu landen, weil man
    gestern dort war, ist keine Hilfe.

=== 3. DIE LISTE IST NACH ROLLE GETRENNT ===

Filipe: "die rechte hand rolle immer zuerst und dan die modis. die
sollen auch schoen getrennt sein und verschieden aussehen also die
rechte hand viel spezieller."

Die Reihenfolge macht der Server ueber ROLLEN_SORTIERUNG -- dieselbe
Konstante wie ueberall sonst im Haus, nicht eine zweite. Eine
Sortierung nach ROLLE ist ausdruecklich keine Rangliste: Sie sagt
nichts darueber, wie gut jemand ist, nur welche Aufgabe er hat.
Deshalb steht ueber jeder Gruppe ein Satz und keine Zahl.

"Spezieller" heisst hier Material, nicht Groesse: eigener Farbton als
Kante und Schimmer, hellere Flaeche, kraeftigerer Name. Beide Karten
sind GLEICH GROSS und tragen dieselben Zeilen -- zwei Sorten Aufgabe,
keine Rangfolge. Im Kontrastmodus traegt eine doppelte Kante die
Unterscheidung, weil dort keine Farbe mehr wirkt.

=== 4. DER ANRUFKASTEN ===

Vorher: vier gleich aussehende Pillen nebeneinander, darunter eine
Geraeteauswahl, die das breiteste Element war. Kein Name, keine
Ordnung, und der einzige Knopf mit Folgen sah aus wie die anderen.

  - MAN SAH NICHT, MIT WEM. Der wichtigste Satz eines Telefonats fehlte.
    Der Chat kennt den Namen und gibt ihn jetzt mit; wer rangeht, sieht
    den Anrufer.
  - "MIKRO AN" WAR ZWEIDEUTIG -- "ist an" oder "schalt an"? Wer falsch
    raet, sitzt stumm da. Jetzt traegt ein durchgestrichenes Zeichen
    den Zustand, das Wort nur die Sache. aria-pressed bleibt die
    Wahrheit, auch fuer Vorleseprogramme.
  - DIE GERAETEAUSWAHL liegt hinter einem kleinen Knopf, der nur
    erscheint, wenn es ueberhaupt etwas zu waehlen gibt.
  - AUFLEGEN ist als einziger Knopf farbig und steht immer rechts.
  - Ein ruhiger Puls atmet, solange die Verbindung aufbaut, und haelt
    an, sobald sie steht. Bei prefers-reduced-motion steht er still.

=== NEBENBEFUND, DEN DIE PRUEFUNG GEFUNDEN HAT ===

pruef-anruf wurde durch die Auffaecherung an vier Stellen rot: Sie
griff `adressen[0]` und erwartete dort Zugangsdaten. Das ist jetzt der
STUN-Weg, und der hat richtigerweise keine. Gesucht wird nicht mehr
nach PLATZ, sondern nach ART -- ein Index ist eine Annahme darueber,
wie die Liste aussieht, und die hat sich gerade geaendert.

Dazu angepasst: Die Pruefung "die Uhr laeuft" verlangte, dass die Uhr
schon beim Klingeln zaehlt. Seit heute frueh beginnt sie beim
Rangehen (sonst standen im Kasten und im Chat zwei verschiedene
Zahlen). Sie prueft jetzt das Gegenteil -- statt sie zu streichen,
denn eine Zeile weniger haette den Fehler beim naechsten Umbau wieder
durchgelassen.

GEMESSEN: pruef-anruf 114/0 (vorher 111 -- drei MEHR, weil die
Auffaecherung mitgeprueft wird), pruef-turn-wege 15/0,
pruef-meldungen 8/0, pruef-css-klassen ALLES IN ORDNUNG,
pruef-rechtetafel 19/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 15:23:15 +02:00

286 lines
12 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: "",
};
}
/* =====================================================================
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;
}