Skip to content

Instantly share code, notes, and snippets.

@acalatrava
Created February 20, 2026 13:01
Show Gist options
  • Select an option

  • Save acalatrava/ba67441288575db40897ee24b2272813 to your computer and use it in GitHub Desktop.

Select an option

Save acalatrava/ba67441288575db40897ee24b2272813 to your computer and use it in GitHub Desktop.
Proxmox L2 network over Wireguard between nodes

Proxmox WireGuard + VXLAN (vmbr3) Manager

A menu-driven automation script to deploy and manage:

  • WireGuard (wg0) as L3 underlay
  • VXLAN (VNI 200) over WireGuard
  • Layer 2 extension attached to vmbr3
  • Multi-node support (2+ nodes)
  • Idempotent, order-aware execution
  • Inventory-driven FDB management

Designed specifically for Proxmox environments using:

  • vmbr3 as internal bridge (e.g. 172.24.200.0/24)
  • Public IPs on vmbr0
  • Static WireGuard topology
  • Manual FDB flood control (nolearning mode)

Architecture Overview

Underlay (Layer 3)

WireGuard wg0

Example:

10.10.10.1/24  (Node 1)
10.10.10.2/24  (Node 2)
10.10.10.3/24  (Node 3)

This is only used for node-to-node encrypted transport.


Overlay (Layer 2)

VXLAN vxlan200 over wg0

  • VNI: 200
  • UDP port: 4789
  • Attached to: vmbr3
  • MTU: recommended 1370

This extends:

172.24.200.0/24

across all nodes at Layer 2.

VMs/CTs in vmbr3 behave as if on the same switch.


Files Used

WireGuard

/etc/wireguard/wg0.conf

VXLAN

/etc/network/interfaces.d/vxlan

Inventory

/etc/krip-vxlan/nodes.conf

Inventory format:

name,wg_ip,public_ip,public_port,public_key

bare1,10.10.10.1,51.178.92.146,51820,
bare2,10.10.10.2,147.135.213.111,51820,

⚠ Each node must list only other nodes, never itself.


What the Script Does

The script:

  • Installs required packages
  • Generates WireGuard keys
  • Creates wg0.conf
  • Adds peers safely (no duplication)
  • Manages an inventory of nodes
  • Generates VXLAN configuration
  • Rebuilds FDB flood entries automatically
  • Brings interfaces up/down safely
  • Validates order of execution
  • Produces a full status report

It does NOT:

  • Modify vmbr0
  • Touch default routes
  • Change public networking
  • Flush bridge FDB globally
  • Modify Proxmox firewall rules

Menu Options Explained

1) Install prerequisites

Installs:

  • wireguard
  • iproute2
  • bridge-utils
  • ifupdown2
  • lxc-utils
  • tcpdump

Safe to run multiple times.


2) Generate WireGuard keys

Creates:

/etc/wireguard/privatekey
/etc/wireguard/publickey

Will not overwrite existing keys.


3) Create wg0.conf

Creates minimal configuration:

[Interface]
Address = 10.10.10.X/24
ListenPort = 51820
PrivateKey = …
MTU = 1420

Idempotent.


4) Add peer to wg0.conf + inventory

Adds a peer:

[Peer]
PublicKey = …
Endpoint = X.X.X.X:51820
AllowedIPs = 10.10.10.Y/32
PersistentKeepalive = 25

Also updates nodes.conf.

Duplicate peers are detected and not re-added.


5) Bring UP WireGuard

Enables and starts:

wg-quick@wg0

Validates tunnel state.


6) Write VXLAN config

Creates:

/etc/network/interfaces.d/vxlan

Example:

auto vxlan200
iface vxlan200 inet manual
mtu 1370
pre-up ip link add vxlan200 type vxlan id 200 dev wg0 local 10.10.10.1 dstport 4789 nolearning
up ip link set vxlan200 master vmbr3
up ip link set vxlan200 up
down ip link del vxlan200

Does NOT bring it up automatically.


7) Update VXLAN FDB entries

Rebuilds wildcard flood entries:

bridge fdb add 00:00:00:00:00:00 dev vxlan200 dst <peer_wg_ip> self permanent vlan 1

Uses inventory as source of truth.

This enables controlled flooding across peers.

Safe to re-run anytime.


8) Bring UP VXLAN

Runs:

bridge fdb add 00:00:00:00:00:00 dev vxlan200 dst <peer_wg_ip> self permanent vlan 1

Uses inventory as source of truth.

This enables controlled flooding across peers.

Safe to re-run anytime.


8) Bring UP VXLAN

Runs:

ifup vxlan200

Only if:

  • wg0 is UP
  • config file exists

9) Set vmbr3 MTU

Sets runtime MTU:

ip link set vmbr3 mtu 1370

You must persist manually in /etc/network/interfaces.

All nodes must use identical MTU.


10) Status report

Displays:

  • Bridge state
  • WireGuard status
  • VXLAN state
  • MTU values
  • Inventory entries
  • Peer connections

Detects incorrect order of setup.


11) Bring DOWN VXLAN

Removes vxlan200 cleanly.


12) Bring DOWN WireGuard

Stops wg0 safely.


Recommended Deployment Order

On each node:

  1. Install prerequisites
  2. Generate keys
  3. Create wg0.conf
  4. Add peers
  5. Bring UP WireGuard
  6. Write VXLAN config
  7. Update FDB entries
  8. Set vmbr3 MTU to 1370
  9. Bring UP VXLAN

Multi-Node Behavior

For N nodes:

Each node must:

  • Have WireGuard peers defined for all other nodes
  • Have inventory entries for all other nodes
  • Rebuild FDB entries after inventory changes

Inventory example for 3 nodes:

Node 1:

bare2,…
bare3,…

Node 2:

bare1,…
bare3,…

Node 3:

bare2,…
bare1,…

Never include self.


MTU Considerations

Typical values:

  • wg0: 1420
  • vxlan200: 1370
  • vmbr3: 1370
  • VMs/CTs in vmbr3: 1370

MTU mismatch will cause silent packet drops.


Troubleshooting

VXLAN up but no L2 traffic

Check:

bridge fdb show br vmbr3
ip -d link show vxlan200
wg show

Verify wildcard FDB entries exist.


Container cannot reach host

Check:

ip route
ip route get 

Ensure no duplicate routes via linkdown bridges.


Do NOT

  • Run bridge fdb flush dev vmbr3
  • Enable bridge learning while using manual FDB
  • Mix MTU values
  • Add self to inventory

Production Notes

  • Stable for small clusters (2–10 nodes)
  • Deterministic behavior
  • No dynamic control plane
  • No dependency on external controllers
  • Works entirely with standard Linux networking

Security Notes

  • WireGuard provides encrypted transport
  • VXLAN runs only inside WireGuard
  • No public VXLAN exposure
  • No L2 broadcast leakage to Internet

Optional Enhancements

Possible future additions:

  • Bootstrap auto-discovery
  • Inventory sync from central node
  • Automatic MTU detection
  • Peer auto-registration
  • Config validation mode
  • Non-interactive CLI mode
  • JSON/YAML config support

Summary

This script provides:

  • Deterministic, controlled VXLAN over WireGuard
  • Multi-node L2 extension
  • Idempotent configuration
  • Order-safe operations
  • Clean separation of underlay and overlay
  • Proxmox-friendly behavior

Designed for operators who prefer:

  • Full control
  • No hidden automation
  • Transparent networking
  • Minimal magic
#!/usr/bin/env bash
set -euo pipefail
# Proxmox WireGuard + VXLAN manager (multi-node)
# - Underlay: WireGuard wg0
# - Overlay: VXLAN VNI 200 over wg0, attached to vmbr3 (172.24.200.0/24)
# - VXLAN config file: /etc/network/interfaces.d/vxlan
# - Inventory: /etc/krip-vxlan/nodes.conf (simple CSV)
#
# Safe: does NOT touch vmbr0 or public routing.
WG_IF="wg0"
WG_DIR="/etc/wireguard"
WG_CONF="${WG_DIR}/${WG_IF}.conf"
WG_PORT_DEFAULT="51820"
VXLAN_IF="vxlan200"
VXLAN_FILE="/etc/network/interfaces.d/vxlan"
VXLAN_VNI_DEFAULT="200"
VXLAN_DSTPORT_DEFAULT="4789"
BRIDGE="vmbr3"
STATE_DIR="/etc/krip-vxlan"
NODES_FILE="${STATE_DIR}/nodes.conf"
# ---------- helpers ----------
err(){ echo "$*" >&2; }
ok(){ echo "$*"; }
warn(){ echo "⚠️ $*"; }
info(){ echo "ℹ️ $*"; }
require_root() {
[[ $EUID -eq 0 ]] || { err "Run as root"; exit 1; }
}
have_cmd(){ command -v "$1" >/dev/null 2>&1; }
pause(){ read -r -p "Press Enter to continue... " _; }
ensure_dirs() {
mkdir -p "$WG_DIR" "$STATE_DIR"
chmod 700 "$STATE_DIR"
}
detect_primary_pubip() {
# best effort: show current default route dev and its src (may be via vmbr0)
local src
src="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '/src/ {for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -n1 || true)"
echo "$src"
}
bridge_exists() {
ip link show "$BRIDGE" >/dev/null 2>&1
}
wg_installed() {
have_cmd wg && have_cmd wg-quick
}
vxlan_supported() {
ip link add "${VXLAN_IF}_test" type vxlan id 999 dev lo dstport 4789 >/dev/null 2>&1 && \
ip link del "${VXLAN_IF}_test" >/dev/null 2>&1
}
wg_is_up() {
ip link show "$WG_IF" >/dev/null 2>&1 && ip link show "$WG_IF" | grep -q "UP"
}
vxlan_is_up() {
ip link show "$VXLAN_IF" >/dev/null 2>&1 && ip link show "$VXLAN_IF" | grep -q "UP"
}
get_link_mtu() {
ip -o link show "$1" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="mtu") print $(i+1)}' | head -n1
}
file_has_line() {
local f="$1"; shift
local pat="$1"
[[ -f "$f" ]] && grep -qE "$pat" "$f"
}
kv_get() {
# read key=value from file (first match)
local key="$1" file="$2"
awk -F= -v k="$key" '$1==k {print $2; exit}' "$file" 2>/dev/null || true
}
# ---------- inventory ----------
# nodes.conf format (CSV):
# name,wg_ip,public_ip,public_port,public_key
ensure_nodes_file() {
[[ -f "$NODES_FILE" ]] || {
cat >"$NODES_FILE" <<'EOF'
# name,wg_ip,public_ip,public_port,public_key
EOF
chmod 600 "$NODES_FILE"
}
}
list_nodes() {
ensure_nodes_file
grep -v '^\s*#' "$NODES_FILE" | grep -v '^\s*$' || true
}
node_exists_by_name() {
local name="$1"
list_nodes | awk -F, -v n="$name" '$1==n {found=1} END{exit !found}'
}
add_node() {
ensure_nodes_file
local name="$1" wgip="$2" pubip="$3" pubport="$4" pubkey="$5"
if node_exists_by_name "$name"; then
warn "Node '$name' already exists in inventory."
return 0
fi
echo "${name},${wgip},${pubip},${pubport},${pubkey}" >>"$NODES_FILE"
ok "Added node '$name' to inventory."
}
# ---------- WireGuard ----------
wg_generate_keys_if_missing() {
local priv="${WG_DIR}/privatekey"
local pub="${WG_DIR}/publickey"
if [[ -f "$priv" && -f "$pub" ]]; then
ok "WireGuard keys already exist."
return 0
fi
umask 077
wg genkey | tee "$priv" | wg pubkey > "$pub"
ok "Generated WireGuard keypair."
ok "Public key:"
cat "$pub"
}
wg_init_conf_if_missing() {
if [[ -f "$WG_CONF" ]]; then
ok "$WG_CONF already exists."
return 0
fi
local privkey wg_ip listen_port
privkey="$(cat "${WG_DIR}/privatekey")"
read -r -p "Local WireGuard IP (e.g. 10.10.10.1/24): " wg_ip
listen_port="${WG_PORT_DEFAULT}"
read -r -p "WireGuard listen port [${WG_PORT_DEFAULT}]: " tmp || true
[[ -n "${tmp:-}" ]] && listen_port="$tmp"
cat >"$WG_CONF" <<EOF
[Interface]
Address = ${wg_ip}
ListenPort = ${listen_port}
PrivateKey = ${privkey}
MTU = 1420
EOF
chmod 600 "$WG_CONF"
ok "Created $WG_CONF"
}
wg_add_peer_to_conf() {
[[ -f "$WG_CONF" ]] || { err "Missing $WG_CONF. Run WireGuard setup first."; return 1; }
local name wgip pubip pubport pubkey allowed extra_allowed
read -r -p "Peer name (inventory key): " name
read -r -p "Peer WG IP (e.g. 10.10.10.2): " wgip
read -r -p "Peer Public IP (public ip or hostname): " pubip
read -r -p "Peer Public port [${WG_PORT_DEFAULT}]: " pubport || true
pubport="${pubport:-$WG_PORT_DEFAULT}"
read -r -p "Peer WireGuard PUBLIC key: " pubkey
# AllowedIPs: peer WG /32 (plus optional extra)
allowed="${wgip}/32"
read -r -p "Extra AllowedIPs (comma-separated) [empty]: " extra_allowed || true
# idempotency: if pubkey already exists, skip
if grep -q "PublicKey = ${pubkey}" "$WG_CONF"; then
warn "Peer already present in $WG_CONF (same PublicKey)."
else
{
echo "[Peer]"
echo "PublicKey = ${pubkey}"
echo "Endpoint = ${pubip}:${pubport}"
if [[ -n "${extra_allowed:-}" ]]; then
echo "AllowedIPs = ${allowed}, ${extra_allowed}"
else
echo "AllowedIPs = ${allowed}"
fi
echo "PersistentKeepalive = 25"
echo
} >>"$WG_CONF"
ok "Added peer to $WG_CONF"
fi
add_node "$name" "$wgip" "$pubip" "$pubport" "$pubkey"
}
wg_up() {
[[ -f "$WG_CONF" ]] || { err "Missing $WG_CONF"; return 1; }
systemctl enable --now "wg-quick@${WG_IF}" >/dev/null
ok "wg-quick@${WG_IF} enabled and started."
wg show || true
}
wg_down() {
systemctl stop "wg-quick@${WG_IF}" >/dev/null 2>&1 || true
ok "wg0 stopped."
}
# ---------- VXLAN ----------
vxlan_write_config() {
bridge_exists || { err "Bridge $BRIDGE not found. Create vmbr3 first."; return 1; }
[[ -f "$WG_CONF" ]] || { err "WireGuard not configured yet. Do WG first."; return 1; }
local vni dstport local_wg_ip mtu
vni="$VXLAN_VNI_DEFAULT"
dstport="$VXLAN_DSTPORT_DEFAULT"
read -r -p "VXLAN VNI [${VXLAN_VNI_DEFAULT}]: " tmp || true
[[ -n "${tmp:-}" ]] && vni="$tmp"
read -r -p "VXLAN UDP dstport [${VXLAN_DSTPORT_DEFAULT}]: " tmp || true
[[ -n "${tmp:-}" ]] && dstport="$tmp"
# derive local wg IP from wg0.conf Address (strip /xx)
local_wg_ip="$(awk -F'=' '/^Address/ {gsub(/ /,"",$2); print $2}' "$WG_CONF" | head -n1 | cut -d/ -f1)"
[[ -n "$local_wg_ip" ]] || { err "Could not detect local WG IP from $WG_CONF"; return 1; }
# MTU: use kernel-determined (don’t force above what kernel allows). We’ll set to 1370 (safe in your setup).
mtu="1370"
read -r -p "VXLAN/Bridge MTU [1370 recommended]: " tmp || true
[[ -n "${tmp:-}" ]] && mtu="$tmp"
cat >"$VXLAN_FILE" <<EOF
auto ${VXLAN_IF}
iface ${VXLAN_IF} inet manual
mtu ${mtu}
pre-up ip link add ${VXLAN_IF} type vxlan id ${vni} dev ${WG_IF} local ${local_wg_ip} dstport ${dstport} nolearning
up ip link set ${VXLAN_IF} master ${BRIDGE}
up ip link set ${VXLAN_IF} up
# Controlled flood to all peers (wildcard MAC) - entries added by script per peer:
down ip link del ${VXLAN_IF}
EOF
chmod 644 "$VXLAN_FILE"
ok "Wrote VXLAN config to $VXLAN_FILE"
info "NOTE: You must ensure ${BRIDGE} MTU matches (${mtu}). Script can set it for you (menu option)."
}
vxlan_add_fdb_for_all_peers() {
ensure_nodes_file
[[ -f "$VXLAN_FILE" ]] || { err "Missing $VXLAN_FILE. Write VXLAN config first."; return 1; }
[[ -f "$WG_CONF" ]] || { err "Missing $WG_CONF."; return 1; }
# Ensure base del/add lines exist for each peer? We’ll append per-peer lines under the comment marker.
# We'll rebuild the peer block each time to avoid duplicates.
local tmpfile mtu
mtu="$(awk '/^\s*mtu\s+/ {print $2; exit}' "$VXLAN_FILE" || true)"
[[ -n "$mtu" ]] || mtu="1370"
tmpfile="$(mktemp)"
awk '
BEGIN{inpeers=0}
{print}
/Controlled flood to all peers/ {inpeers=1}
/down ip link del/ { }
' "$VXLAN_FILE" >"$tmpfile"
# Remove any old peer lines we previously added (between marker and down)
# We will reconstruct by stripping lines matching "bridge fdb" before "down".
tmpfile2="$(mktemp)"
awk '
BEGIN{skip=0}
/Controlled flood to all peers/ {print; skip=1; next}
skip==1 && /^\s*(up\s+)?bridge fdb / {next}
{print}
' "$tmpfile" >"$tmpfile2"
# Now insert new lines right after marker
tmpfile3="$(mktemp)"
awk -v nodes="$NODES_FILE" '
function trim(s){gsub(/^[ \t]+|[ \t]+$/,"",s); return s}
BEGIN{
while((getline line < nodes)>0){
if(line ~ /^[ \t]*#/ || line ~ /^[ \t]*$/) continue
n[++cnt]=line
}
close(nodes)
}
/Controlled flood to all peers/ {
print
print " up bridge fdb del 00:00:00:00:00:00 dev vxlan200 2>/dev/null || true"
for(i=1;i<=cnt;i++){
split(n[i],a,",")
name=trim(a[1]); wgip=trim(a[2])
# dst should be peer WG IP (not public)
# add wildcard flood for VLAN 1
print " up bridge fdb add 00:00:00:00:00:00 dev vxlan200 dst " wgip " self permanent vlan 1"
}
next
}
{print}
' "$tmpfile2" >"$tmpfile3"
mv "$tmpfile3" "$VXLAN_FILE"
rm -f "$tmpfile" "$tmpfile2" 2>/dev/null || true
ok "Updated FDB peer entries in $VXLAN_FILE from inventory ($NODES_FILE)."
info "If you have N nodes, each node should have ALL other peers in its inventory."
}
vxlan_up() {
[[ -f "$VXLAN_FILE" ]] || { err "Missing $VXLAN_FILE"; return 1; }
wg_is_up || { err "WireGuard (${WG_IF}) is not UP. Start wg0 first."; return 1; }
# bring only vxlan200, avoid full ifreload if user is nervous
ifup "$VXLAN_IF" || true
if vxlan_is_up; then
ok "${VXLAN_IF} is UP."
else
err "${VXLAN_IF} did not come UP. Check 'ip -d link show ${VXLAN_IF}'"
return 1
fi
}
vxlan_down() {
ifdown "$VXLAN_IF" >/dev/null 2>&1 || true
ip link del "$VXLAN_IF" >/dev/null 2>&1 || true
ok "${VXLAN_IF} is DOWN/removed."
}
set_vmbr3_mtu() {
bridge_exists || { err "Bridge $BRIDGE not found."; return 1; }
local mtu
read -r -p "Set ${BRIDGE} MTU to (recommended 1370): " mtu
[[ -n "$mtu" ]] || { err "MTU required"; return 1; }
ip link set dev "$BRIDGE" mtu "$mtu" || { err "Failed to set MTU on $BRIDGE"; return 1; }
ok "Set $BRIDGE MTU to $mtu (runtime)."
info "To persist, add 'mtu $mtu' to the ${BRIDGE} stanza in /etc/network/interfaces."
}
# ---------- checks / status ----------
status_report() {
echo "---------------- STATUS ----------------"
echo "Host: $(hostname)"
echo "Date: $(date)"
echo
echo "[Bridge]"
if bridge_exists; then
ok "$BRIDGE exists (mtu=$(get_link_mtu "$BRIDGE"))"
ip -br link show "$BRIDGE" || true
ip -4 addr show "$BRIDGE" | sed -n '1,5p' || true
else
warn "$BRIDGE not found"
fi
echo
echo "[WireGuard]"
if [[ -f "$WG_CONF" ]]; then ok "$WG_CONF present"; else warn "$WG_CONF missing"; fi
if wg_is_up; then ok "wg0 is UP"; else warn "wg0 is DOWN"; fi
wg show 2>/dev/null || true
echo
echo "[VXLAN]"
if [[ -f "$VXLAN_FILE" ]]; then ok "$VXLAN_FILE present"; else warn "$VXLAN_FILE missing"; fi
if vxlan_is_up; then ok "vxlan200 is UP (mtu=$(get_link_mtu "$VXLAN_IF"))"; else warn "vxlan200 is DOWN"; fi
ip -d link show "$VXLAN_IF" 2>/dev/null | sed -n '1,6p' || true
echo
echo "[Inventory]"
ensure_nodes_file
if list_nodes | grep -q .; then
ok "nodes.conf has entries:"
list_nodes | sed 's/^/ - /'
else
warn "nodes.conf empty"
fi
echo "----------------------------------------"
}
sanity_warn_order() {
if ! bridge_exists; then
warn "vmbr3 missing. Create it first (Proxmox UI) before VXLAN."
fi
if ! wg_installed; then
warn "WireGuard tools missing. Install prereqs first."
fi
if [[ ! -f "$WG_CONF" ]]; then
warn "wg0.conf missing. Run WireGuard setup."
fi
if [[ -f "$VXLAN_FILE" && ! -f "$WG_CONF" ]]; then
warn "VXLAN config exists but wg0.conf missing. Order mismatch."
fi
}
install_prereqs() {
info "Installing packages..."
apt-get update -y
apt-get install -y wireguard iproute2 bridge-utils ifupdown2 lxc-utils tcpdump
ok "Prereqs installed."
}
# ---------- menu ----------
menu() {
while true; do
clear
echo "==============================================="
echo " Proxmox WireGuard + VXLAN (vmbr3) Manager"
echo "==============================================="
echo "1) Install prerequisites"
echo "2) Generate WireGuard keys (if missing)"
echo "3) Create wg0.conf (if missing)"
echo "4) Add peer to wg0.conf + inventory"
echo "5) Bring UP WireGuard (wg0)"
echo "6) Write VXLAN config (/etc/network/interfaces.d/vxlan)"
echo "7) Update VXLAN FDB entries from inventory"
echo "8) Bring UP VXLAN (vxlan200)"
echo "9) Set vmbr3 MTU (runtime)"
echo "10) Status report"
echo "11) Bring DOWN VXLAN"
echo "12) Bring DOWN WireGuard"
echo "0) Exit"
echo
sanity_warn_order
echo
read -r -p "Select: " choice
case "$choice" in
1) install_prereqs; pause;;
2) wg_generate_keys_if_missing; pause;;
3) wg_init_conf_if_missing; pause;;
4) wg_add_peer_to_conf; pause;;
5) wg_up; pause;;
6) vxlan_write_config; pause;;
7) vxlan_add_fdb_for_all_peers; pause;;
8) vxlan_up; pause;;
9) set_vmbr3_mtu; pause;;
10) status_report; pause;;
11) vxlan_down; pause;;
12) wg_down; pause;;
0) exit 0;;
*) warn "Invalid option"; pause;;
esac
done
}
# ---------- main ----------
require_root
ensure_dirs
ensure_nodes_file
menu
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment