Skip to content

Commit b250ada

Browse files
committed
feat(ns-ha): configure backup node network
Since keeping the network configuration in sync is hard, the add-interface API takes care to replicate the configuration on the backup node.
1 parent 804436f commit b250ada

File tree

2 files changed

+107
-10
lines changed

2 files changed

+107
-10
lines changed

packages/ns-api/files/ns.ha

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,56 @@ def execute_remote_command(command):
156156
error = stderr.read().decode()
157157
return output, error
158158

159+
def find_device_config(uci, device, config=None):
160+
if config is None:
161+
config = []
162+
# Find the device configuration in UCI
163+
for n in utils.get_all_by_type(uci, 'network', 'device'):
164+
if uci.get('network', n, 'name', default=None) == device:
165+
device_config = uci.get_all('network', n)
166+
device_config['record_type'] = 'device'
167+
device_config['record_id'] = n
168+
169+
if device_config.get('type') == 'bridge':
170+
# List all ports of the bridge
171+
for port in device_config.get('ports', []):
172+
config = config + find_device_config(uci, port, config)
173+
elif device_config.get('type', '').startswith('802'):
174+
ifname = device_config.get('ifname')
175+
if not ifname:
176+
continue
177+
# Get the underlying device of the 802.1q interface
178+
for underlying_device in utils.get_all_by_type(uci, 'network', 'device'):
179+
if uci.get('network', underlying_device, 'name', default=None) == ifname:
180+
config = config + find_device_config(uci, ifname, config)
181+
break
182+
config.append(device_config)
183+
184+
return config
185+
159186
### API functions
160187

188+
def import_network_config(configs):
189+
# config is a dictionary with the network configuration
190+
uci = EUci()
191+
192+
for config in configs:
193+
subprocess.run(["logger", "-t", "ns-ha", f"Importing network configuration: {json.dumps(config)}"], check=True)
194+
195+
for config in configs:
196+
if config.get('record_type') == 'device':
197+
# Remove existing devices for the same name
198+
for n in utils.get_all_by_type(uci, 'network', 'device'):
199+
if uci.get('network', n, 'name', default=None) == config['name']:
200+
uci.delete('network', n)
201+
# Create the configuration
202+
uci.set('network', config['record_id'], config['record_type'])
203+
for k, v in config.items():
204+
if k not in ['record_id', 'record_type']:
205+
uci.set('network', config['record_id'], k, v)
206+
uci.commit('network')
207+
208+
161209
def add_interface(role, interface, virtual_ip, gateway):
162210
# Add a new interface to the keepalived configuration:
163211
# 1. find a fake unused address inside the fake network 169.254.0.0/16 (reserved for documentation by RFC)
@@ -171,10 +219,38 @@ def add_interface(role, interface, virtual_ip, gateway):
171219
raise utils.ValidationError('interface', 'interface_not_found_on_main_node')
172220

173221
if role == 'main':
174-
_, error = execute_remote_command(f"uci get network.{interface}")
222+
# Before adding the interface, configure interface and device on the backup node:
223+
# 1. get the device configuration from the main node and send it to the backup node
224+
# 2. create the interface on the backup node
225+
network_config = []
226+
device = u.get('network', interface, 'device', default=None)
227+
device_config = find_device_config(u, device)
228+
# Please note that device_config could be emptry for a WAN on a clean machine
229+
network_config = network_config + device_config
230+
231+
interface_config = u.get_all('network', interface)
232+
interface_config['record_type'] = 'interface'
233+
interface_config['record_id'] = interface
234+
# Check if the interface is a bonding interface
235+
if interface_config.get('device').startswith('bond-'):
236+
# List all the slaves of the bonding interface, strip the prefix 'bond-'
237+
try:
238+
bond_config = u.get_all('network', interface_config.get('device')[5:])
239+
bond_config['record_type'] = 'interface'
240+
bond_config['record_id'] = interface_config.get('device')[5:]
241+
network_config = network_config + [bond_config]
242+
except:
243+
return utils.generic_error("bonding_interface_not_found")
244+
# Send the slaves to the backup node
245+
for slave in bond_config.get('slaves', []):
246+
network_config = network_config + find_device_config(u, slave)
247+
network_config = network_config + [interface_config]
248+
249+
# Send the interface configuration to the backup node
250+
output, error = execute_remote_command(f"echo '{json.dumps(network_config)}' | /usr/libexec/rpcd/ns.ha call import-network-config")
175251
if error:
176-
raise utils.ValidationError('interface', 'interface_not_found_on_backup_node')
177-
252+
return utils.generic_error("error_adding_interface_on_backup_node")
253+
178254
# Get the fake IP address
179255
main_ip, backup_ip = allocate_fake_ips(u)
180256

@@ -241,9 +317,6 @@ def add_interface(role, interface, virtual_ip, gateway):
241317
})
242318

243319
output, error = execute_remote_command(f"echo '{command}' | /usr/libexec/rpcd/ns.ha call add-interface")
244-
print(output)
245-
print(error)
246-
247320
if error:
248321
return utils.generic_error("error_executing_add_interface_on_backup_node")
249322

@@ -524,6 +597,15 @@ def check_remote(backup_node_ip, ssh_password):
524597
ssh.close()
525598
return result
526599

600+
def list_interfaces():
601+
u = EUci()
602+
interfaces = []
603+
for n in utils.get_all_by_type(u, 'network', 'interface'):
604+
if n == 'loopback':
605+
continue
606+
if u.get('network', n, 'device', default=None):
607+
interfaces.append(n)
608+
return { "interfaces": interfaces }
527609

528610
cmd = sys.argv[1]
529611

@@ -546,12 +628,16 @@ if cmd == 'list':
546628
},
547629
"validate-requirements": { "role": "main" },
548630
"add-interface": { "role": "main", "interface": "wan", "virtual_ip": "1.2.3.4/24", "gateway": "1.2.3.5" },
549-
"status": {}
631+
"import-network-config": { [ { "type": "device", "id": "wan", "name": "wan", "device": "eth0.2", "proto": "static", "ipaddr": ""} ] },
632+
"status": {},
633+
"list-interfaces": {}
550634
}))
551635
else:
552636
action = sys.argv[2]
553637
if action == "status":
554638
ret = status()
639+
elif action == "list-interfaces":
640+
ret = list_interfaces()
555641
else:
556642
# Paramaters:
557643
args = json.loads(sys.stdin.read())
@@ -565,5 +651,7 @@ else:
565651
ret = check_remote(args.get('backup_node_ip'), args.get('ssh_password'))
566652
elif action == "validate-requirements":
567653
ret = validate_requirements(args.get('role'))
654+
elif action == "import-network-config":
655+
ret = import_network_config(args)
568656

569657
print(json.dumps(ret))

packages/ns-ha/README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Limitations:
1212

1313
- Aliases are not supported
1414
- IPv4 only
15+
- VLANs are supported only on physical interfaces
1516
- Extra packages such as NUT are not supported
1617
- rsyslog configuration is not synced: if you need to send logs to a remote server, you must use the controller
1718
- Hotspot is not supported since it requires a new registration when the main node goes down because the MAC address associated with the hotspot interface will be different
@@ -172,13 +173,12 @@ ns-ha-config add-interface <interface> <virtual_ip_address> <gateway>
172173

173174
Make sure to:
174175

175-
- the interface already exists on both nodes
176176
- enter the virtual IP address in CIDR notation
177177
- enter the gateway IP address of the WAN interface
178178

179179
The script will:
180180

181-
- check if the interface exists on both nodes
181+
- create the network interface and devices in the backup node
182182
- configure the interface on both nodes by using fake IP addresses from the fake network 169.254.0.0/16
183183
- configure the virtual IP address on both nodes
184184

@@ -188,6 +188,16 @@ Example:
188188
ns-ha-config add-interface wan 192.168.122.49/24 192.168.122.1
189189
```
190190

191+
### Configure extra interfaces
192+
193+
You can add extra interfaces using the same command:
194+
```
195+
ns-ha-config add-interface <interface> <virtual_ip_address> [<gateway>]
196+
```
197+
198+
As the WAN interface, you must enter the virtual IP address in CIDR notation.
199+
Usually, on non-WAN interfaces, the gateway is not required.
200+
191201
### Check the status
192202

193203
You can check the status of the HA cluster at any time.
@@ -216,7 +226,6 @@ Last Sync Time: Fri Apr 18 13:09:08 UTC 2025
216226
```
217227

218228

219-
220229
## How it works
221230

222231
The HA cluster consists of two nodes: one is the main and the other is the backup.

0 commit comments

Comments
 (0)