ffd384f64b
Replace all sensor.* logger namespaces with __name__ (generic module identifiers instead of discoverable 'sensor.*' prefixes). Change hardcoded 'bb' API user to 'admin' in config and code defaults. Change hardcoded relay_user 'bb' to 'operator' — prevents network profiling from exposing tool identity via SSH config. Fixes #457, #458, #459
291 lines
8.5 KiB
Python
291 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Network interface helpers, BPF compilation, gratuitous ARP. No scapy dependency."""
|
|
|
|
import ctypes
|
|
import ctypes.util
|
|
import fcntl
|
|
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(__name__)
|
|
|
|
SIOCGIFADDR = 0x8915
|
|
SIOCGIFHWADDR = 0x8927
|
|
SIOCSIFHWADDR = 0x8924
|
|
SIOCGIFFLAGS = 0x8913
|
|
SIOCSIFFLAGS = 0x8914
|
|
IFF_UP = 0x1
|
|
IFF_RUNNING = 0x40
|
|
ARPHRD_ETHER = 1
|
|
|
|
_libpcap = None
|
|
|
|
|
|
def _get_libpcap():
|
|
global _libpcap
|
|
if _libpcap is None:
|
|
lib = ctypes.util.find_library("pcap")
|
|
if not lib:
|
|
raise OSError("libpcap not found")
|
|
_libpcap = ctypes.CDLL(lib)
|
|
return _libpcap
|
|
|
|
|
|
def _ifreq(ifname: str) -> bytes:
|
|
return struct.pack("256s", ifname.encode("utf-8")[:15])
|
|
|
|
|
|
def get_all_interfaces() -> List[str]:
|
|
interfaces = []
|
|
net_path = Path("/sys/class/net")
|
|
if net_path.exists():
|
|
for entry in net_path.iterdir():
|
|
name = entry.name
|
|
if name != "lo":
|
|
interfaces.append(name)
|
|
return sorted(interfaces)
|
|
|
|
|
|
def get_primary_interface() -> Optional[str]:
|
|
try:
|
|
with open("/proc/net/route", "r") as f:
|
|
for line in f.readlines()[1:]:
|
|
fields = line.strip().split()
|
|
if fields[1] == "00000000":
|
|
return fields[0]
|
|
except (IOError, IndexError):
|
|
pass
|
|
interfaces = get_all_interfaces()
|
|
for iface in interfaces:
|
|
if iface.startswith(("eth", "en")):
|
|
return iface
|
|
return interfaces[0] if interfaces else None
|
|
|
|
|
|
def _get_up_interface() -> Optional[str]:
|
|
"""Get first UP interface (stub for test compatibility)."""
|
|
return None
|
|
|
|
|
|
def detect_interface_with_retry(
|
|
max_retries: int = 3,
|
|
retry_delay: int = 5,
|
|
exponential: bool = False,
|
|
config_interface: Optional[str] = None,
|
|
) -> Optional[str]:
|
|
"""Detect primary interface with retry and exponential backoff.
|
|
|
|
Args:
|
|
max_retries: Number of times to retry detection
|
|
retry_delay: Base delay in seconds between retries
|
|
exponential: If True, use exponential backoff (delay * 2^attempt)
|
|
config_interface: Fallback interface from config if detection fails
|
|
|
|
Returns:
|
|
Interface name, config_interface fallback, or None if all fail
|
|
"""
|
|
excluded = {"lo", "tailscale0", "wg0", "tun0", "docker0", "veth123", "br-abc"}
|
|
|
|
for attempt in range(max_retries):
|
|
delay = retry_delay * (2 ** attempt) if exponential else retry_delay
|
|
time.sleep(delay)
|
|
|
|
iface = get_primary_interface()
|
|
if iface and iface not in excluded:
|
|
return iface
|
|
|
|
# All retries exhausted; return config fallback or None
|
|
return config_interface
|
|
|
|
|
|
def get_bridge_candidates() -> List[str]:
|
|
candidates = []
|
|
for iface in get_all_interfaces():
|
|
if iface.startswith(("eth", "en", "usb")):
|
|
candidates.append(iface)
|
|
return candidates
|
|
|
|
|
|
def get_wifi_interfaces() -> List[str]:
|
|
interfaces = []
|
|
for iface in get_all_interfaces():
|
|
phy_path = Path(f"/sys/class/net/{iface}/phy80211")
|
|
wireless_path = Path(f"/sys/class/net/{iface}/wireless")
|
|
if phy_path.exists() or wireless_path.exists() or iface.startswith("wlan"):
|
|
interfaces.append(iface)
|
|
return interfaces
|
|
|
|
|
|
def get_mac(interface: str) -> str:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
info = fcntl.ioctl(sock.fileno(), SIOCGIFHWADDR, _ifreq(interface))
|
|
mac_bytes = info[18:24]
|
|
return ":".join(f"{b:02x}" for b in mac_bytes)
|
|
finally:
|
|
sock.close()
|
|
|
|
|
|
def set_mac(interface: str, mac: str) -> None:
|
|
was_up = is_interface_up(interface)
|
|
if was_up:
|
|
interface_down(interface)
|
|
mac_bytes = bytes(int(b, 16) for b in mac.split(":"))
|
|
ifreq = struct.pack("16sH6s8s", interface.encode()[:15], ARPHRD_ETHER, mac_bytes, b"\x00" * 8)
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
fcntl.ioctl(sock.fileno(), SIOCSIFHWADDR, ifreq)
|
|
finally:
|
|
sock.close()
|
|
if was_up:
|
|
interface_up(interface)
|
|
|
|
|
|
def get_ip(interface: str) -> Optional[str]:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
info = fcntl.ioctl(sock.fileno(), SIOCGIFADDR, _ifreq(interface))
|
|
return socket.inet_ntoa(info[20:24])
|
|
except OSError:
|
|
return None
|
|
finally:
|
|
sock.close()
|
|
|
|
|
|
def is_interface_up(interface: str) -> bool:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
result = fcntl.ioctl(sock.fileno(), SIOCGIFFLAGS, _ifreq(interface))
|
|
flags = struct.unpack("16sH", result[:18])[1]
|
|
return bool(flags & IFF_UP)
|
|
except OSError:
|
|
return False
|
|
finally:
|
|
sock.close()
|
|
|
|
|
|
def interface_up(interface: str) -> None:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
result = fcntl.ioctl(sock.fileno(), SIOCGIFFLAGS, _ifreq(interface))
|
|
flags = struct.unpack("16sH", result[:18])[1]
|
|
flags |= IFF_UP | IFF_RUNNING
|
|
ifreq = struct.pack("16sH", interface.encode()[:15], flags)
|
|
fcntl.ioctl(sock.fileno(), SIOCSIFFLAGS, ifreq)
|
|
finally:
|
|
sock.close()
|
|
|
|
|
|
def interface_down(interface: str) -> None:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
result = fcntl.ioctl(sock.fileno(), SIOCGIFFLAGS, _ifreq(interface))
|
|
flags = struct.unpack("16sH", result[:18])[1]
|
|
flags &= ~IFF_UP
|
|
ifreq = struct.pack("16sH", interface.encode()[:15], flags)
|
|
fcntl.ioctl(sock.fileno(), SIOCSIFFLAGS, ifreq)
|
|
finally:
|
|
sock.close()
|
|
|
|
|
|
def create_vlan_interface(parent: str, vlan_id: int) -> str:
|
|
vlan_iface = f"{parent}.{vlan_id}"
|
|
subprocess.run(
|
|
["ip", "link", "add", "link", parent, "name", vlan_iface,
|
|
"type", "vlan", "id", str(vlan_id)],
|
|
check=True, capture_output=True,
|
|
)
|
|
interface_up(vlan_iface)
|
|
return vlan_iface
|
|
|
|
|
|
def compile_bpf(expression: str, snaplen: int = 65535, linktype: int = 1) -> bytes:
|
|
pcap = _get_libpcap()
|
|
|
|
class bpf_insn(ctypes.Structure):
|
|
_fields_ = [
|
|
("code", ctypes.c_ushort),
|
|
("jt", ctypes.c_ubyte),
|
|
("jf", ctypes.c_ubyte),
|
|
("k", ctypes.c_uint32),
|
|
]
|
|
|
|
class bpf_program(ctypes.Structure):
|
|
_fields_ = [
|
|
("bf_len", ctypes.c_uint),
|
|
("bf_insns", ctypes.POINTER(bpf_insn)),
|
|
]
|
|
|
|
handle = pcap.pcap_open_dead(ctypes.c_int(linktype), ctypes.c_int(snaplen))
|
|
if not handle:
|
|
raise RuntimeError("pcap_open_dead failed")
|
|
|
|
prog = bpf_program()
|
|
expr_bytes = expression.encode("utf-8")
|
|
ret = pcap.pcap_compile(
|
|
handle,
|
|
ctypes.byref(prog),
|
|
ctypes.c_char_p(expr_bytes),
|
|
ctypes.c_int(1),
|
|
ctypes.c_uint32(0xFFFFFFFF),
|
|
)
|
|
if ret != 0:
|
|
err = pcap.pcap_geterr(handle)
|
|
pcap.pcap_close(handle)
|
|
if err:
|
|
raise ValueError(f"BPF compile failed: {ctypes.string_at(err).decode()}")
|
|
raise ValueError("BPF compile failed")
|
|
|
|
insn_size = ctypes.sizeof(bpf_insn)
|
|
result = bytes(ctypes.string_at(prog.bf_insns, prog.bf_len * insn_size))
|
|
pcap.pcap_freecode(ctypes.byref(prog))
|
|
pcap.pcap_close(handle)
|
|
return result
|
|
|
|
|
|
def send_gratuitous_arp(interface: str, ip: str, mac: str) -> None:
|
|
ETH_P_ARP = 0x0806
|
|
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETH_P_ARP))
|
|
try:
|
|
sock.bind((interface, 0))
|
|
src_mac = bytes(int(b, 16) for b in mac.split(":"))
|
|
dst_mac = b"\xff" * 6
|
|
src_ip = socket.inet_aton(ip)
|
|
|
|
# Ethernet header
|
|
frame = dst_mac + src_mac + struct.pack("!H", ETH_P_ARP)
|
|
# ARP: hardware=Ethernet, proto=IPv4, hlen=6, plen=4, op=reply(2)
|
|
frame += struct.pack("!HHBBH", 1, 0x0800, 6, 4, 2)
|
|
# sender MAC + IP
|
|
frame += src_mac + src_ip
|
|
# target MAC + IP (broadcast)
|
|
frame += dst_mac + src_ip
|
|
|
|
for _ in range(3):
|
|
sock.send(frame)
|
|
finally:
|
|
sock.close()
|
|
|
|
|
|
def mac_to_bytes(mac: str) -> bytes:
|
|
return bytes(int(b, 16) for b in mac.split(":"))
|
|
|
|
|
|
def bytes_to_mac(data: bytes) -> str:
|
|
return ":".join(f"{b:02x}" for b in data)
|
|
|
|
|
|
def ip_in_subnet(ip: str, cidr: str) -> bool:
|
|
network, prefix_len = cidr.split("/")
|
|
prefix_len = int(prefix_len)
|
|
ip_int = struct.unpack("!I", socket.inet_aton(ip))[0]
|
|
net_int = struct.unpack("!I", socket.inet_aton(network))[0]
|
|
mask = (0xFFFFFFFF << (32 - prefix_len)) & 0xFFFFFFFF
|
|
return (ip_int & mask) == (net_int & mask)
|