From 6bbce800c79403431d8bfba457deb3a2e6ff4d6d Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Thu, 15 Oct 2026 21:31:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(install):=20bash=20<(curl=20=E2=80=A6)=20-?= =?UTF-8?q?u=20updates=20a=20host;=20the=20script=20asks=20sudo=20itself?= =?UTF-8?q?=20(#411)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``` bash <(curl -fsSL https://api.nestri.io/install.sh) -u # update this host bash <(curl -fsSL https://api.nestri.io/install.sh) # make this machine a host bash <(curl -fsSL https://api.nestri.io/install.sh) -h # help ``` Piping still works: `curl -fsSL … | sh -s -- -u`. **`-u` / `--update`.** No token and no questions. On a registered machine it downloads and checksum-verifies the current host agent, swaps it in and restarts the service. The agent then pulls whatever its release changed (a raised image layout re-pulls the guest, jail and Proton). It refuses: - on a machine that was never installed, before downloading anything; - while a box is running, since restarting the agent would end that session; - on an unknown option. On a machine that already has the target version, it says so and exits 0. **Root, asked once.** The host agent runs as root, so when the script is started as anyone else it asks sudo once, before it changes anything. Neither a pipe nor `bash <(…)` leaves a file sudo could run again, so it fetches the same script to a private temp file, runs that as root with the same mode and token, and removes the copy as soon as root has it open. Only the settings somebody actually set (`NESTRI_HOST_VERSION`, `NESTRI_BIN_DIR`, `NESTRI_BOX_STORE`) are carried across. **Tested.** - **On an NVIDIA host:** `--update` took it 0.2.17 → 0.2.18, verified and restarted. A second run said "already 0.2.18. Nothing to do". - **On the fleet host, run as an ordinary user** with `bash <(curl …) -u`: - it asked for sudo, the root copy ran and said "Nothing to do", and no temp copy was left behind; - `-h` prints usage, and an unknown flag is refused. - **In a clean container:** `-u` refuses with "not a Nestri host yet", and a run with no token asks for one. --- apps/api/install/install.sh | 117 +++++++++++++++++++++++++++++++----- 1 file changed, 102 insertions(+), 15 deletions(-) diff --git a/apps/api/install/install.sh b/apps/api/install/install.sh index 23a52527..1955cded 100755 --- a/apps/api/install/install.sh +++ b/apps/api/install/install.sh @@ -4,7 +4,10 @@ # This file is the source of what that URL serves, kept in the public # repository so anyone about to pipe it into a shell can read it first. # -# curl -fsSL https://api.nestri.io/install.sh | sudo sh -s -- +# bash <(curl -fsSL https://api.nestri.io/install.sh) +# bash <(curl -fsSL https://api.nestri.io/install.sh) -u +# +# Piping it works the same way: `curl -fsSL … | sh -s -- -u`. # # What it does, in order: check this is 64-bit Linux, ask where box images # should live, download the host agent for this platform, verify it against the @@ -14,10 +17,16 @@ # # It runs as root because the agent does: each box's VMM is jailed under a uid # of its own, which only root can give it, and a box's network needs -# CAP_NET_ADMIN. The agent keeps its state in /var/lib/nestri. +# CAP_NET_ADMIN. The agent keeps its state in /var/lib/nestri. Started as +# anyone else, it asks sudo for root once, before it changes anything. # # The token comes from the dashboard's Installation page. It registers one # machine, works once and lapses after an hour. +# +# `-u` (`--update`) needs no token: on a machine that is already a host, it installs +# the current agent and restarts it, and the agent picks up whatever a new +# release changed about its images on its own. It touches nothing else -- the +# machine keeps its registration, its box store and its Steam sign-ins. set -eu @@ -32,8 +41,43 @@ STATE_DIR=/var/lib/nestri say() { printf '%s\n' "$*" >&2; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } -TOKEN="${1:-${NESTRI_INSTALL_TOKEN:-}}" -[ -n "$TOKEN" ] || die "no install token. Copy the full command from the dashboard's Installation page." +usage() { + cat >&2 < make this machine a host + bash <(curl -fsSL $API/install.sh) -u update the host agent here + + -u, --update install the current host agent on a machine that is already a host + -h, --help this +USAGE +} + +MODE=install +TOKEN="${NESTRI_INSTALL_TOKEN:-}" +while [ $# -gt 0 ]; do + case "$1" in + -u|--update) MODE=update ;; + -h|--help) usage; exit 0 ;; + --) ;; + -*) usage; die "unknown option $1" ;; + *) TOKEN="$1" ;; + esac + shift +done +if [ "$MODE" = install ] && [ -z "$TOKEN" ]; then + usage + die "no install token. Copy the full command from the dashboard's Installation page." +fi + +# --- fetch ------------------------------------------------------------------ +if command -v curl >/dev/null 2>&1; then + get() { curl -fsSL "$1" -o "$2"; } +elif command -v wget >/dev/null 2>&1; then + get() { wget -qO "$2" "$1"; } +else + die "need curl or wget" +fi # --- platform --------------------------------------------------------------- [ "$(uname -s)" = Linux ] || die "a host has to run Linux (with KVM); this is $(uname -s)." @@ -41,7 +85,46 @@ case "$(uname -m)" in x86_64|amd64) target=x86_64-unknown-linux-gnu ;; *) die "no host build for $(uname -m) yet." ;; esac -[ "$(id -u)" -eq 0 ] || die "run this as root: pipe it into \`sudo sh -s -- \`, as the dashboard shows." +# --- root ------------------------------------------------------------------ +# Asked for once, here, before anything changes. A script read from a pipe or +# from `bash <(curl …)` has no file sudo could run again -- the descriptor it +# came on is this shell's, and sudo starts a fresh one -- so this fetches the +# same script to a private file and runs that as root, with the same mode and +# token. Only the settings somebody actually set are carried across. +if [ "$(id -u)" -ne 0 ]; then + command -v sudo >/dev/null 2>&1 || die "this needs root and there is no sudo here. Run it as root." + self="$(mktemp)" + get "$API/install.sh" "$self" || die "could not fetch $API/install.sh to run it as root" + if [ "$MODE" = update ]; then set -- -u; else set -- "$TOKEN"; fi + say "The host agent runs as root, so this needs sudo." + exec sudo env NESTRI_SELF="$self" NESTRI_API="$API" \ + ${NESTRI_HOST_VERSION:+NESTRI_HOST_VERSION="$NESTRI_HOST_VERSION"} \ + ${NESTRI_BIN_DIR:+NESTRI_BIN_DIR="$NESTRI_BIN_DIR"} \ + ${NESTRI_BOX_STORE:+NESTRI_BOX_STORE="$NESTRI_BOX_STORE"} \ + sh "$self" "$@" +fi +# The copy fetched above, now open as this shell's script: removing the name +# leaves what is being read intact and leaves nothing behind. +if [ -n "${NESTRI_SELF:-}" ]; then rm -f "$NESTRI_SELF"; fi + +# --- update: only where there is something to update ------------------------- +# Checked before anything is downloaded, so a mistaken run costs nothing. +if [ "$MODE" = update ]; then + # Registered means the agent has a machine identity. Without one there is + # nothing to update, and installing would need the token this skips. + [ -f "$STATE_DIR/machine.json" ] || die "this machine is not a Nestri host yet, so there is nothing to update. Install it with the command from the dashboard's Installation page." + # Updating restarts the agent, and the agent stopping stops every box. A + # session somebody is playing would end in the middle, so wait for it. + if pgrep -x nesbox >/dev/null 2>&1; then + die "a box is running on this machine right now, and updating would stop it. Run this again when nobody is playing." + fi + current="$("$BIN_DIR/nestri-host" --version 2>/dev/null | awk '{print $NF}')" || current="" + if [ "$current" = "$VERSION" ]; then + say "The host agent is already $VERSION. Nothing to do." + exit 0 + fi + say "Updating the host agent from ${current:-an unknown version} to $VERSION…" +fi # --- an install from before the agent ran as root --------------------------- # Hosts used to run the agent as the user who installed it, from a state @@ -67,22 +150,14 @@ if [ -n "$OLD_STATE" ] && [ -f "$OLD_STATE"/onboarding.json ] && [ ! -e "$STATE_ say "Copied $OLD_STATE to $STATE_DIR." fi -# --- fetch ------------------------------------------------------------------ -if command -v curl >/dev/null 2>&1; then - get() { curl -fsSL "$1" -o "$2"; } -elif command -v wget >/dev/null 2>&1; then - get() { wget -qO "$2" "$1"; } -else - die "need curl or wget" -fi - # --- where box images go ---------------------------------------------------- # Their own device, xfs or ext4, and never `/`: box images are large, and a # filled root filesystem takes the whole machine down with it. The agent's # preflight checks this again; asking here is so the default is a good guess. +# An update keeps the store the machine already has, so it is not asked. tty_ok() { [ -e /dev/tty ] && (exec 3/dev/null; } BOX_STORE="${NESTRI_BOX_STORE:-}" -if [ -z "$BOX_STORE" ]; then +if [ "$MODE" = install ] && [ -z "$BOX_STORE" ]; then guess="$(df -P -T -x tmpfs -x devtmpfs -x overlay 2>/dev/null \ | awk 'NR>1 && ($2=="xfs"||$2=="ext4") && $7!="/" && $7!~/^\/(boot|efi)/ {print $5, $7}' \ | sort -rn | awk 'NR==1 {print $2}')" @@ -128,6 +203,18 @@ mv "$TMP/$ASSET" "$BIN_DIR/nestri-host" say "Installed $BIN_DIR/nestri-host" say "" +# --- update: restart on the new binary, and stop ----------------------------- +# No onboarding: the machine is registered and configured already. The agent +# reads what its new release needs when it starts -- a raised image layout +# makes it pull the guest and its jail again -- so restarting is the whole of +# it. +if [ "$MODE" = update ]; then + systemctl restart nestri-host.service || die "the new agent is installed, but restarting nestri-host failed. See: journalctl -u nestri-host" + say "Updated to $("$BIN_DIR/nestri-host" --version 2>/dev/null | awk '{print $NF}'). If this release changed the box images, the agent is downloading them now:" + say " journalctl -fu nestri-host" + exit 0 +fi + # --- upgrade ---------------------------------------------------------------- # On a host that already runs the agent, this is an upgrade: the agent holds # the host lock, and onboarding refuses to race it. Stopped here, and started