#!/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 [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 0 fi return 1 } # 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 < [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 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. # Uses a quoted heredoc (<<'DETECT') so nothing is expanded locally -- # all $, ", etc. are passed literally to the remote shell. local detection_blob detection_blob=$(ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_HOST" bash <<'DETECT' 2>/dev/null || true 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---' # Check both .list (PVE 8) and .sources/deb822 (PVE 9) formats enterprise_active='no' # Old .list format: uncommented deb lines with enterprise if grep -rl '^deb.*enterprise' /etc/apt/sources.list.d/*.list 2>/dev/null | head -1 | grep -q .; then enterprise_active='yes' fi # New .sources/deb822 format: check for enterprise files that are NOT disabled for f in /etc/apt/sources.list.d/*enterprise*.sources /etc/apt/sources.list.d/*enterprise*.list; do [ -f "$f" ] || continue # deb822: Enabled: false means disabled if grep -qi '^Enabled:.*false' "$f" 2>/dev/null; then continue fi # deb822: if it has enterprise in URIs and no Enabled: false, it's active if grep -qi 'enterprise' "$f" 2>/dev/null; then enterprise_active='yes' fi done echo "$enterprise_active" echo '---NOSUB_REPO---' # Check both .list and .sources formats for no-subscription repo nosub='no' if grep -rq 'pve-no-subscription' /etc/apt/sources.list.d/ 2>/dev/null; then nosub='yes' fi echo "$nosub" echo '---NAG_PATCHED---' # Detect whether the subscription nag popup has been removed. # Methods vary by tool and PVE version: # 1. community-scripts post-install: installs a dpkg hook that patches # proxmoxlib.js after every update, changing "!== 'active'" to # "== 'NoMoreNagging'" on data.status lines. Leaves behind: # - /etc/apt/apt.conf.d/no-nag-script (dpkg hook) # - /usr/local/bin/pve-remove-nag.sh (the patcher) # 2. pve-nag-buster: similar dpkg hook approach # 3. Manual sed: replaces Ext.Msg.show with void({ nag_patched='no' # Check for persistent dpkg hook (community-scripts / pve-nag-buster) if [ -f /etc/apt/apt.conf.d/no-nag-script ] || [ -f /usr/local/bin/pve-remove-nag.sh ]; then nag_patched='yes' # Check for pve-nag-buster package hook elif ls /etc/dpkg/dpkg.cfg.d/*nag* /etc/apt/apt.conf.d/*nag-buster* 2>/dev/null | grep -q .; then nag_patched='yes' # Check JS for void({ patch (manual sed method) elif grep -q 'void({' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js 2>/dev/null; then nag_patched='yes' # Check if nag text was removed entirely elif ! grep -q "No valid subscription" /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js 2>/dev/null; then nag_patched='yes' fi echo "$nag_patched" echo '---UPGRADABLE---' apt list --upgradable 2>/dev/null | grep -c upgradable || true 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_out=$(wg show wg0 2>/dev/null | head -1) || true if [ -n "$wg_out" ]; then echo "$wg_out"; else echo 'none'; fi echo '---WG_PEERS---' wg_peers=$(wg show wg0 peers 2>/dev/null | wc -l) || true echo "${wg_peers:-0}" echo '---WG_HANDSHAKE---' wg_hs=$(wg show wg0 latest-handshakes 2>/dev/null | head -1) || true if [ -n "$wg_hs" ]; then echo "$wg_hs"; else echo 'none'; fi 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; print("yes" if any(r["roleid"]=="IncusOSDeployer" for r in json.load(sys.stdin)) else "no")' 2>/dev/null \ || echo 'no' echo '---API_POOL---' pveum pool list --output-format json 2>/dev/null \ | python3 -c 'import sys,json; print("yes" if any(p["poolid"]=="IncusLab" for p in json.load(sys.stdin)) else "no")' 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; print("yes" if any(t["tokenid"]=="deploy" for t in json.load(sys.stdin)) else "no")' 2>/dev/null \ || echo 'no' echo '---AVAILABLE_DISKS---' # Build list of whole-disk names that are part of ZFS pools. # zpool status shows partition names (e.g. nvme0n1p3), so strip the # partition suffix to get the parent disk name. zfs_disks=$(zpool status 2>/dev/null \ | grep -oP '(nvme\S+|sd[a-z]+\S*|vd[a-z]+\S*)' \ | sed -E 's/p?[0-9]+$//' \ | sort -u || true) lsblk -dno NAME,SIZE,TYPE | while read name size type; do [ "$type" != 'disk' ] && continue # Skip disks used by ZFS pools echo "$zfs_disks" | grep -qw "$name" && continue # Skip disks with active mount points lsblk -no MOUNTPOINT "/dev/$name" 2>/dev/null | grep -q '/' && continue # Check for existing partition table / signatures partcount=$(lsblk -no NAME "/dev/$name" 2>/dev/null | wc -l) if [ "$partcount" -gt 1 ]; then # Has partitions -- detect type fstype=$(lsblk -no FSTYPE "/dev/$name" 2>/dev/null | grep -v '^$' | head -1) echo "$name $size partitioned:${fstype:-unknown}" else echo "$name $size clean" fi done echo '---END---' DETECT ) # 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 if [[ -n "$section" ]]; then _parse_section "$section" "$section_lines"; fi # 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" == "yes" ]]; then DETECT_ENTERPRISE_ACTIVE="yes" else DETECT_ENTERPRISE_ACTIVE="" 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" ]] && [[ -n "$content" ]]; then DETECT_WG_ACTIVE="yes" fi ;; WG_PEERS) DETECT_WG_PEERS="${content//[^0-9]/}" ;; WG_HANDSHAKE) if [[ "$content" != "none" ]] && [[ -n "$content" ]]; then DETECT_WG_HANDSHAKE="$content" fi ;; NFT_FILE) DETECT_NFT_FILE="$content" ;; NFT_RULES) DETECT_NFT_RULES="$content" ;; API_ROLE) if [[ "$content" == "yes" ]]; then DETECT_API_ROLE="yes"; fi ;; API_POOL) if [[ "$content" == "yes" ]]; then DETECT_API_POOL="yes"; fi ;; API_TOKEN) if [[ "$content" == "yes" ]]; then DETECT_API_TOKEN="yes"; fi ;; 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" if [[ "$SKIP_REPOS" == true ]]; then effective_repos="skipped"; effective_update="skipped"; fi if [[ "$SKIP_STORAGE" == true ]]; then effective_storage="skipped"; fi if [[ "$SKIP_WIREGUARD" == true ]]; then effective_wg="skipped"; fi if [[ "$SKIP_FIREWALL" == true ]]; then effective_fw="skipped"; fi 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 " system pool registered, no extra disks" ;; available) echo " system pool registered, ${DETECT_AVAILABLE_DISK_COUNT} extra 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 if [[ "$effective_repos" == "needed" || "$effective_repos" == "partial" ]]; then pending=$((pending + 1)); fi if [[ "$effective_update" == "needed" ]]; then pending=$((pending + 1)); fi if [[ "$STATE_BRIDGE" == "needed" || "$STATE_BRIDGE" == "partial" ]]; then pending=$((pending + 1)); fi if [[ "$effective_storage" == "needed" ]]; then pending=$((pending + 1)); fi if [[ "$effective_wg" == "needed" ]]; then pending=$((pending + 1)); fi if [[ "$effective_fw" == "needed" ]]; then pending=$((pending + 1)); fi if [[ "$STATE_API" == "needed" || "$STATE_API" == "partial" ]]; then pending=$((pending + 1)); fi 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 # Handle both .list (PVE 8 / bookworm) and .sources (PVE 9 / trixie) formats # .list format: comment out deb lines remote "for f in /etc/apt/sources.list.d/*enterprise*.list; do [ -f \"\$f\" ] && sed -i 's/^deb/# deb/' \"\$f\"; done 2>/dev/null || true" # .sources/deb822 format: set Enabled: false remote "for f in /etc/apt/sources.list.d/*enterprise*.sources; do [ -f \"\$f\" ] && sed -i '/^Enabled:/d' \"\$f\" && echo 'Enabled: false' >> \"\$f\"; done 2>/dev/null || true" success "Enterprise repos disabled" fi if [[ "$DETECT_NOSUB_EXISTS" != "yes" ]]; then # Detect PVE version to choose format local pve_major="${DETECT_PVE_VERSION%%.*}" if [[ "${pve_major:-8}" -ge 9 ]]; then # PVE 9+ uses deb822 .sources format remote "cat > /etc/apt/sources.list.d/proxmox.sources <<'REPO' Types: deb URIs: http://download.proxmox.com/debian/pve Suites: trixie Components: pve-no-subscription Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg REPO" else # PVE 8 uses traditional .list format remote "echo 'deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription' > /etc/apt/sources.list.d/pve-no-subscription.list" fi 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 if [[ -n "$line" ]]; then echo " /dev/$line"; fi 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:" local has_partitioned=false echo "$DETECT_AVAILABLE_DISKS" | while IFS= read -r line; do if [[ -n "$line" ]]; then local dname dsize dstate dname=$(echo "$line" | awk '{print $1}') dsize=$(echo "$line" | awk '{print $2}') dstate=$(echo "$line" | awk '{print $3}') if [[ "$dstate" == partitioned:* ]]; then local fsinfo="${dstate#partitioned:}" echo -e " /dev/${dname} ${dsize} ${YELLOW}has existing partitions (${fsinfo})${RESET}" else echo " /dev/${dname} ${dsize} clean" fi fi done # Check for partitioned disks that need wiping local partitioned_disks partitioned_disks=$(echo "$DETECT_AVAILABLE_DISKS" | awk '/partitioned:/{print "/dev/"$1}' | tr '\n' ' ') if [[ -n "${partitioned_disks// /}" ]]; then has_partitioned=true warn "Disk(s) with existing partitions must be wiped before use in a ZFS pool" warn "Affected: ${partitioned_disks}" fi 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" "vm-storage") if [[ "$has_partitioned" == true ]]; then warn "This will wipe partition tables on: ${partitioned_disks}" fi 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 # Wipe partition tables on disks that have them for disk in $partitioned_disks; do info "Wiping partition table on ${disk}..." remote "wipefs -a ${disk}" remote "sgdisk -Z ${disk} 2>/dev/null || true" done local zpool_cmd="zpool create -f ${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=" echo " HETZNER_PROXMOX_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 "$@"