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
+204
View File
@@ -0,0 +1,204 @@
/* =====================================================================
DEN VERMITTLUNGSSERVER EINTRAGEN UND NACHMESSEN -- 18.09.2026
Aufrufen (auf dem Server, als Benutzer dogiweb):
node tools/turn-einrichten.mjs pruefen
node tools/turn-einrichten.mjs eintragen turn:159.195.212.167:3478
node tools/turn-einrichten.mjs entfernen
---------------------------------------------------------------------
WARUM EIN BEFEHL UND KEIN EINGABEFELD
Die Adresse wird einmal eingetragen und dann jahrelang nicht mehr.
Ein Menuepunkt dafuer waere eine Seite, die niemand findet, wenn er
sie braucht -- und eine, die gepflegt werden muss. Der Befehl steht
dagegen neben den anderen Einrichtungsschritten, in derselben
Anleitung.
WAS DIESER BEFEHL NICHT TUT: Er erzeugt das Geheimnis nicht. Das
gehoert root, weil es auch in /etc/turnserver.conf muss. Hier wird
nur NACHGESEHEN, ob es aus Sicht des Dienstes lesbar ist -- der
haeufigste Fehler beim Einrichten sind Dateirechte, und der faellt
sonst erst beim ersten Anruf auf, der nicht zustande kommt.
DAS GEHEIMNIS WIRD NIE AUSGEGEBEN, auch nicht gekuerzt.
===================================================================== */
import { createSocket } from "node:dgram";
import { randomBytes } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { turnGeheimnisLesen, TURN_GEHEIMNIS_DATEI } from "../server/workspace-turn.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PFAD = process.env.WORKSPACE_DB
|| join(__dirname, "..", "..", "workspace-daten", "workspace.db");
const [, , befehl, wert] = process.argv;
/* ---------------------------------------------------------------------
EIN ECHTER VERSUCH, KEIN BLICK IN DIE KONFIGURATION
Eine Konfigurationsdatei zu lesen beweist, dass jemand etwas
hingeschrieben hat. Ob der Dienst laeuft, ob die Firewall offen ist
und ob er von aussen antwortet, beweist nur eine Anfrage.
Ein STUN-Binding ist dafuer die kleinste moegliche: 20 Byte hin,
und zurueck kommt die eigene Adresse, wie der Server sie sieht.
--------------------------------------------------------------------- */
function stunFragen(host, port = 3478, frist = 3000) {
return new Promise((fertig) => {
const kennung = randomBytes(12);
const anfrage = Buffer.alloc(20);
anfrage.writeUInt16BE(0x0001, 0); // Binding Request
anfrage.writeUInt16BE(0x0000, 2); // keine Attribute
anfrage.writeUInt32BE(0x2112a442, 4); // der feste Erkennungswert
kennung.copy(anfrage, 8);
const s = createSocket("udp4");
const uhr = setTimeout(() => {
try { s.close(); } catch { /* schon zu */ }
fertig({ ok: false, grund: "keine Antwort innerhalb von 3 Sekunden" });
}, frist);
s.on("error", (f) => {
clearTimeout(uhr);
try { s.close(); } catch { /* schon zu */ }
fertig({ ok: false, grund: f.message });
});
s.on("message", (a) => {
clearTimeout(uhr);
try { s.close(); } catch { /* schon zu */ }
if (a.length < 20 || a.readUInt16BE(0) !== 0x0101) {
return fertig({ ok: false, grund: "Antwort ist kein STUN-Erfolg" });
}
if (!a.subarray(8, 20).equals(kennung)) {
return fertig({ ok: false, grund: "Antwort gehoert zu einer anderen Anfrage" });
}
/* XOR-MAPPED-ADDRESS heraussuchen -- die Adresse ist mit dem
Erkennungswert verodert, damit unterwegs niemand daran
herumbastelt. */
let p = 20;
while (p + 4 <= a.length) {
const art = a.readUInt16BE(p), laenge = a.readUInt16BE(p + 2);
if (art === 0x0020 && laenge >= 8) {
const hafen = a.readUInt16BE(p + 6) ^ 0x2112;
const ip = [0, 1, 2, 3]
.map((i) => a[p + 8 + i] ^ [0x21, 0x12, 0xa4, 0x42][i]).join(".");
return fertig({ ok: true, gesehenAls: `${ip}:${hafen}` });
}
p += 4 + laenge + ((4 - (laenge % 4)) % 4);
}
fertig({ ok: true, gesehenAls: "(ohne Adressangabe)" });
});
s.send(anfrage, port, host, (f) => {
if (f) {
clearTimeout(uhr);
try { s.close(); } catch { /* schon zu */ }
fertig({ ok: false, grund: f.message });
}
});
});
}
function adressenLesen(db) {
try {
const r = db.prepare("SELECT wert FROM einstellungen WHERE schluessel = 'anruf_adressen'").get();
const l = JSON.parse(r?.wert || "[]");
return Array.isArray(l) ? l : [];
} catch { return []; }
}
function adressenSchreiben(db, liste) {
db.prepare(`
INSERT INTO einstellungen (schluessel, wert, geaendert, von)
VALUES ('anruf_adressen', ?, ?, NULL)
ON CONFLICT(schluessel) DO UPDATE SET
wert = excluded.wert, geaendert = excluded.geaendert`)
.run(JSON.stringify(liste), new Date().toISOString());
}
/* --------------------------------------------------------------------- */
if (!["pruefen", "eintragen", "entfernen"].includes(befehl)) {
console.log("Aufruf: node tools/turn-einrichten.mjs pruefen"
+ "\n node tools/turn-einrichten.mjs eintragen turn:159.195.212.167:3478"
+ "\n node tools/turn-einrichten.mjs entfernen");
process.exit(2);
}
const db = new DatabaseSync(DB_PFAD);
if (befehl === "eintragen") {
if (!/^(stun|turn|turns):[^\s]+$/.test(wert || "")) {
console.error("FEHLT: eine Adresse wie turn:159.195.212.167:3478");
process.exit(2);
}
const alt = adressenLesen(db);
/* Doppelte Eintraege vermeiden -- zweimal dieselbe Adresse bedeutet,
dass jeder Browser sie zweimal probiert. */
const neu = alt.filter((e) => e.urls !== wert).concat([{ urls: wert }]);
adressenSchreiben(db, neu);
console.log(`Eingetragen. Vorher ${alt.length}, jetzt ${neu.length} Adresse(n).`);
} else if (befehl === "entfernen") {
adressenSchreiben(db, []);
console.log("Alle Adressen entfernt.");
}
/* ---------- Der Zustand, immer ---------------------------------------- */
console.log("");
console.log("=== Zustand ===");
const adressen = adressenLesen(db);
console.log(`Eingetragene Adressen: ${adressen.length}`);
for (const a of adressen) console.log(` ${a.urls}`);
const g = turnGeheimnisLesen();
console.log("");
if (g.wert) {
console.log(`Geheimnis: lesbar (${g.wert.length} Zeichen) aus ${TURN_GEHEIMNIS_DATEI}`);
} else {
console.log(`Geheimnis: NICHT verwendbar -- ${g.grund}`);
console.log(` erwartet unter ${TURN_GEHEIMNIS_DATEI}`);
if (g.grund.startsWith("unlesbar")) {
console.log(" (Dateirechte: root:dogiweb mit 640)");
}
}
/* Wirklich anfragen -- bei jeder eingetragenen Adresse. */
let erreichbar = 0, versucht = 0;
for (const a of adressen) {
const m = /^(?:stun|turns?):([^:/?]+)(?::(\d+))?/.exec(a.urls || "");
if (!m) continue;
versucht++;
const antwort = await stunFragen(m[1], Number(m[2] || 3478));
if (antwort.ok) {
erreichbar++;
console.log(`\n${a.urls}: antwortet. Von dort aus sehen wir aus wie ${antwort.gesehenAls}`);
} else {
console.log(`\n${a.urls}: KEINE ANTWORT -- ${antwort.grund}`);
console.log(" Meist eins von dreien: Dienst laeuft nicht "
+ "(systemctl status coturn), Firewall zu (ufw), oder falsche Adresse.");
}
}
db.close();
console.log("");
if (!adressen.length) {
console.log("ERGEBNIS: Keine Adresse eingetragen -- Anrufe klappen nur im selben Netz.");
process.exit(1);
}
if (!g.wert) {
console.log("ERGEBNIS: Adresse da, aber ohne Geheimnis weist coturn jeden ab.");
process.exit(1);
}
if (versucht && erreichbar < versucht) {
console.log(`ERGEBNIS: ${erreichbar} von ${versucht} Adressen antworten.`);
process.exit(1);
}
console.log("ERGEBNIS: Eingetragen, Geheimnis lesbar, Server antwortet.");
+186
View File
@@ -0,0 +1,186 @@
#!/bin/bash
# =====================================================================
# BEWEIST, DASS COTURN UNSERE ZUGANGSDATEN AKZEPTIERT -- 18.09.2026
#
# Aufrufen auf dem Server: bash tools/turn-probelauf.sh
#
# ---------------------------------------------------------------------
# WARUM ES DIESES SKRIPT GIBT
#
# `pruef-anruf.mjs` prueft die Rechenvorschrift gegen einen festen
# Vergleichswert. Das schuetzt davor, dass sie sich unbemerkt aendert --
# es beweist aber NICHT, dass coturn das Ergebnis akzeptiert. Wir
# koennten beide Seiten konsequent falsch rechnen, und jede Pruefung
# waere gruen, waehrend kein einziger Anruf ueber die Vermittlung
# zustande kaeme. Das faellt im Betrieb nicht auf: Es scheitern nur die
# 15-25 %, die den Server ueberhaupt brauchen.
#
# Hier laeuft deshalb ein echter coturn und ein echter Client.
#
# ---------------------------------------------------------------------
# DASS ES NICHTS ANFASST, IST ABSICHT
#
# Eigenes Heimatverzeichnis, eigener Port, nur 127.0.0.1, kein root,
# kein Systemdienst. Der laufende coturn auf 3478 bleibt unberuehrt,
# und von aussen ist der Probe-Server nicht erreichbar.
# =====================================================================
set -u
P="${TMPDIR:-/tmp}/turn-probe-$$"
FEHLER=0
ok() { echo " ok $1"; }
fehl() { echo " FEHL $1"; FEHLER=$((FEHLER+1)); }
aufraeumen() { pkill -f "$P/turn.conf" 2>/dev/null; kill "${GEGENPART:-0}" 2>/dev/null; sleep 1; rm -rf "$P"; }
trap aufraeumen EXIT
command -v turnserver >/dev/null || { echo "KANN NICHT NACHSEHEN: coturn ist nicht installiert."; exit 3; }
command -v turnutils_uclient >/dev/null || { echo "KANN NICHT NACHSEHEN: turnutils_uclient fehlt."; exit 3; }
NODE=$(command -v node || echo /usr/local/bin/node)
[ -x "$NODE" ] || { echo "KANN NICHT NACHSEHEN: node nicht gefunden."; exit 3; }
mkdir -p "$P"
# ZIEL FUER DEN SPERRTEST -- und warum ausgerechnet dieses.
# Der erste Anlauf nahm 127.0.0.1. Die Gegenprobe (Abschnitt 4) zeigte:
# Auch OHNE Sperrliste kam 403 -- coturn verbietet Loopback-Ziele von
# sich aus. Der Abschnitt haette also NICHT unsere Sperrliste gemessen,
# sondern eine eingebaute Regel, und waere gruen geblieben, wenn jemand
# die Liste entfernt. Genau dafuer ist die Gegenprobe da.
# Die Docker-Bruecke ist ein echtes oertliches Ziel auf diesem Server
# und KEIN Loopback -- ohne unsere Liste waere sie erreichbar.
ZIEL="${TURN_PROBE_ZIEL:-172.17.0.1}"
# WARUM HIER KEIN `-y` STEHT (und im ersten Anlauf stand):
# `-y` heisst "client-to-client" -- der Client verbindet dann zwei
# EIGENE Kanaele miteinander und ignoriert `-e` vollstaendig. Das Ziel
# war damit immer die Relay-Adresse 127.0.0.1, also Loopback, und das
# sperrt coturn von sich aus. Die Gegenprobe blieb deshalb zweimal rot,
# ohne dass an der Sperrliste etwas falsch war. Erst die Hilfeseite des
# Werkzeugs hat es gezeigt -- nicht das dritte Herumprobieren.
# Jetzt laeuft ein echter Gegenpart auf der Docker-Bruecke.
gegenpart_starten() {
turnutils_peer -L "$ZIEL" -p 3480 >/dev/null 2>&1 &
GEGENPART=$!
sleep 1
}
gegenpart_beenden() { kill "$GEGENPART" 2>/dev/null; }
GEHEIM=$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')
# Die Konfiguration entspricht deploy/turnserver.conf; abweichend nur
# Adresse, Ports und dass das Protokoll in den Probeordner geht.
schreibe_konf() { # $1 = "mit-sperre" oder "ohne-sperre"
cat > "$P/turn.conf" <<CONF
listening-ip=127.0.0.1
relay-ip=127.0.0.1
listening-port=34780
no-tls
no-dtls
min-port=49400
max-port=49420
use-auth-secret
static-auth-secret=$GEHEIM
realm=dogfather-universe.com
no-multicast-peers
no-tcp-relay
no-cli
user-quota=12
total-quota=100
CONF
if [ "$1" = "mit-sperre" ]; then
cat >> "$P/turn.conf" <<'CONF'
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
CONF
fi
}
starten() {
turnserver -c "$P/turn.conf" --no-stdout-log --log-file="$P/turn.log" >/dev/null 2>&1 &
for _ in 1 2 3 4 5 6 7 8 9 10; do
ss -lnu 2>/dev/null | grep -q 34780 && return 0
sleep 0.5
done
return 1
}
anhalten() { pkill -f "$P/turn.conf" 2>/dev/null; sleep 1; }
# Dieselbe Rechnung wie server/workspace-turn.js -- bewusst nachgebaut
# statt importiert: Wenn beide Seiten aus derselben Zeile kaemen, waere
# ein Fehler in dieser Zeile hier unsichtbar.
zugang() {
"$NODE" -e "
const {createHmac} = require('node:crypto');
const u = (Math.floor(Date.now()/1000) + 12*3600) + ':w7';
console.log(u + '\t' + createHmac('sha1', process.argv[1]).update(u).digest('base64'));
" "$GEHEIM"
}
echo "=== Probelauf gegen einen echten coturn ==="
echo
# --------------------------------------------------------------------
echo "--- 1. Die Konfiguration aus deploy/turnserver.conf ---"
schreibe_konf mit-sperre
gegenpart_starten
if starten; then ok "coturn startet und lauscht auf 127.0.0.1:34780"
else fehl "coturn startet nicht -- $(tail -3 "$P/turn.log" 2>/dev/null | tr '\n' ' ')"; exit 1; fi
A=$(turnutils_stunclient -p 34780 127.0.0.1 2>&1)
echo "$A" | grep -q "reflexive addr" \
&& ok "er beantwortet eine STUN-Anfrage ($(echo "$A" | grep -m1 -o '127[0-9.:]*'))" \
|| fehl "keine STUN-Antwort: $(echo "$A" | head -2 | tr '\n' ' ')"
# --------------------------------------------------------------------
echo
echo "--- 2. Die Kernfrage: nimmt coturn unsere gerechneten Daten an? ---"
L=$(zugang); U=$(echo "$L" | cut -f1); W=$(echo "$L" | cut -f2)
RICHTIG=$(turnutils_uclient -u "$U" -w "$W" -p 34780 -n 2 -c -e "$ZIEL" -r 3480 127.0.0.1 2>&1)
FALSCH=$(turnutils_uclient -u "$U" -w "nicht-das-richtige-passwort" -p 34780 -n 1 -c -e "$ZIEL" -r 3480 127.0.0.1 2>&1)
# Der Unterschied ist eindeutig und genau der richtige:
# Mit gueltigen Daten kommt der Client UEBER die Allocation hinaus und
# scheitert erst am gesperrten Ziel. Mit ungueltigen scheitert schon
# die Allocation selbst.
echo "$RICHTIG" | grep -q "Cannot complete Allocation" \
&& fehl "unsere Zugangsdaten werden ABGEWIESEN -- kein Anruf ginge ueber die Vermittlung" \
|| ok "unsere Zugangsdaten werden angenommen (die Zuteilung gelingt)"
echo "$FALSCH" | grep -q "Cannot complete Allocation" \
&& ok "ein falsches Passwort wird abgewiesen -- die Pruefung prueft wirklich" \
|| fehl "ein FALSCHES Passwort kam durch -- der Server steht jedem offen"
# --------------------------------------------------------------------
echo
echo "--- 3. Die Sperrliste: kommt jemand an die oertlichen Dienste? ---"
echo "$RICHTIG" | grep -qi "403" \
&& ok "ein Ziel im oertlichen Netz ($ZIEL) wird verweigert (403)" \
|| fehl "$ZIEL war als Ziel ERLAUBT -- ein Weg zu jedem oertlichen Dienst"
# --------------------------------------------------------------------
echo
echo "--- 4. Gegenprobe: ohne Sperrliste MUSS derselbe Versuch durchgehen ---"
echo " (sonst haette Abschnitt 3 nur gemessen, dass irgendetwas scheitert)"
anhalten
schreibe_konf ohne-sperre
if starten; then
L=$(zugang); U=$(echo "$L" | cut -f1); W=$(echo "$L" | cut -f2)
OHNE=$(turnutils_uclient -u "$U" -w "$W" -p 34780 -n 4 -c -e "$ZIEL" -r 3480 127.0.0.1 2>&1)
echo "$OHNE" | grep -qi "403" \
&& fehl "auch ohne Sperrliste 403 -- Abschnitt 3 beweist dann nichts" \
|| ok "ohne Sperrliste laeuft derselbe Versuch durch -- die Sperre war die Ursache"
echo "$OHNE" | grep -qiE "total transmit|tot_send_msgs|Total transmit time" \
&& ok "und es fliessen wirklich Daten ueber die Weiterleitung" \
|| echo " -- Datenfluss nicht ablesbar (nicht schlimm, der Rest steht)"
else
fehl "zweiter Start misslungen"
fi
echo
if [ "$FEHLER" -eq 0 ]; then
echo "ALLES IN ORDNUNG -- coturn und der Workspace rechnen dasselbe."
exit 0
fi
echo "NICHT IN ORDNUNG -- $FEHLER Punkt(e)."
exit 1