Stood up a self-hosted, real-time collaborative tldraw whiteboard (“Miro-type” board) on telep-mainframe with PDF annotation, plus a TV kiosk “board mode” on the 4K HDMI that toggles with the camera wall. A single Bun process serves both the built tldraw client and the WebSocket sync/asset backend on port 8790.
For Agents
App dir:
/home/levander/tldraw-board. One Bun process serves the built Vite/React tldraw client AND the WebSocket sync + asset endpoints on port 8790 (binds0.0.0.0). systemd:tldraw-board.service(runs aslevander,Restart=always, enabled). URLs: tailnethttp://telep-mainframe.taild4189d.ts.net:8447/(viatailscale serve --http 8447→ 8790); LANhttp://192.168.1.123:8790/. Rooms:/r/<roomId>or?room=<id>; default roommainat/. Data:data/rooms/<roomId>.json(persisted snapshots) +data/assets/(uploaded images + PDF page PNGs). TV kiosk:/usr/local/bin/tvwrapper —tv board/tv cams/tv status.board-kiosk.service(root,DISPLAY=:0) is NOT enabled at boot — camwall is the default; the two are mutually exclusive (Conflicts=both directions). ⚠️ tldraw 5.2.5 renders a BLANK canvas over HTTPS without a paid license — that is why every URL above (and the kiosk) is plain HTTP, not HTTPS.
The board app
- Dir:
/home/levander/tldraw-board. - Single Bun process serves the built Vite/React tldraw client AND the WebSocket sync + asset endpoints on port 8790 (binds
0.0.0.0, so LAN + tailnet both reach it). - systemd unit:
tldraw-board.service— runs aslevander,Restart=always, enabled. - Persistence:
data/rooms/<roomId>.json— persisted room snapshots.data/assets/— uploaded images + PDF page PNGs.
URLs & rooms
| Path | Address |
|---|---|
| Tailnet | http://telep-mainframe.taild4189d.ts.net:8447/ (via tailscale serve --http 8447 → 8790) |
| LAN | http://192.168.1.123:8790/ |
| Room by path | /r/<roomId> |
| Room by query | ?room=<id> |
Default room main | / |
Stack detail
- tldraw 5.2.5.
- Client: uses
useSync({uri, assets})from@tldraw/sync. - Server: keeps one
TLSocketRoom(@tldraw/sync-core) per room over Bun’s native WebSocket (handleSocketConnect/handleSocketMessage/handleSocketClose). - Snapshot persistence: JSON —
initialSnapshoton room load + a debouncedonDataChange→getCurrentSnapshot()writeback todata/rooms/<roomId>.json. - Assets: served via
PUT/GET /uploads/:id— shared uploads, not data-URLs embedded in the document (keeps snapshots small; images/PDF pages live indata/assets/). - PDF import: pdf.js renders each page → PNG → uploaded as an asset → placed as locked, stacked image shapes (the tldraw
pdf-editorexample approach), so you annotate on top of the pages.
CRITICAL GOTCHA — tldraw 5.2.5 renders a BLANK canvas over HTTPS without a paid license
tldraw’s
LicenseManager.isDevelopmentis onlytruewhen protocol is NOT https, OR host is loopback, ORNODE_ENV != production. Over plain HTTPS with a non-loopback host and no purchased license, the watermark/gate logic leaves the canvas blank.Fix used: serve the tailnet endpoint over plain HTTP —
tailscale serve --http 8447. This is still WireGuard-encrypted over the tailnet, so it is not a plaintext-on-the-wire regression. It leaves a small “get a license” watermark link.Proper HTTPS needs a purchased tldraw license (pass the
licenseKeyprop; contactsales@tldraw.com).This is the reason the kiosk and tailnet URLs are HTTP, not HTTPS.
TV kiosk “board mode”
The 4K HDMI TV can show either the camera wall (default) or the tldraw board — never both. Toggled with a small wrapper; the two systemd units Conflicts= each other in both directions.
Files
| File | Role |
|---|---|
/usr/local/bin/tv | Wrapper: tv board (stop camwall.service, start board-kiosk.service), tv cams (reverse), tv status. board/cams need sudo (systemctl) — sudo -n works passwordless on this box. |
/usr/local/bin/board-kiosk.sh | Starts matchbox WM, then chromium --kiosk --app=http://192.168.1.123:8790/r/main on DISPLAY=:0. Flags include --no-sandbox (mandatory: chromium runs as root), --force-device-scale-factor=2 (legible on the 4K TV), --user-data-dir=/var/lib/board-kiosk. |
/etc/systemd/system/board-kiosk.service | root, Environment=DISPLAY=:0, Restart=always, Requires/After=camwall-x.service, Conflicts=camwall.service. NOT enabled at boot (camwall is the default). |
/etc/systemd/system/camwall.service.d/conflict.conf | Drop-in adding Conflicts=board-kiosk.service — mutual exclusion both directions. |
Architecture facts learned (correct any stale notes)
These reuse and confirm the camwall-x vs camwall split first documented in 2026-08-04-telep-mainframe-airplay-receiver-uxplay.
The X server is owned by camwall-x.service, NOT camwall.service
On telep-mainframe the Xorg
:0server is owned by a separatecamwall-x.service(xinit,-nolisten tcp).camwall.serviceis ONLY the mpv grid client. So stopping camwall does NOT kill X — the kiosk (and uxplay) reuse the already-running:0server. Root connects to:0with justDISPLAY=:0, noXAUTHORITY.
A kiosk that stops camwall must launch its OWN matchbox WM
matchbox is the WM that fullscreens kiosk windows, and it is a child of camwall’s session. A kiosk that stops
camwall.servicetherefore tears down that matchbox — soboard-kiosk.shmust launch its own matchbox or windows won’t fullscreen. Before this fix, chromium came up as a floating half-window (not fullscreen) until board-kiosk started its own WM.
Tailscale serve
Existing tailscale serve entries were preserved — 8443, 8445, 8446, and root→8971. Only 8447 was added (--http 8447 → 8790), deliberately HTTP for the tldraw-HTTPS gotcha above.
2026-08-31 — named identity, Mermaid import, and the loopback security fix
The board gained named collaborator identity and editable Mermaid import, and a pre-existing exposure was closed. The tailnet front door is now board.taild4189d.ts.net via a board-tsnet tsnet proxy (dirs /home/levander/board-tsnet + /home/levander/tldraw-board).
SECURITY FIX — the board was listening on
*:8790(LAN + tailnet reachable, identity-spoofable)As first documented above,
tldraw-boardbound0.0.0.0:8790— directly reachable from the LAN and the tailnet, bypassing the identity proxy entirely, so anyone on either could setRemote-Userthemselves. Now bound loopback-only (HOST=127.0.0.1via a systemd drop-in). Identity is resolved in-process byboard-tsnet: it is atsnet.Serverthat terminates the tailnet TLS itself, sor.RemoteAddris the real (unforgeable) peer, ands.LocalClient().WhoIs(...)resolves it with noX-Forwarded-Forparsing (unlike the Drive’stsauth-proxy, which must trust XFF because it sits behind another proxy). Resolution order mirrors tsauth-proxy:LoginName→TS_TAG_USERStag→user map (same/etc/board-identity.env) → guest.Loopback alone was not sufficient: Caddy also proxies
http://whiteboard.telep.lan→127.0.0.1:8790and both connect from loopback, so a shared secret (X-Board-Identity-Secretfrom/etc/board-identity.env) gates theRemote-Userheader — the server honours it only when the secret matches, and strips all four client-supplied identity headers first. Fails closed (no secret → everyone is a guest). UTF-8 names (“András Léderer”) are percent-encoded on the Go side because HTTP headers are ISO-8859-1.
Named presence: useSync({ users }) feeds the resolved name into tldraw multiplayer presence (each user a deterministic colour); the name also persists into the room snapshot for attribution. Unresolvable users still get in as a stable per-browser Guest.
Mermaid import (@tldraw/mermaid@5.2.5, exposed like PdfImport — a SharePanel button + paste handler): flowchart / sequenceDiagram / stateDiagram-v2 / mindmap import as real editable tldraw shapes with bound connectors; everything else (pie, gantt, class, ER, journey) falls back to a rendered SVG image via onUnsupportedDiagram. That four-type boundary is the library’s own type union, not a local limit.
These board changes are UNCOMMITTED, pending owner review
Neither
/home/levander/tldraw-boardnor/home/levander/board-tsnetis a git repository, so there is no diff to review — every modified file has a.bak-identitysibling and a full tarball backup exists (/home/levander/backups/tldraw-board-20260831-052642.tar.gz). The changes are live in the working tree but not persisted to any repo; treat them as pending until the owner reviews.
The
tag:telep-usermapping is currently dead config
TS_TAG_USERS=tag:telep-user=falusimarko0240@gmail.comis configured, but tailnet ACLs prevent every tagged device from reachingboard— only user-owned devices can, and they all belong to one login. If a tagged collaborator is meant to use the board, the ACL must grant that tag access toboardfirst. The kiosk URL was also repointed off the now-removed LAN IP tohttp://127.0.0.1:8790/r/main.
Related
- 2026-08-31-tailnet-plaintext-port-hardening — the same class of “backend bound too wide” exposure, swept across the box’s sidecars the same night
- telep-mainframe — the host (i9-12900K + RTX 3080, Debian 13); owns the
:0display this kiosk shares - 2026-08-04-telep-mainframe-airplay-receiver-uxplay — same TV/HDMI/X-on-
:0story: introduced the camwall-x.service vs camwall.service split (persistent Xorg:0) and root-on-:0pattern that board mode reuses - 2026-07-28-camwall-4-substream-composite — the camera wall that board mode toggles against
- homelab
- LOG
- TOPICS