diff --git a/server/pruef-anruf.mjs b/server/pruef-anruf.mjs index 47c04f29..188b8384 100644 --- a/server/pruef-anruf.mjs +++ b/server/pruef-anruf.mjs @@ -686,10 +686,42 @@ melde("=== 6b. Zugangsdaten fuer die Vermittlung ==="); 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"); + /* AUS EINEM EINTRAG WERDEN DREI WEGE (angepasst 19.09.2026). + + Bis heute stand hier `adressen[0]` -- und das war der einzige + Eintrag. Seit ein schlichtes `turn:host:port` in stun + turn/udp + + turn/tcp aufgefaechert wird (workspace-turn.js), ist + `adressen[0]` der STUN-Weg, und der hat RICHTIGERWEISE kein + Passwort. Die Pruefung suchte also am falschen Eintrag. + + WARUM ES DIESE AUFFAECHERUNG GIBT: Ein `turn:` ohne + `?transport=` heisst UDP, und nur UDP. Wer in einem Netz sitzt, + das UDP sperrt, bekam damit keinen Vermittlungsweg -- es + klingelte, und danach passierte nichts. Genau der Fehler, den + Filipe am 19.09. gemeldet hat. + + Gesucht wird jetzt nach ART, nicht nach Platz. Ein Index ist + eine Annahme darueber, wie die Liste aussieht -- und die hat + sich schon einmal geaendert. */ + const turnWege = (r.adressen || []).filter( + (e) => /^turns?:/.test(String(e.urls || ""))); + const stunWege = (r.adressen || []).filter( + (e) => /^stun:/.test(String(e.urls || ""))); + const ersterTurn = turnWege[0]; + + ok(r.turn_gesamt === 2 && r.turn_bereit === 2, + `der Modi bekommt zwei einsatzbereite Wege (${r.turn_bereit}/${r.turn_gesamt})`); + ok(turnWege.length === 2 + && turnWege.some((e) => /transport=udp/.test(String(e.urls))) + && turnWege.some((e) => /transport=tcp/.test(String(e.urls))), + `naemlich UDP und TCP (${turnWege.map((e) => e.urls).join(", ")})`); + ok(stunWege.length === 1, "dazu ein STUN-Weg, der ohne Vermittlung auskommt"); + /* Die ZAHL in die Bedingung, nicht nur in den Meldetext: `every()` + auf einer leeren Liste waere sonst trivial wahr. */ + ok(turnWege.length === 2 && turnWege.every((e) => e.credential && e.username), + "und beide tragen Benutzername und Passwort"); + ok(!stunWege[0]?.credential, + "STUN bekommt keins -- es kennt keine Anmeldung"); /* DIE WICHTIGSTE PRUEFUNG DES ABSCHNITTS. Im ROHEN Text gesucht, nicht im ausgewerteten Objekt. */ @@ -699,12 +731,14 @@ melde("=== 6b. Zugangsdaten fuer die Vermittlung ==="); /* 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, + const dogiTurn = (rDogi.adressen || []).find( + (e) => /^turns?:/.test(String(e.urls || ""))); + ok(!!dogiTurn?.username && dogiTurn.username !== ersterTurn?.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]); + const ablauf = Number(String(ersterTurn?.username || "").split(":")[0]); ok(ablauf > Math.floor(Date.now() / 1000) + 3600, `der Ablauf steht vorn im Benutzernamen und liegt in der Zukunft (${ablauf})`); } @@ -812,7 +846,20 @@ melde("=== 7. Im Browser ==="); 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})`); + /* WAEHREND ES KLINGELT, ZAEHLT DIE UHR NICHT (geaendert 19.09.2026). + + Vorher stand hier, die Uhr MUESSE laufen -- und sie tat es, + ab dem Waehlen. Wer 30 Sekunden klingeln liess und 12 + Sekunden sprach, las im Kasten "00:42" und danach im Chat + "Anruf . 12 Sekunden". Zwei Zahlen fuer dieselbe Sache. + + Jetzt beginnt sie beim Rangehen. Solange es klingelt, ist + das Feld leer -- und GENAU DAS wird hier geprueft, statt + die Pruefung einfach zu streichen: Eine Zeile weniger + haette denselben Fehler beim naechsten Umbau wieder + durchgelassen. */ + ok(kasten.zeit === "" || kasten.zeit === "–", + `waehrend es klingelt zaehlt die Uhr NICHT ("${kasten.zeit}")`); /* Und es klingelt hoerbar. Waere der Ton wieder ausgebaut, stuende hier 0. */ @@ -979,7 +1026,13 @@ melde("=== 8. Zu dritt ==="); const k = document.getElementById("anruf-hoeren"); return k ? { text: k.textContent.trim(), an: k.getAttribute("aria-pressed") } : null; }); - ok(knopf?.text === "Lautsprecher an" && knopf?.an === "true", + /* DER ZUSTAND STEHT IN aria-pressed, NICHT MEHR IM TEXT + (geaendert 19.09.2026). Die Aufschrift "Lautsprecher an" war + zweideutig -- "ist an" oder "schalt an"? Jetzt traegt das + durchgestrichene Zeichen den Zustand und das Wort nur die + Sache. Geprueft wird deshalb aria-pressed; der Text bleibt + gleich und beweist nichts mehr. */ + ok(knopf?.an === "true", `der Lautsprecher-Knopf ist da und steht auf an („${knopf?.text}")`); await a.se.click("#anruf-hoeren"); @@ -991,8 +1044,8 @@ melde("=== 8. Zu dritt ==="); const k = document.getElementById("anruf-hoeren"); return { text: k.textContent.trim(), an: k.getAttribute("aria-pressed") }; }); - ok(knopf2.text === "Lautsprecher aus" && knopf2.an === "false", - `und der Knopf sagt es auch („${knopf2.text}")`); + ok(knopf2.an === "false", + `und der Knopf steht auf aus (aria-pressed="${knopf2.an}")`); /* NUR BEI IHM. Ein Schalter, der bei den anderen mitwirkt, waere kein Lautsprecher, sondern eine Fernbedienung. */ diff --git a/server/pruef-turn-wege.mjs b/server/pruef-turn-wege.mjs new file mode 100644 index 00000000..bee540c4 --- /dev/null +++ b/server/pruef-turn-wege.mjs @@ -0,0 +1,142 @@ +/* ===================================================================== + pruef-turn-wege.mjs — Es klingelt, aber es verbindet nicht. + + Filipe, 19.09.2026: „es klingelt erscheint auch alles bei jedem aber + telefonieren klappt immer noch nicht was läuft ich will es heute an + die leute bringen also los." + + --------------------------------------------------------------------- + WAS VORHER GEMESSEN WURDE — UND WARUM ALLES GRÜN WAR + + Der Reihe nach, bevor eine Zeile Code angefasst wurde: + + coturn läuft aktiv + STUN von außen antwortet, nennt die eigene Adresse + echte Zuteilung von außen 7/7, das Testpaket kam an + Dreier-Anruf im Prüflauf verbindet, alle hören alle + + Vier Messungen, vier Mal in Ordnung — und das Telefon ging trotzdem + nicht. Das ist die unangenehmste Lage überhaupt, und sie hat genau + einen Grund: Der Prüflauf fährt BEIDE Seiten auf demselben Rechner. + Dort finden sie sich über die direkte Adresse, und die Vermittlung + wird nie gebraucht. Draußen sitzen sie in verschiedenen Netzen. + + --------------------------------------------------------------------- + DER FUND + + Eingetragen war eine einzige Adresse: `turn:159.195.212.167:3478`. + + Ein `turn:` OHNE `?transport=` heißt **UDP**, und nur UDP. Gemessen + hört coturn aber auf beidem — 3478/UDP offen, 3478/TCP offen. + Angeboten wurde nur der halbe Server. + + Wer in einem Netz sitzt, das UDP nach draußen sperrt (Mobilfunk mit + strengem Profil, Gast-WLAN, Firmennetz), bekommt damit KEINEN + Vermittlungsweg. Es klingelt — das läuft über die Website, also über + 443 — und danach passiert nichts. + + --------------------------------------------------------------------- + WAS DIESE PRÜFUNG SICHERT + + Nicht „gibt es einen TURN-Server" — das war schon vorher grün und hat + nichts genützt. Sondern: **Kommt aus einem eingetragenen Eintrag + wirklich jeder Weg heraus, und trägt jeder davon Zugangsdaten?** + + Der zweite Teil ist der wichtigere. Würde aufgefächert, ohne danach + anzumelden, hätten zwei von drei Wegen kein Passwort — coturn weist + sie ab, und wir hätten den Fehler nur verschoben. + + AUFRUF: node server/pruef-turn-wege.mjs + Läuft in unter einer Sekunde, ohne Server, ohne Netz. + ===================================================================== */ + +import { writeFileSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { wegeAusfaechern, mitZugangsdaten, turnZugang } from "./workspace-turn.js"; + +let ok = 0, fehl = 0; +const pruef = (name, bedingung, zusatz = "") => { + if (bedingung) { ok++; console.log(` OK ${name}`); } + else { fehl++; console.log(` FEHL ${name}${zusatz ? " — " + zusatz : ""}`); } +}; + +console.log("\n=== Vermittlungswege: kommt jeder heraus, und trägt jeder ein Passwort? ===\n"); + +/* Ein Geheimnis nur für diese Prüfung -- nie das echte. Es wandert in + eine Datei, weil mitZugangsdaten aus einer Datei liest; sie wird am + Ende wieder entfernt. */ +const DATEI = join(tmpdir(), `turn-pruef-${process.pid}.geheimnis`); +writeFileSync(DATEI, "x".repeat(48), "utf8"); + +try { + /* ---- 1. Ein schlichter Eintrag wird zu drei Wegen ---------------- */ + const raus = wegeAusfaechern([{ urls: "turn:beispiel.de:3478" }]); + const urls = raus.map((e) => String(e.urls)); + + pruef("aus einem Eintrag werden genau drei Wege", raus.length === 3, + `es sind ${raus.length}: ${urls.join(", ")}`); + pruef("darunter STUN — der Weg, der ohne Vermittlung auskommt", + urls.includes("stun:beispiel.de:3478")); + pruef("darunter der schnelle Weg (UDP)", + urls.includes("turn:beispiel.de:3478?transport=udp")); + pruef("darunter der Weg durch fast jede Sperre (TCP)", + urls.includes("turn:beispiel.de:3478?transport=tcp"), + "GENAU DIESER hat gefehlt — deshalb ging es nicht"); + pruef("STUN steht vorn, TCP hinten", /^stun:/.test(urls[0]) && /transport=tcp$/.test(urls[2]), + urls.join(" → ")); + + /* ---- 2. Und jeder turn:-Weg bekommt Zugangsdaten ----------------- */ + const { adressen, turn_gesamt, turn_bereit } = + mitZugangsdaten(raus, "w1", Date.now(), DATEI); + + pruef("beide turn:-Wege werden gezählt", turn_gesamt === 2, `gezählt: ${turn_gesamt}`); + /* DIE ZAHL GEHOERT IN DIE BEDINGUNG, nicht nur in den Meldetext: + Bei einer leeren Liste waere "alle haben ein Passwort" sonst + trivialerweise wahr. */ + pruef("und beide haben auch eins", turn_bereit === 2 && turn_gesamt === 2, + `bereit: ${turn_bereit} von ${turn_gesamt}`); + + const mitPasswort = adressen.filter( + (e) => /^turns?:/.test(String(e.urls)) && e.credential); + pruef("nachgezählt an der ausgelieferten Liste", mitPasswort.length === 2, + `${mitPasswort.length} von 2`); + pruef("STUN bekommt KEINS — es kennt keine Anmeldung", + !adressen.find((e) => /^stun:/.test(String(e.urls)))?.credential); + + /* ---- 3. Was ausdrücklich gemeint war, bleibt unangetastet -------- */ + const fremd = wegeAusfaechern([ + { urls: "turn:eigen.de:3478?transport=tcp" }, + { urls: "stun:schon.da:3478" }, + { urls: "turn:fremder-dienst.de:3478", username: "a", credential: "b" }, + ]); + pruef("ein Eintrag MIT transport bleibt, wie er ist", + fremd.length === 3 && fremd.some((e) => e.urls === "turn:eigen.de:3478?transport=tcp")); + pruef("ein fremder Dienst mit eigenem Passwort wird nicht angefasst", + fremd.some((e) => e.urls === "turn:fremder-dienst.de:3478" && e.credential === "b")); + + /* ---- 4. Die Zugangsdaten sind zeitlich begrenzt ------------------ */ + const z = turnZugang("w1", "x".repeat(48), Date.parse("2026-09-19T12:00:00Z")); + pruef("der Benutzername trägt seinen Ablauf", /^\d+:w1$/.test(z.username), z.username); + pruef("und er läuft wirklich ab", z.ablauf > Math.floor(Date.parse("2026-09-19T12:00:00Z") / 1000)); + + /* ---- DIE GEGENPROBEN --------------------------------------------- + Eine Prüfung, die immer bestätigt, bestätigt nichts. */ + console.log("\n --- Gegenproben (jede MUSS anschlagen) ---"); + + const ohneTcp = wegeAusfaechern([{ urls: "turn:x:1" }]) + .filter((e) => !/transport=tcp/.test(String(e.urls))); + pruef("Gegenprobe: fehlte der TCP-Weg, schlüge Prüfung 1 an", + !ohneTcp.some((e) => /transport=tcp/.test(String(e.urls)))); + + const ohneGeheimnis = mitZugangsdaten(raus, "w1", Date.now(), + join(tmpdir(), "gibt-es-nicht-" + process.pid)); + pruef("Gegenprobe: ohne Geheimnis meldet die Rechnung 0 bereit und nennt den Grund", + ohneGeheimnis.turn_bereit === 0 && !!ohneGeheimnis.grund, + `bereit ${ohneGeheimnis.turn_bereit}, Grund "${ohneGeheimnis.grund}"`); +} finally { + try { unlinkSync(DATEI); } catch { /* egal */ } +} + +console.log(`\n ${ok} bestanden, ${fehl} fehlgeschlagen\n`); +process.exit(fehl ? 1 : 0); diff --git a/server/workspace-anruf.js b/server/workspace-anruf.js index 1614dd14..cbe115d8 100644 --- a/server/workspace-anruf.js +++ b/server/workspace-anruf.js @@ -73,7 +73,7 @@ import { } from "./workspace.js"; import { chatEreignis, istDrinFuerAnruf, teilnehmerFuerAnruf } from "./workspace-chat.js"; import { darfTelefonieren } from "./workspace-treffchat.js"; -import { mitZugangsdaten } from "./workspace-turn.js"; +import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js"; export const anrufRouter = express.Router(); @@ -324,8 +324,23 @@ anrufRouter.get("/workspace/api/anruf/adressen", (req, res) => { Als Kennung die PERSONENNUMMER, nicht der Name -- sie landet im Protokoll von coturn, und das ist kein Ort fuer Klarnamen. */ + /* ERST AUFFAECHERN, DANN ANMELDEN (19.09.2026). + + Ein eingetragenes `turn:host:port` ohne `?transport=` heisst + UDP -- und nur UDP. Wer in einem Netz sitzt, das UDP nach + draussen sperrt, bekommt damit keinen Vermittlungsweg: Es + klingelt (das laeuft ueber 443), und danach passiert nichts. + + Nachgemessen hoert coturn auf beidem. Aus einem Eintrag werden + deshalb drei Wege -- stun, turn/udp, turn/tcp. Die Reihenfolge + ist Absicht, siehe workspace-turn.js. + + VOR mitZugangsdaten, nicht danach: Die neuen turn:-Eintraege + brauchen dieselben gerechneten Zugangsdaten wie der Eintrag, aus + dem sie entstanden sind. Andersherum waeren zwei von drei ohne + Passwort -- also genau der Zustand, den wir beheben wollen. */ const { adressen, turn_gesamt, turn_bereit, grund } = - mitZugangsdaten(liste, `w${req.person.id}`); + mitZugangsdaten(wegeAusfaechern(liste), `w${req.person.id}`); if (grund) { /* DER DRITTE AUSGANG. „Kann nicht nachsehen" ist etwas anderes diff --git a/server/workspace-turn.js b/server/workspace-turn.js index da6def1b..3e9801f7 100644 --- a/server/workspace-turn.js +++ b/server/workspace-turn.js @@ -181,3 +181,105 @@ export function mitZugangsdaten(liste, kennung, jetzt = Date.now(), grund: "", }; } + +/* ===================================================================== + EIN EINTRAG, DREI WEGE — 19.09.2026 + + Filipe: „es klingelt erscheint auch alles bei jedem aber telefonieren + klappt immer noch nicht." + + --------------------------------------------------------------------- + WAS GEMESSEN WURDE, BEVOR ETWAS GEÄNDERT WURDE + + Der Reihe nach, und jeder Schritt war grün: + + coturn läuft aktiv + STUN von außen antwortet, nennt meine Adresse + echte Zuteilung von außen 7 von 7 Prüfungen, Paket kam an + Dreier-Anruf im Prüflauf verbindet, alle hören alle + + Es war also weder der Server noch die Logik. Der Unterschied zwischen + Prüflauf und Wirklichkeit ist das NETZ: Im Prüflauf sitzen beide + Seiten auf demselben Rechner und finden sich über die direkte + Adresse. Draußen sitzen sie in verschiedenen Netzen. + + --------------------------------------------------------------------- + DER FUND + + Eingetragen war genau eine Adresse: + + turn:159.195.212.167:3478 + + Ein `turn:` OHNE `?transport=` heißt **UDP**. Nachgemessen hört + coturn aber auf beidem: 3478/UDP offen, 3478/TCP offen (5349/TLS ist + zu). Angeboten wurde nur der UDP-Weg. + + Wer in einem Netz sitzt, das UDP nach draußen sperrt — Mobilfunk mit + strengem Profil, Gast-WLAN, Firmennetz, manche Hotspots —, bekommt + damit KEINEN Vermittlungsweg. Es klingelt (das läuft über die + Website, also über 443), und danach passiert nichts. Genau das + Bild, das Filipe beschreibt. + + --------------------------------------------------------------------- + WARUM ABGELEITET UND NICHT EINGETRAGEN + + Man könnte die drei Zeilen von Hand in die Einstellung schreiben. + Dann stünden dort drei Adressen, die denselben Host meinen, und beim + nächsten Serverumzug müsste jemand an drei Stellen denken — die + abgeschriebene Liste, die dieses Haus schon zweimal teuer bezahlt + hat. + + Stattdessen: Aus einem eingetragenen `turn:host:port` entstehen + automatisch + + stun:host:port damit jeder seine öffentliche + Adresse findet (das ist der Weg, + der in den meisten Fällen reicht + und KEINE Vermittlung kostet) + turn:host:port?transport=udp der schnelle Weg + turn:host:port?transport=tcp der Weg, der durch fast jede + Sperre kommt + + Wer eine Adresse MIT `?transport=` einträgt, meint sie genau so — + die bleibt unangetastet. Und ein fremder Dienst mit eigenem Passwort + sowieso (siehe mitZugangsdaten). + + DIE REIHENFOLGE IST ABSICHT: STUN zuerst, dann UDP, dann TCP. Der + Browser probiert alle gleichzeitig und nimmt den ersten, der trägt — + aber bei Gleichstand gewinnt der frühere, und TCP-Vermittlung ist + der langsamste der drei. + ===================================================================== */ + +/** Aus jedem schlichten `turn:`-Eintrag drei Wege machen. + * + * @param liste wie eingetragen + * @returns {object[]} die erweiterte Liste + */ +export function wegeAusfaechern(liste) { + const rein = Array.isArray(liste) ? liste : []; + const raus = []; + const schon = new Set(); + const dazu = (e) => { + const s = String(e?.urls || ""); + if (!s || schon.has(s)) return; + schon.add(s); + raus.push(e); + }; + + for (const e of rein) { + const url = String(e?.urls || ""); + const m = /^turn:([^?]+)$/.exec(url); + /* Kein schlichter turn:-Eintrag -- unverändert übernehmen. Das + trifft stun:, turns:, alles mit ?transport= und jeden fremden + Dienst mit eigenen Zugangsdaten. */ + if (!m || e?.credential) { dazu(e); continue; } + + const ziel = m[1]; + /* STUN braucht keine Anmeldung und bekommt deshalb auch keine -- + mitZugangsdaten lässt stun:-Einträge ohnehin in Ruhe. */ + dazu({ urls: `stun:${ziel}` }); + dazu({ ...e, urls: `turn:${ziel}?transport=udp` }); + dazu({ ...e, urls: `turn:${ziel}?transport=tcp` }); + } + return raus; +} diff --git a/server/workspace-werdegang.js b/server/workspace-werdegang.js index 8673b571..8029c79a 100644 --- a/server/workspace-werdegang.js +++ b/server/workspace-werdegang.js @@ -104,7 +104,7 @@ import express from "express"; import { - db, sitzungLesen, fuehrtTeamDogi, ROLLEN_NAME, + db, sitzungLesen, fuehrtTeamDogi, ROLLEN_NAME, ROLLEN_SORTIERUNG, } from "./workspace.js"; import { ENTWICKLUNG_BLOECKE, ENTWICKLUNG_PUNKTE, ENTWICKLUNG_STUFEN, @@ -587,10 +587,26 @@ werdegangRouter.use("/workspace/api/werdegang", angemeldet); ------------------------------------------------------------------- */ werdegangRouter.get("/workspace/api/werdegang/liste", nurLeitung, (req, res) => { try { + /* NACH ROLLE, DANN NACH NAMEN (19.09.2026). + + Filipe: „die reihenfolge der listen soll immer angepasst sein + hatten wir doch schon. die rechte hand rolle immer zuerst und + dan die modis." + + Vorher stand hier nur `ORDER BY name` mit der Begründung, eine + Sortierung nach Aktivität wäre eine Rangliste mit anderem Namen. + Das gilt weiter — aber eine Sortierung nach ROLLE ist keine: + Sie sagt nichts darüber, wie gut jemand ist, sondern nur, welche + Aufgabe er hat. Genau dieselbe Reihenfolge benutzt das Haus in + `ROLLEN_SORTIERUNG` schon überall sonst. + + ABGELEITET, NICHT ABGESCHRIEBEN: Die Reihenfolge kommt aus + derselben Konstante wie in der Personenliste. Wer dort eine + Rolle verschiebt, verschiebt sie hier mit. */ const leute = db().prepare( `SELECT id, name, rolle FROM personen WHERE aktiv = 1 AND rolle IN (${KARTEN_ROLLEN.map(() => "?").join(",")}) - ORDER BY name COLLATE NOCASE`).all(...KARTEN_ROLLEN); + ORDER BY ${ROLLEN_SORTIERUNG}, name COLLATE NOCASE`).all(...KARTEN_ROLLEN); const spalten = monatsspalten(); const liste = leute.map((p) => { @@ -604,6 +620,11 @@ werdegangRouter.get("/workspace/api/werdegang/liste", nurLeitung, (req, res) => return { id: p.id, name: p.name, + /* DIE ROLLE SELBST KOMMT MIT, nicht nur ihr Name: Die + Oberfläche gruppiert danach und zeichnet die Leitung anders. + Über den NAMEN zu gruppieren wäre die Stelle, die beim + nächsten Umbenennen auseinanderfällt. */ + rolle: p.rolle, rolle_name: ROLLEN_NAME[p.rolle] ?? p.rolle, gesetzt: stand.length, gesamt: ENTWICKLUNG_FREMD.length, diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index c745c28a..e0d6e57a 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +