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>
201 lines
9.6 KiB
Bash
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[@]}" "$@"
|