Skip to content

Commit c70cb52

Browse files
feat(network): opt-in VIP detection (CARP/tunnel/loopback) with IP roles
Detect virtual IPs from runtime signals and set the matching NetBox IP role so hosts sharing a VIP each keep their own record instead of stealing it. Three independent detectors, each opt-in via config and all default off, so default behavior is unchanged: - network.vip_carp: CARP VIPs (addresses carrying a vhid in ifconfig, *BSD) -> CARP role. Ifconfig now also collects carp_addresses. - network.vip_tunnel: a /32 or /128 on an IP-tunnel interface (IPIP/IP6IP6/ SIT/GRE, via the /sys ARPHRD type) -> VIP role (e.g. LVS-TUN real servers). - network.vip_loopback: non-localhost addresses on a loopback interface -> VIP. vip_roles() aggregates them to {address: role}. In create_or_update_netbox_ip_on_interface a detected role is set when the IP is created/adopted, and the multi-assignable branch now handles any such role, not just Anycast. Also fix that branch to use assigned_object_id instead of the `.interface` attribute removed from the NetBox API, so Anycast handling works again. With all detectors off, behavior is identical to before apart from that fix. Adds the config options + example, and extends the ifconfig test to cover CARP address extraction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b31bbd8 commit c70cb52

5 files changed

Lines changed: 142 additions & 26 deletions

File tree

netbox_agent.yaml.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ network:
99
ignore_ips: (127\.0\.0\..*)
1010
# enable auto-cabling
1111
lldp: true
12+
# Optional virtual-IP (VIP) detection. All default off; when enabled, matching
13+
# addresses get a NetBox IP role so hosts sharing a VIP each keep their own
14+
# record instead of stealing it.
15+
# vip_carp: true # CARP VIPs (addresses with a vhid in ifconfig, *BSD)
16+
# vip_tunnel: true # /32 or /128 on an IP tunnel (IPIP/SIT/GRE), e.g. LVS-TUN
17+
# vip_loopback: true # non-localhost addresses on a loopback interface
1218

1319
#
1420
# You can use these to change the roles.

netbox_agent/config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,23 @@ def get_config():
132132
default="temp",
133133
help="Which MAC address to use as primary. Permanent requires ethtool and fallbacks to temporary",
134134
)
135+
p.add_argument(
136+
"--network.vip_carp",
137+
action="store_true",
138+
help="Detect CARP virtual IPs (addresses carrying a vhid in ifconfig, *BSD) "
139+
"and set the NetBox CARP role so peers share the address",
140+
)
141+
p.add_argument(
142+
"--network.vip_tunnel",
143+
action="store_true",
144+
help="Detect VIPs on IP-tunnel interfaces (a /32 or /128 on IPIP/SIT/GRE) "
145+
"and set the NetBox VIP role",
146+
)
147+
p.add_argument(
148+
"--network.vip_loopback",
149+
action="store_true",
150+
help="Detect non-localhost loopback addresses as VIPs and set the NetBox VIP role",
151+
)
135152
p.add_argument(
136153
"--inventory",
137154
action="store_true",

netbox_agent/ifconfig.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ def __init__(self, output=None):
1717
if output is None:
1818
output = subprocess.getoutput("ifconfig -a")
1919
self.output = output
20+
# Bare addresses that carry a CARP `vhid` (i.e. CARP virtual IPs).
21+
self.carp_addresses = set()
2022
self.interfaces = self.parse()
2123

2224
def parse(self):
@@ -41,4 +43,10 @@ def parse(self):
4143
ether = re.match(r"\s+ether ([0-9a-fA-F:]{17})\b", line)
4244
if ether:
4345
interfaces[current]["mac"] = ether.group(1)
46+
continue
47+
# An inet/inet6 line carrying a `vhid` is a CARP virtual IP, e.g.
48+
# "\tinet 10.0.6.1 netmask 0xffffff00 broadcast 10.0.6.255 vhid 10"
49+
vip = re.match(r"\s+inet6? (\S+).*\bvhid \d+", line)
50+
if vip:
51+
self.carp_addresses.add(vip.group(1).split("%")[0].split("/")[0])
4452
return interfaces

netbox_agent/network.py

Lines changed: 106 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ def _use_sysfs(self):
5454
"""
5555
return os.path.isdir("/sys/class/net")
5656

57-
def _ifconfig_interfaces(self):
57+
def _ifconfig(self):
5858
"""Lazily parse ``ifconfig -a`` once, for the non-sysfs (BSD) code path."""
5959
if not hasattr(self, "_ifconfig_cache"):
60-
self._ifconfig_cache = Ifconfig().interfaces
60+
self._ifconfig_cache = Ifconfig()
6161
return self._ifconfig_cache
6262

6363
def _interface_names(self):
@@ -68,7 +68,7 @@ def _interface_names(self):
6868
for i in os.listdir("/sys/class/net/")
6969
if os.path.islink("/sys/class/net/{}".format(i))
7070
]
71-
return list(self._ifconfig_interfaces().keys())
71+
return list(self._ifconfig().interfaces.keys())
7272

7373
def _interface_mac(self, interface, ethtool):
7474
if config.network.primary_mac == "permanent" and ethtool and ethtool.get("mac_address"):
@@ -78,7 +78,7 @@ def _interface_mac(self, interface, ethtool):
7878
if mac == "00:00:00:00:00:00":
7979
mac = None
8080
else:
81-
mac = self._ifconfig_interfaces().get(interface, {}).get("mac")
81+
mac = self._ifconfig().interfaces.get(interface, {}).get("mac")
8282
if mac == "00:00:00:00:00:00":
8383
mac = None
8484
if mac:
@@ -88,7 +88,7 @@ def _interface_mac(self, interface, ethtool):
8888
def _interface_mtu(self, interface):
8989
if self._use_sysfs():
9090
return int(open("/sys/class/net/{}/mtu".format(interface), "r").read().strip())
91-
return self._ifconfig_interfaces().get(interface, {}).get("mtu")
91+
return self._ifconfig().interfaces.get(interface, {}).get("mtu")
9292

9393
def _interface_bonding(self, interface):
9494
if self._use_sysfs() and os.path.isdir("/sys/class/net/{}/bonding".format(interface)):
@@ -107,6 +107,76 @@ def _interface_virtual(self, interface):
107107
)
108108
)
109109

110+
def _carp_vip_addresses(self):
111+
"""CARP virtual IPs: addresses carrying a `vhid` in ``ifconfig``.
112+
113+
CARP is *BSD-only and read from ``ifconfig``; the sysfs (Linux) path has
114+
no equivalent, so this returns nothing there.
115+
"""
116+
if not config.network.vip_carp or self._use_sysfs():
117+
return set()
118+
return set(self._ifconfig().carp_addresses)
119+
120+
def _tunnel_vip_addresses(self):
121+
"""VIPs on IP-tunnel interfaces: a /32 (or /128) on an interface whose
122+
sysfs ARPHRD type is IPIP/IP6IP6/SIT/GRE/IP6GRE (e.g. LVS-TUN)."""
123+
if not config.network.vip_tunnel or not self._use_sysfs():
124+
return set()
125+
tunnel_types = ("768", "769", "776", "778", "823")
126+
vips = set()
127+
for interface in self._interface_names():
128+
try:
129+
with open("/sys/class/net/{}/type".format(interface)) as fh:
130+
if fh.read().strip() not in tunnel_types:
131+
continue
132+
except OSError:
133+
continue
134+
for family in (netifaces.AF_INET, netifaces.AF_INET6):
135+
for addr in netifaces.ifaddresses(interface).get(family, []):
136+
bits = IPAddress(addr["mask"].split("/")[0]).netmask_bits()
137+
if (family == netifaces.AF_INET and bits == 32) or (
138+
family == netifaces.AF_INET6 and bits == 128
139+
):
140+
vips.add(addr["addr"].split("%")[0])
141+
return vips
142+
143+
def _loopback_vip_addresses(self):
144+
"""Non-localhost addresses configured on a loopback interface (lo/lo0)."""
145+
if not config.network.vip_loopback:
146+
return set()
147+
vips = set()
148+
for interface in self._interface_names():
149+
if not re.match(r"^lo\d*$", interface):
150+
continue
151+
for family in (netifaces.AF_INET, netifaces.AF_INET6):
152+
for addr in netifaces.ifaddresses(interface).get(family, []):
153+
a = addr["addr"].split("%")[0]
154+
ipobj = IPAddress(a)
155+
if ipobj.is_loopback() or ipobj.is_link_local():
156+
continue
157+
vips.add(a)
158+
return vips
159+
160+
def vip_roles(self):
161+
"""Map locally-detected VIP addresses to NetBox IP role labels.
162+
163+
Each detector is opt-in via config (``network.vip_carp`` / ``vip_tunnel``
164+
/ ``vip_loopback``), all default off, so with none enabled this returns
165+
``{}`` and IP handling is unchanged. A detected role lets
166+
:meth:`create_or_update_netbox_ip_on_interface` mark the address so peers
167+
sharing it each keep their own record instead of stealing it.
168+
"""
169+
if not hasattr(self, "_vip_roles_cache"):
170+
roles = {}
171+
for addr in self._carp_vip_addresses():
172+
roles[addr] = "CARP"
173+
for addr in self._tunnel_vip_addresses():
174+
roles.setdefault(addr, "VIP")
175+
for addr in self._loopback_vip_addresses():
176+
roles.setdefault(addr, "VIP")
177+
self._vip_roles_cache = roles
178+
return self._vip_roles_cache
179+
110180
def scan(self):
111181
nics = []
112182
for interface in self._interface_names():
@@ -421,9 +491,8 @@ def create_or_update_netbox_ip_on_interface(self, ip, interface):
421491
* If IP exists and isn't assigned, take it
422492
* If IP exists and interface is wrong, change interface
423493
"""
424-
netbox_ips = nb.ipam.ip_addresses.filter(
425-
address=ip,
426-
)
494+
role = self.vip_roles().get(ip.split("/")[0])
495+
netbox_ips = list(nb.ipam.ip_addresses.filter(address=ip))
427496
if not netbox_ips:
428497
logging.info("Create new IP {ip} on {interface}".format(ip=ip, interface=interface))
429498
query_params = {
@@ -432,31 +501,43 @@ def create_or_update_netbox_ip_on_interface(self, ip, interface):
432501
"assigned_object_type": self.assigned_object_type,
433502
"assigned_object_id": interface.id,
434503
}
504+
if role:
505+
query_params["role"] = self.ipam_choices["ip-address:role"][role]
435506

436507
netbox_ip = nb.ipam.ip_addresses.create(**query_params)
437508
return netbox_ip
438509

439-
netbox_ip = list(netbox_ips)[0]
440-
# If IP exists in anycast
441-
if netbox_ip.role and netbox_ip.role.label == "Anycast":
442-
logging.debug("IP {} is Anycast..".format(ip))
443-
unassigned_anycast_ip = [x for x in netbox_ips if x.interface is None]
444-
assigned_anycast_ip = [
445-
x for x in netbox_ips if x.interface and x.interface.id == interface.id
446-
]
447-
# use the first available anycast ip
448-
if len(unassigned_anycast_ip):
449-
logging.info("Assigning existing Anycast IP {} to interface".format(ip))
450-
netbox_ip = unassigned_anycast_ip[0]
451-
netbox_ip.interface = interface
510+
netbox_ip = netbox_ips[0]
511+
existing_role = netbox_ip.role.label if netbox_ip.role else None
512+
# Multi-assignable / shared IPs (Anycast, plus any detected VIP role):
513+
# each host keeps its own record for the shared address instead of
514+
# stealing it. With VIP detection off (role is None) this triggers only
515+
# for a pre-existing Anycast role -- as before -- but now via
516+
# assigned_object_id rather than the removed `.interface` attribute.
517+
if role or existing_role == "Anycast":
518+
role_label = role or existing_role
519+
logging.debug("IP {} is {} (multi-assignable)..".format(ip, role_label))
520+
assigned_here = [x for x in netbox_ips if x.assigned_object_id == interface.id]
521+
unassigned = [x for x in netbox_ips if x.assigned_object_id is None]
522+
if assigned_here:
523+
netbox_ip = assigned_here[0]
524+
elif unassigned:
525+
logging.info("Assigning existing {} IP {} to interface".format(role_label, ip))
526+
netbox_ip = unassigned[0]
527+
netbox_ip.assigned_object_type = self.assigned_object_type
528+
netbox_ip.assigned_object_id = interface.id
529+
if role:
530+
netbox_ip.role = self.ipam_choices["ip-address:role"][role]
452531
netbox_ip.save()
453-
# or if everything is assigned to other servers
454-
elif not len(assigned_anycast_ip):
455-
logging.info("Creating Anycast IP {} and assigning it to interface".format(ip))
532+
else:
533+
# every existing copy is assigned to another host; create our own
534+
logging.info(
535+
"Creating {} IP {} and assigning it to interface".format(role_label, ip)
536+
)
456537
query_params = {
457538
"address": ip,
458539
"status": "active",
459-
"role": self.ipam_choices["ip-address:role"]["Anycast"],
540+
"role": self.ipam_choices["ip-address:role"][role_label],
460541
"tenant": self.tenant.id if self.tenant else None,
461542
"assigned_object_type": self.assigned_object_type,
462543
"assigned_object_id": interface.id,

tests/network.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ def test_lldp_parse_with_vlan(fixture):
4444
],
4545
)
4646
def test_ifconfig_parse_freebsd(fixture):
47-
interfaces = Ifconfig(fixture).interfaces
47+
ifconfig = Ifconfig(fixture)
48+
interfaces = ifconfig.interfaces
4849
# MAC + MTU are picked up from the ether/header lines
4950
assert interfaces["vtnet0"]["mac"] == "bc:24:11:6e:21:cd"
5051
assert interfaces["vtnet0"]["mtu"] == 1500
@@ -55,3 +56,6 @@ def test_ifconfig_parse_freebsd(fixture):
5556
assert interfaces["lo0"]["mtu"] == 16384
5657
assert interfaces["pflog0"]["mtu"] == 33152
5758
assert interfaces["tailscale0"]["mtu"] == 1280
59+
# the CARP virtual IP (inet line carrying a vhid) is detected; the real
60+
# address on the same interface and everything else is not
61+
assert ifconfig.carp_addresses == {"10.0.6.1"}

0 commit comments

Comments
 (0)