Files
bluebluesoda b7ebdb5374 fix(install): bound stalled downloads and tighten prebuild checksums
- dl_content no longer runs with no timeout at all: every attempt carries
  --connect-timeout, and a content transfer that connects then crawls below
  1 KiB/s for 30s is aborted so the next try can take over (a slow but moving
  download is still never cut off by a total cap)
- verify the release binary by looking up its SHA256SUMS entry explicitly
  instead of `sha256sum -c --ignore-missing`, which quietly succeeded when the
  manifest held no line for the arch
- copy the local vps-prebuild instead of moving it, so a later failure cannot
  destroy the only snapshot along with the temp dir
- fail loudly when lib-download.sh is missing instead of degrading silently
- drop the unused dl_handshake helper, accept binary-mode `*asset` lines, and
  lower-case v4_forward in 30-haproxy so True/TRUE match like 00-ip-ask
2026-09-27 12:16:30 +00:00

296 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# 40-panel.sh — install vpsmgr binary and initialize panel (config/cert/db/systemd).
# Binary source: prebuilt GitHub release by default, local Go build as fallback
# (or VPSMGR_BUILD_MODE=local via --local-build; VPSMGR_BUILD_MODE=update via
# --update forces a fresh prebuilt download over an existing binary).
set -uo pipefail
log(){ echo "[40] $*"; }
die(){ echo "[40] error: $*" >&2; exit 1; }
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LIB_DOWNLOAD="$SCRIPT_DIR/lib-download.sh"
[[ -f "$LIB_DOWNLOAD" ]] || die "missing $LIB_DOWNLOAD (incomplete checkout?)"
# shellcheck source=lib-download.sh
source "$LIB_DOWNLOAD"
REPO="bluebluesoda/vpsmgr"
ensure_go(){
# Reuse a usable go on PATH (>= 1.21, toolchain auto-switch works) when one
# already exists; otherwise install the official Go from go.dev directly.
# No distro package: Debian 12's golang-go is 1.19 (can't even parse
# go.mod's `toolchain` directive) and the official tarball is one curl away.
#
# Some hosts resolve go.dev/dl through an IPv6 path that returns 404 for the
# download files (the site and manifest work, the tarball CDN does not), so
# every fetch below first tries the default address family and falls back to
# forcing IPv4 (`curl -4`) on failure.
if command -v go >/dev/null 2>&1; then
if GO_VER=$(go version 2>/dev/null | awk '{print $3}' | sed 's/^go//'); then
major=${GO_VER%%.*}; minor=${GO_VER#*.}; minor=${minor%%.*}
if (( major > 1 || (major == 1 && minor >= 21) )); then
log "go $GO_VER is new enough (>= 1.21, toolchain auto-switch works)"
return 0
fi
log "go $GO_VER on PATH is too old (< 1.21) — installing official Go"
fi
fi
# go.mod requires go >= 1.21 (it pins `toolchain go1.26.5` and relies on
# auto toolchain download). Install the official Go from go.dev into
# /usr/local/go and put it first on PATH for this installer's shell.
log "installing official Go to /usr/local/go..."
local arch tarball url
case "$(uname -m)" in
x86_64) arch=amd64 ;;
aarch64) arch=arm64 ;;
*) die "no official Go for arch $(uname -m)" ;;
esac
# curl_retry: fetch a URL, trying the default address family first (works on
# normal hosts and IPv6-first ones) and falling back to forcing IPv4 when the
# IPv6 path is broken. Args: <max-time> <url> <outfile>.
curl_retry(){
local max_time="$1" url="$2" out="$3"
if curl -fsSL --max-time "$max_time" -o "$out" "$url" 2>/dev/null; then
return 0
fi
rm -f "$out"
log " IPv6 path failed, retrying via IPv4: $url"
curl -4 -fsSL --max-time "$max_time" -o "$out" "$url" 2>/dev/null
}
# Read the official stable-download manifest instead of trusting the
# VERSION endpoint alone. During a new release, VERSION can briefly name a
# version whose architecture tarball is not available through every CDN
# edge yet, which otherwise turns a transient 404 into a failed install.
local candidates candidate filename manifest
manifest="/tmp/go-manifest-$$.json"
if curl_retry 30 "https://go.dev/dl/?mode=json" "$manifest"; then
mapfile -t candidates < <(python3 -c 'import json,sys
try:
releases=json.load(open(sys.argv[1]))
except Exception:
sys.exit(1)
for release in releases:
for f in release.get("files", []):
if f.get("os") == "linux" and f.get("arch") == sys.argv[2] and f.get("kind") == "archive":
print(release["version"] + " " + f["filename"])
break' "$manifest" "$arch")
rm -f "$manifest"
fi
# Keep a known previous toolchain as a last-resort candidate if the manifest
# endpoint is temporarily unavailable. The download loop below still retries
# every candidate before failing.
if [[ ${#candidates[@]} -eq 0 ]]; then
candidates=("go1.26.5 go1.26.5.linux-${arch}.tar.gz")
fi
local downloaded=0
for candidate in "${candidates[@]}"; do
filename="${candidate#* }"
tarball="/tmp/${filename}"
url="https://go.dev/dl/${filename}"
for attempt in 1 2 3; do
log " downloading $url (attempt $attempt/3)"
if curl_retry 300 "$url" "$tarball"; then
downloaded=1
break 2
fi
rm -f "$tarball"
done
log " warn: $filename was unavailable; trying the previous stable release"
done
[[ "$downloaded" -eq 1 ]] || return 1
rm -rf /usr/local/go && tar -C /usr/local -xzf "$tarball" || return 1
rm -f "$tarball"
export PATH="/usr/local/go/bin:$PATH"
# Remember that WE installed this toolchain so build_local can remove it
# (plus all build caches) right after the binary is produced.
export GO_FROM_OFFICIAL=1
command -v go >/dev/null 2>&1
}
# cleanup_go removes the go toolchain and every build cache (compiler cache,
# module cache including the auto-downloaded go1.26.5 toolchain) when the
# installer brought the toolchain itself. go is only needed to produce the
# binary — once bin/vps is in place it is multi-hundred-MB dead weight on a
# small host. If go pre-existed on the host it is left untouched.
cleanup_go(){
[[ "${GO_FROM_OFFICIAL:-}" == "1" ]] || return 0
log "removing go toolchain and build caches..."
if command -v go >/dev/null 2>&1; then
local modcache buildcache
modcache=$(go env GOMODCACHE 2>/dev/null || echo /root/go/pkg/mod)
buildcache=$(go env GOCACHE 2>/dev/null || echo /root/.cache/go-build)
rm -rf /usr/local/go "$modcache" "$buildcache"
else
rm -rf /usr/local/go /root/go/pkg/mod /root/.cache/go-build
fi
unset GO_FROM_OFFICIAL
log "go toolchain removed; host left clean"
}
# stop_holders stops every vpsmgr unit that keeps /usr/local/bin/vps mapped
# (running), so the binary can be replaced without ETXTBSY ("Text file busy").
# vps.service is the panel; on the dev-ndp branch vps-ipv6.service is a
# long-lived `vps ipv6-proxy` process that ALSO maps the binary — stopping only
# the panel leaves it holding the file and cp then fails. Unconditional:
# stopping a unit that is not present is a harmless no-op.
stop_holders(){
systemctl stop vps.service >/dev/null 2>&1 || true
systemctl stop vps-ipv6.service >/dev/null 2>&1 || true
log "stopped services holding the vps binary"
}
build_local(){
ensure_go || die "go toolchain install failed"
log "building vpsmgr from source..."
bash "$ROOT/build.sh" || die "build failed"
# Replacing a running binary fails with ETXTBSY ("Text file busy") on an
# adopted install where a vpsmgr unit is already active. Stop the services
# that map the binary first, then copy, then let the installer re-enable.
stop_holders
if ! cp "$ROOT/bin/vps" /usr/local/bin/vps; then
die "could not copy built binary to /usr/local/bin/vps"
fi
chmod 755 /usr/local/bin/vps
log "installed /usr/local/bin/vps from source ($(/usr/local/bin/vps version))"
# The toolchain was only needed to produce the binary — drop it and every
# build cache now that bin/vps is in place (no-op when go pre-existed).
cleanup_go
}
install_prebuilt(){
local arch dir bin_url sum_url sums want got local_pre
case "$(uname -m)" in
x86_64) arch=amd64 ;;
aarch64) arch=arm64 ;;
*) log "warn: no prebuilt binary for $(uname -m), falling back to local build"; return 1 ;;
esac
dir="$(mktemp -d /tmp/vpsmgr-dl.XXXXXX)"
# EXIT trap for cleanup. Must NOT reference the local $dir: the trap string
# is evaluated when the script EXITS, after the function returned and $dir
# went out of scope — under `set -u` that would raise "unbound variable"
# (and skip the cleanup) at the very end of a successful install. Globbing
# the unique mktemp prefix is local-agnostic and only ever matches this dir.
trap 'rm -rf /tmp/vpsmgr-dl.*' EXIT
bin_url="https://github.com/$REPO/releases/latest/download/vps-$arch"
sum_url="https://github.com/$REPO/releases/latest/download/SHA256SUMS"
log "downloading prebuilt vpsmgr (linux/$arch) from GitHub releases..."
log " $bin_url"
# Prefer a local vps-prebuild snapshot if its checksum matches the
# release manifest; otherwise download the release binary.
local_pre="${VPSMGR_PREBUILD_LOCAL:-/root/vps-prebuild}"
sums="$dir/SHA256SUMS"
if ! dl_meta "$sum_url" "$sums"; then
log "warn: could not fetch checksums — checksum verification skipped"
sums=""
fi
if [[ -f "$local_pre" && -s "$sums" ]]; then
want=$(dl_release_sha "$sums" "vps-$arch" 2>/dev/null || true)
got=$(sha256sum "$local_pre" | awk '{print $1}')
if [[ -n "$want" && "$want" == "$got" ]]; then
# Copy, not move: the prebuild is the only copy on the box, so a later
# failure must not take it with the temp dir.
cp "$local_pre" "$dir/vps-$arch"
chmod 755 "$dir/vps-$arch"
log "using local vps-prebuild ($local_pre, checksum matches latest release)"
else
log "local vps-prebuild checksum mismatch — downloading the release binary"
fi
elif [[ -f "$local_pre" && ! -s "$sums" ]]; then
log "warn: could not fetch checksums, skipping local vps-prebuild"
fi
# Real content download: up to 10 tries, no total cap (a big binary may take
# a while) but a stalled transfer is aborted so the retry can take over.
if [[ ! -s "$dir/vps-$arch" ]]; then
dl_content "$bin_url" "$dir/vps-$arch" \
|| { log "warn: binary download failed"; rm -rf "$dir"; return 1; }
fi
# Checksums are mandatory whenever the manifest was fetched. Look the entry up
# explicitly: `sha256sum -c --ignore-missing` would quietly succeed when the
# manifest has no line for this arch, which is worse than not checking at all.
if [[ -s "$sums" ]]; then
want=$(dl_release_sha "$sums" "vps-$arch" 2>/dev/null || true)
got=$(sha256sum "$dir/vps-$arch" | awk '{print $1}')
if [[ -z "$want" || "$want" != "$got" ]]; then
log "warn: checksum mismatch or no entry for vps-$arch (corrupt download?), falling back to local build"
rm -rf "$dir"
return 1
fi
else
log "warn: no checksum manifest available — skipping checksum verification"
fi
# Replace a running binary (ETXTBSY): stop the units that map it first,
# then copy, then let the installer re-enable them below. This runs only
# after the download verified, so a failed download never takes the panel
# down.
stop_holders
cp "$dir/vps-$arch" /usr/local/bin/vps
chmod 755 /usr/local/bin/vps
log "installed /usr/local/bin/vps from release ($(/usr/local/bin/vps version))"
}
install_completions(){
# Bash tab completion for the `vps` CLI: `vps config set <TAB>` completes
# config keys (read live from `vps config completions` so they match the
# registry), plus top-level commands and container names. Installed into the
# Debian/Ubuntu bash-completion load path; sourced automatically in a new
# shell. Non-fatal: if the dir is unavailable the binary still works.
local dest="/usr/share/bash-completion/completions/vps"
local src="$ROOT/configs/completions/vps.bash"
if ! install -d -m 0755 "$(dirname "$dest")" 2>/dev/null; then
log "warn: could not create bash-completion dir; skipping CLI completion"
return 0
fi
if cp "$src" "$dest" && chmod 644 "$dest"; then
log "installed shell completion: $dest"
else
log "warn: could not install shell completion to $dest"
fi
}
if [[ "${VPSMGR_BUILD_MODE:-}" == "local" ]]; then
# --local-build always rebuilds from this repo (never reuse an installed
# stable binary), so what runs is exactly what the shown branch compiled.
log "local build requested (--local-build)"
build_local
elif [[ "${VPSMGR_BUILD_MODE:-}" == "update" ]]; then
# --update forces a re-download of the latest prebuilt release over whatever
# is installed. Conservative: on a download/checksum failure the existing
# binary is kept (it was never touched), not replaced by a local build.
log "update requested (--update) — replacing the installed binary with the latest prebuilt release"
if ! install_prebuilt; then
log "warn: prebuilt update failed — keeping the installed binary ($(/usr/local/bin/vps version))"
fi
elif [[ -x /usr/local/bin/vps ]]; then
log "vps already installed, skipping ($(/usr/local/bin/vps version))"
else
if install_prebuilt; then
:
else
log "prebuilt install failed — falling back to local build"
build_local
fi
fi
install_completions
log "running vps install (config/cert/db/nft/systemd)..."
# Capture the install output so the one-time admin password printed by a fresh
# `vps install` can be re-shown at the very end of the main installer. The
# file lives under /etc/vpsmgr (root-only) with 0600, NOT a fixed /tmp path a
# local attacker could pre-create or read (review P2-9 — it carries a one-time
# admin password).
INSTALL_OUT=/etc/vpsmgr/.last-install.out
rm -f "$INSTALL_OUT"
# `vps install` creates /etc/vpsmgr itself — make sure it exists before tee
# opens the capture file, or the pipeline fails on a fresh host.
install -d -m 0755 /etc/vpsmgr
if /usr/local/bin/vps install 2>&1 | tee "$INSTALL_OUT"; then
chmod 600 "$INSTALL_OUT"
else
rm -f "$INSTALL_OUT"
exit 1
fi
echo "[40] panel ready"