diff --git a/.docker/kasm/Dockerfile b/.docker/kasm/Dockerfile index f7653a7..f25fd0f 100644 --- a/.docker/kasm/Dockerfile +++ b/.docker/kasm/Dockerfile @@ -2,17 +2,20 @@ # Go toolchain under QEMU. FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS sidecar ARG TARGETOS TARGETARCH +ARG VERSION=dev WORKDIR /src COPY go.mod ./ COPY internal ./internal COPY cmd/sidecar ./cmd/sidecar ENV CGO_ENABLED=0 -RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/sidecar ./cmd/sidecar +RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w -X github.com/dockette/viewdoc/internal/version.Version=$VERSION" -o /out/sidecar ./cmd/sidecar # Base pinned by digest for reproducible builds; Dependabot bumps it # (.github/dependabot.yml). FROM kasmweb/ubuntu-noble-desktop:1.19.0@sha256:14594dcf30c33768485a7dc6cb7bf3b7bef25121743d30371cbc56b525d96c71 USER root +ARG VERSION=dev +LABEL org.opencontainers.image.version=$VERSION SHELL ["/bin/bash", "-o", "pipefail", "-c"] # wireshark-common preseed: we only ever read pcap files, never capture, so diff --git a/.docker/viewdoc.sh b/.docker/viewdoc.sh index 0fd243c..0c4bd79 100644 --- a/.docker/viewdoc.sh +++ b/.docker/viewdoc.sh @@ -12,6 +12,8 @@ QUERY_ENV="/tmp/viewdoc.env" LAST="/tmp/.viewdoc_last_url" STATE="/tmp/.viewdoc_last_state" LOG="/tmp/viewdoc.log" +# Latest outcome for the sidecar's GET /status (key=value lines). +STATUS="/tmp/viewdoc.status" # A launcher that exits 0 faster than this handed the URL to an already # running instance (chromium, soffice, xdg-open) rather than failing. HANDOFF_SECS=5 @@ -25,6 +27,20 @@ log() { printf '%s\n' "$msg" >&2 || true } +# status [app] [error] — records the outcome of this request for the +# sidecar's GET /status. Never load-bearing, like log(). +status() { + if { + printf 'phase=%s\n' "$1" + printf 'url=%s\n' "$(printf '%s' "$url" | tr '\n' ' ')" + printf 'app=%s\n' "${2:-}" + printf 'error=%s\n' "${3:-}" + printf 'updated=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > "$STATUS.$$" 2>/dev/null; then + mv -f "$STATUS.$$" "$STATUS" 2>/dev/null || true + fi +} + [ -r "$QUERY_ENV" ] || { log "no $QUERY_ENV; nothing to do"; exit 0; } # shellcheck disable=SC1090 . "$QUERY_ENV" @@ -35,13 +51,14 @@ if [ -z "$url" ]; then exit 0 fi -# $STATE is " " for the launch that wrote $LAST. +# $STATE is " [rc]" for the launch that wrote +# $LAST (rc once it has exited). # The URL counts as still open while that launch's process is alive, or when # it handed off to an existing instance (whose window we can't track). A # crashed or closed app ("exited") lets the same URL open again. is_open() { local pid state - read -r pid state < "$STATE" 2>/dev/null || return 1 + read -r pid state _ < "$STATE" 2>/dev/null || return 1 case "$state" in handoff) return 0 ;; running) kill -0 "$pid" 2>/dev/null ;; @@ -53,11 +70,12 @@ is_open() { # "--version.mp4" would be parsed as an option. Only real URLs get through. case "$url" in http://*|https://*) ;; - *) log "not an http(s) URL, refusing: $url"; exit 0 ;; + *) log "not an http(s) URL, refusing: $url"; status refused "" "not an http(s) URL"; exit 0 ;; esac if [ -r "$LAST" ] && [ "$(cat "$LAST")" = "$url" ] && is_open; then log "dedup hit, $url already open" + status deduped exit 0 fi @@ -68,10 +86,12 @@ download() { local f f="$(mktemp --suffix=".$ext" /tmp/viewdoc-dl.XXXXXXXX)" log "download $url -> $f" + status downloading "$app" if curl -fsSL --max-time 60 -o "$f" "$url"; then printf '%s' "$f" else log "download failed url=$url" + status failed "$app" "download failed" rm -f "$f" return 1 fi @@ -81,6 +101,13 @@ lower="$(printf '%s' "$url" | tr '[:upper:]' '[:lower:]')" path="${lower%%\?*}"; path="${path%%#*}" ext="${path##*.}" +# dispatch names the app this request goes to (for logs + status). +app="" +dispatch() { + app="$1" + log "dispatch=$app ext=$ext url=$url" +} + run() { log "exec: $*" # tee fans the launched app's combined stdout/stderr to both the single @@ -100,7 +127,7 @@ run() { owner="" read -r owner _ < "$STATE" 2>/dev/null || true if [ "$owner" = "$BASHPID" ]; then - printf '%s %s\n' "$BASHPID" "$state" > "$STATE" + printf '%s %s %s\n' "$BASHPID" "$state" "$rc" > "$STATE" fi log "exit: $1 rc=$rc state=$state" ) & @@ -108,17 +135,18 @@ run() { # doesn't make the next identical request a no-op. printf '%s %s\n' "$!" running > "$STATE" printf '%s' "$url" > "$LAST" + status launched "${app:-$1}" } case "$ext" in 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) - log "dispatch=vlc ext=$ext url=$url" + dispatch vlc # --no-qt-privacy-ask suppresses VLC's first-run "Privacy and Network # Access Policy" modal, which otherwise blocks playback until clicked. run vlc --no-qt-privacy-ask --no-video-title-show "$url" ;; doc|docx|odt|rtf|xls|xlsx|ods|csv|ppt|pptx|odp|odg|vsd|vsdx|pub|pages|key|numbers) - log "dispatch=soffice ext=$ext url=$url" + dispatch soffice # soffice handles http(s) URLs natively via UCB. --view opens read-only, # --norestore kills the "recover document?" prompt after a previous # session, --nologo suppresses the splash. Visio / Publisher / Apple @@ -130,13 +158,13 @@ case "$ext" in # open. curl runs synchronously here because the sidecar already forked # viewdoc.sh as a separate process, so blocking here doesn't delay the # sidecar's HTTP response. RAR5 needs unrar (p7zip-full only reads RAR4). - log "dispatch=xarchiver ext=$ext url=$url" + dispatch xarchiver if tmpfile="$(download)"; then run xarchiver "$tmpfile" fi ;; pcap|pcapng|cap) - log "dispatch=wireshark ext=$ext url=$url" + dispatch wireshark if tmpfile="$(download)"; then run wireshark -r "$tmpfile" fi @@ -151,7 +179,7 @@ case "$ext" in # rewritten every time because claws-mail saves its own copies on exit. cfgdir="/tmp/viewdoc-claws" inbox="$cfgdir/Mail/inbox" - log "dispatch=claws-mail ext=$ext url=$url" + dispatch claws-mail if tmpfile="$(download)"; then # Outlook .msg is an OLE compound file claws-mail can't read; msgconvert # (libemail-outlook-message-perl) rewrites it as MIME first. @@ -159,6 +187,7 @@ case "$ext" in eml="${tmpfile%.msg}.eml" if ! msgconvert --outfile "$eml" "$tmpfile" >&2; then log "msgconvert failed url=$url" + status failed "$app" "msgconvert failed" exit 0 fi tmpfile="$eml" @@ -192,13 +221,13 @@ CFG tif|tiff|heic|heif|avif|ico|tga) # Formats Chromium won't render. ristretto decodes via gdk-pixbuf, with # heif-gdk-pixbuf / libavif-gdk-pixbuf adding HEIC and AVIF. - log "dispatch=ristretto ext=$ext url=$url" + dispatch ristretto if tmpfile="$(download)"; then run ristretto "$tmpfile" fi ;; epub|djvu|xps|oxps|cbz|cbr|cb7) - log "dispatch=atril ext=$ext url=$url" + dispatch atril if tmpfile="$(download)"; then run atril "$tmpfile" fi @@ -206,7 +235,7 @@ CFG txt|log|json|yaml|yml|xml|md|ini|conf|cfg|toml) # Downloaded rather than handed to Chromium: servers often send these as # application/octet-stream, which Chromium saves instead of showing. - log "dispatch=mousepad ext=$ext url=$url" + dispatch mousepad if tmpfile="$(download)"; then run mousepad "$tmpfile" fi @@ -218,19 +247,19 @@ CFG fi ;; dxf) - log "dispatch=librecad ext=$ext url=$url" + dispatch librecad if tmpfile="$(download)"; then run librecad "$tmpfile" fi ;; ttf|otf|ttc) - log "dispatch=gnome-font-viewer ext=$ext url=$url" + dispatch gnome-font-viewer if tmpfile="$(download)"; then run gnome-font-viewer "$tmpfile" fi ;; bin|exe|dll|so|o|elf|dat|dmp) - log "dispatch=ghex ext=$ext url=$url" + dispatch ghex if tmpfile="$(download)"; then run ghex "$tmpfile" fi @@ -244,12 +273,12 @@ CFG # the container leaves a stale lock that wedges every new launch). for b in chromium chromium-browser google-chrome chrome; do if command -v "$b" >/dev/null 2>&1; then - log "dispatch=$b ext=$ext url=$url" + dispatch "$b" run "$b" --no-sandbox --user-data-dir=/tmp/viewdoc-chromium --new-window "$url" exit 0 fi done - log "dispatch=xdg-open ext=$ext url=$url" + dispatch xdg-open run xdg-open "$url" ;; esac diff --git a/.docker/webtop/Dockerfile b/.docker/webtop/Dockerfile index f69e5ef..6bf3401 100644 --- a/.docker/webtop/Dockerfile +++ b/.docker/webtop/Dockerfile @@ -2,17 +2,20 @@ # Go toolchain under QEMU. FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS sidecar ARG TARGETOS TARGETARCH +ARG VERSION=dev WORKDIR /src COPY go.mod ./ COPY internal ./internal COPY cmd/sidecar ./cmd/sidecar ENV CGO_ENABLED=0 -RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/sidecar ./cmd/sidecar +RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w -X github.com/dockette/viewdoc/internal/version.Version=$VERSION" -o /out/sidecar ./cmd/sidecar # Base pinned by digest for reproducible builds; Dependabot bumps it # (.github/dependabot.yml). FROM lscr.io/linuxserver/webtop:ubuntu-xfce@sha256:c766271bf5de1c7c8c6cd7be1ae4015953f1b45a9325dbc1c94f7a163ff7ffc7 SHELL ["/bin/bash", "-o", "pipefail", "-c"] +ARG VERSION=dev +LABEL org.opencontainers.image.version=$VERSION # wireshark-common preseed: we only ever read pcap files, never capture, so # answer "no" to the setuid-helper question and skip the interactive prompt. diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3a46a46..d71c047 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -24,6 +24,9 @@ jobs: with: go-version-file: go.mod + - name: VERSION format + run: grep -Eqx '[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?' VERSION || { echo "::error file=VERSION::expected YYYY.MM.DD or YYYY.MM.DD.N, got '$(cat VERSION)'"; exit 1; } + - name: go vet run: go vet ./... @@ -34,6 +37,11 @@ jobs: uses: dominikh/staticcheck-action@v1 with: install-go: false + # v0.8.1 (latest release) can't read go1.27.2 export data ("export + # data version 5 is greater than maximum supported version 4"). + # Pinned to a master commit verified against go1.27.2; switch + # back to a release tag once one newer than v0.8.1 ships. + version: "452d5bb86b45" - name: govulncheck run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... @@ -98,20 +106,30 @@ jobs: - name: Checkout uses: actions/checkout@v7 + # Only a push needs credentials; pull requests from forks and Dependabot + # have no access to the secrets (same as dockette/.github docker.yml). - name: Login to DockerHub + if: github.ref == 'refs/heads/master' uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Read VERSION + id: version + run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" + - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - # dockette/viewdoc: (rolling) + dockette/viewdoc:- - # (immutable, for rollbacks / pinning). + # dockette/viewdoc: rolling, latest master + # dockette/viewdoc:- release (YYYY.MM.DD); like other + # dockette versions it's rebuilt + # by the weekly schedule + # dockette/viewdoc:- immutable, for rollbacks / pinning - name: Docker meta id: meta uses: docker/metadata-action@v6 @@ -120,7 +138,10 @@ jobs: flavor: latest=false tags: | type=raw,value=${{ matrix.variant }} + type=raw,value=${{ matrix.variant }}-${{ steps.version.outputs.version }} type=sha,prefix=${{ matrix.variant }}-,format=short + labels: | + org.opencontainers.image.version=${{ steps.version.outputs.version }} - name: Build and push uses: docker/build-push-action@v7 @@ -130,10 +151,36 @@ jobs: push: ${{ github.ref == 'refs/heads/master' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.version.outputs.version }} platforms: linux/amd64,linux/arm64 cache-from: type=gha,scope=${{ matrix.variant }} cache-to: type=gha,scope=${{ matrix.variant }} + # Tags the commit and publishes a GitHub release for VERSION the first + # time it reaches master with all images pushed (make release bumps it). + release: + name: "Release" + needs: build + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Create release if VERSION is new + env: + GH_TOKEN: ${{ github.token }} + run: | + v="$(cat VERSION)" + if gh release view "$v" >/dev/null 2>&1; then + echo "Release $v already exists" + exit 0 + fi + gh release create "$v" --target "$GITHUB_SHA" --title "$v" --generate-notes + docs: name: "Docs" needs: build diff --git a/.gitignore b/.gitignore index 1fcdc30..5017be0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /control-center .DS_Store +.claude/worktrees/ diff --git a/AGENTS.md b/AGENTS.md index 27a863d..01751e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,11 @@ Remote streaming sandbox for **viewing files** (PDF, office docs, video, audio, 1. **Go stdlib.** Single static binary built on `net/http` + `httputil.ReverseProxy`. 2. **N slots, default 4** (2× Kasm + 2× Webtop, matching `docker-compose.yml`). Configured via `VIEWER_SLOTS` — comma-separated entries of the form `[|]`. The vnc half accepts `http(s)://host[:port]`, plus two pseudo-schemes — `kasm://host[:port]` expands to `https://host:` (default `6901`), and `webtop://host[:port]` expands to `http://host:` (default `3000`). The sidecar half accepts `http(s)://host[:port]`; when omitted, the sidecar inherits the vnc host on `VIEWER_SIDECAR_DEFAULT_PORT` (default `7000`). Mixed pools are first-class. See **README.md → Slot configuration** for worked examples. -3. **Plain HTTP inside, optional TLS outside.** Cleartext across the compose network (Kasm's own self-signed `:6901` is proxied with skip-verify). The control-center additionally serves TLS on `TLS_ADDR` (compose: `:8443`) with the self-signed cert embedded from `certs/server.{crt,key}` (regenerate via `make cert`) — browsers need a secure context for clipboard access. +3. **Plain HTTP inside, optional TLS outside.** Cleartext across the compose network (Kasm's own self-signed `:6901` is proxied with skip-verify). The control-center additionally serves TLS on `TLS_ADDR` (compose: `:8443`) (browsers need a secure context for clipboard access). `TLS_CERT` picks the cert (`internal/tlsself`): `embedded` (default — the pre-generated self-signed pair in `certs/server.{crt,key}`, regenerate via `make cert`), `generate` (on demand; persisted in `TLS_CERT_DIR`, extra SANs via `TLS_HOSTS`) or `file` (your own `TLS_CERT_FILE` + `TLS_KEY_FILE`). See README → *TLS certificate*. 4. **Static slot topology.** Slots declared in `docker-compose.yml` and discovered by DNS hostname on the compose network. 5. **HTTP sidecar for params.** Each viewer image runs a tiny sidecar on `:7000` by default (override per-slot in `VIEWER_SLOTS` or globally via `VIEWER_SIDECAR_DEFAULT_PORT`); control-center pushes params via `POST http://:/params`. 6. **Stateless slot selection.** Slot picked by `?slot=` (default `0`) or `?slot=auto` (random ready slot). +7. **Date versions.** `VERSION` holds `YYYY.MM.DD` (`.N` for a same-day re-release), bumped by `make release`; it's built into both binaries (`internal/version`, via `-ldflags -X`), tags images `-`, and CI creates the matching GitHub release. See README → *Versions*. ## Architecture @@ -48,10 +49,11 @@ Listens on `:8080` (`LISTEN_ADDR`), plus TLS on `TLS_ADDR` when set. | Method | Path | Purpose | |--------|--------------------------|---------| | GET | `/` | Landing page with `