Skip to content

Commit b31bbd8

Browse files
feat(network): add *BSD support via an ifconfig fallback
Network.scan() read interface facts exclusively from Linux sysfs (/sys/class/net), so it could not run on *BSD. Abstract the OS-specific bits behind small helpers that use sysfs when present and fall back to parsing `ifconfig -a` otherwise: - Add netbox_agent/ifconfig.py: parse `ifconfig -a` for per-interface MAC and MTU (the facts scan() reads from /sys on Linux). - scan() enumerates interfaces and reads MAC/MTU/bonding/virtual via _use_sysfs()-guarded helpers; the Linux path is behaviorally unchanged. - ethtool is already skipped when the binary is absent, so *BSD NICs get ethtool=None (interface type falls back to Other), which downstream already handles. - Guard the MTU update so a missing (None) MTU never clears a known one. - Add a fixture + test parsing real FreeBSD `ifconfig` output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 641bb97 commit b31bbd8

4 files changed

Lines changed: 171 additions & 28 deletions

File tree

netbox_agent/ifconfig.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import re
2+
import subprocess
3+
4+
5+
class Ifconfig:
6+
"""Parse ``ifconfig -a`` output.
7+
8+
Used on systems without Linux sysfs (``/sys/class/net``) -- e.g. *BSD -- to
9+
provide the per-interface facts that :class:`~netbox_agent.network.Network`
10+
otherwise reads from ``/sys``: the hardware (MAC) address and the MTU.
11+
12+
Pass ``output`` to parse a captured string (used by the tests); otherwise it
13+
runs ``ifconfig -a`` itself.
14+
"""
15+
16+
def __init__(self, output=None):
17+
if output is None:
18+
output = subprocess.getoutput("ifconfig -a")
19+
self.output = output
20+
self.interfaces = self.parse()
21+
22+
def parse(self):
23+
interfaces = {}
24+
current = None
25+
for line in self.output.splitlines():
26+
# Interface header lines start in column 0, e.g.
27+
# vtnet0: flags=1008843<UP,BROADCAST,...> metric 0 mtu 1500
28+
header = re.match(r"^(\S+?): flags=\S*<[^>]*>(.*)$", line)
29+
if header:
30+
current = header.group(1)
31+
mtu = re.search(r"\bmtu (\d+)", header.group(2))
32+
interfaces[current] = {
33+
"mac": None,
34+
"mtu": int(mtu.group(1)) if mtu else None,
35+
}
36+
continue
37+
if current is None:
38+
continue
39+
# Indented link-layer line carries the MAC, e.g.
40+
# "\tether bc:24:11:6e:21:cd"
41+
ether = re.match(r"\s+ether ([0-9a-fA-F:]{17})\b", line)
42+
if ether:
43+
interfaces[current]["mac"] = ether.group(1)
44+
return interfaces

netbox_agent/network.py

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from netbox_agent.config import config
1212
from netbox_agent.config import netbox_instance as nb
1313
from netbox_agent.ethtool import Ethtool
14+
from netbox_agent.ifconfig import Ifconfig
1415
from netbox_agent.ipmi import IPMI
1516
from netbox_agent.lldp import LLDP
1617

@@ -46,13 +47,69 @@ def __init__(self, server, *args, **kwargs):
4647
def get_network_type():
4748
return NotImplementedError
4849

49-
def scan(self):
50-
nics = []
51-
for interface in os.listdir("/sys/class/net/"):
50+
def _use_sysfs(self):
51+
"""Whether Linux sysfs (/sys/class/net) is available.
52+
53+
When it isn't (e.g. *BSD), interface facts come from ``ifconfig`` instead.
54+
"""
55+
return os.path.isdir("/sys/class/net")
56+
57+
def _ifconfig_interfaces(self):
58+
"""Lazily parse ``ifconfig -a`` once, for the non-sysfs (BSD) code path."""
59+
if not hasattr(self, "_ifconfig_cache"):
60+
self._ifconfig_cache = Ifconfig().interfaces
61+
return self._ifconfig_cache
62+
63+
def _interface_names(self):
64+
if self._use_sysfs():
5265
# ignore if it's not a link (ie: bonding_masters etc)
53-
if not os.path.islink("/sys/class/net/{}".format(interface)):
54-
continue
66+
return [
67+
i
68+
for i in os.listdir("/sys/class/net/")
69+
if os.path.islink("/sys/class/net/{}".format(i))
70+
]
71+
return list(self._ifconfig_interfaces().keys())
72+
73+
def _interface_mac(self, interface, ethtool):
74+
if config.network.primary_mac == "permanent" and ethtool and ethtool.get("mac_address"):
75+
mac = ethtool["mac_address"]
76+
elif self._use_sysfs():
77+
mac = open("/sys/class/net/{}/address".format(interface), "r").read().strip()
78+
if mac == "00:00:00:00:00:00":
79+
mac = None
80+
else:
81+
mac = self._ifconfig_interfaces().get(interface, {}).get("mac")
82+
if mac == "00:00:00:00:00:00":
83+
mac = None
84+
if mac:
85+
mac = mac.upper()
86+
return mac
87+
88+
def _interface_mtu(self, interface):
89+
if self._use_sysfs():
90+
return int(open("/sys/class/net/{}/mtu".format(interface), "r").read().strip())
91+
return self._ifconfig_interfaces().get(interface, {}).get("mtu")
92+
93+
def _interface_bonding(self, interface):
94+
if self._use_sysfs() and os.path.isdir("/sys/class/net/{}/bonding".format(interface)):
95+
slaves = open("/sys/class/net/{}/bonding/slaves".format(interface)).read().split()
96+
return True, slaves
97+
return False, []
98+
99+
def _interface_virtual(self, interface):
100+
if self._use_sysfs():
101+
return Path(f"/sys/class/net/{interface}").resolve().parent == VIRTUAL_NET_FOLDER
102+
# No sysfs (e.g. *BSD): fall back to a name-based heuristic for the common
103+
# virtual interface types.
104+
return bool(
105+
re.match(
106+
r"^(lo|tun|tap|bridge|vlan|gif|gre|epair|pflog|pfsync|enc|ipfw)\d*$", interface
107+
)
108+
)
55109

110+
def scan(self):
111+
nics = []
112+
for interface in self._interface_names():
56113
if config.network.ignore_interfaces and re.match(
57114
config.network.ignore_interfaces, interface
58115
):
@@ -90,33 +147,15 @@ def scan(self):
90147
ip_addr.append(addr)
91148

92149
ethtool = Ethtool(interface).parse()
93-
if (
94-
config.network.primary_mac == "permanent"
95-
and ethtool
96-
and ethtool.get("mac_address")
97-
):
98-
mac = ethtool["mac_address"]
99-
else:
100-
mac = open("/sys/class/net/{}/address".format(interface), "r").read().strip()
101-
if mac == "00:00:00:00:00:00":
102-
mac = None
103-
if mac:
104-
mac = mac.upper()
150+
mac = self._interface_mac(interface, ethtool)
151+
mtu = self._interface_mtu(interface)
105152

106-
mtu = int(open("/sys/class/net/{}/mtu".format(interface), "r").read().strip())
107153
vlan = None
108154
if len(interface.split(".")) > 1:
109155
vlan = int(interface.split(".")[1])
110156

111-
bonding = False
112-
bonding_slaves = []
113-
if os.path.isdir("/sys/class/net/{}/bonding".format(interface)):
114-
bonding = True
115-
bonding_slaves = (
116-
open("/sys/class/net/{}/bonding/slaves".format(interface)).read().split()
117-
)
118-
119-
virtual = Path(f"/sys/class/net/{interface}").resolve().parent == VIRTUAL_NET_FOLDER
157+
bonding, bonding_slaves = self._interface_bonding(interface)
158+
virtual = self._interface_virtual(interface)
120159

121160
nic = {
122161
"name": interface,
@@ -555,7 +594,7 @@ def batched(it, n):
555594
nic_update += 1
556595

557596
if hasattr(interface, "mtu"):
558-
if nic["mtu"] != interface.mtu:
597+
if nic["mtu"] and nic["mtu"] != interface.mtu:
559598
logging.info(
560599
"Interface mtu is wrong, updating to: {mtu}".format(mtu=nic["mtu"])
561600
)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
vtnet0: flags=1008843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST,LOWER_UP> metric 0 mtu 1500
2+
options=cc039a<TXCSUM,VLAN_MTU,VLAN_HWTAGGING,VLAN_HWCSUM,TSO4,TSO6,VLAN_HWTSO,LINKSTATE,TXCSUM_IPV6,HWSTATS>
3+
ether bc:24:11:6e:21:cd
4+
inet 157.131.224.217 netmask 0xffffffc0 broadcast 157.131.224.255
5+
media: Ethernet autoselect (10Gbase-T <full-duplex>)
6+
status: active
7+
nd6 options=29<PERFORMNUD,IFDISABLED,AUTO_LINKLOCAL>
8+
vtnet1: flags=1008943<UP,BROADCAST,RUNNING,PROMISC,SIMPLEX,MULTICAST,LOWER_UP> metric 0 mtu 1500
9+
options=cc039a<TXCSUM,VLAN_MTU,VLAN_HWTAGGING,VLAN_HWCSUM,TSO4,TSO6,VLAN_HWTSO,LINKSTATE,TXCSUM_IPV6,HWSTATS>
10+
ether bc:24:11:90:23:4d
11+
inet 10.0.6.2 netmask 0xffffff00 broadcast 10.0.6.255
12+
inet 10.0.6.1 netmask 0xffffff00 broadcast 10.0.6.255 vhid 10
13+
carp: MASTER vhid 10 advbase 1 advskew 100
14+
peer 224.0.0.18 peer6 ff02::12
15+
media: Ethernet autoselect (10Gbase-T <full-duplex>)
16+
status: active
17+
nd6 options=29<PERFORMNUD,IFDISABLED,AUTO_LINKLOCAL>
18+
lo0: flags=1008049<UP,LOOPBACK,RUNNING,MULTICAST,LOWER_UP> metric 0 mtu 16384
19+
options=680003<RXCSUM,TXCSUM,LINKSTATE,RXCSUM_IPV6,TXCSUM_IPV6>
20+
inet 127.0.0.1 netmask 0xff000000
21+
inet6 ::1 prefixlen 128
22+
inet6 fe80::1%lo0 prefixlen 64 scopeid 0x3
23+
groups: lo
24+
nd6 options=21<PERFORMNUD,AUTO_LINKLOCAL>
25+
pfsync0: flags=1000041<UP,RUNNING,LOWER_UP> metric 0 mtu 1500
26+
options=0
27+
syncdev: vtnet1 maxupd: 128 defer: off version: 1500
28+
syncok: 1
29+
groups: pfsync
30+
pflog0: flags=1000141<UP,RUNNING,PROMISC,LOWER_UP> metric 0 mtu 33152
31+
options=0
32+
groups: pflog
33+
tailscale0: flags=1008043<UP,BROADCAST,RUNNING,MULTICAST,LOWER_UP> metric 0 mtu 1280
34+
options=4080000<LINKSTATE,MEXTPG>
35+
inet 100.78.225.91 netmask 0xffffffff broadcast 100.78.225.91
36+
inet6 fd7a:115c:a1e0::ac01:e185 prefixlen 48
37+
groups: tun
38+
nd6 options=101<PERFORMNUD,NO_DAD>
39+
Opened by PID 26613

tests/network.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from netbox_agent.ifconfig import Ifconfig
12
from netbox_agent.lldp import LLDP
23
from tests.conftest import parametrize_with_fixtures
34

@@ -34,3 +35,23 @@ def test_lldp_parse_with_vlan(fixture):
3435
lldp = LLDP(fixture)
3536
assert lldp.get_switch_vlan("eth0") == {"300": {"pvid": True}}
3637
assert lldp.get_switch_vlan("eth1") == {"300": {}}
38+
39+
40+
@parametrize_with_fixtures(
41+
"ifconfig/",
42+
only_filenames=[
43+
"freebsd_carp.txt",
44+
],
45+
)
46+
def test_ifconfig_parse_freebsd(fixture):
47+
interfaces = Ifconfig(fixture).interfaces
48+
# MAC + MTU are picked up from the ether/header lines
49+
assert interfaces["vtnet0"]["mac"] == "bc:24:11:6e:21:cd"
50+
assert interfaces["vtnet0"]["mtu"] == 1500
51+
assert interfaces["vtnet1"]["mac"] == "bc:24:11:90:23:4d"
52+
assert interfaces["vtnet1"]["mtu"] == 1500
53+
# interfaces without an ether line have no MAC, but still an MTU
54+
assert interfaces["lo0"]["mac"] is None
55+
assert interfaces["lo0"]["mtu"] == 16384
56+
assert interfaces["pflog0"]["mtu"] == 33152
57+
assert interfaces["tailscale0"]["mtu"] == 1280

0 commit comments

Comments
 (0)