/* ===================================================================== DIE SPRACHE DES VERMITTLUNGSSERVERS (19.09.2026) WARUM ES DIESE DATEI GIBT -- und warum sie einen Fehler behebt, der mich Filipes Vertrauen gekostet hat: Am 18.09. habe ich ihm gemeldet, coturn laufe und sei „von aussen nachgewiesen". Am 19.09. stellte sich heraus: Der Vermittlungsserver hatte NIE eine einzige Zuteilung geschafft. In turnserver.conf fehlte `static-auth-secret`; coturn suchte die Zugangsdaten in seiner eigenen Datenbank und wies jeden ab. Und mein Einrichtungswerkzeug meldete die ganze Zeit: "ERGEBNIS: Eingetragen, Geheimnis lesbar, Server antwortet." Jedes Wort davon war wahr. Die Adresse war eingetragen, das Geheimnis war lesbar, der Server antwortete auf eine STUN-Anfrage. Nur beantwortet das zusammen NICHT die Frage, die zaehlt: Vermittelt er auch? Eine gruene Meldung, die das Falsche prueft -- genau die Sorte, vor der in CLAUDE.md seit dem 01.09. ein Absatz steht. DER UNTERSCHIED IN EINEM SATZ: Eine STUN-Bindung fragt „bist du da?". Eine Zuteilung fragt „traegst du meinen Anruf?". Das erste kann gelingen, waehrend das zweite unmoeglich ist -- und genau so war es vier Tage lang. Deshalb liegt das Protokoll jetzt HIER und nicht zweimal daneben: `turn-einrichten.mjs` und `turn-von-aussen.mjs` benutzen dieselbe Rechnung. Eine zweite Abschrift waere die, die beim naechsten Umbau auseinanderlaeuft. ===================================================================== */ import { createSocket } from "node:dgram"; import { randomBytes, createHash, createHmac } from "node:crypto"; /** Der feste Erkennungswert aus RFC 5389 -- er steht in jeder * Nachricht und trennt STUN von allem anderen, was auf dem Port * ankommt. */ export const KOCHTOPF = 0x2112a442; /** Die Nachrichtenarten, die wir brauchen. */ export const M = { bindung: 0x0001, // "wie sehe ich von draussen aus?" zuteilung: 0x0003, // "gib mir eine Adresse, ueber die man mich erreicht" erlaubnis: 0x0008, // "von dieser Gegenstelle nehme ich Pakete an" }; /** Die Felder in einer Nachricht. */ export const A = { ABBILD: 0x0001, BENUTZER: 0x0006, NACHRICHT_ECHT: 0x0008, FEHLER: 0x0009, GEGENSTELLE: 0x0012, BEREICH: 0x0014, EINMALWERT: 0x0015, WEITERLEITUNG: 0x0016, DATEN: 0x0013, TRANSPORT: 0x0019, XOR_ABBILD: 0x0020, }; /** Antwortarten, die wir erkennen wollen. */ export const ANTWORT = { zuteilung_ok: 0x0103, zuteilung_nein: 0x0113, erlaubnis_ok: 0x0108, daten: 0x0017, }; export function attribut(art, inhalt) { const fuell = (4 - (inhalt.length % 4)) % 4; const b = Buffer.alloc(4 + inhalt.length + fuell); b.writeUInt16BE(art, 0); b.writeUInt16BE(inhalt.length, 2); // Laenge OHNE Fuellbytes -- das ist die Falle inhalt.copy(b, 4); return b; } export function nachricht(art, kennung, attribute, zugang) { let koerper = Buffer.concat(attribute); const kopf = Buffer.alloc(20); kopf.writeUInt16BE(art, 0); kopf.writeUInt32BE(KOCHTOPF, 4); kennung.copy(kopf, 8); if (zugang) { /* Der Pruefwert wird ueber die Nachricht gerechnet, deren Laenge ihn schon MITZAEHLT -- sonst rechnet die Gegenseite etwas anderes aus als wir. Das ist der Punkt, an dem eigene STUN-Versuche typischerweise scheitern. */ kopf.writeUInt16BE(koerper.length + 24, 2); const schluessel = createHash("md5") .update(`${zugang.user}:${zugang.bereich}:${zugang.pass}`, "utf8").digest(); const wert = createHmac("sha1", schluessel) .update(Buffer.concat([kopf, koerper])).digest(); koerper = Buffer.concat([koerper, attribut(A.NACHRICHT_ECHT, wert)]); } kopf.writeUInt16BE(koerper.length, 2); return Buffer.concat([kopf, koerper]); } export function lies(b) { if (b.length < 20) return null; const art = b.readUInt16BE(0); const attribute = {}; let p = 20; while (p + 4 <= b.length) { const a = b.readUInt16BE(p), l = b.readUInt16BE(p + 2); attribute[a] = b.subarray(p + 4, p + 4 + l); p += 4 + l + ((4 - (l % 4)) % 4); } return { art, kennung: b.subarray(8, 20), attribute }; } /** Adressen stehen verodert in der Nachricht -- sonst wuerden Geraete * unterwegs daran herumbessern, weil sie meinen, sie muessten. */ export function adresse(b, kennung) { if (!b || b.length < 8) return null; const hafen = b.readUInt16BE(2) ^ (KOCHTOPF >>> 16); const schluessel = Buffer.concat([ Buffer.from([(KOCHTOPF >>> 24) & 255, (KOCHTOPF >>> 16) & 255, (KOCHTOPF >>> 8) & 255, KOCHTOPF & 255]), kennung]); const ip = []; for (let i = 0; i < b.length - 4; i++) ip.push(b[4 + i] ^ schluessel[i]); return { ip: ip.join("."), hafen }; } /** Eine Adresse als Gegenstellen-Feld verpacken. */ export function gegenstelle(ip) { const teile = String(ip).split(".").map(Number); const b = Buffer.alloc(8); b[1] = 1; // IPv4 b.writeUInt16BE(1 ^ (KOCHTOPF >>> 16), 2); const s = [(KOCHTOPF >>> 24) & 255, (KOCHTOPF >>> 16) & 255, (KOCHTOPF >>> 8) & 255, KOCHTOPF & 255]; for (let i = 0; i < 4; i++) b[4 + i] = teile[i] ^ s[i]; return b; } export function schicke(s, daten, host, port, frist = 4000) { return new Promise((fertig) => { const uhr = setTimeout(() => { s.removeListener("message", zuhoerer); fertig(null); }, frist); const zuhoerer = (a) => { clearTimeout(uhr); s.removeListener("message", zuhoerer); fertig(a); }; s.on("message", zuhoerer); s.send(daten, port, host); }); } /* ===================================================================== DIE EIGENTLICHE FRAGE: TRAEGT DIE VERMITTLUNG? Gibt IMMER dreierlei zurueck, nie nur ja/nein: stand: "traegt" -- eine Zuteilung kam zustande "traegt_nicht" -- der Server hat abgelehnt (mit Code) "weiss_nicht" -- wir konnten gar nicht fragen Der dritte Ausgang ist der wichtige. Ohne ihn sieht „keine Antwort" genauso aus wie „abgelehnt", und ein Netzproblem wuerde als kaputte Konfiguration gemeldet. ===================================================================== */ export async function zuteilungPruefen({ host, port = 3478, user, pass, paketPruefen = true, frist = 4000 }) { if (!user || !pass) { return { stand: "weiss_nicht", grund: "keine Zugangsdaten uebergeben" }; } const s = createSocket("udp4"); try { await new Promise((f) => s.bind(f)); /* 1. Ohne Anmeldung fragen. Die Ablehnung liefert Bereich und Einmalwert, die der zweite Versuch braucht. */ const k1 = randomBytes(12); const a1 = lies(await schicke(s, nachricht(M.zuteilung, k1, [attribut(A.TRANSPORT, Buffer.from([17, 0, 0, 0]))]), host, port, frist) || Buffer.alloc(0)); if (!a1) { return { stand: "weiss_nicht", grund: `keine Antwort von ${host}:${port} -- Dienst aus, Port zu oder Adresse falsch` }; } const bereich = a1.attribute[A.BEREICH]?.toString("utf8") || ""; const einmal = a1.attribute[A.EINMALWERT]; if (a1.art !== ANTWORT.zuteilung_nein || !bereich || !einmal) { return { stand: "weiss_nicht", grund: "der Server verlangt gar keine Anmeldung -- das ist kein coturn im erwarteten Zustand" }; } /* 2. Mit Anmeldung. DAS ist die Frage, die zaehlt. */ const zugang = { user, pass, bereich }; const k2 = randomBytes(12); const a2 = lies(await schicke(s, nachricht(M.zuteilung, k2, [ attribut(A.TRANSPORT, Buffer.from([17, 0, 0, 0])), attribut(A.BENUTZER, Buffer.from(user, "utf8")), attribut(A.BEREICH, Buffer.from(bereich, "utf8")), attribut(A.EINMALWERT, einmal), ], zugang), host, port, frist) || Buffer.alloc(0)); if (!a2) return { stand: "weiss_nicht", bereich, grund: "keine Antwort auf die Zuteilung" }; if (a2.art !== ANTWORT.zuteilung_ok) { const c = a2.attribute[A.FEHLER]; const code = c ? c[2] * 100 + c[3] : 0; const erklaerung = code === 401 ? "Die Zugangsdaten passen nicht zum Geheimnis auf dem Server. " + "Meist fehlt `static-auth-secret` in /etc/turnserver.conf " + "oder es steht ein anderer Wert darin als in der Geheimnisdatei." : code === 486 || code === 508 ? "Die Grenzen in turnserver.conf sind erreicht (Kontingent oder Ports)." : "Unerwartete Ablehnung."; return { stand: "traegt_nicht", bereich, code, grund: erklaerung }; } const weiter = adresse(a2.attribute[A.WEITERLEITUNG], k2); const meins = adresse(a2.attribute[A.XOR_ABBILD], k2); if (!weiter) { return { stand: "traegt_nicht", bereich, code: 0, grund: "Zuteilung ohne Adresse -- damit laesst sich nichts anfangen" }; } if (!paketPruefen) { return { stand: "traegt", bereich, zugeteilt: weiter, wirSind: meins, paket: null }; } /* 3. Uns selbst als Gegenstelle eintragen und ein Paket schicken. ERST DAS beantwortet, ob der Portbereich offen ist -- alles davor beantwortet es NICHT. */ const k3 = randomBytes(12); const a3 = lies(await schicke(s, nachricht(M.erlaubnis, k3, [ attribut(A.GEGENSTELLE, gegenstelle(meins?.ip || "0.0.0.0")), attribut(A.BENUTZER, Buffer.from(user, "utf8")), attribut(A.BEREICH, Buffer.from(bereich, "utf8")), attribut(A.EINMALWERT, einmal), ], zugang), host, port, frist) || Buffer.alloc(0)); if (a3?.art !== ANTWORT.erlaubnis_ok) { return { stand: "traegt", bereich, zugeteilt: weiter, wirSind: meins, paket: false, paketGrund: "die Gegenstelle liess sich nicht eintragen" }; } const gegen = createSocket("udp4"); try { await new Promise((f) => gegen.bind(0, f)); const losung = "hallo-vermittlung"; const kam = await new Promise((fertig) => { const uhr = setTimeout(() => fertig(false), 5000); s.on("message", (b) => { const n = lies(b); if (n?.art === ANTWORT.daten && n.attribute[A.DATEN]) { clearTimeout(uhr); fertig(n.attribute[A.DATEN].toString("utf8") === losung); } }); gegen.send(Buffer.from(losung), weiter.hafen, weiter.ip); }); return { stand: "traegt", bereich, zugeteilt: weiter, wirSind: meins, paket: kam, paketGrund: kam ? "" : `nichts kam an Port ${weiter.hafen} an -- der Portbereich ist zu` }; } finally { try { gegen.close(); } catch { /* egal */ } } } catch (f) { return { stand: "weiss_nicht", grund: `Fehler beim Nachsehen: ${f?.message || f}` }; } finally { try { s.close(); } catch { /* egal */ } } }