From 775c4b420792c18889ef0a51fe6b6496ba073a0b Mon Sep 17 00:00:00 2001 From: Dogfather Date: Fri, 18 Sep 2026 15:22:14 +0200 Subject: [PATCH] Vermittlungsserver: coturn mit Zugangsdaten, die verfallen 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 --- deploy/turnserver.conf | 127 ++++++++++++++++++++ server/pruef-anruf.mjs | 162 ++++++++++++++++++++++++- server/workspace-anruf.js | 28 ++++- server/workspace-turn.js | 183 ++++++++++++++++++++++++++++ tools/turn-einrichten.mjs | 204 ++++++++++++++++++++++++++++++++ tools/turn-probelauf.sh | 186 +++++++++++++++++++++++++++++ workspace/app.webmanifest | 8 +- workspace/assets/js/anruf.js | 67 +++++++++-- 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/wissen.html | 26 ++-- 40 files changed, 1348 insertions(+), 413 deletions(-) create mode 100644 deploy/turnserver.conf create mode 100644 server/workspace-turn.js create mode 100644 tools/turn-einrichten.mjs create mode 100644 tools/turn-probelauf.sh diff --git a/deploy/turnserver.conf b/deploy/turnserver.conf new file mode 100644 index 00000000..8580fe7d --- /dev/null +++ b/deploy/turnserver.conf @@ -0,0 +1,127 @@ +# ===================================================================== +# coturn - Vermittlungsserver fuer die Anrufe im Creator Workspace +# Angelegt 18.09.2026. Gehoert nach /etc/turnserver.conf +# +# WARUM DIESE DATEI IM REPO LIEGT +# Sie steht auf dem Server unter /etc und waere bei einem Neuaufbau weg, +# ohne dass es jemandem auffiele - der Anruf wuerde dann einfach bei +# jedem Fuenften nicht mehr klappen. Genau das ist bei RunOne mit den +# systemd-Dateien passiert; seitdem liegen solche Dateien versioniert. +# +# DAS GEHEIMNIS STEHT NICHT HIER DRIN. +# Es wird vom Einrichtungsbefehl erzeugt und unten angehaengt. Eine +# Vorlage mit Platzhalter waere eine Vorlage, in der irgendwann der +# Platzhalter stehen bleibt - im Haus schon zweimal passiert. +# ===================================================================== + +# --- Wo gelauscht wird ------------------------------------------------- +# Ausdruecklich nur auf der oeffentlichen Adresse. Ohne diese Zeile +# lauscht coturn auf ALLEN Netzgeraeten - auch auf den vier +# Docker-Bruecken (172.17-172.20). Ein Vermittlungsserver, der im +# Docker-Netz erreichbar ist, ist eine Abkuerzung, die niemand braucht. +listening-ip=159.195.212.167 +relay-ip=159.195.212.167 +listening-port=3478 + +# Kein TLS in dieser Stufe: Dafuer braeuchte es ein Zertifikat und damit +# einen eigenen Namen im DNS. Ohne diese beiden Zeilen versucht coturn +# trotzdem, einen TLS-Lauscher zu oeffnen, und schreibt bei jedem Start +# eine Warnung ueber fehlende Zertifikate - eine Warnung, die immer +# kommt, liest nach drei Tagen niemand mehr. +no-tls +no-dtls + +# --- Der Portbereich fuer die Weiterleitung ---------------------------- +# Jedes weitergeleitete Gespraech belegt einen Port. Der Standard geht +# von 49152 bis 65535 - 16000 Ports, die alle in der Firewall offen sein +# muessten. Fuer ein Team dieser Groesse sind 100 reichlich: Sie +# reichen fuer 100 gleichzeitige Weiterleitungen, und die Firewall +# bleibt entsprechend eng. +min-port=49160 +max-port=49260 + +# --- Wer den Server benutzen darf -------------------------------------- +# Keine Benutzerliste, sondern ein gemeinsames Geheimnis: Der Workspace +# rechnet daraus Zugangsdaten, die nach zwoelf Stunden verfallen +# (server/workspace-turn.js). coturn rechnet dieselbe Zahl nach. +# Vorteil gegenueber festen Benutzern: Es gibt nichts abzugleichen, und +# ein Zugang, der nicht mehr gelten soll, gilt am naechsten Tag nicht +# mehr. +use-auth-secret +realm=dogfather-universe.com + +# Wie lange eine einmal ausgestellte Anmeldung nachwirkt. +stale-nonce=600 + +# --- DIE WICHTIGSTEN ZEILEN DER GANZEN DATEI --------------------------- +# Ein TURN-Server leitet auf Zuruf Verkehr an eine Adresse weiter, die +# der Anrufer nennt. Ohne die folgenden Sperren darf er auch 127.0.0.1 +# nennen - und dann steht dieser Server als Tuer vor allem, was auf +# diesem Rechner nur oertlich lauscht. Am 18.09.2026 gemessen: dreizehn +# Dienste, darunter +# +# 127.0.0.1:2019 die Verwaltung von Caddy (!) - wer sie erreicht, +# kann JEDE Website auf diesem Server umleiten +# 127.0.0.1:3000 Gitea 127.0.0.1:4000 VanVans Shop +# 127.0.0.1:4100 Workspace 127.0.0.1:4400 Buchhaltung +# 127.0.0.1:25 der Mailserver +# +# Fuer Caddy saehe eine so weitergeleitete Anfrage aus, als kaeme sie +# von diesem Rechner selbst. Diese Liste ist deshalb keine Haertung, +# sondern die Voraussetzung dafuer, dass der Dienst ueberhaupt laufen +# darf. +no-multicast-peers +denied-peer-ip=0.0.0.0-0.255.255.255 +denied-peer-ip=10.0.0.0-10.255.255.255 +denied-peer-ip=100.64.0.0-100.127.255.255 +denied-peer-ip=127.0.0.0-127.255.255.255 +denied-peer-ip=169.254.0.0-169.254.255.255 +denied-peer-ip=172.16.0.0-172.31.255.255 +denied-peer-ip=192.0.0.0-192.0.0.255 +denied-peer-ip=192.0.2.0-192.0.2.255 +denied-peer-ip=192.88.99.0-192.88.99.255 +denied-peer-ip=192.168.0.0-192.168.255.255 +denied-peer-ip=198.18.0.0-198.19.255.255 +denied-peer-ip=198.51.100.0-198.51.100.255 +denied-peer-ip=203.0.113.0-203.0.113.255 +denied-peer-ip=240.0.0.0-255.255.255.255 +# Dasselbe fuer IPv6 - sonst waere die Sperre oben eine halbe Sperre. +denied-peer-ip=::1 +denied-peer-ip=64:ff9b::-64:ff9b::ffff:ffff +denied-peer-ip=::ffff:0.0.0.0-::ffff:255.255.255.255 +denied-peer-ip=100::-100::ffff:ffff:ffff:ffff +denied-peer-ip=2001::-2001:1ff:ffff:ffff:ffff:ffff:ffff:ffff +denied-peer-ip=2002::-2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff +denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff +denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff + +# Weiterleitung nur fuer UDP. WebRTC braucht nichts anderes; TCP-Ziele +# waeren nur fuer jemanden interessant, der etwas anderes vorhat. +no-tcp-relay + +# --- Grenzen gegen Missbrauch ------------------------------------------ +# Falls Zugangsdaten doch einmal abhandenkommen, kostet es Bandbreite, +# keine Rechnung ohne Ende. 12 gleichzeitige Weiterleitungen je Person +# reichen fuer eine Vierergruppe mit Ton und Bild deutlich. +user-quota=12 +total-quota=100 +max-bps=3000000 + +# --- Die eingebaute Fernsteuerung bleibt aus --------------------------- +# coturn oeffnet sonst auf 127.0.0.1:5766 eine Verwaltungskonsole. Sie +# wird hier nicht gebraucht, und ein offener Verwaltungszugang, den +# niemand benutzt, faellt auch niemandem auf. +no-cli + +# --- Protokoll --------------------------------------------------------- +# In das Systemprotokoll statt in eine eigene Datei: Damit wird es +# mitgedreht und laeuft nicht voll. Bewusst OHNE `verbose` - ein +# ausfuehrliches Protokoll eines Vermittlungsservers ist eine Liste, +# wer wann mit wem telefoniert hat. +syslog +simple-log + +# Hilft Gegenstellen, die Pakete zuzuordnen; kostet nichts. +fingerprint + +# --- Das gemeinsame Geheimnis wird unten angehaengt -------------------- diff --git a/server/pruef-anruf.mjs b/server/pruef-anruf.mjs index 482b51ed..fdcb385f 100644 --- a/server/pruef-anruf.mjs +++ b/server/pruef-anruf.mjs @@ -16,7 +16,7 @@ Aufrufen mit: node server/pruef-anruf.mjs ===================================================================== */ -import { mkdtempSync, rmSync } from "node:fs"; +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"; @@ -28,6 +28,11 @@ 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; @@ -268,6 +273,161 @@ melde("=== 6. Die Verbindungsadressen ==="); "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 ======================================================================= */ diff --git a/server/workspace-anruf.js b/server/workspace-anruf.js index bf587379..92362392 100644 --- a/server/workspace-anruf.js +++ b/server/workspace-anruf.js @@ -72,6 +72,7 @@ import { istDogFather, } from "./workspace.js"; import { chatEreignis, istDrinFuerAnruf, teilnehmerFuerAnruf } from "./workspace-chat.js"; +import { mitZugangsdaten } from "./workspace-turn.js"; export const anrufRouter = express.Router(); @@ -161,13 +162,38 @@ anrufRouter.get("/workspace/api/anruf/adressen", (req, res) => { try { let liste = []; try { liste = JSON.parse(einstellung("anruf_adressen") || "[]"); } catch { liste = []; } + + /* ZUGANGSDATEN WERDEN GERECHNET, NICHT GESPEICHERT. + Warum das so ist, steht ausfuehrlich in workspace-turn.js. Kurz: + Ein festes TURN-Passwort laege dauerhaft im Browser jedes + Teilnehmers und liesse sich nie wieder entziehen. Die hier + erzeugten verfallen von selbst. + + Als Kennung die PERSONENNUMMER, nicht der Name -- sie landet im + Protokoll von coturn, und das ist kein Ort fuer Klarnamen. */ + const { adressen, turn_gesamt, turn_bereit, grund } = + mitZugangsdaten(liste, `w${req.person.id}`); + + if (grund) { + /* DER DRITTE AUSGANG. „Kann nicht nachsehen" ist etwas anderes + als „ist nicht eingerichtet" -- und wer das verschweigt, sucht + den Fehler spaeter beim Anrufer. */ + console.warn(`[anruf] TURN-Geheimnis nicht verwendbar: ${grund}`); + } + res.json({ - adressen: Array.isArray(liste) ? liste : [], + adressen, gruppe_max: GRUPPE_MAX, /* WAS DIE OBERFLAECHE WISSEN MUSS, um ehrlich zu sein: Ohne Adressen klappt der Anruf nur im selben Netz. Das gehoert DAVOR gesagt, nicht als Fehlermeldung danach. */ nur_gleiches_netz: !Array.isArray(liste) || liste.length === 0, + /* Zahlen statt Ja/Nein: Wer zaehlen kann, kann pruefen. Ein + eingetragener TURN-Server, der mangels Geheimnis niemanden + durchlaesst, sieht sonst genauso aus wie ein funktionierender. */ + turn_gesamt, + turn_bereit, + ...(grund ? { turn_grund: grund } : {}), }); } catch (f) { console.error("[anruf] Adressen:", f?.message); diff --git a/server/workspace-turn.js b/server/workspace-turn.js new file mode 100644 index 00000000..da6def1b --- /dev/null +++ b/server/workspace-turn.js @@ -0,0 +1,183 @@ +/* ===================================================================== + ZUGANGSDATEN FÜR DEN VERMITTLUNGSSERVER (TURN) -- 18.09.2026 + + Ein TURN-Server leitet Ton und Bild weiter, wenn zwei Browser sich + nicht direkt erreichen (strenge Firmennetze, manche Mobilfunknetze). + Er will wissen, wer ihn benutzen darf -- also Benutzer und Passwort. + + --------------------------------------------------------------------- + WARUM HIER GERECHNET WIRD, STATT EIN PASSWORT EINZUTRAGEN + + Der naheliegende Weg wäre ein festes Passwort in den Einstellungen. + Es wäre in zwei Minuten fertig -- und es läge danach dauerhaft im + Browser jedes Team-Mitglieds, sichtbar in jedem Netzwerk-Fenster, + gültig ohne Ende. Wer einmal dabei war, könnte den Server für immer + als Weiterleitung benutzen: fremden Verkehr über unsere IP-Adresse + schicken, unsere Bandbreite verbrauchen. Ein Zugang, den man nicht + entziehen kann, ist kein Zugang, sondern ein Loch. + + Deshalb der Weg, den coturn selbst dafür vorsieht + (`use-auth-secret`): Der Server hier kennt ein GEHEIMNIS, das den + Browser nie erreicht. Aus ihm rechnet er bei jeder Abfrage frische + Zugangsdaten, die nach TURN_GUELTIG_STUNDEN von selbst verfallen. + coturn rechnet dieselbe Zahl nach und kennt dafür nur das Geheimnis + -- es muss keine Benutzerliste geben, und nichts muss abgeglichen + werden. + + Benutzername = : + Passwort = base64( HMAC-SHA1( Geheimnis, Benutzername ) ) + + --------------------------------------------------------------------- + WARUM DAS GEHEIMNIS IN EINER DATEI LIEGT UND NICHT IN DER DATENBANK + + Zwei Gründe, beide im Haus schon einmal teuer gewesen: + + 1. `einstellungSetzen()` schreibt den WERT in das Protokoll + (`detail: "schluessel = wert"`). Ein Geheimnis dort wäre im + Klartext in der Prüfspur -- und in jeder nächtlichen Sicherung. + + 2. coturn braucht denselben Wert in `/etc/turnserver.conf`. Eine + Datei, die beide Seiten aus derselben Quelle bekommen, kann nicht + auseinanderlaufen; zwei Eingabefelder können es immer. + + Die Datei gehört root und ist für die Gruppe des Dienstes lesbar + (640). Der Browser bekommt sie nie zu sehen -- das ist der Punkt, + an dem `pruef-anruf.mjs` nachmisst. + ===================================================================== */ + +import { createHmac } from "node:crypto"; +import { readFileSync, statSync } from "node:fs"; + +/** Wie lange erzeugte Zugangsdaten gelten. + * + * Zwölf Stunden ist die Spanne, die einen Arbeitstag überdeckt, ohne + * dass jemand mittendrin herausfliegt -- und kurz genug, dass ein + * Zugang, der nicht mehr gelten soll, am nächsten Tag nicht mehr + * gilt. Die Oberfläche holt die Adressen zusätzlich bei jedem Anruf + * neu; deshalb ist diese Zahl eine Obergrenze, kein Takt. */ +export const TURN_GUELTIG_STUNDEN = 12; + +/** Wo das gemeinsame Geheimnis liegt. + * + * Über eine Umgebungsvariable umstellbar -- nicht aus Vorliebe für + * Einstellbarkeit, sondern weil die Prüfung sonst entweder gar nicht + * messen könnte oder an die echte Datei müsste. */ +export const TURN_GEHEIMNIS_DATEI = + process.env.TURN_GEHEIMNIS_DATEI || "/etc/coturn-workspace.geheimnis"; + +/* Gelesenes Geheimnis, gemerkt über einen ÄNDERUNGSSTEMPEL statt über + eine Laufzeit: Wer die Datei austauscht, soll nicht bis zum Ablauf + einer Frist warten. Ein `stat` je Abfrage kostet nichts, und die + Abfrage kommt ein paar Mal am Tag, nicht ein paar Mal je Sekunde. */ +let gemerkt = { stempel: "", wert: "" }; + +/** Liest das Geheimnis. + * + * DREI AUSGÄNGE, nicht zwei -- „ich kann nicht nachsehen" ist etwas + * anderes als „es ist keins da". Der Grund wandert bis in die Antwort + * der Schnittstelle, damit an der Oberfläche nicht geraten wird. + * + * @returns {{wert: string, grund: string}} `grund` ist leer, wenn + * alles in Ordnung ist; sonst `fehlt`, `leer`, `zu_kurz` oder + * `unlesbar:`. + */ +export function turnGeheimnisLesen(datei = TURN_GEHEIMNIS_DATEI) { + let s; + try { + s = statSync(datei); + } catch (f) { + gemerkt = { stempel: "", wert: "" }; + return { wert: "", grund: f?.code === "ENOENT" ? "fehlt" : `unlesbar:${f?.code || "?"}` }; + } + const stempel = `${s.mtimeMs}:${s.size}`; + if (stempel !== gemerkt.stempel) { + try { + gemerkt = { stempel, wert: readFileSync(datei, "utf8").trim() }; + } catch (f) { + gemerkt = { stempel: "", wert: "" }; + return { wert: "", grund: `unlesbar:${f?.code || "?"}` }; + } + } + if (!gemerkt.wert) return { wert: "", grund: "leer" }; + /* Ein zu kurzes Geheimnis ist schlimmer als keins: Es sieht aus wie + Sicherheit und ist in Minuten geraten. 32 Zeichen entsprechen den + 16 Bytes, die der Einrichtungsbefehl erzeugt. */ + if (gemerkt.wert.length < 32) return { wert: "", grund: "zu_kurz" }; + return { wert: gemerkt.wert, grund: "" }; +} + +/** Rechnet ein Paar Zugangsdaten aus. + * + * @param kennung Wer fragt -- landet im Benutzernamen und damit im + * Protokoll von coturn. Keine Namen, nur die Nummer: Das Protokoll + * eines Vermittlungsservers ist kein Ort für Klarnamen. + * @param geheimnis Der gemeinsame Wert aus der Datei. + * @param jetzt Millisekunden. Als Eingabe übergeben, nicht aus der + * Wanduhr gelesen -- sonst wäre die Prüfung eine Zeitbombe. + */ +export function turnZugang(kennung, geheimnis, jetzt = Date.now()) { + const ablauf = Math.floor(jetzt / 1000) + TURN_GUELTIG_STUNDEN * 3600; + const username = `${ablauf}:${kennung}`; + return { + username, + credential: createHmac("sha1", geheimnis).update(username).digest("base64"), + ablauf, + }; +} + +/** Ergänzt eine Adressliste um frische Zugangsdaten. + * + * WELCHER EINTRAG BEKOMMT WELCHE BEHANDLUNG: + * + * stun:… nichts -- STUN kennt keine Anmeldung. + * turn:… ohne eigenes Passwort gerechnete Zugangsdaten (unser + * coturn). + * turn:… MIT username+credential unverändert -- das ist ein + * fremder Dienst, den jemand bewusst + * eingetragen hat. Wir überschreiben + * keine Zugangsdaten, die wir nicht + * ausgestellt haben. + * + * @returns {{adressen: object[], turn_gesamt: number, + * turn_bereit: number, grund: string}} + * Die ZAHLEN gehören zurückgegeben, nicht nur die Liste: Eine + * Prüfung, die „ist grün" sagen soll, muss zählen können, worüber + * sie redet -- sonst ist sie auch bei einer leeren Liste grün. + */ +export function mitZugangsdaten(liste, kennung, jetzt = Date.now(), + datei = TURN_GEHEIMNIS_DATEI) { + const rein = Array.isArray(liste) ? liste : []; + const brauchtZugang = (e) => + /^turns?:/.test(String(e?.urls || "")) && !e?.credential; + const turn_gesamt = rein.filter((e) => /^turns?:/.test(String(e?.urls || ""))).length; + + if (!rein.some(brauchtZugang)) { + return { adressen: rein, turn_gesamt, turn_bereit: turn_gesamt, grund: "" }; + } + + const { wert, grund } = turnGeheimnisLesen(datei); + if (!wert) { + /* KEIN STILLES WEGLASSEN. Ohne Zugangsdaten weist coturn jede + Anfrage ab -- der Anruf fällt dann auf die direkte Verbindung + zurück und scheitert genau bei den Leuten, für die der Server + gebaut wurde. Der Eintrag bleibt trotzdem in der Liste: Ein + `turn:` ohne Passwort schadet nicht, und der Grund steht dabei. */ + return { + adressen: rein, + turn_gesamt, + turn_bereit: rein.filter((e) => /^turns?:/.test(String(e?.urls || "")) && e?.credential).length, + grund, + }; + } + + const zugang = turnZugang(kennung, wert, jetzt); + return { + adressen: rein.map((e) => + brauchtZugang(e) + ? { ...e, username: zugang.username, credential: zugang.credential } + : e), + turn_gesamt, + turn_bereit: turn_gesamt, + grund: "", + }; +} diff --git a/tools/turn-einrichten.mjs b/tools/turn-einrichten.mjs new file mode 100644 index 00000000..e4d52f86 --- /dev/null +++ b/tools/turn-einrichten.mjs @@ -0,0 +1,204 @@ +/* ===================================================================== + DEN VERMITTLUNGSSERVER EINTRAGEN UND NACHMESSEN -- 18.09.2026 + + Aufrufen (auf dem Server, als Benutzer dogiweb): + + node tools/turn-einrichten.mjs pruefen + node tools/turn-einrichten.mjs eintragen turn:159.195.212.167:3478 + node tools/turn-einrichten.mjs entfernen + + --------------------------------------------------------------------- + WARUM EIN BEFEHL UND KEIN EINGABEFELD + + Die Adresse wird einmal eingetragen und dann jahrelang nicht mehr. + Ein Menuepunkt dafuer waere eine Seite, die niemand findet, wenn er + sie braucht -- und eine, die gepflegt werden muss. Der Befehl steht + dagegen neben den anderen Einrichtungsschritten, in derselben + Anleitung. + + WAS DIESER BEFEHL NICHT TUT: Er erzeugt das Geheimnis nicht. Das + gehoert root, weil es auch in /etc/turnserver.conf muss. Hier wird + nur NACHGESEHEN, ob es aus Sicht des Dienstes lesbar ist -- der + haeufigste Fehler beim Einrichten sind Dateirechte, und der faellt + sonst erst beim ersten Anruf auf, der nicht zustande kommt. + + DAS GEHEIMNIS WIRD NIE AUSGEGEBEN, auch nicht gekuerzt. + ===================================================================== */ + +import { createSocket } from "node:dgram"; +import { randomBytes } from "node:crypto"; +import { DatabaseSync } from "node:sqlite"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { turnGeheimnisLesen, TURN_GEHEIMNIS_DATEI } from "../server/workspace-turn.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DB_PFAD = process.env.WORKSPACE_DB + || join(__dirname, "..", "..", "workspace-daten", "workspace.db"); + +const [, , befehl, wert] = process.argv; + +/* --------------------------------------------------------------------- + EIN ECHTER VERSUCH, KEIN BLICK IN DIE KONFIGURATION + + Eine Konfigurationsdatei zu lesen beweist, dass jemand etwas + hingeschrieben hat. Ob der Dienst laeuft, ob die Firewall offen ist + und ob er von aussen antwortet, beweist nur eine Anfrage. + + Ein STUN-Binding ist dafuer die kleinste moegliche: 20 Byte hin, + und zurueck kommt die eigene Adresse, wie der Server sie sieht. + --------------------------------------------------------------------- */ +function stunFragen(host, port = 3478, frist = 3000) { + return new Promise((fertig) => { + const kennung = randomBytes(12); + const anfrage = Buffer.alloc(20); + anfrage.writeUInt16BE(0x0001, 0); // Binding Request + anfrage.writeUInt16BE(0x0000, 2); // keine Attribute + anfrage.writeUInt32BE(0x2112a442, 4); // der feste Erkennungswert + kennung.copy(anfrage, 8); + + const s = createSocket("udp4"); + const uhr = setTimeout(() => { + try { s.close(); } catch { /* schon zu */ } + fertig({ ok: false, grund: "keine Antwort innerhalb von 3 Sekunden" }); + }, frist); + + s.on("error", (f) => { + clearTimeout(uhr); + try { s.close(); } catch { /* schon zu */ } + fertig({ ok: false, grund: f.message }); + }); + + s.on("message", (a) => { + clearTimeout(uhr); + try { s.close(); } catch { /* schon zu */ } + if (a.length < 20 || a.readUInt16BE(0) !== 0x0101) { + return fertig({ ok: false, grund: "Antwort ist kein STUN-Erfolg" }); + } + if (!a.subarray(8, 20).equals(kennung)) { + return fertig({ ok: false, grund: "Antwort gehoert zu einer anderen Anfrage" }); + } + /* XOR-MAPPED-ADDRESS heraussuchen -- die Adresse ist mit dem + Erkennungswert verodert, damit unterwegs niemand daran + herumbastelt. */ + let p = 20; + while (p + 4 <= a.length) { + const art = a.readUInt16BE(p), laenge = a.readUInt16BE(p + 2); + if (art === 0x0020 && laenge >= 8) { + const hafen = a.readUInt16BE(p + 6) ^ 0x2112; + const ip = [0, 1, 2, 3] + .map((i) => a[p + 8 + i] ^ [0x21, 0x12, 0xa4, 0x42][i]).join("."); + return fertig({ ok: true, gesehenAls: `${ip}:${hafen}` }); + } + p += 4 + laenge + ((4 - (laenge % 4)) % 4); + } + fertig({ ok: true, gesehenAls: "(ohne Adressangabe)" }); + }); + + s.send(anfrage, port, host, (f) => { + if (f) { + clearTimeout(uhr); + try { s.close(); } catch { /* schon zu */ } + fertig({ ok: false, grund: f.message }); + } + }); + }); +} + +function adressenLesen(db) { + try { + const r = db.prepare("SELECT wert FROM einstellungen WHERE schluessel = 'anruf_adressen'").get(); + const l = JSON.parse(r?.wert || "[]"); + return Array.isArray(l) ? l : []; + } catch { return []; } +} + +function adressenSchreiben(db, liste) { + db.prepare(` + INSERT INTO einstellungen (schluessel, wert, geaendert, von) + VALUES ('anruf_adressen', ?, ?, NULL) + ON CONFLICT(schluessel) DO UPDATE SET + wert = excluded.wert, geaendert = excluded.geaendert`) + .run(JSON.stringify(liste), new Date().toISOString()); +} + +/* --------------------------------------------------------------------- */ + +if (!["pruefen", "eintragen", "entfernen"].includes(befehl)) { + console.log("Aufruf: node tools/turn-einrichten.mjs pruefen" + + "\n node tools/turn-einrichten.mjs eintragen turn:159.195.212.167:3478" + + "\n node tools/turn-einrichten.mjs entfernen"); + process.exit(2); +} + +const db = new DatabaseSync(DB_PFAD); + +if (befehl === "eintragen") { + if (!/^(stun|turn|turns):[^\s]+$/.test(wert || "")) { + console.error("FEHLT: eine Adresse wie turn:159.195.212.167:3478"); + process.exit(2); + } + const alt = adressenLesen(db); + /* Doppelte Eintraege vermeiden -- zweimal dieselbe Adresse bedeutet, + dass jeder Browser sie zweimal probiert. */ + const neu = alt.filter((e) => e.urls !== wert).concat([{ urls: wert }]); + adressenSchreiben(db, neu); + console.log(`Eingetragen. Vorher ${alt.length}, jetzt ${neu.length} Adresse(n).`); +} else if (befehl === "entfernen") { + adressenSchreiben(db, []); + console.log("Alle Adressen entfernt."); +} + +/* ---------- Der Zustand, immer ---------------------------------------- */ +console.log(""); +console.log("=== Zustand ==="); + +const adressen = adressenLesen(db); +console.log(`Eingetragene Adressen: ${adressen.length}`); +for (const a of adressen) console.log(` ${a.urls}`); + +const g = turnGeheimnisLesen(); +console.log(""); +if (g.wert) { + console.log(`Geheimnis: lesbar (${g.wert.length} Zeichen) aus ${TURN_GEHEIMNIS_DATEI}`); +} else { + console.log(`Geheimnis: NICHT verwendbar -- ${g.grund}`); + console.log(` erwartet unter ${TURN_GEHEIMNIS_DATEI}`); + if (g.grund.startsWith("unlesbar")) { + console.log(" (Dateirechte: root:dogiweb mit 640)"); + } +} + +/* Wirklich anfragen -- bei jeder eingetragenen Adresse. */ +let erreichbar = 0, versucht = 0; +for (const a of adressen) { + const m = /^(?:stun|turns?):([^:/?]+)(?::(\d+))?/.exec(a.urls || ""); + if (!m) continue; + versucht++; + const antwort = await stunFragen(m[1], Number(m[2] || 3478)); + if (antwort.ok) { + erreichbar++; + console.log(`\n${a.urls}: antwortet. Von dort aus sehen wir aus wie ${antwort.gesehenAls}`); + } else { + console.log(`\n${a.urls}: KEINE ANTWORT -- ${antwort.grund}`); + console.log(" Meist eins von dreien: Dienst laeuft nicht " + + "(systemctl status coturn), Firewall zu (ufw), oder falsche Adresse."); + } +} + +db.close(); + +console.log(""); +if (!adressen.length) { + console.log("ERGEBNIS: Keine Adresse eingetragen -- Anrufe klappen nur im selben Netz."); + process.exit(1); +} +if (!g.wert) { + console.log("ERGEBNIS: Adresse da, aber ohne Geheimnis weist coturn jeden ab."); + process.exit(1); +} +if (versucht && erreichbar < versucht) { + console.log(`ERGEBNIS: ${erreichbar} von ${versucht} Adressen antworten.`); + process.exit(1); +} +console.log("ERGEBNIS: Eingetragen, Geheimnis lesbar, Server antwortet."); diff --git a/tools/turn-probelauf.sh b/tools/turn-probelauf.sh new file mode 100644 index 00000000..91d3db19 --- /dev/null +++ b/tools/turn-probelauf.sh @@ -0,0 +1,186 @@ +#!/bin/bash +# ===================================================================== +# BEWEIST, DASS COTURN UNSERE ZUGANGSDATEN AKZEPTIERT -- 18.09.2026 +# +# Aufrufen auf dem Server: bash tools/turn-probelauf.sh +# +# --------------------------------------------------------------------- +# WARUM ES DIESES SKRIPT GIBT +# +# `pruef-anruf.mjs` prueft die Rechenvorschrift gegen einen festen +# Vergleichswert. Das schuetzt davor, dass sie sich unbemerkt aendert -- +# es beweist aber NICHT, dass coturn das Ergebnis akzeptiert. Wir +# koennten beide Seiten konsequent falsch rechnen, und jede Pruefung +# waere gruen, waehrend kein einziger Anruf ueber die Vermittlung +# zustande kaeme. Das faellt im Betrieb nicht auf: Es scheitern nur die +# 15-25 %, die den Server ueberhaupt brauchen. +# +# Hier laeuft deshalb ein echter coturn und ein echter Client. +# +# --------------------------------------------------------------------- +# DASS ES NICHTS ANFASST, IST ABSICHT +# +# Eigenes Heimatverzeichnis, eigener Port, nur 127.0.0.1, kein root, +# kein Systemdienst. Der laufende coturn auf 3478 bleibt unberuehrt, +# und von aussen ist der Probe-Server nicht erreichbar. +# ===================================================================== +set -u +P="${TMPDIR:-/tmp}/turn-probe-$$" +FEHLER=0 +ok() { echo " ok $1"; } +fehl() { echo " FEHL $1"; FEHLER=$((FEHLER+1)); } + +aufraeumen() { pkill -f "$P/turn.conf" 2>/dev/null; kill "${GEGENPART:-0}" 2>/dev/null; sleep 1; rm -rf "$P"; } +trap aufraeumen EXIT + +command -v turnserver >/dev/null || { echo "KANN NICHT NACHSEHEN: coturn ist nicht installiert."; exit 3; } +command -v turnutils_uclient >/dev/null || { echo "KANN NICHT NACHSEHEN: turnutils_uclient fehlt."; exit 3; } +NODE=$(command -v node || echo /usr/local/bin/node) +[ -x "$NODE" ] || { echo "KANN NICHT NACHSEHEN: node nicht gefunden."; exit 3; } + +mkdir -p "$P" + +# ZIEL FUER DEN SPERRTEST -- und warum ausgerechnet dieses. +# Der erste Anlauf nahm 127.0.0.1. Die Gegenprobe (Abschnitt 4) zeigte: +# Auch OHNE Sperrliste kam 403 -- coturn verbietet Loopback-Ziele von +# sich aus. Der Abschnitt haette also NICHT unsere Sperrliste gemessen, +# sondern eine eingebaute Regel, und waere gruen geblieben, wenn jemand +# die Liste entfernt. Genau dafuer ist die Gegenprobe da. +# Die Docker-Bruecke ist ein echtes oertliches Ziel auf diesem Server +# und KEIN Loopback -- ohne unsere Liste waere sie erreichbar. +ZIEL="${TURN_PROBE_ZIEL:-172.17.0.1}" + +# WARUM HIER KEIN `-y` STEHT (und im ersten Anlauf stand): +# `-y` heisst "client-to-client" -- der Client verbindet dann zwei +# EIGENE Kanaele miteinander und ignoriert `-e` vollstaendig. Das Ziel +# war damit immer die Relay-Adresse 127.0.0.1, also Loopback, und das +# sperrt coturn von sich aus. Die Gegenprobe blieb deshalb zweimal rot, +# ohne dass an der Sperrliste etwas falsch war. Erst die Hilfeseite des +# Werkzeugs hat es gezeigt -- nicht das dritte Herumprobieren. +# Jetzt laeuft ein echter Gegenpart auf der Docker-Bruecke. +gegenpart_starten() { + turnutils_peer -L "$ZIEL" -p 3480 >/dev/null 2>&1 & + GEGENPART=$! + sleep 1 +} +gegenpart_beenden() { kill "$GEGENPART" 2>/dev/null; } +GEHEIM=$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n') + +# Die Konfiguration entspricht deploy/turnserver.conf; abweichend nur +# Adresse, Ports und dass das Protokoll in den Probeordner geht. +schreibe_konf() { # $1 = "mit-sperre" oder "ohne-sperre" + cat > "$P/turn.conf" <> "$P/turn.conf" <<'CONF' +denied-peer-ip=127.0.0.0-127.255.255.255 +denied-peer-ip=10.0.0.0-10.255.255.255 +denied-peer-ip=172.16.0.0-172.31.255.255 +denied-peer-ip=192.168.0.0-192.168.255.255 +CONF + fi +} + +starten() { + turnserver -c "$P/turn.conf" --no-stdout-log --log-file="$P/turn.log" >/dev/null 2>&1 & + for _ in 1 2 3 4 5 6 7 8 9 10; do + ss -lnu 2>/dev/null | grep -q 34780 && return 0 + sleep 0.5 + done + return 1 +} +anhalten() { pkill -f "$P/turn.conf" 2>/dev/null; sleep 1; } + +# Dieselbe Rechnung wie server/workspace-turn.js -- bewusst nachgebaut +# statt importiert: Wenn beide Seiten aus derselben Zeile kaemen, waere +# ein Fehler in dieser Zeile hier unsichtbar. +zugang() { + "$NODE" -e " +const {createHmac} = require('node:crypto'); +const u = (Math.floor(Date.now()/1000) + 12*3600) + ':w7'; +console.log(u + '\t' + createHmac('sha1', process.argv[1]).update(u).digest('base64')); +" "$GEHEIM" +} + +echo "=== Probelauf gegen einen echten coturn ===" +echo + +# -------------------------------------------------------------------- +echo "--- 1. Die Konfiguration aus deploy/turnserver.conf ---" +schreibe_konf mit-sperre +gegenpart_starten +if starten; then ok "coturn startet und lauscht auf 127.0.0.1:34780" +else fehl "coturn startet nicht -- $(tail -3 "$P/turn.log" 2>/dev/null | tr '\n' ' ')"; exit 1; fi + +A=$(turnutils_stunclient -p 34780 127.0.0.1 2>&1) +echo "$A" | grep -q "reflexive addr" \ + && ok "er beantwortet eine STUN-Anfrage ($(echo "$A" | grep -m1 -o '127[0-9.:]*'))" \ + || fehl "keine STUN-Antwort: $(echo "$A" | head -2 | tr '\n' ' ')" + +# -------------------------------------------------------------------- +echo +echo "--- 2. Die Kernfrage: nimmt coturn unsere gerechneten Daten an? ---" +L=$(zugang); U=$(echo "$L" | cut -f1); W=$(echo "$L" | cut -f2) +RICHTIG=$(turnutils_uclient -u "$U" -w "$W" -p 34780 -n 2 -c -e "$ZIEL" -r 3480 127.0.0.1 2>&1) +FALSCH=$(turnutils_uclient -u "$U" -w "nicht-das-richtige-passwort" -p 34780 -n 1 -c -e "$ZIEL" -r 3480 127.0.0.1 2>&1) + +# Der Unterschied ist eindeutig und genau der richtige: +# Mit gueltigen Daten kommt der Client UEBER die Allocation hinaus und +# scheitert erst am gesperrten Ziel. Mit ungueltigen scheitert schon +# die Allocation selbst. +echo "$RICHTIG" | grep -q "Cannot complete Allocation" \ + && fehl "unsere Zugangsdaten werden ABGEWIESEN -- kein Anruf ginge ueber die Vermittlung" \ + || ok "unsere Zugangsdaten werden angenommen (die Zuteilung gelingt)" + +echo "$FALSCH" | grep -q "Cannot complete Allocation" \ + && ok "ein falsches Passwort wird abgewiesen -- die Pruefung prueft wirklich" \ + || fehl "ein FALSCHES Passwort kam durch -- der Server steht jedem offen" + +# -------------------------------------------------------------------- +echo +echo "--- 3. Die Sperrliste: kommt jemand an die oertlichen Dienste? ---" +echo "$RICHTIG" | grep -qi "403" \ + && ok "ein Ziel im oertlichen Netz ($ZIEL) wird verweigert (403)" \ + || fehl "$ZIEL war als Ziel ERLAUBT -- ein Weg zu jedem oertlichen Dienst" + +# -------------------------------------------------------------------- +echo +echo "--- 4. Gegenprobe: ohne Sperrliste MUSS derselbe Versuch durchgehen ---" +echo " (sonst haette Abschnitt 3 nur gemessen, dass irgendetwas scheitert)" +anhalten +schreibe_konf ohne-sperre +if starten; then + L=$(zugang); U=$(echo "$L" | cut -f1); W=$(echo "$L" | cut -f2) + OHNE=$(turnutils_uclient -u "$U" -w "$W" -p 34780 -n 4 -c -e "$ZIEL" -r 3480 127.0.0.1 2>&1) + echo "$OHNE" | grep -qi "403" \ + && fehl "auch ohne Sperrliste 403 -- Abschnitt 3 beweist dann nichts" \ + || ok "ohne Sperrliste laeuft derselbe Versuch durch -- die Sperre war die Ursache" + echo "$OHNE" | grep -qiE "total transmit|tot_send_msgs|Total transmit time" \ + && ok "und es fliessen wirklich Daten ueber die Weiterleitung" \ + || echo " -- Datenfluss nicht ablesbar (nicht schlimm, der Rest steht)" +else + fehl "zweiter Start misslungen" +fi + +echo +if [ "$FEHLER" -eq 0 ]; then + echo "ALLES IN ORDNUNG -- coturn und der Workspace rechnen dasselbe." + exit 0 +fi +echo "NICHT IN ORDNUNG -- $FEHLER Punkt(e)." +exit 1 diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index f5be81aa..a4044584 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -22,25 +22,25 @@ }, "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609181442", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609181521", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609181442", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609181521", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609181442", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609181521", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609181442", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609181521", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/js/anruf.js b/workspace/assets/js/anruf.js index 00bb6553..05c47cc1 100644 --- a/workspace/assets/js/anruf.js +++ b/workspace/assets/js/anruf.js @@ -247,6 +247,10 @@ kastenBauen(); $('anruf').hidden = false; lage('Mikrofon …'); + /* Läuft NEBEN der Mikrofonfrage, nicht davor: Das Auffrischen + kostet sonst Zeit, in der nichts passiert -- und die Frage nach + dem Mikrofon dauert ohnehin länger als der kleine Abruf. */ + const adressenFertig = adressenAuffrischen(); let eigener; try { eigener = await eigenenStromHolen(mitVideo); @@ -255,6 +259,7 @@ setTimeout(() => { if (!anruf) $('anruf').hidden = true; }, 6000); return; } + await adressenFertig; anruf = { raumId, video: mitVideo, verbindungen: new Map(), stroeme: new Map(), eigener, beginn: Date.now() }; $('anruf-video').hidden = !mitVideo; @@ -278,6 +283,10 @@ } async function annehmen(raumId) { + /* Auch hier auffrischen -- wer angerufen WIRD, braucht die + Vermittlung genauso. Beide Wege einzeln abgesichert, nicht nur + der, an den man zuerst denkt. */ + const adressenFertig = adressenAuffrischen(); const a = await fetch(`/workspace/api/anruf/${raumId}/dabei`, { method: 'POST', credentials: 'same-origin', }).catch(() => null); @@ -288,6 +297,7 @@ return; } const d = await a.json(); + await adressenFertig; kastenBauen(); $('anruf').hidden = false; let eigener; @@ -500,6 +510,53 @@ } } + /* ---------- Verbindungsadressen ---------------------------------------- + Die Zugangsdaten zum Vermittlungsserver VERFALLEN (sie werden vom + Server gerechnet, nicht gespeichert -- siehe server/workspace-turn.js). + + Diese Seite bleibt als App oft tagelang offen. Wer sie nur beim + Laden holt, telefoniert am zweiten Tag ohne Vermittlung -- und das + fällt nicht auf: Es scheitern nur die Anrufe derer, die sie + gebraucht hätten. Genau dafür ist sie gebaut. + + Deshalb vor jedem Anruf nachsehen. Der Ablaufzeitpunkt steht im + Benutzernamen (`:`) -- er lässt sich hier ablesen, + ohne dass der Server ein zusätzliches Feld schicken muss. */ + + /* Fünf Minuten Vorlauf: Ein Anruf, der in 30 Sekunden beginnt, soll + nicht mit Daten starten, die in 40 ablaufen. */ + const ZUGANG_VORLAUF = 300; + let adressenGeholt = false; + + function adressenFrisch() { + if (!adressenGeholt) return false; + const jetzt = Math.floor(Date.now() / 1000); + return adressen.every((a) => { + const m = /^(\d{10,}):/.exec(a && a.username || ''); + return !m || Number(m[1]) > jetzt + ZUGANG_VORLAUF; + }); + } + + async function adressenAuffrischen(erzwingen) { + if (!erzwingen && adressenFrisch()) return; + try { + const a = await fetch('/workspace/api/anruf/adressen', { credentials: 'same-origin' }); + if (a.ok) { + const d = await a.json(); + adressen = d.adressen || []; + nurGleichesNetz = !!d.nur_gleiches_netz; + gruppeMax = d.gruppe_max || 4; + adressenGeholt = true; + /* Ein eingetragener Vermittlungsserver, der niemanden + durchlässt, ist schlimmer als keiner -- er sieht aus, als + wäre alles eingerichtet. Deshalb steht er in der Konsole. */ + if (d.turn_grund) { + console.warn('[anruf] Vermittlungsserver ohne Zugangsdaten:', d.turn_grund); + } + } + } catch { /* dann eben ohne -- im selben Netz geht es trotzdem */ } + } + /* ---------- Nach außen -------------------------------------------------- */ window.anrufStarten = starten; @@ -508,15 +565,7 @@ window.anrufBereit = () => adressen.length > 0 || nurGleichesNetz; (async () => { - try { - const a = await fetch('/workspace/api/anruf/adressen', { credentials: 'same-origin' }); - if (a.ok) { - const d = await a.json(); - adressen = d.adressen || []; - nurGleichesNetz = !!d.nur_gleiches_netz; - gruppeMax = d.gruppe_max || 4; - } - } catch { /* dann eben ohne -- im selben Netz geht es trotzdem */ } + await adressenAuffrischen(); try { const i = await fetch('/workspace/api/ich', { credentials: 'same-origin' }); if (i.ok) ichId = (await i.json())?.id ?? null; diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 438deecb..9a9ecacd 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - + @@ -291,10 +291,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 2ecd2bbe..47adb294 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -168,10 +168,10 @@ - - - - - + + + + + diff --git a/workspace/befinden.html b/workspace/befinden.html index 7dd3cb4d..080ac9e3 100644 --- a/workspace/befinden.html +++ b/workspace/befinden.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -105,9 +105,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 21fec4b0..de057359 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,24 +12,24 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - - + + - + @@ -243,14 +243,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/bewerben.html b/workspace/bewerben.html index 8a12e878..33929993 100644 --- a/workspace/bewerben.html +++ b/workspace/bewerben.html @@ -7,17 +7,17 @@ - - - - - - - - - + + + + + + + + + - + @@ -107,10 +107,10 @@ - - - - - + + + + + diff --git a/workspace/bewerbungen.html b/workspace/bewerbungen.html index 1298c9ad..0200ec5b 100644 --- a/workspace/bewerbungen.html +++ b/workspace/bewerbungen.html @@ -7,16 +7,16 @@ - - - - - - - - + + + + + + + + - + @@ -80,10 +80,10 @@ - - - - - + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index fe3b518b..9e0ea19d 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 5df781d4..191c35ea 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,15 +341,15 @@ - - - - + + + + - - + + diff --git a/workspace/content.html b/workspace/content.html index 61aff365..5e9c9049 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 4eee4528..a742d637 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -339,6 +339,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index 1bbeef3a..fd8da0b9 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -22,25 +22,25 @@ }, "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609181442", + "src": "/assets/img/app-symbole/crew-192.png?v=202609181521", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609181442", + "src": "/assets/img/app-symbole/crew-512.png?v=202609181521", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609181442", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609181521", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609181442", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609181521", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index 28652546..a6ca6909 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index bc4bbc9e..c7925010 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -102,9 +102,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index 93b36843..3a2ea1c2 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 1f840ca6..744eeb55 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 4882c1b3..50e951de 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -444,10 +444,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 66281780..904fcc99 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,10 +423,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index b8932b92..0544fe12 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index c1493f57..b8043ed1 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,9 +134,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/report.html b/workspace/report.html index 53e0d640..dc2d0bd6 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 5d951d34..e3e02039 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 10089e58..15fddd7d 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index f3aeff19..2ffa9581 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index f505ade5..da0a537e 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -146,11 +146,11 @@ - - - - - - + + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index db4ae9cc..d30ab113 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,18 +7,18 @@ - - - - - - + + + + + + - - - + + + @@ -111,9 +111,9 @@ - - - - + + + + diff --git a/workspace/team.html b/workspace/team.html index 0ac2c90a..322bcfa0 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index e867d795..044507dc 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/teilen.html b/workspace/teilen.html index 3e333fa2..24196118 100644 --- a/workspace/teilen.html +++ b/workspace/teilen.html @@ -7,13 +7,13 @@ - - - - - - - + + + + + + + @@ -112,9 +112,9 @@ - - - - + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index 33b4fb99..92c0fba3 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -126,9 +126,9 @@ - - - - + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index 02a72d10..a9296837 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -211,10 +211,10 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 13fe5fb8..b0186ebb 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 2debfe32..2d089dbb 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +