Files
dogfather-universe/tools/turn-von-aussen.mjs
T
DogFatherGitandClaude Opus 5 88a8712638 Messung von aussen: traegt die Vermittlung wirklich?
Dass der Server auf 3478 antwortet, beweist nur, dass der Dienst
laeuft und dieser eine Port offen ist. Ein vermittelter Anruf laeuft
ueber einen ZWEITEN Port, den coturn erst bei der Zuteilung vergibt.
Ist der Bereich 49160-49260 in der Firewall zu, antwortet STUN
weiterhin brav, die Zuteilung gelingt sogar -- und es kommt trotzdem
kein Ton an. Das faellt erst im echten Anruf auf, und auch dann nur
bei denen, die die Vermittlung ueberhaupt brauchen.

Deshalb wird bis zum Schluss gemessen: zuteilen lassen, sich selbst
als Gegenstelle eintragen, ein Paket ueber die zugeteilte Adresse
schicken, nachsehen ob es ankommt. Live von aussen gelaufen:
7 Pruefungen, 0 Fehler, Relay-Port 49190.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 15:36:20 +02:00

230 lines
9.0 KiB
JavaScript

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