#!/bin/sh # provision-kiosk.sh — Linux kiosk provisioning wizard # # Turns a systemd Linux machine into a Vestibule kiosk without manual OS # configuration: # # 1. Verify prerequisites: systemd, cage, a Gecko browser (LibreWolf # or Firefox — native package, Flatpak, or snap), usher, python3 # 2. Create the dedicated kiosk user (password stays locked) # 3. Stage files under /opt/vestibule (usher, extension XPI, configs) # 4. Install /usr/local/bin/vestibule-kiosk-launch # 5. Write /etc/vestibule/kiosk.env (home URL, browser + flavor) # 6. Register the usher Native Messaging host for the kiosk user # 7. Deploy a merged policies.json (policy force-installs the # extension) where the chosen browser reads it # 8. Generate + enable vestibule-kiosk.service (cage on a VT) # # The unit IS the graphical session: no display manager, no autologin # config. cage takes the VT and runs the browser as the kiosk user, # restarted by systemd if anything dies. # # Browser support (auto-detect picks the first available, LibreWolf # preferred): librewolf native, librewolf flatpak, firefox native, # firefox flatpak, firefox snap. # # Exit codes: # 0 success # 2 not root / not supported (no systemd) # 3 prerequisite missing (cage, browser, usher, python3, dbus) # 4 kiosk user error # 5 unit install / enable failure # 6 invalid parameters # # Unattended examples: # sudo ./provision-kiosk.sh --home-url https://checkin.example.org \ # --kiosk-user kiosk --librewolf flatpak --yes --start # sudo ./provision-kiosk.sh --firefox native \ # --home-url https://lobby.example.org --yes # # Interactive: # sudo ./provision-kiosk.sh # # POSIX sh — no bashisms. Runs on any minimal Linux base. set -eu HOST_NAME="com.vestibule.usher" EXT_ID="vestibule@vestibule.kiosk" LW_FLATPAK_APP="io.gitlab.librewolf-community" FF_FLATPAK_APP="org.mozilla.firefox" OPT_ROOT="/opt/vestibule" ENV_DIR="/etc/vestibule" UNIT_SRC_DIR=$(cd "$(dirname "$0")" && pwd) PROJECT_ROOT=$(cd "${UNIT_SRC_DIR}/.." && pwd) UNIT_DST="/etc/systemd/system/vestibule-kiosk.service" LAUNCH_DST="/usr/local/bin/vestibule-kiosk-launch" USHER_DST="/usr/local/bin/usher" KIOSK_USER="vestibule-kiosk" TTY_NUM="2" BROWSER_REQ="auto" FLAVOR_REQ="auto" BROWSER_FLAGS_SEEN="" HOME_URL="about:blank" USHER_SRC="" QUIET=0 CHECK=0 ASSUME_YES=0 START_NOW=0 usage() { sed -n '2,48p' "$0" | sed 's/^# \{0,1\}//' cat <<'EOF' Options: --home-url URL Kiosk home URL (default: about:blank) --kiosk-user NAME Kiosk account name (default: vestibule-kiosk) --tty N VT for the cage session, 1-12 (default: 2) --librewolf FLAVOR Use LibreWolf: native | flatpak | auto --firefox FLAVOR Use Firefox: native | flatpak | snap | auto (default: auto-detect, LibreWolf preferred; --librewolf and --firefox are mutually exclusive) --usher-bin PATH Explicit usher binary to install --start Start the kiosk session immediately --yes Skip the confirmation prompt (unattended) --quiet Minimal output --check Validate prerequisites only; change nothing -h, --help This text Firefox notes: Firefox ESR is recommended for kiosk stability. The extension, usher, and lockdown policies are identical for both browsers. Vestibule configures an existing Firefox install; it never downloads or redistributes Firefox (Mozilla trademark policy). EOF } die() { code="$1"; msg="$2" echo "" echo "ERROR: ${msg}" >&2 echo " exiting with code ${code}" >&2 exit "${code}" } info() { echo "$1"; } ok() { echo " [ok] $1"; } step() { echo ""; echo "==> $1"; } # First executable in the argument list wins; empty arguments are skipped. # Single home for every ordered-path probe in this script. first_executable() { for cand in "$@"; do if [ -n "${cand}" ] && [ -x "${cand}" ]; then printf '%s\n' "${cand}" return 0 fi done return 1 } # ─── Argument parsing ───────────────────────────────────────────────── while [ $# -gt 0 ]; do case "$1" in --home-url) HOME_URL="$2"; shift 2 ;; --kiosk-user) KIOSK_USER="$2"; shift 2 ;; --tty) TTY_NUM="$2"; shift 2 ;; --librewolf) BROWSER_REQ="librewolf"; FLAVOR_REQ="$2" BROWSER_FLAGS_SEEN="${BROWSER_FLAGS_SEEN} librewolf"; shift 2 ;; --firefox) BROWSER_REQ="firefox"; FLAVOR_REQ="$2" BROWSER_FLAGS_SEEN="${BROWSER_FLAGS_SEEN} firefox"; shift 2 ;; --usher-bin) USHER_SRC="$2"; shift 2 ;; --start) START_NOW=1; shift ;; --yes|-y) ASSUME_YES=1; shift ;; --quiet) QUIET=1; shift ;; --check) CHECK=1; shift ;; -h|--help) usage; exit 0 ;; *) usage >&2; die 6 "unknown option: $1" ;; esac done if [ "$(printf '%s' "${BROWSER_FLAGS_SEEN}" | wc -w)" -gt 1 ]; then die 6 "--librewolf and --firefox are mutually exclusive (saw:${BROWSER_FLAGS_SEEN})" fi if [ "${BROWSER_REQ}" = "librewolf" ]; then case "${FLAVOR_REQ}" in native|flatpak|auto) ;; *) die 6 "--librewolf must be native, flatpak, or auto (got: ${FLAVOR_REQ})" ;; esac fi if [ "${BROWSER_REQ}" = "firefox" ]; then case "${FLAVOR_REQ}" in native|flatpak|snap|auto) ;; *) die 6 "--firefox must be native, flatpak, snap, or auto (got: ${FLAVOR_REQ})" ;; esac fi case "${TTY_NUM}" in ''|*[!0-9]*) die 6 "--tty must be a number (got: ${TTY_NUM})" ;; esac [ "${TTY_NUM}" -ge 1 ] && [ "${TTY_NUM}" -le 12 ] || die 6 "--tty must be 1-12" case "${HOME_URL}" in http://*|https://*|about:*) ;; *) die 6 "--home-url must start with http://, https://, or about: (got: ${HOME_URL})" ;; esac # ─── Detection ──────────────────────────────────────────────────────── step "Pre-flight detection" if [ ! -d /run/systemd/system ]; then die 2 "systemd is not the running init system — this provisioning path targets systemd" fi ok "systemd" # Distro + package manager (informational: we print install commands, # we never auto-install — operator stays in control of the base image). # NOTE: os-release is sourced in a SUBSHELL — its standard fields # (HOME_URL, SUPPORT_URL, ...) would otherwise clobber our variables. DISTRO_ID="unknown" PKG_MGR="none" if [ -r /etc/os-release ]; then DISTRO_ID=$( # shellcheck disable=SC1091 . /etc/os-release printf '%s' "${ID:-unknown}" ) fi for pm in apt-get dnf pacman zypper; do if command -v "${pm}" >/dev/null 2>&1; then PKG_MGR="${pm}"; break; fi done [ "${QUIET}" -eq 1 ] || info "distro: ${DISTRO_ID} (pkg mgr: ${PKG_MGR})" CAGE_BIN="" if command -v cage >/dev/null 2>&1; then CAGE_BIN=$(command -v cage) elif [ -x /usr/bin/cage ]; then CAGE_BIN="/usr/bin/cage" fi if [ -n "${CAGE_BIN}" ]; then ok "cage: ${CAGE_BIN}" fi # ── LibreWolf detection ────────────────────────────────────────────── # Native LibreWolf: binary + its distribution directory. LW_NATIVE_BIN=$(first_executable \ "$(command -v librewolf 2>/dev/null || true)" \ /usr/lib/librewolf/librewolf \ /usr/local/lib/librewolf/librewolf \ /opt/librewolf/librewolf \ /usr/local/bin/librewolf || true) LW_DIST="" if [ -n "${LW_NATIVE_BIN}" ]; then LW_REAL=$(readlink -f "${LW_NATIVE_BIN}" 2>/dev/null || echo "${LW_NATIVE_BIN}") LW_REAL_DIR=$(dirname "${LW_REAL}") # A real LibreWolf install dir carries application.ini + browser/; # /usr/bin/librewolf may be a wrapper script or symlink — don't trust # its directory unless those markers are there. if [ -d "${LW_REAL_DIR}/distribution" ] || [ -f "${LW_REAL_DIR}/application.ini" ] \ || [ -d "${LW_REAL_DIR}/browser" ]; then LW_DIST="${LW_REAL_DIR}/distribution" else # Wrapper-script install: pick the first candidate whose parent dir # exists — provisioning creates distribution/ inside it. for dist in \ /usr/lib/librewolf/distribution \ /usr/share/librewolf/distribution \ /opt/librewolf/distribution; do if [ -d "$(dirname "${dist}")" ]; then LW_DIST="${dist}"; break; fi done fi ok "librewolf (native): ${LW_NATIVE_BIN} (policies: ${LW_DIST})" fi # Flatpak LibreWolf: system installation only — a per-user install would # be invisible to the kiosk account. LW_FLATPAK=0 if command -v flatpak >/dev/null 2>&1; then if flatpak info --system "${LW_FLATPAK_APP}" >/dev/null 2>&1; then LW_FLATPAK=1 ok "librewolf (flatpak, system): ${LW_FLATPAK_APP}" elif flatpak info --user "${LW_FLATPAK_APP}" >/dev/null 2>&1; then info " [!!] ${LW_FLATPAK_APP} is installed per-USER — the kiosk account cannot see it." info " reinstall system-wide: flatpak install --system flathub ${LW_FLATPAK_APP}" fi fi # ── Firefox detection ──────────────────────────────────────────────── # Native Firefox. Canonical distro paths first (Debian/Ubuntu: # /usr/lib/firefox, Fedora: /usr/lib64/firefox, Arch: /usr/bin symlink), # then tarball-style installs (/opt, /usr/local). command -v results # that resolve into /snap or a flatpak export are routed to their own # flavors below. FF_NATIVE_BIN="" FF_NATIVE_REAL="" FF_DISTRO=0 FF_DIST="" for cand in \ /usr/lib/firefox/firefox \ /usr/lib64/firefox/firefox \ /usr/bin/firefox \ /opt/firefox/firefox \ /usr/local/firefox/firefox \ /usr/local/bin/firefox \ "$(command -v firefox 2>/dev/null || true)"; do if [ -z "${cand}" ] || [ ! -x "${cand}" ]; then continue fi real=$(readlink -f "${cand}" 2>/dev/null || echo "${cand}") case "${real}" in /snap/*) continue ;; # snap firefox, handled below */flatpak/*|*/.local/share/flatpak/*) continue ;; # flatpak export esac case "${cand}" in /usr/lib/firefox/*|/usr/lib64/firefox/*|/usr/bin/*|/usr/share/*) FF_DISTRO=1 ;; *) FF_DISTRO=0 ;; esac FF_NATIVE_BIN="${cand}" FF_NATIVE_REAL="${real}" break done if [ -n "${FF_NATIVE_BIN}" ]; then if [ "${FF_DISTRO}" -eq 1 ]; then # Distro package: /etc/firefox/policies is the canonical, # update-safe location (Mozilla's documented Linux path). FF_DIST="/etc/firefox/policies" else # Tarball-style install: policies live beside the binary, in the # distribution/ directory — same mechanism as Windows. FF_DIST="$(dirname "${FF_NATIVE_REAL}")/distribution" fi ok "firefox (native): ${FF_NATIVE_BIN} (policies: ${FF_DIST})" fi # Firefox snap (Ubuntu's default): the sandbox home is # ~/snap/firefox/common — policies and NM manifests for the kiosk user # go there. Detected via snap list or a /usr/bin/firefox wrapper that # resolves into /snap. FF_SNAP=0 if command -v snap >/dev/null 2>&1; then if snap list firefox >/dev/null 2>&1; then FF_SNAP=1 fi fi if [ "${FF_SNAP}" -eq 0 ]; then ff_probe="" if [ -e /usr/bin/firefox ]; then ff_probe=$(readlink -f /usr/bin/firefox 2>/dev/null || true) elif [ -e /snap/bin/firefox ]; then ff_probe=$(readlink -f /snap/bin/firefox 2>/dev/null || true) fi case "${ff_probe}" in /snap/*) FF_SNAP=1 ;; esac fi if [ "${FF_SNAP}" -eq 1 ]; then ok "firefox (snap): /snap/bin/firefox (policies: ~kiosk/snap/firefox/common/.mozilla/policies)" fi # Firefox Flatpak: system installation only. FF_FLATPAK=0 if command -v flatpak >/dev/null 2>&1; then if flatpak info --system "${FF_FLATPAK_APP}" >/dev/null 2>&1; then FF_FLATPAK=1 ok "firefox (flatpak, system): ${FF_FLATPAK_APP}" elif flatpak info --user "${FF_FLATPAK_APP}" >/dev/null 2>&1; then info " [!!] ${FF_FLATPAK_APP} is installed per-USER — the kiosk account cannot see it." info " reinstall system-wide: flatpak install --system flathub ${FF_FLATPAK_APP}" fi fi # ── Resolve browser + flavor ───────────────────────────────────────── # # Step-down resolution: each probe names one flavor; the first available # wins. An explicit --librewolf/--firefox flavor request pins the probe to # that flavor alone; "auto" walks the whole ladder. BROWSER="" FLAVOR="" flavor_available() { # Lookup table: browser:flavor -> availability test. case "$1:$2" in librewolf:native) [ -n "${LW_NATIVE_BIN}" ] ;; librewolf:flatpak) [ "${LW_FLATPAK}" -eq 1 ] ;; firefox:native) [ -n "${FF_NATIVE_BIN}" ] ;; firefox:flatpak) [ "${FF_FLATPAK}" -eq 1 ] ;; firefox:snap) [ "${FF_SNAP}" -eq 1 ] ;; *) return 1 ;; esac } resolve_flavor() { # $1 = browser, $2 = flavor if [ "${FLAVOR_REQ}" = "auto" ] || [ "${FLAVOR_REQ}" = "$2" ]; then if flavor_available "$1" "$2"; then BROWSER="$1"; FLAVOR="$2"; return 0 fi fi return 1 } resolve_librewolf() { resolve_flavor librewolf native && return 0 resolve_flavor librewolf flatpak && return 0 return 1 } resolve_firefox() { resolve_flavor firefox native && return 0 resolve_flavor firefox flatpak && return 0 resolve_flavor firefox snap && return 0 return 1 } if [ "${BROWSER_REQ}" = "librewolf" ]; then resolve_librewolf elif [ "${BROWSER_REQ}" = "firefox" ]; then resolve_firefox else # Auto step-down: LibreWolf (privacy defaults + trademark-safe), then # Firefox (fully supported alternative). FLAVOR_REQ="auto" resolve_librewolf || resolve_firefox fi BROWSER_MISSING="" if [ -z "${BROWSER}" ]; then if [ "${BROWSER_REQ}" = "librewolf" ]; then BROWSER_MISSING="librewolf" elif [ "${BROWSER_REQ}" = "firefox" ]; then BROWSER_MISSING="firefox" else BROWSER_MISSING="browser (librewolf or firefox)" fi fi # usher source: explicit flag > existing install > repo build > staged. if [ -z "${USHER_SRC}" ]; then if [ -x "${USHER_DST}" ]; then USHER_SRC="${USHER_DST}" elif [ -x "${PROJECT_ROOT}/helper/target/release/usher" ]; then USHER_SRC="${PROJECT_ROOT}/helper/target/release/usher" elif [ -x "${OPT_ROOT}/bin/usher" ]; then USHER_SRC="${OPT_ROOT}/bin/usher" fi fi PYTHON_BIN="" if command -v python3 >/dev/null 2>&1; then PYTHON_BIN=$(command -v python3) fi DBUS_RUN="" if command -v dbus-run-session >/dev/null 2>&1; then DBUS_RUN=$(command -v dbus-run-session) fi # ─── Missing-prerequisite report ────────────────────────────────────── MISSING="" if [ -z "${CAGE_BIN}" ]; then MISSING="${MISSING} cage"; fi if [ -n "${BROWSER_MISSING}" ]; then MISSING="${MISSING} ${BROWSER_MISSING}"; fi if [ -z "${USHER_SRC}" ]; then MISSING="${MISSING} usher"; fi if [ -z "${PYTHON_BIN}" ]; then MISSING="${MISSING} python3"; fi if [ -z "${DBUS_RUN}" ]; then MISSING="${MISSING} dbus"; fi if [ -n "${MISSING}" ]; then echo "" info "Missing prerequisites:${MISSING}" info "Install them, then re-run this script. Per-distro commands:" echo "" case "${PKG_MGR}" in apt-get) echo " sudo apt-get install -y cage dbus python3 firefox-esr" echo " # LibreWolf: deb repo — https://librewolf.net/installation/linux/" echo " # or Flatpak: flatpak install --system flathub ${LW_FLATPAK_APP}" echo " # or Firefox Flatpak: flatpak install --system flathub ${FF_FLATPAK_APP}" ;; dnf) echo " sudo dnf install -y cage dbus python3 firefox" echo " # LibreWolf: https://librewolf.net/installation/linux/" echo " # or Flatpak: flatpak install --system flathub ${LW_FLATPAK_APP}" ;; pacman) echo " sudo pacman -S --needed cage dbus python3 firefox" echo " # LibreWolf: AUR (librewolf / librewolf-bin)" echo " # or Flatpak: flatpak install --system flathub ${LW_FLATPAK_APP}" ;; zypper) echo " sudo zypper install cage dbus python3 MozillaFirefox" echo " # LibreWolf: https://librewolf.net/installation/linux/" echo " # or Flatpak: flatpak install --system flathub ${LW_FLATPAK_APP}" ;; *) echo " # Install cage, dbus, python3, and a browser (firefox or" echo " # librewolf) from your distribution." echo " # cage from source: https://github.com/cage-kiosk/cage" echo " # Flatpak browsers: flatpak install --system flathub ${FF_FLATPAK_APP}" ;; esac echo "" echo " usher: build from source — cd helper && cargo build --release" die 3 "prerequisites not met" fi ok "usher: ${USHER_SRC}" ok "python3: ${PYTHON_BIN}" ok "browser: ${BROWSER} (${FLAVOR})" # ─── Policy destination preview (needs KIOSK_HOME for sandboxed flavors) policy_dir_for_kiosk_home() { # $1 = kiosk home case "${BROWSER}:${FLAVOR}" in librewolf:native) printf '%s' "${LW_DIST}" ;; librewolf:flatpak) printf '%s' "/var/lib/flatpak/app/${LW_FLATPAK_APP}/current/active/files/librewolf/distribution" ;; firefox:native) printf '%s' "${FF_DIST}" ;; firefox:flatpak) printf '%s' "$1/.var/app/${FF_FLATPAK_APP}/.mozilla/policies" ;; firefox:snap) printf '%s' "$1/snap/firefox/common/.mozilla/policies" ;; esac } if [ "${CHECK}" -eq 1 ]; then step "Check complete (no changes made)" info "kiosk user : ${KIOSK_USER} (created if absent)" info "tty : /dev/tty${TTY_NUM}" info "browser : ${BROWSER} (${FLAVOR})" info "home URL : ${HOME_URL}" info "policies dir : $(policy_dir_for_kiosk_home "/home/${KIOSK_USER}")" info "opt root : ${OPT_ROOT}" ok "all prerequisites satisfied — re-run without --check to provision" exit 0 fi [ "$(id -u)" -eq 0 ] || die 2 "must run as root (sudo)" # ─── Confirmation ───────────────────────────────────────────────────── if [ "${ASSUME_YES}" -eq 0 ] && [ "${QUIET}" -eq 0 ]; then echo "" printf "Provision kiosk (user=%s, tty=%s, browser=%s/%s, url=%s)? [y/N] " \ "${KIOSK_USER}" "${TTY_NUM}" "${BROWSER}" "${FLAVOR}" "${HOME_URL}" read -r answer case "${answer}" in y|Y|yes|YES) ;; *) info "aborted"; exit 0 ;; esac fi # ─── 1. Kiosk user ──────────────────────────────────────────────────── step "Kiosk user '${KIOSK_USER}'" KIOSK_HOME="" if id -u "${KIOSK_USER}" >/dev/null 2>&1; then KIOSK_HOME=$(getent passwd "${KIOSK_USER}" | cut -d: -f6) ok "exists (home: ${KIOSK_HOME})" else useradd -m -s /bin/sh "${KIOSK_USER}" || die 4 "useradd failed" KIOSK_HOME=$(getent passwd "${KIOSK_USER}" | cut -d: -f6) ok "created with locked password (no password login is possible)" fi [ -n "${KIOSK_HOME}" ] || die 4 "could not resolve home directory" # ─── 2. Stage /opt/vestibule ────────────────────────────────────────── step "Stage ${OPT_ROOT}" mkdir -p "${OPT_ROOT}/bin" "${OPT_ROOT}/extension" "${OPT_ROOT}/config" "${OPT_ROOT}/docs" install -m 0755 "${USHER_SRC}" "${OPT_ROOT}/bin/usher" install -m 0755 "${USHER_SRC}" "${USHER_DST}" ok "usher installed: ${USHER_DST}" # Build the XPI (a zip of extension/) — python3 zipfile, no zip binary # dependency. If an XPI is already staged and no source tree is present, # keep the staged one. XPI_SRC_DIR="${PROJECT_ROOT}/extension" XPI_DST="${OPT_ROOT}/extension/vestibule.xpi" if [ -f "${XPI_SRC_DIR}/manifest.json" ]; then "${PYTHON_BIN}" - "${XPI_SRC_DIR}" "${XPI_DST}" <<'PYEOF' import os, sys, zipfile src_dir, dst = sys.argv[1], sys.argv[2] with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as z: for root, dirs, files in os.walk(src_dir): dirs.sort() for f in sorted(files): full = os.path.join(root, f) z.write(full, os.path.relpath(full, src_dir)) PYEOF chmod 0644 "${XPI_DST}" ok "built extension XPI: ${XPI_DST}" elif [ -f "${XPI_DST}" ]; then ok "using staged XPI: ${XPI_DST}" else die 3 "no extension source at ${XPI_SRC_DIR} and no staged XPI" fi for f in config/policies.json config/librewolf.overrides.cfg \ config/vestibule.toml.example; do if [ -f "${PROJECT_ROOT}/${f}" ]; then install -m 0644 "${PROJECT_ROOT}/${f}" "${OPT_ROOT}/${f}" fi done if [ -f "${PROJECT_ROOT}/LICENSE" ]; then install -m 0644 "${PROJECT_ROOT}/LICENSE" "${OPT_ROOT}/LICENSE"; fi if [ -f "${PROJECT_ROOT}/DEPLOYMENT.md" ]; then install -m 0644 "${PROJECT_ROOT}/DEPLOYMENT.md" "${OPT_ROOT}/docs/DEPLOYMENT.md"; fi if [ -f "${PROJECT_ROOT}/README.md" ]; then install -m 0644 "${PROJECT_ROOT}/README.md" "${OPT_ROOT}/docs/README.md"; fi ok "staged config + docs" # ─── 3. Launcher + kiosk.env ────────────────────────────────────────── step "Launcher + session config" install -m 0755 "${UNIT_SRC_DIR}/vestibule-kiosk-launch" "${LAUNCH_DST}" ok "installed ${LAUNCH_DST}" mkdir -p "${ENV_DIR}" cat > "${ENV_DIR}/kiosk.env" <= 0.1.2). # In a VM without GPU: add WLR_LIBINPUT_NO_DEVICES=1 and WLR_RENDERER=pixman # as separate Environment entries in the systemd unit, not here. VESTIBULE_CAGE_ARGS="-d" EOF chmod 0644 "${ENV_DIR}/kiosk.env" ok "wrote ${ENV_DIR}/kiosk.env (browser: ${BROWSER}/${FLAVOR})" # ─── 4. Native Messaging host for the kiosk user ────────────────────── step "Native Messaging host (kiosk user)" NM_MANIFEST_BODY=$(cat < "${nm_dir}/${HOST_NAME}.json" chmod 0644 "${nm_dir}/${HOST_NAME}.json" done chown -R "${KIOSK_USER}:${KIOSK_USER}" "${KIOSK_HOME}/.librewolf" \ "${KIOSK_HOME}/.mozilla" "${KIOSK_HOME}/.var" "${KIOSK_HOME}/snap" 2>/dev/null || \ chown -R "${KIOSK_USER}" "${KIOSK_HOME}/.librewolf" "${KIOSK_HOME}/.mozilla" ok "manifests installed for ${KIOSK_USER} (librewolf + firefox, native + flatpak + snap)" # ─── 5. policies.json ───────────────────────────────────────────────── step "${BROWSER} policies" # For sandboxed flavors (flatpak/snap) the browser cannot read /opt — # its filesystem is the kiosk home remap. Copy the XPI to a # sandbox-visible path and point install_url there. Native flavors read # /opt/vestibule directly. XPI_INSTALL_URL="file://${XPI_DST}" if [ "${FLAVOR}" = "flatpak" ]; then if [ "${BROWSER}" = "firefox" ]; then SANDBOX_APP_DIR="${KIOSK_HOME}/.var/app/${FF_FLATPAK_APP}" else SANDBOX_APP_DIR="${KIOSK_HOME}/.var/app/${LW_FLATPAK_APP}" fi mkdir -p "${SANDBOX_APP_DIR}" install -m 0644 "${XPI_DST}" "${SANDBOX_APP_DIR}/vestibule.xpi" chown -R "${KIOSK_USER}:${KIOSK_USER}" "${SANDBOX_APP_DIR}" 2>/dev/null || \ chown -R "${KIOSK_USER}" "${SANDBOX_APP_DIR}" # Inside the sandbox $HOME is the kiosk home path — that is where the # browser must find the XPI. XPI_INSTALL_URL="file://${KIOSK_HOME}/vestibule.xpi" ok "XPI copied to sandbox-visible ${SANDBOX_APP_DIR}/vestibule.xpi" elif [ "${FLAVOR}" = "snap" ]; then SANDBOX_APP_DIR="${KIOSK_HOME}/snap/firefox/common" mkdir -p "${SANDBOX_APP_DIR}" install -m 0644 "${XPI_DST}" "${SANDBOX_APP_DIR}/vestibule.xpi" chown -R "${KIOSK_USER}:${KIOSK_USER}" "${SANDBOX_APP_DIR}" 2>/dev/null || \ chown -R "${KIOSK_USER}" "${SANDBOX_APP_DIR}" XPI_INSTALL_URL="file://${KIOSK_HOME}/vestibule.xpi" ok "XPI copied to snap-visible ${SANDBOX_APP_DIR}/vestibule.xpi" fi POLICY_DIR=$(policy_dir_for_kiosk_home "${KIOSK_HOME}") mkdir -p "${POLICY_DIR}" POLICY_DST="${POLICY_DIR}/policies.json" if [ -f "${POLICY_DST}" ] && [ ! -f "${POLICY_DST}.vestibule-bak" ]; then cp "${POLICY_DST}" "${POLICY_DST}.vestibule-bak" ok "backed up existing policies.json -> vestibule-bak" fi "${PYTHON_BIN}" - "${PROJECT_ROOT}/config/policies.json" "${POLICY_DST}" \ "${XPI_INSTALL_URL}" "${EXT_ID}" <<'PYEOF' import json, sys canonical_path, target, xpi_url, ext_id = sys.argv[1:5] policies = {"policies": {}} try: with open(target) as f: existing = json.load(f) if isinstance(existing, dict): policies = existing except (OSError, ValueError): pass with open(canonical_path) as f: canonical = json.load(f) def deep_merge(base, overlay): for k, v in overlay.items(): if k in base and isinstance(base[k], dict) and isinstance(v, dict): deep_merge(base[k], v) else: base[k] = v deep_merge(policies, canonical) # Policy-install the extension: force_installed survives the "*" blocked # wildcard and re-installs itself on every browser start. policies.setdefault("policies", {}) policies["policies"]["ExtensionSettings"] = { "*": { "blocked_install_message": "Extensions are not allowed on this kiosk.", "install_sources": [], "installation_mode": "blocked", }, ext_id: { "installation_mode": "force_installed", "install_url": xpi_url, }, } with open(target, "w") as f: json.dump(policies, f, indent=2) f.write("\n") PYEOF if [ -n "${POLICY_DIR##${KIOSK_HOME}*}" ]; then # System-level policy file — root-owned is correct. : else # Policy file inside the kiosk home (flatpak/snap flavors) — the # browser reads it as the kiosk user. chown "${KIOSK_USER}:${KIOSK_USER}" "${POLICY_DST}" 2>/dev/null || \ chown "${KIOSK_USER}" "${POLICY_DST}" fi ok "deployed ${POLICY_DST} (extension force-installed from ${XPI_INSTALL_URL})" if [ "${FLAVOR}" = "flatpak" ] && [ "${BROWSER}" = "librewolf" ]; then info "NOTE: the Flatpak app dir is replaced on every LibreWolf update." info " re-run this script after updates (the extension's own session" info " sanitization is unaffected — this file is layer 1 of 3)." fi if [ "${FLAVOR}" = "flatpak" ] || [ "${FLAVOR}" = "snap" ]; then info "NOTE: for ${BROWSER} ${FLAVOR}, policies and the XPI live in the" info " kiosk user's home — they survive browser updates (unlike a" info " LibreWolf-Flatpak system-dir deploy)." fi # ─── 6. systemd unit ────────────────────────────────────────────────── step "systemd unit" sed -e "s|__KIOSK_USER__|${KIOSK_USER}|g" -e "s|__TTY__|${TTY_NUM}|g" \ "${UNIT_SRC_DIR}/vestibule-kiosk.service.in" > "${UNIT_DST}" chmod 0644 "${UNIT_DST}" ok "generated ${UNIT_DST} (tty${TTY_NUM}, user ${KIOSK_USER})" systemctl daemon-reload || die 5 "systemctl daemon-reload failed" systemctl enable vestibule-kiosk.service >/dev/null 2>&1 || die 5 "enable failed" ok "enabled vestibule-kiosk.service" if [ "${START_NOW}" -eq 1 ]; then systemctl start vestibule-kiosk.service || die 5 "start failed — check: journalctl -u vestibule-kiosk.service" ok "started — the kiosk should be running on tty${TTY_NUM}" else info "start now with: sudo systemctl start vestibule-kiosk.service" info "or reboot — the unit starts automatically at boot." fi # ─── Summary ────────────────────────────────────────────────────────── step "Provisioning complete" info "kiosk user : ${KIOSK_USER} (locked password)" info "session : cage on tty${TTY_NUM} via systemd" info "browser : ${BROWSER} (${FLAVOR})" info "home URL : ${HOME_URL}" info "policies : ${POLICY_DST}" info "logs : journalctl -u vestibule-kiosk.service -f" info "escape hatch : Ctrl+Alt+F3 (VT switching; requires VESTIBULE_CAGE_ARGS=-d)" exit 0