#!/bin/bash # DevGrail Desktop installer: adds a desktop (XFCE, Firefox, sound, RDP) to a # running DevGrail workspace, reached through one of the workspace's app # subdomains. Run it in the workspace's terminal, as the workspace user: # # curl -fsSL https:///addons/desktop/install.sh | bash # curl -fsSL https:///addons/desktop/install.sh | bash -s -- --no-rdp # # Re-running it upgrades in place and keeps the choices made the first time # (ports, display, RDP, Firefox, CJK fonts) unless a flag changes them. To # remove the add-on: # # devgrail-desktop uninstall [--purge-packages] # # which runs the copy of this script kept in /opt/devgrail-desktop. # # Flags: # --port=N the app's port, which the subdomain points at (26080) # --vnc-port=N Xvnc's loopback port (25901) # --rdp-port=N xrdp's loopback port (23389) # --display=N the X display number (8) # --cjk-fonts also install fonts-noto-cjk (about 90 MB); --no-cjk-fonts # --no-firefox skip Firefox and Mozilla's apt repo; --firefox # --no-rdp skip xrdp; --rdp # --version=X install the add-on from DevGrail release X, not the latest # --yes accepted; there are no questions to answer # uninstall [--purge-packages] # remove the add-on; --purge-packages also removes the # packages this installer added # # Environment: # DEVGRAIL_WEB_URL the DevGrail site to download from (default: the # site this script was downloaded from) # DEVGRAIL_ALLOW_UNSIGNED 1: install downloads whose release signature # cannot be checked # DEVGRAIL_RELEASE_PUBKEY_FILE extra trusted release signing keys (PEM) # # It runs as the workspace user, not root, and uses `sudo -n` for what needs # root, so a sudo that asks for a password fails the checks instead of hanging # on a prompt nobody can answer. Everything happens inside main(), called on # the last line: under `curl | bash` the script arrives on bash's stdin, and # nothing may run before all of it has been read. For the same reason every # command that could read stdin is given /dev/null. # # docs/plans/DESKTOP_ADDON_PLAN.md is the design. set -Eeuo pipefail # https://get.devgrail.com is replaced with the serving origin by devgrail-web, as # for install.sh. An explicit DEVGRAIL_WEB_URL still wins. DEVGRAIL_WEB_URL="${DEVGRAIL_WEB_URL:-https://get.devgrail.com}" WEB="${DEVGRAIL_WEB_URL%/}" BIN=/usr/local/bin/devgrail-desktop SHARE=/opt/devgrail-desktop ETC=/etc/devgrail-desktop CONFIG="$ETC/config.json" RECORD=/etc/devgrail-desktop.json # The files this installer wrote outside its own directories, one path a line. # Each one's original, when there was one, is kept under $ETC/orig/, and # uninstall puts it back. MANAGED="$ETC/managed" # Packages this installer installed that were not installed before, for # `uninstall --purge-packages`. PACKAGES_ADDED="$ETC/packages-added" # Present when this installer created the boot hook, which uninstall then # removes again if nothing else is left in it. CREATED_HOOK="$ETC/created-startup-hook" STARTUP=/workspace/.devgrail/startup.sh STATE_DIR="$HOME/.local/state/devgrail-desktop" LOG="$STATE_DIR/install.log" MOZILLA_FPR=35BAA0B33E9EB396F59CA838C0BA5CE6DC6315A3 # Ours by name, so a re-run and uninstall recognise them. MOZILLA_KEYRING=/etc/apt/keyrings/devgrail-desktop-mozilla.gpg MOZILLA_LIST=/etc/apt/sources.list.d/devgrail-desktop-mozilla.list MOZILLA_PIN=/etc/apt/preferences.d/devgrail-desktop-mozilla # xrdp 0.9 (Ubuntu 22.04) has sesman listen on TCP 3350; this moves it. # xrdp 0.10 (26.04) uses a Unix socket and needs nothing. SESMAN_PORT=23350 # The release the default workspace image is built on, the one this is tested # on. Others get a warning; apt's dry run decides whether they can take it. TESTED_RELEASE=26.04 BLOCK_BEGIN='# >>> devgrail-desktop >>>' BLOCK_END='# <<< devgrail-desktop <<<' # --- output ------------------------------------------------------------------ # What the user sees is a short report; everything the commands print goes to # $LOG, which is where an error points. ON_LINE=0 note() { [ -n "${LOG_FD:-}" ] && printf '%s\n' "$*" >&"$LOG_FD" || true; } say() { printf '%s\n' "$*"; note "$*"; } ok() { say " ✓ $*"; } caution() { say " ! $*"; } # begin/done print one "Doing this ... done" line around a long step. begin() { printf ' %s ... ' "$*"; note "--- $*"; ON_LINE=1; STEP_START=$SECONDS; } finish() { local took=$((SECONDS - STEP_START)) msg="${1:-done}" if [ "$took" -ge 5 ]; then msg="$msg ($(duration "$took"))"; fi printf '%s\n' "$msg"; note "$msg"; ON_LINE=0 } duration() { if [ "$1" -ge 60 ]; then printf '%dm %ds' $(($1 / 60)) $(($1 % 60)); else printf '%ds' "$1"; fi } die() { [ "$ON_LINE" = 1 ] && printf 'failed\n' >&2 ON_LINE=0 printf '\n error: %s\n' "$*" >&2 note "error: $*" if [ -n "${LOG_FD:-}" ]; then printf ' The full log is %s\n' "$LOG" >&2; fi exit 1 } # quietly runs a command with its output in the log and nothing on stdin. quietly() { "$@" &"$LOG_FD" 2>&1; } # log_tail prints the end of the log, indented, after a command failed. log_tail() { tail -n "${1:-15}" "$LOG" | sed 's/^/ /' >&2; } as_root() { sudo -n "$@"; } # --- arguments --------------------------------------------------------------- MODE=install PURGE=0 F_PORT=""; F_VNC=""; F_RDP_PORT=""; F_DISPLAY="" F_FIREFOX=""; F_RDP=""; F_CJK="" RELEASE=current usage() { sed -n '2,/^set -Eeuo/{/^set -Eeuo/d;s/^# \{0,1\}//;p}' "$SHARE/install.sh" 2>/dev/null || printf 'usage: install.sh [--port=N] [--vnc-port=N] [--rdp-port=N] [--display=N]\n [--cjk-fonts] [--no-firefox] [--no-rdp] [--version=X] | uninstall [--purge-packages]\n' } parse_args() { local arg for arg in "$@"; do case "$arg" in uninstall) MODE=uninstall ;; --purge-packages) PURGE=1 ;; --port=*) F_PORT="${arg#*=}" ;; --vnc-port=*) F_VNC="${arg#*=}" ;; --rdp-port=*) F_RDP_PORT="${arg#*=}" ;; --display=*) F_DISPLAY="${arg#*=}" ;; --cjk-fonts) F_CJK=1 ;; --no-cjk-fonts) F_CJK=0 ;; --firefox) F_FIREFOX=1 ;; --no-firefox) F_FIREFOX=0 ;; --rdp) F_RDP=1 ;; --no-rdp) F_RDP=0 ;; --version=*) RELEASE="${arg#*=}" ;; --yes | -y) ;; --help | -h) usage; exit 0 ;; *) printf 'install.sh: unknown option %s (see --help)\n' "$arg" >&2; exit 2 ;; esac done if [ "$PURGE" = 1 ] && [ "$MODE" != uninstall ]; then printf 'install.sh: --purge-packages goes with uninstall\n' >&2 exit 2 fi } # --- the log and the lock ---------------------------------------------------- open_log() { mkdir -p "$STATE_DIR" chmod 0700 "$STATE_DIR" # The lock first, so a second run does not truncate the first one's log. exec {LOCK_FD}>"$STATE_DIR/install.lock" flock -n "$LOCK_FD" || die "another run of this installer is still going." exec {LOG_FD}>"$LOG" note "devgrail-desktop $MODE, $(date -u +%Y-%m-%dT%H:%M:%SZ), args: $*" } # --- cleanup ----------------------------------------------------------------- # Nothing a run downloads stays in the workspace once it is no longer needed, # whether the run succeeded or not: the temporary directory goes, apt's .deb # files go, and so do apt's package lists when there were none before the run # (the default image ships without them). On a failure before any package was # installed, the Mozilla repo files this run added go too, so a failed check # leaves the system as it was. WORK="" APT_TOUCHED=0 LISTS_BEFORE=1 REPO_ADDED_NOW="" TEMP_POLICY_RC=0 FAILED_LINE="" cleanup() { local status=$? if [ "$status" -ne 0 ] && [ -n "$REPO_ADDED_NOW" ]; then # shellcheck disable=SC2086 # a list of paths as_root rm -f $REPO_ADDED_NOW || true note "removed the apt repo files this run added" fi if [ "$TEMP_POLICY_RC" = 1 ]; then as_root rm -f /usr/sbin/policy-rc.d || true TEMP_POLICY_RC=0 fi if [ "$APT_TOUCHED" = 1 ]; then as_root apt-get clean /dev/null 2>&1 || true if [ "$LISTS_BEFORE" = 0 ]; then as_root find /var/lib/apt/lists -type f ! -name lock -delete 2>/dev/null || true fi APT_TOUCHED=0 fi if [ -n "$WORK" ]; then rm -rf "$WORK" || true WORK="" fi if [ "$status" -ne 0 ] && [ -n "$FAILED_LINE" ]; then [ "$ON_LINE" = 1 ] && printf 'failed\n' >&2 printf '\n error: the installer stopped at line %s.\n The full log is %s\n' "$FAILED_LINE" "$LOG" >&2 fi } # apt_lists_present says whether apt has any package lists. apt_lists_present() { find /var/lib/apt/lists -maxdepth 1 -type f -name '*_Packages*' 2>/dev/null | grep -q . } # --- small helpers ----------------------------------------------------------- is_port() { case "$1" in '' | *[!0-9]*) return 1 ;; esac; [ "$1" -ge 1 ] && [ "$1" -le 65535 ]; } # port_listening says whether something listens on TCP port $1, on any # address, from /proc: ss and lsof are not in the image. port_listening() { local hex hex="$(printf '%04X' "$1")" awk -v h="$hex" 'FNR > 1 { n = split($2, a, ":"); if (a[n] == h && $4 == "0A") found = 1 } END { exit !found }' /proc/net/tcp /proc/net/tcp6 2>/dev/null } # display_taken says whether a running X server holds display $1. display_taken() { local pid comm [ -f "/tmp/.X$1-lock" ] || return 1 pid="$(tr -dc '0-9' <"/tmp/.X$1-lock")" [ -n "$pid" ] && [ -r "/proc/$pid/comm" ] || return 1 comm="$(cat "/proc/$pid/comm")" case "$comm" in X*) return 0 ;; esac return 1 } # json_value reads one key from a JSON file this installer wrote: one key a # line, so no JSON parser is needed (jq is not on every Ubuntu). json_value() { [ -r "$2" ] || return 0 sed -n "s/^ *\"$1\": *\"\{0,1\}\([^\",]*\)\"\{0,1\},\{0,1\} *\$/\1/p" "$2" | head -n 1 } json_str() { printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')"; } bool() { if [ "$1" = 1 ]; then printf true; else printf false; fi; } from_bool() { case "$1" in true) printf 1 ;; false) printf 0 ;; *) printf '%s' "$2" ;; esac; } sha256_of() { sha256sum "$1" | awk '{print $1}'; } installed_version() { dpkg-query -W -f='${Version}' "$1" 2>/dev/null || true; } installed_packages() { dpkg-query -W -f='${binary:Package} ${db:Status-Status}\n' 2>/dev/null | awk '$2 == "installed" {print $1}' | LC_ALL=C sort -u } # root_install puts a file in place as root. root_install() { as_root install -D -m "$1" "$2" "$3"; } # take_over registers a file this installer is about to write outside # its own directories, keeping the original the first time. A file named # devgrail-desktop* is ours by name, so there is no original to keep. take_over() { local path="$1" if [ -f "$MANAGED" ] && grep -qxF "$path" "$MANAGED"; then return 0 fi case "${path##*/}" in devgrail-desktop*) ;; *) if as_root test -e "$path"; then as_root mkdir -p "$ETC/orig${path%/*}" as_root cp -a "$path" "$ETC/orig$path" fi ;; esac printf '%s\n' "$path" | as_root tee -a "$MANAGED" >/dev/null } # give_back puts the original back, or removes the file when there was # none, and forgets it. give_back() { local path="$1" if as_root test -e "$ETC/orig$path"; then as_root cp -a "$ETC/orig$path" "$path" as_root rm -f "$ETC/orig$path" else as_root rm -f "$path" fi if [ -f "$MANAGED" ]; then grep -vxF "$path" "$MANAGED" >"$WORK/managed" || true root_install 0644 "$WORK/managed" "$MANAGED" fi } managed() { [ -f "$MANAGED" ] && grep -qxF "$1" "$MANAGED"; } # app_running says whether the add-on's app is up, by its own account. app_running() { [ -x "$BIN" ] && "$BIN" status /dev/null 2>&1; } # --- choices ----------------------------------------------------------------- # Flags over what the last run chose over the defaults, so the upgrade command # the viewer shows (which has no flags) keeps a moved port where it is. PREV_PORT=""; PREV_VNC=""; PREV_RDP_PORT=""; PREV_DISPLAY="" settle_choices() { PREV_PORT="$(json_value port "$CONFIG")" PREV_VNC="$(json_value vnc_port "$CONFIG")" PREV_RDP_PORT="$(json_value rdp_port "$CONFIG")" PREV_DISPLAY="$(json_value display "$CONFIG")" PORT="${F_PORT:-${PREV_PORT:-26080}}" VNC_PORT="${F_VNC:-${PREV_VNC:-25901}}" RDP_PORT="${F_RDP_PORT:-${PREV_RDP_PORT:-23389}}" DISPLAY_NO="${F_DISPLAY:-${PREV_DISPLAY:-8}}" RDP="${F_RDP:-$(from_bool "$(json_value rdp "$CONFIG")" 1)}" FIREFOX="${F_FIREFOX:-$(from_bool "$(json_value firefox "$CONFIG")" 1)}" CJK="${F_CJK:-$(from_bool "$(json_value cjk_fonts "$CONFIG")" 0)}" local name value for name in port:"$PORT" vnc-port:"$VNC_PORT" rdp-port:"$RDP_PORT"; do value="${name#*:}" is_port "$value" || die "--${name%%:*}=$value is not a TCP port." done if [ "$PORT" = "$VNC_PORT" ] || [ "$PORT" = "$RDP_PORT" ] || [ "$VNC_PORT" = "$RDP_PORT" ] || [ "$SESMAN_PORT" = "$PORT" ] || [ "$SESMAN_PORT" = "$VNC_PORT" ] || [ "$SESMAN_PORT" = "$RDP_PORT" ]; then die "--port, --vnc-port and --rdp-port must differ from each other and from $SESMAN_PORT." fi case "$DISPLAY_NO" in '' | *[!0-9]*) die "--display=$DISPLAY_NO is not a display number." ;; esac [ "$DISPLAY_NO" -le 999 ] || die "--display=$DISPLAY_NO is out of range." } # ours says whether a port is held by this add-on's running app: on a # re-run, the ports it already listens on are not taken by somebody else. OURS_RUNNING=0 ours() { [ "$OURS_RUNNING" = 1 ] || return 1 case "$1" in "$PREV_PORT" | "$PREV_VNC" | "$PREV_RDP_PORT") return 0 ;; esac return 1 } # --- preflight --------------------------------------------------------------- # Everything here fails before anything changes. ARCH="" PASSWORD_OK=0 preflight() { say "" say " Checks" [ -r /etc/os-release ] || die "this is not Ubuntu (no /etc/os-release)." local id version_id # shellcheck source=/dev/null # the system's, not this repo's id="$(. /etc/os-release && printf '%s' "${ID:-}")" # shellcheck source=/dev/null version_id="$(. /etc/os-release && printf '%s' "${VERSION_ID:-}")" if [ "$id" != ubuntu ] || ! command -v apt-get >/dev/null; then die "the desktop add-on installs on Ubuntu, and this is ${id:-unknown}." fi ARCH="$(dpkg --print-architecture)" case "$ARCH" in amd64 | arm64) ;; *) die "the desktop add-on is built for amd64 and arm64, not $ARCH." ;; esac if [ "$version_id" = "$TESTED_RELEASE" ]; then ok "Ubuntu $version_id, $ARCH" else caution "Ubuntu $version_id, $ARCH: tested on $TESTED_RELEASE; the package check below decides" fi [ "$(id -u)" -ne 0 ] || die "run this as the workspace user, not as root: the desktop belongs to that user. In the workspace's terminal: curl -fsSL $WEB/addons/desktop/install.sh | bash" sudo -n true 2>/dev/null /dev/null || die "$tool is not installed." done # Workspaces have both; a release cannot be checked without them. for tool in jq openssl; do command -v "$tool" >/dev/null || die "$tool is needed to check the release this installs (jq reads it, openssl checks its signature). Install it with: sudo apt-get install $tool" done if [ "$FIREFOX" = 1 ] && ! command -v gpg >/dev/null; then die "gpg is needed to check the key of Mozilla's apt repo. Install gnupg, or pass --no-firefox." fi # About 1 GB lands under /usr on a first install; an upgrade needs little. local need_mb=1200 free_kb [ "$CJK" = 1 ] && need_mb=1300 [ -n "$(installed_version xfce4)" ] && need_mb=300 free_kb="$(df -Pk / | awk 'NR == 2 {print $4}')" if [ "$((free_kb / 1024))" -lt "$need_mb" ]; then die "$((free_kb / 1024)) MB free under /, and the desktop needs about $need_mb MB." fi ok "$(awk -v k="$free_kb" 'BEGIN { printf "%.1f GB", k / 1048576 }') free (needs about $((need_mb / 100 / 10)).$((need_mb / 100 % 10)) GB)" if app_running; then OURS_RUNNING=1 fi local p flag for p in "port:$PORT" "vnc-port:$VNC_PORT" "rdp-port:$RDP_PORT"; do flag="${p%%:*}" p="${p#*:}" [ "$flag" = rdp-port ] && [ "$RDP" != 1 ] && continue if ! ours "$p" && port_listening "$p"; then die "port $p is taken by another program. Choose another with --$flag=N." fi done # sesman's port matters only on xrdp 0.9, which configure_xrdp checks for. if ! { [ "$OURS_RUNNING" = 1 ] && [ "$DISPLAY_NO" = "$PREV_DISPLAY" ]; } && display_taken "$DISPLAY_NO"; then die "display :$DISPLAY_NO is taken by another X server. Choose another with --display=N." fi if [ "$RDP" = 1 ]; then ok "ports $PORT, $VNC_PORT and $RDP_PORT and display :$DISPLAY_NO are free" else ok "ports $PORT and $VNC_PORT and display :$DISPLAY_NO are free" fi local pw pw="$(sudo -n passwd -S "$(id -un)" 2>/dev/null &"$LOG_FD")" || body="" if [ -z "$body" ]; then # --version must never quietly install something else. [ "$RELEASE" = current ] || die "release $RELEASE is not published at $WEB." unverified "$WEB publishes no release manifest" return 0 fi RELEASE_MANIFEST="$body" RELEASE_VERSION="$(printf '%s' "$body" | jq -r '.release_version // empty' 2>/dev/null)" || RELEASE_VERSION="" [ -n "$RELEASE_VERSION" ] || die "the release manifest from $WEB names no release." note "release $RELEASE_VERSION" } # verify_signature checks the manifest's signature against the keys above, and # that the signed payload names this release; then SIGNED_PAYLOAD is the # payload, whose lines each download must match. verify_signature() { local sig alg kid pem verified=0 sig="$(printf '%s' "$RELEASE_MANIFEST" | jq -c '.signature // empty' 2>/dev/null)" || sig="" if [ -z "$sig" ] || [ "$sig" = null ]; then unverified "release $RELEASE_VERSION was published without a signature" return 0 fi alg="$(printf '%s' "$sig" | jq -r '.algorithm // empty')" kid="$(printf '%s' "$sig" | jq -r '.key_id // empty')" # A scheme this script does not know is not the same as no signature: treating # it as unsigned would let anyone downgrade the check by naming one. [ "$alg" = ecdsa-p256-sha256 ] || die "release $RELEASE_VERSION is signed with '$alg', which this installer cannot check." mkdir "$WORK/sig" # -j, not -r: the signature is over exactly these bytes, and -r would add a # newline the signer never saw. printf '%s' "$sig" | jq -j '.payload' >"$WORK/sig/payload" printf '%s' "$sig" | jq -r '.signature' | base64 -d >"$WORK/sig/sig" 2>/dev/null || die "the signature on release $RELEASE_VERSION is not valid base64." release_public_keys >"$WORK/sig/keys.pem" # One file per key; any one verifying is enough, which is what lets a key be # rotated by shipping two for a while. awk -v d="$WORK/sig" '/-----BEGIN PUBLIC KEY-----/ { n++ } n > 0 { print > (d "/key" n ".pem") }' "$WORK/sig/keys.pem" for pem in "$WORK"/sig/key*.pem; do [ -f "$pem" ] || continue if openssl dgst -sha256 -verify "$pem" -signature "$WORK/sig/sig" "$WORK/sig/payload" >/dev/null 2>&1; then verified=1 break fi done [ "$verified" = 1 ] || die "the signature on release $RELEASE_VERSION does not verify against any key this installer trusts (signing key id: ${kid:-unknown}). That is a corrupted download or a tampered release, and there is no telling which from here. Nothing has been changed." # A genuine signature over another release proves nothing about this one: an # old signed release must not be replayable as the current one. grep -qxF devgrail-release-signature-v1 "$WORK/sig/payload" || die "the signature on release $RELEASE_VERSION verifies, but its payload is in a format this installer does not know." grep -qxF "release $RELEASE_VERSION" "$WORK/sig/payload" || die "the signature verifies, but it was made for another release than $RELEASE_VERSION." SIGNED_PAYLOAD="$WORK/sig/payload" note "release $RELEASE_VERSION signature verified (key ${kid:-unknown})" } # release_asset is one field of one asset in the manifest. release_asset() { printf '%s' "$RELEASE_MANIFEST" | jq -r --arg s "$1" --arg f "$2" '.assets[]? | select(.slug == $s) | .[$f] // empty' 2>/dev/null || true } # fetch downloads one of the release's assets and checks it # against the digest the manifest gives, then against the signed payload. # Without a manifest (allowed only unsigned, for development) it comes from # $WEB/install/, unchecked. fetch() { local slug="$1" dest="$2" url="" ver="" sha="" got if [ -n "$RELEASE_MANIFEST" ]; then url="$(release_asset "$slug" download_url)" ver="$(release_asset "$slug" asset_version)" sha="$(release_asset "$slug" sha256)" [ -n "$url" ] && [ -n "$ver" ] && [ -n "$sha" ] || die "release $RELEASE_VERSION at $WEB does not include $slug: either it predates the desktop add-on, or it was published without this architecture." else url="$WEB/install/$slug" fi note "fetch $slug from $url" curl -fsSL --retry 3 "$url" -o "$dest" 2>&"$LOG_FD" || die "cannot download $slug from $url." got="$(sha256_of "$dest")" if [ -n "$sha" ] && [ "$got" != "$sha" ]; then die "$slug does not match its published SHA-256 (expected $sha, got $got)." fi # The check that survives a compromised site: bytes that match the digest it # served are not enough unless the signed release names that digest. if [ -n "$SIGNED_PAYLOAD" ] && ! grep -qxF "asset $slug $ver $got" "$SIGNED_PAYLOAD"; then die "$slug is not what release $RELEASE_VERSION was signed for: its digest $got is not in the signed payload. Nothing has been changed." fi } ADDON_VERSION="" download() { case "$WEB" in *__DEVGRAIL_WEB""_URL__* | "") die "set DEVGRAIL_WEB_URL to the DevGrail site to download the desktop from." ;; esac case "${DEVGRAIL_ALLOW_UNSIGNED:-0}" in 1 | yes | true) ALLOW_UNSIGNED=1 ;; esac say "" begin "Downloading the desktop app" WORK="$(mktemp -d "${TMPDIR:-/tmp}/devgrail-desktop.XXXXXX")" resolve_release [ -z "$RELEASE_MANIFEST" ] || verify_signature fetch "devgrail-desktop-linux-$ARCH" "$WORK/devgrail-desktop" fetch devgrail-desktop-files.tar.gz "$WORK/files.tar.gz" chmod 0755 "$WORK/devgrail-desktop" ADDON_VERSION="$("$WORK/devgrail-desktop" version /dev/null)" || die "the downloaded app does not run on this $ARCH system." mkdir "$WORK/files" tar -xzf "$WORK/files.tar.gz" -C "$WORK/files" 2>&"$LOG_FD" || die "the support files did not unpack." local f for f in install.sh libexec/session libexec/audio libexec/relaunch share/desktop-audio.pa share/relaunch.desktop share/home; do [ -e "$WORK/files/$f" ] || die "the support files have no $f." done rm -f "$WORK/files.tar.gz" if [ -n "$SIGNED_PAYLOAD" ]; then finish "version $ADDON_VERSION, verified (release signature + SHA-256)" else finish "version $ADDON_VERSION, not verified (DEVGRAIL_ALLOW_UNSIGNED=1)" fi } # --- Mozilla repo and the dry run --------------------------------------------- PACKAGES=() package_list() { # The default image's desktop list, without websockify and supervisor (the # app does their jobs), without the libraries XFCE and Firefox pull in # anyway (whose names changed with Ubuntu's 64-bit time_t move, so leaving # them out keeps one list for every release), and without libnss3 and # libgbm1, which nothing here needs. Then the desktop apps a developer # reaches for: a text editor, a diff and merge tool, an image viewer, an # archive manager (with Thunar's Extract here), screenshots, a task manager # and a calculator. No notification daemon: the app is the desktop's. PACKAGES=( xfce4 xfce4-terminal thunar yaru-theme-gtk yaru-theme-icon librsvg2-common fonts-ubuntu fonts-jetbrains-mono fonts-noto-color-emoji dbus-x11 tigervnc-standalone-server tigervnc-common xclip x11-xserver-utils xdg-utils tzdata libnotify-bin pulseaudio pulseaudio-utils libasound2-plugins gstreamer1.0-plugins-base gstreamer1.0-pulseaudio python3-gi gir1.2-gstreamer-1.0 mousepad meld ristretto xarchiver thunar-archive-plugin xfce4-screenshooter xfce4-taskmanager galculator ) [ "$RDP" = 1 ] && PACKAGES+=(xrdp) [ "$CJK" = 1 ] && PACKAGES+=(fonts-noto-cjk) [ "$FIREFOX" = 1 ] && PACKAGES+=(firefox) return 0 } # other_source says whether apt already has a repo whose # address contains pattern from somewhere other than this installer. A second # entry for it with another key would make apt refuse both. other_source() { grep -rlsF "$1" /etc/apt/sources.list /etc/apt/sources.list.d 2>/dev/null | grep -vxF "$2" | grep -q . } # fetch_key downloads a repo's signing key # into out, ready for apt, if it is exactly the one published: one key, with # that fingerprint. Whoever could change the download could otherwise add a # key of their own and sign packages apt then trusts. --show-keys and # --dearmor need no keyring, so no gpg-agent is started: in a container # nothing would reap it when it exits. fetch_key() { local name="$1" url="$2" want="$3" out="$4" keys fpr curl -fsSL --retry 3 "$url" -o "$WORK/key.asc" 2>&"$LOG_FD" || die "cannot download the signing key of $name's apt repo." keys="$(GNUPGHOME="$WORK" gpg --batch --show-keys --with-colons "$WORK/key.asc" &"$LOG_FD")" || die "the signing key of $name's apt repo could not be read." fpr="$(printf '%s\n' "$keys" | awk -F: '$1 == "fpr" { print $10; exit }')" if [ "$(printf '%s\n' "$keys" | grep -c '^pub:')" != 1 ] || [ "$fpr" != "$want" ]; then die "$name's apt repo key is not the one this installer expects ($want, alone); it has ${fpr:-none}." fi GNUPGHOME="$WORK" gpg --batch --yes --dearmor -o "$out" "$WORK/key.asc" &"$LOG_FD" || die "the signing key of $name's apt repo could not be converted for apt." rm -f "$WORK/key.asc" } # add_repo installs a repo's keyring # and list, noting the ones this run created. add_repo() { as_root test -e "$1" || REPO_ADDED_NOW="$REPO_ADDED_NOW $1" as_root test -e "$2" || REPO_ADDED_NOW="$REPO_ADDED_NOW $2" root_install 0644 "$3" "$1" printf '%s\n' "$4" >"$WORK/repo.list" root_install 0644 "$WORK/repo.list" "$2" } add_mozilla_repo() { # Ubuntu's own firefox package only installs the snap, and snaps do not run # in a container. The pin keeps it away; Mozilla's repo has the real one. local pin="$WORK/mozilla.pin" printf 'Package: firefox\nPin: release o=Ubuntu\nPin-Priority: -1\n\nPackage: *\nPin: origin packages.mozilla.org\nPin-Priority: 1000\n' >"$pin" if ! as_root test -e "$MOZILLA_PIN"; then REPO_ADDED_NOW="$REPO_ADDED_NOW $MOZILLA_PIN" fi root_install 0644 "$pin" "$MOZILLA_PIN" if other_source packages.mozilla.org "$MOZILLA_LIST"; then note "Mozilla's repo is configured already; using it" return 0 fi fetch_key Mozilla https://packages.mozilla.org/apt/repo-signing-key.gpg "$MOZILLA_FPR" "$WORK/mozilla.gpg" add_repo "$MOZILLA_KEYRING" "$MOZILLA_LIST" "$WORK/mozilla.gpg" \ "deb [signed-by=$MOZILLA_KEYRING] https://packages.mozilla.org/apt mozilla main" } check_packages() { package_list begin "Checking the packages" apt_lists_present && LISTS_BEFORE=1 || LISTS_BEFORE=0 APT_TOUCHED=1 if [ "$FIREFOX" = 1 ]; then add_mozilla_repo fi if ! quietly as_root apt-get update; then die "apt-get update failed: $(log_tail 2>&1)" fi # The compatibility check: apt answers for every package on this release, # and names the one it cannot install. local out if ! out="$(as_root apt-get install -s --no-install-recommends "${PACKAGES[@]}" &1)"; then note "$out" die "this system cannot take the desktop; apt says: $(printf '%s\n' "$out" | grep -E '^(E:| )' | head -n 20 | sed 's/^/ /')" fi note "$out" local count count="$(printf '%s\n' "$out" | grep -c '^Inst ' || true)" if [ "$count" -gt 0 ]; then finish "all available ($count to install or upgrade)" else finish "all available, all installed" fi } # --- apt --------------------------------------------------------------------- install_packages() { local before after begin "Installing XFCE, the apps, sound$([ "$FIREFOX" = 1 ] && printf ', Firefox')$([ "$RDP" = 1 ] && printf ' and RDP')" # No service is started from a package's install script: xrdp's would # listen on every address. The images block that already; this makes sure. if ! [ -e /usr/sbin/policy-rc.d ]; then printf '#!/bin/sh\nexit 101\n' >"$WORK/policy-rc.d" root_install 0755 "$WORK/policy-rc.d" /usr/sbin/policy-rc.d TEMP_POLICY_RC=1 fi before="$WORK/packages.before" after="$WORK/packages.after" installed_packages >"$before" # From here on the repo stays, whatever happens: it is what Firefox, once # installed, updates from. It is recorded first, so uninstall finds it even # after a failed install. REPO_ADDED_NOW="" as_root install -d -m 0755 "$ETC" if [ "$FIREFOX" = 1 ]; then take_over "$MOZILLA_PIN" if as_root test -e "$MOZILLA_LIST"; then take_over "$MOZILLA_KEYRING" take_over "$MOZILLA_LIST" fi fi # confold: a re-run keeps the xrdp config this installer edited instead of # stopping at dpkg's question about it. local status=0 quietly as_root env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "${PACKAGES[@]}" || status=$? # What did get installed is recorded either way, for --purge-packages. installed_packages >"$after" { if [ -f "$PACKAGES_ADDED" ]; then cat "$PACKAGES_ADDED"; fi LC_ALL=C comm -13 "$before" "$after" } | LC_ALL=C sort -u >"$WORK/packages-added" root_install 0644 "$WORK/packages-added" "$PACKAGES_ADDED" if [ "$status" -ne 0 ]; then die "apt-get install failed: $(log_tail 2>&1)" fi finish } # --- configure --------------------------------------------------------------- configure_xrdp() { local ini=/etc/xrdp/xrdp.ini sesman=/etc/xrdp/sesman.ini src take_over "$ini" src="$ETC/orig$ini" as_root test -e "$src" || src="$ini" # xrdp on the add-on's port, on loopback only (the SSH tunnel reaches it # there), straight into the one session, and every other session type gone. as_root cat "$src" | sed -e "s|^port=3389\$|port=tcp://127.0.0.1:$RDP_PORT|" \ -e 's/^autorun=$/autorun=devgrail/' -e '/^\[Xorg\]/,$d' >"$WORK/xrdp.ini" if ! grep -qxF "port=tcp://127.0.0.1:$RDP_PORT" "$WORK/xrdp.ini" || ! grep -qx 'autorun=devgrail' "$WORK/xrdp.ini"; then die "$ini does not look like the xrdp config this installer knows how to change (no port=3389 or autorun= line). Install with --no-rdp, or put the package's own $ini back and run this again." fi printf '\n[devgrail]\nname=DevGrail shared desktop\nlib=libvnc.so\nip=127.0.0.1\nport=%s\nusername=na\npassword=na\npamusername=ask\npampassword=ask\npamsessionmng=127.0.0.1\n' \ "$VNC_PORT" >>"$WORK/xrdp.ini" root_install 0644 "$WORK/xrdp.ini" "$ini" # xrdp 0.9's sesman listens on TCP 3350, on every address; xrdp finds it # through the same file. src="$ETC/orig$sesman" as_root test -e "$src" || src="$sesman" if as_root grep -qx 'ListenPort=3350' "$src"; then if ! { [ "$OURS_RUNNING" = 1 ] && managed "$sesman"; } && port_listening "$SESMAN_PORT"; then die "port $SESMAN_PORT, which xrdp's session manager needs here, is taken by another program. Install with --no-rdp." fi take_over "$sesman" as_root cat "$ETC/orig$sesman" | sed -e "s/^ListenPort=3350\$/ListenPort=$SESMAN_PORT/" \ -e 's/^ListenAddress=.*$/ListenAddress=127.0.0.1/' >"$WORK/sesman.ini" root_install 0644 "$WORK/sesman.ini" "$sesman" fi } configure() { begin "Configuring the app, $([ "$RDP" = 1 ] && printf 'xrdp, ')$([ "$FIREFOX" = 1 ] && printf 'Firefox, ')the sound and the XFCE defaults" local new="$SHARE.new" f rel # The binary is replaced by a rename: the running app keeps its own file. root_install 0755 "$WORK/devgrail-desktop" "$BIN.new" as_root mv -f "$BIN.new" "$BIN" as_root rm -rf "$new" as_root mkdir -p "$new" as_root cp -r "$WORK/files/." "$new/" as_root chown -R root:root "$new" as_root find "$new" -type d -exec chmod 0755 {} + as_root find "$new" -type f -exec chmod 0644 {} + as_root find "$new/libexec" -type f -exec chmod 0755 {} + as_root chmod 0755 "$new/install.sh" as_root rm -rf "$SHARE.old" if as_root test -d "$SHARE"; then as_root mv "$SHARE" "$SHARE.old"; fi as_root mv "$new" "$SHARE" as_root rm -rf "$SHARE.old" if [ "$RDP" = 1 ]; then configure_xrdp else for f in /etc/xrdp/xrdp.ini /etc/xrdp/sesman.ini; do if managed "$f"; then give_back "$f"; fi done fi if [ "$FIREFOX" = 1 ]; then # No first-run pages, and no smooth scrolling: every frame of a scroll # animation is a screen update sent to the viewer. take_over /etc/firefox/policies/policies.json root_install 0644 "$SHARE/share/firefox-policies.json" /etc/firefox/policies/policies.json elif managed /etc/firefox/policies/policies.json; then give_back /etc/firefox/policies/policies.json fi take_over /etc/pulse/client.conf.d/99-devgrail.conf root_install 0644 "$SHARE/share/pulse-client.conf" /etc/pulse/client.conf.d/99-devgrail.conf # Firefox reopens when the session starts if it was open when it was last # saved; XFCE reopens the rest itself. take_over /etc/xdg/autostart/devgrail-desktop-relaunch.desktop root_install 0644 "$SHARE/share/relaunch.desktop" /etc/xdg/autostart/devgrail-desktop-relaunch.desktop # A browser that an agent opens from the terminal lands on the desktop. An # SSH session with X forwarding keeps its own DISPLAY. # shellcheck disable=SC2016 # expanded by the shell that reads the file printf '# The DevGrail desktop add-on'"'"'s display, for programs started from a shell.\n[ -n "${DISPLAY:-}" ] || export DISPLAY=:%s\n' "$DISPLAY_NO" >"$WORK/profile.sh" take_over /etc/profile.d/devgrail-desktop.sh root_install 0644 "$WORK/profile.sh" /etc/profile.d/devgrail-desktop.sh # The XFCE defaults are the user's own settings: copied only where the user # has none yet, so a re-run never undoes a change. (cd "$SHARE/share/home" && find . -type f) | while read -r rel; do rel="${rel#./}" if [ ! -e "$HOME/$rel" ]; then install -D -m 0644 "$SHARE/share/home/$rel" "$HOME/$rel" fi done { printf '{\n' printf ' "port": %s,\n' "$PORT" printf ' "vnc_port": %s,\n' "$VNC_PORT" printf ' "rdp_port": %s,\n' "$RDP_PORT" printf ' "display": %s,\n' "$DISPLAY_NO" printf ' "rdp": %s,\n' "$(bool "$RDP")" printf ' "firefox": %s,\n' "$(bool "$FIREFOX")" printf ' "cjk_fonts": %s,\n' "$(bool "$CJK")" printf ' "web_url": %s\n' "$(json_str "$WEB")" printf '}\n' } >"$WORK/config.json" root_install 0644 "$WORK/config.json" "$CONFIG" # What the viewer's About shows, and what a support question starts from. { printf '{\n' printf ' "version": %s,\n' "$(json_str "$ADDON_VERSION")" printf ' "arch": %s,\n' "$(json_str "$ARCH")" printf ' "installed_at": %s' "$(json_str "$(date -u +%Y-%m-%dT%H:%M:%SZ)")" local key pkg v for key in xfce4:xfce4 firefox:firefox tigervnc:tigervnc-standalone-server xrdp:xrdp; do pkg="${key#*:}" v="$(installed_version "$pkg")" [ -n "$v" ] && printf ',\n "%s": %s' "${key%%:*}" "$(json_str "$v")" done printf '\n}\n' } >"$WORK/record.json" root_install 0644 "$WORK/record.json" "$RECORD" finish } # --- boot block -------------------------------------------------------------- # The workspace runs /workspace/.devgrail/startup.sh on every container start, # before its terminal comes up. The block starts the app there, in the # background; the app decides whether to start the desktop. strip_block() { awk -v b="$BLOCK_BEGIN" -v e="$BLOCK_END" '$0 == b { skip = 1 } !skip { print } $0 == e { skip = 0 }' "$1" } BOOT_NOTE="" install_boot_block() { if [ ! -d /workspace ]; then BOOT_NOTE="There is no /workspace here, so the desktop does not start by itself when the container does: run devgrail-desktop start." return 0 fi mkdir -p "${STARTUP%/*}" if [ -e "$STARTUP" ]; then strip_block "$STARTUP" >"$WORK/startup.sh" # An empty line between the user's part and the block, but not one more # on every re-run. [ -n "$(tail -n 1 "$WORK/startup.sh")" ] && printf '\n' >>"$WORK/startup.sh" else printf '#!/bin/bash\n\n' >"$WORK/startup.sh" as_root touch "$CREATED_HOOK" fi cat >>"$WORK/startup.sh" <"$STARTUP" || { BOOT_NOTE="$STARTUP could not be written, so the desktop does not start by itself when the container does." return 0 } else install -m 0755 "$WORK/startup.sh" "$STARTUP" fi if [ ! -x "$STARTUP" ]; then BOOT_NOTE="$STARTUP is not executable, so the workspace does not run it and the desktop does not start by itself. chmod +x it if everything in it should run." fi } # --- start ------------------------------------------------------------------- TURNED_OFF=0 start_app() { local out verb=Starting app_running && verb=Restarting begin "$verb the desktop" # The installer's own settings are no business of the desktop's programs, # which get the workspace's DEVGRAIL_* variables. # Nor are its lock and log: the app outlives this run, and would otherwise # hold the lock against every later one. local -a env_clean=(env -u DEVGRAIL_WEB_URL -u DEVGRAIL_ALLOW_UNSIGNED -u DEVGRAIL_RELEASE_PUBKEY_FILE) if [ "$verb" = Restarting ]; then out="$("${env_clean[@]}" "$BIN" restart &1 {LOCK_FD}>&- {LOG_FD}>&-)" || die "the app did not start: $out" else out="$("${env_clean[@]}" "$BIN" start &1 {LOCK_FD}>&- {LOG_FD}>&-)" || die "the app did not start: $out" fi note "$out" if grep -q '"off": *true' "$STATE_DIR/state.json" 2>/dev/null; then TURNED_OFF=1 finish "running on port $PORT; the desktop is turned off" else finish "ready on port $PORT" fi } next_steps() { say "" say " Next:" say " 1. In DevGrail, open this workspace → Subdomains → add one for port $PORT." say " 2. Open https:/// and sign in with the workspace's SSH password." if [ "$PASSWORD_OK" != 1 ]; then say " This workspace has no SSH password yet: set one in DevGrail first." fi if [ "$TURNED_OFF" = 1 ]; then say " The desktop was turned off in the viewer and stays off: sign in and choose Turn on." fi if [ "$RDP" = 1 ]; then say " RDP: ssh -L $RDP_PORT:localhost:$RDP_PORT -p $(id -un)@, then connect to localhost:$RDP_PORT." fi if [ -n "$BOOT_NOTE" ]; then say "" say " Note: $BOOT_NOTE" fi say "" say " devgrail-desktop status | start | stop | restart | uninstall" } install_main() { open_log "$@" trap cleanup EXIT trap 'FAILED_LINE=$LINENO' ERR say "DevGrail Desktop installer" settle_choices preflight download check_packages install_packages configure install_boot_block start_app cleanup next_steps } # --- uninstall --------------------------------------------------------------- # purge_list is the recorded packages that are still installed. purge_list() { [ -f "$PACKAGES_ADDED" ] || return 0 LC_ALL=C comm -12 <(LC_ALL=C sort -u "$PACKAGES_ADDED") <(installed_packages) } # check_purge refuses, before anything changes, a purge that would take # packages this installer did not add with it: something installed since # depends on them. check_purge() { local out extra [ -n "$(purge_list)" ] || return 0 # shellcheck disable=SC2046 # one package a word out="$(as_root apt-get -s purge $(purge_list) &1)" || die "apt cannot remove the packages: $(printf '%s\n' "$out" | grep '^E:' | sed 's/^/ /')" extra="$(LC_ALL=C comm -13 <(purge_list | sed 's/:.*//' | LC_ALL=C sort -u) \ <(printf '%s\n' "$out" | awk '$1 == "Purg" || $1 == "Remv" {print $2}' | sed 's/:.*//' | LC_ALL=C sort -u))" if [ -n "$extra" ]; then die "removing the desktop's packages would also remove these, which the installer did not add: $(printf '%s\n' "$extra" | sed 's/^/ /') Remove them first, or uninstall without --purge-packages. Nothing has been changed." fi } uninstall_main() { open_log "$@" trap cleanup EXIT trap 'FAILED_LINE=$LINENO' ERR WORK="$(mktemp -d "${TMPDIR:-/tmp}/devgrail-desktop.XXXXXX")" say "DevGrail Desktop: uninstall" [ "$(id -u)" -ne 0 ] || die "run this as the workspace user, not as root." sudo -n true 2>/dev/null "$WORK/startup.sh" if [ -e "$CREATED_HOOK" ] && ! grep -qv -e '^#!/bin/bash$' -e '^[[:space:]]*$' "$WORK/startup.sh"; then rm -f "$STARTUP" else # Without the empty lines the installer left at the end. awk '{ line[NR] = $0; if ($0 != "") last = NR } END { for (i = 1; i <= last; i++) print line[i] }' \ "$WORK/startup.sh" >"$WORK/startup.trim" cat "$WORK/startup.trim" >"$STARTUP" fi ok "removed the boot block from $STARTUP" fi # Firefox without --purge-packages stays installed, so the repo it updates # from stays too. local keep_mozilla=0 path if [ "$PURGE" != 1 ] && [ -n "$(installed_version firefox)" ]; then keep_mozilla=1 fi if [ -f "$MANAGED" ]; then # A copy: give_back rewrites the list as it goes. cp "$MANAGED" "$WORK/managed.all" while read -r path; do [ -n "$path" ] || continue case "$path" in "$MOZILLA_KEYRING" | "$MOZILLA_LIST" | "$MOZILLA_PIN") [ "$keep_mozilla" = 1 ] && continue ;; esac give_back "$path" done <"$WORK/managed.all" ok "put back the configuration files it changed" fi if [ "$PURGE" = 1 ] && [ -n "$(purge_list)" ]; then begin "Removing the packages it installed" APT_TOUCHED=1 # shellcheck disable=SC2046 # one package a word quietly as_root env DEBIAN_FRONTEND=noninteractive apt-get purge -y $(purge_list) || die "apt-get purge failed: $(log_tail 2>&1)" finish fi if [ "$PURGE" = 1 ]; then # Ours by name, and no longer listed when an earlier uninstall kept them # for the Firefox it left. as_root rm -f "$MOZILLA_KEYRING" "$MOZILLA_LIST" "$MOZILLA_PIN" fi # Packages that stay keep their record, alone in $ETC: a reinstall adds to # it, and a later --purge-packages (from the install command, once the app # is gone) still knows what to remove. local keep_record=0 if [ "$PURGE" != 1 ] && [ -n "$(purge_list)" ]; then keep_record=1 cp "$PACKAGES_ADDED" "$WORK/packages-added" fi as_root rm -rf "$BIN" "$SHARE" "$SHARE.new" "$SHARE.old" "$ETC" "$RECORD" if [ "$keep_record" = 1 ]; then root_install 0644 "$WORK/packages-added" "$PACKAGES_ADDED" fi ok "removed the app and its files" say "" if [ "$keep_record" = 1 ]; then say " The packages it installed are still here$([ "$keep_mozilla" = 1 ] && printf "; Firefox keeps updating from Mozilla's repo"). To remove them too: curl -fsSL ${WEB:-https://}/addons/desktop/install.sh | bash -s -- uninstall --purge-packages ($PACKAGES_ADDED lists them.)" fi say " Your XFCE settings in ~/.config are yours and stay. The DevGrail desktop add-on is uninstalled." # The log and the lock go last, with the state directory: nothing is left. rm -rf "$STATE_DIR" } main() { parse_args "$@" if [ "$MODE" = uninstall ]; then uninstall_main "$@" else install_main "$@" fi } main "$@"