Skip to content

Commit 43d54cd

Browse files
authored
fix(ns-ha): send gratuitous ARP for WANs (#1400)
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.
1 parent 88dd4f1 commit 43d54cd

File tree

1 file changed

+100
-0
lines changed

1 file changed

+100
-0
lines changed

packages/ns-ha/files/ns-ha-enable

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,56 @@ if not logger.handlers:
2626
logger.addHandler(handler)
2727
logger.setLevel(logging.INFO)
2828

29+
def find_device_from_config(interface):
30+
# Read device from UCI config because sometimes ubus network dump is not updated yet
31+
u = EUci()
32+
try:
33+
section = u.get_all('network', interface)
34+
except:
35+
return None
36+
device = section.get('device', '')
37+
if device.startswith('@'):
38+
try:
39+
parent = u.get_all('network', device[1:])
40+
except:
41+
return None
42+
return parent.get('device')
43+
return device
44+
45+
def is_device_up(device):
46+
# Use JSON output to reliably inspect link state
47+
proc = subprocess.run(["/sbin/ip", "-j", "link", "show", "dev", device], capture_output=True, text=True)
48+
try:
49+
link_json = json.loads(proc.stdout) if proc.stdout else []
50+
except json.JSONDecodeError:
51+
link_json = []
52+
53+
if isinstance(link_json, list) and link_json:
54+
li = link_json[0]
55+
if li.get('operstate') == 'UP' or 'UP' in li.get('flags', []):
56+
return True
57+
return False
58+
59+
def get_device_ips(device):
60+
ipv4 = []
61+
ipv6 = []
62+
# Use JSON output to reliably inspect addresses
63+
addr_proc = subprocess.run(["/sbin/ip", "-j", "addr", "show", "dev", device], capture_output=True, text=True)
64+
try:
65+
addrs = json.loads(addr_proc.stdout) if addr_proc.stdout else []
66+
except json.JSONDecodeError:
67+
addrs = []
68+
69+
for ent in addrs:
70+
for info in ent.get('addr_info', []):
71+
family = info.get('family')
72+
local = info.get('local', '')
73+
if family == 'inet':
74+
ipv4.append(local)
75+
elif family == 'inet6':
76+
ipv6.append(local)
77+
return ipv4, ipv6
78+
2979
def enable_interfaces(file):
3080
u = EUci()
3181
with open(os.path.join(out_dir, file), 'r') as f:
@@ -39,6 +89,55 @@ def enable_interfaces(file):
3989
# Return code of ifup is not reliable, so we do not check it nor log it
4090
logger.info("Bringing up interface %s", interface)
4191

92+
def send_gratuitous_arp(file):
93+
# Get the mapping interface -> device
94+
proc = subprocess.run(["ubus", "-v", "call", "network.interface", "dump"], capture_output=True, text=True)
95+
try:
96+
network_dump = json.loads(proc.stdout)
97+
except json.JSONDecodeError:
98+
logger.error("Can't send gratuitous ARP: failed to decode JSON from network dump")
99+
return
100+
device_map = {}
101+
for iface in network_dump.get('interface', []):
102+
if 'device' in iface and 'interface' in iface:
103+
device_map[iface['interface']] = iface['device']
104+
# Load the file with the interfaces to send gratuitous ARP for
105+
with open(os.path.join(out_dir, file), 'r') as f:
106+
interfaces = json.load(f)
107+
for interface in interfaces:
108+
device = device_map.get(interface, find_device_from_config(interface))
109+
if not device:
110+
logger.error("Can't send gratuitous ARP: no device found for interface %s", interface)
111+
continue
112+
# Check if device is up
113+
max_attempts = 10
114+
ready = False
115+
# First check if device is up
116+
for _ in range(max_attempts):
117+
if is_device_up(device):
118+
ready = True
119+
break
120+
time.sleep(0.5)
121+
if not ready:
122+
logger.error("Can't send gratuitous ARP: device %s for interface %s is down", device, interface)
123+
continue
124+
# Check if device has IP address
125+
for _ in range(max_attempts):
126+
# Ignore IPv6 which has a different ARP tools (ndisc6)
127+
ipv4, _ = get_device_ips(device)
128+
if len(ipv4) > 0:
129+
for ip in ipv4:
130+
# Send gratuitous ARP to update switches ARP tables
131+
# Wait for the device to be up and to have the IP address (timeout ~10s)
132+
proca = subprocess.run(["/usr/bin/arping", "-U", "-I", device, "-c", "1", ip], capture_output=True, text=True)
133+
if proca.returncode == 0:
134+
logger.info("Sent gratuitous ARP for IP %s on interface %s: success", ip, interface)
135+
else:
136+
logger.info("Sent gratuitous ARP for IP %s on interface %s: fail, %s", ip, interface, proca.stderr.strip())
137+
return
138+
time.sleep(0.5)
139+
140+
42141
def enable_hotspot_mac():
43142
u = EUci()
44143
devices = utils.get_all_by_type(u, 'network', 'device')
@@ -60,4 +159,5 @@ if __name__ == "__main__":
60159
enable_interfaces('wg_interfaces')
61160
enable_interfaces('ipsec_interfaces')
62161
enable_hotspot_mac()
162+
send_gratuitous_arp('wan_interfaces')
63163
subprocess.run(["/sbin/reload_config"], capture_output=True)

0 commit comments

Comments
 (0)