diff --git a/hetzner/proxmox-setup b/hetzner/proxmox-setup index 77cf2f2..b52d800 100755 --- a/hetzner/proxmox-setup +++ b/hetzner/proxmox-setup @@ -7,12 +7,15 @@ # 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.1.0" +readonly VERSION="0.2.0" readonly SCRIPT_NAME="proxmox-setup" readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -33,7 +36,7 @@ SKIP_WIREGUARD=false SKIP_FIREWALL=false SKIP_STORAGE=false -# Derived (computed in detect step) +# 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 @@ -41,10 +44,58 @@ 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 -PUBLIC_IP="" -PVE_VERSION="" -HOSTNAME_DETECTED="" -KERNEL_VERSION="" + +# --------------------------------------------------------------------------- +# 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 @@ -80,32 +131,37 @@ dry_run_guard() { 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 # --------------------------------------------------------------------------- -# Run a command on the remote host via SSH remote() { ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_HOST" "$@" } -# Run a command and capture output remote_output() { ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_HOST" "$@" 2>/dev/null } -# Write content to a remote file via SSH remote_write() { local dest="$1" local content="$2" ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_HOST" "cat > '$dest'" <<< "$content" } -# Check if remote file exists -remote_file_exists() { - remote "test -f '$1'" 2>/dev/null -} - # --------------------------------------------------------------------------- # Interactive helpers # --------------------------------------------------------------------------- @@ -145,7 +201,9 @@ ${BOLD}USAGE${RESET} ${SCRIPT_NAME} --host [OPTIONS] ${BOLD}DESCRIPTION${RESET} - Configures a fresh Proxmox host over SSH with: + 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 @@ -153,7 +211,8 @@ ${BOLD}DESCRIPTION${RESET} - nftables firewall lockdown - API token for incusos-proxmox automation - Each step prompts for confirmation. Use --yes to skip prompts. + 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) @@ -172,7 +231,7 @@ ${BOLD}OPTIONS${RESET} ${BOLD}-V, --version${RESET} Show version ${BOLD}EXAMPLES${RESET} - # Preview everything + # See what needs doing (safe, read-only) ${SCRIPT_NAME} --host hetzner-lab --dry-run # Full setup with defaults @@ -181,9 +240,6 @@ ${BOLD}EXAMPLES${RESET} # Custom subnets, skip firewall ${SCRIPT_NAME} --host hetzner-lab --subnet 172.16.0.0/24 --skip-firewall - # Just WireGuard and firewall (repos/storage already done) - ${SCRIPT_NAME} --host hetzner-lab --skip-repos --skip-storage - ${BOLD}PREREQUISITES${RESET} - SSH access to the Proxmox host (key-based auth recommended) - Add an entry to ~/.ssh/config: @@ -193,7 +249,7 @@ ${BOLD}PREREQUISITES${RESET} IdentityFile ~/.ssh/id_ed25519 ${BOLD}SEE ALSO${RESET} - hetzner/hetzner-setup.md Full manual guide + hetzner/hetzner-setup.md Manual installation guide incusos/targets/hetzner/ Target config files EOF exit 0 @@ -286,13 +342,11 @@ parse_args() { # --------------------------------------------------------------------------- parse_subnets() { - # Parse private bridge subnet SUBNET_MASK="${SUBNET##*/}" local subnet_network="${SUBNET%%/*}" SUBNET_BASE="${subnet_network%.*}" SUBNET_GW="${SUBNET_BASE}.1" - # Parse WireGuard subnet WG_SUBNET_MASK="${WG_SUBNET##*/}" local wg_network="${WG_SUBNET%%/*}" WG_SUBNET_BASE="${wg_network%.*}" @@ -301,11 +355,11 @@ parse_subnets() { } # --------------------------------------------------------------------------- -# Step 1: Detect host information +# Detection: gather all state in one pass # --------------------------------------------------------------------------- -step_detect() { - step "Detecting host information" +detect_all() { + step "Detecting host state" # Test SSH connectivity if ! remote "true" 2>/dev/null; then @@ -316,123 +370,446 @@ step_detect() { fi success "SSH connection to ${SSH_HOST}" - # Gather host info - HOSTNAME_DETECTED=$(remote_output "hostname") - KERNEL_VERSION=$(remote_output "uname -r") - PVE_VERSION=$(remote_output "pvesh get /version --output-format json 2>/dev/null | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"version\"])'" || echo "unknown") - PUBLIC_IP=$(remote_output "hostname -I | awk '{print \$1}'" || echo "unknown") - - info "Hostname: ${HOSTNAME_DETECTED}" - info "Kernel: ${KERNEL_VERSION}" - info "PVE version: ${PVE_VERSION}" - info "Primary IP: ${PUBLIC_IP}" - - # Block devices - echo "" - info "Block devices:" - remote "lsblk -d -o NAME,SIZE,MODEL,ROTA,TYPE 2>/dev/null" | while IFS= read -r line; do - echo " $line" - done - - # ZFS pools - local zpool_output - zpool_output=$(remote_output "zpool list -H 2>/dev/null" || true) - if [[ -n "$zpool_output" ]]; then - echo "" - info "ZFS pools:" - echo "$zpool_output" | while IFS= read -r line; do - echo " $line" + # 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 - # Existing storage - echo "" - info "Proxmox storage:" - remote "pvesm status 2>/dev/null" | while IFS= read -r line; do - echo " $line" - done - - # Network interfaces - echo "" - info "Network bridges:" - remote "ip -br addr show type bridge 2>/dev/null" | while IFS= read -r line; do - echo " $line" - done - - # WireGuard status - local wg_status - wg_status=$(remote_output "wg show 2>/dev/null" || true) - if [[ -n "$wg_status" ]]; then - echo "" - info "WireGuard active:" - echo "$wg_status" | head -5 | while IFS= read -r line; do - echo " $line" - done + # 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 - echo "" + # 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 } # --------------------------------------------------------------------------- -# Step 2: Repositories +# Status dashboard # --------------------------------------------------------------------------- -step_repos() { - if [[ "$SKIP_REPOS" == true ]]; then - info "Skipping repository configuration (--skip-repos)" +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" - # Check current state - local has_enterprise - has_enterprise=$(remote_output "grep -l '^deb.*enterprise' /etc/apt/sources.list.d/*.list 2>/dev/null" || true) - local has_nosub - has_nosub=$(remote_output "test -f /etc/apt/sources.list.d/pve-no-subscription.list && echo yes" || true) - - if [[ -z "$has_enterprise" ]] && [[ "$has_nosub" == "yes" ]]; then - success "Already configured (no-subscription repos active)" - return 0 + # 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 disable enterprise repos and enable no-subscription repos" + info "Will: ${actions}" if ! confirm "Apply repository changes?"; then info "Skipped" return 0 fi - if dry_run_guard "Disable enterprise repos, enable no-subscription"; then + if dry_run_guard "${actions}"; then return 0 fi - # Disable enterprise repos - 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" + 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 - # Add no-subscription repo - remote "echo 'deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription' > /etc/apt/sources.list.d/pve-no-subscription.list" + 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 - # Remove subscription nag - 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 "Repositories configured" + 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 } # --------------------------------------------------------------------------- -# Step 3: System update +# Apply: system update # --------------------------------------------------------------------------- -step_update() { - if [[ "$SKIP_REPOS" == true ]]; then - info "Skipping system update (--skip-repos)" +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 @@ -450,23 +827,42 @@ step_update() { } # --------------------------------------------------------------------------- -# Step 4: Private bridge +# Apply: private bridge # --------------------------------------------------------------------------- -step_bridge() { - step "Private bridge (vmbr1)" - - # Check if vmbr1 already exists - local existing_bridge - existing_bridge=$(remote_output "ip addr show vmbr1 2>/dev/null" || true) - if [[ -n "$existing_bridge" ]]; then - success "vmbr1 already exists" - remote "ip -br addr show vmbr1" | while IFS= read -r line; do - echo " $line" - done +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}" @@ -499,90 +895,70 @@ iface vmbr1 inet static return 0 fi - # Append to interfaces file 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" - # Persist IP forwarding 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" - # Apply remote "ifreload -a 2>/dev/null || ifup vmbr1" success "vmbr1 created (${SUBNET_GW}/${SUBNET_MASK})" } # --------------------------------------------------------------------------- -# Step 5: Storage +# Apply: storage # --------------------------------------------------------------------------- -step_storage() { - if [[ "$SKIP_STORAGE" == true ]]; then - info "Skipping storage setup (--skip-storage)" +apply_storage() { + if [[ "$SKIP_STORAGE" == true ]] || [[ "$STATE_STORAGE" == "done" ]]; then return 0 fi step "ZFS storage pool" - # Check if local-zfs already exists in PVE - local existing_zfs - existing_zfs=$(remote_output "pvesm status 2>/dev/null | grep -w local-zfs" || true) - if [[ -n "$existing_zfs" ]]; then - success "local-zfs already registered with Proxmox" - echo " $existing_zfs" - return 0 - fi - - # Check if a zpool named local-zfs already exists - 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 - return 0 - fi - remote "pvesm add zfspool local-zfs -pool local-zfs" - remote "pvesm set local-zfs -content images,rootdir" - success "local-zfs registered" - fi - return 0 - fi - - # Find available disks (not in any zpool, not mounted) - info "Scanning for available disks..." - local available_disks - available_disks=$(remote_output " - 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 - # Skip if in a zpool - echo \"\$used_disks\" | grep -qw \"\$name\" && continue - # Skip if has mounted partitions - lsblk -no MOUNTPOINT /dev/\$name 2>/dev/null | grep -q '/' && continue - echo \"\$name \$size\" + 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 - " || true) + if ! confirm "Create an additional storage pool?"; then + return 0 + fi + fi - if [[ -z "$available_disks" ]]; then - info "No unused disks found -- skipping storage pool creation" - info "If you have disks to use, create the pool manually:" - info " zpool create local-zfs mirror /dev/sdX /dev/sdY" - info " pvesm add zfspool local-zfs -pool local-zfs" + 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 "$available_disks" | while IFS= read -r line; do - echo " /dev/$line" + echo "$DETECT_AVAILABLE_DISKS" | while IFS= read -r line; do + [[ -n "$line" ]] && echo " /dev/$line" done - local disk_count - disk_count=$(echo "$available_disks" | wc -l) + local disk_count="$DETECT_AVAILABLE_DISK_COUNT" local disk_names - disk_names=$(echo "$available_disks" | awk '{print "/dev/"$1}' | tr '\n' ' ') + disk_names=$(echo "$DETECT_AVAILABLE_DISKS" | awk 'NF{print "/dev/"$1}' | tr '\n' ' ') local pool_type if [[ "$disk_count" -eq 1 ]]; then @@ -623,28 +999,16 @@ step_storage() { } # --------------------------------------------------------------------------- -# Step 6: WireGuard +# Apply: WireGuard # --------------------------------------------------------------------------- -step_wireguard() { - if [[ "$SKIP_WIREGUARD" == true ]]; then - info "Skipping WireGuard setup (--skip-wireguard)" +apply_wireguard() { + if [[ "$SKIP_WIREGUARD" == true ]] || [[ "$STATE_WIREGUARD" == "done" ]]; then return 0 fi step "WireGuard tunnel" - # Check if already configured - local wg_active - wg_active=$(remote_output "wg show wg0 2>/dev/null" || true) - if [[ -n "$wg_active" ]]; then - success "WireGuard wg0 already active" - remote "wg show wg0" 2>/dev/null | head -8 | while IFS= read -r line; do - echo " $line" - done - return 0 - fi - info "Will configure WireGuard:" info " Server: ${WG_SERVER_IP}/${WG_SUBNET_MASK} on port ${WG_PORT}" info " Client: ${WG_CLIENT_IP}/${WG_SUBNET_MASK}" @@ -659,9 +1023,11 @@ step_wireguard() { return 0 fi - # Install - info "Installing wireguard..." - remote "DEBIAN_FRONTEND=noninteractive apt install -y -qq wireguard >/dev/null 2>&1" + # 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..." @@ -697,12 +1063,10 @@ AllowedIPs = ${WG_CLIENT_IP}/32" remote_write "/etc/wireguard/wg0.conf" "$server_conf" remote "chmod 600 /etc/wireguard/wg0.conf" - # Enable and start remote "systemctl enable --now wg-quick@wg0" success "WireGuard server configured" - # Print client config echo "" info "Client config -- save to /etc/wireguard/hetzner-lab.conf or import into WireGuard app:" echo "" @@ -712,7 +1076,7 @@ AllowedIPs = ${WG_CLIENT_IP}/32" echo "" echo " [Peer]" echo " PublicKey = ${server_pubkey}" - echo " Endpoint = ${PUBLIC_IP}:${WG_PORT}" + echo " Endpoint = ${DETECT_PUBLIC_IP}:${WG_PORT}" echo " AllowedIPs = 10.10.0.0/16" echo " PersistentKeepalive = 25" echo "" @@ -720,22 +1084,25 @@ AllowedIPs = ${WG_CLIENT_IP}/32" } # --------------------------------------------------------------------------- -# Step 7: Firewall +# Apply: firewall # --------------------------------------------------------------------------- -step_firewall() { - if [[ "$SKIP_FIREWALL" == true ]]; then - info "Skipping firewall setup (--skip-firewall)" +apply_firewall() { + if [[ "$SKIP_FIREWALL" == true ]] || [[ "$STATE_FIREWALL" == "done" ]]; then return 0 fi step "Firewall lockdown (nftables)" - # Check if already configured - local existing_rules - existing_rules=$(remote_output "test -f /etc/nftables-hetzner.conf && echo yes" || true) - if [[ "$existing_rules" == "yes" ]]; then - success "Firewall rules already present (/etc/nftables-hetzner.conf)" + 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 @@ -812,29 +1179,35 @@ table inet filter { } # --------------------------------------------------------------------------- -# Step 8: API token +# Apply: API token # --------------------------------------------------------------------------- -step_api_token() { +apply_api_token() { + if [[ "$STATE_API" == "done" ]]; then + return 0 + fi + step "API token for incusos-proxmox" - # Check if role already exists - local existing_role - existing_role=$(remote_output "pveum role list --output-format json 2>/dev/null | python3 -c 'import sys,json; [print(r[\"roleid\"]) for r in json.load(sys.stdin) if r[\"roleid\"]==\"IncusOSDeployer\"]'" || true) + # 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" - if [[ -n "$existing_role" ]]; then - success "IncusOSDeployer role already exists" - else - if ! confirm "Create API role, pool, and token?"; then - info "Skipped" - return 0 - fi + info "Will: ${actions}" - if dry_run_guard "Create IncusOSDeployer role + automation@pve!deploy token"; then - return 0 - fi + if ! confirm "Create missing API resources?"; then + info "Skipped" + return 0 + fi - # Create role + 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 \ @@ -846,66 +1219,46 @@ step_api_token() { fi # Create pool if needed - local existing_pool - existing_pool=$(remote_output "pveum pool list --output-format json 2>/dev/null | python3 -c 'import sys,json; [print(p[\"poolid\"]) for p in json.load(sys.stdin) if p[\"poolid\"]==\"IncusLab\"]'" || true) - - if [[ -z "$existing_pool" ]] && [[ "$DRY_RUN" != true ]]; then + if [[ "$DETECT_API_POOL" != "yes" ]]; then remote "pveum pool add IncusLab -comment 'IncusOS lab VMs'" success "Created IncusLab pool" fi - # Create token if user doesn't exist yet - local existing_token - existing_token=$(remote_output "pveum user token list automation@pve --output-format json 2>/dev/null | python3 -c 'import sys,json; [print(t[\"tokenid\"]) for t in json.load(sys.stdin) if t[\"tokenid\"]==\"deploy\"]'" || true) - - if [[ -n "$existing_token" ]]; then - success "automation@pve!deploy token already exists" - info "If you need to regenerate the secret, delete and recreate the token" - return 0 - fi - - if [[ "$DRY_RUN" == true ]]; then - info "(dry-run) Would create automation@pve!deploy token" - return 0 - fi - - # Create token - local token_output - token_output=$(remote "pveum user token add automation@pve deploy --privsep 0 --output-format json 2>/dev/null" || true) - - if [[ -z "$token_output" ]]; then - # User might not exist yet + # 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 - - 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" - - # Try to find the storage name for ACL assignment - 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 "" } # --------------------------------------------------------------------------- -# Step 9: Summary output +# Summary output # --------------------------------------------------------------------------- -step_summary() { +show_summary() { step "Summary" echo "" @@ -914,7 +1267,7 @@ step_summary() { echo " host: ${SUBNET_GW}" echo " method: api" echo " api_token_id: automation@pve!deploy" - echo " node: ${HOSTNAME_DETECTED:-pve}" + echo " node: ${DETECT_HOSTNAME:-pve}" echo " storage: local-zfs" echo " iso_storage: local" echo " bridge: vmbr1" @@ -959,15 +1312,31 @@ main() { fi echo "" - step_detect - step_repos - step_update - step_bridge - step_storage - step_wireguard - step_firewall - step_api_token - step_summary + # 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" }