#!/bin/bash # StonePi bootstrap — turns a fresh Raspberry Pi OS install into StonePi. # # curl -fsSL https://stonepi-install.vercel.app/install.sh | sudo bash # # Options (pass after `bash -s --`): # --reinstall Re-run the installer even if StonePi is already present # --force Skip the Pi model / OS checks # --manifest URL Use a different release manifest # --insecure-skip-checksum # Install a release whose manifest has no sha256 (unverified) # # The bootstrap only knows how to find a release. The release's own # deploy/install.sh does the real work (packages, Nginx, Cockpit, Tailscale, # Vault, systemd units) and is safe to re-run: an unfinished install is # picked up again by running the same command. set -euo pipefail MANIFEST_URL="${STONEPI_MANIFEST_URL:-https://stonepi-install.vercel.app/manifest.json}" STATE_DIR="/var/lib/stonepi-bootstrap" CACHE_DIR="/var/cache/stonepi-bootstrap" LOG_FILE="/var/log/stonepi-bootstrap.log" REINSTALL=0 FORCE=0 SKIP_CHECKSUM=0 while [[ $# -gt 0 ]]; do case "$1" in --reinstall) REINSTALL=1; shift ;; --force) FORCE=1; shift ;; --manifest) MANIFEST_URL="${2:?--manifest needs a URL}"; shift 2 ;; --manifest=*) MANIFEST_URL="${1#*=}"; shift ;; --insecure-skip-checksum) SKIP_CHECKSUM=1; shift ;; -h|--help) sed -n '2,17p' "$0" 2>/dev/null || true; exit 0 ;; *) echo "Unknown option: $1" >&2; exit 1 ;; esac done say() { printf '\n\033[1m%s\033[0m\n' "$*"; } fail() { printf '\n\033[31mStonePi installation paused.\033[0m\n' >&2 printf 'Step: %s\n' "${STEP:-starting}" >&2 printf 'Reason: %s\n' "$*" >&2 printf 'Log: %s\n' "$LOG_FILE" >&2 printf 'Retry: run the same command again — it picks up where it stopped.\n' >&2 exit 1 } mark() { mkdir -p "$STATE_DIR"; printf '%s %s\n' "$(date -Is)" "$1" >> "$STATE_DIR/state"; printf '%s\n' "$1" > "$STATE_DIR/current"; } if [[ "$(id -u)" -ne 0 ]]; then echo "Run with sudo: curl -fsSL https://stonepi-install.vercel.app/install.sh | sudo bash" >&2 exit 1 fi mkdir -p "$STATE_DIR" "$CACHE_DIR" # Root-only: the log holds the first admin password the installer prints. touch "$LOG_FILE" chmod 600 "$LOG_FILE" exec > >(tee -a "$LOG_FILE") 2>&1 trap 'fail "command failed on line $LINENO (see log)"' ERR cat <<'BANNER' STONEPI The bedrock of your digital home. BANNER # ---------------------------------------------------------------- checks STEP="Checking this Raspberry Pi" say "$STEP" MODEL="$(tr -d '\0' < /proc/device-tree/model 2>/dev/null || echo unknown)" ARCH="$(uname -m)" . /etc/os-release 2>/dev/null || true echo "Device: $MODEL" echo "System: ${PRETTY_NAME:-unknown} ($ARCH)" if [[ "$FORCE" -ne 1 ]]; then [[ "$ARCH" == "aarch64" ]] || fail "StonePi needs 64-bit Raspberry Pi OS (found $ARCH). Re-flash with the 64-bit image." case "$MODEL" in *"Raspberry Pi 4"*|*"Raspberry Pi 5"*|*"Compute Module 4"*|*"Compute Module 5"*|*"Raspberry Pi 400"*|*"Raspberry Pi 500"*) ;; *) fail "StonePi supports Raspberry Pi 4 and 5 (found: $MODEL). Use --force to try anyway." ;; esac case "${VERSION_CODENAME:-}" in trixie|bookworm) ;; *) fail "Unsupported OS '${PRETTY_NAME:-unknown}'. Use Raspberry Pi OS (64-bit), Bookworm or Trixie." ;; esac fi # Installed means the release's installer ran to its last line, not that files were copied # (it copies /opt/stonepi/VERSION early). From 0.1.9 it writes /var/lib/stonepi/install-state: # "running" at the start, "complete" at the very end. Older releases: this bootstrap's own # INSTALLATION_COMPLETE mark, or the ready-check result their installer writes at its end. # A failed ready check also counts as unfinished: re-running the installer is the fix. install_finished() { [[ -f /opt/stonepi/VERSION ]] || return 1 local state ready state="$(head -n1 /var/lib/stonepi/install-state 2>/dev/null || true)" ready="$(head -n1 /var/lib/stonepi/ready-check 2>/dev/null || true)" [[ "$ready" == failed* ]] && return 1 if [[ -n "$state" ]]; then [[ "$state" == complete ]] return fi [[ "$(cat "$STATE_DIR/current" 2>/dev/null || true)" == INSTALLATION_COMPLETE || "$ready" == ok ]] } if [[ "$REINSTALL" -ne 1 ]]; then if install_finished; then echo echo "StonePi $(head -n1 /opt/stonepi/VERSION) is already installed." echo "Update it from Dashboard → Settings → Updates, or re-run with:" echo " curl -fsSL https://stonepi-install.vercel.app/install.sh | sudo bash -s -- --reinstall" exit 0 fi if [[ -f /opt/stonepi/VERSION ]]; then echo "An earlier StonePi install did not finish — picking it up again." fi fi mark OS_READY # ---------------------------------------------------------------- network STEP="Waiting for the network" say "$STEP" for i in $(seq 1 30); do if curl -fsS --max-time 5 -o /dev/null https://github.com; then break; fi [[ "$i" -eq 30 ]] && fail "Can't reach github.com. Check Wi-Fi/Ethernet, then retry." sleep 2 done command -v python3 >/dev/null || { apt-get update -y && apt-get install -y python3; } # ---------------------------------------------------------------- manifest STEP="Reading the StonePi release manifest" say "$STEP" curl -fsSL --retry 3 "$MANIFEST_URL" -o "$CACHE_DIR/manifest.json" \ || fail "Couldn't download $MANIFEST_URL" read_manifest() { python3 - "$CACHE_DIR/manifest.json" "$1" <<'PY' import json, sys data = json.load(open(sys.argv[1])) value = data for key in sys.argv[2].split("."): value = value.get(key, "") if isinstance(value, dict) else "" print(value if value is not None else "") PY } VERSION="$(read_manifest stonepi_version)" SRC_URL="$(read_manifest source.url)" SRC_SHA="$(read_manifest source.sha256)" SRC_ROOT="$(read_manifest source.root)" INSTALLER="$(read_manifest installer)" [[ -n "$VERSION" && -n "$SRC_URL" && -n "$SRC_ROOT" ]] || fail "Manifest is missing version/source fields." # The download is only trusted against the manifest's sha256. if [[ -z "$SRC_SHA" ]]; then if [[ "$SKIP_CHECKSUM" -eq 1 ]]; then echo "WARNING: manifest has no source.sha256 — installing an UNVERIFIED download (--insecure-skip-checksum)." else fail "Manifest for StonePi $VERSION has no source.sha256, so the download can't be verified. Re-run with --insecure-skip-checksum only if you trust $MANIFEST_URL." fi elif [[ ! "$SRC_SHA" =~ ^[0-9a-fA-F]{64}$ ]]; then fail "Manifest source.sha256 is not a SHA-256 hash: $SRC_SHA" fi INSTALLER="${INSTALLER:-deploy/install.sh}" echo "Release: StonePi $VERSION" # ---------------------------------------------------------------- download STEP="Downloading StonePi $VERSION" say "$STEP" ARCHIVE="$CACHE_DIR/stonepi-$VERSION.tar.gz" download() { curl -fL --retry 3 --progress-bar "$SRC_URL" -o "$ARCHIVE.part" || fail "Download failed: $SRC_URL" mv "$ARCHIVE.part" "$ARCHIVE" } checksum_ok() { if [[ -z "$SRC_SHA" ]]; then [[ "$SKIP_CHECKSUM" -eq 1 ]] return fi echo "$SRC_SHA $ARCHIVE" | sha256sum -c --quiet - >/dev/null 2>&1 } [[ -s "$ARCHIVE" ]] || download if ! checksum_ok; then # A cached copy from an earlier attempt (or a re-published release): fetch it again once. rm -f "$ARCHIVE" download checksum_ok || { rm -f "$ARCHIVE"; fail "Checksum mismatch for StonePi $VERSION — download discarded."; } fi SRC_DIR="$CACHE_DIR/src" rm -rf "$SRC_DIR" && mkdir -p "$SRC_DIR" tar -xzf "$ARCHIVE" -C "$SRC_DIR" TREE="$SRC_DIR/$SRC_ROOT" [[ -f "$TREE/$INSTALLER" ]] || fail "Release $VERSION has no $INSTALLER." # Windows line endings in a script ("set: pipefail\r: invalid option") would stop the install # on line 2; strip them from the release's scripts before running anything. find "$TREE" -type f \( -name '*.sh' -o -path '*/deploy/*.py' \) -exec sed -i 's/\r$//' {} + mark DOWNLOADED # ---------------------------------------------------------------- install STEP="Installing StonePi $VERSION (this takes 10–20 minutes)" say "$STEP" HOST="$(hostname -s 2>/dev/null || hostname)" bash "$TREE/$INSTALLER" --hostname "${HOST:-stonepi}" || fail "The StonePi installer stopped." # Releases before the installer's ready check (0.1.8) leave fresh installs with permission # problems the installer now fixes itself. Patch the ones fixable from outside the code; # skipped automatically once the installed release writes /var/lib/stonepi/ready-check. STEP="Finishing up" D=/var/lib/stonepi if [[ ! -f "$D/ready-check" ]] && id -u stonepi-dash >/dev/null 2>&1; then # SQLite side files left root-owned by the installer's migrations. for pair in auth:stonepi-auth dashboard:stonepi-dash notify:stonepi-notify newscast:stonepi-news \ fileserve:stonepi-files eventtrakr:stonepi-events pinboard:stonepi-pin studio:stonepi-studio \ pricescout:stonepi-prices sportguide:stonepi-sport pricewatch:stonepi-watch; do [[ -d "$D/${pair%%:*}" ]] && chown -R "${pair#*:}:${pair#*:}" "$D/${pair%%:*}" done # Health reads Notify's status files. usermod -aG stonepi-notify stonepi-dash 2>/dev/null || true [[ -d "$D/notify" ]] && chmod 750 "$D/notify" find "$D/notify" -maxdepth 1 -type f -name '*.json' -exec chmod 640 {} \; 2>/dev/null || true # Settings → Network writes the exposure flag. [[ -f "$D/exposure" ]] && chown stonepi-dash:stonepi-dash "$D/exposure" systemctl restart 'stonepi-*.service' >/dev/null 2>&1 || true fi mark INSTALLATION_COMPLETE trap - ERR # ---------------------------------------------------------------- done # The installer's ready check (next release onward) runs as each service user and writes its result. READY="$(cat /var/lib/stonepi/ready-check 2>/dev/null || echo unknown)" if [[ "$READY" == failed* ]]; then HEADLINE="StonePi is installed, but ${READY#failed } ready check(s) failed. Scroll up to \"Ready check\" for the FAIL lines, or run the same install command again." else HEADLINE="Your StonePi is ready." fi IP="$(hostname -I 2>/dev/null | awk '{print $1}')" # 0.1.9+ installers generate the first admin password and keep it in this root-only file. INITIAL_ADMIN=/etc/stonepi/initial-admin.txt ADMIN_USER="$(sed -n 's/^username: //p' "$INITIAL_ADMIN" 2>/dev/null | head -n1 || true)" ADMIN_PASS="$(sed -n 's/^password: //p' "$INITIAL_ADMIN" 2>/dev/null | head -n1 || true)" if [[ -n "$ADMIN_PASS" ]]; then SIGN_IN="Sign in as ${ADMIN_USER:-admin} with password ${ADMIN_PASS} and change it straight away under Settings → General → Your password. (Saved for root in ${INITIAL_ADMIN}: sudo cat ${INITIAL_ADMIN})" else SIGN_IN="Sign in with admin / admin and change the password straight away under Settings → General → Your password." fi # Recover (emergency console) has its own login, separate from the admin account. RECOVER_PASSWD=/etc/stonepi/recover.passwd RECOVER_PASS="$(cut -d: -f2- "$RECOVER_PASSWD" 2>/dev/null | head -n1 || true)" RECOVER_IN="" if [[ -n "$RECOVER_PASS" ]]; then RECOVER_IN=" Recover (emergency console, if the Dashboard is down): http://${HOST}.local/recover/ Sign in as stonepi with password ${RECOVER_PASS} This is separate from your admin password. Change it under Settings → Vault → Recover password. (Saved for root in ${RECOVER_PASSWD}: sudo cut -d: -f2- ${RECOVER_PASSWD}) " fi cat <}/ ${SIGN_IN} ${RECOVER_IN} ───────────────────────────────────────────── DONE