Files
bluebluesodaandCommandCodeBot 26015c1fe9 fix(install): identify the Debian archive by keyring, not by hostname
- The first cut matched the literal host deb.debian.org, which a Debian 13
  netinst never contains: it describes the archive as
  "URIs: mirror+file:///etc/apt/mirrors/debian.list" with
  "Components: main". So contrib was never appended and
  "apt-get install zfsutils-linux" still reported "no installation
  candidate".
- A stanza/line now counts as Debian when its Signed-By names the Debian
  archive keyring, or when it has none (the default trusted keys). A
  third-party repo signs with its own keyring and is left alone. The
  deb822 case is parsed per stanza (awk, mawk-compatible) so a mixed file
  is handled correctly.
- Verified on a Debian 13 netinst: after the edit, apt-get update succeeds
  and zfsutils-linux / zfs-dkms resolve from trixie/contrib.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-10-07 05:05:48 +00:00

438 lines
20 KiB
Bash
Executable File

#!/usr/bin/env bash
# 00-check.sh — environment sanity checks.
set -uo pipefail
log(){ echo "[00] $*"; }
die(){ echo "[00] error: $*" >&2; exit 1; }
if [[ $EUID -ne 0 ]]; then die "must run as root"; fi
# --- distro ---
if [[ ! -f /etc/os-release ]]; then die "cannot find /etc/os-release"; fi
. /etc/os-release
case "${ID:-}:${VERSION_ID:-}" in
debian:12|debian:13) ;;
ubuntu:22.04|ubuntu:24.04|ubuntu:26.04) ;;
*) die "this installer targets Debian 12/13 or Ubuntu 22.04/24.04/26.04 (got ${PRETTY_NAME:-unknown})" ;;
esac
# --- virtualization (require physical or KVM) ---
if command -v systemd-detect-virt >/dev/null 2>&1; then
VIRT=$(systemd-detect-virt)
case "$VIRT" in
openvz|lxc|lxc-libvirt|wsl|container|vm-other|podman|docker)
die "unsupported environment: '$VIRT'. Need a physical machine or KVM VM."
;;
esac
log "virtualization: ${VIRT:-none (physical)}"
fi
# --- architecture ---
ARCH=$(uname -m)
case "$ARCH" in
x86_64) GOARCH=amd64 ;;
aarch64) GOARCH=arm64 ;;
*) die "unsupported architecture: $ARCH (only amd64 / arm64 are supported)" ;;
esac
log "architecture: $ARCH ($GOARCH)"
# --- hardware (warn only) ---
CPUS=$(nproc 2>/dev/null || echo 0)
MEM_KB=$(awk '/MemTotal/{print $2}' /proc/meminfo)
MEM_GB=$(( MEM_KB / 1024 / 1024 ))
log "cpu: ${CPUS} cores, mem: ${MEM_GB} GiB"
[[ $CPUS -lt 2 ]] && log " warn: < 2 CPU may be slow"
[[ $MEM_GB -lt 2 ]] && log " warn: < 2 GiB RAM is tight"
# --- disk ---
FREE_KB=$(df -k --output=avail / | tail -1 | tr -d ' ')
log "free disk on /: $(( FREE_KB / 1024 )) MiB"
if [[ $FREE_KB -lt 5*1024*1024 ]]; then
log "warn: need at least 5 GiB free on /, only $(( FREE_KB / 1024 / 1024 )) GiB available"
read -r -p "force continue anyway? [y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || die "aborted: not enough free disk on /"
fi
# --- swap ---
# Swap is enforced up front in install.sh (before the IPv6 prompts), so it is
# not handled here. An existing swap is left alone; a missing swap aborts the
# install there.
# --- storage backend ---
# zfs (default), btrfs, or dir, chosen by install.sh / VPSMGR_STORAGE. zfs and
# btrfs both provide quotas/snapshots/clone-on-create; dir has none and is
# meant for throwaway test boxes only.
STORAGE="${VPSMGR_STORAGE:-zfs}"
case "$STORAGE" in
zfs|btrfs|dir) ;;
*) die "VPSMGR_STORAGE must be zfs, btrfs or dir (got '$STORAGE')" ;;
esac
log "storage backend: $STORAGE"
# --- packages ---
# Debian minimal images are notoriously bare (sometimes even curl is missing),
# so every tool the installer needs is ensured here, before any later step
# depends on it:
# - zfsutils-linux: Incus does NOT bundle the ZFS userspace tools; the
# storage pool is ZFS-only by default, so zpool/zfs must be present or
# pool creation fails (skipped in dir mode — no kernel module needed)
# - matching kernel headers (plus build-essential): Debian only. The
# linux-headers-amd64 meta package is for the normal amd64 kernel; cloud
# kernels need linux-headers-cloud-amd64 so DKMS can build for the kernel
# that is actually running. Ubuntu ships the ZFS module PREBUILT inside
# the kernel (linux-modules), so it needs none of the DKMS toolchain — no
# compile time at all.
# - ca-certificates: without it every curl HTTPS call (Zabbly key, the
# HAProxy repository, image pulls) fails with a certificate error
# - python3: used by 00-ip-ask.sh (prefix/subnet validation) and the
# check-ipv6-support.sh probe
# - tar/xz-utils: archive extraction; curl: downloads; gpg: Zabbly key and
# HAProxy repository key verification; nftables/zstd: firewall + Incus image
# compression
# - sudo: hard requirement — the panel runs unprivileged and escalates ONLY
# the sudoers whitelist commands via `sudo -n`. Without it every
# privileged operation (nft reload, haproxy/systemctl, IPv6 wiring, ndppd)
# fails at runtime. visudo ships with the sudo package.
KERNEL_REL=$(uname -r)
BASE_DEPS="sudo ca-certificates python3 tar xz-utils nftables zstd curl gpg"
# zfsutils-linux and zfs-dkms live in Debian's 'contrib' component (ZFS's
# CDDL license is not DFSG-free), but a minimal Debian install enables only
# 'main' (plus 'non-free-firmware' when firmware was selected). Without
# contrib, 'apt-get install zfsutils-linux' fails with "has no installation
# candidate" and zfs-dkms is never pulled in, so no module can be built and
# the pool cannot be created.
#
# Enable contrib idempotently, touching only the Debian archive entries.
# Matching a hostname is not enough: a netinst describes the archive through
# a 'mirror+file://' indirection, and the mirror may be a regional/hoster
# one — while third-party repos (tailscale, docker, ...) sign with their own
# keyring and must NOT be given a contrib they lack ('apt-get update' would
# then 404). So a stanza/line counts as Debian when its Signed-By names the
# Debian archive keyring, or when it has none (the default trusted keys).
enable_debian_contrib(){
local f tmp
while IFS= read -r f; do
[[ -z "$f" ]] && continue
case "$f" in
*.sources)
tmp="$f.vpsmgr-tmp"
if awk '
function emit(){
if (n == 0) return
deb = 1
for (i = 1; i <= n; i++)
if (buf[i] ~ /^[ \t]*Signed-By:/ && buf[i] !~ /debian-archive-keyring/) deb = 0
if (deb)
for (i = 1; i <= n; i++)
if (buf[i] ~ /^[ \t]*Components:/ && buf[i] ~ /(^|[ \t])main([ \t]|$)/ && buf[i] !~ /contrib/) buf[i] = buf[i] " contrib"
for (i = 1; i <= n; i++) print buf[i]
n = 0
}
/^[ \t]*$/ { emit(); print ""; next }
{ buf[++n] = $0 }
END { emit() }
' "$f" > "$tmp"; then
cat "$tmp" > "$f"; rm -f "$tmp"
else
rm -f "$tmp"
fi
;;
*)
sed -i -E '
/^deb[[:space:]]/!b
/signed-by=/ { /signed-by=[^]]*debian-archive-keyring/!b }
/[[:space:]]main([[:space:]]|$)/ { /contrib/! s/[[:space:]]*$/ contrib/ }
' "$f"
;;
esac
done < <(ls /etc/apt/sources.list /etc/apt/sources.list.d/*.sources /etc/apt/sources.list.d/*.list 2>/dev/null)
}
if [[ "$STORAGE" == "zfs" ]]; then
BASE_DEPS="$BASE_DEPS zfsutils-linux"
if [[ "$ID" == "debian" ]]; then
KERNEL_HEADERS_PKG=linux-headers-amd64
case "$KERNEL_REL" in
*-cloud-*) KERNEL_HEADERS_PKG=linux-headers-cloud-amd64 ;;
esac
BASE_DEPS="$BASE_DEPS $KERNEL_HEADERS_PKG build-essential"
log "Debian detected — ZFS module will be DKMS-compiled (headers: $KERNEL_HEADERS_PKG)"
if ! grep -rhE '^[^#].*\bcontrib\b' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null | grep -q .; then
log "enabling the Debian contrib component (needed for zfsutils-linux / zfs-dkms)"
enable_debian_contrib
apt-get update -qq
fi
else
log "Ubuntu detected — ZFS module is prebuilt in the kernel, no compilation"
# Ubuntu minimal/cloud images often ship with only the 'main' component; the
# installer needs packages from universe (ndppd for IPv6 pass-through, among
# others). Enable it idempotently.
if ! grep -rhE "^[^#].*universe" /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null | grep -q .; then
log "enabling the Ubuntu universe repository (needed for ndppd etc.)"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq software-properties-common >/dev/null 2>&1 || true
add-apt-repository -y universe >/dev/null 2>&1 || die "could not enable the universe repository"
apt-get update -qq
fi
fi
elif [[ "$STORAGE" == "btrfs" ]]; then
# The btrfs driver needs the userspace tools (btrfs / mkfs.btrfs): Incus uses
# mkfs.btrfs when it creates a loop-backed pool and the btrfs tool to manage
# subvolumes/snapshots/quotas for the native-subvolume case alike.
BASE_DEPS="$BASE_DEPS btrfs-progs"
log "btrfs backend selected — installing btrfs-progs; no ZFS module/kernel headers needed"
else
log "dir backend — no ZFS/btrfs tools needed; WARNING: disk quotas, snapshots and clone-on-create are NOT available"
fi
for p in $BASE_DEPS; do
if ! dpkg -s "$p" >/dev/null 2>&1; then
log "installing $p"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$p" || log " warn: could not install $p"
fi
done
# A headers meta-package may track a newer kernel than the one currently
# running. Try the exact package once, then fail with an actionable message
# instead of letting DKMS silently build for another installed kernel.
if [[ "$STORAGE" == "zfs" && "$ID" == "debian" && ! -e "/lib/modules/$KERNEL_REL/build" ]]; then
EXACT_HEADERS_PKG="linux-headers-$KERNEL_REL"
if apt-cache show "$EXACT_HEADERS_PKG" >/dev/null 2>&1; then
log "matching headers are missing for $KERNEL_REL — installing $EXACT_HEADERS_PKG"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$EXACT_HEADERS_PKG" || true
fi
fi
if [[ "$STORAGE" == "zfs" && "$ID" == "debian" && ! -e "/lib/modules/$KERNEL_REL/build" ]]; then
die "matching kernel headers are missing for $KERNEL_REL — install linux-headers-$KERNEL_REL or reboot into a kernel with installed headers"
fi
# ZFS module present and loadable for the RUNNING kernel? On Debian a kernel
# upgrade can leave the DKMS module missing (headers were unavailable at
# postinst time); rebuild it for the running kernel rather than failing later
# at pool creation. On Ubuntu the module ships with the kernel, so this is a
# no-op. Skipped entirely in dir mode.
if [[ "$STORAGE" == "zfs" ]]; then
if ! modprobe zfs 2>/dev/null; then
log "ZFS module missing for $KERNEL_REL — rebuilding via dkms (takes a minute)..."
# A module whose postinst ran before the headers existed is registered
# without a kernel ("zfs/<ver>: added"); the field carries a trailing
# colon there, so strip it (dkms build rejects "zfs/<ver>:").
DKMS_VERSIONS=$(dkms status 2>/dev/null | awk -F'[,/ ]+' '/^zfs\//{v=$2; sub(/:$/,"",v); if(v!="") print v}' | sort -u)
[[ -n "$DKMS_VERSIONS" ]] || die "no ZFS DKMS version found — check 'dkms status'"
for v in $DKMS_VERSIONS; do
if ! DKMS_OUT=$(dkms build "zfs/$v" -k "$KERNEL_REL" 2>&1); then
printf '%s\n' "$DKMS_OUT" >&2
die "DKMS failed to build zfs/$v for $KERNEL_REL — check the matching kernel headers"
fi
if ! DKMS_OUT=$(dkms install "zfs/$v" -k "$KERNEL_REL" 2>&1); then
printf '%s\n' "$DKMS_OUT" >&2
die "DKMS failed to install zfs/$v for $KERNEL_REL"
fi
done
modprobe zfs || die "ZFS kernel module failed to load for $KERNEL_REL — check 'dkms status' and 'dmesg | grep zfs'"
fi
log "zfs: $(zpool version 2>/dev/null | head -1)"
# Make ZFS boot-safe: the module must be loaded and the pool imported on every
# boot, or Incus's storage pool is unavailable after a reboot. zfsutils-linux
# normally enables these in its postinst; ensure them explicitly so a kernel
# update + manual reboot "just works": module loads, pool imports, datasets
# mount, then Incus and the panel come up on top.
for unit in zfs-import-cache.service zfs-mount.service zfs-zed.service zfs.target; do
systemctl enable "$unit" >/dev/null 2>&1 || log " warn: could not enable $unit"
done
log "zfs boot units enabled (import + mount on every boot)"
fi
# --- Go toolchain (only needed for local build; installed lazily by 40-panel.sh) ---
if command -v go >/dev/null 2>&1; then
log "go: $(go version 2>/dev/null | awk '{print $3}')"
fi
# --- Incus ---
if ! command -v incus >/dev/null 2>&1; then
log "incus not installed yet (installed by 10-incus.sh)"
fi
# --- UFW conflict ---
# Incus manages its own `table inet incus` nftables rules (DHCP/DNS/forwarding)
# on incusbr0, but UFW's `table ip filter` runs at the same priority with a DROP
# policy, so it drops container DHCP/forwarding traffic before Incus's rules
# ever see it. Result: containers get no IPv4 (no DHCP, no DNS, no NAT) and
# the image build fails. See:
# https://linuxcontainers.org/incus/docs/main/howto/network_bridge_firewalld/
# vpsmgr manages its own firewall via `table inet vpsmgr` nftables, so the
# cleanest fix is to disable UFW during install (idempotent: skipped when it
# is already inactive).
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
# Already handled from a previous install? Never disable again silently —
# the user may have re-enabled it on purpose.
V4_NET="${VPSMGR_IPV4_SUBNET:-10.115.0.0/24}"
V4_MATCH="$(printf '%s' "${V4_NET%/*}" | sed 's/\./\\./g')"
if ufw status verbose 2>/dev/null | grep -qE "incusbr0|$V4_MATCH"; then
log "ufw active but already has Incus/incusbr0 allow rules — leaving it as-is"
else
log "ufw is ACTIVE with default-DROP policy — this breaks Incus container IPv4"
log " (Incus's own nftables rules are shadowed by ufw's DROP; known issue:"
log " https://linuxcontainers.org/incus/docs/main/howto/network_bridge_firewalld/)"
log " vpsmgr manages its firewall via nftables; disabling ufw."
ufw disable >/dev/null 2>&1 && log " ufw disabled" || die "failed to disable ufw"
# Keep it off across reboots. Snap/systemd enable it on boot otherwise.
if command -v systemctl >/dev/null 2>&1; then
systemctl disable ufw.service >/dev/null 2>&1 || true
systemctl stop ufw.service >/dev/null 2>&1 || true
fi
fi
else
log "ufw: not active (or not installed) — no conflict"
fi
# --- port occupancy ---
# Ports reserved for vpsmgr must be free on a fresh install. On adoption the
# panel and the domain proxy are already running and owned by vpsmgr — those
# listeners
# are excluded by process name. Checks TCP and UDP (the user port block is
# DNAT-ed for both).
#
# 80/443 are handled specially: if either is already taken, the install does
# NOT fail — it proceeds with HAProxy installed but DISABLED (net.haproxy
# false, not started/autostarted), so a host that already serves 80/443 is
# still usable; the operator can re-enable later via `vps config set
# net.haproxy true`. The user-port ranges (from net.user_ports, exported by
# 00-ip-ask.sh as VPSMGR_USER_PORTS; default 10000-29999) stay hard failures.
#
# SSH ports (30000-31999) are deliberately NOT checked here: they are allocated
# per-container at `vps add` time with a "probe the port, reassign on conflict"
# loop (mgr.allocSSHPort binds the candidate to detect a live listener), so an
# occupied port no longer blocks the install — the host simply skips it. This
# keeps the install usable on machines that happen to run something in that
# range.
#
# 00-check runs as a separate bash process, so its env cannot reach the rest of
# the installer. The decision therefore goes through a root-only marker file
# that install.sh turns into an env var for 30-haproxy.sh and the panel.
#
# The marker keeps its historical name (.install-traefik-off) even though the
# proxy is HAProxy now: an operator may run an older installer checkout that
# reads and writes exactly this path, and the new script honors it as well.
# Clear any stale marker from a previous run so a reinstall re-detects.
rm -f /etc/vpsmgr/.install-traefik-off 2>/dev/null || true
if [[ "${VPSMGR_DISABLE_V4FORWARD:-0}" == "1" ]]; then
log "v4 forwarding disabled by installer — skipping reserved port checks"
else
# Only the ports the configured user-port ranges actually reserve must be
# free — checking the whole 10000-29999 regardless of the ranges would block a
# small-footprint install that never uses the upper ports. VPSMGR_USER_PORTS is
# the comma-separated (already whole-hundred aligned) ranges exported by
# 00-ip-ask.sh; default 10000-29999. SSH ports (30000-31999) are NOT checked
# here — they are probed per-container at add time (see allocSSHPort).
USER_PORTS="${VPSMGR_USER_PORTS:-10000-29999}"
port_in_user_ranges(){
local p="$1"
local r lo hi
while IFS=',' read -ra RS; do
for r in "${RS[@]}"; do
r="${r// /}"
[[ -z "$r" ]] && continue
lo="${r%%-*}"; hi="${r##*-}"
if (( p >= lo && p <= hi )); then
return 0
fi
done
done <<<"$USER_PORTS"
return 1
}
port_hard_reserved(){
local p="$1"
port_in_user_ranges "$p"
return $?
}
log "checking reserved ports (80/443, $USER_PORTS)..."
CONFLICTS=""
PORT_80_443_TAKEN=0
PORT_80_443_PROC=""
while IFS= read -r line; do
[[ -z "$line" ]] && continue
ADDR=$(awk '{print $4}' <<<"$line")
[[ -z "$ADDR" ]] && continue
PORT="${ADDR##*:}"
[[ "$PORT" =~ ^[0-9]+$ ]] || continue
PROC=$(sed -n 's/.*users:(("\([^"]*\)".*/\1/p' <<<"$line")
case "${PROC##*/}" in
# A listener we own is never a conflict: on adoption/upgrade the domain
# proxy is already running. 'haproxy' is the current proxy, 'traefik' the
# pre-HAProxy one (a host that has not been upgraded yet), 'vpsmgr' the
# panel itself.
vpsmgr|traefik|haproxy) continue ;;
esac
if [[ "$PORT" == "80" || "$PORT" == "443" ]]; then
# 80/443 occupied by a non-vpsmgr process: not a hard failure — force the
# domain proxy OFF instead (still installed, kept stopped).
PORT_80_443_TAKEN=1
PORT_80_443_PROC="${PROC:-unknown process}"
continue
fi
if port_hard_reserved "$PORT"; then
CONFLICTS+=" $PORT (${ADDR}) — bound by ${PROC:-unknown process}"$'\n'
fi
done < <(ss -H -tlnp 2>/dev/null; ss -H -ulnp 2>/dev/null)
if [[ -n "$CONFLICTS" ]]; then
rm -f /etc/vpsmgr/.install-traefik-off 2>/dev/null || true
die "ports reserved for vpsmgr are already in use:
$CONFLICTS
Free these ports (or remove the programs above) and re-run install."
fi
if [[ "$PORT_80_443_TAKEN" == "1" ]]; then
# Belt-and-suspenders for update/adoption: the scan already skips our proxy's
# own listeners by process name, but if `ss` could not attribute the listener
# (PROC empty) while OUR proxy is already active, that active proxy is what
# holds 80/443 — do not misread it as a third-party conflict and turn our own
# domain proxy off. Both service names are checked: 'haproxy' now, 'traefik'
# on a host that has not run the upgraded installer yet.
if systemctl is-active --quiet haproxy.service 2>/dev/null \
|| systemctl is-active --quiet traefik.service 2>/dev/null; then
log "80/443 held by the running vpsmgr domain proxy — keeping it enabled (no force-off)"
else
mkdir -p /etc/vpsmgr
# The marker keeps its historical name: an older installer checkout still
# writes and reads this exact path, and the new one honors it too.
touch /etc/vpsmgr/.install-traefik-off
log "port 80 and/or 443 is in use (${PORT_80_443_PROC}) — will NOT fail;"
log "the domain proxy will be installed but DISABLED (net.haproxy false, not started/autostarted)."
log "re-enable later with: vps config set net.haproxy true"
fi
else
log "reserved ports are free"
fi
fi
# --- detect public ip / ext iface ---
# ext iface: prefer the IPv4 default route, then the IPv6 default route (covers
# IPv6-only hosts and policy-routed boxes whose v4 default lives in a non-main
# table), then the first non-virtual UP interface.
EXT_IF=$(ip route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')
if [[ -z "$EXT_IF" ]]; then
EXT_IF=$(ip -6 route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')
fi
if [[ -z "$EXT_IF" ]]; then
EXT_IF=$(ip -o link show up 2>/dev/null | grep -v -E 'lo|incusbr|virbr|docker|veth|warp|wg' | awk -F': ' '{print $2; exit}')
fi
PUB_IP=""
if [[ -n "$EXT_IF" ]]; then
PUB_IP=$(ip -4 -o addr show dev "$EXT_IF" scope global | awk '{print $4}' | cut -d/ -f1 | head -1)
fi
if [[ -z "$PUB_IP" ]]; then
PUB_IP=$(hostname -I | awk '{print $1}')
log " warn: no public IPv4 detected on $EXT_IF, using $PUB_IP (private/other) as fallback"
fi
log "public/panel IP: $PUB_IP (ext iface: ${EXT_IF:-auto})"
# --- network reachability (warn only) ---
if ! curl -sI --max-time 8 https://images.linuxcontainers.org >/dev/null 2>&1; then
log " warn: cannot reach images.linuxcontainers.org — Incus image pull will fail unless cached"
fi
echo "[00] checks passed"