Ctrl K
ComunidadGuías…

install.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
# firecrab host installer (public-docs/getting-started/installation.md).
#
# Downloads a host bundle from a GitHub Release (or installs local binaries
# via --bin-dir), then lays out systemd units so the dashboard is served on
# http://127.0.0.1:5523/.
#
# Bundles are Linux x86_64/aarch64 × gnu (glibc) / musl. glibc hosts get the
# gnu bundle; musl hosts (Alpine) get musl. Override with --libc.
#
# Every step checks before it changes anything, so re-running is safe and
# doubles as a repair for a half-broken install.
set -euo pipefail

FIRECRAB_USER=${FIRECRAB_USER:-firecrab}
FIRECRAB_GROUP=${FIRECRAB_GROUP:-firecrab}
PREFIX=${PREFIX:-/usr/local}
LIBDIR="$PREFIX/lib/firecrab"
SHAREDIR="$PREFIX/share/firecrab"
DATADIR=${DATADIR:-/var/lib/firecrab}
CONFDIR=${CONFDIR:-/etc/firecrab}
UNITDIR=${UNITDIR:-/etc/systemd/system}

# stdin (`curl | bash`) leaves BASH_SOURCE unset under `set -u`.
# `bash <(curl …)` sets it to a /dev/fd path that is not the checkout.
_src=${BASH_SOURCE[0]:-${0:-}}
case "$_src" in
    ""|-|bash|sh|main|/dev/fd/*|/proc/self/fd/*)
        REPO_ROOT=$(pwd)
        ;;
    *)
        REPO_ROOT=$(cd -- "$(dirname -- "$_src")" && pwd)
        ;;
esac
unset _src
UNITS=(firecrab-helper.service firecrab-api.service)

# Placeholder @FIRECRAB_RELEASE_TAG@ is replaced by the tag on a published
# install.sh (v0.1.0). The repo copy keeps the placeholder and means "latest".
FIRECRAB_RELEASE_TAG="@FIRECRAB_RELEASE_TAG@"

MODE=install
INSTALL_DEPS=1
WITH_FRONTEND=1
WITH_PIPE=0
PURGE=0
# Set by ensure_account when it grants the /dev/kvm ACL, so --uninstall can take it back.
KVM_ACL_ADDED=0
BIN_DIR=
DASHBOARD_DIR=
RELEASE_VERSION=
LIBC=
PAYLOAD_TMP=
PAYLOAD_ROOT=
PAYLOAD_BIN=
PAYLOAD_UNITS=
PAYLOAD_EXTRACT=
PAYLOAD_DASHBOARD=
PAYLOAD_LICENSE=
PAYLOAD_THIRD_PARTY=
PAYLOAD_INVENTORY=
PAYLOAD_GPL=

# BEGIN RELEASE_HELPERS
if [ -f "$REPO_ROOT/scripts/firecrab-release.sh" ]; then
    # Optional checkout helper; the published installer inlines this block.
    # shellcheck disable=SC1091
    . "$REPO_ROOT/scripts/firecrab-release.sh"
fi
if ! type firecrab_host_arch >/dev/null 2>&1; then
    printf 'xx  release helpers missing — run from a firecrab checkout or use the install.sh from a GitHub Release\n' >&2
    exit 1
fi
# END RELEASE_HELPERS

PKG=''          # detected package manager
PKG_UPDATED=0   # index refreshed at most once

# Help text, also printed before failing on an unknown option.
usage() {
    cat <<'USAGE'
Usage: ./install.sh [OPTIONS]

  (no options)        download the latest release binaries and start firecrab
  --check             report what is missing and what would be installed
  --doctor            run host diagnostics (UFW, socket, KVM, nft, …); no root required
  --deps-only         install the dependencies, then stop (no host changes)
  --no-deps           never install packages, only report gaps
  --no-frontend       do not install the dashboard
  --with-pipe         also install the verified cloudflared connector (ingress is bundled)
  --version VER       GitHub Release tag (default: latest, or the baked-in tag)
  --libc gnu|musl     host libc (default: detect; gnu = glibc)
  --bin-dir DIR       install these local binaries instead of the release
  --dashboard-dir DIR install this built dashboard (with --bin-dir)
  --print-install-url print the curl install command and exit
  --print-release-url print the host-bundle URL and exit
  --uninstall         stop and remove units, binaries and dashboard
  --purge             with --uninstall, also delete /var/lib/firecrab (VM data!)
  -h, --help          this text

  curl -fsSL https://github.com/SteelCrab/firecrab/releases/latest/download/install.sh | bash

  Host bundles (install.sh picks arch + libc):
    firecrab-host-x86_64-gnu.tar.gz    Linux x86_64, glibc
    firecrab-host-x86_64-musl.tar.gz   Linux x86_64, musl (static)
    firecrab-host-aarch64-gnu.tar.gz   Linux aarch64, glibc
    firecrab-host-aarch64-musl.tar.gz  Linux aarch64, musl (static)

Environment: FIRECRAB_USER, FIRECRAB_GROUP, PREFIX, DATADIR, CONFDIR, UNITDIR
             FIRECRAB_RELEASE_REPO, FIRECRAB_DEFAULT_VERSION
USAGE
}

log()  { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
step() { printf '\033[1;36m--\033[0m  %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m  %s\n' "$*" >&2; }
die()  { printf '\033[1;31mxx\033[0m  %s\n' "$*" >&2; exit 1; }

have() { command -v "$1" >/dev/null 2>&1; }

# True when SELinux is loaded, enforcing or not. Permissive counts: the labels
# still have to be right, or turning enforcement back on breaks the install.
selinux_active() {
    have getenforce || return 1
    case "$(getenforce 2>/dev/null || printf Disabled)" in
        Enforcing|Permissive) return 0 ;;
        *) return 1 ;;
    esac
}

# Privilege escalation: empty when already root, "sudo" otherwise.
# Individual commands that need root use $SUDO; the whole script runs as the
# invoking user so that $HOME, SSH keys, and cargo are the user's, not root's.
if [ "$(id -u)" -eq 0 ]; then
    SUDO=""
else
    have sudo || die "sudo is required to run privileged steps; install it first"
    SUDO="sudo"
fi

# Extra args after --doctor are forwarded to `firecrab doctor`.
DOCTOR_ARGS=()

while [ $# -gt 0 ]; do
    case "$1" in
        --check) MODE=check ;;
        --doctor)
            MODE=doctor
            shift
            # Everything after --doctor belongs to the doctor script.
            DOCTOR_ARGS=("$@")
            break
            ;;
        --deps-only) MODE=deps ;;
        --no-deps) INSTALL_DEPS=0 ;;
        --no-frontend) WITH_FRONTEND=0 ;;
        --with-pipe) WITH_PIPE=1 ;;
        --version)
            [ $# -ge 2 ] || die "--version needs a tag (for example v0.1.0)"
            RELEASE_VERSION=$2
            shift
            ;;
        --libc)
            [ $# -ge 2 ] || die "--libc needs gnu or musl"
            LIBC=$2
            firecrab_normalize_libc "$LIBC" >/dev/null \
                || die "--libc must be gnu, glibc, or musl"
            LIBC=$(firecrab_normalize_libc "$LIBC")
            shift
            ;;
        --bin-dir)
            [ $# -ge 2 ] || die "--bin-dir needs a directory"
            BIN_DIR=$2
            shift
            ;;
        --dashboard-dir)
            [ $# -ge 2 ] || die "--dashboard-dir needs a directory"
            DASHBOARD_DIR=$2
            shift
            ;;
        --print-install-url) MODE=print-install-url ;;
        --print-release-url) MODE=print-release-url ;;
        --uninstall) MODE=uninstall ;;
        --purge) PURGE=1 ;;
        -h|--help) usage; exit 0 ;;
        *) usage >&2; die "unknown option: $1" ;;
    esac
    shift
done

if [ -z "$RELEASE_VERSION" ]; then
    RELEASE_VERSION=${FIRECRAB_DEFAULT_VERSION:-}
fi
# A published installer substitutes the placeholder with the tag. The repo
# copy keeps @FIRECRAB_RELEASE_TAG@, which means "latest".
case "$FIRECRAB_RELEASE_TAG" in
    ""|@*) ;;
    *)
        if [ -z "$RELEASE_VERSION" ]; then
            RELEASE_VERSION=$FIRECRAB_RELEASE_TAG
        fi
        ;;
esac

if [ -z "$LIBC" ]; then
    LIBC=$(firecrab_host_libc)
else
    LIBC=$(firecrab_normalize_libc "$LIBC") || die "--libc must be gnu, glibc, or musl"
fi

cleanup_payload() {
    if [ -n "${PAYLOAD_TMP:-}" ] && [ -d "$PAYLOAD_TMP" ]; then
        rm -rf "$PAYLOAD_TMP"
    fi
}
trap cleanup_payload EXIT

if [ "$WITH_PIPE" = 1 ] && [ "$MODE" != install ] && [ "$MODE" != check ]; then
    die "--with-pipe is supported with installation or --check"
fi

# --- package manager -------------------------------------------------------

# Sets PKG to the first package manager found; returns 1 if none is.
detect_pkg() {
    for candidate in apt-get dnf zypper pacman apk; do
        if have "$candidate"; then PKG=$candidate; return 0; fi
    done
    return 1
}

# Package name per manager, since only a few of these agree.
pkg_name() {
    local generic=$1
    case "$PKG:$generic" in
        # Fedora/RHEL call it iproute; everyone else iproute2.
        dnf:iproute2) echo "iproute" ;;
        *:iproute2)   echo "iproute2" ;;
        *:nftables)   echo "nftables" ;;
        apt-get:conntrack) echo "conntrack" ;;
        dnf:conntrack) echo "conntrack-tools" ;;
        zypper:conntrack) echo "conntrack-tools" ;;
        *:conntrack) echo "conntrack-tools" ;;
        *:dnsmasq)    echo "dnsmasq" ;;
        # dhcp_release is shipped separately on Debian/Ubuntu and Alpine.
        # On RHEL/Fedora it does not exist as a separate package.
        apt-get:dnsmasq-utils) echo "dnsmasq-utils" ;;
        apk:dnsmasq-utils)     echo "dnsmasq-utils" ;;
        dnf:dnsmasq-utils)     echo "dnsmasq" ;;
        *:dnsmasq-utils)       echo "dnsmasq-utils" ;;
        apk:e2fsprogs) echo "e2fsprogs-extra" ;;
        *:e2fsprogs)  echo "e2fsprogs" ;;
        *:curl)       echo "curl" ;;
        apt-get:openssh-client) echo "openssh-client" ;;
        dnf:openssh-client) echo "openssh-clients" ;;
        apk:openssh-client) echo "openssh-client-default" ;;
        *:openssh-client) echo "openssh" ;;
        # scripts/install-firecracker.sh needs find and tar; a minimal
        # openSUSE or Debian install has neither.
        *:findutils)  echo "findutils" ;;
        *:tar)        echo "tar" ;;
        apt-get:xz)   echo "xz-utils" ;;
        apt-get:kernel-compiler) echo "build-essential" ;;
        dnf:kernel-compiler) echo "gcc make" ;;
        zypper:kernel-compiler) echo "gcc make" ;;
        pacman:kernel-compiler) echo "base-devel" ;;
        apk:kernel-compiler) echo "build-base" ;;
        apt-get:kernel-headers) echo "libelf-dev libssl-dev" ;;
        dnf:kernel-headers) echo "elfutils-libelf-devel openssl-devel" ;;
        zypper:kernel-headers) echo "libelf-devel libopenssl-devel" ;;
        pacman:kernel-headers) echo "libelf openssl" ;;
        apk:kernel-headers) echo "elfutils-dev openssl-dev" ;;
        *:xz)         echo "xz" ;;
        # Native logical backups use client tools, never database servers.
        apt-get:mysql-client) echo "mariadb-client" ;;
        dnf:postgresql-client) echo "postgresql" ;;
        dnf:mysql-client) echo "mariadb" ;;
        pacman:postgresql-client) echo "postgresql-libs" ;;
        pacman:mysql-client) echo "mariadb-clients" ;;
        zypper:postgresql-client) echo "postgresql" ;;
        zypper:mysql-client) echo "mariadb-client" ;;
        # Dashboard M2Image install decompresses `{alias}.tar.zst` packages.
        *:zstd)       echo "zstd" ;;
        # semanage lives in a python tooling package on the SELinux distros.
        dnf:selinux-tools)     echo "policycoreutils-python-utils" ;;
        apt-get:selinux-tools) echo "policycoreutils-python-utils" ;;
        zypper:selinux-tools)  echo "policycoreutils-python-utils" ;;
        *:selinux-tools)       echo "policycoreutils" ;;
        *) echo "$generic" ;;
    esac
}

# Refreshes the package index at most once per run.
pkg_refresh() {
    [ "$PKG_UPDATED" -eq 1 ] && return 0
    case "$PKG" in
        apt-get) DEBIAN_FRONTEND=noninteractive $SUDO apt-get update -qq ;;
        apk) $SUDO apk update -q ;;
        *) : ;;  # dnf/zypper/pacman refresh as part of install
    esac
    PKG_UPDATED=1
}

# Installs the named packages with whichever manager was detected.
# One retry: Fedora metalink/mirror writes fail transiently.
pkg_install() {
    pkg_refresh
    local attempt
    for attempt in 1 2; do
        if pkg_install_once "$@"; then
            return 0
        fi
        [ "$attempt" -eq 1 ] || return 1
        warn "package install failed; retrying once"
        sleep 2
    done
}

pkg_install_once() {
    case "$PKG" in
        apt-get) DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq "$@" ;;
        dnf) $SUDO dnf install -y -q "$@" ;;
        zypper) $SUDO zypper --non-interactive install -y "$@" ;;
        pacman) $SUDO pacman -Sy --noconfirm --needed "$@" ;;
        apk) $SUDO apk add --no-cache "$@" ;;
        *) return 1 ;;
    esac
}

# `ensure <command> <generic package>`: install only what is actually absent.
ensure() {
    local command_name=$1 generic=$2 packages
    have "$command_name" && return 0

    # One generic name can map to two packages (nodejs + npm), so the words are
    # split here, deliberately, into an array rather than left to the shell.
    read -ra packages <<<"$(pkg_name "$generic")"
    if [ "$MODE" = check ]; then
        warn "missing: $command_name (would install: ${packages[*]})"
        return 1
    fi
    [ "$INSTALL_DEPS" -eq 1 ] || { warn "missing: $command_name (--no-deps, install '${packages[*]}' yourself)"; return 1; }
    [ -n "$PKG" ] || { warn "missing: $command_name and no known package manager — install '${packages[*]}' yourself"; return 1; }

    step "installing ${packages[*]} (for $command_name)"
    pkg_install "${packages[@]}" || { warn "failed to install ${packages[*]}"; return 1; }
    have "$command_name"
}

# --- host checks -----------------------------------------------------------

# A VM cannot start without /dev/kvm, and this script cannot supply it.
check_kvm() {
    if [ -e /dev/kvm ]; then
        log "/dev/kvm present"
        return 0
    fi
    # Not installable: it is the CPU, the BIOS, or a VM without nested virt.
    warn "/dev/kvm missing — enable virtualization in BIOS, or nested virt if this host is itself a VM"
    return 1
}

# The units need a running systemd, not merely the binary.
check_systemd() {
    if [ -d /run/systemd/system ]; then
        log "systemd is running"
        return 0
    fi
    warn "not a systemd host — the daemons can still be started by hand, but units will not work"
    return 1
}

# UFW is still reported here; the helper punches DHCP/DNS on each bridge
# for UFW, firewalld, iptables, and nftables.
report_ufw() {
    have ufw || return 0
    local status
    if status=$(ufw status 2>/dev/null); then
        case "$status" in
            *"Status: active"*)
                log "UFW is active — net-helper opens DHCP/DNS on each MicroNetwork bridge" ;;
            *) log "UFW installed but inactive" ;;
        esac
    else
        warn "UFW installed; re-run as root to see whether it is active"
    fi
}

# --- dependency resolution -------------------------------------------------

# The commands the two daemons shell out to while running, plus the handful the
# installer's own helper scripts need.
ensure_runtime_deps() {
    local failed=0
    ensure ip iproute2   || failed=1
    ensure ss iproute2   || failed=1
    ensure nft nftables  || failed=1
    ensure conntrack conntrack || warn "conntrack is required when using security groups"
    ensure dnsmasq dnsmasq || failed=1
    # dhcp_release releases DHCP leases on VM stop; VMs can still run without
    # it but leases accumulate until they expire (default 1 h). Fedora/RHEL
    # ship dnsmasq without that binary — do not reinstall dnsmasq for it.
    if ! have dhcp_release; then
        if [ "$PKG" = dnf ]; then
            warn "dhcp_release is not available on this distribution; leases expire on their own"
        else
            ensure dhcp_release dnsmasq-utils \
                || warn "dhcp_release not found; install dnsmasq-utils to release leases on VM stop"
        fi
    fi
    # semanage records the bin_t file context for the installed binaries. Without
    # it the services stay in SELinux's init_t domain, where they cannot reach a
    # registry or exec nft — an install that looks successful and works for
    # nothing. Fedora does not ship it in a minimal install.
    if selinux_active && ! have semanage; then
        ensure semanage selinux-tools \
            || warn "SELinux is on but semanage is missing; the services will be confined to init_t"
    fi
    ensure mkfs.ext4 e2fsprogs || failed=1
    ensure fakeroot fakeroot || failed=1
    ensure curl curl     || failed=1
    ensure git git       || failed=1
    ensure ssh openssh-client || failed=1
    ensure find findutils || failed=1
    ensure tar tar       || failed=1
    ensure zstd zstd     || failed=1
    ensure sha256sum coreutils || failed=1
    # Kernel builds are optional, but install their prerequisites by default.
    ensure_kernel_build_deps || warn "kernel builds are unavailable until their dependencies are installed"
    # Missing backup clients do not prevent VM or read-only DB operation.
    # Install them by default; --no-deps and --check never change the host.
    ensure pg_dump postgresql-client || warn "pg_dump unavailable; PostgreSQL backups need a compatible client"
    ensure pg_restore postgresql-client || warn "pg_restore unavailable; PostgreSQL restore is unavailable"
    ensure psql postgresql-client || warn "psql unavailable; PostgreSQL restore is unavailable"
    ensure mysqldump mysql-client || warn "mysqldump unavailable; MySQL backups are unavailable"
    ensure mysql mysql-client || warn "mysql unavailable; MySQL restore is unavailable"
    return $failed
}

# The dashboard builds kernels from verified upstream sources on this host.
ensure_kernel_build_deps() {
    local failed=0 packages
    ensure make kernel-compiler || failed=1
    ensure cc kernel-compiler || failed=1
    ensure flex flex || failed=1
    ensure bison bison || failed=1
    ensure bc bc || failed=1
    ensure perl perl || failed=1
    ensure xz xz || failed=1
    if ! printf '#include <libelf.h>\n#include <gelf.h>\n#include <openssl/opensslv.h>\n' | cc -x c -fsyntax-only - >/dev/null 2>&1; then
        read -ra packages <<<"$(pkg_name kernel-headers)"
        if [ "$MODE" = check ] || [ "$INSTALL_DEPS" -ne 1 ] || [ -z "$PKG" ]; then
            warn "missing kernel build headers (install: ${packages[*]})"
            failed=1
        else
            step "installing ${packages[*]} (kernel build headers)"
            pkg_install "${packages[@]}" || failed=1
            printf '#include <libelf.h>\n#include <gelf.h>\n#include <openssl/opensslv.h>\n' | cc -x c -fsyntax-only - >/dev/null 2>&1 || failed=1
        fi
    fi
    return "$failed"
}

# Comes from its own upstream script rather than a distro package.
# A piped release installer has no checkout, so fetch the same script.
ensure_firecracker() {
    have firecracker && { log "firecracker present"; return 0; }

    if [ "$MODE" = check ]; then
        warn "missing: firecracker (would install the upstream Firecracker binary)"
        return 1
    fi
    [ "$INSTALL_DEPS" -eq 1 ] || { warn "missing: firecracker (--no-deps; install it or re-run without --no-deps)"; return 1; }

    step "installing firecracker"
    local script fetched=
    script=$REPO_ROOT/scripts/install-firecracker.sh
    if [ ! -f "$script" ]; then
        fetched=$(mktemp)
        script=$fetched
        if ! curl --proto '=https' --tlsv1.2 -fsSL \
            "$(firecrab_repo_raw_url "${RELEASE_VERSION:-latest}" scripts/install-firecracker.sh)" \
            -o "$fetched"; then
            rm -f "$fetched"
            warn "firecracker install failed (could not download the installer)"
            return 1
        fi
    fi
    if $SUDO env FIRECRACKER_NOTICE_DIR="$SHAREDIR/firecracker" bash "$script"; then
        rm -f "$fetched"
        have firecracker && return 0
    fi
    rm -f "$fetched"
    warn "firecracker install failed"
    return 1
}

# Whether this script is running from a firecrab git checkout.
is_checkout() {
    [ -f "$REPO_ROOT/Cargo.toml" ] && [ -f "$REPO_ROOT/packaging/systemd/firecrab-api.service" ]
}

# Downloads the host bundle for this arch + libc.
download_host_bundle() {
    local arch tarball url sums_url
    arch=$(firecrab_host_arch) || die "unsupported architecture: $(uname -m) (need x86_64 or aarch64)"
    tarball=$(firecrab_host_tarball "$arch" "$LIBC")
    PAYLOAD_TMP=$(mktemp -d)
    url=$(firecrab_release_asset_url "$RELEASE_VERSION" "$tarball")
    sums_url=$(firecrab_release_asset_url "$RELEASE_VERSION" SHA256SUMS)
    step "downloading $tarball"
    if ! curl --proto '=https' --tlsv1.2 -fsSL "$url" -o "$PAYLOAD_TMP/$tarball"; then
        die "failed to download $url
Tag a GitHub Release, or install local binaries:
  cargo build --release -p firecrab-api -p firecrab-helper -p firecrab-cli
  ./install.sh --bin-dir target/release --dashboard-dir firecrab-frontend/dist"
    fi
    if ! curl --proto '=https' --tlsv1.2 -fsSL "$sums_url" -o "$PAYLOAD_TMP/SHA256SUMS"; then
        die "failed to download $sums_url"
    fi
    firecrab_verify_sha256 "$PAYLOAD_TMP/SHA256SUMS" "$PAYLOAD_TMP/$tarball" \
        || die "checksum mismatch for $tarball"
    mkdir -p "$PAYLOAD_TMP/root"
    tar -xzf "$PAYLOAD_TMP/$tarball" -C "$PAYLOAD_TMP/root"
    PAYLOAD_ROOT=$PAYLOAD_TMP/root
    local name
    for name in firecrab-api firecrab-helper firecrab firecrab-ingress; do
        local member
        member=$(firecrab_resolve_binary "$name" "$PAYLOAD_ROOT" "") \
            || die "release bundle is missing $name"
        firecrab_assert_binary_arch "$member" "$arch" \
            || die "$tarball: $name is not a $arch binary"
    done
    for name in LICENSE THIRD_PARTY_NOTICES.txt release-license-inventory.json \
            licenses/GPL-2.0-only.txt ingress/LICENSE.nginx ingress/LICENSE.pcre2 \
            ingress/LICENSE.musl ingress/sources.json; do
        [ -f "$PAYLOAD_ROOT/$name" ] || die "release bundle is missing compliance artifact $name"
    done
    log "host bundle $arch/$LIBC"
}

# Sets PAYLOAD_* to either --bin-dir + this checkout, or a downloaded release.
resolve_payload() {
    if [ "$(firecrab_payload_mode "$BIN_DIR")" = dir ]; then
        [ -d "$BIN_DIR" ] || die "--bin-dir is not a directory: $BIN_DIR"
        is_checkout || die "--bin-dir needs a firecrab checkout for units and helper scripts"
        PAYLOAD_BIN=$BIN_DIR
        PAYLOAD_UNITS=$REPO_ROOT/packaging/systemd
        PAYLOAD_EXTRACT=$REPO_ROOT/scripts/firecracker-menual
        PAYLOAD_LICENSE=$REPO_ROOT/LICENSE
        PAYLOAD_GPL=$REPO_ROOT/licenses/GPL-2.0-only.txt
        if [ -f "$REPO_ROOT/dist/compliance/THIRD_PARTY_NOTICES.txt" ] \
            && [ -f "$REPO_ROOT/dist/compliance/release-license-inventory.json" ]; then
            PAYLOAD_THIRD_PARTY=$REPO_ROOT/dist/compliance/THIRD_PARTY_NOTICES.txt
            PAYLOAD_INVENTORY=$REPO_ROOT/dist/compliance/release-license-inventory.json
        else
            PAYLOAD_THIRD_PARTY=
            PAYLOAD_INVENTORY=
        fi
        if [ -n "$DASHBOARD_DIR" ]; then
            PAYLOAD_DASHBOARD=$DASHBOARD_DIR
        elif [ -f "$REPO_ROOT/firecrab-frontend/dist/index.html" ]; then
            PAYLOAD_DASHBOARD=$REPO_ROOT/firecrab-frontend/dist
        else
            PAYLOAD_DASHBOARD=
        fi
        return 0
    fi

    download_host_bundle
    PAYLOAD_BIN=$PAYLOAD_ROOT
    PAYLOAD_UNITS=$PAYLOAD_ROOT/systemd
    PAYLOAD_EXTRACT=$PAYLOAD_ROOT
    PAYLOAD_DASHBOARD=$PAYLOAD_ROOT/dashboard
    PAYLOAD_LICENSE=$PAYLOAD_ROOT/LICENSE
    PAYLOAD_THIRD_PARTY=$PAYLOAD_ROOT/THIRD_PARTY_NOTICES.txt
    PAYLOAD_INVENTORY=$PAYLOAD_ROOT/release-license-inventory.json
    PAYLOAD_GPL=$PAYLOAD_ROOT/licenses/GPL-2.0-only.txt
}

report_payload() {
    local tarball
    if [ "$(firecrab_payload_mode "$BIN_DIR")" = dir ]; then
        log "binaries: would install from $BIN_DIR"
        if [ ! -d "$BIN_DIR" ]; then
            warn "not a directory: $BIN_DIR"
            return 1
        fi
        local name gaps=0
        for name in firecrab-api firecrab-helper firecrab; do
            if [ -x "$BIN_DIR/$name" ]; then
                log "  $name"
            else
                warn "  missing $name (ok if already installed under $LIBDIR)"
                gaps=1
            fi
        done
        return 0
    fi
    if ! tarball=$(firecrab_host_tarball "" "$LIBC"); then
        warn "unsupported architecture: $(uname -m) (need x86_64 or aarch64)"
        return 1
    fi
    log "binaries: would download $(firecrab_release_asset_url "$RELEASE_VERSION" "$tarball") ($LIBC)"
    return 0
}

# --- host layout -----------------------------------------------------------

# The account the API runs as, in the kvm group for firecracker.
ensure_account() {
    if getent group "$FIRECRAB_GROUP" >/dev/null; then
        log "group $FIRECRAB_GROUP exists"
    else
        step "creating group $FIRECRAB_GROUP"
        $SUDO groupadd --system "$FIRECRAB_GROUP"
    fi

    if id "$FIRECRAB_USER" >/dev/null 2>&1; then
        log "user $FIRECRAB_USER exists"
    else
        step "creating user $FIRECRAB_USER"
        $SUDO useradd --system --gid "$FIRECRAB_GROUP" --home-dir "$DATADIR" \
                --shell /usr/sbin/nologin "$FIRECRAB_USER"
    fi

    # The API spawns firecracker, which opens /dev/kvm.
    if getent group kvm >/dev/null && ! id -nG "$FIRECRAB_USER" | tr ' ' '\n' | grep -qx kvm; then
        step "adding $FIRECRAB_USER to the kvm group"
        $SUDO usermod --append --groups kvm "$FIRECRAB_USER"
    fi

    # Group membership above is necessary but not always sufficient: some
    # hosts' /dev/kvm doesn't actually land in the kvm group the standard
    # udev rule (KERNEL=="kvm", GROUP="kvm") asks for — seen live on a
    # Parallels ARM host where it came up group "sgx" instead, which made
    # every VM start fail with "Kvm error: Permission denied (os error 13)"
    # even though $FIRECRAB_USER was correctly in the kvm group. An ACL
    # entry is a narrow, idempotent belt-and-suspenders fix: it grants the
    # kvm group access without touching whatever else owns the device node,
    # and re-running this is a no-op once the entry is already there.
    if [ -e /dev/kvm ]; then
        if have setfacl; then
            if ! getfacl -p /dev/kvm 2>/dev/null | grep -qx 'group:kvm:rw-'; then
                step "granting the kvm group ACL access to /dev/kvm"
                $SUDO setfacl -m g:kvm:rw /dev/kvm
                KVM_ACL_ADDED=1
            fi
        else
            warn "setfacl not found — skipping the /dev/kvm ACL fixup (install the 'acl' package if VMs fail with a KVM permission error)"
        fi
    fi
}

# Data, config and install directories with their intended owners.
ensure_directories() {
    $SUDO install -d -o root -g root -m 0755 "$LIBDIR" "$SHAREDIR"
    $SUDO install -d -o "$FIRECRAB_USER" -g "$FIRECRAB_GROUP" -m 0750 \
            "$DATADIR" "$DATADIR/data" "$DATADIR/images" "$DATADIR/updates"
    $SUDO install -d -o root -g "$FIRECRAB_GROUP" -m 0750 "$CONFDIR"
    log "directories ready under $DATADIR, $CONFDIR"
}

# What this host looked like before firecrab changed it, so --uninstall can put
# back what the installer and the helper changed and nothing else. Written once:
# a re-install must not record values the helper has already switched on.
record_host_baseline() {
    local file="$CONFDIR/host-baseline.env" content
    [ -f "$file" ] && return 0
    content="# written by install.sh; --uninstall restores what is listed here"$'\n'
    if [ "$KVM_ACL_ADDED" -eq 1 ]; then
        content+="kvm_acl=added"$'\n'
    fi
    # A helper that already ran has turned forwarding on, so those values are no
    # longer the host's own.
    if ! systemctl is-active --quiet firecrab-helper.service firecrab-net-helper.service 2>/dev/null; then
        content+="ip_forward=$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null || echo 0)"$'\n'
        if [ -r /proc/sys/net/ipv6/conf/all/forwarding ]; then
            content+="ipv6_forwarding=$(cat /proc/sys/net/ipv6/conf/all/forwarding)"$'\n'
        fi
    fi
    printf '%s' "$content" | $SUDO tee "$file" >/dev/null
    $SUDO chown root:"$FIRECRAB_GROUP" "$file"
    $SUDO chmod 0640 "$file"
}

# Keep the same release attribution available after the archive is unpacked.
# Firecracker's separately installed upstream notices live below firecracker/.
install_compliance() {
    [ -f "$PAYLOAD_LICENSE" ] || die "missing FireCrab LICENSE in install payload"
    [ -f "$PAYLOAD_GPL" ] || die "missing GPL-2.0 text in install payload"
    $SUDO install -d -o root -g root -m 0755 "$SHAREDIR/licenses"
    $SUDO install -o root -g root -m 0644 "$PAYLOAD_LICENSE" "$SHAREDIR/LICENSE"
    $SUDO install -o root -g root -m 0644 \
        "$PAYLOAD_GPL" "$SHAREDIR/licenses/GPL-2.0-only.txt"
    if [ -n "$PAYLOAD_THIRD_PARTY" ] && [ -n "$PAYLOAD_INVENTORY" ]; then
        $SUDO install -o root -g root -m 0644 \
            "$PAYLOAD_THIRD_PARTY" "$SHAREDIR/THIRD_PARTY_NOTICES.txt"
        $SUDO install -o root -g root -m 0644 \
            "$PAYLOAD_INVENTORY" "$SHAREDIR/release-license-inventory.json"
    else
        $SUDO rm -f "$SHAREDIR/THIRD_PARTY_NOTICES.txt" \
            "$SHAREDIR/release-license-inventory.json"
    fi
    log "license and attribution material installed to $SHAREDIR"
}

# Puts the payload binaries, and the dashboard, where the units expect them.
install_binaries() {
    local name src ingress_notices guest_notices
    # Validate the new required component before replacing any installed binary.
    src=$(firecrab_resolve_binary firecrab-ingress "$PAYLOAD_BIN" "$LIBDIR") \
        || die "missing firecrab-ingress; build with scripts/build-ingress.sh or install a host release"
    ingress_notices="$PAYLOAD_BIN/ingress"
    if [ "$src" -ef "$LIBDIR/firecrab-ingress" ]; then
        ingress_notices="$LIBDIR/ingress"
    fi
    for name in LICENSE.nginx LICENSE.pcre2 LICENSE.musl sources.json; do
        [ -f "$ingress_notices/$name" ] || die "missing ingress/$name in install payload"
    done
    for name in firecrab-api firecrab-helper firecrab-ingress; do
        src=$(firecrab_resolve_binary "$name" "$PAYLOAD_BIN" "$LIBDIR") \
            || die "no $name in ${PAYLOAD_BIN:-<release>} or $LIBDIR — pass it via --bin-dir or install a release"
        if [ "$src" -ef "$LIBDIR/$name" ]; then
            log "keeping existing $LIBDIR/$name"
        else
            $SUDO install -o root -g root -m 0755 "$src" "$LIBDIR/$name"
        fi
    done
    # Old custom payloads retain a usable serial console without resize support.
    if src=$(firecrab_resolve_binary firecrab-guest-console "$PAYLOAD_BIN" "$LIBDIR"); then
        guest_notices="$PAYLOAD_BIN/guest-console/LICENSE.musl"
        if [ "$src" -ef "$LIBDIR/firecrab-guest-console" ]; then guest_notices="$LIBDIR/guest-console/LICENSE.musl"; fi
        [ -f "$guest_notices" ] || die "missing guest-console/LICENSE.musl in install payload"
        if [ ! "$src" -ef "$LIBDIR/firecrab-guest-console" ]; then
            $SUDO install -o root -g root -m 0755 "$src" "$LIBDIR/firecrab-guest-console"
        fi
        if [ ! "$guest_notices" -ef "$LIBDIR/guest-console/LICENSE.musl" ]; then
            $SUDO install -d -o root -g root -m 0755 "$LIBDIR/guest-console"
            $SUDO install -o root -g root -m 0644 "$guest_notices" "$LIBDIR/guest-console/LICENSE.musl"
        fi
    fi
    # Keep the historical executable path usable by old units and clients.
    $SUDO ln -sfn firecrab-helper "$LIBDIR/firecrab-net-helper"
    # extract-vmlinux and extract-arm64-image are shell scripts used at
    # runtime by the API to turn a distro vmlinuz into the kernel format
    # Firecracker boots on each architecture. Installing them next to the
    # binary lets the API find them without needing access to the repo
    # checkout (which may be in a user home the service account cannot reach).
    for helper in extract-vmlinux extract-arm64-image; do
        [ -f "$PAYLOAD_EXTRACT/$helper" ] || die "missing $PAYLOAD_EXTRACT/$helper"
        $SUDO install -o root -g root -m 0755 \
            "$PAYLOAD_EXTRACT/$helper" "$LIBDIR/$helper"
    done
    local cli_src
    cli_src=$(firecrab_resolve_binary "firecrab" "$PAYLOAD_BIN" "$PREFIX/bin") \
        || die "no firecrab (CLI) in ${PAYLOAD_BIN:-<release>} or $PREFIX/bin — pass it via --bin-dir or install a release"
    if [ "$cli_src" -ef "$PREFIX/bin/firecrab" ]; then
        log "keeping existing $PREFIX/bin/firecrab"
    else
        $SUDO install -d -o root -g root -m 0755 "$PREFIX/bin"
        $SUDO install -o root -g root -m 0755 "$cli_src" "$PREFIX/bin/firecrab"
    fi
    if [ "$ingress_notices" != "$LIBDIR/ingress" ]; then
        $SUDO install -d -o root -g root -m 0755 "$LIBDIR/ingress"
        for name in LICENSE.nginx LICENSE.pcre2 LICENSE.musl sources.json; do
            $SUDO install -o root -g root -m 0644 "$ingress_notices/$name" "$LIBDIR/ingress/$name"
        done
    fi
    log "binaries installed to $LIBDIR (cli → $PREFIX/bin/firecrab)"

    [ "$WITH_FRONTEND" -eq 1 ] || return 0
    if [ -n "$PAYLOAD_DASHBOARD" ] && [ -f "$PAYLOAD_DASHBOARD/index.html" ]; then
        $SUDO rm -rf "$SHAREDIR/dashboard"
        $SUDO install -d -o root -g root -m 0755 "$SHAREDIR/dashboard"
        $SUDO cp -r "$PAYLOAD_DASHBOARD/." "$SHAREDIR/dashboard/"
        $SUDO chown -R root:root "$SHAREDIR/dashboard"
        log "dashboard installed to $SHAREDIR/dashboard"
    elif [ -f "$SHAREDIR/dashboard/index.html" ]; then
        log "keeping existing $SHAREDIR/dashboard"
    else
        die "dashboard not found — pass --dashboard-dir, or omit --no-frontend only when using a release bundle"
    fi
}

# On an enforcing SELinux host the services must not keep systemd's own
# domain.
#
# $PREFIX/lib/firecrab is labelled lib_t, and the policy rule that moves a
# service out of init_t into unconfined_service_t only fires for bin_t
# executables. Without this the API stays in init_t, where an outbound HTTPS
# connect is denied — every registry read fails with EACCES while a shell on
# the same account reaches the registry fine (public-docs/guides/troubleshooting.md).
label_selinux_binaries() {
    local pattern="${LIBDIR}(/.*)?"

    selinux_active || return 0

    # restorecon alone cannot help: it applies the policy's default context,
    # which for this path is lib_t. The rule has to be recorded first.
    if ! have semanage; then
        warn "semanage missing; cannot label $LIBDIR bin_t — the services will"
        warn "stay in init_t and every registry read will fail (see --doctor)"
        return 0
    fi

    # -a fails when the rule already exists, which a re-run always hits. Both
    # spellings narrate on stdout ("already defined, modifying instead"); the
    # log line below is the one an operator needs.
    $SUDO semanage fcontext -a -t bin_t "$pattern" >/dev/null 2>&1 \
        || $SUDO semanage fcontext -m -t bin_t "$pattern" >/dev/null 2>&1 \
        || { warn "could not record an SELinux file context for $LIBDIR"; return 0; }

    if have restorecon; then
        $SUDO restorecon -R "$LIBDIR" >/dev/null 2>&1 \
            || warn "restorecon failed for $LIBDIR"
    fi
    log "SELinux: $LIBDIR labelled bin_t"
}

# Seeds api.env once so an operator's edits survive a re-run.
install_config() {
    if [ -f "$CONFDIR/api.env" ]; then
        log "keeping existing $CONFDIR/api.env"
        return 0
    fi
    $SUDO tee "$CONFDIR/api.env" > /dev/null <<'ENVFILE'
# firecrab API settings. The unit already sets the image root, the dashboard
# assets and the working directory; uncomment only what you want to change.
#
# FIRECRAB_BIND_ADDR=127.0.0.1:5523
# A non-loopback address requires authentication AND TLS to be enabled.
# FIRECRAB_ALLOWED_ORIGINS=
# Empty is correct while the dashboard is served from this same origin.
# FIRECRAB_FIRECRACKER_BIN=firecracker
#
# M2Image install (Images page / POST /api/images/{alias}/install).
# Points at a directory-style base that serves `{alias}.tar.zst` packages
# (see scripts/package-m2images.sh). Unset = use the public MicroRegistry.
# Example local mirror:
# FIRECRAB_IMAGE_BASE_URL=http://127.0.0.1:8765
# Requires host tools: tar, zstd. Restart firecrab-api after changing this.
ENVFILE
    $SUDO chown root:"$FIRECRAB_GROUP" "$CONFDIR/api.env"
    $SUDO chmod 0640 "$CONFDIR/api.env"
    log "wrote $CONFDIR/api.env"
}

# Renders the unit templates with this host's paths and account.
install_units() {
    local uid source legacy="$UNITDIR/firecrab-net-helper.service"
    uid=$(id -u "$FIRECRAB_USER")
    local unit
    for unit in "${UNITS[@]}"; do
        source="$PAYLOAD_UNITS/$unit"
        if [ "$unit" = firecrab-helper.service ] && [ ! -f "$source" ]; then
            source="$PAYLOAD_UNITS/firecrab-net-helper.service"
        fi
        [ -r "$source" ] || die "missing unit template $source"
    done
    # Only a real old unit needs migration. An alias from a previous install
    # already denotes the new service and must not be disabled separately.
    if [ -f "$legacy" ] && [ ! -L "$legacy" ]; then
        $SUDO systemctl disable --now firecrab-net-helper.service
        $SUDO rm -f "$legacy"
    fi
    for unit in "${UNITS[@]}"; do
        source="$PAYLOAD_UNITS/$unit"
        if [ "$unit" = firecrab-helper.service ] && [ ! -f "$source" ]; then
            source="$PAYLOAD_UNITS/firecrab-net-helper.service"
        fi
        sed -e 's/firecrab-net-helper/firecrab-helper/g' \
            -e '/^Alias=/d' \
            -e "s|@LIBDIR@|$LIBDIR|g" \
            -e "s|@SHAREDIR@|$SHAREDIR|g" \
            -e "s|@DATADIR@|$DATADIR|g" \
            -e "s|@CONFDIR@|$CONFDIR|g" \
            -e "s|@UNITDIR@|$UNITDIR|g" \
            -e "s|@PREFIX@|$PREFIX|g" \
            -e "s|@FIRECRAB_USER@|$FIRECRAB_USER|g" \
            -e "s|@FIRECRAB_GROUP@|$FIRECRAB_GROUP|g" \
            -e "s|@FIRECRAB_UID@|$uid|g" \
            "$source" | $SUDO tee "$UNITDIR/$unit" > /dev/null
        if [ "$unit" = firecrab-helper.service ]; then
            printf 'Alias=firecrab-net-helper.service\n' | $SUDO tee -a "$UNITDIR/$unit" >/dev/null
        fi
        $SUDO chmod 0644 "$UNITDIR/$unit"
    done
    $SUDO ln -sfn firecrab-helper.service "$legacy"
    $SUDO systemctl daemon-reload
    log "units installed to $UNITDIR"
}

# --- guest images ----------------------------------------------------------

# Whether a guest rootfs is already installed.
images_present() {
    compgen -G "$DATADIR/images/rootfs/*.ext4" >/dev/null 2>&1
}

# --- run -------------------------------------------------------------------

# Enables and starts both units, then confirms they really came up.
start_units() {
    $SUDO systemctl enable "${UNITS[@]}"
    $SUDO systemctl restart "${UNITS[@]}"
    sleep 1
    local failed=0
    for unit in "${UNITS[@]}"; do
        if systemctl is-active --quiet "$unit"; then
            log "$unit is running"
        else
            warn "$unit failed to start — journalctl -u $unit -n 30"
            failed=1
        fi
    done
    if [ "$failed" -eq 0 ]; then
        wait_for_api || failed=1
    fi
    return $failed
}

# `is-active` on a Type=simple unit is true as soon as the process forks —
# not once it is actually serving. At startup firecrab-api hashes every
# present template artifact before binding its listener (see
# TemplateRegistry::from_specs in firecrab-api/src/templates.rs), and a
# large rootfs from a previous OCI import can take noticeably longer to hash
# than the process takes to fork. Poll the real HTTP port so callers relying
# on start_units's return don't race it.
wait_for_api() {
    local bind=${FIRECRAB_BIND_ADDR:-127.0.0.1:5523}
    local _attempt
    for _attempt in $(seq 1 60); do
        curl -fs -o /dev/null "http://$bind/" 2>/dev/null && return 0
        sleep 1
    done
    warn "firecrab-api did not answer http://$bind/ within 60s — journalctl -u firecrab-api -n 30"
    return 1
}

# Report-only path: every gap, no changes, no root required.
do_check() {
    log "checking host readiness (nothing will be changed)"
    local gaps=0
    if detect_pkg; then
        log "package manager: $PKG"
    else
        warn "no known package manager (apt/dnf/zypper/pacman/apk)"
        gaps=1
    fi
    ensure_runtime_deps || gaps=1
    if [ "$WITH_PIPE" = 1 ]; then
        [ -x "$LIBDIR/firecrab-ingress" ] || { warn "missing bundled firecrab-ingress"; gaps=1; }
        if [ -x "$LIBDIR/pipe/cloudflared" ]; then
            log "pipe engine present: $LIBDIR/pipe/cloudflared"
        else
            warn "missing pipe engine: $LIBDIR/pipe/cloudflared (installed with --with-pipe)"
            gaps=1
        fi
    fi
    ensure_firecracker || gaps=1
    report_payload || gaps=1
    check_kvm || gaps=1
    check_systemd || gaps=1
    report_ufw

    if [ "$gaps" -eq 0 ]; then
        log "host is ready — nothing missing"
    else
        warn "run './install.sh' to install the above"
    fi
    step "for runtime host diagnostics (UFW, helper socket, wrong DB path): ./install.sh --doctor"
    return 0
}

# Runtime host diagnostics — delegates to `firecrab doctor` (no root).
do_doctor() {
    local cli
    if have firecrab; then
        cli=firecrab
    elif [ -x "$REPO_ROOT/target/release/firecrab" ]; then
        cli=$REPO_ROOT/target/release/firecrab
    elif [ -x "$REPO_ROOT/target/debug/firecrab" ]; then
        cli=$REPO_ROOT/target/debug/firecrab
    else
        die "missing firecrab CLI (run 'cargo build -p firecrab-cli', or install first)"
    fi
    # Pass DATADIR through so the doctor matches this install layout.
    DATADIR="$DATADIR" FIRECRAB_API_USER="$FIRECRAB_USER" \
        exec "$cli" doctor "${DOCTOR_ARGS[@]}"
}

# Installs what firecrab needs and stops there — no account, directories, units
# or services. This is the part that differs per distribution, so it is what a
# container without systemd (and CI's distro matrix) can usefully exercise.
do_deps() {
    require_sudo_ticket
    detect_pkg || die "no known package manager (apt/dnf/zypper/pacman/apk)"

    ensure_runtime_deps || die "missing runtime dependencies (see above)"
    ensure_firecracker  || die "firecracker is required"
    check_kvm || warn "no KVM here; that only matters where VMs actually run"
    report_ufw
    log "dependencies ready — run without --deps-only to install firecrab"
}

do_print_install_url() {
    firecrab_install_curl_url "${RELEASE_VERSION:-latest}"
}

do_print_release_url() {
    local tarball
    tarball=$(firecrab_host_tarball "" "$LIBC") || die "unsupported architecture: $(uname -m) (need x86_64 or aarch64)"
    firecrab_release_asset_url "$RELEASE_VERSION" "$tarball"
}

# `curl | bash` occupies stdin, so sudo cannot read a password from it.
# Ask once on the real terminal; later $SUDO calls reuse the ticket.
require_sudo_ticket() {
    [ -n "$SUDO" ] || return 0
    sudo -n true >/dev/null 2>&1 && return 0
    [ -e /dev/tty ] || die "sudo needs a password and this session has no terminal"
    step "sudo password required"
    # Redirects attach sudo's prompt to the controlling tty, not stdin.
    # shellcheck disable=SC2024
    sudo -v </dev/tty >/dev/tty 2>/dev/tty || die "sudo authentication failed"
}

# The default path: fill the gaps, fetch binaries, lay out, start.
do_install() {
    require_sudo_ticket
    # Checked before anything is installed: refusing after pulling in five
    # packages would be a waste of the operator's time.
    check_systemd || die "this installer manages systemd units"
    detect_pkg || warn "no known package manager — anything missing has to be installed by hand"

    ensure_runtime_deps || die "missing runtime dependencies (see above)"
    ensure_firecracker  || die "firecracker is required"
    check_kvm || warn "continuing, but VMs will not start until KVM is available"

    resolve_payload
    ensure_account
    ensure_directories
    record_host_baseline
    install_compliance
    install_binaries
    label_selinux_binaries
    install_config
    install_units
    if [ "$WITH_PIPE" = 1 ]; then
        $SUDO env PREFIX="$PREFIX" FIRECRAB_LIBDIR="$LIBDIR" DATADIR="$DATADIR" \
            CONFDIR="$CONFDIR" UNITDIR="$UNITDIR" "$PREFIX/bin/firecrab" pipe install
    fi
    # Guest images are not part of the host install — import one afterwards
    # with POST /api/oci/import or the dashboard Images page.
    start_units || die "installed, but a unit did not come up"
    report_ufw

    local bind=${FIRECRAB_BIND_ADDR:-127.0.0.1:5523}
    log "done — dashboard at http://$bind/"
    if ! images_present; then
        step "guest images: install from the dashboard Images page"
    fi
}

# Each MicroVM runs in its own transient unit, which is meant to outlive the API
# so that restarting the API never kills a guest. Stopping the API therefore
# leaves every shim and its firecracker process running with nothing to manage
# them, so the VMs are stopped first, while the helper can still clean up after
# them.
stop_vm_units() {
    local units
    units=$(systemctl list-units --all --plain --no-legend --no-pager 'firecrab-vm-*.service' 2>/dev/null \
        | awk '{print $1}') || true
    [ -n "$units" ] || return 0
    step "stopping $(printf '%s\n' "$units" | wc -l | tr -d ' ') MicroVM unit(s)"
    # shellcheck disable=SC2086  # one word per unit
    $SUDO systemctl stop $units \
        || warn "some MicroVM units did not stop — their firecracker processes may keep running"
    # A failed transient unit is not collected on its own.
    # shellcheck disable=SC2086
    $SUDO systemctl reset-failed $units 2>/dev/null || true
}

# Whether something besides firecrab's own (already removed) networks may rely on
# IP forwarding: a bridge or a veth is left. When `ip` cannot tell, assume so.
others_may_route() {
    local kind out
    for kind in bridge veth; do
        out=$(ip -o link show type "$kind" 2>/dev/null) || return 0
        [ -z "$out" ] || return 0
    done
    return 1
}

# Puts back what record_host_baseline recorded: the /dev/kvm ACL the installer
# added, and IP forwarding where the host had it off and still has it on. A host
# installed before the record existed is left exactly as it is.
restore_host_baseline() {
    local file="$CONFDIR/host-baseline.env" content spec key was
    local -a to_disable=()
    content=$($SUDO cat "$file" 2>/dev/null) || return 0

    if printf '%s\n' "$content" | grep -qx 'kvm_acl=added' \
        && have getfacl && getfacl -p /dev/kvm 2>/dev/null | grep -qx 'group:kvm:rw-'; then
        if $SUDO setfacl -x g:kvm /dev/kvm; then
            # `setfacl -x` leaves a bare mask behind; with nothing else named in
            # the ACL take it all off. An entry another tool added is kept.
            if ! getfacl -p /dev/kvm 2>/dev/null | grep -Eq '^(user|group):[^:]+:'; then
                $SUDO setfacl -b /dev/kvm
            fi
            log "removed the kvm group ACL the installer added to /dev/kvm"
        else
            warn "could not remove the kvm group ACL from /dev/kvm — it resets at reboot"
        fi
    fi

    for spec in ip_forward:net.ipv4.ip_forward ipv6_forwarding:net.ipv6.conf.all.forwarding; do
        was=$(printf '%s\n' "$content" | sed -n "s/^${spec%%:*}=//p" | head -n 1)
        key=${spec#*:}
        if [ "$was" = 0 ] && [ "$(sysctl -n "$key" 2>/dev/null)" = 1 ]; then
            to_disable+=("$key")
        fi
    done
    if [ "${#to_disable[@]}" -gt 0 ]; then
        if others_may_route; then
            warn "left ${to_disable[*]} on: other bridges or containers are present (resets at reboot)"
        else
            for key in "${to_disable[@]}"; do
                if $SUDO sysctl -qw "$key=0"; then
                    log "restored $key=0"
                else
                    warn "could not restore $key=0"
                fi
            done
        fi
    fi
    # The next install records the host as it is then.
    $SUDO rm -f "$file"
}

# Removes what this script installed; data only with --purge.
do_uninstall() {
    require_sudo_ticket
    stop_vm_units
    for unit in firecrab-address-updater.service firecrab-tunnel.service firecrab-ingress.service firecrab-api.service firecrab-helper.service firecrab-net-helper.service; do
        $SUDO systemctl disable --now "$unit" 2>/dev/null || true
        $SUDO rm -f "$UNITDIR/$unit"
    done
    $SUDO systemctl daemon-reload
    log "units removed"
    $SUDO rm -rf "$LIBDIR/pipe"

    # SIGTERM (above) only stops the helper's socket loop — it never deletes
    # the bridges, TAP devices or nftables tables it created, only a reboot
    # would. Run its own teardown while the binary is still on disk.
    local teardown_helper
    if teardown_helper=$(firecrab_resolve_binary firecrab-helper "" "$LIBDIR"); then
        $SUDO "$teardown_helper" --teardown \
            || warn "network teardown failed — bridges/nftables tables may remain until reboot"
    fi

    $SUDO rm -f "$LIBDIR/firecrab-api" "$LIBDIR/firecrab-helper" "$LIBDIR/firecrab-net-helper" \
        "$LIBDIR/extract-vmlinux" "$LIBDIR/extract-arm64-image"
    $SUDO rm -f "$PREFIX/bin/firecrab" "$LIBDIR/firecrab-ingress" "$LIBDIR/firecrab-guest-console"
    $SUDO rm -rf "$LIBDIR/guest-console"
    $SUDO rm -rf "$LIBDIR/ingress"
    $SUDO rm -rf "$SHAREDIR/dashboard"
    $SUDO rm -f "$SHAREDIR/LICENSE" "$SHAREDIR/THIRD_PARTY_NOTICES.txt" \
        "$SHAREDIR/release-license-inventory.json"
    $SUDO rm -rf "$SHAREDIR/licenses"
    # Firecracker is installed independently and is intentionally not removed;
    # keep its upstream notices alongside that remaining binary.
    $SUDO rmdir --ignore-fail-on-non-empty "$LIBDIR" "$SHAREDIR" 2>/dev/null || true
    # The file-context rule outlives the directory, so leaving it behind would
    # silently relabel whatever is installed there next.
    if have semanage; then
        $SUDO semanage fcontext -d "${LIBDIR}(/.*)?" 2>/dev/null || true
    fi
    log "binaries and dashboard removed"

    # Before the purge below: the record lives in $CONFDIR.
    restore_host_baseline

    # Left alone on purpose: the account, the config and the data directory.
    if [ "$PURGE" -eq 1 ]; then
        warn "purging $DATADIR and $CONFDIR (all VM disks and the database)"
        $SUDO rm -rf "$DATADIR" "$CONFDIR"
    else
        log "kept $DATADIR and $CONFDIR — pass --purge to delete them too"
    fi
}

case "$MODE" in
    check) do_check ;;
    doctor) do_doctor ;;
    deps) do_deps ;;
    print-install-url) do_print_install_url ;;
    print-release-url) do_print_release_url ;;
    install) do_install ;;
    uninstall) do_uninstall ;;
esac