diff --git a/cookbooks/seedbox/Makefile b/cookbooks/seedbox/Makefile index ec031e7..8065454 100644 --- a/cookbooks/seedbox/Makefile +++ b/cookbooks/seedbox/Makefile @@ -10,3 +10,8 @@ PROJECT_GID = 10000 # Include common Makefile include ../../scripts/common.mk + +# browser.conf is sourced with root privileges by seedbox-waypipe.sh, so it must +# not be owned by the unprivileged user the browser itself runs as. +$(filter %/browser.conf, $(TARGET_CONFIG_FILES)): + install -o root -g root -m 0644 $< $@ diff --git a/cookbooks/seedbox/README.md b/cookbooks/seedbox/README.md index 8ad3e37..dd44f60 100644 --- a/cookbooks/seedbox/README.md +++ b/cookbooks/seedbox/README.md @@ -87,3 +87,142 @@ sudo make uninstall clean ```sh sudo make test ``` + +## Browser (LibreWolf over waypipe) + +A browser is sometimes needed *on* the seedbox: to log into a private tracker, +to solve a challenge FlareSolverr cannot, or simply to reach an interface that +is only exposed on the seedbox's own network. Rather than a full remote-desktop +stack, this cookbook ships a browser that is displayed on your workstation with +[waypipe](https://gitlab.freedesktop.org/mstoeckl/waypipe): the browser runs +here, its Wayland protocol is forwarded over SSH, and it draws in a window on +your desktop like a local application. + +### Where waypipe runs, and why it matters + +`waypipe ssh` runs a waypipe *server* on the remote side. That server connects +back to your local waypipe client through an SSH-forwarded Unix socket, creates +a Wayland socket of its own, and starts the program under it -- so it has to +share a mount namespace with the browser. + +waypipe therefore lives **in the container**, not on the Fedora CoreOS host: + +- the host stays a stock, immutable CoreOS image with no layered package; +- the waypipe version is pinned by the image. This is not cosmetic: waypipe 0.9 + is the old C implementation and 0.10+ is the Rust rewrite, and they do not + interoperate. The image is built on Fedora 44, so it carries the same waypipe + as a Fedora 44 workstation, and the nightly rebuild keeps them in step. + +The entry point on the host is a small shim, +`/etc/quadlets/seedbox/seedbox-waypipe.sh`, which you point waypipe at with +`--remote-bin`. It is a plain file rather than a shell function because +`ssh ` runs a non-interactive, non-login shell, which never +sources `/etc/profile.d`. + +### Usage + +From your workstation (waypipe must be installed there too, at a matching +version): + +```sh +waypipe --remote-bin /etc/quadlets/seedbox/seedbox-waypipe.sh \ + --remote-socket /run/seedbox/waypipe/wp \ + --no-gpu \ + ssh @ librewolf +``` + +`--remote-socket` is not optional in practice: by default waypipe picks a +randomised path under `/tmp`, and the shim has to know the directory in advance +to hand the socket to the container. `/run/seedbox/waypipe` is created by +`tmpfiles.d` and writable by the `wheel` group. + +Logged in on the seedbox, `/etc/profile.d/seedbox.sh` provides +`seedbox-browser` (same arguments as the waypipe binary; with no argument it +opens a shell in the image), `seedbox-browser-rebuild` and +`seedbox-browser-command`. + +### Why LibreWolf + +The seedbox runs on aarch64, which rules out the two strongest options: neither +Tor Browser nor Mullvad Browser publishes a Linux arm64 build. LibreWolf does, +in a GPG-signed RPM repository, and it ships the hardening already applied: +`privacy.resistFingerprinting` on, uBlock Origin auto-installed through its +`policies.json`, telemetry and sponsored content gone, HTTPS-only mode on, +cookies partitioned, and app updates disabled -- which suits an image rebuilt +nightly by `librewolf-build.timer`. + +`config/container/itix-hardening.cfg` is deliberately short. RFP works by making +every user look *identical*, so each pref flipped on top of it moves this +browser out of that crowd and makes it easier to single out. It only corrects +for what the container changes: letterboxing (the window is resized to arbitrary +dimensions by the waypipe client) and the download directory. The installed font +set is pinned in the `Containerfile` for the same reason -- a font list that +drifts every night is a fingerprint that drifts every night. + +Note that arm64 Linux has no Widevine, so DRM streaming services will not work, +and `EncryptedMediaExtensions` is disabled by LibreWolf anyway. + +### Configuration + +`/etc/quadlets/seedbox/browser.conf` holds the image name, the uid/gid, the +profile and download directories, and escape hatches for extra podman or waypipe +arguments. Every setting uses the `${VAR:-default}` form, so an environment +variable still wins for one-off overrides. The file is owned by `root:root` +because the shim sources it with root privileges. + +On this cookbook's defaults the profile lives in +`/var/lib/quadlets/seedbox/librewolf`; on a real seedbox, point +`BROWSER_PROFILE_DIR` at the SSD virtiofs mount and `BROWSER_DOWNLOAD_DIR` at +the import directory the \*arr stack already watches. + +### Two security trade-offs worth knowing about + +**The container runs with `--security-opt label=disable`.** SELinux checks +`connect()` on a Unix socket with the `connectto` permission against the +*listening process*, not against the socket file, so relabelling the socket does +not help: + +``` +avc: denied { connectto } comm="waypipe" path="/run/seedbox/waypipe/....sock" + scontext=...:container_t:s0:c266,c919 + tcontext=...:unconfined_t:s0-s0:c0.c1023 + tclass=unix_stream_socket +``` + +The listener is the SSH session, which belongs to an unconfined user, and no +label this container could carry is allowed to connect to it. The alternative is +a system-wide policy module granting +`allow container_t unconfined_t:unix_stream_socket connectto`, which opens that +hole for *every* container on the host rather than for this one. Neither option +is free; this cookbook picks the narrower blast radius. The browser still runs +as an unprivileged uid, with no added capabilities, and the container is +discarded when the window closes. + +**The browser is not on the host network.** Unlike the rest of this stack, it +gets podman's default network rather than `Network=host`, precisely so that a +hostile page cannot reach `127.0.0.1:8989` and friends -- the \*arr interfaces +are only protected by Traefik, and a browser is the one process here that +routinely executes untrusted code. + +### Two sandboxes fighting over one capability + +Worth knowing before you touch the podman flags or the `Containerfile` +entrypoint, because the failure modes look unrelated to each other: + +- **Firefox's content sandbox chroots itself.** It does that inside a fresh user + namespace, where it holds whatever is left in the *bounding* set, so + `CAP_SYS_CHROOT` has to survive `--cap-drop=ALL`. Without it every content + process logs `Sandbox: chroot: EPERM` and dies on SIGSEGV. It is the only + capability needed -- `SYS_ADMIN` is not. +- **bubblewrap refuses to run when the caller holds capabilities without being + setuid** (`bwrap: Unexpected capabilities but not setuid, old file caps + config?`). On Fedora, gdk-pixbuf hands SVG decoding to glycin, which isolates + its loaders with `bwrap --unshare-all`. When that fails, GTK cannot load an + icon, falls back to `image-missing.svg`, fails again, and aborts the browser: + `Gtk:ERROR:gtkiconhelper.c:495:ensure_surface_for_gicon: assertion failed`. + +`--cap-add=SYS_CHROOT` satisfies the first and breaks the second, because podman +adds the capability to the *ambient* set as well when the container runs as a +non-root user. The image entrypoint therefore drops the ambient and inheritable +sets with `setpriv`, which needs no privilege and leaves the bounding set alone. +Each sandbox then sees what it expects. diff --git a/cookbooks/seedbox/config/browser.conf b/cookbooks/seedbox/config/browser.conf new file mode 100644 index 0000000..6b3028c --- /dev/null +++ b/cookbooks/seedbox/config/browser.conf @@ -0,0 +1,49 @@ +## +## Configuration of the on-demand browser container. +## +## Sourced by /etc/quadlets/seedbox/seedbox-waypipe.sh (as root) and by +## /etc/profile.d/seedbox.sh, so keep it to plain shell assignments. +## +## Every setting uses the ${VAR:-default} form so that an environment variable +## still wins over the file, which makes one-off overrides easy: +## BROWSER_PODMAN_OPTS=--security-opt=seccomp=unconfined seedbox-browser ... +## +## It is owned by root:root on purpose (see the seedbox Makefile): the shim +## sources it with root privileges, so it must not be writable by the seedbox +## user the browser runs as. +## + +# Container image, rebuilt every night by librewolf-build.timer. +BROWSER_IMAGE="${BROWSER_IMAGE:-localhost/librewolf:latest}" + +# The browser runs as the unprivileged seedbox user, like the rest of the stack. +# Changing these means rebuilding the image: the Containerfile creates +# /run/user/ (the browser's XDG_RUNTIME_DIR) with matching ownership. +BROWSER_UID="${BROWSER_UID:-10017}" +BROWSER_GID="${BROWSER_GID:-10000}" + +# Persistent browser profile: bookmarks, cookies, extension state. +# On the production seedbox, point this at the SSD virtiofs mount instead: +# BROWSER_PROFILE_DIR=/var/lib/virtiofs/ssd/librewolf +BROWSER_PROFILE_DIR="${BROWSER_PROFILE_DIR:-/var/lib/quadlets/seedbox/librewolf}" + +# Where downloads land. On the production seedbox, the import directory the +# *arr stack already watches is the obvious candidate: +# BROWSER_DOWNLOAD_DIR=/var/lib/virtiofs/hdd/import +BROWSER_DOWNLOAD_DIR="${BROWSER_DOWNLOAD_DIR:-/var/lib/quadlets/seedbox/downloads}" + +# Firefox-family browsers are multi-process and need a large /dev/shm. +BROWSER_SHM_SIZE="${BROWSER_SHM_SIZE:-1g}" + +# --no-gpu: there is no GPU in this VM, so block wayland-drm and linux-dmabuf +# instead of letting waypipe negotiate them and fail. +# +# These are only added when the client has not passed them already: "waypipe +# ssh" forwards several of its own options to the server side, and a repeated +# flag is a hard error ("cannot be used multiple times"). +BROWSER_WAYPIPE_OPTS="${BROWSER_WAYPIPE_OPTS:---no-gpu}" + +# Escape hatch for extra podman arguments, e.g. if the browser's own sandbox +# ends up needing a looser seccomp profile: +# BROWSER_PODMAN_OPTS="--security-opt seccomp=unconfined" +BROWSER_PODMAN_OPTS="${BROWSER_PODMAN_OPTS:-}" diff --git a/cookbooks/seedbox/config/container/Containerfile b/cookbooks/seedbox/config/container/Containerfile new file mode 100644 index 0000000..c947cc7 --- /dev/null +++ b/cookbooks/seedbox/config/container/Containerfile @@ -0,0 +1,77 @@ +# LibreWolf, displayed on a remote desktop through waypipe (Wayland over SSH). +# +# waypipe is installed *here* rather than on the Fedora CoreOS host: the waypipe +# server has to share a mount namespace with the browser (it creates the Wayland +# socket the browser connects to), and putting it in the image keeps the host a +# stock, immutable CoreOS with no layered packages. It also pins the waypipe +# version, which matters: waypipe 0.9 (C) and 0.10+ (Rust) do not interoperate, +# and the client and server versions have to match. +FROM quay.io/fedora/fedora:44 + +COPY librewolf.repo /etc/yum.repos.d/librewolf.repo + +# The font set is pinned explicitly and must stay identical across rebuilds: the +# list of installed fonts is a fingerprinting vector, and a set that drifts every +# night would make this browser a little more recognisable every day. +# +# mesa-dri-drivers provides llvmpipe -- there is no GPU in this VM. +# +# The icon theme, the extra gdk-pixbuf loaders and the MIME database are weak +# dependencies, and they are not optional here: without them GTK cannot resolve +# an icon, falls back to image-missing.svg, fails to load that too, and aborts +# the whole browser with +# "Gtk:ERROR:gtkiconhelper.c:495:ensure_surface_for_gicon: assertion failed". +RUN dnf install -y --setopt=install_weak_deps=False \ + librewolf \ + waypipe \ + adwaita-icon-theme \ + hicolor-icon-theme \ + gdk-pixbuf2-modules-extra \ + shared-mime-info \ + util-linux \ + dejavu-sans-fonts dejavu-serif-fonts dejavu-sans-mono-fonts \ + liberation-sans-fonts liberation-serif-fonts liberation-mono-fonts \ + google-noto-color-emoji-fonts \ + mesa-dri-drivers \ + && dnf clean all \ + && rm -rf /var/cache/dnf + +# See the header of itix-hardening.cfg for why this is appended to LibreWolf's +# own AutoConfig file instead of living in librewolf.overrides.cfg (which would +# sit in the persistent profile volume and escape the nightly rebuild). +COPY itix-hardening.cfg /tmp/itix-hardening.cfg +RUN cat /tmp/itix-hardening.cfg >> /usr/share/librewolf/librewolf.cfg \ + && rm -f /tmp/itix-hardening.cfg + +# Two sandboxes fight over CAP_SYS_CHROOT in here, and setpriv (util-linux) is +# what reconciles them: +# +# - Firefox's content sandbox chroots itself. It does so inside a fresh user +# namespace, where it holds every capability still present in the *bounding* +# set -- so CAP_SYS_CHROOT has to be in the bounding set, or content +# processes die with "Sandbox: chroot: EPERM". +# - bubblewrap, which glycin (Fedora's SVG image loader) uses, refuses to run +# at all when the calling process holds capabilities without being setuid: +# "bwrap: Unexpected capabilities but not setuid, old file caps config?". +# GTK then fails to load an icon and aborts the browser. +# +# podman's --cap-add puts the capability in the *ambient* set too when the +# container runs as a non-root user, which is exactly what bubblewrap objects +# to. Dropping ambient and inheritable capabilities needs no privilege, and +# leaves the bounding set untouched, so both sandboxes get what they want. +ENTRYPOINT [ "/usr/bin/setpriv", "--inh-caps=-all", "--ambient-caps=-all", "--" ] + +# XDG_RUNTIME_DIR for the browser user: waypipe creates its Wayland socket in +# there. podman does not mount a tmpfs over /run, so a directory baked into the +# image with the right ownership is writable, and disappears with the container. +# +# The uid/gid are hardcoded, like everywhere else in this cookbook; they have to +# match BROWSER_UID/BROWSER_GID in browser.conf. +RUN install -d -m 0755 -o root -g root /run/user \ + && install -d -m 0700 -o 10017 -g 10000 /run/user/10017 + +# The profile and the download directory are bind-mounted at run time. +VOLUME /home/browser /downloads + +# The shim runs "waypipe ... server -- librewolf" through the entrypoint above. +CMD [ "librewolf" ] diff --git a/cookbooks/seedbox/config/container/itix-hardening.cfg b/cookbooks/seedbox/config/container/itix-hardening.cfg new file mode 100644 index 0000000..43fd10b --- /dev/null +++ b/cookbooks/seedbox/config/container/itix-hardening.cfg @@ -0,0 +1,27 @@ + +/** ------------------------------ + * [CATEGORY] ITIX OVERRIDES + * + * Appended to librewolf.cfg when the image is built (see Containerfile). + * librewolf.cfg is an AutoConfig file, evaluated after defaults/pref/*.js, and + * the last defaultPref() for a given pref is the one that sticks -- which is + * why these overrides are appended rather than dropped in a separate file. + * + * Deliberately short. LibreWolf already enables privacy.resistFingerprinting, + * and RFP works by making every user look *identical*: each pref we flip on top + * of it moves this browser out of that crowd and makes it easier to single out, + * not harder. Only add something here when it corrects for the fact that this + * browser runs in a container displayed over waypipe. + * ------------------------------- */ + +// Letterboxing rounds the content area to a coarse grid of common sizes. +// LibreWolf ships the pref but leaves it off; Tor Browser has it on. Here the +// window is resized to whatever the waypipe client feels like, and window size +// is one of the strongest signals there is, so turn it on. +defaultPref("privacy.resistFingerprinting.letterboxing", true); + +// Downloads land in the bind-mounted /downloads directory (see browser.conf), +// not somewhere inside the container's ephemeral filesystem. +defaultPref("browser.download.folderList", 2); +defaultPref("browser.download.dir", "/downloads"); +defaultPref("browser.download.start_downloads_in_tmp_dir", false); diff --git a/cookbooks/seedbox/config/container/librewolf.repo b/cookbooks/seedbox/config/container/librewolf.repo new file mode 100644 index 0000000..e3597f2 --- /dev/null +++ b/cookbooks/seedbox/config/container/librewolf.repo @@ -0,0 +1,11 @@ +# LibreWolf is not packaged in Fedora. This is the upstream repository, which +# publishes both x86_64 and aarch64 (the seedbox VM runs on Ampere hardware). +# +# Both the packages and the repository metadata are GPG-checked. +[librewolf] +name=LibreWolf Software Repository +baseurl=https://repo.librewolf.net +gpgcheck=1 +repo_gpgcheck=1 +gpgkey=https://repo.librewolf.net/pubkey.gpg +enabled=1 diff --git a/cookbooks/seedbox/config/seedbox-waypipe.sh b/cookbooks/seedbox/config/seedbox-waypipe.sh new file mode 100755 index 0000000..2769fce --- /dev/null +++ b/cookbooks/seedbox/config/seedbox-waypipe.sh @@ -0,0 +1,227 @@ +#!/bin/bash +# +# waypipe "remote binary" shim for the seedbox browser. +# +# `waypipe ssh` does not run the browser directly. It runs a waypipe *server* on +# the remote side, which connects back to the local waypipe client through an +# SSH-forwarded Unix socket, creates a Wayland socket of its own, and starts the +# program under it. That server has to share a mount namespace with the browser, +# so here it runs *inside* the container -- which is what keeps waypipe and the +# browser off the immutable Fedora CoreOS host entirely. +# +# waypipe calls this script exactly the way it would call the waypipe binary: +# +# seedbox-waypipe.sh --socket server -- librewolf +# +# so the arguments are handed to the in-container waypipe untouched. Wire it in +# from the client with --remote-bin; see the cookbook README for the full +# client-side command line. +# +# Called with no arguments, it opens an interactive shell in the image instead, +# which is handy to inspect what the nightly rebuild produced. +# +set -Eeuo pipefail + +CONFIG_FILE=/etc/quadlets/seedbox/browser.conf + +## +## The containers of this cookbook are started by root and drop to the seedbox +## user themselves (--user below), but ssh logs us in as an unprivileged +## account, so re-exec through sudo first. +## +## sudo scrubs the environment, so any BROWSER_* override the caller set is +## carried across explicitly. That does let the caller influence a podman +## command line that runs as root: only grant sudo on this script to accounts +## that already have full root anyway (on Fedora CoreOS, the wheel group). +## +if [ "$(id -u)" -ne 0 ]; then + overrides=() + while IFS= read -r name; do + overrides+=("${name}=${!name}") + done < <(compgen -v | grep "^BROWSER_" || true) + + exec sudo -n -- /usr/bin/env ${overrides[@]+"${overrides[@]}"} "$0" "$@" +fi + +# Defaults live in browser.conf, which uses the ${VAR:-default} form so that the +# environment still wins; these are the fallbacks if the file is missing. +# shellcheck source=/dev/null +[ -r "$CONFIG_FILE" ] && . "$CONFIG_FILE" + +BROWSER_IMAGE="${BROWSER_IMAGE:-localhost/librewolf:latest}" +BROWSER_UID="${BROWSER_UID:-10017}" +BROWSER_GID="${BROWSER_GID:-10000}" +BROWSER_PROFILE_DIR="${BROWSER_PROFILE_DIR:-/var/lib/quadlets/seedbox/librewolf}" +BROWSER_DOWNLOAD_DIR="${BROWSER_DOWNLOAD_DIR:-/var/lib/quadlets/seedbox/downloads}" +BROWSER_SHM_SIZE="${BROWSER_SHM_SIZE:-1g}" +BROWSER_WAYPIPE_OPTS="${BROWSER_WAYPIPE_OPTS:---no-gpu}" +BROWSER_PODMAN_OPTS="${BROWSER_PODMAN_OPTS:-}" + +## +## Locate the --socket argument waypipe passed us. +## +## Parsing stops at the sub-command: everything after "server" belongs to the +## program being run and may legitimately carry a --socket of its own. +## +socket="" +previous="" +leading_arguments=() +for argument in "$@"; do + case "$argument" in + server|client|ssh|bench) + break + ;; + --socket=*) + socket="${argument#--socket=}" + ;; + esac + case "$previous" in + -s|--socket) + socket="$argument" + ;; + esac + leading_arguments+=("$argument") + previous="$argument" +done + +## +## Work out which of our own waypipe options still need to be added. +## +## "waypipe ssh" forwards several of its options to the server side (--no-gpu +## and --threads at least), and the argument parser rejects a repeated flag +## outright: +## +## error: the argument '--no-gpu' cannot be used multiple times +## +## so anything already on the command line has to be left alone. +## +option_matches() { + case "$1:$2" in + "--no-gpu:-n"|"-n:--no-gpu") return 0 ;; + esac + [ "$1" = "$2" ] +} + +waypipe_options=() +for option in ${BROWSER_WAYPIPE_OPTS}; do + already_given=false + for argument in ${leading_arguments[@]+"${leading_arguments[@]}"}; do + if option_matches "$option" "$argument"; then + already_given=true + break + fi + done + "$already_given" || waypipe_options+=("$option") +done + +socket_options=() +if [ -n "$socket" ]; then + if [ ! -S "$socket" ]; then + echo "$0: '$socket' is not a socket: is the SSH remote forwarding set up?" >&2 + exit 1 + fi + + # sshd creates the forwarded socket owned by the login user with mode 0600 + # (StreamLocalBindMask 0177), so the unprivileged browser user can neither + # connect() to it nor, on shutdown, remove it: + # + # Error: "src/main.rs:1505: Failed to unlink socket: EACCES: Permission denied" + # + # Handing the socket over outright covers both: it keeps mode 0600, so no + # other account can hijack the Wayland connection, and the sticky bit on the + # parent directory then lets its new owner -- and only its new owner -- + # unlink it. An ACL would grant the connect but not the unlink, since + # deleting a file is governed by the directory and the file's ownership. + chown "${BROWSER_UID}:${BROWSER_GID}" "$socket" + + # The *directory* is mounted, not the socket alone. Bind-mounting the + # socket by itself makes podman synthesize a parent directory inside the + # container -- root:root, mode 0755 -- and the host's permissions on the + # real directory become irrelevant: the browser's uid cannot write to that + # synthetic parent, so waypipe cannot remove its own socket on shutdown: + # + # Error: "src/main.rs:1505: Failed to unlink socket: EACCES: Permission denied" + # + # Mounting the directory gives the container the real one, sticky bit and + # all. Concurrent sessions become visible to each other by name, but their + # sockets stay 0600 and owned by their own uid, so they can be neither + # connected to nor deleted. + socket_directory=$(dirname "$socket") + socket_options+=(--volume "${socket_directory}:${socket_directory}") +fi + +## +## Run the browser. +## +## --userns=host is not decorative: it is what makes the in-container uid the +## same as the host uid, without which the ACL set on the socket above would +## not apply to the browser process. +## +podman_options=( + --rm + --interactive + # Without an init, waypipe is PID 1 and inherits every orphaned browser + # process, logging each one it reaps as an error: + # ERR waypipe-server(1) main.rs:260] Received SIGCHLD for unexpected child + --init + --user "${BROWSER_UID}:${BROWSER_GID}" + --userns=host + --cap-drop=ALL + # Firefox's own content sandbox chroots itself, and cannot get the + # capability back from an empty bounding set: without this, every content + # process dies with "Sandbox: chroot: EPERM" followed by SIGSEGV. This is + # the only capability it needs -- SYS_ADMIN is not required. + # + # podman also puts it in the ambient set, which breaks the *other* sandbox + # in this image (bubblewrap, used by the SVG image loader). The image's + # entrypoint drops the ambient set again; see the Containerfile. + --cap-add=SYS_CHROOT + --security-opt no-new-privileges + # SELinux checks connect() on a Unix socket with "connectto" against the + # *listening process*, not against the socket file, so relabelling the + # socket does not help: + # + # avc: denied { connectto } comm="waypipe" + # path="/run/seedbox/waypipe/....sock" + # scontext=...:container_t:s0:c266,c919 + # tcontext=...:unconfined_t:s0-s0:c0.c1023 + # tclass=unix_stream_socket + # + # The listener is the SSH session (an unconfined user), and no label this + # container could carry is allowed to connect to it. The alternative to + # disabling the label here is a system-wide policy module granting + # "allow container_t unconfined_t:unix_stream_socket connectto", which + # opens that hole for *every* container on the host rather than this one. + # See the README for the trade-off. The container still runs unprivileged + # (--user, --cap-drop=ALL, no-new-privileges) and is thrown away on exit. + --security-opt label=disable + --shm-size "${BROWSER_SHM_SIZE}" + --env "XDG_RUNTIME_DIR=/run/user/${BROWSER_UID}" + --env "HOME=/home/browser" + --env "MOZ_ENABLE_WAYLAND=1" + --volume "${BROWSER_PROFILE_DIR}:/home/browser:z" + --volume "${BROWSER_DOWNLOAD_DIR}:/downloads:z" +) + +if [ "$#" -eq 0 ]; then + # No arguments: interactive shell in the image, for troubleshooting. + exec podman run --tty "${podman_options[@]}" ${BROWSER_PODMAN_OPTS} "${BROWSER_IMAGE}" /bin/bash +fi + +status=0 +# shellcheck disable=SC2086 # BROWSER_PODMAN_OPTS and BROWSER_WAYPIPE_OPTS are word-split on purpose +podman run "${podman_options[@]}" "${socket_options[@]}" ${BROWSER_PODMAN_OPTS} \ + "${BROWSER_IMAGE}" \ + waypipe ${waypipe_options[@]+"${waypipe_options[@]}"} "$@" || status=$? + +## +## waypipe unlinks the forwarded socket itself on a clean shutdown (see the +## chown above), and sshd does not (StreamLocalBindUnlink defaults to no). This +## is the safety net for the cases where waypipe never gets that far -- a crash, +## a killed session -- so that the directory does not fill with dead sockets. +## +if [ -n "$socket" ]; then + rm -f "$socket" +fi + +exit "$status" diff --git a/cookbooks/seedbox/librewolf-build.timer b/cookbooks/seedbox/librewolf-build.timer new file mode 100644 index 0000000..9405a8d --- /dev/null +++ b/cookbooks/seedbox/librewolf-build.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Rebuild the LibreWolf browser image +PartOf=seedbox.target + +[Timer] +OnCalendar=daily +Persistent=true + +[Install] +WantedBy=seedbox.target diff --git a/cookbooks/seedbox/librewolf.build b/cookbooks/seedbox/librewolf.build new file mode 100644 index 0000000..6794991 --- /dev/null +++ b/cookbooks/seedbox/librewolf.build @@ -0,0 +1,11 @@ +[Unit] +Description=Build of the LibreWolf browser image +Documentation=https://librewolf.net/ +Wants=network-online.target +After=network-online.target fedora-image.service +Requires=fedora-image.service + +[Build] +File=/etc/quadlets/seedbox/container/Containerfile +ImageTag=localhost/librewolf:latest +SetWorkingDirectory=/etc/quadlets/seedbox/container diff --git a/cookbooks/seedbox/profile.d/seedbox.sh b/cookbooks/seedbox/profile.d/seedbox.sh new file mode 100644 index 0000000..2abbd08 --- /dev/null +++ b/cookbooks/seedbox/profile.d/seedbox.sh @@ -0,0 +1,35 @@ +## +## Interactive helpers for the seedbox browser container. +## +## Note that this file is NOT what starts the browser for a `waypipe ssh` +## session: `ssh ` runs a non-interactive, non-login shell, +## which does not source /etc/profile.d. The entry point in that case is +## /etc/quadlets/seedbox/seedbox-waypipe.sh, passed to waypipe with +## --remote-bin. These helpers are for when you are logged in and want to +## inspect or drive the same container by hand. +## + +if [ -r /etc/quadlets/seedbox/browser.conf ]; then + . /etc/quadlets/seedbox/browser.conf + export BROWSER_IMAGE BROWSER_PROFILE_DIR BROWSER_DOWNLOAD_DIR +fi + +# Run the browser container. Takes the same arguments as the waypipe binary, so +# it can stand in for a manual `waypipe --socket ... server -- librewolf`; with +# no arguments, it opens a shell in the image. +seedbox-browser() { + sudo -n /etc/quadlets/seedbox/seedbox-waypipe.sh "$@" +} + +# Rebuild the image now instead of waiting for tonight's timer. +seedbox-browser-rebuild() { + sudo -n systemctl start librewolf-build.service && + sudo -n journalctl -u librewolf-build.service -n 20 --no-pager +} + +# Print the command line to run on the *client* to get a browser on screen. +seedbox-browser-command() { + printf 'waypipe --remote-bin /etc/quadlets/seedbox/seedbox-waypipe.sh \\\n' + printf ' --remote-socket /run/seedbox/waypipe/wp \\\n' + printf ' --no-gpu ssh %s@%s librewolf\n' "${USER:-nicolas}" "$(hostname -f 2>/dev/null || hostname)" +} diff --git a/cookbooks/seedbox/tmpfiles.d/seedbox.conf b/cookbooks/seedbox/tmpfiles.d/seedbox.conf index fd02da2..40fa808 100644 --- a/cookbooks/seedbox/tmpfiles.d/seedbox.conf +++ b/cookbooks/seedbox/tmpfiles.d/seedbox.conf @@ -28,3 +28,18 @@ d$ /var/lib/virtiofs/ssd/jellyfin 0700 10017 10000 - d$ /var/lib/virtiofs/ssd/jellyfin/config 0700 10017 10000 - d$ /var/lib/virtiofs/ssd/cross-seed 0700 10017 10000 - d$ /var/lib/virtiofs/ssd/cross-seed/config 0700 10017 10000 - + +# Browser container displayed over waypipe. /run/seedbox/waypipe holds the +# sockets sshd forwards back to the waypipe client. +# +# Same permissions as /tmp, which is where waypipe puts these sockets by +# default, and for the same reasons: whoever opens the session has to be able to +# create one, and the browser -- running as a different, unprivileged uid -- has +# to be able to remove its own when waypipe shuts down. The sticky bit is what +# keeps that from becoming "anybody may delete anybody's socket". The sockets +# themselves are created 0600 by sshd (StreamLocalBindMask) and handed to a +# single uid by the shim. +d$ /run/seedbox 0755 root root - +d$ /run/seedbox/waypipe 1777 root root - +d$ /var/lib/quadlets/seedbox/librewolf 0700 10017 10000 - +d$ /var/lib/quadlets/seedbox/downloads 0755 10017 10000 -