feat(scripts): install Incus via Zabbly LTS repo; unify services as vps.*

- 10-incus.sh replaces 10-lxd.sh: adds the Zabbly incus lts-7.0 apt
  repo (key fingerprint verified), installs the incus package, creates
  the vpsmgr pool (ZFS/spare/loop-file, dir fallback) and lxdbr0 via
  `incus admin init --preseed`, and keeps dns.mode=none.
- 00-check.sh targets Debian 12/13 + Ubuntu 22.04/24.04/26.04, drops
  snapd, checks incus instead of the lxd snap.
- 50/60-image scripts use `incus` (was `lxc`).
- Service naming unified to vps.*: vps.service (panel),
  vps-nft.service, vps-ipv6.service. `systemctl restart vps` is now the
  one command operators need. unit After= incus.service.
- uninstall.sh stops vps.* units and removes the Incus package on
  --purge.
- sudoers whitelist for the unprivileged panel (configs/sudoers.d/vps).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-08-14 03:33:30 +00:00
1 parent ebc2b1478f
commit c0faea6c8c
17 files changed
+369 -296

No files matched your search

+1 -1
View File
@@ -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
+2 -2
View File
@@ -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-<name>.nft.
table inet vpsmgr {
chain prerouting {
+32
View File
@@ -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
+17
View File
@@ -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
+19
View File
@@ -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
-15
View File
@@ -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
-15
View File
@@ -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
+11 -16
View File
@@ -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"
+16 -15
View File
@@ -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"
+215
View File
@@ -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" <<EOF
Enabled: yes
Types: deb
URIs: https://pkgs.zabbly.com/incus/lts-7.0
Suites: $(. /etc/os-release && echo ${VERSION_CODENAME})
Components: main
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/zabbly.asc
EOF
apt-get update -qq || die "apt-get update failed (check the Zabbly repo)"
fi
if dpkg -s incus >/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.<n>.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" <<EOF
config: {}
networks:
- config:
ipv4.address: $V4_GW
ipv4.nat: "true"
ipv6.address: $V6_IP
ipv6.nat: "$V6_NAT"
ipv6.dhcp.stateful: "true"
ipv6.routing: "true"
# Do not serve instance-name DNS: Incus's dnsmasq would publish
# <username>.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 <username>.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"
-172
View File
@@ -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.<n>.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" <<EOF
config: {}
networks:
- config:
ipv4.address: $V4_GW
ipv4.nat: "true"
ipv6.address: $V6_IP
ipv6.nat: "$V6_NAT"
ipv6.dhcp.stateful: "true"
ipv6.routing: "true"
# Do not serve instance-name DNS: LXD's dnsmasq would publish
# <username>.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 <username>.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"
+1 -2
View File
@@ -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 <<EOF
# Managed by vpsmgr — generated file, do not edit by hand.
-1
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env bash
# 30-traefik.sh — install Traefik binary + static config + systemd service.
set -uo pipefail
export PATH="$PATH:/snap/bin"
log(){ echo "[30] $*"; }
die(){ echo "[30] error: $*" >&2; exit 1; }
-1
View File
@@ -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; }
+18 -19
View File
@@ -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"
+20 -20
View File
@@ -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"
+17 -17
View File
@@ -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