976 lines
31 KiB
Bash
Executable File
976 lines
31 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# proxmox-setup - Interactive Proxmox post-install configuration for Hetzner
|
|
#
|
|
# Configures a fresh Proxmox host with private networking, ZFS storage,
|
|
# WireGuard tunnel, firewall lockdown, and API tokens for incusos-proxmox.
|
|
#
|
|
# Runs from your workstation over SSH. Requires an SSH host alias configured
|
|
# in ~/.ssh/config (e.g. "hetzner-lab").
|
|
#
|
|
# Usage: proxmox-setup --host <ssh-alias> [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 <<EOF
|
|
${BOLD}${SCRIPT_NAME}${RESET} v${VERSION} - Interactive Proxmox post-install configuration for Hetzner
|
|
|
|
${BOLD}USAGE${RESET}
|
|
${SCRIPT_NAME} --host <ssh-alias> [OPTIONS]
|
|
|
|
${BOLD}DESCRIPTION${RESET}
|
|
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 <public-ip>
|
|
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=<token from step above>"
|
|
echo " HETZNER_PROXMOX_ROOT_PASSWORD=<your root password>"
|
|
|
|
echo ""
|
|
echo -e "${BOLD}Next steps:${RESET}"
|
|
echo ""
|
|
echo " 1. Save WireGuard client config and connect"
|
|
echo " 2. Copy proxmox.yaml.example and fill in credentials:"
|
|
echo " cp incusos/targets/hetzner/proxmox.yaml.example incusos/targets/hetzner/proxmox.yaml"
|
|
echo " 3. Test with dry run:"
|
|
echo " export PROXMOX_TOKEN_SECRET=\"\$HETZNER_PROXMOX_TOKEN_SECRET\""
|
|
echo " incusos-proxmox --dry-run --proxmox incusos/targets/hetzner/proxmox.yaml \\"
|
|
echo " incusos/targets/hetzner/lab-cluster.yaml"
|
|
echo ""
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
main() {
|
|
setup_colors
|
|
parse_args "$@"
|
|
parse_subnets
|
|
|
|
echo ""
|
|
echo -e "${BOLD}${SCRIPT_NAME}${RESET} v${VERSION} -- Proxmox post-install configuration"
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
echo -e "${YELLOW}(dry-run mode -- no changes will be made)${RESET}"
|
|
fi
|
|
echo ""
|
|
|
|
step_detect
|
|
step_repos
|
|
step_update
|
|
step_bridge
|
|
step_storage
|
|
step_wireguard
|
|
step_firewall
|
|
step_api_token
|
|
step_summary
|
|
|
|
success "Setup complete"
|
|
}
|
|
|
|
main "$@"
|