diff --git a/configs/config.yaml.example b/configs/config.yaml.example index 8ecdb14..d7bb93c 100644 --- a/configs/config.yaml.example +++ b/configs/config.yaml.example @@ -38,7 +38,7 @@ net: # empty = IPv6 disabled (default). Containers get global # addresses via SLAAC; no NAT; set at install time. -lxd: +incus: image: "vpsmgr/debian-sshd" image_fallback: "images:debian/13" pool: vpsmgr diff --git a/configs/nftables.conf b/configs/nftables.conf index 05f616e..86cbd3c 100644 --- a/configs/nftables.conf +++ b/configs/nftables.conf @@ -1,5 +1,5 @@ -# vpsmgr main nftables config — GENERATED by `vpsmgr install`. -# This file is re-applied on boot by vpsmgr-nft.service. +# vpsmgr main nftables config — GENERATED by `vps install`. +# This file is re-applied on boot by vps-nft.service. # Per-user DNAT rules live in /etc/vpsmgr/nftables.d/user-.nft. table inet vpsmgr { chain prerouting { diff --git a/configs/sudoers.d/vps b/configs/sudoers.d/vps new file mode 100644 index 0000000..d17944e --- /dev/null +++ b/configs/sudoers.d/vps @@ -0,0 +1,32 @@ +# Managed by vpsmgr — installed file, do not edit by hand. +# Changes are overwritten on the next install. +# +# The vps panel runs as the unprivileged 'vps' user. These are the ONLY +# root-privileged commands it may run, each pinned to its exact invocation: +# - nft: reload the vpsmgr nftables ruleset (fw.Reload) +# - systemctl: manage the traefik proxy and restart the vps panel itself +# - ip: IPv6 pass-through route / neighbor / addr changes +# - sysctl: toggle IPv6 forwarding +# - service/pkill/ndppd: control the ndppd NDP proxy daemon +# Nothing else may be run as root by the panel. +vps ALL=(root) NOPASSWD: /usr/sbin/nft add table inet vpsmgr +vps ALL=(root) NOPASSWD: /usr/sbin/nft -f /etc/vpsmgr/nftables.conf +vps ALL=(root) NOPASSWD: /usr/bin/systemctl enable traefik.service +vps ALL=(root) NOPASSWD: /usr/bin/systemctl disable traefik.service +vps ALL=(root) NOPASSWD: /usr/bin/systemctl start traefik.service +vps ALL=(root) NOPASSWD: /usr/bin/systemctl stop traefik.service +vps ALL=(root) NOPASSWD: /usr/bin/systemctl is-active traefik.service +vps ALL=(root) NOPASSWD: /usr/bin/systemctl restart vps.service +vps ALL=(root) NOPASSWD: /usr/bin/systemctl is-active vps.service +vps ALL=(root) NOPASSWD: /sbin/ip -6 route del * +vps ALL=(root) NOPASSWD: /sbin/ip -6 route add * +vps ALL=(root) NOPASSWD: /sbin/ip -6 route replace * +vps ALL=(root) NOPASSWD: /sbin/ip -6 neigh del proxy * +vps ALL=(root) NOPASSWD: /sbin/ip -6 neigh add proxy * +vps ALL=(root) NOPASSWD: /sbin/ip -6 addr replace * +vps ALL=(root) NOPASSWD: /sbin/sysctl -w net.ipv6.conf.all.forwarding=1 +vps ALL=(root) NOPASSWD: /usr/sbin/service ndppd restart +vps ALL=(root) NOPASSWD: /usr/sbin/service ndppd start +vps ALL=(root) NOPASSWD: /usr/sbin/service ndppd stop +vps ALL=(root) NOPASSWD: /usr/bin/pkill -x ndppd +vps ALL=(root) NOPASSWD: /usr/sbin/ndppd -d -p /var/run/ndppd.pid diff --git a/configs/systemd/vps-nft.service b/configs/systemd/vps-nft.service new file mode 100644 index 0000000..d1fc251 --- /dev/null +++ b/configs/systemd/vps-nft.service @@ -0,0 +1,17 @@ +# Managed by vpsmgr — installed file, do not edit by hand. +# Changes are overwritten on the next install. +[Unit] +Description=vps nftables rules +After=network-online.target incus.service +Wants=network-online.target +Before=vps.service + +[Service] +Type=oneshot +# The config file starts with a delete-table line so the whole ruleset reloads +# as ONE atomic batch (a failed rule keeps the previous table intact). +ExecStart=/usr/sbin/nft -f /etc/vpsmgr/nftables.conf +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/configs/systemd/vps.service b/configs/systemd/vps.service new file mode 100644 index 0000000..97484ff --- /dev/null +++ b/configs/systemd/vps.service @@ -0,0 +1,19 @@ +# Managed by vpsmgr — installed file, do not edit by hand. +# Changes are overwritten on the next install. +[Unit] +Description=vps panel +After=network-online.target incus.service +Wants=network-online.target + +[Service] +Type=simple +# Unprivileged: the panel talks to Incus over its group-readable socket +# (incus-admin) and escalates only the exact whitelisted commands via sudo. +User=vps +Group=vps +ExecStart=/usr/local/bin/vps serve +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/configs/systemd/vpsmgr-nft.service b/configs/systemd/vpsmgr-nft.service deleted file mode 100644 index f2a6f1d..0000000 --- a/configs/systemd/vpsmgr-nft.service +++ /dev/null @@ -1,15 +0,0 @@ -# Managed by vpsmgr — installed file, do not edit by hand. -# Changes are overwritten on the next install. -[Unit] -Description=vpsmgr nftables rules -After=network-online.target lxd.service -Wants=network-online.target -Before=vpsmgr-panel.service - -[Service] -Type=oneshot -ExecStart=/bin/sh -c 'nft delete table inet vpsmgr 2>/dev/null; exec nft -f /etc/vpsmgr/nftables.conf' -RemainAfterExit=yes - -[Install] -WantedBy=multi-user.target diff --git a/configs/systemd/vpsmgr-panel.service b/configs/systemd/vpsmgr-panel.service deleted file mode 100644 index 148bdb6..0000000 --- a/configs/systemd/vpsmgr-panel.service +++ /dev/null @@ -1,15 +0,0 @@ -# Managed by vpsmgr — installed file, do not edit by hand. -# Changes are overwritten on the next install. -[Unit] -Description=vpsmgr panel -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -ExecStart=/usr/local/bin/vpsmgr serve -Restart=always -RestartSec=3 - -[Install] -WantedBy=multi-user.target diff --git a/install.sh b/install.sh index f2099e0..cfe8693 100755 --- a/install.sh +++ b/install.sh @@ -7,7 +7,6 @@ set -euo pipefail cd "$(dirname "$0")" ROOT="$PWD" -export PATH="$PATH:/snap/bin" BUILD_MODE=release if [[ "${1:-}" == "--local-build" ]]; then @@ -20,20 +19,16 @@ if [[ $EUID -ne 0 ]]; then exit 1 fi -# v0.3 makes breaking changes; a 0.1.x/0.2.x install must NOT be upgraded yet. -# Abort before any script runs so the box cannot end up half-upgraded (v0.3 -# scripts over a v0.2.x binary). A box that already uninstalled is still caught -# later by `vps install`, which refuses to adopt an old config. -if [[ -x /usr/local/bin/vpsmgr ]]; then - OLD_VER="$(/usr/local/bin/vpsmgr version 2>/dev/null || true)" - case "$OLD_VER" in - 0.1.*|0.2.*) - echo "error: this is the vpsmgr v0.3 installer, which makes breaking changes." >&2 - echo " this box still runs vpsmgr $OLD_VER, which cannot be upgraded yet." >&2 - echo " stay on v0.2.x for now; a migration path will be released later." >&2 - exit 1 - ;; - esac +# The Incus-based vpsmgr is a new product line. A leftover binary from the old +# LXD-based v0.2.x/early-v0.3 line is not compatible — refuse to run over it +# so the box cannot end up half-upgraded. A non-purging uninstall of the old +# line leaves no binary (uninstall.sh removes it), so this only fires when the +# old panel was never uninstalled. +if [[ -x /usr/local/bin/vpsmgr ]] && ! /usr/local/bin/vpsmgr version 2>/dev/null | grep -q "0.3"; then + echo "error: an old LXD-based vpsmgr binary is still installed." >&2 + echo " this installer uses Incus and cannot run over it." >&2 + echo " uninstall the old version first (bash uninstall.sh --purge)." >&2 + exit 1 fi # Local build: make it obvious WHICH branch will be compiled, and give the user @@ -72,7 +67,7 @@ if [[ -n "${VPSMGR_IPV6_SUBNET:-}" ]]; then systemctl enable ndppd.service >/dev/null 2>&1 || true fi -for step in 00-check 10-lxd 20-network 30-traefik 40-panel 50-image; do +for step in 00-check 10-incus 20-network 30-traefik 40-panel 50-image; do echo echo "===== $step =====" bash "$ROOT/scripts/$step.sh" diff --git a/scripts/00-check.sh b/scripts/00-check.sh index 03691aa..8e8e893 100755 --- a/scripts/00-check.sh +++ b/scripts/00-check.sh @@ -11,8 +11,9 @@ if [[ $EUID -ne 0 ]]; then die "must run as root"; fi if [[ ! -f /etc/os-release ]]; then die "cannot find /etc/os-release"; fi . /etc/os-release case "${ID:-}:${VERSION_ID:-}" in - ubuntu:24.04|ubuntu:26.04) ;; - *) die "this installer targets Ubuntu 24.04 / 26.04 (got ${PRETTY_NAME:-unknown})" ;; + 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) --- @@ -85,7 +86,7 @@ else fi # --- packages --- -for p in snapd nftables zstd curl; do +for p in nftables zstd curl gpg; do if ! dpkg -s "$p" >/dev/null 2>&1; then log "installing $p" apt-get update -qq @@ -98,18 +99,18 @@ if command -v go >/dev/null 2>&1; then log "go: $(go version 2>/dev/null | awk '{print $3}')" fi -# --- LXD snap --- -if ! snap list lxd >/dev/null 2>&1; then - log "lxd snap not installed yet (installed by 10-lxd.sh)" +# --- Incus --- +if ! command -v incus >/dev/null 2>&1; then + log "incus not installed yet (installed by 10-incus.sh)" fi # --- UFW conflict --- -# LXD manages its own `table inet lxd` nftables rules (DHCP/DNS/forwarding) on -# lxdbr0, but UFW's `table ip filter` runs at the same priority with a DROP -# policy, so it drops container DHCP/forwarding traffic before LXD's rules +# Incus manages its own `table inet incus` nftables rules (DHCP/DNS/forwarding) +# on lxdbr0, 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://canonical.com/lxd/docs/latest/howto/network_bridge_firewalld/ +# 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). @@ -119,11 +120,11 @@ if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: a 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 "lxdbr0|$V4_MATCH"; then - log "ufw active but already has LXD/lxdbr0 allow rules — leaving it as-is" + log "ufw active but already has Incus/lxdbr0 allow rules — leaving it as-is" else - log "ufw is ACTIVE with default-DROP policy — this breaks LXD container IPv4" - log " (LXD's own nftables rules are shadowed by ufw's DROP; known issue:" - log " https://canonical.com/lxd/docs/latest/howto/network_bridge_firewalld/)" + 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. @@ -185,7 +186,7 @@ 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 — LXD image pull will fail unless cached" + log " warn: cannot reach images.linuxcontainers.org — Incus image pull will fail unless cached" fi echo "[00] checks passed" diff --git a/scripts/10-incus.sh b/scripts/10-incus.sh new file mode 100644 index 0000000..5c5f575 --- /dev/null +++ b/scripts/10-incus.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# 10-incus.sh — install Incus (Zabbly LTS repo), storage pool, bridge. +# Targets Debian 12/13 and Ubuntu 22.04/24.04/26.04 via the Zabbly package +# repository (the Incus team's official third-party repo, maintained by the +# project lead). Version follows the lts-7.0 channel. +set -uo pipefail + +log(){ echo "[10] $*"; } +die(){ echo "[10] error: $*" >&2; exit 1; } + +# --- ensure the Zabbly repo is present, then install Incus --- +ZABBLY_SOURCE="/etc/apt/sources.list.d/zabbly-incus-lts-7.0.sources" +if [[ -f "$ZABBLY_SOURCE" ]]; then + log "zabbly incus lts-7.0 repo already configured" +else + log "adding Zabbly Incus LTS 7.0 repository..." + install -d -m 0755 /etc/apt/keyrings + curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc \ + || die "could not download the Zabbly signing key" + # Verify the published fingerprint (4EFC 5906 96CB 15B8 7C73 A3AD 82CC 8797 C838 DCFD). + FP=$(gpg --show-keys --with-colons /etc/apt/keyrings/zabbly.asc 2>/dev/null | awk -F: '/^fpr:/{print $10; exit}') + if [[ "$FP" != "4EFC590696CB15B87C73A3AD82CC8797C838DCFD" ]]; then + die "zabbly key fingerprint mismatch ($FP) — refusing to install from an unverified repo" + fi + cat > "$ZABBLY_SOURCE" </dev/null 2>&1; then + log "incus already installed: $(incus version 2>/dev/null | head -1)" +else + log "installing incus (Zabbly lts-7.0)..." + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq incus || die "apt install incus failed" +fi +log "incus version: $(incus version 2>/dev/null | head -1)" + +# Make the incus-admin group available to the vps service user (created by +# 40-panel.sh / `vps install`). The panel talks to Incus over its Unix socket, +# which is group-readable by incus-admin — membership grants the same control +# the root-only LXD snap used to give, without running as root. +if id -u vps >/dev/null 2>&1; then + usermod -aG incus-admin vps >/dev/null 2>&1 || true + log "added user 'vps' to group incus-admin (panel can manage Incus via the API)" +fi + +# Wait for the daemon socket to be up (systemd socket activation). +for i in $(seq 1 60); do + [[ -S /var/lib/incus/unix.socket ]] && break + sleep 1 +done +[[ -S /var/lib/incus/unix.socket ]] || die "incus daemon socket not ready" + +# --- storage pool --- +# The Debian-package Incus bundles its own userspace tools; on a kernel that +# ships the zfs module the pool is created with zfs. Like the LXD snap, no +# host zfsutils are needed. Fall back to dir (no quotas) when zfs is +# unavailable. +POOL=vpsmgr +POOL_EXISTS=0 +if incus storage show "$POOL" >/dev/null 2>&1; then + POOL_EXISTS=1 + log "storage pool '$POOL' exists ($(incus storage show "$POOL" | awk -F': ' '/driver:/{print $2}'))" +fi + +# find a spare whole-disk block device (no partitions, not the root disk, unmounted) +find_spare_disk(){ + ROOT_DISK=$(lsblk -rno NAME,MOUNTPOINTS | awk '$2=="/"{print $1; exit}' | sed 's/[0-9]*$//') + for d in $(lsblk -rno NAME,TYPE | awk '$2=="disk"{print $1}'); do + [[ "$d" == "$ROOT_DISK" ]] && continue + [[ -b "/dev/$d" ]] || continue + NCHILD=$(lsblk -rno NAME "$d" | tail -n +2 | wc -l) + [[ "$NCHILD" -gt 0 ]] && continue + grep -q "/dev/$d" /proc/mounts && continue + echo "/dev/$d"; return 0 + done + return 1 +} + +# decide how to configure the pool in preseed +DRIVER=zfs +SPARE="" +POOL_SIZE_MB="" +if [[ $POOL_EXISTS -eq 1 ]]; then + # adopt existing pool + DRIVER=$(incus storage show "$POOL" | awk -F': ' '/driver:/{print $2}') + SRC_LINE=" source: \"$POOL\"" + SIZE_LINE="" +else + SPARE=$(find_spare_disk || true) + if [[ -n "$SPARE" ]]; then + log "zfs pool '$POOL' will be created on spare block device $SPARE" + SRC_LINE=" source: \"$SPARE\"" + SIZE_LINE="" + else + FREE_KB=$(df -k --output=avail / | tail -1 | tr -d ' ') + # Pool ceiling = a share of the free space, as a sparse loop file that + # grows on demand. Small disks keep 80% so the host / Incus image store + # keep enough headroom; big disks (>= 20 GiB free) can afford 90%. + PCT=80 + if (( FREE_KB > 20 * 1024 * 1024 )); then + PCT=90 + fi + POOL_SIZE_MB=$(( FREE_KB * PCT / 100 / 1024 )) + log "loop-file zfs pool '$POOL' (~${POOL_SIZE_MB} MiB = ${PCT}% of free, created by Incus)" + SRC_LINE="" + SIZE_LINE=" size: \"${POOL_SIZE_MB}MiB\"" + fi +fi + +# --- incus admin init (preseed) --- +# IPv6 pass-through: when VPSMGR_IPV6_SUBNET is set, lxdbr0 carries the global +# prefix (no NAT) and containers SLAAC global addresses from it. The bridge +# ADDRESS is deliberately not set here: the Go setup (SetupIPv6Bridge, run at +# `vps install`) picks a free address inside the prefix and skips any the host +# already uses — hardcoding prefix::1 at init time would claim the most common +# upstream-router / host address before that clash check ever runs. +V6_IP="none" +V6_NAT="false" +if [[ -n "${VPSMGR_IPV6_SUBNET:-}" ]]; then + log "IPv6 pass-through enabled: lxdbr0 address chosen by \`vps install\` (clash-avoiding)" +fi +# Container IPv4 subnet (10..0.0/24): the bridge gateway is .1. Defaults to +# 10.115.0.1/24; 00-ip-ask.sh exports the chosen subnet before this step. +V4_GW="$(echo "${VPSMGR_IPV4_SUBNET:-10.115.0.0/24}" | cut -d. -f1-3).1/24" +if [[ $POOL_EXISTS -eq 0 ]] || ! incus network show lxdbr0 >/dev/null 2>&1; then + PRESEED=/tmp/vpsmgr-preseed.yaml + cat > "$PRESEED" <.lxd records for every container, so any tenant could nmap the + # subnet and read everyone's username. dns.mode=none keeps DHCP + upstream + # forwarding but drops the instance-name records. + dns.mode: none + description: "" + name: lxdbr0 + type: bridge +storage_pools: +- config: +$SRC_LINE +$SIZE_LINE + description: "" + name: $POOL + driver: $DRIVER +profiles: +- config: {} + description: Default Incus profile + devices: + eth0: + name: eth0 + nictype: bridged + parent: lxdbr0 + type: nic + root: + path: / + pool: $POOL + type: disk + name: default +EOF + log "running incus admin init --preseed (driver=$DRIVER, subnet $V4_GW, ipv6=${VPSMGR_IPV6_SUBNET:-none})" + if ! incus admin init --preseed < "$PRESEED"; then + log "preseed failed — creating missing pieces" + if [[ -n "${VPSMGR_IPV6_SUBNET:-}" ]]; then + incus network show lxdbr0 >/dev/null 2>&1 || incus network create lxdbr0 ipv4.address=$V4_GW ipv4.nat=true ipv6.address=none ipv6.nat=false ipv6.dhcp.stateful=true ipv6.routing=true dns.mode=none + else + incus network show lxdbr0 >/dev/null 2>&1 || incus network create lxdbr0 ipv4.address=$V4_GW ipv4.nat=true ipv6.address=none dns.mode=none + fi + if ! incus storage show "$POOL" >/dev/null 2>&1; then + if [[ -n "$SPARE" ]]; then + incus storage create "$POOL" zfs source="$SPARE" || true + elif [[ -n "$POOL_SIZE_MB" ]]; then + incus storage create "$POOL" zfs size="${POOL_SIZE_MB}MiB" || true + fi + # last resort: dir backend (no quotas) + incus storage show "$POOL" >/dev/null 2>&1 || { + log " warn: zfs pool creation failed, using dir backend (quotas disabled)" + incus storage create "$POOL" dir + } + fi + fi + # ensure default profile devices point at our pool/bridge + incus profile set default root.pool "$POOL" 2>/dev/null || true + incus profile device set default root pool "$POOL" 2>/dev/null || true + incus profile device set default eth0 parent lxdbr0 2>/dev/null || true +else + log "Incus already initialized (pool+network present)" +fi + +# Always re-assert dns.mode=none (fresh install and upgrade alike): this is the +# only thing that stops Incus from publishing .lxd DNS/PTR records +# that let any tenant enumerate every other user's username with a subnet scan. +incus network set lxdbr0 dns.mode=none 2>/dev/null || true + +DRIVER_NOW=$(incus storage show "$POOL" | awk -F': ' '/driver:/{print $2}') +log "storage backend: $DRIVER_NOW" +if [[ "$DRIVER_NOW" == "dir" ]]; then + log " warn: dir backend — disk quotas NOT enforced" +fi + +echo "[10] incus ready" diff --git a/scripts/10-lxd.sh b/scripts/10-lxd.sh deleted file mode 100755 index aa8f9cc..0000000 --- a/scripts/10-lxd.sh +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env bash -# 10-lxd.sh — install LXD snap, storage pool, bridge. -set -uo pipefail -export PATH="$PATH:/snap/bin" - -log(){ echo "[10] $*"; } -die(){ echo "[10] error: $*" >&2; exit 1; } - -# --- ensure LXD snap 5.21/stable --- -if snap list lxd >/dev/null 2>&1; then - log "lxd snap already installed: $(snap list lxd | awk 'NR==2{print $2}')" -else - log "installing lxd snap (5.21/stable)..." - snap install lxd --channel=5.21/stable || die "snap install lxd failed" -fi -log "waiting for LXD daemon..." -lxd waitready --timeout=120 || die "lxd daemon not ready" - -# --- storage pool --- -# No host zfsutils needed: Ubuntu 24.04 kernel ships the zfs kernel module and -# the LXD snap bundles the zfs userspace tools, so LXD creates the pool itself. -POOL=vpsmgr -POOL_EXISTS=0 -if lxc storage show "$POOL" >/dev/null 2>&1; then - POOL_EXISTS=1 - log "storage pool '$POOL' exists ($(lxc storage show "$POOL" | awk -F': ' '/driver:/{print $2}'))" -fi - -# find a spare whole-disk block device (no partitions, not the root disk, unmounted) -find_spare_disk(){ - ROOT_DISK=$(lsblk -rno NAME,MOUNTPOINTS | awk '$2=="/"{print $1; exit}' | sed 's/[0-9]*$//') - for d in $(lsblk -rno NAME,TYPE | awk '$2=="disk"{print $1}'); do - [[ "$d" == "$ROOT_DISK" ]] && continue - [[ -b "/dev/$d" ]] || continue - NCHILD=$(lsblk -rno NAME "$d" | tail -n +2 | wc -l) - [[ "$NCHILD" -gt 0 ]] && continue - grep -q "/dev/$d" /proc/mounts && continue - echo "/dev/$d"; return 0 - done - return 1 -} - -# decide how to configure the pool in preseed -DRIVER=zfs -SPARE="" -POOL_SIZE_MB="" -if [[ $POOL_EXISTS -eq 1 ]]; then - # adopt existing pool - DRIVER=$(lxc storage show "$POOL" | awk -F': ' '/driver:/{print $2}') - SRC_LINE=" source: \"$POOL\"" - SIZE_LINE="" -else - SPARE=$(find_spare_disk || true) - if [[ -n "$SPARE" ]]; then - log "zfs pool '$POOL' will be created on spare block device $SPARE" - SRC_LINE=" source: \"$SPARE\"" - SIZE_LINE="" - else - FREE_KB=$(df -k --output=avail / | tail -1 | tr -d ' ') - # Pool ceiling = a share of the free space, as a sparse loop file that - # grows on demand. Small disks keep 80% so the host / LXD image store / - # snap keep enough headroom; big disks (>= 20 GiB free) can afford 90%. - PCT=80 - if (( FREE_KB > 20 * 1024 * 1024 )); then - PCT=90 - fi - POOL_SIZE_MB=$(( FREE_KB * PCT / 100 / 1024 )) - log "loop-file zfs pool '$POOL' (~${POOL_SIZE_MB} MiB = ${PCT}% of free, created by LXD)" - SRC_LINE="" - SIZE_LINE=" size: \"${POOL_SIZE_MB}MiB\"" - fi -fi - -# --- lxd init (preseed) --- -# IPv6 pass-through: when VPSMGR_IPV6_SUBNET is set, lxdbr0 carries the global -# prefix (no NAT) and containers SLAAC global addresses from it. The bridge -# ADDRESS is deliberately not set here: the Go setup (SetupIPv6Bridge, run at -# `vps install`) picks a free address inside the prefix and skips any the host -# already uses — hardcoding prefix::1 at init time would claim the most common -# upstream-router / host address before that clash check ever runs. -V6_IP="none" -V6_NAT="false" -if [[ -n "${VPSMGR_IPV6_SUBNET:-}" ]]; then - log "IPv6 pass-through enabled: lxdbr0 address chosen by \`vps install\` (clash-avoiding)" -fi -# Container IPv4 subnet (10..0.0/24): the bridge gateway is .1. Defaults to -# 10.115.0.1/24; 00-ip-ask.sh exports the chosen subnet before this step. -V4_GW="$(echo "${VPSMGR_IPV4_SUBNET:-10.115.0.0/24}" | cut -d. -f1-3).1/24" -if [[ $POOL_EXISTS -eq 0 ]] || ! lxc network show lxdbr0 >/dev/null 2>&1; then - PRESEED=/tmp/vpsmgr-preseed.yaml - cat > "$PRESEED" <.lxd records for every container, so any tenant could nmap the - # subnet and read everyone's username. dns.mode=none keeps DHCP + upstream - # forwarding but drops the instance-name records. - dns.mode: none - description: "" - name: lxdbr0 - type: bridge -storage_pools: -- config: -$SRC_LINE -$SIZE_LINE - description: "" - name: $POOL - driver: $DRIVER -profiles: -- config: {} - description: Default LXD profile - devices: - eth0: - name: eth0 - nictype: bridged - parent: lxdbr0 - type: nic - root: - path: / - pool: $POOL - type: disk - name: default -cluster: null -EOF - log "running lxd init --preseed (driver=$DRIVER, subnet $V4_GW, ipv6=${VPSMGR_IPV6_SUBNET:-none})" - if ! lxd init --preseed < "$PRESEED"; then - log "preseed failed — creating missing pieces" - if [[ -n "${VPSMGR_IPV6_SUBNET:-}" ]]; then - lxc network show lxdbr0 >/dev/null 2>&1 || lxc network create lxdbr0 ipv4.address=$V4_GW ipv4.nat=true ipv6.address=none ipv6.nat=false ipv6.dhcp.stateful=true ipv6.routing=true dns.mode=none - else - lxc network show lxdbr0 >/dev/null 2>&1 || lxc network create lxdbr0 ipv4.address=$V4_GW ipv4.nat=true ipv6.address=none dns.mode=none - fi - if ! lxc storage show "$POOL" >/dev/null 2>&1; then - if [[ -n "$SPARE" ]]; then - lxc storage create "$POOL" zfs source="$SPARE" || true - elif [[ -n "$POOL_SIZE_MB" ]]; then - lxc storage create "$POOL" zfs size="${POOL_SIZE_MB}MiB" || true - fi - # last resort: dir backend (no quotas) - lxc storage show "$POOL" >/dev/null 2>&1 || { - log " warn: zfs pool creation failed, using dir backend (quotas disabled)" - lxc storage create "$POOL" dir - } - fi - fi - # ensure default profile devices point at our pool/bridge - lxc profile set default root.pool "$POOL" 2>/dev/null || true - lxc profile device set default root pool "$POOL" 2>/dev/null || true - lxc profile device set default eth0 parent lxdbr0 2>/dev/null || true -else - log "LXD already initialized (pool+network present)" -fi - -# Always re-assert dns.mode=none (fresh install and upgrade alike): this is the -# only thing that stops LXD from publishing .lxd DNS/PTR records that -# let any tenant enumerate every other user's username with a subnet scan. -lxc network set lxdbr0 dns.mode=none 2>/dev/null || true - -DRIVER_NOW=$(lxc storage show "$POOL" | awk -F': ' '/driver:/{print $2}') -log "storage backend: $DRIVER_NOW" -if [[ "$DRIVER_NOW" == "dir" ]]; then - log " warn: dir backend — disk quotas NOT enforced" -fi - -echo "[10] lxd ready" diff --git a/scripts/20-network.sh b/scripts/20-network.sh index 8360a8b..9abf928 100755 --- a/scripts/20-network.sh +++ b/scripts/20-network.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash # 20-network.sh — sysctl + nftables basics. set -uo pipefail -export PATH="$PATH:/snap/bin" log(){ echo "[20] $*"; } @@ -9,7 +8,7 @@ log(){ echo "[20] $*"; } # run (idempotent). io_uring_disabled=1 lets only init-userns CAP_SYS_ADMIN (the # host's own root services) create io_uring; container tenants — even container # root, which only has userns-scoped caps — get EPERM, closing the biggest -# kernel LPE attack surface for tenants. Value 1, not 2: the host stack (LXD, +# kernel LPE attack surface for tenants. Value 1, not 2: the host stack (Incus, # ZFS, Go panel/traefik) is untouched. Matches RHEL 9.3+'s shipped default. cat > /etc/sysctl.d/99-vpsmgr.conf <&2; exit 1; } diff --git a/scripts/40-panel.sh b/scripts/40-panel.sh index ca39e1e..3cbce98 100755 --- a/scripts/40-panel.sh +++ b/scripts/40-panel.sh @@ -3,7 +3,6 @@ # Binary source: prebuilt GitHub release by default, local Go build as fallback # (or when VPSMGR_BUILD_MODE=local, e.g. via ./install.sh --local-build). set -uo pipefail -export PATH="$PATH:/snap/bin" log(){ echo "[40] $*"; } die(){ echo "[40] error: $*" >&2; exit 1; } diff --git a/scripts/50-image.sh b/scripts/50-image.sh index e56ae14..008cabc 100755 --- a/scripts/50-image.sh +++ b/scripts/50-image.sh @@ -6,57 +6,56 @@ # build intermediate only) is deleted afterwards so only the modified # vpsmgr/debian-sshd stays on disk. set -uo pipefail -export PATH="$PATH:/snap/bin" log(){ echo "[50] $*"; } -lxc info >/dev/null 2>&1 || { echo "[50] error: LXD not ready" >&2; exit 1; } +incus info >/dev/null 2>&1 || { echo "[50] error: Incus not ready" >&2; exit 1; } # --- base image --- -if lxc image show vpsmgr-debian-13 >/dev/null 2>&1; then +if incus image show vpsmgr-debian-13 >/dev/null 2>&1; then log "base image vpsmgr-debian-13 already present" else log "pulling images:debian/13 (fallback images:debian/trixie)..." - if ! lxc image copy images:debian/13 local: --alias vpsmgr-debian-13; then + if ! incus image copy images:debian/13 local: --alias vpsmgr-debian-13; then log " debian/13 alias failed, trying debian/trixie" - lxc image copy images:debian/trixie local: --alias vpsmgr-debian-13 \ + incus image copy images:debian/trixie local: --alias vpsmgr-debian-13 \ || log " warn: image pull failed — add-user needs network and will retry" fi fi # --- prebuilt sshd image --- -if lxc image show vpsmgr/debian-sshd >/dev/null 2>&1; then +if incus image show vpsmgr/debian-sshd >/dev/null 2>&1; then log "sshd image vpsmgr/debian-sshd already present" exit 0 fi -if ! lxc image show vpsmgr-debian-13 >/dev/null 2>&1; then +if ! incus image show vpsmgr-debian-13 >/dev/null 2>&1; then log " warn: base image unavailable, skipping sshd image build (add-user will install sshd on the fly)" exit 0 fi log "building sshd image (this takes a few minutes)..." NAME=tmp-sshd-builder -lxc delete --force "$NAME" >/dev/null 2>&1 || true -if lxc launch vpsmgr-debian-13 "$NAME"; then +incus delete --force "$NAME" >/dev/null 2>&1 || true +if incus launch vpsmgr-debian-13 "$NAME"; then # wait until usable for i in $(seq 1 60); do - if lxc exec "$NAME" -- /bin/true >/dev/null 2>&1; then break; fi + if incus exec "$NAME" -- /bin/true >/dev/null 2>&1; then break; fi sleep 2 done # DNS can lag agent readiness; running apt before it is up fails the update # and, without a gate, a broken sshd image would still be published. DNS_OK= for i in $(seq 1 30); do - if lxc exec "$NAME" -- getent hosts deb.debian.org >/dev/null 2>&1; then + if incus exec "$NAME" -- getent hosts deb.debian.org >/dev/null 2>&1; then DNS_OK=1; break fi sleep 2 done if [ -z "$DNS_OK" ]; then log " warn: builder never got working DNS; skipping sshd image (add-user will install sshd on the fly)" - lxc delete --force "$NAME" >/dev/null 2>&1 || true - elif lxc exec "$NAME" -- sh -c 'export DEBIAN_FRONTEND=noninteractive + incus delete --force "$NAME" >/dev/null 2>&1 || true + elif incus exec "$NAME" -- sh -c 'export DEBIAN_FRONTEND=noninteractive set -e # universal user tooling (small, every container hits these): curl/wget need # ca-certificates or HTTPS fails; dnsutils is bind9-dnsutils on Debian 13. @@ -88,12 +87,12 @@ rm -rf /tmp/* /var/tmp/* 2>/dev/null || true # IPv6 drops at the 1h lease mark). Drop it so each container generates its # own on first boot. rm -f /etc/machine-id /var/lib/dbus/machine-id 2>/dev/null || true'; then - lxc stop "$NAME" --timeout=30 || true - if lxc publish "$NAME" --alias vpsmgr/debian-sshd; then - lxc delete --force "$NAME" || true + incus stop "$NAME" --timeout=30 || true + if incus publish "$NAME" --alias vpsmgr/debian-sshd; then + incus delete --force "$NAME" || true # keep only the modified image — the Debian base was a build intermediate # and is never used to launch containers (fallback is the remote images:). - if lxc image delete vpsmgr-debian-13 >/dev/null 2>&1; then + if incus image delete vpsmgr-debian-13 >/dev/null 2>&1; then log "removed base image vpsmgr-debian-13 (only vpsmgr/debian-sshd kept)" else log " warn: could not remove base image vpsmgr-debian-13" @@ -101,12 +100,12 @@ rm -f /etc/machine-id /var/lib/dbus/machine-id 2>/dev/null || true'; then log "sshd image published: vpsmgr/debian-sshd" else log " warn: publish FAILED — sshd image NOT built (base image kept; re-run to retry)" - lxc delete --force "$NAME" >/dev/null 2>&1 || true + incus delete --force "$NAME" >/dev/null 2>&1 || true exit 1 fi else log " warn: sshd install in builder failed; add-user will install sshd on the fly" - lxc delete --force "$NAME" >/dev/null 2>&1 || true + incus delete --force "$NAME" >/dev/null 2>&1 || true fi else log " warn: could not launch builder; add-user will install sshd on the fly" diff --git a/scripts/60-rhel-image.sh b/scripts/60-rhel-image.sh index 349366f..7c70809 100755 --- a/scripts/60-rhel-image.sh +++ b/scripts/60-rhel-image.sh @@ -10,7 +10,7 @@ # Same hygiene as 50-image.sh: sshd + common tools baked in, dnf caches and # logs cleaned before publishing, base image deleted afterwards. set -uo pipefail -export PATH="$PATH:/snap/bin" + DISTRO="${1:-alma}" case "$DISTRO" in @@ -21,16 +21,16 @@ esac log(){ echo "[60] $*"; } -lxc info >/dev/null 2>&1 || { echo "[60] error: LXD not ready" >&2; exit 1; } +incus info >/dev/null 2>&1 || { echo "[60] error: Incus not ready" >&2; exit 1; } -if lxc image show "$IMAGE" >/dev/null 2>&1; then +if incus image show "$IMAGE" >/dev/null 2>&1; then log "image $IMAGE already present" exit 0 fi -if ! lxc image list "$BASE_ALIAS" --format=csv 2>/dev/null | grep -q .; then +if ! incus image list "$BASE_ALIAS" --format=csv 2>/dev/null | grep -q .; then log "pulling $REMOTE_ALIAS (fallback base, deleted after build)..." - if ! lxc image copy "$REMOTE_ALIAS" local: --alias "$BASE_ALIAS"; then + if ! incus image copy "$REMOTE_ALIAS" local: --alias "$BASE_ALIAS"; then log " warn: image pull failed — nothing built" exit 0 fi @@ -40,29 +40,29 @@ fi log "building $IMAGE (this takes a few minutes)..." NAME=tmp-rhel-builder -lxc delete --force "$NAME" >/dev/null 2>&1 || true -if lxc launch "$BASE_ALIAS" "$NAME"; then +incus delete --force "$NAME" >/dev/null 2>&1 || true +if incus launch "$BASE_ALIAS" "$NAME"; then # wait until usable for i in $(seq 1 60); do - if lxc exec "$NAME" -- /bin/true >/dev/null 2>&1; then break; fi + if incus exec "$NAME" -- /bin/true >/dev/null 2>&1; then break; fi sleep 2 done - # RHEL containers bring eth0 up with NetworkManager, which lags the LXD agent + # RHEL containers bring eth0 up with NetworkManager, which lags the Incus agent # by a few seconds: running dnf before DHCP has written resolv.conf makes it # die with "Curl error (6): Couldn't resolve host". Wait until DNS answers, # or the builder install fails and a broken image gets published. DNS_OK= for i in $(seq 1 30); do - if lxc exec "$NAME" -- getent hosts mirrors.almalinux.org >/dev/null 2>&1; then + if incus exec "$NAME" -- getent hosts mirrors.almalinux.org >/dev/null 2>&1; then DNS_OK=1; break fi sleep 2 done if [ -z "$DNS_OK" ]; then log " warn: builder never got working DNS; nothing built" - lxc delete --force "$NAME" >/dev/null 2>&1 || true - lxc image delete "$BASE_ALIAS" >/dev/null 2>&1 || true - elif lxc exec "$NAME" -- sh -c ' + incus delete --force "$NAME" >/dev/null 2>&1 || true + incus image delete "$BASE_ALIAS" >/dev/null 2>&1 || true + elif incus exec "$NAME" -- sh -c ' set -e # universal user tooling (mirrors the Debian image): sshd, curl/wget need # ca-certificates or HTTPS fails; bind-utils is the RHEL nslookup/dig package. @@ -95,11 +95,11 @@ printf "#!/bin/sh\n[ -f /etc/vpsmgr-ipv6.conf ] || exit 0\nip -6 addr replace \" chmod +x /usr/local/sbin/vpsmgr-ipv6 printf "[Unit]\nDescription=vpsmgr IPv6 primary address\nAfter=network-online.target\nWants=network-online.target\n[Service]\nType=oneshot\nExecStart=/usr/local/sbin/vpsmgr-ipv6\nRemainAfterExit=yes\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/vpsmgr-ipv6.service systemctl enable vpsmgr-ipv6.service >/dev/null 2>&1 || true'; then - lxc stop "$NAME" --timeout=30 || true - if lxc publish "$NAME" --alias "$IMAGE"; then - lxc delete --force "$NAME" || true + incus stop "$NAME" --timeout=30 || true + if incus publish "$NAME" --alias "$IMAGE"; then + incus delete --force "$NAME" || true # keep only the modified image — the base was a build intermediate - if lxc image delete "$BASE_ALIAS" >/dev/null 2>&1; then + if incus image delete "$BASE_ALIAS" >/dev/null 2>&1; then log "removed base image $BASE_ALIAS (only $IMAGE kept)" else log " warn: could not remove base image $BASE_ALIAS" @@ -107,13 +107,13 @@ systemctl enable vpsmgr-ipv6.service >/dev/null 2>&1 || true'; then log "image published: $IMAGE" else log " warn: publish FAILED — $IMAGE NOT built (base image kept; re-run to retry)" - lxc delete --force "$NAME" >/dev/null 2>&1 || true + incus delete --force "$NAME" >/dev/null 2>&1 || true exit 1 fi else log " warn: install in builder failed; nothing built" - lxc delete --force "$NAME" >/dev/null 2>&1 || true - lxc image delete "$BASE_ALIAS" >/dev/null 2>&1 || true + incus delete --force "$NAME" >/dev/null 2>&1 || true + incus image delete "$BASE_ALIAS" >/dev/null 2>&1 || true fi else log " warn: could not launch builder; nothing built" diff --git a/uninstall.sh b/uninstall.sh index a8448c0..ba39dad 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -3,14 +3,13 @@ # and the traefik config are KEPT so a plain reinstall adopts the previous # users/domains/settings. --purge also removes those, plus containers/storage. set -uo pipefail -export PATH="$PATH:/snap/bin" PURGE=0 if [[ "${1:-}" == "--purge" ]]; then PURGE=1; fi log(){ echo "[un] $*"; } log "stopping services..." -for svc in vpsmgr-panel vpsmgr-nft vpsmgr-ipv6 traefik; do +for svc in vps vps-nft vps-ipv6 traefik; do systemctl disable --now "$svc.service" >/dev/null 2>&1 || true done systemctl daemon-reload >/dev/null 2>&1 || true @@ -42,11 +41,11 @@ if [[ -n "$V6SUBNET" ]]; then esac done # restore lxdbr0 IPv6 to disabled (matches vpsmgr default) - if command -v lxc >/dev/null 2>&1 && lxc network show lxdbr0 >/dev/null 2>&1; then - lxc network set lxdbr0 ipv6.address none 2>/dev/null || true - lxc network set lxdbr0 ipv6.nat false 2>/dev/null || true - lxc network set lxdbr0 ipv6.routing false 2>/dev/null || true - lxc network set lxdbr0 ipv6.dhcp.stateful false 2>/dev/null || true + if command -v incus >/dev/null 2>&1 && incus network show lxdbr0 >/dev/null 2>&1; then + incus network set lxdbr0 ipv6.address none 2>/dev/null || true + incus network set lxdbr0 ipv6.nat false 2>/dev/null || true + incus network set lxdbr0 ipv6.routing false 2>/dev/null || true + incus network set lxdbr0 ipv6.dhcp.stateful false 2>/dev/null || true fi # live sysctls back to defaults sysctl -w net.ipv6.conf.all.forwarding=0 net.ipv6.conf.default.forwarding=0 >/dev/null 2>&1 || true @@ -67,10 +66,8 @@ if [[ $PURGE -eq 0 ]] && [[ -f /etc/vpsmgr/config.yaml ]] && command -v vps >/de fi log "removing files..." -# The CLI binary is `vps` in v0.3+; also remove a legacy vpsmgr binary left by -# a pre-0.3 install. -rm -f /usr/local/bin/vps /usr/local/bin/vpsmgr /usr/local/bin/traefik -rm -f /etc/systemd/system/vpsmgr-panel.service /etc/systemd/system/vpsmgr-nft.service /etc/systemd/system/vpsmgr-ipv6.service /etc/systemd/system/traefik.service +rm -f /usr/local/bin/vps /usr/local/bin/traefik +rm -f /etc/systemd/system/vps.service /etc/systemd/system/vps-nft.service /etc/systemd/system/vps-ipv6.service /etc/systemd/system/traefik.service # Restore the host-wide io_uring clamp to the kernel default before dropping # 99-vpsmgr.conf (which sets it to 1 at install time). sysctl -w kernel.io_uring_disabled=0 >/dev/null 2>&1 || true @@ -83,19 +80,22 @@ log " kept /etc/vpsmgr and /etc/traefik (reinstall will adopt them)" if [[ $PURGE -eq 1 ]]; then log "purging vpsmgr config/db/certs and traefik config..." rm -rf /etc/vpsmgr /etc/traefik - log "purging LXD instances..." - for c in $(lxc list --format=csv -c n 2>/dev/null); do + log "purging Incus instances..." + for c in $(incus list --format=csv -c n 2>/dev/null); do log " deleting container $c" - lxc delete --force "$c" >/dev/null 2>&1 || true + incus delete --force "$c" >/dev/null 2>&1 || true done log "removing storage pool..." - lxc storage delete vpsmgr >/dev/null 2>&1 || true + incus storage delete vpsmgr >/dev/null 2>&1 || true if command -v zpool >/dev/null 2>&1 && zpool list vpsmgr >/dev/null 2>&1; then zpool destroy -f vpsmgr >/dev/null 2>&1 || true fi rm -rf /var/lib/vpsmgr - log "removing lxd snap..." - snap remove lxd --purge >/dev/null 2>&1 || true + log "removing Incus (Zabbly package) and its repo..." + DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq incus >/dev/null 2>&1 || true + apt-get autoremove -y -qq >/dev/null 2>&1 || true + rm -f /etc/apt/sources.list.d/zabbly-incus-lts-7.0.sources /etc/apt/keyrings/zabbly.asc + apt-get update -qq >/dev/null 2>&1 || true fi if [[ $PURGE -eq 1 ]]; then