As-built “knowledgebase”: a tailnet-only manual library + OCR ingest portal, live at
https://knowledgebase.taild4189d.ts.net as its own tailnet node (not a port on
telep-mainframe). It renders OCR’d PDF manuals as an mkdocs-material static site
(folder-tree browse, full-text search, dark mode, phone-responsive, lazy images) and a Flask+waitress
portal (/upload) accepts a PDF, runs marker OCR on the GPU (reusing ~/ocr/venv), splits the
markdown into per-section pages, copies images, and atomically rebuilds the site. Runs on the
mainframe host (where the GPU + marker venv live); only the tailnet identity is separate.
Design in 2026-07-24-knowledgebase-design, task-by-task plan in 2026-07-24-knowledgebase-plan — this note is the completion record and the hard-won knowledge, not a restatement of either.
For Agents
URL:
https://knowledgebase.taild4189d.ts.net— its own tailnet device, not atelep-mainframeport. Services (both enabled, survive reboot):
knowledgebase.service— Flask+waitress app on127.0.0.1:8092,User=levander(NOT root).tailscaled-knowledgebase.service— the dedicated secondtailscaledthat gives theknowledgebasenode its identity.Code:
/home/levander/knowledgebase/—split_manual.py,slugs.py,build.py,seed_import.py,ingest.py,app.py,mkdocs.yml,hooks.py+ 26 unit tests (all pass). Own venvvenv/(flask, waitress, mkdocs-material). OCR: reuses the existing~/ocr/venvmarker pipeline as a subprocess (marker_single,TORCH_DEVICE=cuda) — no duplicate torch/model install. See scanned-pdf-to-markdown-marker. Library source:manuals-src/docs/<folder>/<name>/(folder tree → mkdocs auto-nav); build outputsite/. Add manuals: the/uploadportal (pick folder + name, PDF only), orseed_import.pyto import already-OCR’d marker output without re-OCR. Seeded with: Suzuki Vitara 5-door supplement (59 pages / 1248 imgs) + Chevrolet Tracker 1995 owners manual (5 pages / 352 imgs). Since greatly expanded — see the Library expansion section below.
How it works (the reusable parts)
- Chunked loading = split by
#h1. Each manual’s marker markdown is split on its#h1 headings into many small per-section HTML pages (a single 795 KB markdown would otherwise be one huge page). Plus a mkdocson_post_pagehook (hooks.py) injectsloading="lazy"into every<img>(verified 1600/1600 tags lazy). This is what fixed “Ulysses chokes on 1248 images”. - Browse = folder tree.
manuals-src/docs/<folder>/<manual>/→ mkdocs auto-nav; no hand-maintainednav:. A new manual folder appears automatically after a rebuild. - One service serves BOTH the static site (at
/) and the portal (/upload) — mkdocs output is static, so the Flask app hosts uploads and triggers rebuilds behind a singletailscale serveport. - Ingest = single background worker thread, GPU-serialised (one marker OCR at a time, the GPU is
shared with Frigate). Job states
queued → ocr → splitting → building → done/failed, polled by the job page. - Atomic build swap. mkdocs builds into a temp dir on the SAME filesystem, then
os.renameswapssite/. A broken source never takes down the live site.
The two that cost thought (separate-node + TLS)
Making the app appear as its own tailnet device — without a container or GPU passthrough — is the whole trick here, and one TLS trap inside it burned real time.
A separate tailnet identity on one host = a second
tailscaledwith userspace networkingTo give one app its own
<name>.<tailnet>.ts.netdevice while it keeps running on the host (where the GPU + marker venv are), run a dedicated secondtailscaledwith--tun=userspace-networking, its own--socketand--statedir. Then, against that socket:tailscale --socket=… up --hostname=knowledgebase(one interactive auth, like exit-vpn was) andtailscale --socket=… serve …. Result:knowledgebase.<tailnet>.ts.netas its own node — the lightweight alternative to a GPU-passthrough Incus container with a duplicate ~5 GB OCR stack.
tailscale serve --httpsfails with "no TailscaleVarRoot" unless the second tailscaled has a--statedirStart the second
tailscaledwith only--state=<file>andtailscale serve --https 443fails with “no TailscaleVarRoot” — a TLS handshake error wherecurlgets000even with-k, while TCP and ping succeed (so it looks like a listener/firewall problem, but isn’t). Cause: with only a state file, tailscaled has no var root to store the Let’s Encrypt cert. Fix: start it with--statedir=<dir>instead (the state file lives inside the statedir, so the node stays authenticated). Thentailscale --socket=… cert <name>provisions the cert and TLS works.
Security & robustness
- Path-traversal guard (
slugs.safe_slug+ aresolve_withinrealpath containment check) is applied before ANY filesystem write from an upload. Proven sound in review: sibling-dir, symlink, absolute-component and%2e%2epayloads are all rejected. The final destination is always insidemanuals-src/docs/. - Uploads restricted to
%PDFmagic bytes (not just extension) and capped atMAX_CONTENT_LENGTH=500 MB(413 on oversize). - Re-ingest must
rmtreethe destination first. Re-ingesting the same folder+name without clearing the destination leaves stale orphan section pages/images that show up as ghost pages in nav/search. Fixed in bothingest.pyandseed_import.py. - Atomic swap means an OCR or mkdocs-build failure never corrupts the live library or the served
site/— the job goesfailedwith the error tail, the old site stays up. - Runs unprivileged (
levander); no secrets involved.
Library expansion (2026-07-24)
The library grew well past the two seed manuals. Many OCR’d manuals now live under
docs/<folder>/<manual>/:
- suzuki-vitara:
5door-supplement,supplement-61a40(99501),workshop-1988-1998(835 pg),wiring-diagrams(99512),body-measurements. - chevy-tracker:
owners-1995,geo-tracker-repair. - suzuki-sidekick:
wiring-1996,parts-catalogue-89-98.
Plus the kick-fix.com community archive under docs/kick-fix/
(25 topics / 127 articles). All of it is now semantically searchable via
2026-07-24-kb-vectorize-complete (2839 chunks in Qdrant).
OCR pipeline pattern + discipline
- Pipeline: marker (
~/ocr/venv,marker_single --force_ocr,TORCH_DEVICE=cuda) →seed_import.py(splits + copies images) →build_site(). See scanned-pdf-to-markdown-marker. - CHAINED nohup jobs to serialize the single GPU. Manuals were queued as chained
nohupscripts, each waiting on the prior’s.donesentinel, so the one GPU does one OCR at a time (and coexists with Frigate). This is the manual-batch analogue of the app’s single-worker queue.
Always md5-dedup downloads BEFORE OCR — many were exact or same-content duplicates
A lot of the source downloads were exact md5 duplicates or same content under different names (workshop had 2 copies, 99501 had 2, parts-catalogue had 2). OCR is expensive and GPU-serialised — md5-dedup first so you don’t burn GPU hours re-OCRing the same PDF. Also: zips sometimes contain the PDF (the sidekick wiring download was a zip of
wiring2.pdf) — unpack and check before queuing.
Ecosystem (this session)
The knowledgebase became its own tag:telep tailnet node (not just a serve port), and a sibling
global dashboard node was added — see 2026-07-24-global-dashboard. Both use the same
separate-tailscaled-identity pattern documented below.
Behaviours that are by design (not bugs)
- A bare folder URL 404s. mkdocs section pages have no folder index, so
…/5door-supplement/returns 404 by design; the nav links to section pages (…/001-introduction/), not folders. - Coarse split when the OCR has few h1s. The
#split is on the ORIGINAL doc’s h1 count: the Tracker OCR had only 4 h1s → 5 coarse pages (vs the Vitara’s 59). Coarse navigation, not a perf problem (images lazy-load). A finer split (by h2 or by size) is a possible later enhancement.
Current state / verification
- Live and verified end-to-end over the tailnet: root
200,/upload200, section pages200, search index present, both seeded manuals in the nav, images serve, and a real portal upload ranocr → donein ~20 s and rendered. - Both services
active+enabled(survive reboot). All 26 unit tests pass.
This is on telep-mainframe, whose mains power is unstable
The knowledgebase (and its second
tailscalednode) are down whenever the box is, until the UPS is sorted. See 2026-07-23-mains-power-shutdowns.
Related
- 2026-07-24-knowledgebase-design — the design this implements
- 2026-07-24-knowledgebase-plan — the task-by-task implementation plan
- 2026-07-24-kickfix-ingest — the kick-fix.com community archive ingested into
docs/kick-fix/ - 2026-07-24-kb-vectorize-complete — the whole library made semantically searchable in Qdrant
- 2026-07-24-global-dashboard — the sibling
home.taild4189d.ts.netnode; same tailnet-node pattern - scanned-pdf-to-markdown-marker — the reused
~/ocr/venvmarker OCR pipeline - telep-mainframe — the host; GPU + marker venv,
tailscale servefront door - 2026-07-23-mains-power-shutdowns — why it’s down when the box is
- SESSION-HANDOVER
- homelab
- LOG
- TOPICS