incus-contrib/hetzner/proxmox-setup

1345 lines
45 KiB
Bash
Executable File

#!/usr/bin/env bash
# proxmox-setup - Interactive Proxmox post-install configuration for Hetzner
#
# Configures a fresh Proxmox host with private networking, ZFS storage,
# WireGuard tunnel, firewall lockdown, and API tokens for incusos-proxmox.
#
# Runs from your workstation over SSH. Requires an SSH host alias configured
# in ~/.ssh/config (e.g. "hetzner-lab").
#
# The script first detects the full state of the host, shows a status
# dashboard, and then only offers to change what's actually needed.
#
# Usage: proxmox-setup --host <ssh-alias> [OPTIONS]
# Run 'proxmox-setup --help' for full usage information.
set -euo pipefail
readonly VERSION="0.2.0"
readonly SCRIPT_NAME="proxmox-setup"
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
SSH_HOST=""
DRY_RUN=false
VERBOSE=false
QUIET=false
YES=false
SUBNET="10.10.0.0/24"
WG_SUBNET="10.10.99.0/24"
WG_PORT="51820"
SKIP_REPOS=false
SKIP_WIREGUARD=false
SKIP_FIREWALL=false
SKIP_STORAGE=false
# Derived (computed after argument parsing)
SUBNET_BASE="" # e.g. 10.10.0
SUBNET_MASK="" # e.g. 24
SUBNET_GW="" # e.g. 10.10.0.1
WG_SUBNET_BASE="" # e.g. 10.10.99
WG_SUBNET_MASK="" # e.g. 24
WG_SERVER_IP="" # e.g. 10.10.99.1
WG_CLIENT_IP="" # e.g. 10.10.99.2
# ---------------------------------------------------------------------------
# Detected state (populated by detect_all)
# ---------------------------------------------------------------------------
# Host basics
DETECT_HOSTNAME=""
DETECT_KERNEL=""
DETECT_PVE_VERSION=""
DETECT_PUBLIC_IP=""
DETECT_BLOCK_DEVICES=""
# Repos
STATE_REPOS="needed" # done | needed | partial
DETECT_ENTERPRISE_ACTIVE="" # non-empty if enterprise repos active
DETECT_NOSUB_EXISTS="" # "yes" if no-sub repo file exists
DETECT_NAG_PATCHED="" # "yes" if nag popup already patched
# Updates
STATE_UPDATE="unknown" # done | needed | unknown
DETECT_UPGRADABLE_COUNT="0"
# Bridge
STATE_BRIDGE="needed" # done | needed
DETECT_VMBR1_IP="" # e.g. "10.10.0.1/24" or empty
DETECT_IP_FORWARD="" # "1" or "0"
DETECT_NAT_ACTIVE="" # "yes" or empty
# Storage
STATE_STORAGE="done" # done | available | needed
DETECT_ZPOOLS=""
DETECT_PVE_STORAGE=""
DETECT_AVAILABLE_DISKS=""
DETECT_AVAILABLE_DISK_COUNT="0"
# WireGuard
STATE_WIREGUARD="needed" # done | needed
DETECT_WG_INSTALLED="" # "yes" or empty
DETECT_WG_ACTIVE="" # "yes" or empty
DETECT_WG_PEERS="" # peer count
DETECT_WG_HANDSHAKE="" # latest handshake info
# Firewall
STATE_FIREWALL="needed" # done | needed
DETECT_NFT_RULES="" # "yes" if nftables rules loaded with our pattern
DETECT_NFT_FILE="" # "yes" if config file exists
# API token
STATE_API="needed" # done | partial | needed
DETECT_API_ROLE="" # "yes" or empty
DETECT_API_POOL="" # "yes" or empty
DETECT_API_TOKEN="" # "yes" or empty
# ---------------------------------------------------------------------------
# Color and formatting
# ---------------------------------------------------------------------------
setup_colors() {
if [[ -t 1 ]] && [[ "${NO_COLOR:-}" != "1" ]] && [[ "${TERM:-}" != "dumb" ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
RESET='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' DIM='' RESET=''
fi
}
info() { [[ "$QUIET" == true ]] && return; echo -e "${BLUE}[info]${RESET} $*"; }
success() { [[ "$QUIET" == true ]] && return; echo -e "${GREEN}[ok]${RESET} $*"; }
warn() { echo -e "${YELLOW}[warn]${RESET} $*" >&2; }
error() { echo -e "${RED}[error]${RESET} $*" >&2; }
step() { [[ "$QUIET" == true ]] && return; echo -e "${CYAN}[step]${RESET} ${BOLD}$*${RESET}"; }
detail() { [[ "$VERBOSE" != true ]] && return; echo -e "${DIM} $*${RESET}"; }
dry_run_guard() {
if [[ "$DRY_RUN" == true ]]; then
info "(dry-run) $*"
return 1
fi
return 0
}
# Status indicator for the dashboard
status_icon() {
local state="$1"
case "$state" in
done) echo -e "${GREEN}done${RESET}" ;;
needed) echo -e "${YELLOW}needed${RESET}" ;;
partial) echo -e "${YELLOW}partial${RESET}" ;;
available) echo -e "${BLUE}available${RESET}" ;;
skipped) echo -e "${DIM}skipped${RESET}" ;;
unknown) echo -e "${DIM}unknown${RESET}" ;;
esac
}
# ---------------------------------------------------------------------------
# SSH helpers
# ---------------------------------------------------------------------------
remote() {
ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_HOST" "$@"
}
remote_output() {
ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_HOST" "$@" 2>/dev/null
}
remote_write() {
local dest="$1"
local content="$2"
ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_HOST" "cat > '$dest'" <<< "$content"
}
# ---------------------------------------------------------------------------
# Interactive helpers
# ---------------------------------------------------------------------------
confirm() {
local prompt="$1"
if [[ "$YES" == true ]]; then
return 0
fi
echo -en "${BOLD}${prompt}${RESET} [y/N] "
read -r answer
[[ "$answer" =~ ^[Yy]$ ]]
}
prompt_value() {
local prompt="$1"
local default="$2"
local result
if [[ "$YES" == true ]]; then
echo "$default"
return
fi
echo -en "${BOLD}${prompt}${RESET} [${default}]: "
read -r result
echo "${result:-$default}"
}
# ---------------------------------------------------------------------------
# Help
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
${BOLD}${SCRIPT_NAME}${RESET} v${VERSION} - Interactive Proxmox post-install configuration for Hetzner
${BOLD}USAGE${RESET}
${SCRIPT_NAME} --host <ssh-alias> [OPTIONS]
${BOLD}DESCRIPTION${RESET}
Detects the full state of a Proxmox host, shows a status dashboard,
and interactively configures what's needed:
- No-subscription repositories and system update
- Private bridge (vmbr1) with NAT masquerading
- ZFS storage pool on extra disks
- WireGuard tunnel for secure remote access
- nftables firewall lockdown
- API token for incusos-proxmox automation
Already-configured items are detected and skipped automatically.
Each pending step prompts for confirmation. Use --yes to skip prompts.
${BOLD}OPTIONS${RESET}
${BOLD}--host ALIAS${RESET} SSH host alias (required, e.g. hetzner-lab)
${BOLD}--subnet CIDR${RESET} Private bridge subnet (default: ${SUBNET})
${BOLD}--wg-subnet CIDR${RESET} WireGuard subnet (default: ${WG_SUBNET})
${BOLD}--wg-port PORT${RESET} WireGuard listen port (default: ${WG_PORT})
${BOLD}--skip-repos${RESET} Skip repository changes
${BOLD}--skip-storage${RESET} Skip storage pool creation
${BOLD}--skip-wireguard${RESET} Skip WireGuard setup
${BOLD}--skip-firewall${RESET} Skip firewall lockdown
${BOLD}-n, --dry-run${RESET} Preview actions without executing
${BOLD}-y, --yes${RESET} Skip confirmations (use defaults)
${BOLD}-v, --verbose${RESET} Show detailed output
${BOLD}-q, --quiet${RESET} Suppress informational output
${BOLD}-h, --help${RESET} Show this help message
${BOLD}-V, --version${RESET} Show version
${BOLD}EXAMPLES${RESET}
# See what needs doing (safe, read-only)
${SCRIPT_NAME} --host hetzner-lab --dry-run
# Full setup with defaults
${SCRIPT_NAME} --host hetzner-lab --yes
# Custom subnets, skip firewall
${SCRIPT_NAME} --host hetzner-lab --subnet 172.16.0.0/24 --skip-firewall
${BOLD}PREREQUISITES${RESET}
- SSH access to the Proxmox host (key-based auth recommended)
- Add an entry to ~/.ssh/config:
Host hetzner-lab
HostName <public-ip>
User root
IdentityFile ~/.ssh/id_ed25519
${BOLD}SEE ALSO${RESET}
hetzner/hetzner-setup.md Manual installation guide
incusos/targets/hetzner/ Target config files
EOF
exit 0
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--host)
SSH_HOST="${2:?'--host requires an SSH alias'}"
shift 2
;;
--subnet)
SUBNET="${2:?'--subnet requires a CIDR'}"
shift 2
;;
--wg-subnet)
WG_SUBNET="${2:?'--wg-subnet requires a CIDR'}"
shift 2
;;
--wg-port)
WG_PORT="${2:?'--wg-port requires a port number'}"
shift 2
;;
--skip-repos)
SKIP_REPOS=true
shift
;;
--skip-storage)
SKIP_STORAGE=true
shift
;;
--skip-wireguard)
SKIP_WIREGUARD=true
shift
;;
--skip-firewall)
SKIP_FIREWALL=true
shift
;;
-n|--dry-run)
DRY_RUN=true
shift
;;
-y|--yes)
YES=true
shift
;;
-v|--verbose)
VERBOSE=true
shift
;;
-q|--quiet)
QUIET=true
shift
;;
-h|--help)
usage
;;
-V|--version)
echo "${SCRIPT_NAME} v${VERSION}"
exit 0
;;
-*)
error "Unknown option: $1"
echo "Run '${SCRIPT_NAME} --help' for usage information." >&2
exit 1
;;
*)
error "Unexpected argument: $1"
echo "Run '${SCRIPT_NAME} --help' for usage information." >&2
exit 1
;;
esac
done
if [[ -z "$SSH_HOST" ]]; then
error "--host is required (SSH alias, e.g. hetzner-lab)"
echo "Run '${SCRIPT_NAME} --help' for usage information." >&2
exit 1
fi
}
# ---------------------------------------------------------------------------
# Subnet helpers
# ---------------------------------------------------------------------------
parse_subnets() {
SUBNET_MASK="${SUBNET##*/}"
local subnet_network="${SUBNET%%/*}"
SUBNET_BASE="${subnet_network%.*}"
SUBNET_GW="${SUBNET_BASE}.1"
WG_SUBNET_MASK="${WG_SUBNET##*/}"
local wg_network="${WG_SUBNET%%/*}"
WG_SUBNET_BASE="${wg_network%.*}"
WG_SERVER_IP="${WG_SUBNET_BASE}.1"
WG_CLIENT_IP="${WG_SUBNET_BASE}.2"
}
# ---------------------------------------------------------------------------
# Detection: gather all state in one pass
# ---------------------------------------------------------------------------
detect_all() {
step "Detecting host state"
# Test SSH connectivity
if ! remote "true" 2>/dev/null; then
error "Cannot connect to '${SSH_HOST}' via SSH"
error "Ensure the host is in ~/.ssh/config and key auth works:"
echo " ssh ${SSH_HOST} hostname" >&2
exit 1
fi
success "SSH connection to ${SSH_HOST}"
# Gather everything in a single SSH call for speed
local detection_blob
detection_blob=$(remote_output "
echo '---HOSTNAME---'
hostname
echo '---KERNEL---'
uname -r
echo '---PVE_VERSION---'
pvesh get /version --output-format json 2>/dev/null | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"version\"])' 2>/dev/null || echo 'unknown'
echo '---PUBLIC_IP---'
hostname -I | awk '{print \$1}'
echo '---BLOCK_DEVICES---'
lsblk -d -o NAME,SIZE,MODEL,ROTA,TYPE 2>/dev/null
echo '---ZPOOLS---'
zpool list -H 2>/dev/null || true
echo '---PVE_STORAGE---'
pvesm status 2>/dev/null || true
echo '---ENTERPRISE_REPOS---'
grep -rl '^deb.*enterprise' /etc/apt/sources.list.d/*.list 2>/dev/null || echo 'none'
echo '---NOSUB_REPO---'
test -f /etc/apt/sources.list.d/pve-no-subscription.list && echo 'yes' || echo 'no'
echo '---NAG_PATCHED---'
grep -q 'void({' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js 2>/dev/null && echo 'yes' || echo 'no'
echo '---UPGRADABLE---'
apt list --upgradable 2>/dev/null | grep -c upgradable || echo '0'
echo '---VMBR1---'
ip -br addr show vmbr1 2>/dev/null || echo 'none'
echo '---IP_FORWARD---'
cat /proc/sys/net/ipv4/ip_forward 2>/dev/null || echo '0'
echo '---NAT_ACTIVE---'
iptables -t nat -L POSTROUTING -n 2>/dev/null | grep -q MASQUERADE && echo 'yes' || echo 'no'
echo '---WG_INSTALLED---'
command -v wg >/dev/null 2>&1 && echo 'yes' || echo 'no'
echo '---WG_ACTIVE---'
wg show wg0 2>/dev/null | head -1 || echo 'none'
echo '---WG_PEERS---'
wg show wg0 peers 2>/dev/null | wc -l || echo '0'
echo '---WG_HANDSHAKE---'
wg show wg0 latest-handshakes 2>/dev/null | head -1 || echo 'none'
echo '---NFT_FILE---'
test -f /etc/nftables-hetzner.conf && echo 'yes' || echo 'no'
echo '---NFT_RULES---'
nft list ruleset 2>/dev/null | grep -q 'nft-drop' && echo 'yes' || echo 'no'
echo '---API_ROLE---'
pveum role list --output-format json 2>/dev/null | python3 -c 'import sys,json; any(r[\"roleid\"]==\"IncusOSDeployer\" for r in json.load(sys.stdin)) and print(\"yes\")' 2>/dev/null || echo 'no'
echo '---API_POOL---'
pveum pool list --output-format json 2>/dev/null | python3 -c 'import sys,json; any(p[\"poolid\"]==\"IncusLab\" for p in json.load(sys.stdin)) and print(\"yes\")' 2>/dev/null || echo 'no'
echo '---API_TOKEN---'
pveum user token list automation@pve --output-format json 2>/dev/null | python3 -c 'import sys,json; any(t[\"tokenid\"]==\"deploy\" for t in json.load(sys.stdin)) and print(\"yes\")' 2>/dev/null || echo 'no'
echo '---AVAILABLE_DISKS---'
used_disks=\$(zpool status 2>/dev/null | grep -oP '/dev/\\S+' | sed 's|/dev/||' || true)
lsblk -dno NAME,SIZE,TYPE | while read name size type; do
[ \"\$type\" != 'disk' ] && continue
echo \"\$used_disks\" | grep -qw \"\$name\" && continue
lsblk -no MOUNTPOINT /dev/\$name 2>/dev/null | grep -q '/' && continue
echo \"\$name \$size\"
done
echo '---END---'
" || true)
# Parse the blob into variables
local section=""
local section_lines=""
while IFS= read -r line; do
if [[ "$line" == ---*--- ]]; then
# Process previous section
if [[ -n "$section" ]]; then
_parse_section "$section" "$section_lines"
fi
section="${line//---/}"
section_lines=""
else
if [[ -n "$section_lines" ]]; then
section_lines="${section_lines}
${line}"
else
section_lines="$line"
fi
fi
done <<< "$detection_blob"
# Process last section
[[ -n "$section" ]] && _parse_section "$section" "$section_lines"
# Determine composite states
_compute_states
}
_parse_section() {
local section="$1"
local content="$2"
content="${content#$'\n'}" # strip leading newline
case "$section" in
HOSTNAME) DETECT_HOSTNAME="$content" ;;
KERNEL) DETECT_KERNEL="$content" ;;
PVE_VERSION) DETECT_PVE_VERSION="$content" ;;
PUBLIC_IP) DETECT_PUBLIC_IP="$content" ;;
BLOCK_DEVICES) DETECT_BLOCK_DEVICES="$content" ;;
ZPOOLS) DETECT_ZPOOLS="$content" ;;
PVE_STORAGE) DETECT_PVE_STORAGE="$content" ;;
ENTERPRISE_REPOS)
if [[ "$content" == "none" ]]; then
DETECT_ENTERPRISE_ACTIVE=""
else
DETECT_ENTERPRISE_ACTIVE="$content"
fi
;;
NOSUB_REPO) DETECT_NOSUB_EXISTS="$content" ;;
NAG_PATCHED) DETECT_NAG_PATCHED="$content" ;;
UPGRADABLE) DETECT_UPGRADABLE_COUNT="${content//[^0-9]/}" ;;
VMBR1)
if [[ "$content" != "none" ]]; then
DETECT_VMBR1_IP=$(echo "$content" | awk '{print $3}')
fi
;;
IP_FORWARD) DETECT_IP_FORWARD="${content//[^01]/}" ;;
NAT_ACTIVE) DETECT_NAT_ACTIVE="$content" ;;
WG_INSTALLED) DETECT_WG_INSTALLED="$content" ;;
WG_ACTIVE)
if [[ "$content" != "none" ]]; then
DETECT_WG_ACTIVE="yes"
fi
;;
WG_PEERS) DETECT_WG_PEERS="${content//[^0-9]/}" ;;
WG_HANDSHAKE)
if [[ "$content" != "none" ]]; then
DETECT_WG_HANDSHAKE="$content"
fi
;;
NFT_FILE) DETECT_NFT_FILE="$content" ;;
NFT_RULES) DETECT_NFT_RULES="$content" ;;
API_ROLE)
[[ "$content" == "yes" ]] && DETECT_API_ROLE="yes"
;;
API_POOL)
[[ "$content" == "yes" ]] && DETECT_API_POOL="yes"
;;
API_TOKEN)
[[ "$content" == "yes" ]] && DETECT_API_TOKEN="yes"
;;
AVAILABLE_DISKS)
DETECT_AVAILABLE_DISKS="$content"
if [[ -n "$content" ]]; then
DETECT_AVAILABLE_DISK_COUNT=$(echo "$content" | wc -l)
else
DETECT_AVAILABLE_DISK_COUNT="0"
fi
;;
esac
}
_compute_states() {
# Repos
if [[ -z "$DETECT_ENTERPRISE_ACTIVE" ]] && [[ "$DETECT_NOSUB_EXISTS" == "yes" ]] && [[ "$DETECT_NAG_PATCHED" == "yes" ]]; then
STATE_REPOS="done"
elif [[ "$DETECT_NOSUB_EXISTS" == "yes" ]] || [[ "$DETECT_NAG_PATCHED" == "yes" ]]; then
STATE_REPOS="partial"
else
STATE_REPOS="needed"
fi
# Updates
if [[ "${DETECT_UPGRADABLE_COUNT:-0}" -eq 0 ]]; then
STATE_UPDATE="done"
elif [[ "${DETECT_UPGRADABLE_COUNT:-0}" -gt 0 ]]; then
STATE_UPDATE="needed"
else
STATE_UPDATE="unknown"
fi
# Bridge
if [[ -n "$DETECT_VMBR1_IP" ]] && [[ "$DETECT_IP_FORWARD" == "1" ]] && [[ "$DETECT_NAT_ACTIVE" == "yes" ]]; then
STATE_BRIDGE="done"
elif [[ -n "$DETECT_VMBR1_IP" ]]; then
STATE_BRIDGE="partial"
else
STATE_BRIDGE="needed"
fi
# Storage: check if any ZFS pool is registered as PVE storage
local zfs_in_pve
zfs_in_pve=$(echo "$DETECT_PVE_STORAGE" | grep -c "zfspool" || true)
if [[ "$zfs_in_pve" -gt 0 ]]; then
if [[ "$DETECT_AVAILABLE_DISK_COUNT" -gt 0 ]]; then
STATE_STORAGE="available" # has pool but also unused disks
else
STATE_STORAGE="done"
fi
elif [[ "$DETECT_AVAILABLE_DISK_COUNT" -gt 0 ]]; then
STATE_STORAGE="needed"
else
STATE_STORAGE="done" # no pool, but no disks available either
fi
# WireGuard
if [[ "$DETECT_WG_ACTIVE" == "yes" ]]; then
STATE_WIREGUARD="done"
else
STATE_WIREGUARD="needed"
fi
# Firewall
if [[ "$DETECT_NFT_RULES" == "yes" ]]; then
STATE_FIREWALL="done"
else
STATE_FIREWALL="needed"
fi
# API token
if [[ "$DETECT_API_ROLE" == "yes" ]] && [[ "$DETECT_API_POOL" == "yes" ]] && [[ "$DETECT_API_TOKEN" == "yes" ]]; then
STATE_API="done"
elif [[ "$DETECT_API_ROLE" == "yes" ]] || [[ "$DETECT_API_POOL" == "yes" ]] || [[ "$DETECT_API_TOKEN" == "yes" ]]; then
STATE_API="partial"
else
STATE_API="needed"
fi
}
# ---------------------------------------------------------------------------
# Status dashboard
# ---------------------------------------------------------------------------
show_status() {
echo ""
echo -e "${BOLD}Host${RESET} ${DETECT_HOSTNAME} (PVE ${DETECT_PVE_VERSION}, kernel ${DETECT_KERNEL})"
echo -e "${BOLD}Public IP${RESET} ${DETECT_PUBLIC_IP}"
echo ""
# Block devices
echo -e "${BOLD}Block devices:${RESET}"
echo "$DETECT_BLOCK_DEVICES" | while IFS= read -r line; do
echo " $line"
done
# ZFS pools
if [[ -n "$DETECT_ZPOOLS" ]]; then
echo ""
echo -e "${BOLD}ZFS pools:${RESET}"
echo "$DETECT_ZPOOLS" | while IFS= read -r line; do
echo " $line"
done
fi
# Bridges
echo ""
echo -e "${BOLD}Network:${RESET}"
if [[ -n "$DETECT_VMBR1_IP" ]]; then
echo -e " vmbr1 ${DETECT_VMBR1_IP} (forwarding: ${DETECT_IP_FORWARD}, NAT: ${DETECT_NAT_ACTIVE})"
else
echo -e " vmbr1 ${DIM}not configured${RESET}"
fi
if [[ "$DETECT_WG_ACTIVE" == "yes" ]]; then
echo -e " wg0 active (${DETECT_WG_PEERS:-0} peer(s))"
else
echo -e " wg0 ${DIM}not configured${RESET}"
fi
# Status dashboard
echo ""
echo -e "${BOLD}Configuration status:${RESET}"
echo ""
local effective_repos="$STATE_REPOS"
local effective_update="$STATE_UPDATE"
local effective_storage="$STATE_STORAGE"
local effective_wg="$STATE_WIREGUARD"
local effective_fw="$STATE_FIREWALL"
[[ "$SKIP_REPOS" == true ]] && effective_repos="skipped" && effective_update="skipped"
[[ "$SKIP_STORAGE" == true ]] && effective_storage="skipped"
[[ "$SKIP_WIREGUARD" == true ]] && effective_wg="skipped"
[[ "$SKIP_FIREWALL" == true ]] && effective_fw="skipped"
printf " %-20s %b" "Repositories" "$(status_icon "$effective_repos")"
case "$effective_repos" in
done) echo -e " no-subscription active, nag removed" ;;
partial)
local detail_parts=""
[[ -z "$DETECT_ENTERPRISE_ACTIVE" ]] || detail_parts="enterprise still active"
[[ "$DETECT_NOSUB_EXISTS" == "yes" ]] || detail_parts="${detail_parts:+$detail_parts, }no-sub repo missing"
[[ "$DETECT_NAG_PATCHED" == "yes" ]] || detail_parts="${detail_parts:+$detail_parts, }nag popup present"
echo " $detail_parts"
;;
needed) echo " enterprise repos active" ;;
skipped) echo "" ;;
esac
printf " %-20s %b" "System update" "$(status_icon "$effective_update")"
case "$effective_update" in
done) echo " all packages up to date" ;;
needed) echo " ${DETECT_UPGRADABLE_COUNT} package(s) upgradable" ;;
unknown) echo " run apt update to check" ;;
skipped) echo "" ;;
esac
printf " %-20s %b" "Private bridge" "$(status_icon "$STATE_BRIDGE")"
case "$STATE_BRIDGE" in
done) echo " vmbr1 ${DETECT_VMBR1_IP}, NAT active" ;;
partial) echo " vmbr1 ${DETECT_VMBR1_IP} but forwarding/NAT incomplete" ;;
needed) echo " vmbr1 not yet created" ;;
esac
printf " %-20s %b" "Storage" "$(status_icon "$effective_storage")"
case "$effective_storage" in
done) echo " ZFS pool registered" ;;
available) echo " ZFS pool exists, ${DETECT_AVAILABLE_DISK_COUNT} unused disk(s) available" ;;
needed) echo " ${DETECT_AVAILABLE_DISK_COUNT} unused disk(s), no pool created" ;;
skipped) echo "" ;;
esac
printf " %-20s %b" "WireGuard" "$(status_icon "$effective_wg")"
case "$effective_wg" in
done) echo " wg0 active, ${DETECT_WG_PEERS:-0} peer(s)" ;;
needed)
if [[ "$DETECT_WG_INSTALLED" == "yes" ]]; then
echo " installed but not configured"
else
echo " not installed"
fi
;;
skipped) echo "" ;;
esac
printf " %-20s %b" "Firewall" "$(status_icon "$effective_fw")"
case "$effective_fw" in
done) echo " nftables rules active" ;;
needed)
if [[ "$DETECT_NFT_FILE" == "yes" ]]; then
echo " config file exists but rules not loaded"
else
echo " no rules configured"
fi
;;
skipped) echo "" ;;
esac
printf " %-20s %b" "API token" "$(status_icon "$STATE_API")"
case "$STATE_API" in
done) echo " role + pool + token configured" ;;
partial)
local missing=""
[[ "$DETECT_API_ROLE" == "yes" ]] || missing="role"
[[ "$DETECT_API_POOL" == "yes" ]] || missing="${missing:+$missing, }pool"
[[ "$DETECT_API_TOKEN" == "yes" ]] || missing="${missing:+$missing, }token"
echo " missing: ${missing}"
;;
needed) echo " not configured" ;;
esac
echo ""
# Count pending items
local pending=0
[[ "$effective_repos" == "needed" || "$effective_repos" == "partial" ]] && pending=$((pending + 1))
[[ "$effective_update" == "needed" ]] && pending=$((pending + 1))
[[ "$STATE_BRIDGE" == "needed" || "$STATE_BRIDGE" == "partial" ]] && pending=$((pending + 1))
[[ "$effective_storage" == "needed" ]] && pending=$((pending + 1))
[[ "$effective_wg" == "needed" ]] && pending=$((pending + 1))
[[ "$effective_fw" == "needed" ]] && pending=$((pending + 1))
[[ "$STATE_API" == "needed" || "$STATE_API" == "partial" ]] && pending=$((pending + 1))
if [[ "$pending" -eq 0 ]]; then
success "All configured -- nothing to do"
return 1 # signal: no work needed
fi
info "${pending} item(s) need attention"
echo ""
return 0
}
# ---------------------------------------------------------------------------
# Apply: repos
# ---------------------------------------------------------------------------
apply_repos() {
if [[ "$SKIP_REPOS" == true ]] || [[ "$STATE_REPOS" == "done" ]]; then
return 0
fi
step "Repository configuration"
# Show what needs fixing
local actions=""
if [[ -n "$DETECT_ENTERPRISE_ACTIVE" ]]; then
actions="disable enterprise repos"
fi
if [[ "$DETECT_NOSUB_EXISTS" != "yes" ]]; then
actions="${actions:+$actions, }add no-subscription repo"
fi
if [[ "$DETECT_NAG_PATCHED" != "yes" ]]; then
actions="${actions:+$actions, }remove subscription nag popup"
fi
info "Will: ${actions}"
if ! confirm "Apply repository changes?"; then
info "Skipped"
return 0
fi
if dry_run_guard "${actions}"; then
return 0
fi
if [[ -n "$DETECT_ENTERPRISE_ACTIVE" ]]; then
remote "sed -i 's/^deb/# deb/' /etc/apt/sources.list.d/pve-enterprise.list 2>/dev/null || true"
remote "test -f /etc/apt/sources.list.d/ceph.list && sed -i 's/^deb/# deb/' /etc/apt/sources.list.d/ceph.list || true"
success "Enterprise repos disabled"
fi
if [[ "$DETECT_NOSUB_EXISTS" != "yes" ]]; then
remote "echo 'deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription' > /etc/apt/sources.list.d/pve-no-subscription.list"
success "No-subscription repo added"
fi
if [[ "$DETECT_NAG_PATCHED" != "yes" ]]; then
remote "sed -Ezi.bak \"s/(Ext\\.Msg\\.show\\(\\{[^}]+title: gettext\\('No valid sub)/void\\(\\{ \\/\\/\\1/\" /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js 2>/dev/null || true"
remote "systemctl restart pveproxy 2>/dev/null || true"
success "Subscription nag removed"
fi
}
# ---------------------------------------------------------------------------
# Apply: system update
# ---------------------------------------------------------------------------
apply_update() {
if [[ "$SKIP_REPOS" == true ]] || [[ "$STATE_UPDATE" == "done" ]]; then
return 0
fi
step "System update"
if [[ "$STATE_UPDATE" == "unknown" ]]; then
info "Package status unknown (apt update not yet run)"
else
info "${DETECT_UPGRADABLE_COUNT} package(s) can be upgraded"
fi
if ! confirm "Run apt update && apt dist-upgrade?"; then
info "Skipped"
return 0
fi
if dry_run_guard "apt update && apt dist-upgrade -y"; then
return 0
fi
info "Updating package lists..."
remote "apt update -qq"
info "Upgrading packages (this may take a few minutes)..."
remote "DEBIAN_FRONTEND=noninteractive apt dist-upgrade -y -qq"
success "System updated"
}
# ---------------------------------------------------------------------------
# Apply: private bridge
# ---------------------------------------------------------------------------
apply_bridge() {
if [[ "$STATE_BRIDGE" == "done" ]]; then
return 0
fi
step "Private bridge (vmbr1)"
if [[ "$STATE_BRIDGE" == "partial" ]]; then
info "vmbr1 exists (${DETECT_VMBR1_IP}) but IP forwarding or NAT needs fixing"
if [[ "$DETECT_IP_FORWARD" != "1" ]]; then
if confirm "Enable IP forwarding?"; then
if ! dry_run_guard "echo 1 > /proc/sys/net/ipv4/ip_forward"; then
remote "echo 1 > /proc/sys/net/ipv4/ip_forward"
remote_write "/etc/sysctl.d/99-forward.conf" "net.ipv4.ip_forward = 1"
success "IP forwarding enabled"
fi
fi
fi
if [[ "$DETECT_NAT_ACTIVE" != "yes" ]]; then
if confirm "Add NAT masquerade rule for ${SUBNET}?"; then
if ! dry_run_guard "iptables -t nat -A POSTROUTING -s ${SUBNET} -o vmbr0 -j MASQUERADE"; then
remote "iptables -t nat -A POSTROUTING -s ${SUBNET} -o vmbr0 -j MASQUERADE"
success "NAT masquerade active"
warn "Add post-up/post-down rules to /etc/network/interfaces to persist across reboots"
fi
fi
fi
return 0
fi
# STATE_BRIDGE == "needed"
info "Will create vmbr1 with:"
info " Subnet: ${SUBNET}"
info " Gateway: ${SUBNET_GW}/${SUBNET_MASK}"
info " NAT: MASQUERADE to vmbr0"
local bridge_config
bridge_config="
auto vmbr1
iface vmbr1 inet static
address ${SUBNET_GW}/${SUBNET_MASK}
bridge-ports none
bridge-stp off
bridge-fd 0
post-up echo 1 > /proc/sys/net/ipv4/ip_forward
post-up iptables -t nat -A POSTROUTING -s ${SUBNET} -o vmbr0 -j MASQUERADE
post-down iptables -t nat -D POSTROUTING -s ${SUBNET} -o vmbr0 -j MASQUERADE"
if [[ "$VERBOSE" == true ]]; then
echo ""
echo "$bridge_config"
echo ""
fi
if ! confirm "Add vmbr1 to /etc/network/interfaces?"; then
info "Skipped"
return 0
fi
if dry_run_guard "Create vmbr1 (${SUBNET_GW}/${SUBNET_MASK})"; then
return 0
fi
remote "printf '%s\n' '' >> /etc/network/interfaces"
remote_write "/tmp/vmbr1-config.tmp" "$bridge_config"
remote "cat /tmp/vmbr1-config.tmp >> /etc/network/interfaces && rm -f /tmp/vmbr1-config.tmp"
remote_write "/etc/sysctl.d/99-forward.conf" "net.ipv4.ip_forward = 1"
remote "sysctl -p /etc/sysctl.d/99-forward.conf >/dev/null 2>&1"
remote "ifreload -a 2>/dev/null || ifup vmbr1"
success "vmbr1 created (${SUBNET_GW}/${SUBNET_MASK})"
}
# ---------------------------------------------------------------------------
# Apply: storage
# ---------------------------------------------------------------------------
apply_storage() {
if [[ "$SKIP_STORAGE" == true ]] || [[ "$STATE_STORAGE" == "done" ]]; then
return 0
fi
step "ZFS storage pool"
if [[ "$STATE_STORAGE" == "available" ]]; then
info "ZFS pool already registered with Proxmox"
info "${DETECT_AVAILABLE_DISK_COUNT} additional unused disk(s) available:"
echo "$DETECT_AVAILABLE_DISKS" | while IFS= read -r line; do
[[ -n "$line" ]] && echo " /dev/$line"
done
if ! confirm "Create an additional storage pool?"; then
return 0
fi
fi
if [[ "$STATE_STORAGE" == "needed" ]]; then
# Check if a zpool named local-zfs already exists but isn't in PVE
local existing_pool
existing_pool=$(remote_output "zpool list -H local-zfs 2>/dev/null" || true)
if [[ -n "$existing_pool" ]]; then
info "ZFS pool 'local-zfs' exists but not registered with Proxmox"
if confirm "Register local-zfs with Proxmox?"; then
if ! dry_run_guard "pvesm add zfspool local-zfs"; then
remote "pvesm add zfspool local-zfs -pool local-zfs"
remote "pvesm set local-zfs -content images,rootdir"
success "local-zfs registered"
fi
fi
return 0
fi
fi
if [[ "$DETECT_AVAILABLE_DISK_COUNT" -eq 0 ]]; then
info "No unused disks found"
return 0
fi
info "Available disks:"
echo "$DETECT_AVAILABLE_DISKS" | while IFS= read -r line; do
[[ -n "$line" ]] && echo " /dev/$line"
done
local disk_count="$DETECT_AVAILABLE_DISK_COUNT"
local disk_names
disk_names=$(echo "$DETECT_AVAILABLE_DISKS" | awk 'NF{print "/dev/"$1}' | tr '\n' ' ')
local pool_type
if [[ "$disk_count" -eq 1 ]]; then
pool_type="single"
info "Recommendation: single disk (no redundancy)"
elif [[ "$disk_count" -eq 2 ]]; then
pool_type="mirror"
info "Recommendation: mirror (2 disks, full redundancy)"
else
pool_type="raidz1"
info "Recommendation: raidz1 (${disk_count} disks, 1-disk redundancy)"
fi
local pool_name
pool_name=$(prompt_value "Pool name" "local-zfs")
if ! confirm "Create ZFS pool '${pool_name}' (${pool_type}) with: ${disk_names}?"; then
info "Skipped"
return 0
fi
if dry_run_guard "zpool create ${pool_name} ${pool_type} ${disk_names}"; then
return 0
fi
local zpool_cmd="zpool create ${pool_name}"
case "$pool_type" in
mirror) zpool_cmd="${zpool_cmd} mirror ${disk_names}" ;;
raidz1) zpool_cmd="${zpool_cmd} raidz1 ${disk_names}" ;;
single) zpool_cmd="${zpool_cmd} ${disk_names}" ;;
esac
remote "$zpool_cmd"
remote "pvesm add zfspool ${pool_name} -pool ${pool_name}"
remote "pvesm set ${pool_name} -content images,rootdir"
success "ZFS pool '${pool_name}' created and registered"
}
# ---------------------------------------------------------------------------
# Apply: WireGuard
# ---------------------------------------------------------------------------
apply_wireguard() {
if [[ "$SKIP_WIREGUARD" == true ]] || [[ "$STATE_WIREGUARD" == "done" ]]; then
return 0
fi
step "WireGuard tunnel"
info "Will configure WireGuard:"
info " Server: ${WG_SERVER_IP}/${WG_SUBNET_MASK} on port ${WG_PORT}"
info " Client: ${WG_CLIENT_IP}/${WG_SUBNET_MASK}"
info " Routing: wg0 <-> vmbr1 forwarding"
if ! confirm "Set up WireGuard?"; then
info "Skipped"
return 0
fi
if dry_run_guard "Install and configure WireGuard (${WG_SERVER_IP}:${WG_PORT})"; then
return 0
fi
# Install if needed
if [[ "$DETECT_WG_INSTALLED" != "yes" ]]; then
info "Installing wireguard..."
remote "DEBIAN_FRONTEND=noninteractive apt install -y -qq wireguard >/dev/null 2>&1"
fi
# Generate server keys
info "Generating server keypair..."
remote "wg genkey | tee /etc/wireguard/server-private.key | wg pubkey > /etc/wireguard/server-public.key"
remote "chmod 600 /etc/wireguard/server-private.key"
local server_privkey
server_privkey=$(remote_output "cat /etc/wireguard/server-private.key")
local server_pubkey
server_pubkey=$(remote_output "cat /etc/wireguard/server-public.key")
# Generate client keys locally
info "Generating client keypair..."
local client_privkey client_pubkey
client_privkey=$(wg genkey)
client_pubkey=$(echo "$client_privkey" | wg pubkey)
# Write server config
local server_conf="[Interface]
PrivateKey = ${server_privkey}
Address = ${WG_SERVER_IP}/${WG_SUBNET_MASK}
ListenPort = ${WG_PORT}
# Allow forwarding between WireGuard clients and the private VM bridge
PostUp = iptables -A FORWARD -i wg0 -o vmbr1 -j ACCEPT; iptables -A FORWARD -i vmbr1 -o wg0 -j ACCEPT
PostDown = iptables -D FORWARD -i wg0 -o vmbr1 -j ACCEPT; iptables -D FORWARD -i vmbr1 -o wg0 -j ACCEPT
[Peer]
# Workstation
PublicKey = ${client_pubkey}
AllowedIPs = ${WG_CLIENT_IP}/32"
remote_write "/etc/wireguard/wg0.conf" "$server_conf"
remote "chmod 600 /etc/wireguard/wg0.conf"
remote "systemctl enable --now wg-quick@wg0"
success "WireGuard server configured"
echo ""
info "Client config -- save to /etc/wireguard/hetzner-lab.conf or import into WireGuard app:"
echo ""
echo " [Interface]"
echo " PrivateKey = ${client_privkey}"
echo " Address = ${WG_CLIENT_IP}/${WG_SUBNET_MASK}"
echo ""
echo " [Peer]"
echo " PublicKey = ${server_pubkey}"
echo " Endpoint = ${DETECT_PUBLIC_IP}:${WG_PORT}"
echo " AllowedIPs = 10.10.0.0/16"
echo " PersistentKeepalive = 25"
echo ""
warn "Save the client config now -- private key is not stored anywhere else"
}
# ---------------------------------------------------------------------------
# Apply: firewall
# ---------------------------------------------------------------------------
apply_firewall() {
if [[ "$SKIP_FIREWALL" == true ]] || [[ "$STATE_FIREWALL" == "done" ]]; then
return 0
fi
step "Firewall lockdown (nftables)"
if [[ "$DETECT_NFT_FILE" == "yes" ]]; then
info "Config file exists but rules not loaded"
if confirm "Load existing firewall rules from /etc/nftables-hetzner.conf?"; then
if ! dry_run_guard "nft -f /etc/nftables-hetzner.conf"; then
remote "nft -f /etc/nftables-hetzner.conf"
remote "systemctl enable nftables"
success "Firewall rules loaded"
fi
fi
return 0
fi
info "Will restrict public interface to SSH (22) and WireGuard (${WG_PORT}) only"
info "Web UI (8006) and VM traffic only via WireGuard"
warn "Make sure WireGuard is working before applying firewall rules!"
if ! confirm "Apply firewall rules?"; then
info "Skipped"
return 0
fi
if dry_run_guard "Apply nftables rules (SSH + WG only on public interface)"; then
return 0
fi
local nft_rules="#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
# Loopback
iif lo accept
# Established/related connections
ct state established,related accept
# ICMP (ping)
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
# SSH on public interface
iifname \"vmbr0\" tcp dport 22 accept
# WireGuard on public interface
iifname \"vmbr0\" udp dport ${WG_PORT} accept
# Allow everything on private bridge and WireGuard
iifname \"vmbr1\" accept
iifname \"wg0\" accept
# Log and drop everything else
log prefix \"nft-drop: \" limit rate 5/minute counter drop
}
chain forward {
type filter hook forward priority 0; policy drop;
# Established/related
ct state established,related accept
# WireGuard <-> private bridge
iifname \"wg0\" oifname \"vmbr1\" accept
iifname \"vmbr1\" oifname \"wg0\" accept
# Private bridge -> internet (NAT)
iifname \"vmbr1\" oifname \"vmbr0\" accept
}
chain output {
type filter hook output priority 0; policy accept;
}
}"
remote_write "/etc/nftables-hetzner.conf" "$nft_rules"
remote "nft -f /etc/nftables-hetzner.conf"
remote "cp /etc/nftables-hetzner.conf /etc/nftables.conf"
remote "systemctl enable nftables"
success "Firewall applied (SSH + WireGuard only on public interface)"
}
# ---------------------------------------------------------------------------
# Apply: API token
# ---------------------------------------------------------------------------
apply_api_token() {
if [[ "$STATE_API" == "done" ]]; then
return 0
fi
step "API token for incusos-proxmox"
# Show what's missing
local actions=""
[[ "$DETECT_API_ROLE" == "yes" ]] || actions="create IncusOSDeployer role"
[[ "$DETECT_API_POOL" == "yes" ]] || actions="${actions:+$actions, }create IncusLab pool"
[[ "$DETECT_API_TOKEN" == "yes" ]] || actions="${actions:+$actions, }create automation@pve!deploy token"
info "Will: ${actions}"
if ! confirm "Create missing API resources?"; then
info "Skipped"
return 0
fi
if dry_run_guard "${actions}"; then
return 0
fi
# Create role if needed
if [[ "$DETECT_API_ROLE" != "yes" ]]; then
remote "pveum role add IncusOSDeployer -privs \
'VM.Allocate VM.Config.CDROM VM.Config.CPU VM.Config.Disk VM.Config.HWType \
VM.Config.Memory VM.Config.Network VM.Config.Options VM.PowerMgmt \
VM.Monitor VM.Audit VM.Console \
Datastore.AllocateSpace Datastore.Audit \
Pool.Allocate Pool.Audit \
SDN.Use Sys.Modify'"
success "Created IncusOSDeployer role"
fi
# Create pool if needed
if [[ "$DETECT_API_POOL" != "yes" ]]; then
remote "pveum pool add IncusLab -comment 'IncusOS lab VMs'"
success "Created IncusLab pool"
fi
# Create token if needed
if [[ "$DETECT_API_TOKEN" != "yes" ]]; then
# Ensure user exists
remote "pveum user add automation@pve --comment 'IncusOS automation' 2>/dev/null || true"
local token_output
token_output=$(remote "pveum user token add automation@pve deploy --privsep 0 --output-format json")
local token_secret
token_secret=$(echo "$token_output" | python3 -c "import sys,json; print(json.load(sys.stdin)['value'])" 2>/dev/null || echo "$token_output")
# Assign ACLs
remote "pveum acl modify /pool/IncusLab -user automation@pve -role IncusOSDeployer"
local storage_name
storage_name=$(remote_output "pvesm status 2>/dev/null | awk '/zfspool/{print \$1}' | head -1" || true)
if [[ -n "$storage_name" ]]; then
remote "pveum acl modify /storage/${storage_name} -user automation@pve -role IncusOSDeployer"
fi
remote "pveum acl modify /storage/local -user automation@pve -role IncusOSDeployer"
success "API token created"
echo ""
warn "Token secret (save now -- shown only once):"
echo ""
echo " ${token_secret}"
echo ""
fi
}
# ---------------------------------------------------------------------------
# Summary output
# ---------------------------------------------------------------------------
show_summary() {
step "Summary"
echo ""
echo -e "${BOLD}proxmox.yaml for incusos/targets/hetzner/:${RESET}"
echo ""
echo " host: ${SUBNET_GW}"
echo " method: api"
echo " api_token_id: automation@pve!deploy"
echo " node: ${DETECT_HOSTNAME:-pve}"
echo " storage: local-zfs"
echo " iso_storage: local"
echo " bridge: vmbr1"
echo " gateway: ${SUBNET_GW}"
echo " dns: ${SUBNET_GW}"
echo " pool: IncusLab"
echo " ssh_user: root"
echo ""
echo -e "${BOLD}env file additions:${RESET}"
echo ""
echo " # Hetzner"
echo " HETZNER_PROXMOX_TOKEN_SECRET=<token from step above>"
echo " HETZNER_PROXMOX_ROOT_PASSWORD=<your root password>"
echo ""
echo -e "${BOLD}Next steps:${RESET}"
echo ""
echo " 1. Save WireGuard client config and connect"
echo " 2. Copy proxmox.yaml.example and fill in credentials:"
echo " cp incusos/targets/hetzner/proxmox.yaml.example incusos/targets/hetzner/proxmox.yaml"
echo " 3. Test with dry run:"
echo " export PROXMOX_TOKEN_SECRET=\"\$HETZNER_PROXMOX_TOKEN_SECRET\""
echo " incusos-proxmox --dry-run --proxmox incusos/targets/hetzner/proxmox.yaml \\"
echo " incusos/targets/hetzner/lab-cluster.yaml"
echo ""
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
setup_colors
parse_args "$@"
parse_subnets
echo ""
echo -e "${BOLD}${SCRIPT_NAME}${RESET} v${VERSION} -- Proxmox post-install configuration"
if [[ "$DRY_RUN" == true ]]; then
echo -e "${YELLOW}(dry-run mode -- no changes will be made)${RESET}"
fi
echo ""
# Phase 1: detect everything
detect_all
# Phase 2: show status dashboard
if ! show_status; then
# All done, nothing to do
show_summary
exit 0
fi
# Phase 3: apply only what's needed
if ! confirm "Proceed with configuration?"; then
info "Exiting without changes"
exit 0
fi
echo ""
apply_repos
apply_update
apply_bridge
apply_storage
apply_wireguard
apply_firewall
apply_api_token
show_summary
success "Setup complete"
}
main "$@"