diff --git a/netbox_agent.yaml.example b/netbox_agent.yaml.example index a1a8e028..dbd70521 100644 --- a/netbox_agent.yaml.example +++ b/netbox_agent.yaml.example @@ -9,6 +9,12 @@ network: ignore_ips: (127\.0\.0\..*) # enable auto-cabling lldp: true + # Optional virtual-IP (VIP) detection. All default off; when enabled, matching + # addresses get a NetBox IP role so hosts sharing a VIP each keep their own + # record instead of stealing it. + # vip_carp: true # CARP VIPs (addresses with a vhid in ifconfig, *BSD) + # vip_tunnel: true # /32 or /128 on an IP tunnel (IPIP/SIT/GRE), e.g. LVS-TUN + # vip_loopback: true # non-localhost addresses on a loopback interface # # You can use these to change the roles. diff --git a/netbox_agent/config.py b/netbox_agent/config.py index 48a910de..5fc900ff 100644 --- a/netbox_agent/config.py +++ b/netbox_agent/config.py @@ -132,6 +132,23 @@ def get_config(): default="temp", help="Which MAC address to use as primary. Permanent requires ethtool and fallbacks to temporary", ) + p.add_argument( + "--network.vip_carp", + action="store_true", + help="Detect CARP virtual IPs (addresses carrying a vhid in ifconfig, *BSD) " + "and set the NetBox CARP role so peers share the address", + ) + p.add_argument( + "--network.vip_tunnel", + action="store_true", + help="Detect VIPs on IP-tunnel interfaces (a /32 or /128 on IPIP/SIT/GRE) " + "and set the NetBox VIP role", + ) + p.add_argument( + "--network.vip_loopback", + action="store_true", + help="Detect non-localhost loopback addresses as VIPs and set the NetBox VIP role", + ) p.add_argument( "--inventory", action="store_true", diff --git a/netbox_agent/ifconfig.py b/netbox_agent/ifconfig.py new file mode 100644 index 00000000..5e47ceb6 --- /dev/null +++ b/netbox_agent/ifconfig.py @@ -0,0 +1,52 @@ +import re +import subprocess + + +class Ifconfig: + """Parse ``ifconfig -a`` output. + + Used on systems without Linux sysfs (``/sys/class/net``) -- e.g. *BSD -- to + provide the per-interface facts that :class:`~netbox_agent.network.Network` + otherwise reads from ``/sys``: the hardware (MAC) address and the MTU. + + Pass ``output`` to parse a captured string (used by the tests); otherwise it + runs ``ifconfig -a`` itself. + """ + + def __init__(self, output=None): + if output is None: + output = subprocess.getoutput("ifconfig -a") + self.output = output + # Bare addresses that carry a CARP `vhid` (i.e. CARP virtual IPs). + self.carp_addresses = set() + self.interfaces = self.parse() + + def parse(self): + interfaces = {} + current = None + for line in self.output.splitlines(): + # Interface header lines start in column 0, e.g. + # vtnet0: flags=1008843 metric 0 mtu 1500 + header = re.match(r"^(\S+?): flags=\S*<[^>]*>(.*)$", line) + if header: + current = header.group(1) + mtu = re.search(r"\bmtu (\d+)", header.group(2)) + interfaces[current] = { + "mac": None, + "mtu": int(mtu.group(1)) if mtu else None, + } + continue + if current is None: + continue + # Indented link-layer line carries the MAC, e.g. + # "\tether bc:24:11:6e:21:cd" + ether = re.match(r"\s+ether ([0-9a-fA-F:]{17})\b", line) + if ether: + interfaces[current]["mac"] = ether.group(1) + continue + # An inet/inet6 line carrying a `vhid` is a CARP virtual IP, e.g. + # "\tinet 10.0.6.1 netmask 0xffffff00 broadcast 10.0.6.255 vhid 10" + vip = re.match(r"\s+inet6? (\S+).*\bvhid \d+", line) + if vip: + self.carp_addresses.add(vip.group(1).split("%")[0].split("/")[0]) + return interfaces diff --git a/netbox_agent/network.py b/netbox_agent/network.py index c2706108..2cd72266 100644 --- a/netbox_agent/network.py +++ b/netbox_agent/network.py @@ -11,6 +11,7 @@ from netbox_agent.config import config from netbox_agent.config import netbox_instance as nb from netbox_agent.ethtool import Ethtool +from netbox_agent.ifconfig import Ifconfig from netbox_agent.ipmi import IPMI from netbox_agent.lldp import LLDP @@ -46,13 +47,139 @@ def __init__(self, server, *args, **kwargs): def get_network_type(): return NotImplementedError - def scan(self): - nics = [] - for interface in os.listdir("/sys/class/net/"): + def _use_sysfs(self): + """Whether Linux sysfs (/sys/class/net) is available. + + When it isn't (e.g. *BSD), interface facts come from ``ifconfig`` instead. + """ + return os.path.isdir("/sys/class/net") + + def _ifconfig(self): + """Lazily parse ``ifconfig -a`` once, for the non-sysfs (BSD) code path.""" + if not hasattr(self, "_ifconfig_cache"): + self._ifconfig_cache = Ifconfig() + return self._ifconfig_cache + + def _interface_names(self): + if self._use_sysfs(): # ignore if it's not a link (ie: bonding_masters etc) - if not os.path.islink("/sys/class/net/{}".format(interface)): + return [ + i + for i in os.listdir("/sys/class/net/") + if os.path.islink("/sys/class/net/{}".format(i)) + ] + return list(self._ifconfig().interfaces.keys()) + + def _interface_mac(self, interface, ethtool): + if config.network.primary_mac == "permanent" and ethtool and ethtool.get("mac_address"): + mac = ethtool["mac_address"] + elif self._use_sysfs(): + mac = open("/sys/class/net/{}/address".format(interface), "r").read().strip() + if mac == "00:00:00:00:00:00": + mac = None + else: + mac = self._ifconfig().interfaces.get(interface, {}).get("mac") + if mac == "00:00:00:00:00:00": + mac = None + if mac: + mac = mac.upper() + return mac + + def _interface_mtu(self, interface): + if self._use_sysfs(): + return int(open("/sys/class/net/{}/mtu".format(interface), "r").read().strip()) + return self._ifconfig().interfaces.get(interface, {}).get("mtu") + + def _interface_bonding(self, interface): + if self._use_sysfs() and os.path.isdir("/sys/class/net/{}/bonding".format(interface)): + slaves = open("/sys/class/net/{}/bonding/slaves".format(interface)).read().split() + return True, slaves + return False, [] + + def _interface_virtual(self, interface): + if self._use_sysfs(): + return Path(f"/sys/class/net/{interface}").resolve().parent == VIRTUAL_NET_FOLDER + # No sysfs (e.g. *BSD): fall back to a name-based heuristic for the common + # virtual interface types. + return bool( + re.match( + r"^(lo|tun|tap|bridge|vlan|gif|gre|epair|pflog|pfsync|enc|ipfw)\d*$", interface + ) + ) + + def _carp_vip_addresses(self): + """CARP virtual IPs: addresses carrying a `vhid` in ``ifconfig``. + + CARP is *BSD-only and read from ``ifconfig``; the sysfs (Linux) path has + no equivalent, so this returns nothing there. + """ + if not config.network.vip_carp or self._use_sysfs(): + return set() + return set(self._ifconfig().carp_addresses) + + def _tunnel_vip_addresses(self): + """VIPs on IP-tunnel interfaces: a /32 (or /128) on an interface whose + sysfs ARPHRD type is IPIP/IP6IP6/SIT/GRE/IP6GRE (e.g. LVS-TUN).""" + if not config.network.vip_tunnel or not self._use_sysfs(): + return set() + tunnel_types = ("768", "769", "776", "778", "823") + vips = set() + for interface in self._interface_names(): + try: + with open("/sys/class/net/{}/type".format(interface)) as fh: + if fh.read().strip() not in tunnel_types: + continue + except OSError: continue + for family in (netifaces.AF_INET, netifaces.AF_INET6): + for addr in netifaces.ifaddresses(interface).get(family, []): + bits = IPAddress(addr["mask"].split("/")[0]).netmask_bits() + if (family == netifaces.AF_INET and bits == 32) or ( + family == netifaces.AF_INET6 and bits == 128 + ): + vips.add(addr["addr"].split("%")[0]) + return vips + + def _loopback_vip_addresses(self): + """Non-localhost addresses configured on a loopback interface (lo/lo0).""" + if not config.network.vip_loopback: + return set() + vips = set() + for interface in self._interface_names(): + if not re.match(r"^lo\d*$", interface): + continue + for family in (netifaces.AF_INET, netifaces.AF_INET6): + for addr in netifaces.ifaddresses(interface).get(family, []): + a = addr["addr"].split("%")[0] + ipobj = IPAddress(a) + if ipobj.is_loopback() or ipobj.is_link_local(): + continue + vips.add(a) + return vips + + def vip_roles(self): + """Map locally-detected VIP addresses to NetBox IP role labels. + + Each detector is opt-in via config (``network.vip_carp`` / ``vip_tunnel`` + / ``vip_loopback``), all default off, so with none enabled this returns + ``{}`` and IP handling is unchanged. A detected role lets + :meth:`create_or_update_netbox_ip_on_interface` mark the address so peers + sharing it each keep their own record instead of stealing it. + """ + if not hasattr(self, "_vip_roles_cache"): + roles = {} + for addr in self._carp_vip_addresses(): + roles[addr] = "CARP" + for addr in self._tunnel_vip_addresses(): + roles.setdefault(addr, "VIP") + for addr in self._loopback_vip_addresses(): + roles.setdefault(addr, "VIP") + self._vip_roles_cache = roles + return self._vip_roles_cache + def scan(self): + nics = [] + for interface in self._interface_names(): if config.network.ignore_interfaces and re.match( config.network.ignore_interfaces, interface ): @@ -90,33 +217,15 @@ def scan(self): ip_addr.append(addr) ethtool = Ethtool(interface).parse() - if ( - config.network.primary_mac == "permanent" - and ethtool - and ethtool.get("mac_address") - ): - mac = ethtool["mac_address"] - else: - mac = open("/sys/class/net/{}/address".format(interface), "r").read().strip() - if mac == "00:00:00:00:00:00": - mac = None - if mac: - mac = mac.upper() + mac = self._interface_mac(interface, ethtool) + mtu = self._interface_mtu(interface) - mtu = int(open("/sys/class/net/{}/mtu".format(interface), "r").read().strip()) vlan = None if len(interface.split(".")) > 1: vlan = int(interface.split(".")[1]) - bonding = False - bonding_slaves = [] - if os.path.isdir("/sys/class/net/{}/bonding".format(interface)): - bonding = True - bonding_slaves = ( - open("/sys/class/net/{}/bonding/slaves".format(interface)).read().split() - ) - - virtual = Path(f"/sys/class/net/{interface}").resolve().parent == VIRTUAL_NET_FOLDER + bonding, bonding_slaves = self._interface_bonding(interface) + virtual = self._interface_virtual(interface) nic = { "name": interface, @@ -382,9 +491,8 @@ def create_or_update_netbox_ip_on_interface(self, ip, interface): * If IP exists and isn't assigned, take it * If IP exists and interface is wrong, change interface """ - netbox_ips = nb.ipam.ip_addresses.filter( - address=ip, - ) + role = self.vip_roles().get(ip.split("/")[0]) + netbox_ips = list(nb.ipam.ip_addresses.filter(address=ip)) if not netbox_ips: logging.info("Create new IP {ip} on {interface}".format(ip=ip, interface=interface)) query_params = { @@ -393,31 +501,43 @@ def create_or_update_netbox_ip_on_interface(self, ip, interface): "assigned_object_type": self.assigned_object_type, "assigned_object_id": interface.id, } + if role: + query_params["role"] = self.ipam_choices["ip-address:role"][role] netbox_ip = nb.ipam.ip_addresses.create(**query_params) return netbox_ip - netbox_ip = list(netbox_ips)[0] - # If IP exists in anycast - if netbox_ip.role and netbox_ip.role.label == "Anycast": - logging.debug("IP {} is Anycast..".format(ip)) - unassigned_anycast_ip = [x for x in netbox_ips if x.interface is None] - assigned_anycast_ip = [ - x for x in netbox_ips if x.interface and x.interface.id == interface.id - ] - # use the first available anycast ip - if len(unassigned_anycast_ip): - logging.info("Assigning existing Anycast IP {} to interface".format(ip)) - netbox_ip = unassigned_anycast_ip[0] - netbox_ip.interface = interface + netbox_ip = netbox_ips[0] + existing_role = netbox_ip.role.label if netbox_ip.role else None + # Multi-assignable / shared IPs (Anycast, plus any detected VIP role): + # each host keeps its own record for the shared address instead of + # stealing it. With VIP detection off (role is None) this triggers only + # for a pre-existing Anycast role -- as before -- but now via + # assigned_object_id rather than the removed `.interface` attribute. + if role or existing_role == "Anycast": + role_label = role or existing_role + logging.debug("IP {} is {} (multi-assignable)..".format(ip, role_label)) + assigned_here = [x for x in netbox_ips if x.assigned_object_id == interface.id] + unassigned = [x for x in netbox_ips if x.assigned_object_id is None] + if assigned_here: + netbox_ip = assigned_here[0] + elif unassigned: + logging.info("Assigning existing {} IP {} to interface".format(role_label, ip)) + netbox_ip = unassigned[0] + netbox_ip.assigned_object_type = self.assigned_object_type + netbox_ip.assigned_object_id = interface.id + if role: + netbox_ip.role = self.ipam_choices["ip-address:role"][role] netbox_ip.save() - # or if everything is assigned to other servers - elif not len(assigned_anycast_ip): - logging.info("Creating Anycast IP {} and assigning it to interface".format(ip)) + else: + # every existing copy is assigned to another host; create our own + logging.info( + "Creating {} IP {} and assigning it to interface".format(role_label, ip) + ) query_params = { "address": ip, "status": "active", - "role": self.ipam_choices["ip-address:role"]["Anycast"], + "role": self.ipam_choices["ip-address:role"][role_label], "tenant": self.tenant.id if self.tenant else None, "assigned_object_type": self.assigned_object_type, "assigned_object_id": interface.id, @@ -555,7 +675,7 @@ def batched(it, n): nic_update += 1 if hasattr(interface, "mtu"): - if nic["mtu"] != interface.mtu: + if nic["mtu"] and nic["mtu"] != interface.mtu: logging.info( "Interface mtu is wrong, updating to: {mtu}".format(mtu=nic["mtu"]) ) diff --git a/tests/fixtures/ifconfig/freebsd_carp.txt b/tests/fixtures/ifconfig/freebsd_carp.txt new file mode 100644 index 00000000..36793db9 --- /dev/null +++ b/tests/fixtures/ifconfig/freebsd_carp.txt @@ -0,0 +1,39 @@ +vtnet0: flags=1008843 metric 0 mtu 1500 + options=cc039a + ether bc:24:11:6e:21:cd + inet 157.131.224.217 netmask 0xffffffc0 broadcast 157.131.224.255 + media: Ethernet autoselect (10Gbase-T ) + status: active + nd6 options=29 +vtnet1: flags=1008943 metric 0 mtu 1500 + options=cc039a + ether bc:24:11:90:23:4d + inet 10.0.6.2 netmask 0xffffff00 broadcast 10.0.6.255 + inet 10.0.6.1 netmask 0xffffff00 broadcast 10.0.6.255 vhid 10 + carp: MASTER vhid 10 advbase 1 advskew 100 + peer 224.0.0.18 peer6 ff02::12 + media: Ethernet autoselect (10Gbase-T ) + status: active + nd6 options=29 +lo0: flags=1008049 metric 0 mtu 16384 + options=680003 + inet 127.0.0.1 netmask 0xff000000 + inet6 ::1 prefixlen 128 + inet6 fe80::1%lo0 prefixlen 64 scopeid 0x3 + groups: lo + nd6 options=21 +pfsync0: flags=1000041 metric 0 mtu 1500 + options=0 + syncdev: vtnet1 maxupd: 128 defer: off version: 1500 + syncok: 1 + groups: pfsync +pflog0: flags=1000141 metric 0 mtu 33152 + options=0 + groups: pflog +tailscale0: flags=1008043 metric 0 mtu 1280 + options=4080000 + inet 100.78.225.91 netmask 0xffffffff broadcast 100.78.225.91 + inet6 fd7a:115c:a1e0::ac01:e185 prefixlen 48 + groups: tun + nd6 options=101 + Opened by PID 26613 diff --git a/tests/network.py b/tests/network.py index 99c42d4c..5cfa2b58 100644 --- a/tests/network.py +++ b/tests/network.py @@ -1,3 +1,4 @@ +from netbox_agent.ifconfig import Ifconfig from netbox_agent.lldp import LLDP from tests.conftest import parametrize_with_fixtures @@ -34,3 +35,27 @@ def test_lldp_parse_with_vlan(fixture): lldp = LLDP(fixture) assert lldp.get_switch_vlan("eth0") == {"300": {"pvid": True}} assert lldp.get_switch_vlan("eth1") == {"300": {}} + + +@parametrize_with_fixtures( + "ifconfig/", + only_filenames=[ + "freebsd_carp.txt", + ], +) +def test_ifconfig_parse_freebsd(fixture): + ifconfig = Ifconfig(fixture) + interfaces = ifconfig.interfaces + # MAC + MTU are picked up from the ether/header lines + assert interfaces["vtnet0"]["mac"] == "bc:24:11:6e:21:cd" + assert interfaces["vtnet0"]["mtu"] == 1500 + assert interfaces["vtnet1"]["mac"] == "bc:24:11:90:23:4d" + assert interfaces["vtnet1"]["mtu"] == 1500 + # interfaces without an ether line have no MAC, but still an MTU + assert interfaces["lo0"]["mac"] is None + assert interfaces["lo0"]["mtu"] == 16384 + assert interfaces["pflog0"]["mtu"] == 33152 + assert interfaces["tailscale0"]["mtu"] == 1280 + # the CARP virtual IP (inet line carrying a vhid) is detected; the real + # address on the same interface and everything else is not + assert ifconfig.carp_addresses == {"10.0.6.1"}