diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml new file mode 100644 index 0000000..fae51d0 --- /dev/null +++ b/.github/workflows/build-desktop.yml @@ -0,0 +1,51 @@ +name: Build desktop image + +on: + push: + tags: ["desktop-v*"] + pull_request: + paths: + - "desktop/**" + - ".github/workflows/build-desktop.yml" + workflow_dispatch: + +permissions: + contents: read + packages: write + +env: + IMAGE: ghcr.io/janeliascicomp/fg-interactive-apps/desktop + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute image tags + id: tags + run: | + if [[ "$GITHUB_REF" == refs/tags/desktop-v* ]]; then + version="${GITHUB_REF_NAME#desktop-v}" + echo "tags=${IMAGE}:${version},${IMAGE}:latest" >> "$GITHUB_OUTPUT" + else + echo "tags=${IMAGE}:dev" >> "$GITHUB_OUTPUT" + fi + + - name: Build (and push, except on pull requests) + uses: docker/build-push-action@v6 + with: + context: desktop + platforms: linux/amd64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.tags.outputs.tags }} diff --git a/README.md b/README.md index d21bc4d..d19607d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Each app lives in its own subdirectory with its own `runnables.yaml` manifest. F | App | Directory | Description | |-----|-----------|-------------| | **OpenVSCode** | [`vscode/`](vscode/) | Browser-based VS Code IDE ([openvscode-server](https://github.com/gitpod-io/openvscode-server)) running in an Apptainer container, with one-click tokenized access. | +| **Remote Desktop** | [`desktop/`](desktop/) | Full Linux desktop (XFCE) with [Fiji](https://fiji.sc/) preinstalled, streamed to the browser via VNC/noVNC from an Apptainer container. | | **JupyterLab** | [`jupyterlab/`](jupyterlab/) | JupyterLab notebook server (SciPy stack) running in an Apptainer container. | | **marimo** | [`marimo/`](marimo/) | [marimo](https://marimo.io/) reactive Python notebook server running in an Apptainer container. | | **TensorBoard** | [`tensorboard/`](tensorboard/) | [TensorBoard](https://www.tensorflow.org/tensorboard) training-log dashboard running in an Apptainer container. | diff --git a/desktop/Dockerfile b/desktop/Dockerfile new file mode 100644 index 0000000..c65c9d3 --- /dev/null +++ b/desktop/Dockerfile @@ -0,0 +1,68 @@ +# Browser-accessible XFCE desktop with Fiji, for the Fileglancer Remote +# Desktop app. Runs read-only under rootless Apptainer on cluster nodes: no +# systemd/s6, nothing writes inside the image at runtime, and everything is +# started by /opt/fg-desktop/start-desktop.sh as the (non-root) invoking user. +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive \ + LANG=C.UTF-8 + +# Desktop + VNC stack, plus the X/GL/font libraries Fiji (Java Swing) needs. +# --no-install-recommends keeps the image lean and, importantly, keeps screen +# lockers (light-locker/xfce4-screensaver) out: a locked screen inside the +# container could never be unlocked. +RUN apt-get update && apt-get install -y --no-install-recommends \ + xfce4 \ + xfce4-terminal \ + elementary-xfce-icon-theme \ + dbus-x11 \ + xauth \ + x11-xserver-utils \ + tigervnc-standalone-server \ + python3 \ + python3-pip \ + ca-certificates \ + curl \ + unzip \ + fonts-dejavu-core \ + fontconfig \ + libxext6 \ + libxrender1 \ + libxtst6 \ + libxi6 \ + libxrandr2 \ + libgl1 \ + libglu1-mesa \ + && rm -rf /var/lib/apt/lists/* + +# websockify >= 0.11 provides the UnixDomainSocketDirectory token plugin that +# the launcher's security model depends on; assert it imports at build time. +RUN pip3 install --no-cache-dir --break-system-packages websockify==0.13.0 \ + && python3 -c 'from websockify.token_plugins import UnixDomainSocketDirectory' + +# noVNC browser client (static files only; websockify does the serving) +ARG NOVNC_VERSION=1.6.0 +RUN curl -fsSL "https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz" \ + | tar -xz -C /opt \ + && mv "/opt/noVNC-${NOVNC_VERSION}" /opt/novnc + +# Fiji, with bundled JDK. Only a rolling "latest" bundle is published these +# days (the versioned releases/ directory stopped at 2.14.0 in 2023), so +# reproducibility comes from this image's version tag, not the Fiji URL. +RUN curl -fsSL https://downloads.imagej.net/fiji/latest/fiji-latest-linux64-jdk.zip \ + -o /tmp/fiji.zip \ + && unzip -q /tmp/fiji.zip -d /tmp/fiji-extract \ + && rm /tmp/fiji.zip \ + && d="$(find /tmp/fiji-extract -mindepth 1 -maxdepth 1 -type d)" \ + && mv "$d" /opt/fiji \ + && rmdir /tmp/fiji-extract \ + && { test -x /opt/fiji/fiji-linux-x64 \ + || test -x /opt/fiji/ImageJ-linux64 \ + || test -x /opt/fiji/fiji; } + +COPY fiji-wrapper.sh /usr/local/bin/fiji +COPY fiji.desktop /usr/share/applications/fiji.desktop +COPY start-desktop.sh /opt/fg-desktop/start-desktop.sh +RUN chmod 755 /usr/local/bin/fiji /opt/fg-desktop/start-desktop.sh + +CMD ["/opt/fg-desktop/start-desktop.sh"] diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 0000000..bda50f7 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,48 @@ +# Remote Desktop + +Runs a full Linux desktop (XFCE) with [Fiji](https://fiji.sc/) preinstalled on a cluster node as a Fileglancer **service**, streamed to your browser with VNC ([TigerVNC](https://tigervnc.org/) + [noVNC](https://novnc.com/)), entirely inside an [Apptainer](https://apptainer.org/) container. The only host requirement is Apptainer. + +Your home directory is bind-mounted and writable, so files, XFCE settings, and anything you save persist across sessions. An optional **Data Folder** parameter bind-mounts an additional file-share folder and links it on the desktop. + +## One-click access + +Fileglancer mints a per-session secret (`$FG_SERVICE_TOKEN`) and publishes the service URL with the token embedded (`?path=websockify%3Ftoken%3D...`) once the server is ready, so clicking **Open Service** drops you straight onto the desktop — no password prompt. The desktop auto-resizes to fit your browser window (`resize=remote`). + +## Security model + +The session is protected at every hop, including against other users on the same shared compute node: + +- **Network**: the only listener is websockify on the Fileglancer-provided port. It serves the noVNC page to anyone, but only tunnels WebSocket connections that present the correct per-job 192-bit token. +- **VNC server**: Xvnc listens **only on a unix domain socket** in a mode-0700 node-local directory (`-rfbport -1` disables TCP entirely). The socket file is named after the token, and websockify's `UnixDomainSocketDirectory` token plugin maps token → socket, so a wrong token matches nothing and local users cannot reach the VNC server at all — the kernel enforces the directory permissions. +- **X display**: the X server requires an xauth cookie (mode 0600), so other local users cannot attach to the display through `/tmp/.X11-unix`. +- The token never appears on a command line (only the socket *directory* does), so it cannot leak via `ps`. + +**Caveats**: anyone with the tokenized URL gets the full desktop, including a terminal running as you, while the job runs — treat the URL as a secret and stop the service when finished. Traffic between your browser and the compute node is plain HTTP (unencrypted), the same trade-off as the other apps in this repository. + +## How it works + +- The image (`ghcr.io/janeliascicomp/fg-interactive-apps/desktop`) is pulled to your per-user Apptainer cache on first launch and reused afterwards. It is large (several GB), so the first launch takes a while — Fileglancer shows the "pulling image" phase during the wait. +- `start-desktop.sh` finds a free X display, starts Xvnc on a private unix socket with an xauth cookie, launches an XFCE session over D-Bus, and serves noVNC/websockify on `$FG_SERVICE_PORT` in the foreground — stopping the job tears everything down. +- Fiji lives at `/opt/fiji` inside the read-only image, with a launcher on the desktop and in the applications menu. + +## Fiji notes + +- The image install is **read-only**, so the ImageJ updater cannot add plugins or update sites there (automatic update checks are disabled). To use custom plugins/update sites, unpack your own Fiji into your home directory — it persists across sessions and appears in the same desktop. +- Fiji is refreshed to the then-current release whenever a new image version is built; pin behavior by keeping the `container:` tag in `runnables.yaml` at a version you have validated. + +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| **Data Folder** | directory | — | Optional folder to show on the desktop. Must be within a mounted file share; bind-mounted into the container automatically. Your home directory is always available. | + +## Building and releasing the image + +The image is built by the [`build-desktop`](../.github/workflows/build-desktop.yml) GitHub Actions workflow. Pull requests touching `desktop/` get a validation build; pushing a tag like `desktop-v1.0.0` builds and publishes `ghcr.io/janeliascicomp/fg-interactive-apps/desktop:1.0.0` (and `:latest`). + +After the first publish, set the GHCR package visibility to **public** (GitHub → org → Packages → `fg-interactive-apps/desktop` → settings) so Apptainer on the cluster can pull it anonymously. Then update the `container:` tag in `runnables.yaml` to match. + +## Notes + +- **Walltime** defaults to `08:00`. The desktop runs until you stop it or the walltime expires — raise it for longer sessions. +- Resolution defaults to 1920x1080 before the first browser connect; after that the desktop follows your browser window size. diff --git a/desktop/fiji-wrapper.sh b/desktop/fiji-wrapper.sh new file mode 100755 index 0000000..ea0995c --- /dev/null +++ b/desktop/fiji-wrapper.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Launch Fiji from the read-only image install at /opt/fiji. +# +# The image is immutable under Apptainer, so the ImageJ updater cannot modify +# this install; automatic update checks are disabled below. Users who need +# extra plugins or update sites can unpack their own Fiji into $HOME (which +# persists across sessions) and run it from there instead. +for exe in /opt/fiji/fiji-linux-x64 /opt/fiji/ImageJ-linux64 /opt/fiji/fiji; do + if [ -x "$exe" ]; then + exec "$exe" -Dimagej.updater.disableAutocheck=true "$@" + fi +done +echo "ERROR: no Fiji launcher found in /opt/fiji" >&2 +exit 1 diff --git a/desktop/fiji.desktop b/desktop/fiji.desktop new file mode 100644 index 0000000..6225bb6 --- /dev/null +++ b/desktop/fiji.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Fiji +Comment=Fiji is just ImageJ - image processing and analysis +Exec=fiji %F +Icon=/opt/fiji/images/icon.png +Terminal=false +Categories=Science;ImageProcessing;Graphics; +StartupNotify=true diff --git a/desktop/runnables.yaml b/desktop/runnables.yaml new file mode 100644 index 0000000..904c1cb --- /dev/null +++ b/desktop/runnables.yaml @@ -0,0 +1,37 @@ +name: Remote Desktop +description: Run a full Linux desktop (XFCE) with Fiji preinstalled on a cluster node, via Apptainer. +requirements: + - apptainer + +runnables: + - id: serve + name: Desktop + type: service + description: >- + Start an XFCE desktop with Fiji preinstalled and open it in your + browser. Your home directory is mounted and writable. Runs until + you stop it. + # Fileglancer publishes the service URL once the port is live (auto_url) + # and embeds the token it minted ($FG_SERVICE_TOKEN) via + # service_url_suffix, so "Open Service" connects in one click. Inside + # the container, websockify only tunnels WebSocket connections whose + # ?token= names the VNC unix socket in a private 0700 directory; see + # start-desktop.sh for the full security model. + auto_url: true + service_url_suffix: "/vnc.html?autoconnect=true&resize=remote&path=websockify%3Ftoken%3D${FG_SERVICE_TOKEN}" + container: docker://ghcr.io/janeliascicomp/fg-interactive-apps/desktop:1.0.0 + command: /opt/fg-desktop/start-desktop.sh + parameters: + - name: Data Folder + type: directory + description: >- + Optional folder to show on the desktop. Must be within a + mounted file share; it is bind-mounted into the container + automatically. Your home directory is always available. + required: false + exists: true + + resources: + cpus: 4 + memory: "16 GB" + walltime: "08:00" diff --git a/desktop/start-desktop.sh b/desktop/start-desktop.sh new file mode 100755 index 0000000..8f3d232 --- /dev/null +++ b/desktop/start-desktop.sh @@ -0,0 +1,126 @@ +#!/bin/bash +# Fileglancer Remote Desktop launcher: an XFCE session on a VNC server, served +# to the browser by noVNC/websockify on the single Fileglancer-provided port. +# +# Connection chain, and how each hop is protected on a shared compute node: +# +# browser --HTTP/WS, ?token=$FG_SERVICE_TOKEN--> websockify 0.0.0.0:$FG_SERVICE_PORT +# --unix socket named after the token, in a 0700 dir--> Xvnc (TCP disabled) +# --X11 display, xauth cookie required--> XFCE session, Fiji, ... +# +# - websockify's UnixDomainSocketDirectory token plugin only tunnels WebSocket +# connections whose ?token= matches a socket filename in the private +# directory, so network access requires the per-job 192-bit token. +# - Xvnc listens only on that unix socket (-rfbport -1 disables TCP). The 0700 +# directory keeps other users on the node from reaching the VNC server at +# all, which is what makes -SecurityTypes None safe here. +# - The X display itself requires the xauth cookie (mode 0600), so other local +# users cannot attach through /tmp/.X11-unix either. + +set -euo pipefail + +: "${FG_SERVICE_PORT:?FG_SERVICE_PORT is not set - this app requires the Fileglancer service contract}" +: "${FG_SERVICE_TOKEN:?FG_SERVICE_TOKEN is not set - this app requires the Fileglancer service contract}" + +DATA_DIR="${1:-}" +GEOMETRY="${FG_DESKTOP_GEOMETRY:-1920x1080}" + +# Private per-session state (X auth cookie, VNC socket, XDG runtime). This must +# be on node-local disk - unix sockets are unreliable on NFS homes - and +# mktemp -d creates it mode 0700, which is what keeps other users out. +PRIV_DIR="$(mktemp -d /tmp/fg-desktop.XXXXXXXX)" +SOCK_DIR="$PRIV_DIR/sock" +XAUTH_FILE="$PRIV_DIR/Xauthority" +VNC_SOCKET="$SOCK_DIR/$FG_SERVICE_TOKEN" +export XDG_RUNTIME_DIR="$PRIV_DIR/run" +mkdir -m 700 "$SOCK_DIR" "$XDG_RUNTIME_DIR" + +XVNC_PID="" +SESSION_PID="" +cleanup() { + if [ -n "$SESSION_PID" ]; then kill "$SESSION_PID" 2>/dev/null || true; fi + if [ -n "$XVNC_PID" ]; then kill "$XVNC_PID" 2>/dev/null || true; fi + rm -rf "$PRIV_DIR" +} +trap cleanup EXIT + +# --- Xvnc -------------------------------------------------------------------- +# Scan for a free X display (vncserver-style). Another user on the node can +# grab a display between our check and Xvnc's startup, so on failure move on +# to the next number. +DISPLAY_NUM="" +for n in $(seq 10 99); do + [ -e "/tmp/.X${n}-lock" ] && continue + [ -e "/tmp/.X11-unix/X${n}" ] && continue + + # The cookie must exist before Xvnc starts: -auth is read at server startup. + rm -f "$XAUTH_FILE" && touch "$XAUTH_FILE" && chmod 600 "$XAUTH_FILE" + xauth -f "$XAUTH_FILE" add ":${n}" MIT-MAGIC-COOKIE-1 "$(mcookie)" 2>/dev/null + + Xvnc ":${n}" \ + -rfbunixpath "$VNC_SOCKET" -rfbunixmode 0600 \ + -rfbport -1 \ + -SecurityTypes None \ + -auth "$XAUTH_FILE" \ + -geometry "$GEOMETRY" -depth 24 \ + -desktop "Fileglancer Desktop" & + XVNC_PID=$! + + # Up when the VNC unix socket appears; dead (lost the display race) if Xvnc exits. + for _ in $(seq 1 50); do + [ -S "$VNC_SOCKET" ] && break + kill -0 "$XVNC_PID" 2>/dev/null || break + sleep 0.2 + done + if [ -S "$VNC_SOCKET" ]; then + DISPLAY_NUM="$n" + break + fi + kill "$XVNC_PID" 2>/dev/null || true + wait "$XVNC_PID" 2>/dev/null || true + XVNC_PID="" + rm -f "$VNC_SOCKET" +done + +if [ -z "$DISPLAY_NUM" ]; then + echo "ERROR: could not start Xvnc on any display (:10-:99)" >&2 + exit 1 +fi + +export DISPLAY=":${DISPLAY_NUM}" +export XAUTHORITY="$XAUTH_FILE" +echo "Xvnc up on display ${DISPLAY} (unix socket only, no TCP)" >&2 + +# --- Desktop conveniences ------------------------------------------------ +# $HOME is the user's real (bind-mounted) home, so these persist across sessions. +mkdir -p "$HOME/Desktop" +if [ ! -e "$HOME/Desktop/fiji.desktop" ]; then + cp /usr/share/applications/fiji.desktop "$HOME/Desktop/" 2>/dev/null || true + chmod +x "$HOME/Desktop/fiji.desktop" 2>/dev/null || true +fi + +# Link the requested data folder onto the desktop (Fileglancer has already +# bind-mounted it into the container). +if [ -n "$DATA_DIR" ]; then + link_name="$HOME/Desktop/$(basename "$DATA_DIR")" + if [ ! -e "$link_name" ] || [ -L "$link_name" ]; then + ln -sfn "$DATA_DIR" "$link_name" + fi +fi + +# --- XFCE session -------------------------------------------------------- +dbus-launch --exit-with-session startxfce4 & +SESSION_PID=$! +echo "XFCE session started (pid ${SESSION_PID})" >&2 + +# --- websockify / noVNC ---------------------------------------------------- +# Serves the noVNC client and tunnels token-authenticated WebSocket +# connections to the VNC unix socket. Runs in the foreground: stopping the +# job (or websockify dying) tears down the whole session via the EXIT trap. +echo "Serving noVNC on port ${FG_SERVICE_PORT}" >&2 +websockify \ + --web /opt/novnc \ + --token-plugin UnixDomainSocketDirectory \ + --token-source "$SOCK_DIR" \ + --heartbeat 30 \ + "0.0.0.0:${FG_SERVICE_PORT}"