#!/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"). # # Usage: proxmox-setup --host [OPTIONS] # Run 'proxmox-setup --help' for full usage information. set -euo pipefail readonly VERSION="0.1.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 in detect step) 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 PUBLIC_IP="" PVE_VERSION="" HOSTNAME_DETECTED="" KERNEL_VERSION="" # --------------------------------------------------------------------------- # 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 } # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- 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} Configures a fresh Proxmox host over SSH with: - 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 Each 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} # Preview everything ${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 # 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: Host hetzner-lab HostName User root IdentityFile ~/.ssh/id_ed25519 ${BOLD}SEE ALSO${RESET} hetzner/hetzner-setup.md Full manual 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() { # 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%.*}" WG_SERVER_IP="${WG_SUBNET_BASE}.1" WG_CLIENT_IP="${WG_SUBNET_BASE}.2" } # --------------------------------------------------------------------------- # Step 1: Detect host information # --------------------------------------------------------------------------- step_detect() { step "Detecting host information" # 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 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" done 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 fi echo "" } # --------------------------------------------------------------------------- # Step 2: Repositories # --------------------------------------------------------------------------- step_repos() { if [[ "$SKIP_REPOS" == true ]]; then info "Skipping repository configuration (--skip-repos)" 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 fi info "Will disable enterprise repos and enable no-subscription repos" if ! confirm "Apply repository changes?"; then info "Skipped" return 0 fi if dry_run_guard "Disable enterprise repos, enable no-subscription"; 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" # 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" # 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" } # --------------------------------------------------------------------------- # Step 3: System update # --------------------------------------------------------------------------- step_update() { if [[ "$SKIP_REPOS" == true ]]; then info "Skipping system update (--skip-repos)" return 0 fi step "System update" 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" } # --------------------------------------------------------------------------- # Step 4: 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 return 0 fi 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 # 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 # --------------------------------------------------------------------------- step_storage() { if [[ "$SKIP_STORAGE" == true ]]; then info "Skipping storage setup (--skip-storage)" 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\" done " || true) 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" return 0 fi info "Available disks:" echo "$available_disks" | while IFS= read -r line; do echo " /dev/$line" done local disk_count disk_count=$(echo "$available_disks" | wc -l) local disk_names disk_names=$(echo "$available_disks" | awk '{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" } # --------------------------------------------------------------------------- # Step 6: WireGuard # --------------------------------------------------------------------------- step_wireguard() { if [[ "$SKIP_WIREGUARD" == true ]]; then info "Skipping WireGuard setup (--skip-wireguard)" 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}" 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 info "Installing wireguard..." remote "DEBIAN_FRONTEND=noninteractive apt install -y -qq wireguard >/dev/null 2>&1" # 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" # 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 "" echo " [Interface]" echo " PrivateKey = ${client_privkey}" echo " Address = ${WG_CLIENT_IP}/${WG_SUBNET_MASK}" echo "" echo " [Peer]" echo " PublicKey = ${server_pubkey}" echo " Endpoint = ${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" } # --------------------------------------------------------------------------- # Step 7: Firewall # --------------------------------------------------------------------------- step_firewall() { if [[ "$SKIP_FIREWALL" == true ]]; then info "Skipping firewall setup (--skip-firewall)" 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)" 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)" } # --------------------------------------------------------------------------- # Step 8: API token # --------------------------------------------------------------------------- step_api_token() { 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) 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 if dry_run_guard "Create IncusOSDeployer role + automation@pve!deploy token"; then return 0 fi # Create role 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 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 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 remote "pveum user add automation@pve --comment 'IncusOS automation' 2>/dev/null || true" token_output=$(remote "pveum user token add automation@pve deploy --privsep 0 --output-format json") 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 # --------------------------------------------------------------------------- step_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: ${HOSTNAME_DETECTED:-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 "" step_detect step_repos step_update step_bridge step_storage step_wireguard step_firewall step_api_token step_summary success "Setup complete" } main "$@"