Damit Anrufe auch in Netzen zustande kommen, die keine direkte Verbindung zulassen (15-25 % der Faelle). coturn ist installiert, steht aber still, bis die Konfiguration liegt -- ein coturn mit Werkseinstellung ist ein offenes Relais. KEIN FESTES PASSWORT. Es laege dauerhaft im Browser jedes Team-Mitglieds und liesse sich nie entziehen. Der Server rechnet stattdessen bei jeder Abfrage Zugangsdaten, die nach zwoelf Stunden verfallen (server/workspace-turn.js, coturns `use-auth-secret`). Das Geheimnis liegt in einer Datei, nicht in der Datenbank: einstellung- Setzen() schreibt Werte ins Protokoll, und coturn braucht denselben Wert ohnehin in /etc. Die Oberflaeche frischt die Daten vor jedem Anruf auf. Der Chat ist eine App, die tagelang offen bleibt -- wer nur beim Laden holt, telefoniert am zweiten Tag ohne Vermittlung, und es faellt nicht auf: Es scheitern nur die, die sie gebraucht haetten. DIE WICHTIGSTE ZEILE DER KONFIGURATION ist die Sperrliste. Gemessen: dreizehn Dienste lauschen auf diesem Server nur oertlich, darunter Caddys Verwaltung auf 127.0.0.1:2019 -- wer sie erreicht, kann jede Website umleiten. Ohne Sperrliste waere der Vermittlungsserver die Tuer dorthin, und die Anfrage saehe fuer Caddy aus wie von localhost. Geprueft: pruef-anruf.mjs 51 -> 73 Pruefungen. Gegenprobe (Geheimnis als Passwort ausliefern + fremde Zugangsdaten ueberschreiben) macht genau 5 rot, darunter "das Geheimnis steht NIRGENDS in der Antwort". tools/turn-probelauf.sh beweist am echten coturn, was ein fester Vergleichswert nicht kann: dass coturn unsere Rechnung akzeptiert. Beide Seiten koennten sonst konsequent falsch rechnen und jede Pruefung waere gruen. Seine eigene Gegenprobe war zweimal zu Recht rot -- der erste Aufbau mass wegen `-y` gar nicht das Ziel, das er zu messen behauptete, sondern coturns eingebauten Loopback-Schutz. Co-Authored-By: Claude Opus 5 <[email protected]>
661 lines
29 KiB
JavaScript
661 lines
29 KiB
JavaScript
/* =====================================================================
|
|
TELEFONIEREN IM CHAT -- die Riegel (18.09.2026)
|
|
|
|
Filipe: „kann man machen dass die modis, rechte hand und ich auch
|
|
telefonieren können im chat?"
|
|
|
|
DER WICHTIGSTE ABSCHNITT IST DER LETZTE. Ein Anruf ist der
|
|
vertraulichste Vorgang im ganzen Haus: Was gesagt wird, steht
|
|
nirgends, und wer mithört, hinterlässt keine Spur. Die Frage „kommt
|
|
jemand hinein, der nicht in den Raum gehört?" muss deshalb an JEDER
|
|
Route beantwortet sein und nicht an der Liste davor.
|
|
|
|
Besonders die Signalisierung: Sie reicht beliebiges JSON an eine
|
|
Personennummer weiter. Ohne Prüfung wäre das ein Weg, jedem
|
|
Angemeldeten etwas zu schicken -- an jeder Chatberechtigung vorbei.
|
|
|
|
Aufrufen mit: node server/pruef-anruf.mjs
|
|
===================================================================== */
|
|
import { mkdtempSync, rmSync, writeFileSync, unlinkSync } 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-anruf");
|
|
const ordner = mkdtempSync(join(tmpdir(), "ws-anruf-"));
|
|
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";
|
|
/* Der Vermittlungsserver holt sein Geheimnis aus einer Datei. Hier muss
|
|
der Pfad UMGEBOGEN werden, bevor index.js geladen wird -- sonst
|
|
zeigte er auf die echte Datei unter /etc, und die Pruefung wuerde
|
|
entweder am echten Betrieb messen oder gar nichts sehen koennen. */
|
|
process.env.TURN_GEHEIMNIS_DATEI = join(ordner, "turn.geheimnis");
|
|
|
|
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(180_000, "pruef-anruf");
|
|
await new Promise((r) => setTimeout(r, 800));
|
|
|
|
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
|
|
|
|
let fehler = 0, geprueft = 0, offen = 0;
|
|
const melde = (t) => console.log(t);
|
|
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
|
const unklar = (t) => { offen++; console.log(" -- " + t); };
|
|
|
|
const CREW = "crew.dogfather-universe.com";
|
|
const HAUS = "workspace.dogfather-universe.com";
|
|
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const { scryptSync, randomBytes } = await import("node:crypto");
|
|
const anrufModul = await import("./workspace-anruf.js");
|
|
|
|
/* ---- Leute anlegen ---- */
|
|
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
|
function person(name, rolle, code) {
|
|
const salt = randomBytes(16).toString("hex");
|
|
const hash = scryptSync(code, salt, 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, salt, 32768, new Date().toISOString());
|
|
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
|
}
|
|
const idDogi = person("Filipe", "admin", "CODE-DOGI-0001");
|
|
const idHand = person("Rieke", "hand", "CODE-HAND-0001");
|
|
const idModi = person("Kessi", "modi", "CODE-MODI-0001");
|
|
const idFremd = person("Tili", "creator", "CODE-TILI-0001");
|
|
d.close();
|
|
|
|
function roh(pfad, host, kopf = {}, koerper = null, methode = null) {
|
|
return new Promise((fertig, schief) => {
|
|
const a = httpAnfrage({
|
|
host: "127.0.0.1", port: PORT, path: pfad,
|
|
method: methode || (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 json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } };
|
|
async function anmelden(rolle, code, host) {
|
|
const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code });
|
|
return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
|
|
}
|
|
const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS);
|
|
const kHand = await anmelden("hand", "CODE-HAND-0001", CREW);
|
|
const kModi = await anmelden("modi", "CODE-MODI-0001", CREW);
|
|
const kFremd = await anmelden("creator", "CODE-TILI-0001", HAUS);
|
|
ok(!!kDogi && !!kHand && !!kModi && !!kFremd, "vier Leute sind angemeldet");
|
|
|
|
/* =======================================================================
|
|
1. DER KATALOG
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 1. Was fest steht ===");
|
|
ok(anrufModul.GRUPPE_MAX >= 2 && anrufModul.GRUPPE_MAX <= 8,
|
|
`die Gruppengrenze steht als Zahl im Code (${anrufModul.GRUPPE_MAX})`);
|
|
|
|
/* =======================================================================
|
|
2. EIN GESPRAECH UND EIN ANRUF
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 2. Anrufen ===");
|
|
|
|
/* Ein Zweier-Gespraech zwischen DogFather und der rechten Hand. */
|
|
let raumId = null;
|
|
{
|
|
/* Die Route heisst `direkt`, nicht `mit` -- nachgesehen statt
|
|
geraten. */
|
|
const r = await roh("/workspace/api/chat/direkt", HAUS, { cookie: kDogi },
|
|
{ mit: idHand });
|
|
raumId = json(r).raum?.id || json(r).raum_id || json(r).id;
|
|
ok(!!raumId, `ein Gespraech entsteht (${r.code}, Raum ${raumId})`);
|
|
}
|
|
|
|
{
|
|
const r = await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi });
|
|
ok(r.code === 200 && json(r).laeuft === false,
|
|
`am Anfang laeuft kein Anruf (${json(r).laeuft})`);
|
|
}
|
|
|
|
{
|
|
const r = await roh(`/workspace/api/anruf/${raumId}/start`, HAUS,
|
|
{ cookie: kDogi }, { video: false });
|
|
ok(r.code === 200, `anrufen geht (${r.code})`);
|
|
const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
|
|
ok(l.laeuft === true, "und danach laeuft einer");
|
|
ok(l.wer?.length === 1 && l.wer[0].id === idDogi,
|
|
`mit genau einem Teilnehmer (${l.wer?.map((w) => w.name).join(", ")})`);
|
|
}
|
|
|
|
{
|
|
const r = await roh(`/workspace/api/anruf/${raumId}/dabei`, CREW, { cookie: kHand }, {}, "POST");
|
|
ok(r.code === 200, `die rechte Hand geht ran (${r.code})`);
|
|
const l = json(await roh(`/workspace/api/anruf/${raumId}`, CREW, { cookie: kHand }));
|
|
ok(l.wer?.length === 2, `jetzt sind es zwei (${l.wer?.map((w) => w.name).join(", ")})`);
|
|
ok(l.ich_dabei === true, "und sie weiss, dass sie dabei ist");
|
|
}
|
|
|
|
/* =======================================================================
|
|
3. DIE SIGNALISIERUNG
|
|
|
|
Sie reicht beliebiges JSON an eine Personennummer weiter. Ohne
|
|
Pruefung waere das ein Weg, jedem Angemeldeten etwas zu schicken --
|
|
an jeder Chatberechtigung vorbei.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 3. Die Signalisierung ===");
|
|
{
|
|
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi },
|
|
{ an: idHand, inhalt: { angebot: { type: "offer", sdp: "v=0" } } });
|
|
ok(r.code === 200, `ein Angebot geht durch (${r.code})`);
|
|
}
|
|
{
|
|
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi },
|
|
{ an: idFremd, inhalt: { angebot: {} } });
|
|
ok(r.code === 404,
|
|
`an jemanden AUSSERHALB des Raums geht nichts (${r.code}) -- sonst waere das `
|
|
+ "ein Weg, jedem Angemeldeten JSON zu schicken");
|
|
}
|
|
{
|
|
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi },
|
|
{ an: idHand });
|
|
ok(r.code === 400, `ohne Inhalt wird abgewiesen (${r.code})`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
4. WER NICHT HINEINGEHOERT
|
|
|
|
Der wichtigste Abschnitt. Ein Anruf hinterlaesst keine Spur -- wer
|
|
mithoert, faellt nicht auf. Die Frage muss an JEDER Route
|
|
beantwortet sein.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 4. Wer nicht hineingehoert ===");
|
|
for (const [pfad, methode, koerper] of [
|
|
["", "GET", null],
|
|
["/start", "POST", { video: false }],
|
|
["/dabei", "POST", {}],
|
|
["/ende", "POST", {}],
|
|
["/signal", "POST", { an: idDogi, inhalt: { weg: {} } }],
|
|
]) {
|
|
const r = await roh(`/workspace/api/anruf/${raumId}${pfad}`, HAUS,
|
|
{ cookie: kFremd }, koerper, methode);
|
|
ok(r.code === 404,
|
|
`${(pfad || "/lage").padEnd(8)} ist fuer Fremde zu -- und zwar 404, `
|
|
+ `nicht „kein Zugriff" (${r.code})`);
|
|
}
|
|
{
|
|
const r = await roh(`/workspace/api/anruf/${raumId}`, HAUS, {});
|
|
ok(r.code === 401, `ohne Anmeldung gar nichts (${r.code})`);
|
|
}
|
|
{
|
|
const r = await roh("/workspace/api/anruf/999999/start", HAUS,
|
|
{ cookie: kDogi }, { video: false });
|
|
ok(r.code === 404, `ein erfundener Raum wird abgewiesen (${r.code})`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
5. AUFLEGEN
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 5. Auflegen ===");
|
|
{
|
|
const r = await roh(`/workspace/api/anruf/${raumId}/ende`, CREW, { cookie: kHand }, {}, "POST");
|
|
ok(r.code === 200, `die rechte Hand legt auf (${r.code})`);
|
|
const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
|
|
ok(l.laeuft === true && l.wer?.length === 1,
|
|
`der Anruf laeuft weiter, solange noch jemand drin ist (${l.wer?.length})`);
|
|
await roh(`/workspace/api/anruf/${raumId}/ende`, HAUS, { cookie: kDogi }, {}, "POST");
|
|
const l2 = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
|
|
ok(l2.laeuft === false, "wenn der Letzte geht, ist er vorbei");
|
|
}
|
|
|
|
/* =======================================================================
|
|
6. DIE ADRESSEN
|
|
|
|
Sie enthalten Zugangsdaten (TURN braucht Benutzer und Passwort).
|
|
Deshalb darf nur DogFather sie setzen.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 6. Die Verbindungsadressen ===");
|
|
{
|
|
const r = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
|
|
ok(Array.isArray(r.adressen), `die Liste kommt (${r.adressen?.length} Eintraege)`);
|
|
ok(r.nur_gleiches_netz === true,
|
|
"und sie sagt ehrlich, dass es ohne Eintrag nur im selben Netz geht");
|
|
}
|
|
{
|
|
const r = await roh("/workspace/api/anruf/adressen", CREW, { cookie: kHand },
|
|
{ adressen: [{ urls: "stun:beispiel.de:3478" }] }, "PUT");
|
|
ok(r.code === 404, `die rechte Hand darf sie nicht setzen (${r.code})`);
|
|
}
|
|
{
|
|
const r = await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }, {
|
|
adressen: [
|
|
{ urls: "stun:turn.dogfather-universe.com:3478" },
|
|
{ urls: "turn:turn.dogfather-universe.com:3478", username: "a", credential: "b" },
|
|
/* DAS HIER MUSS RAUSFALLEN: kein STUN/TURN, sondern irgendetwas. */
|
|
{ urls: "https://beispiel.de/boese" },
|
|
],
|
|
}, "PUT");
|
|
ok(r.code === 200 && json(r).anzahl === 2,
|
|
`DogFather setzt sie, und nur echte Adressen bleiben (${json(r).anzahl} von 3)`);
|
|
const l = json(await roh("/workspace/api/anruf/adressen", CREW, { cookie: kModi }));
|
|
ok(l.adressen?.length === 2, `alle bekommen sie zu sehen (${l.adressen?.length})`);
|
|
ok(l.nur_gleiches_netz === false, "und der Hinweis verschwindet");
|
|
|
|
/* UND WIEDER WEG. Die Adressen oben sind erfunden -- es gibt unter
|
|
`turn.dogfather-universe.com` keinen STUN-Dienst. Ein Browser
|
|
versucht sie trotzdem zu erreichen und wartet auf das Zeitlimit;
|
|
der Dreier-Test unten haette danach gegen diese Wartezeit
|
|
gekaempft statt gegen den Code.
|
|
|
|
Eine Pruefung, die der naechsten den Boden verstellt, macht deren
|
|
Ergebnis unlesbar -- genau das ist heute schon einmal passiert
|
|
(die Anfragen, Abschnitt 8b). */
|
|
await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi },
|
|
{ adressen: [] }, "PUT");
|
|
const zurueck = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
|
|
ok(zurueck.adressen?.length === 0,
|
|
"danach ist die Liste wieder leer -- der naechste Abschnitt faengt sauber an");
|
|
}
|
|
|
|
/* =======================================================================
|
|
6b. DIE ZUGANGSDATEN ZUM VERMITTLUNGSSERVER
|
|
|
|
Hier steht die Frage, an der sich entscheidet, ob coturn ein Dienst
|
|
fuer uns ist oder eine offene Weiterleitung fuer jeden: Das
|
|
Geheimnis, aus dem die Zugangsdaten gerechnet werden, darf den
|
|
Browser NIE erreichen. Wer es haette, koennte sich selbst beliebig
|
|
viele Zugaenge ausstellen -- dauerhaft, ohne Konto, ueber unsere
|
|
IP-Adresse.
|
|
|
|
Deshalb wird hier nicht nur geprueft, dass Zugangsdaten ANKOMMEN,
|
|
sondern vor allem, dass das Geheimnis es NICHT tut -- und zwar im
|
|
rohen Antworttext, nicht im ausgewerteten Objekt. Ein Feld, an das
|
|
niemand denkt, faellt beim Auswerten nicht auf.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 6b. Zugangsdaten fuer die Vermittlung ===");
|
|
{
|
|
const turn = await import("./workspace-turn.js");
|
|
const datei = process.env.TURN_GEHEIMNIS_DATEI;
|
|
/* Erfunden, nur fuer diesen Lauf, und am Ende wieder weg. Lang genug,
|
|
dass die Laengenpruefung nicht zufaellig anschlaegt. */
|
|
const GEHEIM = "pruef-geheimnis-nur-fuer-diesen-lauf-0123456789";
|
|
|
|
/* --- Die drei Ausgaenge beim Lesen ------------------------------- */
|
|
{
|
|
const g = turn.turnGeheimnisLesen(datei);
|
|
ok(g.wert === "" && g.grund === "fehlt",
|
|
`ohne Datei sagt es "fehlt" statt stillschweigend nichts (${g.grund})`);
|
|
}
|
|
{
|
|
writeFileSync(datei, "zu-kurz");
|
|
const g = turn.turnGeheimnisLesen(datei);
|
|
ok(g.wert === "" && g.grund === "zu_kurz",
|
|
`ein zu kurzes Geheimnis wird abgelehnt, nicht benutzt (${g.grund})`);
|
|
}
|
|
{
|
|
writeFileSync(datei, " \n");
|
|
const g = turn.turnGeheimnisLesen(datei);
|
|
ok(g.wert === "" && g.grund === "leer", `eine leere Datei meldet "leer" (${g.grund})`);
|
|
}
|
|
{
|
|
writeFileSync(datei, GEHEIM + "\n");
|
|
const g = turn.turnGeheimnisLesen(datei);
|
|
ok(g.wert === GEHEIM && g.grund === "",
|
|
"mit Datei kommt das Geheimnis an (Zeilenumbruch abgeschnitten)");
|
|
}
|
|
|
|
/* --- Ein WECHSEL muss auffallen ---------------------------------- */
|
|
{
|
|
writeFileSync(datei, GEHEIM + "-zweite-fassung-mit-anderer-laenge");
|
|
const g = turn.turnGeheimnisLesen(datei);
|
|
ok(g.wert.endsWith("-zweite-fassung-mit-anderer-laenge"),
|
|
"wird die Datei getauscht, wird der neue Wert gelesen (kein Festhaengen)");
|
|
writeFileSync(datei, GEHEIM + "\n");
|
|
}
|
|
|
|
/* --- Die Rechnung ------------------------------------------------ */
|
|
{
|
|
/* Eine FESTE Zeit als Eingabe, nicht die Wanduhr: Sonst waere die
|
|
Pruefung eine Zeitbombe, die irgendwann etwas anderes misst. */
|
|
const JETZT = 1_700_000_000_000;
|
|
const z = turn.turnZugang("w7", GEHEIM, JETZT);
|
|
const [ablaufStr, kennung] = z.username.split(":");
|
|
ok(kennung === "w7", `im Benutzernamen steht die Kennung (${kennung})`);
|
|
ok(Number(ablaufStr) === Math.floor(JETZT / 1000) + turn.TURN_GUELTIG_STUNDEN * 3600,
|
|
`und davor der Ablauf, genau ${turn.TURN_GUELTIG_STUNDEN} Stunden spaeter`);
|
|
|
|
/* FESTER VERGLEICHSWERT, ausgerechnet und nicht behauptet. Er
|
|
beweist NICHT, dass coturn ihn akzeptiert -- das beweist nur ein
|
|
Versuch am laufenden coturn (turnutils_uclient). Er beweist,
|
|
dass sich die Rechenvorschrift nicht unbemerkt aendert; genau
|
|
das wuerde jeden Anruf ueber die Vermittlung scheitern lassen,
|
|
ohne dass irgendwo ein Fehler steht. */
|
|
ok(z.credential === "9AbMBJeTDL6uebcR6KRpYVTFwt8=",
|
|
`das Passwort ist die bekannte HMAC-SHA1-Rechnung (${z.credential})`);
|
|
|
|
const nochmal = turn.turnZugang("w7", GEHEIM, JETZT);
|
|
ok(nochmal.credential === z.credential, "dieselbe Eingabe ergibt dasselbe Passwort");
|
|
const anderer = turn.turnZugang("w8", GEHEIM, JETZT);
|
|
ok(anderer.credential !== z.credential,
|
|
"eine andere Person bekommt ein anderes Passwort");
|
|
const spaeter = turn.turnZugang("w7", GEHEIM, JETZT + 60_000);
|
|
ok(spaeter.credential !== z.credential,
|
|
"und eine Minute spaeter ein anderes -- die Daten verfallen wirklich");
|
|
}
|
|
|
|
/* --- Wer bekommt Zugangsdaten und wer nicht ---------------------- */
|
|
{
|
|
const liste = [
|
|
{ urls: "stun:turn.dogfather-universe.com:3478" },
|
|
{ urls: "turn:turn.dogfather-universe.com:3478" },
|
|
{ urls: "turn:fremder-dienst.example:3478", username: "fremd", credential: "eigenes" },
|
|
];
|
|
const e = turn.mitZugangsdaten(liste, "w7", 1_700_000_000_000, datei);
|
|
ok(!e.adressen[0].credential, "ein STUN-Eintrag bekommt keine Zugangsdaten");
|
|
ok(!!e.adressen[1].credential && e.adressen[1].username.includes(":w7"),
|
|
"unser TURN-Eintrag bekommt gerechnete");
|
|
ok(e.adressen[2].credential === "eigenes" && e.adressen[2].username === "fremd",
|
|
"ein fremder Dienst behaelt seine eigenen -- wir ueberschreiben nichts");
|
|
ok(e.turn_gesamt === 2 && e.turn_bereit === 2,
|
|
`und die Zahlen stimmen (${e.turn_bereit} von ${e.turn_gesamt} bereit)`);
|
|
}
|
|
{
|
|
/* OHNE Geheimnis: Der Eintrag bleibt stehen, aber es wird gesagt. */
|
|
unlinkSync(datei);
|
|
const e = turn.mitZugangsdaten(
|
|
[{ urls: "turn:turn.dogfather-universe.com:3478" }], "w7", Date.now(), datei);
|
|
ok(e.turn_gesamt === 1 && e.turn_bereit === 0 && e.grund === "fehlt",
|
|
`ohne Geheimnis ist nichts bereit, und der Grund steht dabei (${e.grund})`);
|
|
writeFileSync(datei, GEHEIM + "\n");
|
|
}
|
|
|
|
/* --- Und jetzt ueber die Schnittstelle, wie der Browser sie sieht - */
|
|
{
|
|
await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }, {
|
|
adressen: [{ urls: "turn:turn.dogfather-universe.com:3478" }],
|
|
}, "PUT");
|
|
const a = await roh("/workspace/api/anruf/adressen", CREW, { cookie: kModi });
|
|
const r = json(a);
|
|
|
|
ok(r.turn_gesamt === 1 && r.turn_bereit === 1,
|
|
`der Modi bekommt einen einsatzbereiten Eintrag (${r.turn_bereit}/${r.turn_gesamt})`);
|
|
ok(!!r.adressen?.[0]?.credential && !!r.adressen?.[0]?.username,
|
|
"mit Benutzername und Passwort");
|
|
|
|
/* DIE WICHTIGSTE PRUEFUNG DES ABSCHNITTS. Im ROHEN Text gesucht,
|
|
nicht im ausgewerteten Objekt. */
|
|
ok(!a.text.includes(GEHEIM),
|
|
"und das Geheimnis selbst steht NIRGENDS in der Antwort");
|
|
|
|
/* Jeder bekommt EIGENE Zugangsdaten -- im coturn-Protokoll steht
|
|
damit die Nummer, nicht ein Sammelkonto. */
|
|
const rDogi = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
|
|
ok(rDogi.adressen?.[0]?.username !== r.adressen?.[0]?.username,
|
|
"DogFather und der Modi bekommen verschiedene Zugangsdaten");
|
|
|
|
/* Der Ablauf muss fuer den Browser LESBAR sein -- darauf beruht das
|
|
Auffrischen vor jedem Anruf in anruf.js. */
|
|
const ablauf = Number(String(r.adressen?.[0]?.username || "").split(":")[0]);
|
|
ok(ablauf > Math.floor(Date.now() / 1000) + 3600,
|
|
`der Ablauf steht vorn im Benutzernamen und liegt in der Zukunft (${ablauf})`);
|
|
}
|
|
|
|
/* --- Aufraeumen: der naechste Abschnitt faengt sauber an ---------- */
|
|
{
|
|
await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi },
|
|
{ adressen: [] }, "PUT");
|
|
try { unlinkSync(datei); } catch { /* schon weg */ }
|
|
const zurueck = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
|
|
ok(zurueck.adressen?.length === 0 && !zurueck.turn_grund,
|
|
"danach ist wieder alles leer -- keine erfundene Adresse bleibt stehen");
|
|
}
|
|
}
|
|
|
|
/* =======================================================================
|
|
7. IM BROWSER
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 7. Im Browser ===");
|
|
{
|
|
let chromium;
|
|
try {
|
|
({ chromium } = await import(
|
|
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"));
|
|
} catch { chromium = null; }
|
|
|
|
if (!chromium) {
|
|
unklar("kein Playwright gefunden -- die Oberflaeche wurde nicht angesehen");
|
|
} else {
|
|
const browser = await chromium.launch({
|
|
/* Mikrofon ohne Rueckfrage und mit einem Testton statt echter
|
|
Hardware -- sonst haengt getUserMedia im Prueflauf. */
|
|
args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
|
|
});
|
|
try {
|
|
const sicht = await browser.newContext({
|
|
viewport: { width: 1280, height: 900 },
|
|
permissions: ["microphone", "camera"],
|
|
});
|
|
await sicht.addCookies(kDogi.split("; ").map((c) => {
|
|
const [name, ...rest] = c.split("=");
|
|
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
|
|
}));
|
|
const seite = await sicht.newPage();
|
|
const konsole = [];
|
|
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
|
|
await seite.goto(`http://127.0.0.1:${PORT}/workspace/chat.html`,
|
|
{ waitUntil: "domcontentloaded" });
|
|
await seite.waitForTimeout(1800);
|
|
|
|
ok(await seite.evaluate(() => typeof window.anrufStarten === "function"),
|
|
"der Anruf ist geladen");
|
|
|
|
/* Ein Gespraech oeffnen -- erst dann gibt es Knoepfe. */
|
|
const raeume = await seite.locator("#raeume li").count();
|
|
ok(raeume > 0, `${raeume} Gespraech(e) in der Liste`);
|
|
await seite.locator("#raeume li").first().click();
|
|
await seite.waitForTimeout(1200);
|
|
|
|
const knoepfe = await seite.evaluate(() => ({
|
|
ton: !!document.getElementById("anruf-ton-start"),
|
|
video: !!document.getElementById("anruf-video-start"),
|
|
sichtbar: document.getElementById("anruf-ton-start")?.offsetHeight > 0,
|
|
}));
|
|
ok(knoepfe.ton && knoepfe.video, "beide Anrufknoepfe stehen im Kopf");
|
|
ok(knoepfe.sichtbar, "und man sieht sie");
|
|
|
|
/* ANRUFEN -- und zwar wirklich. Ein Test, der nur prueft, dass
|
|
ein Knopf da ist, prueft, dass jemand einen Knopf gebaut hat. */
|
|
await seite.click("#anruf-ton-start");
|
|
await seite.waitForTimeout(2500);
|
|
const kasten = await seite.evaluate(() => {
|
|
const k = document.getElementById("anruf");
|
|
return {
|
|
da: !!k && !k.hidden,
|
|
lage: document.getElementById("anruf-lage")?.textContent || "",
|
|
zeit: document.getElementById("anruf-zeit")?.textContent || "",
|
|
};
|
|
});
|
|
ok(kasten.da, `der Anrufkasten erscheint ("${kasten.lage}")`);
|
|
ok(/klingelt|Verbind/i.test(kasten.lage),
|
|
`und sagt, was gerade passiert ("${kasten.lage}")`);
|
|
ok(/^\d\d:\d\d$/.test(kasten.zeit), `die Uhr laeuft (${kasten.zeit})`);
|
|
|
|
/* Und der Server weiss davon. */
|
|
const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
|
|
ok(l.laeuft === true, "der Server kennt den Anruf");
|
|
|
|
await seite.screenshot({ path: "server/pruef-anruf.png", fullPage: false });
|
|
melde(" -- Bildschirmfoto: server/pruef-anruf.png");
|
|
|
|
await seite.click("#anruf-auflegen");
|
|
await seite.waitForTimeout(900);
|
|
ok(await seite.evaluate(() => document.getElementById("anruf")?.hidden === true),
|
|
"nach dem Auflegen ist der Kasten weg");
|
|
const l2 = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
|
|
ok(l2.laeuft === false, "und der Server auch");
|
|
|
|
ok(konsole.length === 0,
|
|
konsole.length ? `Konsolenfehler: ${konsole[0]}` : "kein Konsolenfehler");
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
/* =======================================================================
|
|
8. ZU DRITT -- UND ZWAR WIRKLICH (18.09.2026)
|
|
|
|
GEBAUT IST NICHT BEWIESEN. Der Anruf ist fuer bis zu vier Leute
|
|
geschrieben, geprueft war er zu zweit. Genau diese Luecke zwischen
|
|
Behauptung und Beleg ist die, an der im Haus schon mehrfach etwas
|
|
haengengeblieben ist -- zuletzt bei den Kachelfarben, wo ein
|
|
Werkzeug 21 Farben kannte und 37 gemeint waren.
|
|
|
|
DER ENTSCHEIDENDE PUNKT IST DIE ZAHL DER VERBINDUNGEN. Bei drei
|
|
Teilnehmern muss JEDER zwei haben -- eine zu jedem anderen. Haette
|
|
`verbindungen` eine einzelne Variable statt einer Karte, ginge zu
|
|
zweit alles gut und der Dritte ueberschriebe stillschweigend den
|
|
Ersten: Man sieht sich zu dritt und hoert nur einen.
|
|
|
|
UND DIE ANGEBOTSREGEL. Bei WebRTC muss genau EINER das Angebot
|
|
machen. Machen beide eins, heben sie sich auf ("glare") und es kommt
|
|
keine Verbindung zustande -- ohne Fehlermeldung, man wartet nur.
|
|
Die Regel „die kleinere Personennummer beginnt" ist auf beiden
|
|
Seiten ohne Absprache dieselbe Entscheidung. Ob sie traegt, sieht
|
|
man erst ab drei Leuten.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 8. Zu dritt ===");
|
|
{
|
|
let chromium;
|
|
try {
|
|
({ chromium } = await import(
|
|
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"));
|
|
} catch { chromium = null; }
|
|
|
|
if (!chromium) {
|
|
unklar("kein Playwright -- der Gruppenanruf wurde nicht ausprobiert");
|
|
} else {
|
|
/* Ein Gruppenraum mit allen dreien. */
|
|
const g = await roh("/workspace/api/chat/gruppe", HAUS, { cookie: kDogi },
|
|
{ name: "Testrunde", mit: [idHand, idModi] });
|
|
const gruppeId = json(g).raum?.id || json(g).raum_id || json(g).id;
|
|
ok(!!gruppeId, `ein Gruppengespraech entsteht (${g.code}, Raum ${gruppeId})`);
|
|
|
|
const browser = await chromium.launch({
|
|
args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
|
|
});
|
|
try {
|
|
const machSeite = async (keks) => {
|
|
const k = await browser.newContext({
|
|
viewport: { width: 1100, height: 800 },
|
|
permissions: ["microphone"],
|
|
});
|
|
await k.addCookies(keks.split("; ").map((c) => {
|
|
const [name, ...rest] = c.split("=");
|
|
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
|
|
}));
|
|
const se = await k.newPage();
|
|
await se.goto(`http://127.0.0.1:${PORT}/workspace/chat.html`,
|
|
{ waitUntil: "domcontentloaded" });
|
|
await se.waitForTimeout(1600);
|
|
return { k, se };
|
|
};
|
|
|
|
const a = await machSeite(kDogi);
|
|
const b = await machSeite(kHand);
|
|
const c = await machSeite(kModi);
|
|
|
|
/* Alle drei oeffnen denselben Raum. */
|
|
for (const x of [a, b, c]) {
|
|
await x.se.evaluate((id) => {
|
|
const li = [...document.querySelectorAll("#raeume li")]
|
|
.find((e) => Number(e.dataset.raum || e.dataset.id) === id);
|
|
(li || document.querySelector("#raeume li"))?.click();
|
|
}, gruppeId);
|
|
await x.se.waitForTimeout(700);
|
|
}
|
|
|
|
/* DogFather ruft an. */
|
|
await a.se.evaluate((id) => window.anrufStarten(id, false), gruppeId);
|
|
await a.se.waitForTimeout(1500);
|
|
|
|
/* Die anderen beiden gehen ran -- ueber den Klingelknopf, nicht
|
|
ueber eine Abkuerzung: Geprueft werden soll der Weg, den ein
|
|
Mensch geht. */
|
|
for (const x of [b, c]) {
|
|
const da = await x.se.locator("#anruf-klingelt .anruf-klingelt__ja").count();
|
|
ok(da === 1, `es klingelt bei ${x === b ? "der rechten Hand" : "dem Modi"} (${da})`);
|
|
if (da) await x.se.click("#anruf-klingelt .anruf-klingelt__ja");
|
|
}
|
|
/* WebRTC braucht ein paar Sekunden, bis die Verbindungen stehen. */
|
|
await a.se.waitForTimeout(6000);
|
|
|
|
/* Der Server: drei drin. */
|
|
const lage = json(await roh(`/workspace/api/anruf/${gruppeId}`, HAUS, { cookie: kDogi }));
|
|
ok(lage.wer?.length === 3,
|
|
`der Server zaehlt drei Teilnehmer (${lage.wer?.map((w) => w.name).join(", ")})`);
|
|
|
|
/* DER EIGENTLICHE PUNKT: Jeder hat ZWEI Verbindungen. */
|
|
const zaehlen = (x) => x.se.evaluate(() => ({
|
|
/* Ueber die Oberflaeche nicht erreichbar -- deshalb ueber die
|
|
Zahl der Video-/Audio-Elemente im Kasten, die jede
|
|
Gegenstelle erzeugt. */
|
|
stroeme: document.querySelectorAll("#anruf-leute audio, #anruf-leute video").length,
|
|
lage: document.getElementById("anruf-lage")?.textContent || "",
|
|
}));
|
|
for (const [wer, x] of [["DogFather", a], ["die rechte Hand", b], ["der Modi", c]]) {
|
|
const z = await zaehlen(x);
|
|
ok(z.stroeme >= 2,
|
|
`${wer} hoert BEIDE anderen (${z.stroeme} Stroeme, „${z.lage}")`);
|
|
}
|
|
|
|
/* Einer legt auf -- fuer die anderen laeuft es weiter. */
|
|
await c.se.click("#anruf-auflegen");
|
|
await c.se.waitForTimeout(2000);
|
|
const lage2 = json(await roh(`/workspace/api/anruf/${gruppeId}`, HAUS, { cookie: kDogi }));
|
|
ok(lage2.laeuft === true && lage2.wer?.length === 2,
|
|
`einer geht, zwei telefonieren weiter (${lage2.wer?.length})`);
|
|
const zA = await zaehlen(a);
|
|
ok(zA.stroeme === 1,
|
|
`und bei DogFather ist genau ein Strom weniger (${zA.stroeme})`);
|
|
|
|
await a.se.click("#anruf-auflegen");
|
|
await b.se.click("#anruf-auflegen");
|
|
await a.se.waitForTimeout(1200);
|
|
const lage3 = json(await roh(`/workspace/api/anruf/${gruppeId}`, HAUS, { cookie: kDogi }));
|
|
ok(lage3.laeuft === false, "wenn alle auflegen, ist der Anruf vorbei");
|
|
|
|
await a.k.close(); await b.k.close(); await c.k.close();
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------- */
|
|
melde("");
|
|
melde(`${geprueft} Pruefungen, ${fehler} Fehler`
|
|
+ (offen ? `, ${offen} nicht nachsehbar` : ""));
|
|
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
|
|
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
|
|
process.exit(fehler ? 1 : 0);
|