bambuddy/backend/app/services/network_utils.py
maziggy af867c0392
Some checks failed
Security Audit / Python Security Analysis (Bandit) (push) Failing after 47s
Security Audit / Container Security Scan (Trivy) (push) Failing after 46s
Security Audit / Backend Security Audit (push) Failing after 46s
Security Audit / Frontend Security Audit (push) Failing after 6s
fix(vp): populate bind-interface list on macOS (route non-Linux to psutil)
get_network_interfaces() only sent Windows to the psutil path; macOS fell into
the Linux ioctl branch, whose SIOCGIFADDR/SIOCGIFNETMASK ioctls are Linux-only.
macOS/BSD have fcntl but different ioctl numbers, so every call raised OSError
and the function returned an empty list — the VP bind-interface dropdown showed
nothing. Route all non-Linux platforms through the cross-platform psutil path.
2026-07-06 13:03:22 +02:00

298 lines
9.7 KiB
Python

"""Network utility functions for interface detection."""
import ipaddress
import json
import logging
import shutil
import socket
import struct
import subprocess
import sys
logger = logging.getLogger(__name__)
# Interfaces to exclude from selection (Linux only — Windows adapter names
# don't follow these prefixes and there's no equivalent uniform Windows
# exclude list worth hard-coding; the psutil path filters on address class
# (loopback, link-local) and interface up-state instead).
EXCLUDED_INTERFACE_PREFIXES = ("lo", "docker", "br-", "veth", "virbr")
# Resolve full path to `ip` command (may not be in PATH for service users)
_IP_CMD: str | None = shutil.which("ip") or shutil.which("ip", path="/usr/sbin:/sbin:/usr/bin:/bin")
def _is_excluded(name: str) -> bool:
"""Check if an interface name should be excluded."""
return any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES)
def _get_network_interfaces_psutil() -> list[dict]:
"""Non-Linux path (Windows, macOS, BSD): enumerate interfaces via psutil.
The ioctl request numbers in the Linux path (SIOCGIFADDR 0x8915,
SIOCGIFNETMASK 0x891B) and the sockaddr layout they return are
Linux-specific. On macOS/BSD ``fcntl`` still imports, so those ioctls
don't raise ImportError — they raise ``OSError`` per interface and the
Linux path silently returns an empty list (no VP bind interfaces).
Windows has no ``fcntl``/``ip`` at all. psutil is already a Bambuddy dep
(``psutil>=6.0.0``) and gives cross-platform name + IPv4 + netmask in one
call, so we use it for everything that isn't Linux.
Filters: IPv4 only (matches the Linux path), skip loopback and
link-local (169.254.0.0/16), skip interfaces psutil reports as down.
No name-based exclusion — users may legitimately want to bind a VP to a
Hyper-V / WSL / Tailscale / utun virtual adapter.
"""
try:
import psutil
except ImportError:
logger.warning("psutil not available, interface detection unavailable on this platform")
return []
interfaces = []
try:
addrs_by_iface = psutil.net_if_addrs()
stats_by_iface = psutil.net_if_stats()
except Exception as e:
logger.error("psutil failed to enumerate interfaces: %s", e)
return []
for name, addrs in addrs_by_iface.items():
stats = stats_by_iface.get(name)
if stats is not None and not stats.isup:
continue
for addr in addrs:
if addr.family != socket.AF_INET:
continue
ip = addr.address
netmask = addr.netmask
if not ip or not netmask:
continue
try:
ip_obj = ipaddress.IPv4Address(ip)
except ValueError:
continue
if ip_obj.is_loopback or ip_obj.is_link_local:
continue
try:
network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
except ValueError:
continue
interfaces.append(
{
"name": name,
"ip": ip,
"netmask": netmask,
"subnet": str(network),
}
)
# First IPv4 per interface is enough; matches Linux ioctl which
# returns only the primary IP (aliases land via get_all_interface_ips
# on Linux, which has no Windows analogue worth replicating).
break
return interfaces
def get_network_interfaces() -> list[dict]:
"""Get all network interfaces with their IPs and subnets.
Returns:
List of dicts with name, ip, netmask, subnet, broadcast
"""
# Only Linux has the SIOCGIFADDR/SIOCGIFNETMASK ioctls + sockaddr layout the
# path below relies on. Windows lacks fcntl entirely; macOS/BSD have fcntl but
# different ioctl numbers, so the ioctl path there fails per-interface and
# returns an empty list (breaking the VP bind-interface dropdown on macOS).
# Route everything non-Linux to the cross-platform psutil path.
if not sys.platform.startswith("linux"):
return _get_network_interfaces_psutil()
interfaces = []
try:
import fcntl
for iface in socket.if_nameindex():
name = iface[1]
# Skip excluded interfaces
if _is_excluded(name):
continue
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# Get IP address
ip_bytes = fcntl.ioctl(
s.fileno(),
0x8915, # SIOCGIFADDR
struct.pack("256s", name[:15].encode()),
)[20:24]
ip = socket.inet_ntoa(ip_bytes)
# Get netmask
netmask_bytes = fcntl.ioctl(
s.fileno(),
0x891B, # SIOCGIFNETMASK
struct.pack("256s", name[:15].encode()),
)[20:24]
netmask = socket.inet_ntoa(netmask_bytes)
# Calculate subnet
network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
interfaces.append(
{
"name": name,
"ip": ip,
"netmask": netmask,
"subnet": str(network),
}
)
s.close()
except OSError:
# Interface doesn't have an IP or other error
pass
except Exception as e:
logger.debug("Error getting info for interface %s: %s", name, e)
except ImportError:
# fcntl not available (Windows)
logger.warning("fcntl not available, interface detection limited")
except Exception as e:
logger.error("Error enumerating interfaces: %s", e)
return interfaces
def get_all_interface_ips() -> list[dict]:
"""Get all IPs (primary + aliases) for all non-excluded interfaces.
Uses `ip -j addr show` to see secondary/alias IPs that ioctl misses.
Falls back to ioctl-based get_network_interfaces() if `ip` is unavailable.
Returns:
List of dicts with name, ip, netmask, subnet, is_alias, label
"""
if not _IP_CMD:
logger.debug("ip command not found, using ioctl fallback")
return _fallback_get_all_ips()
try:
result = subprocess.run(
[_IP_CMD, "-j", "addr", "show"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
logger.warning("ip addr show failed: %s", result.stderr)
return _fallback_get_all_ips()
interfaces_data = json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
logger.warning("Failed to run ip -j addr show: %s", e)
return _fallback_get_all_ips()
entries = []
for iface in interfaces_data:
ifname = iface.get("ifname", "")
if _is_excluded(ifname):
continue
ipv4_count = 0
for addr_info in iface.get("addr_info", []):
if addr_info.get("family") != "inet":
continue
ip = addr_info.get("local", "")
prefix = addr_info.get("prefixlen", 24)
label = addr_info.get("label", ifname)
try:
network = ipaddress.IPv4Network(f"{ip}/{prefix}", strict=False)
netmask = str(network.netmask)
except ValueError:
continue
# An alias has ":" in label (e.g. eth0:vp1) or is not the first IPv4
is_alias = ":" in label or ipv4_count > 0
entries.append(
{
"name": ifname,
"ip": ip,
"netmask": netmask,
"subnet": str(network),
"is_alias": is_alias,
"label": label,
}
)
ipv4_count += 1
# Sort: primary IPs first per interface, then by interface name
entries.sort(key=lambda e: (e["name"], e["is_alias"], e["ip"]))
return entries
def _fallback_get_all_ips() -> list[dict]:
"""Fallback: wrap get_network_interfaces() result with alias fields."""
return [
{
**iface,
"is_alias": False,
"label": iface["name"],
}
for iface in get_network_interfaces()
]
def find_interface_for_ip(target_ip: str) -> dict | None:
"""Find which interface is on the same subnet as the target IP.
Args:
target_ip: IP address to find the matching interface for
Returns:
Interface dict or None if not found
"""
try:
target = ipaddress.IPv4Address(target_ip)
except ValueError:
logger.error("Invalid target IP: %s", target_ip)
return None
interfaces = get_all_interface_ips()
for iface in interfaces:
if iface.get("is_alias"):
continue
try:
network = ipaddress.IPv4Network(iface["subnet"], strict=False)
if target in network:
logger.debug("Found interface %s (%s) for target %s", iface["name"], iface["ip"], target_ip)
return iface
except ValueError:
continue
logger.warning("No interface found for target IP %s", target_ip)
return None
def get_other_interfaces(exclude_ip: str) -> list[dict]:
"""Get all interfaces except the one with the given IP.
Args:
exclude_ip: IP address of interface to exclude
Returns:
List of interface dicts
"""
interfaces = get_network_interfaces()
return [iface for iface in interfaces if iface["ip"] != exclude_ip]