#!/usr/bin/env bash # install-localsend.sh — Install LocalSend from the latest official GitHub release. # Hardened: arch-aware, GitHub API token support, SHA-256 verification from the # release asset digest, idempotent, --dry-run. set -euo pipefail IFS=$'\n\t' readonly SCRIPT_NAME="${0##*/}" DRY_RUN=0 REPO="localsend/localsend" usage() { cat <&2; } die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR while (( $# )); do case "$1" in --dry-run) DRY_RUN=1 ;; -h|--help) usage; exit 0 ;; *) die "Unknown argument: $1 (try --help)" ;; esac shift done (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" [[ -r /etc/os-release ]] || die "/etc/os-release not found." # shellcheck disable=SC1091 . /etc/os-release case "${ID:-}:${ID_LIKE:-}" in *ubuntu*|*debian*) : ;; *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; esac ARCH="$(dpkg --print-architecture)" case "$ARCH" in amd64) ASSET_ARCH="x86-64" ;; arm64) ASSET_ARCH="arm-64" ;; *) die "LocalSend publishes Linux .deb assets for amd64 and arm64 only (detected: $ARCH)." ;; esac log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH" export DEBIAN_FRONTEND=noninteractive log "Installing prerequisites..." run "apt-get update -qq" run "apt-get install -y curl jq ca-certificates" GH_HDRS=(-H "Accept: application/vnd.github+json") [[ -n "${GITHUB_TOKEN:-}" ]] && GH_HDRS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") log "Querying GitHub API for latest release of $REPO..." RELEASE_JSON="$(curl -fsSL "${GH_HDRS[@]}" "https://api.github.com/repos/${REPO}/releases/latest")" TAG="$(jq -r '.tag_name // empty' <<<"$RELEASE_JSON")" [[ -n "$TAG" ]] || die "Could not parse latest release tag (rate limited? set GITHUB_TOKEN)." log "Latest release: $TAG" ASSET_REGEX="^LocalSend-[^-]+-linux-${ASSET_ARCH}\\.deb$" DOWNLOAD_URL="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)" ASSET_NAME="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .name' <<<"$RELEASE_JSON" | head -n1)" EXPECTED_SHA="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | (.digest // "") | sub("^sha256:"; "")' <<<"$RELEASE_JSON" | head -n1)" [[ "$DOWNLOAD_URL" =~ ^https:// ]] || die "No $ARCH .deb asset found in latest LocalSend release." STAGE="$(mktemp -d -t localsend.XXXXXX)" trap 'rm -rf "$STAGE"' EXIT DEB="$STAGE/$ASSET_NAME" log "Downloading $ASSET_NAME..." run "curl -fL -o '$DEB' '$DOWNLOAD_URL'" if (( DRY_RUN )); then [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "null" ]] \ && printf ' DRY-RUN: verify SHA-256 %s\n' "$EXPECTED_SHA" \ || printf ' DRY-RUN: skip SHA-256 verification; no GitHub asset digest found\n' printf ' DRY-RUN: apt-get install -y %s\n' "$DEB" log "Done." exit 0 fi if [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "null" ]]; then log "Verifying SHA-256..." ACTUAL_SHA="$(sha256sum "$DEB" | awk '{print $1}')" [[ "$EXPECTED_SHA" == "$ACTUAL_SHA" ]] || die "SHA-256 mismatch: expected=$EXPECTED_SHA actual=$ACTUAL_SHA" log "SHA-256 ok." else warn "No SHA-256 asset digest in GitHub release metadata; skipping verification." fi log "Installing LocalSend..." run "apt-get install -y '$DEB'" if command -v localsend_app >/dev/null 2>&1; then log "Installed: $(localsend_app --version 2>/dev/null || echo "$TAG")" else log "Installed: $TAG" fi log "Done."