LibrePortal/install.sh
librelad 5700f78c6b feat(release): minisign signature signing + verification
The sha256 only proves a download is intact; a compromised host could swap the
tarball + its checksum. Add minisign signatures, which prove authenticity (the host
can't forge them without the offline secret key). Ships INACTIVE behind a REPLACE_ME
placeholder, so installs work until a real key is generated; then it's REQUIRED.

- make_release.sh: signs the tarball when LP_MINISIGN_SECKEY is set -> <tarball>.minisig.
- libreportal.pub: the public key (placeholder), ships in the tarball and is installed
  to the ROOT-OWNED footprint (/usr/local/lib/libreportal/libreportal.pub) by init.sh
  -> the manager can't swap it to accept forged updates. footprint_version -> 2.
- install.sh: LP_MINISIGN_PUBKEY constant; once non-placeholder, downloads + verifies
  the .minisig (minisign -P) and REFUSES on invalid/missing (auto-installs minisign if
  needed). --no-verify-signature is a dev-only escape hatch.
- fetch.sh (update path): verifies against the footprint .pub (minisign -p), refuses on
  invalid/missing.
- docs/DEVELOPMENT.md: keygen (minisign -G), paste pubkey into libreportal.pub +
  install.sh, keep the secret key offline, sign builds via LP_MINISIGN_SECKEY, bump
  footprint_version on key rotation.

Verified end-to-end with a real throwaway key: good signature accepted; tampered,
wrong-key, and missing-signature all refused; placeholder skips (sha256 still enforced).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-25 19:40:30 +01:00

201 lines
9.6 KiB
Bash

#!/usr/bin/env bash
#
# LibrePortal bootstrap installer.
#
# curl -fsSL https://get.libreportal.org/install.sh | sudo bash
# curl -fsSL https://get.libreportal.org/install.sh | sudo bash -s -- \
# --system-dir=/mnt/ssd/lp --backups-dir=/mnt/usb/lp-backups --manager-user=admin
#
# Fetches a versioned, checksum-verified release tarball over plain HTTPS (no git,
# no auth), extracts it to the system root, and hands off to init.sh. Git/local
# remain available for development. Self-contained on purpose — it's downloaded and
# run on its own, before any LibrePortal code exists on the box.
set -euo pipefail
# --- defaults ---------------------------------------------------------------
CHANNEL="stable"
VERSION_PIN=""
MODE="release" # release | local | git
LOCAL_SRC=""
GIT_URL="" ; GIT_USER="" ; GIT_TOKEN=""
RELEASE_BASE_URL="${LP_RELEASE_BASE_URL:-https://get.libreportal.org}"
PASSWORD="${LP_PASSWORD:-}"
SYSTEM_DIR="" ; CONTAINERS_DIR="" ; BACKUPS_DIR="" ; MANAGER_USER="" ; ALLOW_HOME=""
DRY_RUN=0
NO_VERIFY_SIG=0
# minisign public key. Keep the SECRET key offline. Once you run `minisign -G`,
# paste the public key here AND into libreportal.pub. While it contains REPLACE_ME,
# signature verification is skipped (the sha256 still runs); once replaced, a valid
# signature becomes REQUIRED for release installs.
LP_MINISIGN_PUBKEY="RWREPLACE_ME_run_minisign_-G_then_paste_the_public_key_here_and_in_install.sh"
usage() {
sed -n '3,12p' "$0" | sed 's/^# \{0,1\}//'
cat <<'EOF'
Options:
--channel=stable|edge Release channel (default stable)
--version=X.Y.Z Pin a specific version (else latest in channel)
--system-dir=PATH Manager-owned control plane (default /libreportal-system)
--containers-dir=PATH Live app data (default /libreportal-containers)
--backups-dir=PATH Backup repos (default /libreportal-backups)
--manager-user=NAME Control-plane user (default libreportal)
--allow-home Permit containers/backups inside /home/<user>
--password=PASS Manager/WebUI password (else a random one is generated)
--base-url=URL Override the release host (also: LP_RELEASE_BASE_URL)
--local=PATH Dev: install from a local source folder
--git-url=URL Dev: install by cloning a git repo
--git-user=U --git-token=T Dev: git credentials
--dry-run Fetch + verify + stage, but don't install
EOF
}
# --- parse flags ------------------------------------------------------------
for arg in "$@"; do
case "$arg" in
--channel=*) CHANNEL="${arg#*=}" ;;
--version=*) VERSION_PIN="${arg#*=}" ;;
--system-dir=*) SYSTEM_DIR="${arg#*=}" ;;
--containers-dir=*) CONTAINERS_DIR="${arg#*=}" ;;
--backups-dir=*) BACKUPS_DIR="${arg#*=}" ;;
--manager-user=*) MANAGER_USER="${arg#*=}" ;;
--allow-home) ALLOW_HOME="--allow-home" ;;
--password=*) PASSWORD="${arg#*=}" ;;
--base-url=*) RELEASE_BASE_URL="${arg#*=}" ;;
--local=*) MODE="local"; LOCAL_SRC="${arg#*=}" ;;
--git-url=*) MODE="git"; GIT_URL="${arg#*=}" ;;
--git-user=*) GIT_USER="${arg#*=}" ;;
--git-token=*) GIT_TOKEN="${arg#*=}" ;;
--dry-run) DRY_RUN=1 ;;
--no-verify-signature) NO_VERIFY_SIG=1 ;;
-h|--help) usage; exit 0 ;;
*) echo "install.sh: unknown option '$arg' (try --help)" >&2; exit 2 ;;
esac
done
err() { echo "install.sh: $*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || err "missing required tool: $1"; }
[[ $DRY_RUN -eq 1 || $EUID -eq 0 ]] || err "must run as root (try: sudo). Use --dry-run to test fetch only."
# Resolve the system root (honour env; the default matches paths.sh).
SYSTEM_DIR="${SYSTEM_DIR:-${LP_SYSTEM_DIR:-/libreportal-system}}"
INSTALL_DIR="$SYSTEM_DIR/install"
need tar
if command -v curl >/dev/null 2>&1; then DL=curl
elif command -v wget >/dev/null 2>&1; then DL=wget
else err "need curl or wget"; fi
SHACMD=""
command -v sha256sum >/dev/null 2>&1 && SHACMD="sha256sum"
[[ -z "$SHACMD" ]] && command -v shasum >/dev/null 2>&1 && SHACMD="shasum -a 256"
[[ -n "$SHACMD" || "$MODE" != "release" ]] || err "need sha256sum or shasum to verify the release"
fetch() { # fetch <url> [outfile] (outfile omitted -> stdout)
if [[ "$DL" == curl ]]; then
[[ -n "${2:-}" ]] && curl -fsSL "$1" -o "$2" || curl -fsSL "$1"
else
[[ -n "${2:-}" ]] && wget -qO "$2" "$1" || wget -qO- "$1"
fi
}
json_str() { printf '%s' "$1" | grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed -E 's/.*"([^"]*)"$/\1/'; }
STAGE="$(mktemp -d)"; trap 'rm -rf "$STAGE"' EXIT
SRC=""
# --- obtain the code --------------------------------------------------------
case "$MODE" in
release)
local_ver="$VERSION_PIN" tarname="" want_sha=""
if [[ -z "$local_ver" ]]; then
echo "Resolving $CHANNEL channel from $RELEASE_BASE_URL ..."
manifest="$(fetch "$RELEASE_BASE_URL/$CHANNEL/latest.json")" || err "cannot fetch channel manifest"
local_ver="$(json_str "$manifest" version)"
tarname="$(json_str "$manifest" url)"
want_sha="$(json_str "$manifest" sha256)"
fi
[[ -n "$local_ver" ]] || err "could not determine version"
[[ -n "$tarname" ]] || tarname="libreportal-${local_ver}.tar.gz"
tarurl="$RELEASE_BASE_URL/$CHANNEL/$tarname"
echo "Downloading $tarurl ..."
fetch "$tarurl" "$STAGE/$tarname" || err "download failed"
if [[ -z "$want_sha" ]]; then
fetch "$tarurl.sha256" "$STAGE/$tarname.sha256" || err "no checksum available for $tarname"
want_sha="$(cut -d' ' -f1 < "$STAGE/$tarname.sha256")"
fi
got_sha="$($SHACMD "$STAGE/$tarname" | cut -d' ' -f1)"
[[ "$got_sha" == "$want_sha" ]] || err "CHECKSUM MISMATCH — refusing to install. expected $want_sha, got $got_sha"
echo "✓ checksum verified ($got_sha)"
# Signature: once the public key is real (not the REPLACE_ME placeholder), a
# valid minisign signature is REQUIRED — it survives a compromised host (the
# checksum alone doesn't, since the host serves it too). --no-verify-signature
# is a dev escape hatch only.
if [[ "$LP_MINISIGN_PUBKEY" != *REPLACE_ME* && "$NO_VERIFY_SIG" -ne 1 ]]; then
command -v minisign >/dev/null 2>&1 || { echo "Installing minisign to verify the release ..."; apt-get install -y minisign >/dev/null 2>&1 || true; }
command -v minisign >/dev/null 2>&1 || err "minisign required to verify the release but couldn't be installed"
fetch "$tarurl.minisig" "$STAGE/$tarname.minisig" || err "release signature (.minisig) missing — refusing"
# -P takes the public key as a string (our inline constant is just the
# key line, not a full 2-line pubkey file).
minisign -Vm "$STAGE/$tarname" -P "$LP_MINISIGN_PUBKEY" -x "$STAGE/$tarname.minisig" >/dev/null 2>&1 \
|| err "SIGNATURE INVALID — refusing to install (the release isn't authentic)"
echo "✓ signature verified"
fi
mkdir -p "$STAGE/x"
tar xzf "$STAGE/$tarname" -C "$STAGE/x" --strip-components=1
SRC="$STAGE/x"
;;
local)
[[ -d "$LOCAL_SRC" ]] || err "local source folder not found: $LOCAL_SRC"
SRC="$LOCAL_SRC"
;;
git)
need git
[[ -n "$GIT_URL" ]] || err "--git-url is required for a git install"
local_url="$GIT_URL"
[[ -n "$GIT_USER" ]] && local_url="${GIT_URL%://*}://${GIT_USER}:${GIT_TOKEN}@${GIT_URL#*://}"
git clone -q "$local_url" "$STAGE/x" || err "git clone failed"
SRC="$STAGE/x"
;;
esac
[[ -f "$SRC/init.sh" && -d "$SRC/scripts" ]] || err "fetched tree is not a LibrePortal source (missing init.sh/scripts)"
if [[ $DRY_RUN -eq 1 ]]; then
echo "[dry-run] verified $MODE source at: $SRC"
echo "[dry-run] would copy -> $INSTALL_DIR and run init.sh with:"
echo " system=$SYSTEM_DIR containers=${CONTAINERS_DIR:-(default)} backups=${BACKUPS_DIR:-(default)} manager=${MANAGER_USER:-(default)} $ALLOW_HOME"
exit 0
fi
# --- place the code + hand off to init.sh -----------------------------------
[[ -n "$PASSWORD" ]] || { PASSWORD="$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | cut -c1-20)"; GEN_PW=1; }
mkdir -p "$INSTALL_DIR"
cp -a "$SRC/." "$INSTALL_DIR/"
cp -f "$INSTALL_DIR/init.sh" /root/ 2>/dev/null || true
flags=( --unattended )
[[ -n "$SYSTEM_DIR" ]] && flags+=( "--system-dir=$SYSTEM_DIR" )
[[ -n "$CONTAINERS_DIR" ]] && flags+=( "--containers-dir=$CONTAINERS_DIR" )
[[ -n "$BACKUPS_DIR" ]] && flags+=( "--backups-dir=$BACKUPS_DIR" )
[[ -n "$MANAGER_USER" ]] && flags+=( "--manager-user=$MANAGER_USER" )
[[ -n "$ALLOW_HOME" ]] && flags+=( "$ALLOW_HOME" )
# Positional contract: init <password> <git_user> <git_token> <git_url> <unattended> <install_mode>
case "$MODE" in
git) set -- init "$PASSWORD" "${GIT_USER:-empty}" "${GIT_TOKEN:-empty}" "$GIT_URL" true git ;;
local) set -- init "$PASSWORD" empty empty empty true local ;;
*) set -- init "$PASSWORD" empty empty empty true release ;;
esac
[[ "${GEN_PW:-0}" == 1 ]] && echo "Generated manager/WebUI password: $PASSWORD (also recorded in the install log)"
echo "Running installer ..."
cd "$INSTALL_DIR"
# Tell init.sh the code is already staged + verified, so initGIT skips re-fetching.
export LP_ALREADY_FETCHED=1
export LP_RELEASE_BASE_URL # carry the (possibly overridden) host through
exec bash "$INSTALL_DIR/init.sh" "${flags[@]}" "$@"