/* ===================================================================== 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, turnZugang, TURN_GEHEIMNIS_DATEI } from "../server/workspace-turn.js"; import { zuteilungPruefen } from "./turn-sprache.mjs"; 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(); /* ---- TRAEGT DIE VERMITTLUNG AUCH? (19.09.2026) --------------------- BIS HIERHER HAT DIESES WERKZEUG NUR GEFRAGT: "bist du da?" Und genau daran ist es gescheitert. Vom 18. auf den 19.09. stand hier vier Tage lang gruen: "Eingetragen, Geheimnis lesbar, Server antwortet." Jedes Wort wahr. Und trotzdem hatte coturn in dieser ganzen Zeit KEINE EINZIGE Zuteilung geschafft -- in /etc/turnserver.conf fehlte `static-auth-secret`, also suchte er die Zugangsdaten in seiner eigenen Datenbank und wies jeden ab. Ich habe Filipe auf Basis dieser gruenen Zeile gesagt, Anrufe aus fremden Netzen seien nachgewiesen. Sie waren nie moeglich. Eine STUN-Bindung beantwortet "laeuft der Dienst". Eine Zuteilung beantwortet "traegt er einen Anruf". Das sind zwei Fragen, und nur die zweite ist die, wegen der coturn ueberhaupt aufgesetzt wurde. */ let vermittlung = null; if (adressen.length && g.wert) { const erste = adressen.find((a) => /^turns?:/.test(a.urls || "")); const m = erste && /^turns?:([^:/?]+)(?::(\d+))?/.exec(erste.urls); if (!m) { vermittlung = { stand: "weiss_nicht", grund: "keine turn:-Adresse eingetragen (nur stun: vermittelt nichts)" }; } else { /* Die Zugangsdaten rechnen wir uns selbst aus -- mit derselben Funktion, die der Server fuer den Browser benutzt. Waeren es andere, wuerde die Pruefung etwas messen, das im Betrieb nie vorkommt. Die Kennung 0 sagt im coturn-Protokoll: niemand Bestimmtes. */ const z = turnZugang(0, g.wert); console.log(""); console.log("Zuteilung wird versucht ..."); vermittlung = await zuteilungPruefen({ host: m[1], port: Number(m[2] || 3478), user: z.username, pass: z.credential, }); } if (vermittlung.stand === "traegt") { const p = vermittlung.zugeteilt; console.log(` Zuteilung gelingt: ${p.ip}:${p.hafen} (Bereich „${vermittlung.bereich}")`); if (vermittlung.paket === true) { console.log(" Ein Testpaket kommt darueber an -- die Vermittlung traegt wirklich."); } else if (vermittlung.paket === false) { console.log(` ABER: ${vermittlung.paketGrund}`); } } else if (vermittlung.stand === "traegt_nicht") { console.log(` ZUTEILUNG ABGELEHNT (Fehler ${vermittlung.code})`); console.log(` ${vermittlung.grund}`); } else { console.log(` KONNTE NICHT NACHSEHEN: ${vermittlung.grund}`); } } 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); } /* DREI AUSGAENGE, NICHT ZWEI. "Konnte nicht nachsehen" darf nicht wie "in Ordnung" aussehen -- sonst entsteht wieder eine gruene Zeile, die nichts weiss. Rueckgabewert 2 sagt das auch einem Skript. */ if (vermittlung?.stand === "traegt_nicht") { console.log("ERGEBNIS: Der Server antwortet, aber er VERMITTELT NICHT."); console.log(" Anrufe aus Mobilfunk und strengen WLANs kommen nicht zustande."); process.exit(1); } if (!vermittlung || vermittlung.stand === "weiss_nicht") { console.log("ERGEBNIS: Ob die Vermittlung traegt, konnte nicht geprueft werden."); console.log(` ${vermittlung?.grund || "kein Versuch moeglich"}`); console.log(" Das ist KEIN Befund -- es heisst nur, dass gerade niemand nachsehen konnte."); process.exit(2); } if (vermittlung.paket === false) { console.log("ERGEBNIS: Die Zuteilung gelingt, aber kein Paket kommt durch."); console.log(` ${vermittlung.paketGrund}`); process.exit(1); } console.log("ERGEBNIS: Eingetragen, Geheimnis lesbar, Server antwortet"); console.log(" UND die Vermittlung traegt (Zuteilung geprueft, Paket angekommen).");