Files
DogFatherGitandClaude Opus 5 f30227c873 Unsere Seiten, klickbare Video-Karten -- und coturn, das nie vermittelt hat
Drei Sachen von Filipe, und eine davon war ein Fehler von mir.

1. DER VERMITTLUNGSSERVER HAT NIE EINEN ANRUF GETRAGEN.

Am 18.09. habe ich gemeldet, coturn laufe und sei "von aussen
nachgewiesen". Das war falsch. Im Protokoll: null Zuteilungen, jemals.
Beim ersten echten Versuch "check_stun_auth: Cannot find credentials"
-- in /etc/turnserver.conf fehlte `static-auth-secret`, also suchte
coturn die Zugangsdaten in seiner eigenen Datenbank und wies jeden ab.

Und mein eigenes Werkzeug meldete die ganze Zeit gruen:
"Eingetragen, Geheimnis lesbar, Server antwortet." Jedes Wort wahr --
und keins beantwortete die Frage, um die es geht. Eine STUN-Bindung
fragt "bist du da?", eine Zuteilung fragt "traegst du meinen Anruf?".

`tools/turn-sprache.mjs` (neu) versucht jetzt eine ECHTE Zuteilung und
schickt ein Paket darueber. `turn-einrichten.mjs` benutzt sie und hat
drei Ausgaenge statt zwei: traegt / traegt nicht / konnte nicht
nachsehen. Nach Filipes Reparatur auf dem Server gemessen: Zuteilung
auf Port 49199, Paket angekommen -- von Luxemburg durch Deutschland.

2. UNSERE SEITEN -- die erste Kachel, die HINAUSFUEHRT.

Website und VanVans Shop, mit dem Rabattcode DOGI10. Der Code wurde
nachgesehen, nicht abgeschrieben: partnercodes.json, 10 Prozent,
aktiv, "zum weitergeben an Community" -- und giltAufSale: false,
weshalb der Satz zu reduzierten Artikeln danebensteht. Der zweite Code
dort ist als "nur fuer Filipe persoenlich" vermerkt und steht nirgends.

Auch Name und Beschreibung des Shops stammen von der Seite selbst.
Meine erste Fassung hiess "Van's DIY Bastelbedarf" und nannte "Perlen,
Anhaenger, Werkzeug" -- ausgedacht und falsch. Er heisst mit "&" und
verkauft Haekelwerke, Plushies, Schmuck.

Der Ton der Kachel wurde GESUCHT, nicht gewaehlt: sieben geratene
Blautoene schafften den noetigen Abstand nicht, also 372 600
Kombinationen abgesucht. #087ce7, Abstand 0.310, Kontrast 4.51:1.

3. DIE GANZE VIDEO-KARTE KLICKT -- "egal wo man drauf drückt".

Und hier steckte der lehrreiche Fehler: Zuerst stand der Klick-Block
unten vor `return k`. Diese Funktion hat DREI Rueckgabepunkte, und der
erste lautet `if (!darfEintragen()) { ...; return k; }` -- also genau
fuer die Mitglieder, fuer die Filipe es wollte. Beim Team ging es, bei
der Community nicht. Jetzt haengt er dort, wo `videoWeg` entsteht.

Nicht klickbar bleiben Karten ohne Video und solche, deren Video bei
TikTok geloescht wurde -- sonst fuehrt der Klick auf eine Fehlerseite,
und wir sehen kaputt aus.

GEMESSEN: pruef-wege-nach-draussen (neu) 63 Pruefungen, 0 Fehler, auf
1400 und 412 px. Mit Gegenproben: der Kopierknopf oeffnet nichts, der
eigene Knopf wirkt genau einmal, auf einer Karte ohne Video passiert
nichts. pruef-community-sicht 10/0 auf jetzt 11 Seiten, 0 tote Wege.

Nebenbei: Der Pfeil klebte bei langen Untertiteln am Text (auf dem
Bildschirmfoto gesehen). 0.45rem Abstand.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 01:17:09 +02:00

258 lines
10 KiB
JavaScript

/* =====================================================================
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 */ }
}
}