1384 lines
47 KiB
Bash
Executable File
1384 lines
47 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# proxmox-setup - Interactive Proxmox post-install configuration for Hetzner
|
|
#
|
|
# Configures a fresh Proxmox host with private networking, ZFS storage,
|
|
# WireGuard tunnel, firewall lockdown, and API tokens for incusos-proxmox.
|
|
#
|
|
# Runs from your workstation over SSH. Requires an SSH host alias configured
|
|
# in ~/.ssh/config (e.g. "hetzner-lab").
|
|
#
|
|
# The script first detects the full state of the host, shows a status
|
|
# dashboard, and then only offers to change what's actually needed.
|
|
#
|
|
# Usage: proxmox-setup --host <ssh-alias> [OPTIONS]
|
|
# Run 'proxmox-setup --help' for full usage information.
|
|
|
|
set -euo pipefail
|
|
|
|
readonly VERSION="0.2.0"
|
|
readonly SCRIPT_NAME="proxmox-setup"
|
|
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defaults
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SSH_HOST=""
|
|
DRY_RUN=false
|
|
VERBOSE=false
|
|
QUIET=false
|
|
YES=false
|
|
SUBNET="10.10.0.0/24"
|
|
WG_SUBNET="10.10.99.0/24"
|
|
WG_PORT="51820"
|
|
SKIP_REPOS=false
|
|
SKIP_WIREGUARD=false
|
|
SKIP_FIREWALL=false
|
|
|
|
# Derived (computed after argument parsing)
|
|
SUBNET_BASE="" # e.g. 10.10.0
|
|
SUBNET_MASK="" # e.g. 24
|
|
SUBNET_GW="" # e.g. 10.10.0.1
|
|
WG_SUBNET_BASE="" # e.g. 10.10.99
|
|
WG_SUBNET_MASK="" # e.g. 24
|
|
WG_SERVER_IP="" # e.g. 10.10.99.1
|
|
WG_CLIENT_IP="" # e.g. 10.10.99.2
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Detected state (populated by detect_all)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Host basics
|
|
DETECT_HOSTNAME=""
|
|
DETECT_KERNEL=""
|
|
DETECT_PVE_VERSION=""
|
|
DETECT_PUBLIC_IP=""
|
|
DETECT_BLOCK_DEVICES=""
|
|
|
|
# Repos
|
|
STATE_REPOS="needed" # done | needed | partial
|
|
DETECT_ENTERPRISE_ACTIVE="" # non-empty if enterprise repos active
|
|
DETECT_NOSUB_EXISTS="" # "yes" if no-sub repo file exists
|
|
DETECT_NAG_PATCHED="" # "yes" if nag popup already patched
|
|
|
|
# Updates
|
|
STATE_UPDATE="unknown" # done | needed | unknown
|
|
DETECT_UPGRADABLE_COUNT="0"
|
|
|
|
# Bridge
|
|
STATE_BRIDGE="needed" # done | needed
|
|
DETECT_VMBR1_IP="" # e.g. "10.10.0.1/24" or empty
|
|
DETECT_IP_FORWARD="" # "1" or "0"
|
|
DETECT_NAT_ACTIVE="" # "yes" or empty
|
|
|
|
DETECT_ZPOOLS=""
|
|
|
|
# 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
|
|
|
|
# DNS (dnsmasq on vmbr1 -- required for IncusOS boot)
|
|
STATE_DNSMASQ="needed" # done | needed
|
|
DETECT_DNSMASQ_ACTIVE="" # "yes" if dnsmasq is installed and listening on vmbr1
|
|
|
|
# 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
|
|
DETECT_API_ACLS="" # "yes" if /vms ACL is set
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Color and formatting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
setup_colors() {
|
|
if [[ -t 1 ]] && [[ "${NO_COLOR:-}" != "1" ]] && [[ "${TERM:-}" != "dumb" ]]; then
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[0;33m'
|
|
BLUE='\033[0;34m'
|
|
CYAN='\033[0;36m'
|
|
BOLD='\033[1m'
|
|
DIM='\033[2m'
|
|
RESET='\033[0m'
|
|
else
|
|
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' DIM='' RESET=''
|
|
fi
|
|
}
|
|
|
|
info() { [[ "$QUIET" == true ]] && return; echo -e "${BLUE}[info]${RESET} $*"; }
|
|
success() { [[ "$QUIET" == true ]] && return; echo -e "${GREEN}[ok]${RESET} $*"; }
|
|
warn() { echo -e "${YELLOW}[warn]${RESET} $*" >&2; }
|
|
error() { echo -e "${RED}[error]${RESET} $*" >&2; }
|
|
step() { [[ "$QUIET" == true ]] && return; echo -e "${CYAN}[step]${RESET} ${BOLD}$*${RESET}"; }
|
|
detail() { [[ "$VERBOSE" != true ]] && return; echo -e "${DIM} $*${RESET}"; }
|
|
|
|
dry_run_guard() {
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
info "(dry-run) $*"
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# Status indicator for the dashboard
|
|
status_icon() {
|
|
local state="$1"
|
|
case "$state" in
|
|
done) echo -e "${GREEN}done${RESET}" ;;
|
|
needed) echo -e "${YELLOW}needed${RESET}" ;;
|
|
partial) echo -e "${YELLOW}partial${RESET}" ;;
|
|
available) echo -e "${BLUE}available${RESET}" ;;
|
|
skipped) echo -e "${DIM}skipped${RESET}" ;;
|
|
unknown) echo -e "${DIM}unknown${RESET}" ;;
|
|
esac
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SSH helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
remote() {
|
|
ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_HOST" "$@"
|
|
}
|
|
|
|
remote_output() {
|
|
ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_HOST" "$@" 2>/dev/null
|
|
}
|
|
|
|
remote_write() {
|
|
local dest="$1"
|
|
local content="$2"
|
|
ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_HOST" "cat > '$dest'" <<< "$content"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Interactive helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
confirm() {
|
|
local prompt="$1"
|
|
if [[ "$YES" == true ]]; then
|
|
return 0
|
|
fi
|
|
echo -en "${BOLD}${prompt}${RESET} [y/N] "
|
|
read -r answer
|
|
[[ "$answer" =~ ^[Yy]$ ]]
|
|
}
|
|
|
|
prompt_value() {
|
|
local prompt="$1"
|
|
local default="$2"
|
|
local result
|
|
if [[ "$YES" == true ]]; then
|
|
echo "$default"
|
|
return
|
|
fi
|
|
echo -en "${BOLD}${prompt}${RESET} [${default}]: "
|
|
read -r result
|
|
echo "${result:-$default}"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Help
|
|
# ---------------------------------------------------------------------------
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
${BOLD}${SCRIPT_NAME}${RESET} v${VERSION} - Interactive Proxmox post-install configuration for Hetzner
|
|
|
|
${BOLD}USAGE${RESET}
|
|
${SCRIPT_NAME} --host <ssh-alias> [OPTIONS]
|
|
|
|
${BOLD}DESCRIPTION${RESET}
|
|
Detects the full state of a Proxmox host, shows a status dashboard,
|
|
and interactively configures what's needed:
|
|
|
|
- No-subscription repositories and system update
|
|
- Private bridge (vmbr1) with NAT masquerading
|
|
- WireGuard tunnel for secure remote access
|
|
- nftables firewall lockdown
|
|
- API token for incusos-proxmox automation
|
|
|
|
Already-configured items are detected and skipped automatically.
|
|
Each pending step prompts for confirmation. Use --yes to skip prompts.
|
|
|
|
${BOLD}OPTIONS${RESET}
|
|
${BOLD}--host ALIAS${RESET} SSH host alias (required, e.g. hetzner-lab)
|
|
${BOLD}--subnet CIDR${RESET} Private bridge subnet (default: ${SUBNET})
|
|
${BOLD}--wg-subnet CIDR${RESET} WireGuard subnet (default: ${WG_SUBNET})
|
|
${BOLD}--wg-port PORT${RESET} WireGuard listen port (default: ${WG_PORT})
|
|
${BOLD}--skip-repos${RESET} Skip repository changes
|
|
${BOLD}--skip-wireguard${RESET} Skip WireGuard setup
|
|
${BOLD}--skip-firewall${RESET} Skip firewall lockdown
|
|
${BOLD}-n, --dry-run${RESET} Preview actions without executing
|
|
${BOLD}-y, --yes${RESET} Skip confirmations (use defaults)
|
|
${BOLD}-v, --verbose${RESET} Show detailed output
|
|
${BOLD}-q, --quiet${RESET} Suppress informational output
|
|
${BOLD}-h, --help${RESET} Show this help message
|
|
${BOLD}-V, --version${RESET} Show version
|
|
|
|
${BOLD}EXAMPLES${RESET}
|
|
# See what needs doing (safe, read-only)
|
|
${SCRIPT_NAME} --host hetzner-lab --dry-run
|
|
|
|
# Full setup with defaults
|
|
${SCRIPT_NAME} --host hetzner-lab --yes
|
|
|
|
# Custom subnets, skip firewall
|
|
${SCRIPT_NAME} --host hetzner-lab --subnet 172.16.0.0/24 --skip-firewall
|
|
|
|
${BOLD}PREREQUISITES${RESET}
|
|
- SSH access to the Proxmox host (key-based auth recommended)
|
|
- Add an entry to ~/.ssh/config:
|
|
Host hetzner-lab
|
|
HostName <public-ip>
|
|
User root
|
|
IdentityFile ~/.ssh/id_ed25519
|
|
|
|
${BOLD}SEE ALSO${RESET}
|
|
hetzner/hetzner-setup.md Manual installation guide
|
|
envs/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-wireguard)
|
|
SKIP_WIREGUARD=true
|
|
shift
|
|
;;
|
|
--skip-firewall)
|
|
SKIP_FIREWALL=true
|
|
shift
|
|
;;
|
|
-n|--dry-run)
|
|
DRY_RUN=true
|
|
shift
|
|
;;
|
|
-y|--yes)
|
|
YES=true
|
|
shift
|
|
;;
|
|
-v|--verbose)
|
|
VERBOSE=true
|
|
shift
|
|
;;
|
|
-q|--quiet)
|
|
QUIET=true
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
;;
|
|
-V|--version)
|
|
echo "${SCRIPT_NAME} v${VERSION}"
|
|
exit 0
|
|
;;
|
|
-*)
|
|
error "Unknown option: $1"
|
|
echo "Run '${SCRIPT_NAME} --help' for usage information." >&2
|
|
exit 1
|
|
;;
|
|
*)
|
|
error "Unexpected argument: $1"
|
|
echo "Run '${SCRIPT_NAME} --help' for usage information." >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "$SSH_HOST" ]]; then
|
|
error "--host is required (SSH alias, e.g. hetzner-lab)"
|
|
echo "Run '${SCRIPT_NAME} --help' for usage information." >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subnet helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
parse_subnets() {
|
|
SUBNET_MASK="${SUBNET##*/}"
|
|
local subnet_network="${SUBNET%%/*}"
|
|
SUBNET_BASE="${subnet_network%.*}"
|
|
SUBNET_GW="${SUBNET_BASE}.1"
|
|
|
|
WG_SUBNET_MASK="${WG_SUBNET##*/}"
|
|
local wg_network="${WG_SUBNET%%/*}"
|
|
WG_SUBNET_BASE="${wg_network%.*}"
|
|
WG_SERVER_IP="${WG_SUBNET_BASE}.1"
|
|
WG_CLIENT_IP="${WG_SUBNET_BASE}.2"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Detection: gather all state in one pass
|
|
# ---------------------------------------------------------------------------
|
|
|
|
detect_all() {
|
|
step "Detecting host state"
|
|
|
|
# Test SSH connectivity
|
|
if ! remote "true" 2>/dev/null; then
|
|
error "Cannot connect to '${SSH_HOST}' via SSH"
|
|
error "Ensure the host is in ~/.ssh/config and key auth works:"
|
|
echo " ssh ${SSH_HOST} hostname" >&2
|
|
exit 1
|
|
fi
|
|
success "SSH connection to ${SSH_HOST}"
|
|
|
|
# Gather everything in a single SSH call for speed.
|
|
# Uses a quoted heredoc (<<'DETECT') so nothing is expanded locally --
|
|
# all $, ", etc. are passed literally to the remote shell.
|
|
local detection_blob
|
|
detection_blob=$(ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_HOST" bash <<'DETECT' 2>/dev/null || true
|
|
echo '---HOSTNAME---'
|
|
hostname
|
|
echo '---KERNEL---'
|
|
uname -r
|
|
echo '---PVE_VERSION---'
|
|
pvesh get /version --output-format json 2>/dev/null \
|
|
| python3 -c 'import sys,json; print(json.load(sys.stdin)["version"])' 2>/dev/null \
|
|
|| echo 'unknown'
|
|
echo '---PUBLIC_IP---'
|
|
hostname -I | awk '{print $1}'
|
|
echo '---BLOCK_DEVICES---'
|
|
lsblk -d -o NAME,SIZE,MODEL,ROTA,TYPE 2>/dev/null
|
|
echo '---ZPOOLS---'
|
|
zpool list -H 2>/dev/null || true
|
|
echo '---ENTERPRISE_REPOS---'
|
|
# Check both .list (PVE 8) and .sources/deb822 (PVE 9) formats
|
|
enterprise_active='no'
|
|
# Old .list format: uncommented deb lines with enterprise
|
|
if grep -rl '^deb.*enterprise' /etc/apt/sources.list.d/*.list 2>/dev/null | head -1 | grep -q .; then
|
|
enterprise_active='yes'
|
|
fi
|
|
# New .sources/deb822 format: check for enterprise files that are NOT disabled
|
|
for f in /etc/apt/sources.list.d/*enterprise*.sources /etc/apt/sources.list.d/*enterprise*.list; do
|
|
[ -f "$f" ] || continue
|
|
# deb822: Enabled: false means disabled
|
|
if grep -qi '^Enabled:.*false' "$f" 2>/dev/null; then
|
|
continue
|
|
fi
|
|
# deb822: if it has enterprise in URIs and no Enabled: false, it's active
|
|
if grep -qi 'enterprise' "$f" 2>/dev/null; then
|
|
enterprise_active='yes'
|
|
fi
|
|
done
|
|
echo "$enterprise_active"
|
|
echo '---NOSUB_REPO---'
|
|
# Check both .list and .sources formats for no-subscription repo
|
|
nosub='no'
|
|
if grep -rq 'pve-no-subscription' /etc/apt/sources.list.d/ 2>/dev/null; then
|
|
nosub='yes'
|
|
fi
|
|
echo "$nosub"
|
|
echo '---NAG_PATCHED---'
|
|
# Detect whether the subscription nag popup has been removed.
|
|
# Methods vary by tool and PVE version:
|
|
# 1. community-scripts post-install: installs a dpkg hook that patches
|
|
# proxmoxlib.js after every update, changing "!== 'active'" to
|
|
# "== 'NoMoreNagging'" on data.status lines. Leaves behind:
|
|
# - /etc/apt/apt.conf.d/no-nag-script (dpkg hook)
|
|
# - /usr/local/bin/pve-remove-nag.sh (the patcher)
|
|
# 2. pve-nag-buster: similar dpkg hook approach
|
|
# 3. Manual sed: replaces Ext.Msg.show with void({
|
|
nag_patched='no'
|
|
# Check for persistent dpkg hook (community-scripts / pve-nag-buster)
|
|
if [ -f /etc/apt/apt.conf.d/no-nag-script ] || [ -f /usr/local/bin/pve-remove-nag.sh ]; then
|
|
nag_patched='yes'
|
|
# Check for pve-nag-buster package hook
|
|
elif ls /etc/dpkg/dpkg.cfg.d/*nag* /etc/apt/apt.conf.d/*nag-buster* 2>/dev/null | grep -q .; then
|
|
nag_patched='yes'
|
|
# Check JS for void({ patch (manual sed method)
|
|
elif grep -q 'void({' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js 2>/dev/null; then
|
|
nag_patched='yes'
|
|
# Check if nag text was removed entirely
|
|
elif ! grep -q "No valid subscription" /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js 2>/dev/null; then
|
|
nag_patched='yes'
|
|
fi
|
|
echo "$nag_patched"
|
|
echo '---UPGRADABLE---'
|
|
apt list --upgradable 2>/dev/null | grep -c upgradable || true
|
|
echo '---VMBR1---'
|
|
ip -br addr show vmbr1 2>/dev/null || echo 'none'
|
|
echo '---IP_FORWARD---'
|
|
cat /proc/sys/net/ipv4/ip_forward 2>/dev/null || echo '0'
|
|
echo '---NAT_ACTIVE---'
|
|
iptables -t nat -L POSTROUTING -n 2>/dev/null | grep -q MASQUERADE && echo 'yes' || echo 'no'
|
|
echo '---WG_INSTALLED---'
|
|
command -v wg >/dev/null 2>&1 && echo 'yes' || echo 'no'
|
|
echo '---WG_ACTIVE---'
|
|
wg_out=$(wg show wg0 2>/dev/null | head -1) || true
|
|
if [ -n "$wg_out" ]; then echo "$wg_out"; else echo 'none'; fi
|
|
echo '---WG_PEERS---'
|
|
wg_peers=$(wg show wg0 peers 2>/dev/null | wc -l) || true
|
|
echo "${wg_peers:-0}"
|
|
echo '---WG_HANDSHAKE---'
|
|
wg_hs=$(wg show wg0 latest-handshakes 2>/dev/null | head -1) || true
|
|
if [ -n "$wg_hs" ]; then echo "$wg_hs"; else echo 'none'; fi
|
|
echo '---NFT_FILE---'
|
|
test -f /etc/nftables-hetzner.conf && echo 'yes' || echo 'no'
|
|
echo '---NFT_RULES---'
|
|
nft list ruleset 2>/dev/null | grep -q 'nft-drop' && echo 'yes' || echo 'no'
|
|
echo '---DNSMASQ---'
|
|
# dnsmasq installed and listening on port 53 on vmbr1?
|
|
dnsmasq_ok='no'
|
|
if command -v dnsmasq >/dev/null 2>&1 && systemctl is-active dnsmasq >/dev/null 2>&1; then
|
|
# Check it's listening on the bridge IP
|
|
if ss -ulnp 2>/dev/null | grep ':53 ' | grep -q dnsmasq; then
|
|
dnsmasq_ok='yes'
|
|
fi
|
|
fi
|
|
echo "$dnsmasq_ok"
|
|
echo '---API_ROLE---'
|
|
pveum role list --output-format json 2>/dev/null \
|
|
| python3 -c 'import sys,json; print("yes" if any(r["roleid"]=="IncusOSDeployer" for r in json.load(sys.stdin)) else "no")' 2>/dev/null \
|
|
|| echo 'no'
|
|
echo '---API_POOL---'
|
|
pveum pool list --output-format json 2>/dev/null \
|
|
| python3 -c 'import sys,json; print("yes" if any(p["poolid"]=="IncusLab" for p in json.load(sys.stdin)) else "no")' 2>/dev/null \
|
|
|| echo 'no'
|
|
echo '---API_TOKEN---'
|
|
pveum user token list automation@pve --output-format json 2>/dev/null \
|
|
| python3 -c 'import sys,json; print("yes" if any(t["tokenid"]=="deploy" for t in json.load(sys.stdin)) else "no")' 2>/dev/null \
|
|
|| echo 'no'
|
|
echo '---API_ACLS---'
|
|
pveum acl list --output-format json 2>/dev/null \
|
|
| python3 -c 'import sys,json; acls=json.load(sys.stdin); paths={a.get("path") for a in acls if "automation@pve" in a.get("ugid","")}; ok=("/vms" in paths and "/sdn" in paths); print("yes" if ok else "no")' 2>/dev/null \
|
|
|| echo 'no'
|
|
echo '---END---'
|
|
DETECT
|
|
)
|
|
|
|
# Parse the blob into variables
|
|
local section=""
|
|
local section_lines=""
|
|
while IFS= read -r line; do
|
|
if [[ "$line" == ---*--- ]]; then
|
|
# Process previous section
|
|
if [[ -n "$section" ]]; then
|
|
_parse_section "$section" "$section_lines"
|
|
fi
|
|
section="${line//---/}"
|
|
section_lines=""
|
|
else
|
|
if [[ -n "$section_lines" ]]; then
|
|
section_lines="${section_lines}
|
|
${line}"
|
|
else
|
|
section_lines="$line"
|
|
fi
|
|
fi
|
|
done <<< "$detection_blob"
|
|
# Process last section
|
|
if [[ -n "$section" ]]; then _parse_section "$section" "$section_lines"; fi
|
|
|
|
# Determine composite states
|
|
_compute_states
|
|
}
|
|
|
|
_parse_section() {
|
|
local section="$1"
|
|
local content="$2"
|
|
content="${content#$'\n'}" # strip leading newline
|
|
|
|
case "$section" in
|
|
HOSTNAME) DETECT_HOSTNAME="$content" ;;
|
|
KERNEL) DETECT_KERNEL="$content" ;;
|
|
PVE_VERSION) DETECT_PVE_VERSION="$content" ;;
|
|
PUBLIC_IP) DETECT_PUBLIC_IP="$content" ;;
|
|
BLOCK_DEVICES) DETECT_BLOCK_DEVICES="$content" ;;
|
|
ZPOOLS) DETECT_ZPOOLS="$content" ;;
|
|
ENTERPRISE_REPOS)
|
|
if [[ "$content" == "yes" ]]; then
|
|
DETECT_ENTERPRISE_ACTIVE="yes"
|
|
else
|
|
DETECT_ENTERPRISE_ACTIVE=""
|
|
fi
|
|
;;
|
|
NOSUB_REPO) DETECT_NOSUB_EXISTS="$content" ;;
|
|
NAG_PATCHED) DETECT_NAG_PATCHED="$content" ;;
|
|
UPGRADABLE) DETECT_UPGRADABLE_COUNT="${content//[^0-9]/}" ;;
|
|
VMBR1)
|
|
if [[ "$content" != "none" ]]; then
|
|
DETECT_VMBR1_IP=$(echo "$content" | awk '{print $3}')
|
|
fi
|
|
;;
|
|
IP_FORWARD) DETECT_IP_FORWARD="${content//[^01]/}" ;;
|
|
NAT_ACTIVE) DETECT_NAT_ACTIVE="$content" ;;
|
|
WG_INSTALLED) DETECT_WG_INSTALLED="$content" ;;
|
|
WG_ACTIVE)
|
|
if [[ "$content" != "none" ]] && [[ -n "$content" ]]; then
|
|
DETECT_WG_ACTIVE="yes"
|
|
fi
|
|
;;
|
|
WG_PEERS) DETECT_WG_PEERS="${content//[^0-9]/}" ;;
|
|
WG_HANDSHAKE)
|
|
if [[ "$content" != "none" ]] && [[ -n "$content" ]]; then
|
|
DETECT_WG_HANDSHAKE="$content"
|
|
fi
|
|
;;
|
|
NFT_FILE) DETECT_NFT_FILE="$content" ;;
|
|
NFT_RULES) DETECT_NFT_RULES="$content" ;;
|
|
DNSMASQ)
|
|
if [[ "$content" == "yes" ]]; then DETECT_DNSMASQ_ACTIVE="yes"; fi
|
|
;;
|
|
API_ROLE)
|
|
if [[ "$content" == "yes" ]]; then DETECT_API_ROLE="yes"; fi
|
|
;;
|
|
API_POOL)
|
|
if [[ "$content" == "yes" ]]; then DETECT_API_POOL="yes"; fi
|
|
;;
|
|
API_TOKEN)
|
|
if [[ "$content" == "yes" ]]; then DETECT_API_TOKEN="yes"; fi
|
|
;;
|
|
API_ACLS)
|
|
if [[ "$content" == "yes" ]]; then DETECT_API_ACLS="yes"; fi
|
|
;;
|
|
esac
|
|
}
|
|
|
|
_compute_states() {
|
|
# Repos
|
|
if [[ -z "$DETECT_ENTERPRISE_ACTIVE" ]] && [[ "$DETECT_NOSUB_EXISTS" == "yes" ]] && [[ "$DETECT_NAG_PATCHED" == "yes" ]]; then
|
|
STATE_REPOS="done"
|
|
elif [[ "$DETECT_NOSUB_EXISTS" == "yes" ]] || [[ "$DETECT_NAG_PATCHED" == "yes" ]]; then
|
|
STATE_REPOS="partial"
|
|
else
|
|
STATE_REPOS="needed"
|
|
fi
|
|
|
|
# Updates
|
|
if [[ "${DETECT_UPGRADABLE_COUNT:-0}" -eq 0 ]]; then
|
|
STATE_UPDATE="done"
|
|
elif [[ "${DETECT_UPGRADABLE_COUNT:-0}" -gt 0 ]]; then
|
|
STATE_UPDATE="needed"
|
|
else
|
|
STATE_UPDATE="unknown"
|
|
fi
|
|
|
|
# Bridge
|
|
if [[ -n "$DETECT_VMBR1_IP" ]] && [[ "$DETECT_IP_FORWARD" == "1" ]] && [[ "$DETECT_NAT_ACTIVE" == "yes" ]]; then
|
|
STATE_BRIDGE="done"
|
|
elif [[ -n "$DETECT_VMBR1_IP" ]]; then
|
|
STATE_BRIDGE="partial"
|
|
else
|
|
STATE_BRIDGE="needed"
|
|
fi
|
|
|
|
# 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" ]] && [[ "$DETECT_API_ACLS" == "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
|
|
|
|
# dnsmasq (DNS for VMs on vmbr1 -- required for IncusOS boot)
|
|
if [[ "$DETECT_DNSMASQ_ACTIVE" == "yes" ]]; then
|
|
STATE_DNSMASQ="done"
|
|
else
|
|
STATE_DNSMASQ="needed"
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Status dashboard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
show_status() {
|
|
echo ""
|
|
echo -e "${BOLD}Host${RESET} ${DETECT_HOSTNAME} (PVE ${DETECT_PVE_VERSION}, kernel ${DETECT_KERNEL})"
|
|
echo -e "${BOLD}Public IP${RESET} ${DETECT_PUBLIC_IP}"
|
|
echo ""
|
|
|
|
# Block devices
|
|
echo -e "${BOLD}Block devices:${RESET}"
|
|
echo "$DETECT_BLOCK_DEVICES" | while IFS= read -r line; do
|
|
echo " $line"
|
|
done
|
|
|
|
# ZFS pools
|
|
if [[ -n "$DETECT_ZPOOLS" ]]; then
|
|
echo ""
|
|
echo -e "${BOLD}ZFS pools:${RESET}"
|
|
echo "$DETECT_ZPOOLS" | while IFS= read -r line; do
|
|
echo " $line"
|
|
done
|
|
fi
|
|
|
|
# Bridges
|
|
echo ""
|
|
echo -e "${BOLD}Network:${RESET}"
|
|
if [[ -n "$DETECT_VMBR1_IP" ]]; then
|
|
echo -e " vmbr1 ${DETECT_VMBR1_IP} (forwarding: ${DETECT_IP_FORWARD}, NAT: ${DETECT_NAT_ACTIVE})"
|
|
else
|
|
echo -e " vmbr1 ${DIM}not configured${RESET}"
|
|
fi
|
|
if [[ "$DETECT_WG_ACTIVE" == "yes" ]]; then
|
|
echo -e " wg0 active (${DETECT_WG_PEERS:-0} peer(s))"
|
|
else
|
|
echo -e " wg0 ${DIM}not configured${RESET}"
|
|
fi
|
|
|
|
# Status dashboard
|
|
echo ""
|
|
echo -e "${BOLD}Configuration status:${RESET}"
|
|
echo ""
|
|
|
|
local effective_repos="$STATE_REPOS"
|
|
local effective_update="$STATE_UPDATE"
|
|
local effective_wg="$STATE_WIREGUARD"
|
|
local effective_fw="$STATE_FIREWALL"
|
|
|
|
if [[ "$SKIP_REPOS" == true ]]; then effective_repos="skipped"; effective_update="skipped"; fi
|
|
if [[ "$SKIP_WIREGUARD" == true ]]; then effective_wg="skipped"; fi
|
|
if [[ "$SKIP_FIREWALL" == true ]]; then effective_fw="skipped"; fi
|
|
|
|
printf " %-20s %b" "Repositories" "$(status_icon "$effective_repos")"
|
|
case "$effective_repos" in
|
|
done) echo -e " no-subscription active, nag removed" ;;
|
|
partial)
|
|
local detail_parts=""
|
|
[[ -z "$DETECT_ENTERPRISE_ACTIVE" ]] || detail_parts="enterprise still active"
|
|
[[ "$DETECT_NOSUB_EXISTS" == "yes" ]] || detail_parts="${detail_parts:+$detail_parts, }no-sub repo missing"
|
|
[[ "$DETECT_NAG_PATCHED" == "yes" ]] || detail_parts="${detail_parts:+$detail_parts, }nag popup present"
|
|
echo " $detail_parts"
|
|
;;
|
|
needed) echo " enterprise repos active" ;;
|
|
skipped) echo "" ;;
|
|
esac
|
|
|
|
printf " %-20s %b" "System update" "$(status_icon "$effective_update")"
|
|
case "$effective_update" in
|
|
done) echo " all packages up to date" ;;
|
|
needed) echo " ${DETECT_UPGRADABLE_COUNT} package(s) upgradable" ;;
|
|
unknown) echo " run apt update to check" ;;
|
|
skipped) echo "" ;;
|
|
esac
|
|
|
|
printf " %-20s %b" "Private bridge" "$(status_icon "$STATE_BRIDGE")"
|
|
case "$STATE_BRIDGE" in
|
|
done) echo " vmbr1 ${DETECT_VMBR1_IP}, NAT active" ;;
|
|
partial) echo " vmbr1 ${DETECT_VMBR1_IP} but forwarding/NAT incomplete" ;;
|
|
needed) echo " vmbr1 not yet created" ;;
|
|
esac
|
|
|
|
printf " %-20s %b" "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 written, apply manually (see firewall step)"
|
|
else
|
|
echo " config not yet written"
|
|
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
|
|
|
|
printf " %-20s %b" "DNS (dnsmasq)" "$(status_icon "$STATE_DNSMASQ")"
|
|
case "$STATE_DNSMASQ" in
|
|
done) echo " dnsmasq active, forwarding DNS on vmbr1" ;;
|
|
needed) echo " not installed (required for IncusOS boot)" ;;
|
|
esac
|
|
|
|
echo ""
|
|
|
|
# Count pending items
|
|
local pending=0
|
|
if [[ "$effective_repos" == "needed" || "$effective_repos" == "partial" ]]; then pending=$((pending + 1)); fi
|
|
if [[ "$effective_update" == "needed" ]]; then pending=$((pending + 1)); fi
|
|
if [[ "$STATE_BRIDGE" == "needed" || "$STATE_BRIDGE" == "partial" ]]; then pending=$((pending + 1)); fi
|
|
if [[ "$effective_wg" == "needed" ]]; then pending=$((pending + 1)); fi
|
|
if [[ "$effective_fw" == "needed" ]]; then pending=$((pending + 1)); fi
|
|
if [[ "$STATE_API" == "needed" || "$STATE_API" == "partial" ]]; then pending=$((pending + 1)); fi
|
|
if [[ "$STATE_DNSMASQ" == "needed" ]]; then pending=$((pending + 1)); fi
|
|
|
|
if [[ "$pending" -eq 0 ]]; then
|
|
success "All configured -- nothing to do"
|
|
return 1 # signal: no work needed
|
|
fi
|
|
|
|
info "${pending} item(s) need attention"
|
|
echo ""
|
|
return 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Apply: repos
|
|
# ---------------------------------------------------------------------------
|
|
|
|
apply_repos() {
|
|
if [[ "$SKIP_REPOS" == true ]] || [[ "$STATE_REPOS" == "done" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
step "Repository configuration"
|
|
|
|
# Show what needs fixing
|
|
local actions=""
|
|
if [[ -n "$DETECT_ENTERPRISE_ACTIVE" ]]; then
|
|
actions="disable enterprise repos"
|
|
fi
|
|
if [[ "$DETECT_NOSUB_EXISTS" != "yes" ]]; then
|
|
actions="${actions:+$actions, }add no-subscription repo"
|
|
fi
|
|
if [[ "$DETECT_NAG_PATCHED" != "yes" ]]; then
|
|
actions="${actions:+$actions, }remove subscription nag popup"
|
|
fi
|
|
|
|
info "Will: ${actions}"
|
|
|
|
if ! confirm "Apply repository changes?"; then
|
|
info "Skipped"
|
|
return 0
|
|
fi
|
|
|
|
if dry_run_guard "${actions}"; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ -n "$DETECT_ENTERPRISE_ACTIVE" ]]; then
|
|
# Handle both .list (PVE 8 / bookworm) and .sources (PVE 9 / trixie) formats
|
|
# .list format: comment out deb lines
|
|
remote "for f in /etc/apt/sources.list.d/*enterprise*.list; do [ -f \"\$f\" ] && sed -i 's/^deb/# deb/' \"\$f\"; done 2>/dev/null || true"
|
|
# .sources/deb822 format: set Enabled: false
|
|
remote "for f in /etc/apt/sources.list.d/*enterprise*.sources; do [ -f \"\$f\" ] && sed -i '/^Enabled:/d' \"\$f\" && echo 'Enabled: false' >> \"\$f\"; done 2>/dev/null || true"
|
|
success "Enterprise repos disabled"
|
|
fi
|
|
|
|
if [[ "$DETECT_NOSUB_EXISTS" != "yes" ]]; then
|
|
# Detect PVE version to choose format
|
|
local pve_major="${DETECT_PVE_VERSION%%.*}"
|
|
if [[ "${pve_major:-8}" -ge 9 ]]; then
|
|
# PVE 9+ uses deb822 .sources format
|
|
remote "cat > /etc/apt/sources.list.d/proxmox.sources <<'REPO'
|
|
Types: deb
|
|
URIs: http://download.proxmox.com/debian/pve
|
|
Suites: trixie
|
|
Components: pve-no-subscription
|
|
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
|
|
REPO"
|
|
else
|
|
# PVE 8 uses traditional .list format
|
|
remote "echo 'deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription' > /etc/apt/sources.list.d/pve-no-subscription.list"
|
|
fi
|
|
success "No-subscription repo added"
|
|
fi
|
|
|
|
if [[ "$DETECT_NAG_PATCHED" != "yes" ]]; then
|
|
remote "sed -Ezi.bak \"s/(Ext\\.Msg\\.show\\(\\{[^}]+title: gettext\\('No valid sub)/void\\(\\{ \\/\\/\\1/\" /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js 2>/dev/null || true"
|
|
remote "systemctl restart pveproxy 2>/dev/null || true"
|
|
success "Subscription nag removed"
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Apply: system update
|
|
# ---------------------------------------------------------------------------
|
|
|
|
apply_update() {
|
|
if [[ "$SKIP_REPOS" == true ]] || [[ "$STATE_UPDATE" == "done" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
step "System update"
|
|
|
|
if [[ "$STATE_UPDATE" == "unknown" ]]; then
|
|
info "Package status unknown (apt update not yet run)"
|
|
else
|
|
info "${DETECT_UPGRADABLE_COUNT} package(s) can be upgraded"
|
|
fi
|
|
|
|
if ! confirm "Run apt update && apt dist-upgrade?"; then
|
|
info "Skipped"
|
|
return 0
|
|
fi
|
|
|
|
if dry_run_guard "apt update && apt dist-upgrade -y"; then
|
|
return 0
|
|
fi
|
|
|
|
info "Updating package lists..."
|
|
remote "apt update -qq"
|
|
info "Upgrading packages (this may take a few minutes)..."
|
|
remote "DEBIAN_FRONTEND=noninteractive apt dist-upgrade -y -qq"
|
|
success "System updated"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Apply: private bridge
|
|
# ---------------------------------------------------------------------------
|
|
|
|
apply_bridge() {
|
|
if [[ "$STATE_BRIDGE" == "done" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
step "Private bridge (vmbr1)"
|
|
|
|
if [[ "$STATE_BRIDGE" == "partial" ]]; then
|
|
info "vmbr1 exists (${DETECT_VMBR1_IP}) but IP forwarding or NAT needs fixing"
|
|
|
|
if [[ "$DETECT_IP_FORWARD" != "1" ]]; then
|
|
if confirm "Enable IP forwarding?"; then
|
|
if ! dry_run_guard "echo 1 > /proc/sys/net/ipv4/ip_forward"; then
|
|
remote "echo 1 > /proc/sys/net/ipv4/ip_forward"
|
|
remote_write "/etc/sysctl.d/99-forward.conf" "net.ipv4.ip_forward = 1"
|
|
success "IP forwarding enabled"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [[ "$DETECT_NAT_ACTIVE" != "yes" ]]; then
|
|
if confirm "Add NAT masquerade rule for ${SUBNET}?"; then
|
|
if ! dry_run_guard "iptables -t nat -A POSTROUTING -s ${SUBNET} -o vmbr0 -j MASQUERADE"; then
|
|
remote "iptables -t nat -A POSTROUTING -s ${SUBNET} -o vmbr0 -j MASQUERADE"
|
|
success "NAT masquerade active"
|
|
warn "Add post-up/post-down rules to /etc/network/interfaces to persist across reboots"
|
|
fi
|
|
fi
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
# STATE_BRIDGE == "needed"
|
|
info "Will create vmbr1 with:"
|
|
info " Subnet: ${SUBNET}"
|
|
info " Gateway: ${SUBNET_GW}/${SUBNET_MASK}"
|
|
info " NAT: MASQUERADE to vmbr0"
|
|
|
|
local bridge_config
|
|
bridge_config="
|
|
auto vmbr1
|
|
iface vmbr1 inet static
|
|
address ${SUBNET_GW}/${SUBNET_MASK}
|
|
bridge-ports none
|
|
bridge-stp off
|
|
bridge-fd 0
|
|
post-up echo 1 > /proc/sys/net/ipv4/ip_forward
|
|
post-up iptables -t nat -A POSTROUTING -s ${SUBNET} -o vmbr0 -j MASQUERADE
|
|
post-down iptables -t nat -D POSTROUTING -s ${SUBNET} -o vmbr0 -j MASQUERADE"
|
|
|
|
if [[ "$VERBOSE" == true ]]; then
|
|
echo ""
|
|
echo "$bridge_config"
|
|
echo ""
|
|
fi
|
|
|
|
if ! confirm "Add vmbr1 to /etc/network/interfaces?"; then
|
|
info "Skipped"
|
|
return 0
|
|
fi
|
|
|
|
if dry_run_guard "Create vmbr1 (${SUBNET_GW}/${SUBNET_MASK})"; then
|
|
return 0
|
|
fi
|
|
|
|
remote "printf '%s\n' '' >> /etc/network/interfaces"
|
|
remote_write "/tmp/vmbr1-config.tmp" "$bridge_config"
|
|
remote "cat /tmp/vmbr1-config.tmp >> /etc/network/interfaces && rm -f /tmp/vmbr1-config.tmp"
|
|
|
|
remote_write "/etc/sysctl.d/99-forward.conf" "net.ipv4.ip_forward = 1"
|
|
remote "sysctl -p /etc/sysctl.d/99-forward.conf >/dev/null 2>&1"
|
|
|
|
remote "ifreload -a 2>/dev/null || ifup vmbr1"
|
|
|
|
success "vmbr1 created (${SUBNET_GW}/${SUBNET_MASK})"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Apply: dnsmasq (DNS for VMs on vmbr1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
apply_dnsmasq() {
|
|
if [[ "$STATE_DNSMASQ" == "done" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
step "DNS (dnsmasq on vmbr1)"
|
|
|
|
info "Will: install dnsmasq, configure it to forward DNS queries from VMs on vmbr1"
|
|
info " Interface: vmbr1 (${SUBNET_GW})"
|
|
info " Upstream: 1.1.1.1, 8.8.8.8"
|
|
info " Required for IncusOS boot (downloads updates from images.linuxcontainers.org)"
|
|
|
|
if ! confirm "Install and configure dnsmasq?"; then
|
|
info "Skipped"
|
|
return 0
|
|
fi
|
|
|
|
dry_run_guard
|
|
|
|
remote "apt-get install -y dnsmasq"
|
|
|
|
local dnsmasq_conf
|
|
dnsmasq_conf="# DNS for VMs on vmbr1 (${SUBNET})
|
|
# Generated by proxmox-setup -- do not edit manually
|
|
interface=vmbr1
|
|
bind-interfaces
|
|
no-resolv
|
|
server=1.1.1.1
|
|
server=8.8.8.8
|
|
domain-needed
|
|
bogus-priv"
|
|
|
|
remote_write "/etc/dnsmasq.d/vmbr1.conf" "$dnsmasq_conf"
|
|
remote "systemctl enable dnsmasq && systemctl restart dnsmasq"
|
|
remote "ss -ulnp 2>/dev/null | grep ':53 ' | grep -q dnsmasq && echo 'OK' || echo 'WARN: dnsmasq not listening on port 53'"
|
|
|
|
success "dnsmasq configured on vmbr1"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Apply: WireGuard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
apply_wireguard() {
|
|
if [[ "$SKIP_WIREGUARD" == true ]] || [[ "$STATE_WIREGUARD" == "done" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
step "WireGuard tunnel"
|
|
|
|
info "Will configure WireGuard:"
|
|
info " Server: ${WG_SERVER_IP}/${WG_SUBNET_MASK} on port ${WG_PORT}"
|
|
info " Client: ${WG_CLIENT_IP}/${WG_SUBNET_MASK}"
|
|
info " Routing: wg0 <-> vmbr1 forwarding"
|
|
|
|
if ! confirm "Set up WireGuard?"; then
|
|
info "Skipped"
|
|
return 0
|
|
fi
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
info "(dry-run) Client config will look like:"
|
|
echo ""
|
|
echo " [Interface]"
|
|
echo " PrivateKey = <generated on setup>"
|
|
echo " Address = ${WG_CLIENT_IP}/${WG_SUBNET_MASK}"
|
|
echo ""
|
|
echo " [Peer]"
|
|
echo " PublicKey = <server public key>"
|
|
echo " Endpoint = ${DETECT_PUBLIC_IP:-<server-public-ip>}:${WG_PORT}"
|
|
echo " AllowedIPs = 10.10.0.0/16"
|
|
echo " PersistentKeepalive = 25"
|
|
echo ""
|
|
fi
|
|
|
|
if dry_run_guard "Install and configure WireGuard (${WG_SERVER_IP}:${WG_PORT})"; then
|
|
return 0
|
|
fi
|
|
|
|
# Install if needed
|
|
if [[ "$DETECT_WG_INSTALLED" != "yes" ]]; then
|
|
info "Installing wireguard..."
|
|
remote "DEBIAN_FRONTEND=noninteractive apt install -y -qq wireguard >/dev/null 2>&1"
|
|
fi
|
|
|
|
# Generate server keys
|
|
info "Generating server keypair..."
|
|
remote "wg genkey | tee /etc/wireguard/server-private.key | wg pubkey > /etc/wireguard/server-public.key"
|
|
remote "chmod 600 /etc/wireguard/server-private.key"
|
|
|
|
local server_privkey
|
|
server_privkey=$(remote_output "cat /etc/wireguard/server-private.key")
|
|
local server_pubkey
|
|
server_pubkey=$(remote_output "cat /etc/wireguard/server-public.key")
|
|
|
|
# Generate client keys locally
|
|
info "Generating client keypair..."
|
|
local client_privkey client_pubkey
|
|
client_privkey=$(wg genkey)
|
|
client_pubkey=$(echo "$client_privkey" | wg pubkey)
|
|
|
|
# Write server config
|
|
local server_conf="[Interface]
|
|
PrivateKey = ${server_privkey}
|
|
Address = ${WG_SERVER_IP}/${WG_SUBNET_MASK}
|
|
ListenPort = ${WG_PORT}
|
|
|
|
# Allow forwarding between WireGuard clients and the private VM bridge
|
|
PostUp = iptables -A FORWARD -i wg0 -o vmbr1 -j ACCEPT; iptables -A FORWARD -i vmbr1 -o wg0 -j ACCEPT
|
|
PostDown = iptables -D FORWARD -i wg0 -o vmbr1 -j ACCEPT; iptables -D FORWARD -i vmbr1 -o wg0 -j ACCEPT
|
|
|
|
[Peer]
|
|
# Workstation
|
|
PublicKey = ${client_pubkey}
|
|
AllowedIPs = ${WG_CLIENT_IP}/32"
|
|
|
|
remote_write "/etc/wireguard/wg0.conf" "$server_conf"
|
|
remote "chmod 600 /etc/wireguard/wg0.conf"
|
|
|
|
remote "systemctl enable --now wg-quick@wg0"
|
|
|
|
success "WireGuard server configured"
|
|
|
|
echo ""
|
|
info "Client config -- save to /etc/wireguard/hetzner-lab.conf or import into WireGuard app:"
|
|
echo ""
|
|
echo " [Interface]"
|
|
echo " PrivateKey = ${client_privkey}"
|
|
echo " Address = ${WG_CLIENT_IP}/${WG_SUBNET_MASK}"
|
|
echo ""
|
|
echo " [Peer]"
|
|
echo " PublicKey = ${server_pubkey}"
|
|
echo " Endpoint = ${DETECT_PUBLIC_IP}:${WG_PORT}"
|
|
echo " AllowedIPs = 10.10.0.0/16"
|
|
echo " PersistentKeepalive = 25"
|
|
echo ""
|
|
warn "Save the client config now -- private key is not stored anywhere else"
|
|
echo ""
|
|
info "Verify WireGuard before applying firewall rules"
|
|
if confirm "Connect your WireGuard client now and verify the tunnel?"; then
|
|
info "Checking server-side WireGuard status..."
|
|
local wg_status
|
|
wg_status=$(remote_output "wg show wg0 2>/dev/null" || true)
|
|
if echo "$wg_status" | grep -q "latest handshake"; then
|
|
success "WireGuard is working -- handshake confirmed"
|
|
elif echo "$wg_status" | grep -q "peer:"; then
|
|
warn "Peer configured but no handshake yet -- client connected?"
|
|
info "Try: ping ${WG_SERVER_IP}"
|
|
else
|
|
warn "No peers visible -- check wg0 is up on server"
|
|
remote_output "systemctl status wg-quick@wg0 --no-pager -l" || true
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Apply: firewall
|
|
# ---------------------------------------------------------------------------
|
|
|
|
apply_firewall() {
|
|
if [[ "$SKIP_FIREWALL" == true ]] || [[ "$STATE_FIREWALL" == "done" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
step "Firewall lockdown (nftables)"
|
|
|
|
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;
|
|
}
|
|
}"
|
|
|
|
if [[ "$DETECT_NFT_FILE" != "yes" ]]; then
|
|
if dry_run_guard "Write /etc/nftables-hetzner.conf on ${SSH_HOST}"; then
|
|
info "Rules that would be written to /etc/nftables-hetzner.conf:"
|
|
echo "$nft_rules" | sed 's/^/ /'
|
|
echo ""
|
|
else
|
|
remote_write "/etc/nftables-hetzner.conf" "$nft_rules"
|
|
success "Config written to /etc/nftables-hetzner.conf"
|
|
fi
|
|
else
|
|
info "Config already at /etc/nftables-hetzner.conf (rules not yet loaded)"
|
|
fi
|
|
|
|
warn "Firewall rules are NOT applied automatically — misfire locks out SSH on Hetzner permanently"
|
|
info "Once WireGuard is verified, apply manually:"
|
|
echo ""
|
|
echo " ssh ${SSH_HOST} nft -f /etc/nftables-hetzner.conf"
|
|
echo " ssh ${SSH_HOST} systemctl enable nftables"
|
|
echo " ssh ${SSH_HOST} nft list ruleset # verify"
|
|
echo ""
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Apply: API token
|
|
# ---------------------------------------------------------------------------
|
|
|
|
apply_api_token() {
|
|
if [[ "$STATE_API" == "done" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
step "API token for incusos-proxmox"
|
|
|
|
# Show what's missing
|
|
local actions=""
|
|
[[ "$DETECT_API_ROLE" == "yes" ]] || actions="create IncusOSDeployer role"
|
|
[[ "$DETECT_API_POOL" == "yes" ]] || actions="${actions:+$actions, }create IncusLab pool"
|
|
[[ "$DETECT_API_TOKEN" == "yes" ]] || actions="${actions:+$actions, }create automation@pve!deploy token"
|
|
[[ "$DETECT_API_ACLS" == "yes" ]] || actions="${actions:+$actions, }add missing ACLs (/vms, /sdn, storage)"
|
|
|
|
info "Will: ${actions}"
|
|
|
|
if ! confirm "Create missing API resources?"; then
|
|
info "Skipped"
|
|
return 0
|
|
fi
|
|
|
|
if dry_run_guard "${actions}"; then
|
|
return 0
|
|
fi
|
|
|
|
# Create role if needed
|
|
if [[ "$DETECT_API_ROLE" != "yes" ]]; then
|
|
remote "pveum role add IncusOSDeployer -privs \
|
|
'VM.Allocate VM.Config.CDROM VM.Config.CPU VM.Config.Disk VM.Config.HWType \
|
|
VM.Config.Memory VM.Config.Network VM.Config.Options VM.PowerMgmt VM.Audit \
|
|
Datastore.AllocateSpace Datastore.AllocateTemplate Datastore.Audit \
|
|
Pool.Allocate Pool.Audit \
|
|
SDN.Use Sys.Modify'"
|
|
success "Created IncusOSDeployer role"
|
|
fi
|
|
|
|
# Create pool if needed
|
|
if [[ "$DETECT_API_POOL" != "yes" ]]; then
|
|
remote "pveum pool add IncusLab -comment 'IncusOS lab VMs'"
|
|
success "Created IncusLab pool"
|
|
fi
|
|
|
|
# Create token if needed
|
|
if [[ "$DETECT_API_TOKEN" != "yes" ]]; then
|
|
# Ensure user exists
|
|
remote "pveum user add automation@pve --comment 'IncusOS automation' 2>/dev/null || true"
|
|
|
|
local token_output
|
|
token_output=$(remote "pveum user token add automation@pve deploy --privsep 0 --output-format json")
|
|
|
|
local token_secret
|
|
token_secret=$(echo "$token_output" | python3 -c "import sys,json; print(json.load(sys.stdin)['value'])" 2>/dev/null || echo "$token_output")
|
|
|
|
success "API token created"
|
|
echo ""
|
|
warn "Token secret (save now -- shown only once):"
|
|
echo ""
|
|
echo " ${token_secret}"
|
|
echo ""
|
|
fi
|
|
|
|
# Assign ACLs (idempotent -- run whenever any ACL is missing).
|
|
# PVE 9.x requires /sdn for bridge-backed VMs even with SDN.Use in the role.
|
|
# /vms and /nodes/<node> are required for VM creation and start via API.
|
|
if [[ "$DETECT_API_ACLS" != "yes" ]]; then
|
|
remote "pveum acl modify /vms -user automation@pve -role IncusOSDeployer"
|
|
remote "pveum acl modify /nodes/${DETECT_HOSTNAME} -user automation@pve -role IncusOSDeployer"
|
|
remote "pveum acl modify /pool/IncusLab -user automation@pve -role IncusOSDeployer"
|
|
remote "pveum acl modify /sdn -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 "ACLs configured"
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Summary output
|
|
# ---------------------------------------------------------------------------
|
|
|
|
show_summary() {
|
|
step "Summary"
|
|
|
|
echo ""
|
|
echo -e "${BOLD}proxmox.yaml for envs/hetzner/:${RESET}"
|
|
echo ""
|
|
echo " host: ${SUBNET_GW}"
|
|
echo " method: api"
|
|
echo " api_token_id: automation@pve!deploy"
|
|
echo " node: ${DETECT_HOSTNAME:-pve}"
|
|
echo " storage: local-zfs"
|
|
echo " iso_storage: local"
|
|
echo " bridge: vmbr1"
|
|
echo " gateway: ${SUBNET_GW}"
|
|
echo " dns: ${SUBNET_GW}"
|
|
echo " pool: IncusLab"
|
|
echo " ssh_user: root"
|
|
|
|
echo ""
|
|
echo -e "${BOLD}env file additions:${RESET}"
|
|
echo ""
|
|
echo " # Hetzner"
|
|
echo " HETZNER_PROXMOX_TOKEN_SECRET=<token from step above>"
|
|
|
|
echo ""
|
|
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 envs/hetzner/proxmox.yaml.example envs/hetzner/proxmox.yaml"
|
|
echo " 3. Test with dry run:"
|
|
echo " export PROXMOX_TOKEN_SECRET=\"\$HETZNER_PROXMOX_TOKEN_SECRET\""
|
|
echo " bin/incusos-proxmox --dry-run --proxmox envs/hetzner/proxmox.yaml \\"
|
|
echo " envs/hetzner/lab-cluster.yaml"
|
|
echo ""
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
main() {
|
|
setup_colors
|
|
parse_args "$@"
|
|
parse_subnets
|
|
|
|
echo ""
|
|
echo -e "${BOLD}${SCRIPT_NAME}${RESET} v${VERSION} -- Proxmox post-install configuration"
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
echo -e "${YELLOW}(dry-run mode -- no changes will be made)${RESET}"
|
|
fi
|
|
echo ""
|
|
|
|
# Phase 1: detect everything
|
|
detect_all
|
|
|
|
# Phase 2: show status dashboard
|
|
if ! show_status; then
|
|
# All done, nothing to do
|
|
show_summary
|
|
exit 0
|
|
fi
|
|
|
|
# Phase 3: apply only what's needed
|
|
if ! confirm "Proceed with configuration?"; then
|
|
info "Exiting without changes"
|
|
exit 0
|
|
fi
|
|
|
|
echo ""
|
|
apply_repos
|
|
apply_update
|
|
apply_bridge
|
|
apply_dnsmasq
|
|
apply_wireguard
|
|
apply_firewall
|
|
apply_api_token
|
|
show_summary
|
|
|
|
success "Setup complete"
|
|
}
|
|
|
|
main "$@"
|