diff --git a/.gitignore b/.gitignore index eabfba6..d4a9109 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ coverage/ test-results/ playwright-report/ deploy/*.env +acceptance-*.png diff --git a/CHANGES.md b/CHANGES.md index b980436..8d3b081 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -284,12 +284,23 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/sync.ts` (`you`), `protocol/src/sync.ts` (`AccessMessage.you`) | Plan §7.4 | erweitert (F5.4): die Zugangsnachricht beim Verbinden nennt die Person (Kennung, Name, Farbe aus der Karte), damit der Editor sich den anderen zeigen kann — der Keks ist für die Seite nicht lesbar | | `src/blobs.ts`, `src/blobRoutes.ts`, `src/simWorker.ts` (Blobs), `src/simRooms.ts` (Blobs), `src/simChannel.ts`, `src/server.ts` (`/blobs`), `src/main.ts`, `test/blobs.test.ts` | Plan §5.1 | neu (F5.5): die Blobs auf F, inhaltsadressiert (`filius_blob`, F rechnet den Namen selbst aus); `GET /blobs/?project=` mit Berechtigung am Projekt, `POST /blobs?project=` mit Schreibstufe, nicht eingefroren, nur von der Herkunft des Editors, höchstens 16 MiB; CORS für die Herkünfte des Editors; der Raumfaden fragt nach den Blobs seines Dokuments, ein Start wartet auf die gefragten, was F noch nicht hat, wird ein paar Mal neu gefragt und kommt in die laufende Simulation; ein Blob, den die Simulation macht, wird gespeichert, bevor die Änderung, die ihn nennt, angewandt wird. Noch nicht: Blobs vergessen, die kein Projekt mehr nennt | | `src/editorFiles.ts`, `src/precompress.ts`, `src/precompressMain.ts`, `src/server.ts` (Editor), `src/main.ts` (`FILIUS_EDITOR_DIR`), `test/editorFiles.test.ts`, `editor/browser/fromF.spec.ts` | Plan §8.2, §8.3 | neu (F5.6b): F liefert die Seite und das Bundle des Editors selbst aus (Ordner `editor/` neben `main.mjs`), damit der Editor von Fs Herkunft kommt und sie als seinen Server nimmt; nur GET und HEAD, nur Dateien im Ordner (jedes `..`, auch kodiert, wird aufgelöst und außerhalb abgewiesen); Fs eigene Wege gehen vor; `assets/` (Vite benennt nach dem Inhalt) ein Jahr im Cache, alles andere jedes Mal neu gefragt; der Build legt `.br` und `.gz` daneben, wo sie kleiner sind, F schickt die bessere, die der Browser nimmt (das Bundle 695 KB → 212 KB mit brotli). Ohne Ordner beantwortet F nur seine Wege und meldet es im Protokoll | +| `src/ticket.ts` (`TICKET_AHEAD_SECONDS`), `test/ticket.test.ts`, `tools/ticket-oracle` (zwei Fälle) | itsdangerous 2.2.0 `TimestampSigner.unsign` | geändert (F5.6c): eine Karte darf bis zu 5 s vor Fs Uhr signiert sein — itsdangerous lehnt schon eine Sekunde ab. Gemessen auf test-filius: Fs Uhr 886 ms hinter der des Dashboard-Rechners wies fast neun von zehn Karten ab, mit NTP 8 ms noch eine von 125 (eine Karte trägt ganze Sekunden). Das Orakel hält fest, dass Python die Karten 1 s und 5 s voraus ablehnt und 6 s voraus wie F | | `test/editorConnection.test.ts` | — | neu (F5.3b), erweitert (F5.4: Identität von F, die anderen über die Awareness, auch eine lesende Person, weg mit dem Schließen): `connectSharedDoc` des Editors gegen einen echten F — bereit mit Schreib- und Lesestufe, eingefroren und aufgetaut (neues Dokument, die verirrte Änderung ist weg, Schreiben geht wieder), eingefroren von Beginn an, Absage, Widerruf, Löschen, Loslassen | | `test/sim.test.ts`, `test/harness.ts` (Faden) | — | neu (F5.3a): echte Sockets, der echte Faden (esbuild bündelt ihn wie der Build), Beispiel internetwork_komplett: ein Start für alle mit Namen, Ping in einer Befehlszeile bei der anderen, Zurückschreiben ins Dokument und eine Datei aus dem Dokument im Raum, Lesestufe, Absagen, Nachlauf, Einfrieren, Löschen, Widerruf, Wahl des Fadens, scheiternder Faden, Herunterfahren, Ping | | `test/ticket.test.ts`, `test/tickets.ts`, `test/tickets.python.json` | `tools/ticket-oracle` | neu (F5.2): jede Karte des Python-Orakels liest Node wie Python (Nutzlast oder derselbe Grund der Ablehnung); in Node signierte Karten tragen dasselbe JSON zum selben Zeitpunkt (Nodes zlib packt anders, ungepackt byte-gleich); die Ansprüche | | `test/access.test.ts`, `test/harness.ts` | — | neu (F5.2): `/__auth` (Keks, `next`, schlechte Karten, unbekanntes Projekt, zweites Projekt, nächste Person, Verlängerung), Absagen an `/sync` mit Grund, Herkunft, Dienstweg (Schlüssel, Anlegen, Fehlerantworten, Löschen, Einfrieren, Widerruf einzeln und ganz, Sitzungsliste, 500) | | `test/sync.test.ts`, `test/database.ts` | — | neu (F5.1), seit F5.2 mit echter Anmeldung (Karte, Keks): echte Clients (Hocuspocus' Provider über WebSocket) gegen eine Wegwerf-Postgres in Docker: Wiederkehr nach Neustart, nie verdichtete Aktualisierungen nach hartem Halt, zwei Personen live, Verdichten (500, Ruhe, nichts ohne Änderung), Schließen mit Personen, die sofort wiederkommen, gelöschtes Projekt, doppeltes Anlegen, Hausmeister beim Laden und nach einer Zusammenführung, unbekanntes Projekt, Lesestufe, `/healthz`, Protokoll | +## tools/live + +Messen und Abnehmen an einem laufenden F (Standard `test-filius`), mit +dessen Geheimnissen aus `/root/.filius-test.env`: `lib.ts` (Projekt über +`/svc`, Beispiel über `/sync` hineingeschrieben, Anmelden mit Karte, Raum über +`/sim`, jedes angelegte Projekt am Ende gelöscht), `m3.ts` (Räume in Stufen, +Antwortzeit einer Sonde), `m4.ts` (Dokumentgröße, Aktualisierungen beim +Ziehen), `acceptance.ts` (Abnahme F5 mit zwei Browsern), `clock.mjs` +(Versatz von Fs Uhr) — neu (F5.6c). + ## tools/ticket-oracle Schreibt Eintrittskarten mit itsdangerous 2.2.0 selbst (der Version des diff --git a/README.md b/README.md index 9d1c3f7..c22b49f 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,16 @@ Braucht Docker Compose v2. Gesichert werden muss nur das Volume `conf.d`); vorher die Adresse von F für `filius-lxc.invalid` einsetzen. `FILIUS_ORIGINS` ist jeweils der eigene Name mit `https://`. +**Die Uhr von F muss per NTP laufen** (beim LXC: die des Proxmox-Hosts). +Eine Eintrittskarte trägt ganze Sekunden; F nimmt sie bis 5 s vor seiner +Uhr. Ging F 886 ms nach, scheiterte fast jede Anmeldung. +`node tools/live/clock.mjs ` misst den Versatz. + +Messen und Abnehmen an einem laufenden F: `tools/live/` (`m3.ts` Last, +`m4.ts` Dokumentgröße, `m5.ts` Dauerverbindung, `acceptance.ts` zwei +Browser in einem Projekt) mit den Geheimnissen der Umgebung in +`/root/.filius-test.env`; jeder Lauf legt frische Projekte an und löscht sie. + Das Image liefert auch den Editor aus: F nimmt den Ordner `editor/` neben `main.mjs` (anderswo `FILIUS_EDITOR_DIR`). Es läuft auf Node 24, die Entwicklung auf Node 20; vor einem Update einmal mit diff --git a/package.json b/package.json index 3afb49d..e659c29 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "scripts": { "test": "vitest run", - "typecheck": "tsc -b && tsc -p packages/core/test && tsc -p packages/fls/test && tsc -p packages/doc/test && tsc -p packages/protocol/test && tsc -p packages/sim/test && tsc -p packages/server/test && tsc -p packages/sim/browser && tsc -p packages/editor && tsc -p packages/editor/test", + "typecheck": "tsc -b && tsc -p packages/core/test && tsc -p packages/fls/test && tsc -p packages/doc/test && tsc -p packages/protocol/test && tsc -p packages/sim/test && tsc -p packages/server/test && tsc -p packages/sim/browser && tsc -p packages/editor && tsc -p packages/editor/test && tsc -p tools/live", "test:browser": "playwright test", "check": "npm run typecheck && npm run test", "hooks": "git config core.hooksPath scripts/hooks", diff --git a/packages/server/src/ticket.ts b/packages/server/src/ticket.ts index f3b8f9c..f4cc9e7 100644 --- a/packages/server/src/ticket.ts +++ b/packages/server/src/ticket.ts @@ -3,8 +3,13 @@ // JSON, zlib-packed when that is shorter (then a leading "."), base64url; // the moment of signing as the shortest big-endian bytes, base64url; an // HMAC-SHA1 over both under the key sha1(salt + "signer" + secret) -// ("django-concat"), base64url. Valid for TICKET_SECONDS after signing and -// not before it, as itsdangerous counts (whole seconds). What lies under a +// ("django-concat"), base64url. Valid for TICKET_SECONDS after signing, as +// itsdangerous counts (whole seconds), and — unlike itsdangerous, which +// refuses a ticket a second ahead — from TICKET_AHEAD_SECONDS before it: +// the dashboard's machine and F's each have their clock, and a ticket +// carries whole seconds. F's clock 8 ms behind refused one ticket in 125, +// 886 ms behind nearly nine in ten (measured on test-filius, 04.10.2026); +// a person then sees "invalid ticket" for nothing they did. What lies under a // matching signature the dashboard made: its base64 is read as Node reads // it (leniently, as Python does). Python's own tickets are the oracle // (tools/ticket-oracle). @@ -17,6 +22,8 @@ import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; import { inflateSync } from 'node:zlib'; export const TICKET_SECONDS = 60; +/** How far ahead of F's clock a ticket may be signed: two machines' clocks, never quite one. */ +export const TICKET_AHEAD_SECONDS = 5; export const TICKET_SALT = 'mdg-filius-ticket'; export const LEVELS = ['read', 'write'] as const; export type Level = (typeof LEVELS)[number]; @@ -61,7 +68,7 @@ export function readSigned(ticket: string, secret: string, now: number, salt = T if (stamp < 0) throw new TicketError('no timestamp'); const age = now - Buffer.from(value.slice(stamp + 1), 'base64url').reduce((sum, byte) => sum * 256 + byte, 0); if (age > TICKET_SECONDS) throw new TicketError(`ticket ${age} s old`); - if (age < 0) throw new TicketError('ticket from the future'); + if (age < -TICKET_AHEAD_SECONDS) throw new TicketError('ticket from the future'); const payload = value.slice(0, stamp); const packed = payload.startsWith('.'); try { diff --git a/packages/server/test/ticket.test.ts b/packages/server/test/ticket.test.ts index 25d9a7c..2b75587 100644 --- a/packages/server/test/ticket.test.ts +++ b/packages/server/test/ticket.test.ts @@ -2,7 +2,8 @@ // (tools/ticket-oracle → tickets.python.json): every ticket the oracle made // reads the same — the same payload, or refused where Python refuses it, for // the same reason (a signature that does not match, an age beyond 60 s or -// below 0) —; tickets signed in Node carry the same JSON and the same moment +// more than 5 s ahead) —, except a ticket signed up to 5 s ahead of F's +// clock, which Python refuses and F takes (two machines' clocks); tickets signed in Node carry the same JSON and the same moment // as Python's (packed by Node's zlib, Chromium's, the bytes differ; an // unpacked one is the same to the byte); and // the claims the server takes from a ticket: a person, a login, a project @@ -11,7 +12,7 @@ import { readFileSync } from 'node:fs'; import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; -import { readSigned, signature, TICKET_SALT, TICKET_SECONDS, TicketError, verifyTicket } from '../src/ticket.js'; +import { readSigned, signature, TICKET_AHEAD_SECONDS, TICKET_SALT, TICKET_SECONDS, TicketError, verifyTicket } from '../src/ticket.js'; import { signTicket } from './tickets.js'; interface OracleCase { @@ -36,6 +37,8 @@ const unpacked = (ticket: string) => { return (ticket.startsWith('.') ? inflateSync(bytes) : bytes).toString('utf8'); }; const byName = (name: string) => oracle.cases.find((each) => each.name === name)!; +/** Python refuses these, F takes them: signed ahead of F's clock, within TICKET_AHEAD_SECONDS. */ +const AHEAD = ['fromTheFuture', 'fiveSecondsAhead']; function outcome(ticket: string, now: number): { payload: unknown } | { error: string } { try { @@ -52,7 +55,7 @@ describe('the ticket as itsdangerous signs it', () => { expect([TICKET_SALT, TICKET_SECONDS]).toEqual([oracle.salt, oracle.ttl]); }); - it.each(oracle.cases.map((each) => [each.name, each] as const))('reads „%s“ as Python does', (_name, each) => { + it.each(oracle.cases.filter((each) => !AHEAD.includes(each.name)).map((each) => [each.name, each] as const))('reads „%s“ as Python does', (_name, each) => { expect(outcome(each.ticket, each.now)).toEqual(each.error === undefined ? { payload: each.payload } : { error: each.error }); }); @@ -66,6 +69,13 @@ describe('the ticket as itsdangerous signs it', () => { expect(readSigned(ours, oracle.secret, oracle.signedAt)).toEqual(each.payload); }); + it.each(AHEAD)('takes „%s“, which Python refuses: a ticket from up to 5 s ahead of F\'s clock', (name) => { + const each = byName(name); + expect(each.error).toBe('expired'); + expect(outcome(each.ticket, each.now)).toEqual({ payload: byName('plain').payload }); + expect(oracle.signedAt - each.now).toBeLessThanOrEqual(TICKET_AHEAD_SECONDS); + }); + it('covers a packed and an unpacked payload', () => { expect(byName('plain').ticket.startsWith('.')).toBe(true); expect(byName('listPayload').ticket.startsWith('.')).toBe(false); diff --git a/packages/server/test/tickets.python.json b/packages/server/test/tickets.python.json index 890103e..4fd01f6 100644 --- a/packages/server/test/tickets.python.json +++ b/packages/server/test/tickets.python.json @@ -88,6 +88,18 @@ "now": 1789999999, "error": "expired" }, + { + "name": "fiveSecondsAhead", + "ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.xwmcG5wEqkYKe3DxNMUxtEnXaQo", + "now": 1789999995, + "error": "expired" + }, + { + "name": "sixSecondsAhead", + "ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.xwmcG5wEqkYKe3DxNMUxtEnXaQo", + "now": 1789999994, + "error": "expired" + }, { "name": "otherSecret", "ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.4wnVSAZOvBXnfPb_aCCd2137AEI", diff --git a/tools/live/acceptance.ts b/tools/live/acceptance.ts new file mode 100644 index 0000000..c3b2e2b --- /dev/null +++ b/tools/live/acceptance.ts @@ -0,0 +1,161 @@ +// Acceptance of F5 on a live server F (plan §F5): two real browsers in one +// fresh project. At the same time both move a device; one places a computer, +// the other cables it while the first moves another; then one starts the +// simulation, the other makes a file and pings in a computer's command line, +// the first sees that same command line and types in it too; the second +// stops. In the end Mia's drawing, Ben's drawing and the document F stored +// are the same, and the file made in the simulation is in the document. +// Every run makes its own project and deletes it (Scratch's lesson 9). +// +// npx vite-node tools/live/acceptance.ts + +import { randomUUID } from 'node:crypto'; +import { chromium, type Browser, type Page } from '@playwright/test'; +import { nodeFiles, readProject } from '@mdg-filius/doc'; +import { drag, onDevice } from '../../packages/editor/browser/editorPage.js'; +import { cleanUp, joinDoc, seededProject, signIn, signInAddress } from './lib.js'; + +const results: [string, boolean, string][] = []; +const check = (name: string, ok: boolean, detail = '') => { + results.push([name, ok, detail]); + console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail === '' ? '' : ` (${detail})`}`); +}; +async function eventually(name: string, test: () => Promise, ms = 10_000, detail?: () => Promise) { + for (const end = Date.now() + ms; ;) { + if (await test().catch(() => false)) return check(name, true); + if (Date.now() > end) return check(name, false, detail === undefined ? 'timed out' : await detail()); + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +const boxes = (page: Page) => page.locator('g.device[data-node]').evaluateAll((all) => Object.fromEntries(all.map((each) => [each.getAttribute('data-node'), each.getAttribute('data-box')]))); +const linkCount = (page: Page) => page.locator('path[data-link]').count(); +const canvas = (page: Page) => page.locator('svg.filius-canvas'); + +async function person(browser: Browser, project: string, name: string, user: string): Promise { + const context = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + const page = await context.newPage(); + page.on('pageerror', (error) => check(`${name}: no page error`, false, error.message)); + await page.goto(signInAddress(project, { name, user })); + await page.waitForSelector('svg.filius-canvas'); + pages.push(page); + return page; +} + +async function commandLine(page: Page, id: string) { + const at = await onDevice(page, id); + await page.mouse.click(at.x, at.y); + const desktop = page.locator(`section[data-desktop="${id}"]`); + await desktop.locator('[data-program="commandPrompt"]').click(); + const window = desktop.locator('[data-program-window="commandPrompt"]'); + return { input: window.locator('input[name="terminalInput"]'), text: window.locator('.terminal-text') }; +} + +const browser = await chromium.launch(); +const pages: Page[] = []; +try { + const { project, example } = await seededProject('java-examples/internetwork_komplett.fls'); + const node = (name: string) => example.graph.nodes.find((each) => each.displayName === name)!.id; + // A computer's address is its device's setting (a router's are its cards'). + const ipOf = (name: string) => example.deviceConfigurations.get(node(name))!.ipAddress; + const [miaUser, benUser] = [randomUUID(), randomUUID()]; + const mia = await person(browser, project, 'Mia', miaUser); + const ben = await person(browser, project, 'Ben', benUser); + await eventually('Mia and Ben see each other', async () => (await mia.locator(`[data-person="${benUser}"]`).isVisible()) && (await ben.locator(`[data-person="${miaUser}"]`).isVisible())); + const devices = await mia.locator('g.device').count(); + const links = await linkCount(mia); + + // Both move a device at the same time. + const before = await boxes(mia); + const shift = async (page: Page, id: string, dx: number, dy: number) => { + const from = await onDevice(page, id); + await drag(page, from, { x: from.x + dx, y: from.y + dy }); + }; + // Onto free ground: a device moved onto another would take the next click meant for the one below. + await Promise.all([shift(mia, node('Rechner 3.10'), -50, 60), shift(ben, node('Notebook 4.10'), 80, 60)]); + await eventually('both moves arrive at both', async () => { + const [m, b] = [await boxes(mia), await boxes(ben)]; + return JSON.stringify(m) === JSON.stringify(b) && m[node('Rechner 3.10')] !== before[node('Rechner 3.10')] && m[node('Notebook 4.10')] !== before[node('Notebook 4.10')]; + }); + + // Ben places a computer; Mia cables it to Switch 1 while Ben moves the web server. + const known = new Set(Object.keys(await boxes(ben))); + const palette = (await ben.locator('[data-palette="pc"]').boundingBox())!; + const surface = (await canvas(ben).boundingBox())!; + await drag(ben, { x: palette.x + palette.width / 2, y: palette.y + palette.height / 2 }, { x: surface.x + 330, y: surface.y + 230 }); + await ben.keyboard.press('Escape'); + let placed = ''; + await eventually('the computer Ben placed is at Mia too', async () => { + placed = Object.keys(await boxes(ben)).find((id) => !known.has(id)) ?? ''; + return placed !== '' && (await mia.locator(`g.device[data-node="${placed}"]`).count()) === 1; + }); + await mia.locator('[data-palette="cable"]').click(); + await Promise.all([(async () => drag(mia, await onDevice(mia, placed), await onDevice(mia, node('Switch 1'))))(), shift(ben, node('Webserver 5.10'), 70, -40)]); + await mia.locator('[data-palette="cable"]').click(); + await eventually( + 'the cable and the move arrive at both', + async () => (await linkCount(mia)) === links + 1 && (await linkCount(ben)) === links + 1 && JSON.stringify(await boxes(mia)) === JSON.stringify(await boxes(ben)), + ); + + // Mia starts; the simulation runs for both, named after her. + await mia.locator('button[name="simulationMode"]').click(); + await eventually( + 'the simulation runs for both, started by Mia', + async () => (await canvas(ben).getAttribute('data-mode')) === 'simulation' && (await ben.locator('[data-note="startedBy"]').textContent()) === 'Simulation gestartet von Mia', + ); + + // Ben makes a file and pings in Notebook 1.10's command line; Mia sees the same and types in it too. + const notebook = node('Notebook 1.10'); + const benLine = await commandLine(ben, notebook); + await benLine.input.fill('touch acceptance.txt'); + await benLine.input.press('Enter'); + await benLine.input.fill(`ping ${ipOf('Webserver 5.10')}`); + await benLine.input.press('Enter'); + const miaLine = await commandLine(mia, notebook); + await eventually( + "Ben's ping is in Mia's command line", + async () => /4 Paket\(e\) empfangen/.test((await miaLine.text.textContent()) ?? ''), + 20_000, + async () => ((await miaLine.text.textContent()) ?? '').slice(-200), + ); + await miaLine.input.fill('ls'); + await miaLine.input.press('Enter'); + await eventually("Mia's ls, with Ben's file, is in Ben's command line", async () => /acceptance\.txt/.test((await benLine.text.textContent()) ?? '')); + + // Ben stops: both are back in design mode. + await ben.locator('button[name="designMode"]').click(); + await eventually('both are back in design mode', async () => (await canvas(mia).getAttribute('data-mode')) === 'design' && (await canvas(ben).getAttribute('data-mode')) === 'design'); + + // The same everywhere: both drawings and the document F stored. + const [m, b] = [await boxes(mia), await boxes(ben)]; + check("Mia's drawing is Ben's", JSON.stringify(m) === JSON.stringify(b)); + check('one more device and one more cable', Object.keys(m).length === devices + 1 && (await linkCount(mia)) === links + 1, `${Object.keys(m).length} devices, ${await linkCount(mia)} cables`); + await new Promise((resolve) => setTimeout(resolve, 2000)); + const stored = await joinDoc(project, await signIn(project, { name: 'Prüfung' })); + const read = readProject(stored.doc); + check("F's document has Mia's devices", read.graph.nodes.length === Object.keys(m).length && read.graph.nodes.every((each) => m[each.id] !== undefined), `${read.graph.nodes.length} devices`); + check("F's document has Mia's cables", read.graph.links.length === links + 1, `${read.graph.links.length} cables`); + const moved = ['Rechner 3.10', 'Notebook 4.10', 'Webserver 5.10'].map((name) => { + const was = example.graph.nodes.find((each) => each.displayName === name)!.position; + const now = read.graph.nodes.find((each) => each.id === node(name))!.position; + return now.x !== was.x || now.y !== was.y; + }); + check("F's document has all three moves", moved.every(Boolean), JSON.stringify(moved)); + check( + "the file Ben made in the simulation is in F's document", + nodeFiles(stored.doc, notebook) + ?.allEntries() + .some((each) => each.path.endsWith('/acceptance.txt')) === true, + ); + stored.close(); +} catch (error) { + check('ran to the end', false, error instanceof Error ? error.message.split('\n')[0]! : String(error)); + // What each saw when it stopped, for the one who looks next. + for (const [index, page] of pages.entries()) await page.screenshot({ path: `acceptance-${index === 0 ? 'mia' : 'ben'}.png` }).catch(() => undefined); +} finally { + await browser.close(); + await cleanUp(); +} +const failed = results.filter(([, ok]) => !ok).length; +console.log(failed === 0 ? `\nF5 accepted: ${results.length} checks` : `\n${failed} of ${results.length} checks failed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/tools/live/clock.mjs b/tools/live/clock.mjs new file mode 100644 index 0000000..e9a42ad --- /dev/null +++ b/tools/live/clock.mjs @@ -0,0 +1,28 @@ +// How far server F's clock is from this machine's: the moment F's Date header +// turns to the next second, against our clock at that moment (the middle of +// the two requests around it). A ticket carries whole seconds, and F refuses +// one from its future, so F must not be behind the dashboard's machine. +// +// node tools/live/clock.mjs [host] [port] +import http from 'node:http'; +const [host = '172.22.0.38', port = '8081'] = process.argv.slice(2); +const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }); +const ask = () => + new Promise((resolve, reject) => { + const sent = Date.now(); + http + .get({ host, port, path: '/healthz', agent }, (r) => { + r.resume(); + r.on('end', () => resolve({ sent, got: Date.now(), date: Date.parse(r.headers.date) })); + }) + .on('error', reject); + }); +let last = await ask(); +const offsets = []; +for (const end = Date.now() + 6000; Date.now() < end;) { + const now = await ask(); + if (now.date !== last.date) offsets.push(Math.round(now.date - (last.got + now.sent) / 2) + `±${Math.ceil((now.sent - last.got) / 2)}`); + last = now; +} +agent.destroy(); +console.log(`${host}:${port} clock minus ours (ms): ${offsets.join(' ')}`); diff --git a/tools/live/lib.ts b/tools/live/lib.ts new file mode 100644 index 0000000..6f8b57c --- /dev/null +++ b/tools/live/lib.ts @@ -0,0 +1,171 @@ +// Measuring on test-filius: projects through the service, people through +// tickets, the shared document through /sync, rooms through /sim. The +// secrets come from /root/.filius-test.env and are never printed. +import { readFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import * as Y from 'yjs'; +import { openProjectArchive } from '@mdg-filius/fls'; +import { writeProject } from '@mdg-filius/doc'; +import { signTicket } from '../../packages/server/test/tickets.js'; +import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider'; +import WebSocket from 'ws'; + +const env = Object.fromEntries( + readFileSync('/root/.filius-test.env', 'utf8') + .split('\n') + .filter((l) => l.includes('=')) + .map((l) => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]), +); +export const HOST = process.env.FILIUS_HOST ?? 'test-filius.hub.mdg-hamburg.de'; +export const BASE = `https://${HOST}`; +const SECRET = env.FILIUS_TICKET_SECRET!; +const KEY = env.FILIUS_SERVICE_KEY!; +export const made: string[] = []; +/** + * How many seconds before now the tickets are signed. F before + * TICKET_AHEAD_SECONDS refused a ticket from its future, and test-filius' + * clock was behind this machine's (886 ms, then 8 ms with NTP). + */ +const SKEW_SECONDS = Number(process.env.SKEW_SECONDS ?? 2); + +export async function svc(method: string, path: string, body?: unknown): Promise<{ status: number; json: any }> { + const r = await fetch(`${BASE}${path}`, { method, headers: { 'x-filius-service-key': KEY, 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + return { status: r.status, json: await r.json().catch(() => null) }; +} + +export async function newProject(): Promise { + const id = randomUUID(); + const r = await svc('POST', '/svc/projects', { project: id }); + if (r.status !== 201) throw new Error(`project not made: ${r.status}`); + made.push(id); + return id; +} + +export async function cleanUp(): Promise { + for (const id of made.splice(0)) await svc('DELETE', `/svc/projects/${id}`); +} + +/** The address a person's browser opens: a fresh ticket, then the editor on the project. */ +export function signInAddress(project: string, { user = randomUUID(), name = 'Messung', level = 'write' }: { user?: string; name?: string; level?: string } = {}): string { + const ticket = signTicket({ u: user, n: name.toLowerCase().replace(/\W/g, ''), d: name, c: '#3366cc', lang: 'de', p: project, lvl: level }, SECRET, Math.floor(Date.now() / 1000) - SKEW_SECONDS); + return `${BASE}/__auth?ticket=${encodeURIComponent(ticket)}&next=${encodeURIComponent(`/?project=${project}`)}`; +} + +export async function signIn(project: string, { user = randomUUID(), name = 'Messung', level = 'write' }: { user?: string; name?: string; level?: string } = {}): Promise { + const ticket = signTicket({ u: user, n: name.toLowerCase().replace(/\W/g, ''), d: name, c: '#3366cc', lang: 'de', p: project, lvl: level }, SECRET, Math.floor(Date.now() / 1000) - SKEW_SECONDS); + const r = await fetch(`${BASE}/__auth?ticket=${encodeURIComponent(ticket)}&next=/`, { redirect: 'manual' }); + if (r.status !== 302) throw new Error(`not signed in: ${r.status}`); + return r.headers.get('set-cookie')!.split(';')[0]!; +} + +export function readExample(file: string) { + return openProjectArchive(new Uint8Array(readFileSync(new URL(`../../corpus/${file}`, import.meta.url))), () => randomUUID()); +} + +export interface Shared { + doc: Y.Doc; + provider: any; + close(): void; +} + +export function joinDoc(project: string, cookie: string): Promise { + const doc = new Y.Doc(); + class Browser extends WebSocket { + constructor(url: string, protocols?: string | string[]) { + super(url, protocols, { headers: { cookie, origin: BASE } }); + this.on('error', () => {}); + } + } + const socket = new HocuspocusProviderWebsocket({ url: `wss://${HOST}/sync`, WebSocketPolyfill: Browser }); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('not synced in 15 s')), 15_000); + const provider = new HocuspocusProvider({ + websocketProvider: socket, + name: project, + document: doc, + token: 'cookie', + onSynced: () => { + clearTimeout(timer); + resolve({ + doc, + provider, + close: () => { + provider.destroy(); + socket.destroy(); + }, + }); + }, + onAuthenticationFailed: ({ reason }: any) => reject(new Error(`refused ${reason}`)), + }); + provider.attach(); + }); +} + +export async function whenStored(shared: Shared, ms = 15_000): Promise { + for (let waited = 0; shared.provider.unsyncedChanges > 0; waited += 20) { + if (waited > ms) throw new Error('not stored'); + await new Promise((r) => setTimeout(r, 20)); + } +} + +/** A project of F with an example written into it, as one person's editor would. */ +export async function seededProject(file: string) { + const project = await newProject(); + const example = readExample(file); + const shared = await joinDoc(project, await signIn(project)); + writeProject(shared.doc, example); + await whenStored(shared); + shared.close(); + return { project, example }; +} + +export interface Room { + send(m: object): void; + messages: any[]; + waitFor(test: (m: any) => boolean, ms?: number): Promise; + onMessage: ((m: any) => void) | undefined; + close(): void; + closed: Promise; +} + +export function openRoom(project: string, cookie: string): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(`wss://${HOST}/sim?project=${project}`, { headers: { cookie, origin: BASE } }); + const messages: any[] = []; + const waiters: { test: (m: any) => boolean; resolve: (m: any) => void }[] = []; + let closedWith: (r: string) => void = () => {}; + const room: Room = { + send: (m) => socket.send(JSON.stringify({ v: 1, ...m })), + messages, + onMessage: undefined, + waitFor: (test, ms = 10_000) => + new Promise((resolve, reject) => { + const found = messages.find(test); + if (found) return resolve(found); + const timer = setTimeout(() => reject(new Error('waited in vain')), ms); + waiters.push({ + test, + resolve: (m) => { + clearTimeout(timer); + resolve(m); + }, + }); + }), + close: () => socket.close(), + closed: new Promise((r) => (closedWith = r)), + }; + socket.on('message', (data: Buffer) => { + const m = JSON.parse(data.toString()); + room.onMessage?.(m); + if (m.kind !== 'activity') messages.push(m); + for (const w of [...waiters]) + if (w.test(m)) { + waiters.splice(waiters.indexOf(w), 1); + w.resolve(m); + } + }); + socket.on('open', () => resolve(room)); + socket.on('error', (e: Error) => reject(e)); + socket.on('close', (code: number, reason: Buffer) => closedWith(`${code} ${reason}`)); + }); +} diff --git a/tools/live/m3.ts b/tools/live/m3.ts new file mode 100644 index 0000000..b99ca6d --- /dev/null +++ b/tools/live/m3.ts @@ -0,0 +1,93 @@ +// M3 on a live server F: how many simulation rooms it carries. Each room is a +// fresh project with the largest example, one person, the simulation at +// 100 %, the cables' activity watched (as an editor does) and a ping across +// the network every 6 s. The probe: once a second per room, an action for a +// device that does not exist — the room answers it at once, between two of +// its thread's ticks, so the time to the answer is how far behind the +// room's thread is (plus the way there and back). Rooms are added in steps; +// each step is held, then measured. Every project made is deleted at the end. +// +// npx vite-node tools/live/m3.ts [steps, e.g. 1,10,25,50] [seconds per step] + +import { cleanUp, openRoom, seededProject, signIn, svc, type Room } from './lib.js'; + +const EXAMPLE = 'java-examples/Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls'; +const steps = (process.argv[2] ?? '1,10,25,50,100,150,200').split(',').map(Number); +const seconds = Number(process.argv[3] ?? 45); +const SETTLE_SECONDS = 10; + +interface Running { + readonly room: Room; + readonly timers: NodeJS.Timeout[]; +} + +const rooms: Running[] = []; +let samples: number[] = []; +let lost = 0; +let errors = 0; +const pending = new Map(); +let counter = 0; + +async function addRoom(): Promise { + const { project, example } = await seededProject(EXAMPLE); + const room = await openRoom(project, await signIn(project, { name: `Raum ${rooms.length + 1}` })); + const ipad = example.graph.nodes.find((node) => node.displayName === 'iPad 1')!; + room.onMessage = (message) => { + if (message.kind === 'error' && typeof message.reqId === 'string' && pending.has(message.reqId)) { + samples.push(performance.now() - pending.get(message.reqId)!); + pending.delete(message.reqId); + } else if (message.kind === 'error' && message.code !== 'unknownNode') errors += 1; + }; + room.send({ kind: 'speed', value: 100 }); + room.send({ kind: 'start' }); + await room.waitFor((message) => message.kind === 'state' && message.mode === 'simulation', 20_000); + room.send({ kind: 'sub', topics: ['activity', `app:${ipad.id}/terminal`] }); + const probe = () => { + const reqId = `p${counter++}`; + pending.set(reqId, performance.now()); + room.send({ kind: 'input', node: 'probe', app: 'terminal', action: 'enter', args: { line: '' }, reqId }); + }; + const ping = () => room.send({ kind: 'input', node: ipad.id, app: 'terminal', action: 'enter', args: { line: 'ping 120.130.140.80' }, reqId: `ping${counter++}` }); + const timers: NodeJS.Timeout[] = [setInterval(ping, 6000)]; + // Spread over the second, so that the probes do not all arrive at one tick. + timers.push(setTimeout(() => timers.push(setInterval(probe, 1000)), Math.random() * 1000)); + rooms.push({ room, timers }); + ping(); +} + +const quantile = (sorted: number[], q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? NaN; + +try { + console.log('rooms probes p50 ms p95 ms p99 ms max ms lost errors F rooms seeding s'); + for (const target of steps) { + const seeding = performance.now(); + while (rooms.length < target) { + await addRoom(); + await new Promise((resolve) => setTimeout(resolve, Math.random() * 50)); + } + const seeded = (performance.now() - seeding) / 1000; + await new Promise((resolve) => setTimeout(resolve, SETTLE_SECONDS * 1000)); + samples = []; + lost = 0; + errors = 0; + for (const sent of pending.values()) if (performance.now() - sent > 10_000) lost += 1; + pending.clear(); + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + lost += [...pending.values()].filter((sent) => performance.now() - sent > 5_000).length; + const sorted = [...samples].sort((a, b) => a - b); + const health = await (await fetch('https://test-filius.hub.mdg-hamburg.de/healthz')).json(); + console.log( + [rooms.length, sorted.length, quantile(sorted, 0.5), quantile(sorted, 0.95), quantile(sorted, 0.99), sorted.at(-1) ?? NaN] + .map((value, index) => (index < 2 ? String(value) : value.toFixed(0)).padStart(index === 0 ? 5 : 7)) + .join(' ') + ` ${String(lost).padStart(4)} ${String(errors).padStart(6)} ${String(health.rooms).padStart(7)} ${seeded.toFixed(0).padStart(9)}`, + ); + } +} finally { + for (const { room, timers } of rooms) { + timers.forEach((timer) => clearInterval(timer)); + room.close(); + } + await cleanUp(); + const health = await (await fetch('https://test-filius.hub.mdg-hamburg.de/healthz')).json(); + console.log('after clean-up:', JSON.stringify(health), (await svc('GET', '/svc/sessions')).status); +} diff --git a/tools/live/m4.ts b/tools/live/m4.ts new file mode 100644 index 0000000..dadc647 --- /dev/null +++ b/tools/live/m4.ts @@ -0,0 +1,59 @@ +// M4 on a live server F: how large the shared document of the largest example +// is, and how many updates dragging a device makes. Mia drags a device in a +// real browser for two seconds at 60 pointer moves a second (as a finger or +// a mouse does); a second person, watching through /sync, counts the updates +// that arrive and their bytes. Then the document's size as F holds it: fresh, +// after the drags, and as one update (what a new person loads). +// +// npx vite-node tools/live/m4.ts + +import * as Y from 'yjs'; +import { chromium } from '@playwright/test'; +import { onDevice } from '../../packages/editor/browser/editorPage.js'; +import { cleanUp, joinDoc, seededProject, signIn, signInAddress } from './lib.js'; + +const EXAMPLE = 'java-examples/Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls'; +const MOVES_PER_SECOND = 60; +const SECONDS = 2; + +const browser = await chromium.launch(); +try { + const { project, example } = await seededProject(EXAMPLE); + const watcher = await joinDoc(project, await signIn(project, { name: 'Zuschauer' })); + console.log(`fresh document: ${Y.encodeStateAsUpdate(watcher.doc).length} bytes, ${example.graph.nodes.length} devices, ${example.graph.links.length} cables`); + const page = await (await browser.newContext({ viewport: { width: 1400, height: 900 } })).newPage(); + await page.goto(signInAddress(project, { name: 'Mia' })); + await page.waitForSelector('svg.filius-canvas'); + const arrivals: { at: number; bytes: number }[] = []; + watcher.doc.on('update', (update: Uint8Array, origin: unknown) => { + if (origin !== null && typeof origin === 'object') arrivals.push({ at: performance.now(), bytes: update.length }); + }); + for (const name of ['Heimrouter', 'webserver.de (120.130.140.80)', 'iPad 1']) { + arrivals.length = 0; + const from = await onDevice(page, example.graph.nodes.find((node) => node.displayName === name)!.id); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + const start = performance.now(); + const moves = MOVES_PER_SECOND * SECONDS; + for (let step = 1; step <= moves; step++) { + await page.mouse.move(from.x + 150 * Math.sin((step / moves) * Math.PI), from.y + 80 * (step / moves)); + const due = start + (step * 1000) / MOVES_PER_SECOND; + await new Promise((resolve) => setTimeout(resolve, Math.max(0, due - performance.now()))); + } + await page.mouse.up(); + const dragged = (performance.now() - start) / 1000; + await new Promise((resolve) => setTimeout(resolve, 1500)); + const bytes = arrivals.reduce((sum, each) => sum + each.bytes, 0); + console.log( + `drag ${name.padEnd(30)} ${dragged.toFixed(1)} s: ${arrivals.length} updates (${(arrivals.length / dragged).toFixed(0)}/s), ${bytes} bytes, ${(bytes / Math.max(1, arrivals.length)).toFixed(0)} per update`, + ); + } + console.log(`after the drags: ${Y.encodeStateAsUpdate(watcher.doc).length} bytes as one update`); + const fresh = await joinDoc(project, await signIn(project, { name: 'Neu' })); + console.log(`a new person loads: ${Y.encodeStateAsUpdate(fresh.doc).length} bytes`); + fresh.close(); + watcher.close(); +} finally { + await browser.close(); + await cleanUp(); +} diff --git a/tools/live/m5.ts b/tools/live/m5.ts new file mode 100644 index 0000000..2d34bcf --- /dev/null +++ b/tools/live/m5.ts @@ -0,0 +1,47 @@ +// M5 on a live server F: does a WebSocket through the openresty hold an hour? +// One person in a fresh project, with /sync (the document) and /sim (the +// room) open and nothing to say — the proxy's limit is between two bytes, +// so only the pings of F (/sim, 30 s) and Hocuspocus (/sync) keep them. Every +// five minutes a line; at the end a probe through /sim and a change through +// /sync, to show that both still carry. +// +// npx vite-node tools/live/m5.ts [minutes] + +import { cleanUp, joinDoc, newProject, openRoom, signIn } from './lib.js'; + +const minutes = Number(process.argv[2] ?? 65); +const started = Date.now(); +const elapsed = () => `${((Date.now() - started) / 60_000).toFixed(1)} min`; +try { + const project = await newProject(); + const cookie = await signIn(project, { name: 'Dauer' }); + const shared = await joinDoc(project, cookie); + const room = await openRoom(project, cookie); + let syncClosed = ''; + shared.provider.on('close', ({ event }: { event: { code: number; reason: string } }) => (syncClosed ||= `${event.code} ${event.reason} at ${elapsed()}`)); + let simClosed = ''; + void room.closed.then((reason) => (simClosed = `${reason} at ${elapsed()}`)); + console.log(`open: /sync and /sim, holding ${minutes} min`); + for (let minute = 5; minute <= minutes; minute += 5) { + await new Promise((resolve) => setTimeout(resolve, Math.min(5, minutes - minute + 5) * 60_000)); + console.log(`${elapsed()}: /sync ${syncClosed || 'open'}, /sim ${simClosed || 'open'}`); + } + room.send({ kind: 'input', node: 'probe', app: 'terminal', action: 'enter', args: { line: '' }, reqId: 'last' }); + const answer = await room + .waitFor((message) => message.reqId === 'last', 10_000) + .then( + () => 'answered', + () => 'no answer', + ); + shared.doc.getMap('meta').set('m5', Date.now()); + const stored = await new Promise((resolve) => { + const end = Date.now() + 10_000; + const look = () => (shared.provider.unsyncedChanges === 0 ? resolve('stored') : Date.now() > end ? resolve('not stored') : setTimeout(look, 50)); + look(); + }); + console.log(`after ${elapsed()}: /sim probe ${answer}, /sync change ${stored}; closed: /sync ${syncClosed || 'never'}, /sim ${simClosed || 'never'}`); + room.close(); + shared.close(); +} finally { + await cleanUp(); +} diff --git a/tools/live/tsconfig.json b/tools/live/tsconfig.json new file mode 100644 index 0000000..150c3a9 --- /dev/null +++ b/tools/live/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "composite": false, + "declaration": false, + "lib": ["ES2023", "DOM"], + "types": ["node"] + }, + "include": ["."] +} diff --git a/tools/ticket-oracle/tickets.py b/tools/ticket-oracle/tickets.py index 1917b89..737d747 100644 --- a/tools/ticket-oracle/tickets.py +++ b/tools/ticket-oracle/tickets.py @@ -64,6 +64,8 @@ def main(out): ("sixtySecondsOld", plain, SIGNED_AT + 60), ("sixtyOneSecondsOld", plain, SIGNED_AT + 61), ("fromTheFuture", plain, SIGNED_AT - 1), + ("fiveSecondsAhead", plain, SIGNED_AT - 5), + ("sixSecondsAhead", plain, SIGNED_AT - 6), ("otherSecret", sign(claims(), secret="another-secret"), SIGNED_AT), ("scratchSalt", sign(claims(), salt="mdg-scratch-ticket"), SIGNED_AT), ("tamperedPayload", tampered(plain), SIGNED_AT),