๐ณ Safe noVNC-based file viewer based on Kasm & Neko
7.0K
๐ ๐๏ธ ๐ผ๏ธ Browser-embeddable remote viewer for PDFs, office docs, audio, video, and images.
A small Go control-center fronts a fixed pool of Kasmโ /
LinuxServer Webtopโ desktop containers โ
pass ?url=... and the file opens in the right app inside an iframe.
๐น f3l1x.ioโ | ๐ป f3l1xโ | ๐ฆ @xf3l1xโ
Embedding rich file viewers in your product is a thousand papercuts: PDF.js for some types, an office viewer for others, a media player for the rest โ each with its own quirks, sandbox story, and asset list. Viewdoc skips it. Open a real desktop in a tab, let the desktop open the file with its native app, and stream the pixels back. One iframe, every file type.
flowchart LR
browser["Browser<br/>localhost:8080/?url=โฆ"]
subgraph cc["control-center (Go)"]
proxy["reverse-proxy<br/>/slot/{i}/*"]
api["POST /api/slot/{i}/params"]
end
subgraph viewer["viewer-i (Kasm / Webtop)"]
novnc["KasmVNC :6901<br/>or Webtop :3000"]
sidecar["sidecar :7000"]
hook["viewdoc.sh"]
apps["chromium ยท libreoffice ยท vlc<br/>xarchiver ยท wireshark ยท claws-mail"]
end
browser -->|iframe src=/slot/i/| proxy
browser -->|"{url:โฆ}"| api
proxy -->|HTTP + WS| novnc
api -->|POST /params| sidecar
sidecar -->|fork| hook
hook -->|dispatch by ext| apps
docker compose build
docker compose up -d
open https://localhost:8443/ # iframe + URL bar + slot tabs
open "https://localhost:8443/?url=https://example.com/x.pdf" # auto-opens in slot 0
curl -sk https://localhost:8443/healthz # {"ready":4,"total":4}
Default pool (VIEWER_SLOTS): 2ร Kasm + 2ร Webtop.
The dispatcher routes by extension:
| Group | Extensions | Opens with |
|---|---|---|
| Media | mp4, mkv, webm, mov, avi, mpeg, mpg, m4v, 3gp, 3g2, ts, mts, m2ts, vob, wmv, asf, divx, ogv, mp3, wav, flac, ogg, m4a, aac, opus, mka, wma, aiff, ape, mid | VLC |
| Web / docs | pdf, html, htm | Chromium (new window) |
| Images | png, jpg, jpeg, gif, webp, svg, bmp | Chromium (new window) |
| Images (other) | tif, tiff, heic, heif, avif, ico, tga | Ristretto (downloaded locally first) |
| Office | doc, docx, odt, rtf, xls, xlsx, ods, csv, ppt, pptx, odp, odg, vsd, vsdx, pub, pages, key, numbers | LibreOffice |
| Ebooks | epub, djvu, xps, oxps, cbz, cbr, cb7 | Atril (downloaded locally first) |
| Text | txt, log, json, yaml, yml, xml, md, ini, conf, cfg, toml | Mousepad (downloaded locally first) |
| Archives | zip, 7z, rar, tar, gz, tgz, bz2, tbz2, xz, txz, zst, tzst | Xarchiver (downloaded locally first) |
eml, msg | Claws Mail (downloaded locally first; .msg converted via msgconvert) | |
| Captures | pcap, pcapng, cap | Wireshark (downloaded locally first) |
| 3D models | stl, obj, ply, glb, gltf, 3ds, vtk, vtp | F3D (downloaded locally first) |
| CAD | dxf | LibreCAD (downloaded locally first) |
| Fonts | ttf, otf, ttc | GNOME Font Viewer (downloaded locally first) |
| Binaries | bin, exe, dll, so, o, elf, dat, dmp | GHex (downloaded locally first) |
| Other | anything else | Chromium, otherwise xdg-open |
Only http:// and https:// URLs are accepted; anything else is refused before dispatch. Both images also ship exiftool for metadata inspection from a terminal, and Office-metric fonts (Liberation, Carlito, Caladea) so documents keep their original layout.
The system exposes two HTTP services. The control-center is the public entrypoint; the sidecar runs inside every viewer image on the compose network.
:8080 (plain), :8443 (TLS)| Method | Path | Purpose |
|---|---|---|
| GET | / | UI: iframe + URL bar + slot tabs |
| GET | /healthz | {ready, total} โ slots whose desktop and sidecar answer; 503 when none |
| GET | /api/slots | slot table with per-slot vnc, sidecar and ready flags |
| POST | /api/slot/{i}/params | forwards {params:{url,โฆ}} to slot i |
| POST | /api/slot/{i}/restart | restarts slot i's container (requires Content-Type: application/json) |
| GET | /api/slot/{i}/status | what happened to slot i's latest request (see sidecar /status) |
| ANY | /slot/{i}/* | reverse-proxy โ <slot[i]>:port (HTTP + WS) |
| GET | /* | embedded UI assets (/app.js, /style.css) |
| Var | Default | Purpose |
|---|---|---|
LISTEN_ADDR | :8080 | Plain-HTTP bind address |
TLS_ADDR | (unset) | If set, also bind TLS. The cert comes from TLS_CERT (see TLS certificate below). |
TLS_CERT | (inferred) | embedded, generate or file. Unset: file if TLS_CERT_FILE is set, generate if TLS_CERT_DIR is set, else embedded. |
TLS_CERT_DIR | (unset) | generate: keep the cert here (server.crt + server.key) so it survives restarts. Unset = new in-memory cert on every start. |
TLS_HOSTS | (unset) | generate: extra comma-separated IPs / DNS names for a new cert. |
TLS_CERT_FILE, TLS_KEY_FILE | (unset) | file: paths to your own PEM cert (full chain) and key. |
VIEWER_SLOTS | kasm://viewer-kasm-1,kasm://viewer-kasm-2,webtop://viewer-webtop-1,webtop://viewer-webtop-2 (the compose services) | Comma-separated slot list. See Slot configuration below for the full grammar. |
VIEWER_KASM_DEFAULT_PORT | 6901 | VNC port used for bare entries and the kasm:// pseudo-scheme. |
VIEWER_WEBTOP_DEFAULT_PORT | 3000 | VNC port used for the webtop:// pseudo-scheme. |
VIEWER_SIDECAR_DEFAULT_PORT | 7000 | Sidecar port used when the sidecar half of a slot entry is omitted (or specifies no port). |
TLS_ADDR serves the same UI over HTTPS (browsers only allow clipboard access in a secure context). The startup log prints which cert is used and its SHA-256 fingerprint.
TLS_CERT | Cert used | When to use it |
|---|---|---|
embedded (default) | Pre-generated self-signed pair from certs/, embedded in the image (make cert regenerates it). | Local and trusted networks: same fingerprint everywhere, so the browser's "accept once" sticks. The key is public in this repo, so it gives no real protection. |
generate | Self-signed pair generated on first start. With TLS_CERT_DIR it's written there (key 0600) and reused, so the fingerprint stays stable; compose mounts a certs volume at /certs for this. Without it, a new in-memory cert on every start. Delete the files to regenerate. | A private key per deployment. Always covers localhost, 127.0.0.1, ::1, plus TLS_HOSTS. |
file | TLS_CERT_FILE + TLS_KEY_FILE, read at startup. | Your own cert, e.g. from your CA or Let's Encrypt (mount the files read-only). |
Each entry in VIEWER_SLOTS has the shape <vnc>[|<sidecar>]. The pipe is optional โ omit it when the sidecar lives on the same host as the VNC endpoint (the compose default).
VNC half โ accepted forms (port falls back to VIEWER_KASM_DEFAULT_PORT, except webtop:// which uses VIEWER_WEBTOP_DEFAULT_PORT):
| Input | Parsed as |
|---|---|
viewer-1 | http://viewer-1:6901 |
viewer-1:9000 | http://viewer-1:9000 |
http://viewer-1 | http://viewer-1:6901 |
https://viewer-1 | https://viewer-1:6901 |
https://viewer-1:6901 | https://viewer-1:6901 (canonical Kasm) |
kasm://viewer-1 | https://viewer-1:6901 (kasm pseudo-scheme = https+kasm) |
kasm://viewer-1:9000 | https://viewer-1:9000 |
webtop://viewer-1 | http://viewer-1:3000 (webtop pseudo-scheme = http+webtop) |
webtop://viewer-1:9001 | http://viewer-1:9001 |
Sidecar half โ accepted forms (port falls back to VIEWER_SIDECAR_DEFAULT_PORT; kasm:// / webtop:// are not allowed here):
| Input | Parsed as |
|---|---|
| (omitted) | http://<vnc-host>:7000 |
viewer-1 | http://viewer-1:7000 |
viewer-1:7100 | http://viewer-1:7100 |
http://other-host:7100 | http://other-host:7100 |
https://sidecar-1:7443 | https://sidecar-1:7443 |
Worked examples
# Compose (DNS-friendly, sidecar implicit on same host:7000)
VIEWER_SLOTS="kasm://viewer-kasm-1,kasm://viewer-kasm-2,webtop://viewer-webtop-1,webtop://viewer-webtop-2"
# Equivalent fully-explicit form
VIEWER_SLOTS="https://viewer-kasm-1:6901,https://viewer-kasm-2:6901,http://viewer-webtop-1:3000,http://viewer-webtop-2:3000"
# Nomad / dynamic ports โ VNC and sidecar published on different host ports
VIEWER_SLOTS="kasm://10.0.0.5:23456|10.0.0.5:34567,kasm://10.0.0.6:11111|10.0.0.6:22222"
# Override the pool-wide defaults so bare entries become 7901 / 3100 / 7900
VIEWER_KASM_DEFAULT_PORT=7901
VIEWER_WEBTOP_DEFAULT_PORT=3100
VIEWER_SIDECAR_DEFAULT_PORT=7900
VIEWER_SLOTS="kasm://node-1,webtop://node-2"
# โ slot 0 vnc=https://node-1:7901 sidecar=http://node-1:7900
# โ slot 1 vnc=http://node-2:3100 sidecar=http://node-2:7900
:7000 (inside each viewer)| Method | Path | Purpose |
|---|---|---|
| GET | /healthz | plain-text ok |
| POST | /params | validate + atomically write /tmp/viewdoc.{json,env}, fork viewdoc.sh |
| POST | /restart | reply 202, then exit with code 42 โ the image turns that into a container exit |
| GET | /status | latest request: phase (idle, queued, refused, deduped, downloading, failed, launched), url, app, error, updated, and for launched apps app_state (running, handoff, exited) + exit_code |
| Var | Default | Purpose |
|---|---|---|
LISTEN_ADDR | :7000 | Sidecar bind address (fixed by compose convention) |
The Restart button (POST /api/slot/{i}/restart) makes the viewer container exit; whatever supervises it brings up a fresh one. The sidecar can't kill the container itself (on Webtop it runs as abc, PID 1 is root), so it exits with code 42 and the image's wrapper does the rest:
custom_startup.sh loops on the sidecar; on 42 it sends SIGTERM to PID 1 (vnc_startup.sh, same uid), which exits.finish script (runs as root) calls /run/s6/basedir/bin/halt; the container exits with code 42.Any other sidecar exit just respawns the sidecar. Restarting requires a restart policy: compose's restart: unless-stopped works as-is. On Nomad, the default restart stanza (attempts = 2 per 30m, mode = "fail") runs out after a few clicks, and the allocation gets rescheduled โ possibly to another node with new dynamic ports, which breaks a static VIEWER_SLOTS. Allow many quick in-place restarts:
restart {
attempts = 100
interval = "30m"
delay = "2s"
mode = "delay"
}
Requires Go 1.27.2+ (go.mod pins the toolchain; older Go downloads it automatically) and Docker with Compose v2.
make test # go test ./...
go vet ./...
make build # build all three images, stamped with VERSION
make cert # regenerate the embedded certs/server.{crt,key} for your host IPs
make version # print VERSION
make release # set VERSION to today's date (see Versions)
CI (.github/workflows/docker.yml) checks the VERSION format and runs go vet, go test -race, staticcheck, govulncheck, shellcheck and hadolint, then builds every image. Pushes to master publish linux/amd64 + linux/arm64 images to Docker Hub (see Versions). The Kasm and Webtop base images are pinned by digest; Dependabot opens weekly PRs for new digests, Go builder images and GitHub Actions.
viewdoc is versioned by release date: YYYY.MM.DD, or YYYY.MM.DD.N for another release on the same day. The current version is in VERSIONโ . It is built into both binaries (logged at startup, returned by the control-center's /healthz and the sidecar's /status) and set as the org.opencontainers.image.version label.
| Image tag | Meaning |
|---|---|
dockette/viewdoc:<variant> | latest master build |
dockette/viewdoc:<variant>-2026.10.12 | release 2026.10.12 โ pin this |
dockette/viewdoc:<variant>-<short-sha> | immutable build of that commit |
<variant> is control-center, viewer-kasm or viewer-webtop. Like other dockette images, release tags are rebuilt by the weekly schedule to pick up base-image updates; use the <short-sha> tag for a byte-identical image.
To release: make release sets VERSION to today's date (UTC; .1, .2, โฆ if that date is already tagged), then commit and push to master. Once all images are pushed, CI tags the commit and creates the GitHub release <VERSION> with generated notes. A plain docker compose build stamps dev; make build uses VERSION.
See how to contributeโ to this package. Consider to supportโ f3l1x. Thank you for using this package.
Content type
Image
Digest
sha256:960ed39e8โฆ
Size
4.4 GB
Last updated
about 14 hours ago
docker pull dockette/viewdoc:viewer-kasm-a45754dPulls:
111
Sep 28 to Oct 4