From f30227c873e6cbff9289188b795514d468f22c23 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Sat, 19 Sep 2026 01:17:09 +0200 Subject: [PATCH] Unsere Seiten, klickbare Video-Karten -- und coturn, das nie vermittelt hat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/pruef-wege-nach-draussen.mjs | 513 +++++++++++++++++++++++++ server/rechte.js | 14 + server/workspace.js | 22 ++ tools/turn-einrichten.mjs | 80 +++- tools/turn-sprache.mjs | 257 +++++++++++++ workspace/anruf-probe.html | 8 +- workspace/app.webmanifest | 8 +- workspace/assets/css/bereich.css | 37 ++ workspace/assets/css/start.css | 27 ++ workspace/assets/css/unsere-seiten.css | 288 ++++++++++++++ workspace/assets/js/bereich.js | 102 ++++- workspace/assets/js/bereiche.js | 14 + workspace/assets/js/unsere-seiten.js | 144 +++++++ workspace/aufgaben.html | 24 +- workspace/automation.html | 26 +- workspace/befinden.html | 24 +- workspace/bereich.html | 36 +- workspace/bewerben.html | 30 +- workspace/bewerbungen.html | 28 +- workspace/calls.html | 28 +- workspace/chat.html | 28 +- workspace/content.html | 30 +- workspace/crew-index.html | 10 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 26 +- workspace/entwicklung.html | 24 +- workspace/index.html | 8 +- workspace/kalender.html | 28 +- workspace/leistung.html | 26 +- workspace/personen.html | 26 +- workspace/profil.html | 28 +- workspace/rechte.html | 24 +- workspace/report.html | 28 +- workspace/scouting.html | 26 +- workspace/start.html | 24 +- workspace/startcheck.html | 28 +- workspace/steckbrief.html | 28 +- workspace/talente.html | 26 +- workspace/team.html | 26 +- workspace/teamlage.html | 24 +- workspace/teilen.html | 22 +- workspace/treff-moderation.html | 24 +- workspace/treff-regeln.html | 26 +- workspace/uebersicht.html | 26 +- workspace/unsere-seiten.html | 197 ++++++++++ workspace/wissen.html | 26 +- 46 files changed, 2096 insertions(+), 411 deletions(-) create mode 100644 server/pruef-wege-nach-draussen.mjs create mode 100644 tools/turn-sprache.mjs create mode 100644 workspace/assets/css/unsere-seiten.css create mode 100644 workspace/assets/js/unsere-seiten.js create mode 100644 workspace/unsere-seiten.html diff --git a/server/pruef-wege-nach-draussen.mjs b/server/pruef-wege-nach-draussen.mjs new file mode 100644 index 00000000..ef317248 --- /dev/null +++ b/server/pruef-wege-nach-draussen.mjs @@ -0,0 +1,513 @@ +/* ===================================================================== + WEGE NACH DRAUSSEN -- 19.09.2026 + + Zwei Aufträge von Filipe, und beide haben dieselbe Wurzel: + + „ich brauch im community bereich noch eine kachel, die wenn man + drauf drückt dan kacheln hat die auf die website von dogfather + universe geht ... und die shop site von vanvan." + + „die community soll sofort auf das video geschickt werden sobald + sie auf die kachel drücken, also die ganze kacheln will ich egal + wo man drauf drückt, foto text.... egal wo." + + Beides führt AUS dem Treff hinaus. Und beides ist die Sorte + Funktion, die man für erledigt hält, weil sie im Quelltext + dasteht -- bis jemand daraufklickt und nichts passiert. + + DIE DREI FALLEN, DIE HIER ABGESICHERT WERDEN: + + 1. EIN LINK IM LINK. Die Video-Karte enthält bereits Links und + Knöpfe. Hätte ich die ganze Karte in ein gepackt, würde der + Browser das still reparieren -- und ein Teil der Karte wäre tot, + ohne Fehlermeldung. Deshalb wird hier gemessen, dass BEIDES + funktioniert: die Fläche UND die Knöpfe darin. + + 2. EINE FLÄCHE, DIE ÜBERALL KLICKT. Genauso falsch wäre das + Gegenteil: Wer auf „löschen“ tippt, darf nicht bei TikTok + landen. Die Gegenprobe steht deshalb gleichberechtigt daneben. + + 3. EIN TOTER WEG. Eine Kachel, die ein Mitglied sieht und nicht + öffnen darf, ist schlimmer als keine. Genau diese Sorte hat die + Sichtprüfung am 18.09. zehnmal gefunden -- alle derselbe + Chat-Knopf. + + Aufrufen mit: node server/pruef-wege-nach-draussen.mjs + ===================================================================== */ +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; +import { portMussFreiSein } from "./helfer-port.mjs"; + +const PORT = await portMussFreiSein(4196, "pruef-wege-nach-draussen"); +const ordner = mkdtempSync(join(tmpdir(), "ws-wege-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(150_000, "pruef-wege-nach-draussen"); +await new Promise((r) => setTimeout(r, 700)); + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); + +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const CREW = "crew.dogfather-universe.com"; +const WURZEL = join(import.meta.dirname, ".."); + +function roh(pfad, host, kopf = {}, koerper = null) { + return new Promise((fertig, schief) => { + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, + method: koerper ? "POST" : "GET", + headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf }, + }, (antwort) => { + let text = ""; + antwort.on("data", (s) => { text += s; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); + }); + a.on("error", schief); + if (koerper) a.write(JSON.stringify(koerper)); + a.end(); + }); +} + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); + +/* ---- Leute anlegen ------------------------------------------------- */ +const LEUTE = [ + ["Filipe", "admin", "CODE-DOGI-0001"], + ["Mara", "gast", "CODE-GAST-0001"], + ["Nico", "creator", "CODE-CREA-0001"], +]; +{ + const d = new DatabaseSync(process.env.WORKSPACE_DB); + for (const [name, rolle, code] of LEUTE) { + const salz = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salz, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salz, 32768, new Date().toISOString()); + } + d.close(); +} + +async function anmelden(rolle, code, host) { + let a = await roh("/workspace/api/anmelden", host, {}, { rolle, code }); + if (a.code === 400 && a.text.includes("alter_offen")) { + a = await roh("/workspace/api/anmelden", host, {}, { rolle, code, alter_ok: true }); + } + return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); +} + +/* ======================================================================= + 1. DIE KACHEL -- GIBT ES SIE, UND FUEHRT SIE IRGENDWOHIN? + ======================================================================= */ +melde(""); +melde("=== 1. Die Kachel „Unsere Seiten“ ==="); +/* GEFRAGT WIRD, WAS EIN MITGLIED BEKOMMT -- nicht, was in einer + internen Liste steht. `TREFF_BEREICHE` ist absichtlich nicht + exportiert; `bereicheFuer` ist die Funktion, die auch den Browser + beliefert. Damit misst dieser Abschnitt denselben Weg, den die + Community geht, statt einen daneben. */ +const { bereicheFuer, TREFF_BRETTER } = await import("./workspace.js"); +{ + const TREFF_BEREICHE = bereicheFuer({ rolle: "gast" }); + ok(Array.isArray(TREFF_BEREICHE) && TREFF_BEREICHE.length > 0, + `ein Mitglied bekommt ${TREFF_BEREICHE?.length} Kacheln`); + const kachel = TREFF_BEREICHE.find((k) => k.ziel === "unsere-seiten.html"); + ok(!!kachel, "sie steht in der Liste der Treff-Kacheln"); + ok(kachel?.name === "Unsere Seiten", `sie heisst „${kachel?.name}“`); + ok(!!kachel?.unter && kachel.unter.length > 5, + `und sagt, was dahinter steckt („${kachel?.unter}“)`); + + /* SIE DARF KEIN BRETT SEIN. Waere sie eins, wuerde der Server ein + Brett „unsere-seiten“ erwarten, das es nicht gibt -- und die + Ableitung unten nimmt sie nur deshalb nicht mit, weil im Ziel kein + „?b=“ steht. Das ist eine stille Abhaengigkeit, also wird sie + gemessen. */ + ok(!TREFF_BRETTER.includes("unsere-seiten"), + `sie ist kein Brett (${TREFF_BRETTER.length} Bretter abgeleitet)`); + ok(TREFF_BRETTER.length === 7, + `es bleiben genau die sieben Bretter (${TREFF_BRETTER.join(", ")})`); + + /* DAS RASTER. Drei Spalten, „Der Treff“ belegt zwei -- die Summe + muss durch drei teilbar sein, sonst klafft eine Luecke. */ + const plaetze = TREFF_BEREICHE.reduce((n, k) => n + (k.gross ? 2 : 1), 0); + ok(plaetze % 3 === 0, + `das Raster geht auf: ${TREFF_BEREICHE.length} Kacheln = ${plaetze} Plaetze, ${plaetze / 3} volle Reihen`); + + /* EIGENER TON. Zwei Kacheln mit derselben Farbe nebeneinander sind + auf einem Bildschirmfoto nicht zu unterscheiden. */ + const toene = TREFF_BEREICHE.map((k) => k.ton); + ok(new Set(toene).size === toene.length, + `jede Kachel hat einen eigenen Ton (${toene.join(", ")})`); + + /* UND DEN TON MUSS ES IM CSS GEBEN. Fehlt er, faellt die Kachel auf + die Vorgabefarbe zurueck -- und sieht aus wie eine andere. */ + const css = readFileSync(join(WURZEL, "workspace/assets/css/start.css"), "utf8"); + ok(css.includes(`[data-ton="${kachel?.ton}"]`), + `Ton ${kachel?.ton} ist im CSS hinterlegt`); + + /* UND DAS ZEICHEN. Ein unbekannter Name gibt ein leeres Feld -- ohne + Fehlermeldung. */ + const zeichenDatei = readFileSync(join(WURZEL, "workspace/assets/js/bereiche.js"), "utf8"); + ok(new RegExp(`^\\s{4}${kachel?.zeichen}: \\{`, "m").test(zeichenDatei), + `das Zeichen „${kachel?.zeichen}“ gibt es wirklich`); +} + +/* ======================================================================= + 2. DARF SIE AUCH JEMAND OEFFNEN? -- der tote Weg + ======================================================================= */ +melde(""); +melde("=== 2. Wer darf hinein ==="); +const kGast = await anmelden("gast", "CODE-GAST-0001", CREW); +const kDogi = await anmelden("admin", "CODE-DOGI-0001", CREW); +const kCreator = await anmelden("creator", "CODE-CREA-0001", CREW); +{ + ok(!!kGast && !!kDogi, "Mitglied und DogFather sind angemeldet"); + + const a = await roh("/workspace/unsere-seiten.html", CREW, { cookie: kGast }); + ok(a.code === 200, `ein Mitglied bekommt die Seite (${a.code})`); + ok(a.text.includes("DOGI10"), "der Rabattcode steht darin"); + ok(a.text.includes("vans-diy-bastelbedarf.com"), "die Adresse des Shops steht darin"); + ok(a.text.includes("dogfather-universe.com"), "die Adresse der Website steht darin"); + + /* KEIN PERSOENLICHER CODE AUF EINER SEITE FUER ALLE. In derselben + Datei des Shops steht ein zweiter, der ausdruecklich „Nur fuer + Filipe persoenlich" ist. Stuende er hier, waere er binnen eines + Tages in jedem Gruppenchat. */ + ok(!/FILIPE\d/i.test(a.text), + "der persoenliche Partnercode steht NICHT darin"); + + const b = await roh("/workspace/unsere-seiten.html", CREW, { cookie: kDogi }); + ok(b.code === 200, `DogFather auch (${b.code})`); + + /* GEGENPROBE -- ohne sie beweist „200“ nichts. Die Agentur hat mit + dem Treff nichts zu tun. */ + const c = await roh("/workspace/unsere-seiten.html", CREW, { cookie: kCreator }); + ok(c.code === 403 || c.code === 302, + `ein Creator kommt NICHT hinein (${c.code})`); + + const d = await roh("/workspace/unsere-seiten.html", CREW); + ok(d.code === 302 || d.code === 401 || d.code === 403, + `ohne Anmeldung auch nicht (${d.code})`); +} + +/* ======================================================================= + 3. EIN HIGHLIGHT MIT VIDEO -- die Daten dafuer + ======================================================================= */ +melde(""); +melde("=== 3. Drei Karten zum Vergleich ==="); +const VIDEO = "https://www.tiktok.com/@dogfather0804/video/7500000000000000000"; +{ + const d = new DatabaseSync(process.env.WORKSPACE_DB); + const wer = d.prepare("SELECT id FROM personen WHERE name = 'Filipe'").get().id; + const jetzt = new Date().toISOString(); + + /* (a) mit Video -- muss klickbar sein */ + d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, status, erstellt, erstellt_von," + + " quelle_url, quelle_kanal) VALUES (?,?,?,?,?,?,?,?,?,?)") + .run("highlight", "clip", "Wer ist euer Komet", "Schreibt es in die Kommentare", + jetzt.slice(0, 10), "offen", jetzt, wer, VIDEO, "tiktok"); + + /* (b) ohne Video -- darf NICHT klickbar sein */ + d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, status, erstellt, erstellt_von)" + + " VALUES (?,?,?,?,?,?,?,?)") + .run("highlight", "clip", "Nur ein Bild", "Ohne Quelle", + jetzt.slice(0, 10), "offen", jetzt, wer); + + /* (c) Video bei TikTok geloescht -- darf NICHT klickbar sein, sonst + fuehrt der Klick auf eine Fehlerseite und WIR sehen kaputt aus */ + d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, status, erstellt, erstellt_von," + + " quelle_url, quelle_kanal, quelle_fehlt_seit) VALUES (?,?,?,?,?,?,?,?,?,?,?)") + .run("highlight", "clip", "Nicht mehr da", "Wurde geloescht", + jetzt.slice(0, 10), "offen", jetzt, wer, VIDEO, "tiktok", jetzt); + + /* EIN ECHTES BILD AN DIE ERSTE KARTE. + + Filipe hat „foto" ausdrücklich genannt -- es ist die größte Fläche + der Karte und das, worauf ein Daumen zuerst landet. Ohne + angehängtes Bild meldete dieser Abschnitt beim ersten Lauf + „Bildweg nicht gemessen", und damit wäre genau der Teil ungeprüft + geblieben, um den es geht. + + ES MUSS EIN ECHTES PNG SEIN. Die Bildroute entscheidet an den + ersten Bytes der Datei, nicht am behaupteten Typ -- und schlägt + sie fehl, entfernt der Fehlerbehandler im Browser den Rahmen + wieder. Eine leere Datei hätte also dasselbe Loch hinterlassen, + nur unauffälliger. */ + const EIN_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64"); + { + const { mkdirSync, writeFileSync } = await import("node:fs"); + const bildOrdner = join(ordner, "dateien"); + mkdirSync(bildOrdner, { recursive: true }); + writeFileSync(join(bildOrdner, "probe-standbild.png"), EIN_PNG); + const mitVideoId = d.prepare( + "SELECT id FROM eintraege WHERE bereich = 'highlight' AND quelle_url IS NOT NULL" + + " AND quelle_fehlt_seit IS NULL").get().id; + d.prepare("INSERT INTO dateien (name_original, name_datei, groesse, typ, status," + + " hochgeladen_von, erstellt, eintrag_id) VALUES (?,?,?,?,?,?,?,?)") + .run("standbild.png", "probe-standbild.png", EIN_PNG.length, "image/png", + "freigegeben", wer, jetzt, mitVideoId); + } + + /* FREIGEBEN -- sonst sieht ein Mitglied gar nichts. + + Im Treff ist ein Beitrag erst sichtbar, wenn jemand aus dem Team + ihn freigegeben hat (`treff_freigaben`). Beim ersten Lauf hat + genau das gefehlt: Die drei Karten standen in der Datenbank, das + Brett blieb leer, und vier Prüfungen meldeten „0 Karten" -- was wie + ein Fehler am Klickverhalten aussah und keiner war. Wer eine + Oberfläche prüft, muss den Zustand herstellen, in dem sie im + Alltag steht. */ + const wann = new Date().toISOString(); + for (const z of d.prepare("SELECT id FROM eintraege WHERE bereich = 'highlight'").all()) { + d.prepare("INSERT INTO treff_freigaben (eintrag_id, zeitpunkt, von) VALUES (?,?,?)") + .run(z.id, wann, wer); + } + const n = d.prepare("SELECT COUNT(*) AS n FROM eintraege WHERE bereich = 'highlight'").get().n; + const f = d.prepare("SELECT COUNT(*) AS n FROM treff_freigaben").get().n; + d.close(); + ok(n === 3, `drei Karten angelegt: mit Video, ohne Video, geloescht (${n})`); + ok(f === 3, `und alle drei freigegeben, sonst sieht ein Mitglied nichts (${f})`); +} + +/* ======================================================================= + 4. IM BROWSER -- klickt die Flaeche wirklich? + + ALLES DAVOR IST VORARBEIT. Ob eine Karte anklickbar ist, entscheidet + sich nicht im Quelltext, sondern unter einem Finger. + ======================================================================= */ +melde(""); +melde("=== 4. Im Browser ==="); +{ + let pw = null; + try { + pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + } catch { /* dritter Ausgang unten */ } + + if (!pw) { + /* DRITTER AUSGANG: nicht „bestanden“, sondern „konnte nicht + nachsehen" -- und die Zaehlung sagt das auch. */ + melde(" -- Playwright ist nicht da -- im Browser wurde NICHT nachgesehen."); + melde(" Das ist kein Befund. Die Abschnitte 1-3 gelten weiter."); + } else { + const browser = await pw.chromium.launch(); + try { + for (const breite of [1400, 412]) { + melde(""); + melde(` --- Breite ${breite} px ---`); + const ctx = await browser.newContext({ + viewport: { width: breite, height: 950 }, + isMobile: breite < 700, hasTouch: breite < 700 }); + /* ANMELDUNG ALS KEKS, NICHT ÜBER DAS FORMULAR. Der Weg über die + Oberfläche prüft hier nichts, was nicht anderswo schon + geprüft wird -- er bricht nur bei jeder Änderung an der + Anmeldeseite, und dann sieht diese Prüfung kaputt aus, + obwohl an den Wegen nach draußen nichts fehlt. */ + await ctx.addCookies(kGast.split("; ").filter(Boolean).map((z) => { + const [name, ...rest] = z.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + })); + const seite = await ctx.newPage(); + const kaputt = []; + seite.on("pageerror", (e) => kaputt.push(String(e).slice(0, 140))); + + await seite.goto(`http://127.0.0.1:${PORT}/workspace/start.html`, + { waitUntil: "networkidle" }); + await seite.waitForTimeout(900); + + /* ---- 4a. Steht die Kachel wirklich auf dem Bildschirm? ---- */ + const kachel = await seite.evaluate(() => { + const a = [...document.querySelectorAll('a[href*="unsere-seiten"]')][0]; + if (!a) return null; + const r = a.getBoundingClientRect(); + return { text: a.textContent.trim().slice(0, 40), breit: Math.round(r.width), + hoch: Math.round(r.height), sichtbar: r.width > 0 && r.height > 0 }; + }); + ok(!!kachel && kachel.sichtbar, + `die Kachel ist da und sichtbar (${kachel?.breit}x${kachel?.hoch} px)`); + /* GROSS GENUG FUER EINEN DAUMEN. 44 px ist das Mass, unter dem + ein Ziel auf dem Handy regelmaessig verfehlt wird. */ + ok((kachel?.hoch || 0) >= 44, + `und hoch genug zum Antippen (${kachel?.hoch} px, mindestens 44)`); + + /* ---- 4b. Die Seite selbst ---- */ + await seite.goto(`http://127.0.0.1:${PORT}/workspace/unsere-seiten.html`, + { waitUntil: "networkidle" }); + await seite.waitForTimeout(500); + + /* window.open abfangen, damit keine echten Fenster aufgehen -- + und damit messbar wird, WOHIN es gegangen waere. */ + await seite.evaluate(() => { + window.__wohin = []; + window.open = (u) => { window.__wohin.push(String(u)); return null; }; + }); + + const karten = await seite.evaluate(() => + [...document.querySelectorAll(".aussen[data-weg]")].map((k) => k.dataset.weg)); + ok(karten.length === 2, `zwei Karten nach draussen (${karten.length})`); + ok(karten.some((w) => w.includes("dogfather-universe.com")), "eine zur Website"); + ok(karten.some((w) => w.includes("vans-diy-bastelbedarf.com")), "eine zum Shop"); + + /* KLICK AUF DEN TEXT -- nicht auf den Knopf. Genau das war + Filipes Wunsch: „egal wo man drauf drückt“. */ + await seite.click(".aussen--shop .aussen__satz"); + await seite.waitForTimeout(250); + let wohin = await seite.evaluate(() => window.__wohin); + ok(wohin.length === 1 && wohin[0].includes("vans-diy-bastelbedarf"), + `ein Klick auf den TEXT fuehrt zum Shop (${wohin.join(", ") || "nichts"})`); + + /* GEGENPROBE: Der Kopierknopf darf NICHT zusaetzlich die Karte + ausloesen. Ohne diese Zeile waere „die Flaeche klickt“ auch + dann gruen, wenn sie ueberall klickt -- auch dort, wo es + stoert. */ + await seite.evaluate(() => { window.__wohin = []; }); + await seite.click("#code-knopf"); + await seite.waitForTimeout(250); + wohin = await seite.evaluate(() => window.__wohin); + ok(wohin.length === 0, + `der Kopierknopf oeffnet NICHTS (${wohin.length} Fenster)`); + + const hinweis = await seite.textContent("#code-hinweis"); + ok(/kopiert|markiert|abschreiben/.test(hinweis || ""), + `und sagt, was er getan hat: ${(hinweis || "").trim()}`); + + /* GEGENPROBE 2: Der eigene Knopf oeffnet genau EINMAL -- nicht + zweimal, weil die Karte mitklickt. */ + await seite.evaluate(() => { + window.__wohin = []; + /* Der Knopf ist ein echter Link; sein Standardverhalten + unterbinden, sonst navigiert die Prueferin weg. */ + for (const a of document.querySelectorAll(".aussen__knopf")) { + a.addEventListener("click", (e) => { + e.preventDefault(); + window.__wohin.push(a.href); + }); + } + }); + await seite.click(".aussen--dogi .aussen__knopf"); + await seite.waitForTimeout(250); + wohin = await seite.evaluate(() => window.__wohin); + ok(wohin.length === 1, + `der Knopf „Seite oeffnen“ wirkt genau einmal (${wohin.length}x)`); + + /* ---- 4c. Die Highlight-Karten ---- */ + await seite.goto(`http://127.0.0.1:${PORT}/workspace/bereich.html?b=highlight`, + { waitUntil: "networkidle" }); + await seite.waitForTimeout(1200); + await seite.evaluate(() => { + window.__wohin = []; + window.open = (u) => { window.__wohin.push(String(u)); return null; }; + }); + + const stand = await seite.evaluate(() => + [...document.querySelectorAll(".eintrag-karte")].map((k) => ({ + titel: k.querySelector(".eintrag-titel")?.textContent?.trim() || "", + klickbar: k.dataset.klickbar === "ja", + }))); + ok(stand.length === 3, `drei Karten stehen da (${stand.length})`); + + const mitVideo = stand.find((s) => s.titel.includes("Komet")); + const ohneVideo = stand.find((s) => s.titel.includes("Nur ein Bild")); + const fort = stand.find((s) => s.titel.includes("Nicht mehr da")); + + ok(mitVideo?.klickbar === true, "die Karte MIT Video ist klickbar"); + /* DIE BEIDEN GEGENPROBEN. Ohne sie waere „klickbar“ auch dann + gruen, wenn ALLE Karten klicken -- und ein Klick auf ein + geloeschtes Video fuehrt auf eine Fehlerseite. */ + ok(ohneVideo?.klickbar === false, "die Karte OHNE Video ist es nicht"); + ok(fort?.klickbar === false, + "und die mit geloeschtem Video auch nicht (sonst Fehlerseite)"); + + /* Klick auf die Ueberschrift -- „egal wo“. */ + const titelWahl = ".eintrag-karte[data-klickbar='ja'] .eintrag-titel"; + await seite.click(titelWahl); + await seite.waitForTimeout(250); + wohin = await seite.evaluate(() => window.__wohin); + ok(wohin.length === 1 && wohin[0] === VIDEO, + `ein Klick auf die UEBERSCHRIFT fuehrt zum Video (${wohin[0] || "nichts"})`); + + /* Und auf den Fliesstext. */ + await seite.evaluate(() => { window.__wohin = []; }); + const textWahl = ".eintrag-karte[data-klickbar='ja'] .eintrag-text"; + if (await seite.$(textWahl)) { + await seite.click(textWahl); + await seite.waitForTimeout(250); + wohin = await seite.evaluate(() => window.__wohin); + ok(wohin.length === 1 && wohin[0] === VIDEO, + `und ein Klick auf den TEXT ebenso (${wohin[0] || "nichts"})`); + } else { + ok(false, "der Fliesstext der Karte liess sich nicht finden"); + } + + /* GEGENPROBE: Auf der NICHT klickbaren Karte passiert nichts. */ + await seite.evaluate(() => { window.__wohin = []; }); + const stiller = ".eintrag-karte:not([data-klickbar='ja']) .eintrag-titel"; + await seite.click(stiller); + await seite.waitForTimeout(250); + wohin = await seite.evaluate(() => window.__wohin); + ok(wohin.length === 0, + `auf einer Karte ohne Video passiert NICHTS (${wohin.length})`); + + /* DER TIKTOK-KNOPF MUSS WEITER EIN LINK SEIN. Waere er von + einem umschlossen worden, haette der Browser ihn still + aufgeloest -- die haeufigste Art, eine Karte kaputtzumachen. */ + const knopf = await seite.evaluate(() => { + const a = document.querySelector(".eintrag-karte[data-klickbar='ja'] .videozeile__weg"); + return a ? { tag: a.tagName, href: a.getAttribute("href"), + imLink: !!a.closest("a a") } : null; + }); + ok(knopf?.tag === "A" && knopf.href === VIDEO, + `der TikTok-Knopf ist noch ein echter Link (${knopf?.tag})`); + ok(knopf?.imLink === false, "und steckt in keinem zweiten Link"); + + /* DAS BILD FUEHRT ZUM VIDEO, nicht mehr ins Vollbild -- das war + Filipes „foto“. Ohne Bilddatei gibt es keinen Bildstreifen; + dann ist hier nichts zu messen, und das muss man sehen. */ + const bild = await seite.evaluate(() => { + const a = document.querySelector(".eintrag-karte[data-klickbar='ja'] .bildstreifen__feld"); + return a ? { href: a.getAttribute("href"), label: a.getAttribute("aria-label") } : null; + }); + if (bild) { + ok(bild.href === VIDEO, `das Bild fuehrt zum Video (${bild.href})`); + ok(/Video ansehen/.test(bild.label || ""), + `und sagt das auch vorlesbar („${bild.label}“)`); + } else { + /* KEIN STILLES UEBERSPRINGEN. Das Bild ist angelegt; fehlt der + Streifen trotzdem, ist etwas kaputt -- entweder lud das + Bild nicht (dann entfernt der Fehlerbehandler den Rahmen) + oder die Karte zeigt gar keine Anhaenge mehr. Beides ist + ein Befund, kein Grund zu schweigen. */ + ok(false, "der Bildstreifen fehlt, obwohl ein Bild angehaengt ist"); + } + + ok(kaputt.length === 0, + `kein Skriptfehler auf dem Weg${kaputt.length ? ": " + kaputt.join(" | ") : ""}`); + await ctx.close(); + } + } finally { + await browser.close(); + } + } +} + +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest */ } +process.exit(fehler ? 1 : 0); diff --git a/server/rechte.js b/server/rechte.js index d7230d2b..7f002f2e 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -351,6 +351,20 @@ export const SEITEN = { sondern eine Einladung zur Vergeltung. */ "/workspace/treff-moderation.html": ["modi", "hand", "admin"], + /* UNSERE SEITEN (19.09.2026). Die Website und VanVans Shop, mit dem + Rabattcode fuer die Community. + + GENAU DIE VIER ROLLEN DES TREFFS -- also MIT "gast", anders als die + Moderationsseite eine Zeile darueber. Sie ist ausdruecklich FUER die + Community gebaut; ohne "gast" waere die Kachel bei jedem Mitglied + sichtbar und beim Antippen zu. Genau diese Sorte toter Weg hat die + Sichtpruefung am 18.09. zehnmal gefunden. + + Die Agentur steht bewusst nicht dabei (siehe TREFF_ROLLEN in + workspace.js): Creator, Scouts und Spicy Media haben mit dem Treff + nichts zu tun. */ + "/workspace/unsere-seiten.html": ["gast", "modi", "hand", "admin"], + /* WER SIEHT WAS. DogFather und die rechte Hand (Filipe, 11.09.2026: "ich will die kategorie auch auf der team dogi seite fuer die dogfather und rechte hand rollen"). diff --git a/server/workspace.js b/server/workspace.js index 62fcedba..941115b0 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -938,6 +938,28 @@ const TREFF_BEREICHE = [ zeichen: "schutz", ton: 28, ziel: "bereich.html?b=regeln", szene: "studio" }, { ...TREFF_GRUPPE, name: "Mitmachen", unter: "Wie du dazugeh\u00f6ren kannst", zeichen: "personen", ton: 32, ziel: "bereich.html?b=mitmachen", szene: "garage" }, + /* UNSERE SEITEN (19.09.2026) -- die einzige Kachel, die HINAUSFUEHRT. + + Filipe: "ich brauch im community bereich noch eine kachel, die wenn + man drauf dr\u00fcckt dan kacheln hat die auf die website von dogfather + universe geht ... und die shop site von vanvan." + + SIE IST KEIN BRETT, sondern eine Seite -- deshalb ein Dateiname als + Ziel und kein "bereich.html?b=...". Die Ableitung von TREFF_BRETTER + weiter unten nimmt sie dadurch von selbst nicht mit, genau wie die + Moderationskachel. Eine zweite Liste, die man pflegen muesste, + entsteht nicht. + + UND SIE STEHT AM ENDE. Die sieben davor fuehren nach innen: lesen, + schreiben, mitmachen. Diese eine verlaesst den Treff -- das ist der + letzte Schritt, nicht der erste. + + RASTERRECHNUNG (drei Spalten, "Der Treff" belegt zwei): Mit dieser + Kachel sind es fuer die Community 7 + 1 Kacheln = 9 Plaetze, also + genau drei volle Reihen. Vorher waren es acht Plaetze und damit + eine Luecke am Ende. Sie passt also nicht nur inhaltlich. */ + { ...TREFF_GRUPPE, name: "Unsere Seiten", unter: "Website, Shop & dein Rabatt", + zeichen: "hinaus", ton: 38, ziel: "unsere-seiten.html", szene: "portal" }, ]; /* MELDUNGEN & MASSNAHMEN -- FUER DAS TEAM, NICHT FUER DIE COMMUNITY diff --git a/tools/turn-einrichten.mjs b/tools/turn-einrichten.mjs index e4d52f86..fef16130 100644 --- a/tools/turn-einrichten.mjs +++ b/tools/turn-einrichten.mjs @@ -30,7 +30,8 @@ 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"; +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 @@ -188,6 +189,61 @@ for (const a of adressen) { 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."); @@ -201,4 +257,24 @@ if (versucht && erreichbar < versucht) { console.log(`ERGEBNIS: ${erreichbar} von ${versucht} Adressen antworten.`); process.exit(1); } -console.log("ERGEBNIS: Eingetragen, Geheimnis lesbar, Server antwortet."); +/* 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)."); diff --git a/tools/turn-sprache.mjs b/tools/turn-sprache.mjs new file mode 100644 index 00000000..ae39a1dd --- /dev/null +++ b/tools/turn-sprache.mjs @@ -0,0 +1,257 @@ +/* ===================================================================== + 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 */ } + } +} diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 0cb6eb64..5e2c28be 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -5,9 +5,9 @@ Anruf-Probe · Team Dogi - - - + + +