From 667fbefa30799959828822712499db979e5dcfae Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Thu, 16 Oct 2025 10:57:22 +0200 Subject: [PATCH 1/4] fix(ns-ha): send gratuitous ARP for WANs Keepalived automatically send gratuitous ARP for tracked interfaces. Since WAN interfaces are not tracked, gratuitous ARP packets must be sent just after the switch to master. --- packages/ns-ha/files/ns-ha-enable | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/ns-ha/files/ns-ha-enable b/packages/ns-ha/files/ns-ha-enable index 009f13484..39f4e58cc 100644 --- a/packages/ns-ha/files/ns-ha-enable +++ b/packages/ns-ha/files/ns-ha-enable @@ -39,6 +39,38 @@ def enable_interfaces(file): # Return code of ifup is not reliable, so we do not check it nor log it logger.info("Bringing up interface %s", interface) +def send_gratuitous_arp(file): + # Get the mapping interface -> device + proc = subprocess.run(["ubus", "-v", "call", "network.interface", "dump"], capture_output=True, text=True) + try: + network_dump = json.loads(proc.stdout) + except json.JSONDecodeError: + logger.error("Can't send gratuitous ARP: failed to decode JSON from network dump") + return + device_map = {} + for iface in network_dump.get('interface', []): + if 'device' in iface and 'interface' in iface: + device_map[iface['interface']] = iface['device'] + # Load the file with the interfaces to send gratuitous ARP for + with open(os.path.join(out_dir, file), 'r') as f: + interfaces = json.load(f) + for interface in interfaces: + if 'ipaddr' in interfaces[interface]: + device = device_map.get(interface) + if not device: + logger.error("Can't send gratuitous ARP: no device found for interface %s", interface) + continue + # It should not happen, but ipaddr can contain multiple IPs + ipaddr = interfaces[interface]['ipaddr'] + if isinstance(ipaddr, str): + ipaddr = [ipaddr] + for ip in ipaddr: + # Remove /mask if present + ip = ip.split('/')[0] + # Send gratuitous ARP to update switches ARP tables + aproc = subprocess.run(["/usr/bin/arping", "-c", "1", "-U", "-I", device, ip], capture_output=True) + logger.info("Sending gratuitous ARP on interface %s (%s) for IP %s: %s", interface, device, ip, "success" if aproc.returncode == 0 else "fail") + def enable_hotspot_mac(): u = EUci() devices = utils.get_all_by_type(u, 'network', 'device') @@ -60,4 +92,5 @@ if __name__ == "__main__": enable_interfaces('wg_interfaces') enable_interfaces('ipsec_interfaces') enable_hotspot_mac() + send_gratuitous_arp('wan_interfaces') subprocess.run(["/sbin/reload_config"], capture_output=True) From cdcb877513f702d73623ceb131c657b9ee6b4112 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Fri, 17 Oct 2025 08:16:31 +0200 Subject: [PATCH 2/4] feat(ns-ha): show arping failure reason --- packages/ns-ha/files/ns-ha-enable | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ns-ha/files/ns-ha-enable b/packages/ns-ha/files/ns-ha-enable index 39f4e58cc..8d5853f64 100644 --- a/packages/ns-ha/files/ns-ha-enable +++ b/packages/ns-ha/files/ns-ha-enable @@ -68,8 +68,11 @@ def send_gratuitous_arp(file): # Remove /mask if present ip = ip.split('/')[0] # Send gratuitous ARP to update switches ARP tables - aproc = subprocess.run(["/usr/bin/arping", "-c", "1", "-U", "-I", device, ip], capture_output=True) - logger.info("Sending gratuitous ARP on interface %s (%s) for IP %s: %s", interface, device, ip, "success" if aproc.returncode == 0 else "fail") + aproc = subprocess.run(["/usr/bin/arping", "-c", "1", "-U", "-I", device, ip], capture_output=True, text=True) + if aproc.returncode == 0: + logger.info("Sending gratuitous ARP on interface %s (%s) for IP %s: success", interface, device, ip) + else: + logger.error("Sending gratuitous ARP on interface %s (%s) for IP %s: fail, %s", interface, device, ip, aproc.stderr.strip()) def enable_hotspot_mac(): u = EUci() From 9fe2279c2c190554ec204c8b52a00efde489318c Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Fri, 17 Oct 2025 11:23:25 +0200 Subject: [PATCH 3/4] fix(ns-ha): improve gratuitous ARP Changes: - wait for the device to be up, wait maximum 5 seconds per interface - wait for IP to be configured into the device, wait maximum 5 seconds per interface --- packages/ns-ha/files/ns-ha-enable | 84 ++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/packages/ns-ha/files/ns-ha-enable b/packages/ns-ha/files/ns-ha-enable index 8d5853f64..19eb1e7ea 100644 --- a/packages/ns-ha/files/ns-ha-enable +++ b/packages/ns-ha/files/ns-ha-enable @@ -26,6 +26,40 @@ if not logger.handlers: logger.addHandler(handler) logger.setLevel(logging.INFO) +def is_device_up(device): + # Use JSON output to reliably inspect link state + proc = subprocess.run(["/sbin/ip", "-j", "link", "show", "dev", device], capture_output=True, text=True) + try: + link_json = json.loads(proc.stdout) if proc.stdout else [] + except json.JSONDecodeError: + link_json = [] + + if isinstance(link_json, list) and link_json: + li = link_json[0] + if li.get('operstate') == 'UP' or 'UP' in li.get('flags', []): + return True + return False + +def get_device_ips(device): + ipv4 = [] + ipv6 = [] + # Use JSON output to reliably inspect addresses + addr_proc = subprocess.run(["/sbin/ip", "-j", "addr", "show", "dev", device], capture_output=True, text=True) + try: + addrs = json.loads(addr_proc.stdout) if addr_proc.stdout else [] + except json.JSONDecodeError: + addrs = [] + + for ent in addrs: + for info in ent.get('addr_info', []): + family = info.get('family') + local = info.get('local', '') + if family == 'inet': + ipv4.append(local) + elif family == 'inet6': + ipv6.append(local) + return ipv4, ipv6 + def enable_interfaces(file): u = EUci() with open(os.path.join(out_dir, file), 'r') as f: @@ -55,24 +89,38 @@ def send_gratuitous_arp(file): with open(os.path.join(out_dir, file), 'r') as f: interfaces = json.load(f) for interface in interfaces: - if 'ipaddr' in interfaces[interface]: - device = device_map.get(interface) - if not device: - logger.error("Can't send gratuitous ARP: no device found for interface %s", interface) - continue - # It should not happen, but ipaddr can contain multiple IPs - ipaddr = interfaces[interface]['ipaddr'] - if isinstance(ipaddr, str): - ipaddr = [ipaddr] - for ip in ipaddr: - # Remove /mask if present - ip = ip.split('/')[0] - # Send gratuitous ARP to update switches ARP tables - aproc = subprocess.run(["/usr/bin/arping", "-c", "1", "-U", "-I", device, ip], capture_output=True, text=True) - if aproc.returncode == 0: - logger.info("Sending gratuitous ARP on interface %s (%s) for IP %s: success", interface, device, ip) - else: - logger.error("Sending gratuitous ARP on interface %s (%s) for IP %s: fail, %s", interface, device, ip, aproc.stderr.strip()) + device = device_map.get(interface) + if not device: + logger.error("Can't send gratuitous ARP: no device found for interface %s", interface) + continue + # Check if device is up + max_attempts = 10 + ready = False + # First check if device is up + for _ in range(max_attempts): + if is_device_up(device): + ready = True + break + time.sleep(0.5) + if not ready: + logger.error("Can't send gratuitous ARP: device %s for interface %s is down", device, interface) + continue + # Check if device has IP address + for _ in range(max_attempts): + # Ignore IPv6 which has a different ARP tools (ndisc6) + ipv4, _ = get_device_ips(device) + if len(ipv4) > 0: + for ip in ipv4: + # Send gratuitous ARP to update switches ARP tables + # Wait for the device to be up and to have the IP address (timeout ~10s) + proc = subprocess.run(["/usr/bin/arping", "-U", "-I", device, "-c", "3", ip], capture_output=True, text=True) + if proc.returncode == 0: + logger.info("Sent gratuitous ARP for IP %s on interface %s: success", ip, interface) + else: + logger.info("Sent gratuitous ARP for IP %s on interface %s: fail, %s", ip, interface, proc.stderr.strip()) + break + time.sleep(0.5) + def enable_hotspot_mac(): u = EUci() From 0ff0845f2f108487b2e4029990b6596e281197fd Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Mon, 20 Oct 2025 17:13:28 +0200 Subject: [PATCH 4/4] fix(ns-ha): improve arp sending --- packages/ns-ha/files/ns-ha-enable | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/ns-ha/files/ns-ha-enable b/packages/ns-ha/files/ns-ha-enable index 19eb1e7ea..4a13dddd2 100644 --- a/packages/ns-ha/files/ns-ha-enable +++ b/packages/ns-ha/files/ns-ha-enable @@ -26,6 +26,22 @@ if not logger.handlers: logger.addHandler(handler) logger.setLevel(logging.INFO) +def find_device_from_config(interface): + # Read device from UCI config because sometimes ubus network dump is not updated yet + u = EUci() + try: + section = u.get_all('network', interface) + except: + return None + device = section.get('device', '') + if device.startswith('@'): + try: + parent = u.get_all('network', device[1:]) + except: + return None + return parent.get('device') + return device + def is_device_up(device): # Use JSON output to reliably inspect link state proc = subprocess.run(["/sbin/ip", "-j", "link", "show", "dev", device], capture_output=True, text=True) @@ -89,7 +105,7 @@ def send_gratuitous_arp(file): with open(os.path.join(out_dir, file), 'r') as f: interfaces = json.load(f) for interface in interfaces: - device = device_map.get(interface) + device = device_map.get(interface, find_device_from_config(interface)) if not device: logger.error("Can't send gratuitous ARP: no device found for interface %s", interface) continue @@ -113,12 +129,12 @@ def send_gratuitous_arp(file): for ip in ipv4: # Send gratuitous ARP to update switches ARP tables # Wait for the device to be up and to have the IP address (timeout ~10s) - proc = subprocess.run(["/usr/bin/arping", "-U", "-I", device, "-c", "3", ip], capture_output=True, text=True) - if proc.returncode == 0: + proca = subprocess.run(["/usr/bin/arping", "-U", "-I", device, "-c", "1", ip], capture_output=True, text=True) + if proca.returncode == 0: logger.info("Sent gratuitous ARP for IP %s on interface %s: success", ip, interface) else: - logger.info("Sent gratuitous ARP for IP %s on interface %s: fail, %s", ip, interface, proc.stderr.strip()) - break + logger.info("Sent gratuitous ARP for IP %s on interface %s: fail, %s", ip, interface, proca.stderr.strip()) + return time.sleep(0.5)