Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions netbox_agent.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions netbox_agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions netbox_agent/ifconfig.py
Original file line number Diff line number Diff line change
@@ -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<UP,BROADCAST,...> 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
214 changes: 167 additions & 47 deletions netbox_agent/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand All @@ -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,
Expand Down Expand Up @@ -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"])
)
Expand Down
Loading