Project Overview: Dual-WAN Failover Gateway with NetworkManager
With the release of Raspberry Pi OS Bookworm, the Raspberry Pi Foundation officially deprecated dhcpcd and wpa_supplicant in favor of NetworkManager. For hobbyists used to editing /etc/wpa_supplicant/wpa_supplicant.conf or dhcpcd.conf, this shift caused widespread headless-bricking and connectivity loss. NetworkManager is a powerful, enterprise-grade daemon, but it requires a completely different mental model and command-line syntax (nmcli).
In this guide, we are building a Dual-WAN Failover Gateway. We will configure a primary Ethernet connection via a PoE+ HAT and a secondary fallback connection via a USB-to-Ethernet adapter. When the primary link drops, NetworkManager will automatically route traffic through the secondary link based on route metrics.
Time Required: 45 minutes
Target Board Variant: Raspberry Pi 5 (8GB RAM) running Raspberry Pi OS Bookworm (64-bit, Lite). Note: This code and configuration strictly targets the Bookworm NetworkManager implementation.
Parts List
- Compute: Raspberry Pi 5 (8GB RAM variant) - ~$80 USD
- Primary NIC: Official Raspberry Pi PoE+ HAT (provides power and Gigabit Ethernet passthrough) - ~$20 USD
- Secondary NIC: Waveshare USB to RJ45 Gigabit Ethernet Adapter (RTL8156 chipset, explicitly supported in Pi OS kernel) - ~$25 USD
- Storage: SanDisk Extreme 64GB microSD (A2, V30) or official Pi 5 NVMe Base with 2230 SSD
- Indicators: 2x 3mm LEDs with 330Ω resistors (for GPIO status indication)
Hardware Assembly and Pin Mapping
Before flashing the OS, assemble the hardware. The PoE+ HAT sits directly on the 40-pin header. The Waveshare USB adapter plugs into one of the blue USB 3.0 ports. We will wire two status LEDs to the GPIO header to visually indicate which WAN interface is currently holding the default route.
| Component | Function | BCM GPIO Pin | Physical Pin | Notes |
|---|---|---|---|---|
| PoE+ HAT Fan | PWM Control | GPIO 12 / 13 | 32 / 33 | Managed automatically by Pi firmware via I2C |
| Primary WAN LED | Active Route Indicator | GPIO 17 | 11 | Anode to GPIO 17, Cathode to GND via 330Ω |
| Secondary WAN LED | Fallback Route Indicator | GPIO 27 | 13 | Anode to GPIO 27, Cathode to GND via 330Ω |
Configuring Raspberry Pi Network Manager via nmcli
Boot your Pi headless (or via monitor) and open the terminal. We will use nmcli (NetworkManager Command Line Interface) to configure the interfaces. The secret to failover in NetworkManager is the ipv4.route-metric. A lower metric means higher priority.
screen/tmux to ensure your commands finish even if the network drops.
Step-by-Step Configuration
- Identify Interfaces: Run
nmcli device status. You should seeeth0(PoE HAT) andeth1(USB Adapter). Note their exact names; USB adapters sometimes enumerate asenx...based on MAC address. - Set Primary WAN (eth0): We assign a low metric (100) so it is preferred.
sudo nmcli connection modify "Wired connection 1" \ connection.id "Primary-WAN" \ connection.interface-name "eth0" \ ipv4.method auto \ ipv4.route-metric 100 sudo nmcli connection up "Primary-WAN" - Set Secondary WAN (eth1): We assign a higher metric (200) so it only routes if eth0 fails.
sudo nmcli connection add type ethernet con-name "Fallback-WAN" ifname eth1 \ ipv4.method auto \ ipv4.route-metric 200 sudo nmcli connection up "Fallback-WAN" - Disable WiFi Power Management: If you are also using WiFi as a tertiary fallback, NetworkManager might let the chip sleep. Prevent this:
sudo nmcli connection modify "MyWiFi" 802-11-wireless.powersave 2
Python Failover Monitor Script (D-Bus API)
To make this a true embedded project, we need to monitor the network state changes in real-time and trigger our GPIO LEDs. Polling nmcli via subprocess every second is inefficient and causes unnecessary SD card I/O. Instead, we hook directly into NetworkManager's D-Bus API to listen for PropertiesChanged signals.
This script targets the Raspberry Pi 5 (Bookworm 64-bit) and requires the python3-dbus and python3-gi packages (sudo apt install python3-dbus python3-gi).
#!/usr/bin/env python3
"""
NetworkManager D-Bus Failover Monitor for Raspberry Pi 5
Monitors default route changes and toggles GPIO status LEDs.
Target: Raspberry Pi OS Bookworm (64-bit)
"""
import dbus
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib
import sys
import os
# --- PIN DEFINITIONS & HARDWARE CONFIG ---
# Using sysfs for GPIO to avoid external dependencies like RPi.GPIO
# Primary WAN LED: BCM 17 (Physical 11)
# Secondary WAN LED: BCM 27 (Physical 13)
GPIO_PRIMARY = 17
GPIO_FALLBACK = 27
NM_DBUS_PATH = '/org/freedesktop/NetworkManager'
NM_DBUS_INTERFACE = 'org.freedesktop.NetworkManager'
def setup_gpio(pin):
"""Export and configure GPIO pin via sysfs."""
pin_path = f"/sys/class/gpio/gpio{pin}"
if not os.path.exists(pin_path):
with open("/sys/class/gpio/export", "w") as f:
f.write(str(pin))
with open(f"{pin_path}/direction", "w") as f:
f.write("out")
def set_gpio(pin, state):
"""Set GPIO pin high (1) or low (0)."""
with open(f"/sys/class/gpio/gpio{pin}/value", "w") as f:
f.write("1" if state else "0")
def get_active_primary_interface():
"""Query NM via D-Bus to find which interface holds the default route."""
try:
bus = dbus.SystemBus()
nm_obj = bus.get_object(NM_DBUS_INTERFACE, NM_DBUS_PATH)
nm_props = dbus.Interface(nm_obj, 'org.freedesktop.DBus.Properties')
# Get active connections
active_paths = nm_props.Get(NM_DBUS_INTERFACE, 'ActiveConnections')
for path in active_paths:
conn_obj = bus.get_object(NM_DBUS_INTERFACE, path)
conn_props = dbus.Interface(conn_obj, 'org.freedesktop.DBus.Properties')
# Check if this connection has the default route
default = conn_props.Get('org.freedesktop.NetworkManager.Connection.Active', 'Default')
if default:
devices = conn_props.Get('org.freedesktop.NetworkManager.Connection.Active', 'Devices')
if devices:
dev_obj = bus.get_object(NM_DBUS_INTERFACE, devices[0])
dev_props = dbus.Interface(dev_obj, 'org.freedesktop.DBus.Properties')
iface = dev_props.Get('org.freedesktop.NetworkManager.Device', 'Interface')
return str(iface)
except dbus.exceptions.DBusException as e:
print(f"[ERROR] D-Bus query failed: {e}", file=sys.stderr)
return None
def properties_changed_handler(interface, changed, invalidated):
"""Callback for NetworkManager state changes."""
if interface == NM_DBUS_INTERFACE:
# State 70 = NM_STATE_CONNECTED_GLOBAL
if 'State' in changed:
state = changed['State']
if state == 70:
active_iface = get_active_primary_interface()
print(f"[INFO] Global connected. Default route on: {active_iface}")
if active_iface == 'eth0':
set_gpio(GPIO_PRIMARY, 1)
set_gpio(GPIO_FALLBACK, 0)
elif active_iface == 'eth1':
set_gpio(GPIO_PRIMARY, 0)
set_gpio(GPIO_FALLBACK, 1)
else:
# Fallback to WiFi or unknown
set_gpio(GPIO_PRIMARY, 0)
set_gpio(GPIO_FALLBACK, 0)
elif state == 20: # NM_STATE_DISCONNECTED
print("[WARN] Network disconnected globally.")
set_gpio(GPIO_PRIMARY, 0)
set_gpio(GPIO_FALLBACK, 0)
def main():
print("Initializing GPIO pins...")
setup_gpio(GPIO_PRIMARY)
setup_gpio(GPIO_FALLBACK)
print("Connecting to NetworkManager D-Bus...")
DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
try:
bus.add_signal_receiver(
properties_changed_handler,
signal_name='PropertiesChanged',
dbus_interface='org.freedesktop.DBus.Properties',
path=NM_DBUS_PATH
)
except dbus.exceptions.DBusException as e:
print(f"[FATAL] Could not bind to D-Bus signal: {e}", file=sys.stderr)
sys.exit(1)
print("Monitoring failover events. Press Ctrl+C to exit.")
# Initial state check
active_iface = get_active_primary_interface()
if active_iface == 'eth0': set_gpio(GPIO_PRIMARY, 1)
elif active_iface == 'eth1': set_gpio(GPIO_FALLBACK, 1)
loop = GLib.MainLoop()
try:
loop.run()
except KeyboardInterrupt:
print("\nShutting down and cleaning up GPIO...")
set_gpio(GPIO_PRIMARY, 0)
set_gpio(GPIO_FALLBACK, 0)
loop.quit()
if __name__ == '__main__':
main()
Debugging Common NetworkManager Errors
When migrating from legacy Pi OS, you will hit walls. Here are the exact error strings generated by nmcli and how to fix them.
Error 1: The Device Eligibility Failure
Error: Connection activation failed: (2) Device is not eligible for the requested operation.
Ranked Causes:
- RF-Kill is active: The wireless interface is soft-blocked by the kernel. Fix: Run
sudo rfkill unblock wifi. - Interface is Unmanaged: NetworkManager is explicitly told to ignore the device. Fix: Check
/etc/NetworkManager/NetworkManager.conffor anunmanaged-devices=interface-name:eth1line and remove it. - MAC Address Randomization Conflict: The router rejects the randomized MAC. Fix: Disable it via
sudo nmcli connection modify "MyWiFi" wifi.cloned-mac-address permanent.
Error 2: The Headless Secrets Failure
Error: Secrets were required, but not provided.
Ranked Causes:
- Missing Key Management Flag: You passed the PSK but forgot to tell NM it's a WPA2 network. Fix: Ensure
wifi-sec.key-mgmt wpa-pskis in yournmcli connection addcommand. - Interactive Prompt Timeout: Running
nmcli connection upover a non-interactive SSH script without the--askflag or pre-supplied secrets.
- Is it soft-blocked? Run
rfkill list. If it says "Soft blocked: yes", runsudo rfkill unblock all. - Is NetworkManager actually managing it? Run
nmcli device status. If the STATE column saysunmanaged, you have a configuration file overriding NM (check/etc/NetworkManager/conf.d/). - Are you fighting a ghost daemon? Run
systemctl status dhcpcd. If it's running, it's conflicting with NM. Disable it permanently:sudo systemctl disable --now dhcpcd.
Extending and Simplifying the Build
How to Simplify: If you don't need the D-Bus Python script or GPIO LEDs, you can rely entirely on NetworkManager's built-in dispatcher scripts. Place a bash script in /etc/NetworkManager/dispatcher.d/ that triggers on up and down events. This removes the need for Python and GLib dependencies, reducing the OS footprint for headless Lite installations.
How to Extend: To turn this into a true edge router, install iptables or nftables and configure NAT masquerading. You can use the nm-dispatcher to dynamically rewrite your NAT rules based on which interface just came up, ensuring outbound traffic always exits through the correct active WAN interface. For cellular failover, swap the USB Ethernet adapter for a Waveshare USB 4G/LTE Cat4 Modem and use ModemManager alongside NetworkManager.
Frequently Asked Questions
How to configure static IP with Raspberry Pi Network Manager?
Unlike dhcpcd.conf where you appended static blocks, NetworkManager binds IP configurations to the connection profile, not the physical interface. To set a static IP on your primary Ethernet:
sudo nmcli connection modify "Primary-WAN" \
ipv4.method manual \
ipv4.addresses 192.168.1.50/24 \
ipv4.gateway 192.168.1.1 \
ipv4.dns "1.1.1.1 8.8.8.8"
sudo nmcli connection up "Primary-WAN"
What are the main Raspberry Pi NetworkManager vs dhcpcd differences?
The fundamental difference is scope. dhcpcd was primarily a DHCP client that happened to manage static IPs and basic routing. NetworkManager is a full connection state machine. It handles 802.1X enterprise WiFi, VPN integrations, cellular modems (via ModemManager), and complex routing metrics natively. Furthermore, NetworkManager stores configs as individual key-value files in /etc/NetworkManager/system-connections/ with strict 600 permissions, whereas dhcpcd used a single flat text file.
How to connect to hidden WiFi using nmcli on Raspberry Pi?
Hidden SSIDs require you to explicitly tell NetworkManager to scan for the specific network name rather than relying on broadcast beacons. Add the wifi.hidden yes flag during creation:
sudo nmcli connection add type wifi con-name "HiddenNet" ifname wlan0 \
ssid "MyHiddenSSID" \
wifi.hidden yes \
wifi-sec.key-mgmt wpa-psk \
wifi-sec.psk "SuperSecretPassword"
Why does my Pi forget WiFi passwords after a reboot on Bookworm?
This usually happens if the connection profile was created with the --temporary flag, or if the permissions on the generated file in /etc/NetworkManager/system-connections/ are incorrect. NetworkManager enforces strict security; if the file is readable by group or others (e.g., permissions are 644 instead of 600), NM will silently ignore it on boot. Fix it by running sudo chmod 600 /etc/NetworkManager/system-connections/*.nmconnection.






