/* ===================================================================== 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.");