diff --git a/tools/turn-von-aussen.mjs b/tools/turn-von-aussen.mjs new file mode 100644 index 00000000..2874b44f --- /dev/null +++ b/tools/turn-von-aussen.mjs @@ -0,0 +1,229 @@ +/* ===================================================================== + FUNKTIONIERT DIE VERMITTLUNG VON AUSSEN? -- 18.09.2026 + + Aufrufen (von einem Rechner ausserhalb des Servers): + + TURN_USER=… TURN_PASS=… node tools/turn-von-aussen.mjs 159.195.212.167 3478 + + Die Zugangsdaten kommen ueber die Umgebung, nicht als Argument: + Argumente stehen in der Prozessliste, die jeder auf dem Rechner + lesen kann. + + --------------------------------------------------------------------- + WARUM DAS NICHT SCHON DURCH DIE STUN-ANTWORT BEWIESEN IST + + Dass der Server auf 3478 antwortet, heisst nur: Der Dienst laeuft + und dieser eine Port ist offen. Ein Anruf, der wirklich vermittelt + wird, laeuft aber ueber einen ZWEITEN Port, den coturn erst bei der + Zuteilung vergibt (49160-49260). Ist der Bereich in der Firewall + nicht offen, dann + + - antwortet STUN weiterhin brav, + - gelingt die Zuteilung sogar, + - und trotzdem kommt kein einziger Ton an. + + Das faellt beim Einrichten nicht auf und beim ersten echten Anruf + auch nicht sofort -- es scheitern ja nur die Verbindungen, die die + Vermittlung ueberhaupt brauchen. Deshalb wird hier bis zum Schluss + gemessen: zuteilen lassen, sich selbst als Gegenstelle eintragen, + ein Paket ueber die zugeteilte Adresse schicken und nachsehen, ob es + ankommt. + ===================================================================== */ + +import { createSocket } from "node:dgram"; +import { createHash, createHmac, randomBytes } from "node:crypto"; + +const HOST = process.argv[2] || "159.195.212.167"; +const PORT = Number(process.argv[3] || 3478); +const USER = process.env.TURN_USER || ""; +const PASS = process.env.TURN_PASS || ""; + +if (!USER || !PASS) { + console.error("KANN NICHT NACHSEHEN: TURN_USER und TURN_PASS fehlen."); + console.error("Sie werden vom Server aus dem Geheimnis gerechnet -- siehe"); + console.error("den Aufrufblock in der Anleitung."); + process.exit(3); +} + +const KOCHTOPF = 0x2112a442; // der feste Erkennungswert aus RFC 5389 +const M = { + bindung: 0x0001, zuteilung: 0x0003, erlaubnis: 0x0008, +}; +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, +}; + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ---------- Nachrichten bauen und lesen ------------------------------- */ + +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; +} + +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]); +} + +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. */ +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 }; +} + +function schicke(s, daten, 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); + }); +} + +/* ---------- Der Ablauf ------------------------------------------------ */ + +console.log(`=== Vermittlung von aussen: ${HOST}:${PORT} ===`); +console.log(""); + +const s = createSocket("udp4"); +await new Promise((f) => s.bind(f)); + +/* 1. Zuteilung ohne Anmeldung -- muss abgelehnt werden, und die + Ablehnung liefert Bereich und Einmalwert fuer den zweiten Versuch. */ +const k1 = randomBytes(12); +const a1 = lies(await schicke(s, nachricht(M.zuteilung, k1, + [attribut(A.TRANSPORT, Buffer.from([17, 0, 0, 0]))]))); + +ok(!!a1, "der Server antwortet ueberhaupt"); +if (!a1) { console.log("\nKeine Antwort -- Dienst aus oder Port 3478 zu."); process.exit(1); } + +const bereich = a1.attribute[A.BEREICH]?.toString("utf8") || ""; +const einmal = a1.attribute[A.EINMALWERT]; +ok(a1.art === 0x0113 && !!bereich && !!einmal, + `ohne Anmeldung wird abgewiesen, wie es sein muss (Bereich „${bereich}")`); + +/* 2. Mit Anmeldung -- das ist die Zuteilung, die ein Anruf braucht. */ +const zugang = { user: USER, pass: 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))); + +if (!a2 || a2.art !== 0x0103) { + const c = a2?.attribute[A.FEHLER]; + const code = c ? c[2] * 100 + c[3] : "?"; + ok(false, `die Zuteilung MISSLINGT (Fehler ${code}) -- so kaeme kein Anruf zustande`); + console.log("\n Bei 401: Die Zugangsdaten passen nicht zum Geheimnis auf dem Server."); + console.log(" Bei 486/508: Die Grenzen in turnserver.conf sind erreicht."); + process.exit(1); +} + +const weiter = adresse(a2.attribute[A.WEITERLEITUNG], k2); +ok(!!weiter, `die Zuteilung gelingt -- wir bekommen ${weiter?.ip}:${weiter?.hafen}`); +ok(weiter.hafen >= 49160 && weiter.hafen <= 49260, + `und der Port liegt im vorgesehenen Bereich (${weiter.hafen}, erwartet 49160-49260)`); + +const meins = adresse(a2.attribute[A.XOR_ABBILD], k2); +ok(!!meins, `von dort aus sehen wir aus wie ${meins?.ip}:${meins?.hafen}`); + +/* 3. Uns selbst als Gegenstelle eintragen. */ +const k3 = randomBytes(12); +const eigeneIp = meins.ip.split(".").map(Number); +const gegen = Buffer.alloc(8); +gegen[1] = 1; // IPv4 +gegen.writeUInt16BE(1 ^ (KOCHTOPF >>> 16), 2); +for (let i = 0; i < 4; i++) { + gegen[4 + i] = eigeneIp[i] ^ [(KOCHTOPF >>> 24) & 255, (KOCHTOPF >>> 16) & 255, + (KOCHTOPF >>> 8) & 255, KOCHTOPF & 255][i]; +} +const a3 = lies(await schicke(s, nachricht(M.erlaubnis, k3, [ + attribut(A.GEGENSTELLE, gegen), + attribut(A.BENUTZER, Buffer.from(USER, "utf8")), + attribut(A.BEREICH, Buffer.from(bereich, "utf8")), + attribut(A.EINMALWERT, einmal), +], zugang))); +ok(a3?.art === 0x0108, "wir duerfen uns selbst als Gegenstelle eintragen"); + +/* 4. DIE EIGENTLICHE FRAGE: Kommt ein Paket ueber die zugeteilte + Adresse an? Das beantwortet, ob der Portbereich offen ist -- + alles davor beantwortet es NICHT. */ +const peer = createSocket("udp4"); +/* Irgendein freier Port -- fuer die Erlaubnis zaehlt allein die + IP-Adresse, und ein fest verdrahteter Port waere genau dann belegt, + wenn man ihn braucht. */ +await new Promise((f) => peer.bind(0, f)); + +const angekommen = new Promise((fertig) => { + const uhr = setTimeout(() => fertig(false), 5000); + s.on("message", (b) => { + const n = lies(b); + if (n?.art === 0x0017 && n.attribute[A.DATEN]) { // Data Indication + clearTimeout(uhr); + fertig(n.attribute[A.DATEN].toString("utf8") === "hallo-vermittlung"); + } + }); +}); +peer.send(Buffer.from("hallo-vermittlung"), weiter.hafen, weiter.ip); +const kam = await angekommen; + +ok(kam, kam + ? "ein Paket ueber die zugeteilte Adresse kommt an -- die Vermittlung traegt wirklich" + : `NICHTS kommt an Port ${weiter.hafen} an -- der Bereich 49160-49260/udp ist zu`); + +peer.close(); s.close(); + +console.log(""); +console.log(`${geprueft} Pruefungen, ${fehler} Fehler`); +console.log(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG -- Anrufe werden auch dann vermittelt," + + "\n wenn keine direkte Verbindung zustande kommt."); +process.exit(fehler ? 1 : 0);