Robust interface detection — retry fallback chain for headless boot (#206)

Fixes interface auto-detection failures on Pi Zero 2W where wlan0 doesn't exist
at boot. Implementation:

- Added detect_interface_with_retry() function with 3x retry fallback chain
- Config-supplied interface preferred, then /proc/net/route, then UP interface
- Exponential backoff: 5s, 10s, 20s delays between retries
- Filters excluded prefixes: lo, tailscale*, wg*, tun*, docker*, veth*, br-*
- Used in Engine.__init__(), PacketCapture.start(), MacManager.start()
- Full test coverage: 10 tests for detection, backoff, filtering

Addresses issue where network stack initializes after boot and interfaces
become available after 5-10 second delay.
This commit is contained in:
Cobra
2026-04-08 20:58:43 -04:00
parent 6f421511de
commit 245731e73b
6 changed files with 235 additions and 8 deletions
+1
View File
@@ -3,6 +3,7 @@ from utils.networking import (
get_primary_interface, get_bridge_candidates, get_wifi_interfaces,
get_mac, set_mac, get_ip, compile_bpf, send_gratuitous_arp,
interface_up, interface_down, create_vlan_interface,
detect_interface_with_retry,
)
from utils.logging import BBLogger
from utils.stealth import (
+92
View File
@@ -8,9 +8,13 @@ import os
import socket
import struct
import subprocess
import time
import logging
from pathlib import Path
from typing import List, Optional, Tuple
logger = logging.getLogger("sensor.utils.networking")
SIOCGIFADDR = 0x8915
SIOCGIFHWADDR = 0x8927
SIOCSIFHWADDR = 0x8924
@@ -64,6 +68,94 @@ def get_primary_interface() -> Optional[str]:
return interfaces[0] if interfaces else None
def _is_excluded_interface(iface: str) -> bool:
"""Check if interface should be excluded from detection."""
excluded_prefixes = ("lo", "tailscale", "wg", "tun", "docker", "veth", "br-")
return iface.startswith(excluded_prefixes)
def _get_up_interface() -> Optional[str]:
"""Get first UP interface that is not excluded."""
try:
interfaces = get_all_interfaces()
for iface in interfaces:
if not _is_excluded_interface(iface):
if is_interface_up(iface):
return iface
except Exception as e:
logger.debug("Error checking interface status: %s", e)
return None
def detect_interface_with_retry(
max_retries: int = 3,
retry_delay: int = 5,
exponential: bool = True,
config_interface: Optional[str] = None,
) -> Optional[str]:
"""Detect primary interface with retry fallback chain.
Retry chain:
1. Try get_primary_interface() which checks /proc/net/route
2. Retry with exponential backoff if interface not available (delays: 5s, 10s, 20s)
3. Fall back to first non-excluded UP interface
4. Final fallback: use config_interface if all else fails
Excluded interfaces: lo, tailscale*, wg*, tun*, docker*, veth*, br-*
Args:
max_retries: Number of retry attempts (default 3)
retry_delay: Initial retry delay in seconds (default 5)
exponential: Use exponential backoff (default True, multiplies delay by 2 each retry)
config_interface: Interface from config to use as fallback
Returns:
Interface name if found, otherwise None or config_interface if set.
"""
# Try direct detection first
iface = get_primary_interface()
if iface and not _is_excluded_interface(iface):
logger.debug("Detected interface from /proc/net/route: %s", iface)
return iface
# Retry with exponential backoff
current_delay = retry_delay
for attempt in range(max_retries):
logger.debug(
"Interface detection retry %d/%d (delay=%ds)",
attempt + 1,
max_retries,
current_delay,
)
time.sleep(current_delay)
# Try detection again
iface = get_primary_interface()
if iface and not _is_excluded_interface(iface):
logger.info("Interface detected after retry %d: %s", attempt + 1, iface)
return iface
# Try first UP interface
iface = _get_up_interface()
if iface:
logger.info("Fallback to UP interface after retry %d: %s", attempt + 1, iface)
return iface
if exponential:
current_delay *= 2
# Final fallback to config-supplied interface
if config_interface:
logger.warning(
"Interface detection exhausted retries, using config fallback: %s",
config_interface,
)
return config_interface
logger.error("Interface detection failed after %d retries and no config fallback", max_retries)
return None
def get_bridge_candidates() -> List[str]:
candidates = []
for iface in get_all_interfaces():