Skip to content

Instantly share code, notes, and snippets.

@sarkrui
Last active March 19, 2026 10:14
Show Gist options
  • Select an option

  • Save sarkrui/63a1ba3dfb41009d5eb6d7e2b96a5875 to your computer and use it in GitHub Desktop.

Select an option

Save sarkrui/63a1ba3dfb41009d5eb6d7e2b96a5875 to your computer and use it in GitHub Desktop.
PVE network config auto-bind auto-net
#!/usr/bin/env bash
set -euo pipefail
AUTONET_URL="https://gist.githubusercontent.com/sarkrui/63a1ba3dfb41009d5eb6d7e2b96a5875/raw/pve-autonet"
SERVICE_URL="https://gist.githubusercontent.com/sarkrui/63a1ba3dfb41009d5eb6d7e2b96a5875/raw/pve-autonet.service"
AUTONET_DST="/usr/local/sbin/pve-autonet"
SERVICE_DST="/etc/systemd/system/pve-autonet.service"
DEFAULTS_DST="/etc/default/pve-autonet"
INTERFACES_FILE="/etc/network/interfaces"
BACKUP_SUFFIX="$(date +%Y%m%d-%H%M%S)"
if [ "${EUID:-$(id -u)}" -ne 0 ]; then
echo "Run installer as root." >&2
exit 1
fi
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "Missing required command: $1" >&2
exit 1
}
}
fetch() {
local url="$1"
local dst="$2"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url" -o "$dst"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$dst" "$url"
else
echo "Need curl or wget to download files." >&2
exit 1
fi
}
echo "[1/7] Checking prerequisites..."
need_cmd systemctl
need_cmd install
need_cmd ifreload
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
echo "[2/7] Downloading pve-autonet files from gist..."
fetch "$AUTONET_URL" "$TMPDIR/pve-autonet"
fetch "$SERVICE_URL" "$TMPDIR/pve-autonet.service"
echo "[3/7] Installing script and service..."
install -m 0755 "$TMPDIR/pve-autonet" "$AUTONET_DST"
install -m 0644 "$TMPDIR/pve-autonet.service" "$SERVICE_DST"
echo "[4/7] Installing defaults file (if absent)..."
if [ ! -f "$DEFAULTS_DST" ]; then
cat > "$DEFAULTS_DST" <<'EOF'
# Optional fixed NIC (for example: enp3s0). Leave empty for auto-selection.
PVE_AUTONET_NIC=""
# Bridge name created/managed by pve-autonet.
PVE_AUTONET_BRIDGE_NAME="vmbr0"
# Allowed values: dhcp or static
PVE_AUTONET_BRIDGE_METHOD="dhcp"
# Used only when BRIDGE_METHOD=static
PVE_AUTONET_BRIDGE_ADDRESS=""
PVE_AUTONET_BRIDGE_GATEWAY=""
# Seconds to wait for bridge IPv4 before skipping /etc/hosts update.
PVE_AUTONET_WAIT_SECONDS="20"
# Bark notifications for important auto-net stages and online status.
PVE_AUTONET_BARK_ENABLED="1"
PVE_AUTONET_BARK_URL="https://bark.ws/WNA2EuVCumFkyG7BJuua8L"
PVE_AUTONET_BARK_TITLE="PVE9 Info"
PVE_AUTONET_BARK_CURL_TIMEOUT="5"
EOF
chmod 0644 "$DEFAULTS_DST"
fi
echo "[5/7] Backing up existing network config..."
if [ -f "$INTERFACES_FILE" ]; then
cp -a "$INTERFACES_FILE" "${INTERFACES_FILE}.bak.${BACKUP_SUFFIX}"
fi
echo "[6/7] Reloading systemd, enabling and starting service..."
systemctl daemon-reload
systemctl enable pve-autonet.service
systemctl restart pve-autonet.service
echo "[7/7] Done."
echo
echo "Installed:"
echo " - $AUTONET_DST"
echo " - $SERVICE_DST"
echo " - $DEFAULTS_DST"
echo
echo "Backup:"
echo " - ${INTERFACES_FILE}.bak.${BACKUP_SUFFIX}"
echo
echo "The script will auto-generate /etc/network/interfaces with the"
echo "detected NIC and bridge on the next run (or reboot). Once PVE"
echo "has a bridge, the script skips generation and only refreshes"
echo "/etc/hosts and sends the online notification."
echo
echo "Next steps:"
echo " 1) Review $DEFAULTS_DST if you need static IP or pinned NIC."
echo " 2) Reboot once to validate early-boot behavior on this host."
#!/usr/bin/env bash
set -uo pipefail
readonly INTERFACES_FILE="/etc/network/interfaces"
readonly STATE_DIR="/var/lib/pve-autonet"
readonly STATE_UPLINK_FILE="${STATE_DIR}/last-uplink"
readonly LOCK_FILE="/run/lock/pve-autonet.lock"
readonly DEFAULTS_FILE="/etc/default/pve-autonet"
PVE_AUTONET_NIC="${PVE_AUTONET_NIC:-}"
PVE_AUTONET_BRIDGE_NAME="${PVE_AUTONET_BRIDGE_NAME:-vmbr0}"
PVE_AUTONET_BRIDGE_METHOD="${PVE_AUTONET_BRIDGE_METHOD:-dhcp}"
PVE_AUTONET_BRIDGE_ADDRESS="${PVE_AUTONET_BRIDGE_ADDRESS:-}"
PVE_AUTONET_BRIDGE_GATEWAY="${PVE_AUTONET_BRIDGE_GATEWAY:-}"
PVE_AUTONET_WAIT_SECONDS="${PVE_AUTONET_WAIT_SECONDS:-20}"
PVE_AUTONET_BARK_ENABLED="${PVE_AUTONET_BARK_ENABLED:-1}"
PVE_AUTONET_BARK_URL="${PVE_AUTONET_BARK_URL:-https://bark.ws/WNA2EuVCumFkyG7BJuua8L}"
PVE_AUTONET_BARK_TITLE="${PVE_AUTONET_BARK_TITLE:-PVE9 Info}"
PVE_AUTONET_BARK_CURL_TIMEOUT="${PVE_AUTONET_BARK_CURL_TIMEOUT:-5}"
PVE_AUTONET_FAILURE_NOTIFIED=0
# ── Colors (disabled when stderr is not a terminal or via journal) ─
if [ -t 2 ]; then
C_RESET='\033[0m'
C_BOLD='\033[1m'
C_DIM='\033[2m'
C_GREEN='\033[1;32m'
C_YELLOW='\033[1;33m'
C_RED='\033[1;31m'
C_CYAN='\033[1;36m'
else
C_RESET='' C_BOLD='' C_DIM='' C_GREEN='' C_YELLOW='' C_RED='' C_CYAN=''
fi
# ── Logging ──────────────────────────────────────────────────────
log() { echo -e "${C_DIM}[pve-autonet]${C_RESET} $*" >&2; }
log_ok() { echo -e "${C_GREEN}[pve-autonet] ✓${C_RESET} ${C_BOLD}$*${C_RESET}" >&2; }
log_key() { echo -e "${C_CYAN}[pve-autonet]${C_RESET} ${C_BOLD}$*${C_RESET}" >&2; }
warn() { echo -e "${C_YELLOW}[pve-autonet] WARN:${C_RESET} $*" >&2; }
fail() {
notify_failure "$*"
echo -e "${C_RED}[pve-autonet] ERROR:${C_RESET} ${C_BOLD}$*${C_RESET}" >&2
exit 1
}
host_short() {
hostname -s 2>/dev/null || hostname 2>/dev/null || echo "pve-host"
}
# ── Bark notifications ───────────────────────────────────────────
urlencode() {
local input="$1" output="" char i
local LC_ALL=C
for ((i = 0; i < ${#input}; i++)); do
char="${input:i:1}"
case "$char" in
[a-zA-Z0-9.~_-]) output+="$char" ;;
' ') output+="%20" ;;
*) printf -v output '%s%%%02X' "$output" "'$char" ;;
esac
done
printf '%s' "$output"
}
send_bark() {
local message="$1"
[ "$PVE_AUTONET_BARK_ENABLED" = "1" ] || return 0
[ -n "$PVE_AUTONET_BARK_URL" ] || return 0
command -v curl >/dev/null 2>&1 || return 0
curl -fsS --max-time "$PVE_AUTONET_BARK_CURL_TIMEOUT" \
"${PVE_AUTONET_BARK_URL%/}/$(urlencode "$PVE_AUTONET_BARK_TITLE")/$(urlencode "$message")" \
>/dev/null 2>&1 || true
}
notify_stage() {
log_key "$1"
send_bark "$1"
}
notify_ok() {
log_ok "$1"
send_bark "$1"
}
notify_failure() {
local message="$1"
[ "$PVE_AUTONET_FAILURE_NOTIFIED" = "1" ] && return 0
PVE_AUTONET_FAILURE_NOTIFIED=1
send_bark "FAILED on $(host_short): ${message}"
}
notify_online() {
local ip="$1"
log_ok "Host IP Address is: https://${ip}:8006"
send_bark "Host IP Address is: https://${ip}:8006"
}
# ── Hardware helpers ─────────────────────────────────────────────
read_int_file() {
local path="$1" value
[ -r "$path" ] || { echo "0"; return; }
value="$(<"$path")"
case "$value" in ''|*[!0-9]*) echo "0" ;; *) echo "$value" ;; esac
}
is_physical_nic() {
local dev="$1"
[ -d "/sys/class/net/${dev}/device" ] || return 1
case "$dev" in
lo|vmbr*|bond*|tap*|fw*|veth*|dummy*|ifb*|sit*|gre*|gretap*|erspan*|ip6*|tun*|wg*) return 1 ;;
esac
return 0
}
phys_nics() {
local p d
for p in /sys/class/net/*; do
d="${p##*/}"
is_physical_nic "$d" || continue
echo "$d"
done
}
route_uplink_nic() {
local dev
dev="$(ip -4 route show default 2>/dev/null | awk 'NR==1 {for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1);exit}}')"
[ -n "$dev" ] && is_physical_nic "$dev" && echo "$dev"
}
pick_best_physical_nic() {
local best="" best_score=-1 best_carrier=0 best_speed=0
local dev carrier speed score
while IFS= read -r dev; do
[ -n "$dev" ] || continue
carrier="$(read_int_file "/sys/class/net/${dev}/carrier")"
speed="$(read_int_file "/sys/class/net/${dev}/speed")"
score=$((carrier * 100000 + speed))
if [ "$score" -gt "$best_score" ] || { [ "$score" -eq "$best_score" ] && [ "$dev" \< "$best" ]; }; then
best="$dev"; best_score="$score"; best_carrier="$carrier"; best_speed="$speed"
fi
done < <(phys_nics)
[ -n "$best" ] || return 1
log_key "Selected NIC ${best} (carrier=${best_carrier}, speed=${best_speed}Mb/s)"
echo "$best"
}
select_uplink_nic() {
if [ -n "$PVE_AUTONET_NIC" ]; then
is_physical_nic "$PVE_AUTONET_NIC" || fail "Configured PVE_AUTONET_NIC=${PVE_AUTONET_NIC} is not a physical NIC"
echo "$PVE_AUTONET_NIC"; return 0
fi
local route_dev
route_dev="$(route_uplink_nic || true)"
if [ -n "$route_dev" ]; then
log_key "Using default-route NIC ${route_dev}"
echo "$route_dev"; return 0
fi
pick_best_physical_nic
}
# ── Network diagnostics ─────────────────────────────────────────
bridge_exists_in_config() {
grep -qE "^iface[[:space:]]+${PVE_AUTONET_BRIDGE_NAME}[[:space:]]" "$INTERFACES_FILE" 2>/dev/null
}
bridge_port_from_config() {
awk "/^iface[[:space:]]+${PVE_AUTONET_BRIDGE_NAME}[[:space:]]/,/^(auto|iface|source|$)/" \
"$INTERFACES_FILE" 2>/dev/null \
| awk '/bridge-ports/ {print $2; exit}'
}
bridge_is_up() {
[ -d "/sys/class/net/${PVE_AUTONET_BRIDGE_NAME}" ]
}
bridge_has_ip() {
ip -4 -o addr show dev "${PVE_AUTONET_BRIDGE_NAME}" scope global 2>/dev/null | grep -q .
}
bridge_port_nic_exists() {
local port="$1"
[ -n "$port" ] && [ -d "/sys/class/net/${port}" ]
}
gateway_reachable() {
local gw
gw="$(ip -4 route show default dev "${PVE_AUTONET_BRIDGE_NAME}" 2>/dev/null \
| awk 'NR==1 {for(i=1;i<=NF;i++) if($i=="via"){print $(i+1);exit}}')"
[ -n "$gw" ] && ping -c1 -W2 -I "${PVE_AUTONET_BRIDGE_NAME}" "$gw" >/dev/null 2>&1
}
get_bridge_ip() {
ip -4 -o addr show dev "${PVE_AUTONET_BRIDGE_NAME}" scope global 2>/dev/null \
| awk 'NR==1 {print $4}' | cut -d/ -f1 || true
}
diagnose_network() {
local port reason=""
if ! bridge_exists_in_config; then
reason="no bridge in config"
else
port="$(bridge_port_from_config)"
if [ -z "$port" ]; then
reason="bridge has no port defined"
elif ! bridge_port_nic_exists "$port"; then
reason="bridge port ${port} does not exist (hardware changed?)"
elif ! bridge_is_up; then
reason="bridge device not up"
fi
fi
if [ -n "$reason" ]; then
echo "$reason"
return 1
fi
return 0
}
wait_for_ip() {
local wait="$1" i ip=""
for ((i = 0; i < wait; i++)); do
ip="$(get_bridge_ip)"
[ -n "$ip" ] && { echo "$ip"; return 0; }
sleep 1
done
return 1
}
# ── Config generation ────────────────────────────────────────────
render_interfaces() {
local uplink="$1"
local bridge="${PVE_AUTONET_BRIDGE_NAME}"
local method="${PVE_AUTONET_BRIDGE_METHOD}"
local all_nics nic
all_nics="$(phys_nics)"
cat <<EOF
# network interface settings; autogenerated
# Please do NOT modify this file directly, unless you know what
# you're doing.
#
# If you want to manage parts of the network configuration manually,
# please utilize the 'source' or 'source-directory' directives to do
# so.
# PVE will preserve these directives, but will NOT read its network
# configuration from sourced files, so do not attempt to move any of
# the PVE managed interfaces into external files!
auto lo
iface lo inet loopback
EOF
while IFS= read -r nic; do
[ -n "$nic" ] || continue
echo "iface ${nic} inet manual"
echo ""
done <<< "$all_nics"
cat <<EOF
auto ${bridge}
iface ${bridge} inet ${method}
bridge-ports ${uplink}
bridge-stp off
bridge-fd 0
bridge-vlan-aware yes
bridge-vids 2-4094
EOF
if [ "$method" = "static" ]; then
[ -n "$PVE_AUTONET_BRIDGE_ADDRESS" ] || fail "PVE_AUTONET_BRIDGE_ADDRESS required for static"
echo " address ${PVE_AUTONET_BRIDGE_ADDRESS}"
[ -n "$PVE_AUTONET_BRIDGE_GATEWAY" ] && echo " gateway ${PVE_AUTONET_BRIDGE_GATEWAY}"
fi
echo ""
echo "source /etc/network/interfaces.d/*"
}
apply_interfaces() {
command -v ifreload >/dev/null 2>&1 || fail "ifreload is required (install ifupdown2)"
ifreload -a
}
purge_stale_files() {
rm -f /run/network/interfaces.d/10-uplink* \
/run/network/interfaces.d/pve-autonet* \
/etc/network/interfaces.d/10-uplink* \
/etc/network/interfaces.d/pve-autonet 2>/dev/null || true
}
# ── /etc/hosts + online notification ─────────────────────────────
update_hosts_and_notify_online() {
local ip fqdn short hosts_tmp
ip="$(wait_for_ip "$PVE_AUTONET_WAIT_SECONDS" || true)"
if [ -z "$ip" ]; then
warn "No IPv4 on ${PVE_AUTONET_BRIDGE_NAME} after ${PVE_AUTONET_WAIT_SECONDS}s"
send_bark "No IP on ${PVE_AUTONET_BRIDGE_NAME} after ${PVE_AUTONET_WAIT_SECONDS}s"
return 0
fi
fqdn="$(hostname -f 2>/dev/null || hostname || true)"
short="$(hostname -s 2>/dev/null || hostname || true)"
if [ -n "$fqdn" ] && [ -n "$short" ]; then
hosts_tmp="$(mktemp /run/pve-autonet.hosts.XXXXXX)"
awk -v short="$short" -v fqdn="$fqdn" '
{
drop=0
if ($1 ~ /^[0-9]/) {
for (i=2; i<=NF; i++) {
if ($i == short || $i == fqdn) { drop=1; break }
}
}
if (!drop) print $0
}
' /etc/hosts > "$hosts_tmp" || true
{ cat "$hosts_tmp"; echo "${ip} ${fqdn} ${short}"; } > /etc/hosts
rm -f "$hosts_tmp"
fi
[ -x /usr/bin/pvebanner ] && { /usr/bin/pvebanner || true; }
notify_online "$ip"
}
# ── Generate or regenerate bridge config ─────────────────────────
generate_bridge_config() {
local uplink="$1"
local cfg_tmp cfg_backup
cfg_tmp="$(mktemp /tmp/pve-autonet.cfg.XXXXXX)"
cfg_backup="$(mktemp /tmp/pve-autonet.bak.XXXXXX)"
trap 'rm -f "${cfg_tmp:-}" "${cfg_backup:-}"' EXIT
purge_stale_files
render_interfaces "$uplink" > "$cfg_tmp"
if ! grep -q "bridge-ports ${uplink}" "$cfg_tmp"; then
fail "Generated config failed validation (missing bridge-ports ${uplink})"
fi
if cmp -s "$cfg_tmp" "$INTERFACES_FILE"; then
log "Config unchanged; no reload needed"
return 0
fi
cp -a "$INTERFACES_FILE" "$cfg_backup"
cp "$cfg_tmp" "$INTERFACES_FILE"
if ! apply_interfaces; then
warn "ifreload failed; rolling back"
cp -a "$cfg_backup" "$INTERFACES_FILE"
apply_interfaces || true
fail "Failed to apply generated network config"
fi
return 0
}
# ── Main ─────────────────────────────────────────────────────────
main() {
local uplink previous_uplink diag_reason ip
if [ -r "$DEFAULTS_FILE" ]; then
# shellcheck disable=SC1090
. "$DEFAULTS_FILE"
fi
: "${PVE_AUTONET_NIC:=}"
: "${PVE_AUTONET_BRIDGE_NAME:=vmbr0}"
: "${PVE_AUTONET_BRIDGE_METHOD:=dhcp}"
: "${PVE_AUTONET_BRIDGE_ADDRESS:=}"
: "${PVE_AUTONET_BRIDGE_GATEWAY:=}"
: "${PVE_AUTONET_WAIT_SECONDS:=20}"
: "${PVE_AUTONET_BARK_ENABLED:=1}"
: "${PVE_AUTONET_BARK_URL:=https://bark.ws/WNA2EuVCumFkyG7BJuua8L}"
: "${PVE_AUTONET_BARK_TITLE:=PVE9 Info}"
: "${PVE_AUTONET_BARK_CURL_TIMEOUT:=5}"
case "$PVE_AUTONET_BRIDGE_METHOD" in
dhcp|static) ;;
*) fail "PVE_AUTONET_BRIDGE_METHOD must be 'dhcp' or 'static'" ;;
esac
mkdir -p "$STATE_DIR" "$(dirname "$LOCK_FILE")"
if command -v flock >/dev/null 2>&1; then
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "Another instance running — skipping"
exit 0
fi
fi
notify_stage "Auto-net started on $(host_short)"
log ""
# ── Phase 1: Diagnose existing config ────────────────────────
diag_reason="$(diagnose_network 2>/dev/null || true)"
if [ -z "$diag_reason" ]; then
log "Bridge config present; checking connectivity..."
ip="$(wait_for_ip "$PVE_AUTONET_WAIT_SECONDS" || true)"
if [ -n "$ip" ]; then
if gateway_reachable; then
log_ok "Network healthy (${ip}, gateway reachable)"
echo "$(bridge_port_from_config)" > "$STATE_UPLINK_FILE" 2>/dev/null || true
update_hosts_and_notify_online
return 0
else
diag_reason="bridge has IP ${ip} but gateway unreachable"
fi
else
diag_reason="bridge has no IP after ${PVE_AUTONET_WAIT_SECONDS}s"
fi
fi
# ── Phase 2: Self-heal ───────────────────────────────────────
notify_stage "Network issue detected: ${diag_reason}"
notify_stage "Attempting auto-repair..."
uplink="$(select_uplink_nic || true)"
[ -n "$uplink" ] || fail "No usable physical NIC found"
previous_uplink=""
if [ -r "$STATE_UPLINK_FILE" ]; then
previous_uplink="$(<"$STATE_UPLINK_FILE")"
previous_uplink="$(echo "$previous_uplink" | head -n1 | tr -cd 'a-zA-Z0-9._-')"
fi
if [ -n "$previous_uplink" ] && [ "$previous_uplink" != "$uplink" ]; then
notify_stage "Uplink changed: ${previous_uplink} -> ${uplink}"
else
notify_stage "Using uplink ${uplink} for ${PVE_AUTONET_BRIDGE_NAME}"
fi
generate_bridge_config "$uplink"
echo "$uplink" > "$STATE_UPLINK_FILE"
notify_stage "Applied ${PVE_AUTONET_BRIDGE_NAME} on ${uplink}"
# ── Phase 3: Verify the fix worked ───────────────────────────
ip="$(wait_for_ip "$PVE_AUTONET_WAIT_SECONDS" || true)"
if [ -n "$ip" ]; then
if gateway_reachable; then
notify_ok "Auto-repair successful (${ip}, gateway reachable)"
else
notify_stage "Bridge has IP ${ip} but gateway still unreachable"
fi
update_hosts_and_notify_online
else
notify_stage "Bridge still has no IP after repair — manual intervention needed"
fi
}
main "$@"
[Unit]
Description=Generate resilient Proxmox bridge config from active NIC
DefaultDependencies=no
After=local-fs.target
Wants=network-pre.target
Before=network-pre.target
Before=networking.service
Before=pve-guests.service
ConditionPathExists=/usr/local/sbin/pve-autonet
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/pve-autonet
TimeoutStartSec=90
[Install]
WantedBy=multi-user.target
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment