Skip to content

Commit 345eb3a

Browse files
authored
feat(dhcp): support multiple dhcp ranges (#1734)
1 parent f3f213a commit 345eb3a

2 files changed

Lines changed: 119 additions & 66 deletions

File tree

packages/ns-api/README.md

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2732,35 +2732,38 @@ Response example:
27322732
{
27332733
"lan": {
27342734
"device": "br-lan",
2735-
"start": "",
2736-
"end": "",
27372735
"active": true,
27382736
"force": true,
27392737
"options": {
27402738
"leasetime": "12h",
27412739
"gateway": "192.168.100.1",
27422740
"domain": "nethserver.org",
27432741
"dns": "1.1.1.1 8.8.8.8",
2744-
"SIP ": "192.168.100.151"
2742+
"SIP": "192.168.100.151"
27452743
},
27462744
"zone": "lan",
2747-
"first": "192.168.100.2",
2748-
"last": "192.168.100.150",
2745+
"ranges": [
2746+
{
2747+
"first": "192.168.100.2",
2748+
"last": "192.168.100.150"
2749+
}
2750+
],
27492751
"ns_binding": 0
27502752
},
27512753
"blue": {
27522754
"device": "eth2.1",
2753-
"start": "",
2754-
"end": "",
27552755
"active": false,
27562756
"force": false,
27572757
"options": {},
27582758
"zone": "",
2759+
"ranges": [],
27592760
"ns_binding": 1
27602761
}
27612762
}
27622763
```
27632764

2765+
The `ranges` field contains the list of active DHCP ranges configured for the interface. Multiple ranges are supported.
2766+
27642767
### list-dhcp-options
27652768

27662769
List all supported DHCPv4 options:
@@ -2808,26 +2811,43 @@ Successfull response example:
28082811
"120": "192.168.100.151"
28092812
}
28102813
],
2811-
"first": "192.168.100.2",
2812-
"last": "192.168.100.150",
2814+
"ranges": [
2815+
{
2816+
"first": "192.168.100.2",
2817+
"last": "192.168.100.150"
2818+
},
2819+
{
2820+
"first": "192.168.100.200",
2821+
"last": "192.168.100.220"
2822+
}
2823+
],
28132824
"leasetime": "12h",
28142825
"active": true,
28152826
"force": true,
2816-
"ns_binding": 0
2827+
"ns_binding": 0,
2828+
"class_start_ip": "192.168.100.1",
2829+
"class_end_ip": "192.168.100.254"
28172830
}
28182831
```
28192832

28202833
Each element of the `options` array is a key-value object.
28212834
The key is the DHCP option name or number, the value is the option value.
28222835
Multiple values can be comma-separated.
28232836

2837+
The `ranges` field contains the list of configured DHCP ranges. Multiple ranges are supported.
2838+
The `class_start_ip` and `class_end_ip` fields represent the first and last usable IP addresses of the interface network (excluding network address and broadcast) and are used in the UI to check whether the entered IP is inside or outside the interface class range.
2839+
28242840
### edit-interface
28252841

2826-
Change or add the DHCPv4 configuration for a given interface:
2842+
Change or add the DHCPv4 configuration for a given interface. Multiple DHCP ranges are supported:
28272843
```
2828-
api-cli ns.dhcp edit-interface --data '{"interface":"lan","first":"192.168.100.2","last":"192.168.100.150","active":true,"leasetime": "12h","force":true,"options":[{"gateway":"192.168.100.1"},{"domain":"nethserver.org"},{"dns":"1.1.1.1,8.8.8.8"},{"120":"192.168.100.151"}], "ns_binding": 0}'
2844+
api-cli ns.dhcp edit-interface --data '{"interface":"lan","ranges":[{"first":"192.168.100.2","last":"192.168.100.150"},{"first":"192.168.100.200","last":"192.168.100.220"}],"active":true,"leasetime":"12h","force":true,"options":[{"gateway":"192.168.100.1"},{"domain":"nethserver.org"},{"dns":"1.1.1.1,8.8.8.8"},{"120":"192.168.100.151"}],"ns_binding":0}'
28292845
```
28302846

2847+
The `ranges` field is an array of objects, each with `first` and `last` IP address fields defining a DHCP range.
2848+
All ranges are saved with the same DHCP options, leasetime, and activation state.
2849+
Existing ranges for the interface are fully replaced on each call.
2850+
28312851
See [ns.dhcp get-interface][#get-interface] for the `options` array format.
28322852

28332853
Successfull response:
@@ -2840,6 +2860,13 @@ Error response example:
28402860
{"error": "interface_not_found"}
28412861
```
28422862

2863+
Validation errors:
2864+
- `first_is_empty`: the `first` IP address of a range is empty
2865+
- `last_is_empty`: the `last` IP address of a range is empty
2866+
- `ip_out_of_interface_network`: the `first` or `last` IP address is not within the interface network
2867+
- `last_must_be_greater_than_first`: the `last` IP address must be greater than `first`
2868+
- `invalid` (ns_binding): `ns_binding` value is not 0, 1, or 2
2869+
28432870
### list-active-leases
28442871

28452872
List active DHCPv4 leases:

packages/ns-api/files/ns.dhcp

Lines changed: 80 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/python3
22

33
#
4-
# Copyright (C) 2023 Nethesis S.r.l.
4+
# Copyright (C) 2026 Nethesis S.r.l.
55
# SPDX-License-Identifier: GPL-2.0-only
66
#
77

@@ -52,7 +52,6 @@ def list_custom_dhcp_options():
5252
continue
5353
return ret
5454

55-
5655
def list_dhcp_options():
5756
ret = {}
5857
pd = subprocess.run(["dnsmasq", "--help", "dhcp"], capture_output=True, text=True)
@@ -82,7 +81,7 @@ def list_interfaces():
8281
if not dhcps:
8382
dhcps = []
8483
for i in interfaces:
85-
record = {"device": "", "start": "", "end": "", "active": False, "options": {}, "force": False, "ns_binding": 0 }
84+
record = {"device": "", "active": False, "options": {}, "force": False, "ns_binding": 0, "ranges": [] }
8685
# skip loopback, bond devices, wans, aliases and interfaces with no IP address (e.g. DHCP interfaces)
8786
if i == "loopback" or i in wans or not ('ipaddr' in interfaces[i]) or interfaces[i].get('proto') == 'bonding' or interfaces[i].get('device', '').startswith('@'):
8887
continue
@@ -94,7 +93,7 @@ def list_interfaces():
9493
if 'interface' in ds and ds['interface'] == i:
9594
if 'ignore' in ds and ds['ignore'] == "1":
9695
continue
97-
(record['first'], record['last']) = conf_to_range(interfaces[i]['ipaddr'], interfaces[i]['netmask'], ds.get('start', 100), ds.get('limit', 150))
96+
(first, last) = conf_to_range(interfaces[i]['ipaddr'], interfaces[i]['netmask'], ds.get('start', 100), ds.get('limit', 150))
9897
record['active'] = ds.get('dhcpv4', 'server') == 'server'
9998
# handle existing active configurations:
10099
# - old default was force off, as OpenWrt
@@ -111,7 +110,7 @@ def list_interfaces():
111110
tmp = opt.split(",")
112111
tmp[0] = tmp[0].removeprefix('option:')
113112
record['options'][tmp[0]] = " ".join(tmp[1:])
114-
break
113+
record['ranges'].append({"first": first, "last": last})
115114
ret[i] = record
116115

117116
return ret
@@ -125,66 +124,89 @@ def edit_interface(args):
125124
# ns-binding validation, this check is more controller-safe
126125
if 'ns_binding' in args and args['ns_binding'] not in [0, 1, 2]:
127126
return utils.validation_error("ns_binding", "invalid", args["ns_binding"])
128-
# handle migrated dhcp config: issue #855
129-
dhcp_id = args['interface']
127+
128+
ipaddr = u.get('network', args['interface'], 'ipaddr')
129+
netmask = u.get('network', args['interface'], 'netmask')
130+
network = ipaddress.ip_network(f"{ipaddr}/{netmask}", strict=False)
131+
132+
# ranges validation
133+
for r in args['ranges']:
134+
r['first'] = r['first'].strip()
135+
r['last'] = r['last'].strip()
136+
if not r['first']:
137+
return utils.validation_error("first", "first_is_empty", r["first"])
138+
if not r['last']:
139+
return utils.validation_error("last", "last_is_empty", r["last"])
140+
first_ip = ipaddress.ip_address(r['first'])
141+
last_ip = ipaddress.ip_address(r['last'])
142+
if not (network.network_address < first_ip < network.broadcast_address):
143+
return utils.validation_error("first", "ip_out_of_interface_network", r["first"])
144+
if not (network.network_address < last_ip < network.broadcast_address):
145+
return utils.validation_error("last", "ip_out_of_interface_network", r["last"])
146+
if ip2int(r['last']) <= ip2int(r['first']):
147+
return utils.validation_error("last", "last_must_be_greater_than_first", r["last"])
148+
130149
dhcp_servers = utils.get_all_by_type(u, 'dhcp', 'dhcp')
131150
for server in dhcp_servers:
132151
if dhcp_servers[server]['interface'] == args['interface']:
133-
dhcp_id = server
152+
u.delete('dhcp', server)
153+
154+
for r in args['ranges']:
155+
dhcp_id = f"{args['interface']}_{utils.get_random_id()}"
156+
(start, limit) = range_to_conf(ipaddr, netmask, r["first"], r["last"])
157+
u.set("dhcp", dhcp_id, 'dhcp')
158+
if args['active']:
159+
u.set("dhcp", dhcp_id, 'dhcpv4', 'server')
160+
else:
161+
u.set("dhcp", dhcp_id, 'dhcpv4', 'disabled')
162+
u.set("dhcp", dhcp_id, "limit", limit)
163+
u.set("dhcp", dhcp_id, "start", start)
164+
u.set("dhcp", dhcp_id, "leasetime", args["leasetime"])
165+
u.set("dhcp", dhcp_id, "interface", args['interface'])
166+
u.set("dhcp", dhcp_id, "force", '1' if args.get('force', False) else '0')
167+
u.set("dhcp", dhcp_id, "instance", "ns_dnsmasq")
168+
if 'ns_binding' in args:
169+
u.set("dhcp", dhcp_id, "ns_binding", args.get('ns_binding'))
170+
u.set("dhcp", dhcp_id, "ignore", '0')
171+
opts = []
172+
for opt in args['options']:
173+
k = list(opt.keys())[0]
174+
v = opt[k].strip().rstrip(',')
175+
if v:
176+
if k.isdigit(): # custom option, no "option" prefix
177+
opts.append(f"{k},{v}")
178+
else:
179+
opts.append(f"option:{k},{v}")
180+
u.set("dhcp", dhcp_id, "dhcp_option", opts)
134181

135-
u.set("dhcp", dhcp_id, 'dhcp')
136-
ipaddr = u.get('network', args['interface'], 'ipaddr')
137-
netmask = u.get('network', args['interface'], 'netmask')
138-
args['first'] = args['first'].strip()
139-
args['last'] = args['last'].strip()
140-
if ip2int(args['first']) > ip2int(args['last']):
141-
return utils.validation_error("last", "last_must_be_greater_than_first", args["last"])
142-
(start, limit) = range_to_conf(ipaddr, netmask, args["first"].strip(), args["last"].strip())
143-
if args['active']:
144-
u.set("dhcp", dhcp_id, 'dhcpv4', 'server')
145-
else:
146-
u.set("dhcp", dhcp_id, 'dhcpv4', 'disabled')
147-
u.set("dhcp", dhcp_id, "limit", limit)
148-
u.set("dhcp", dhcp_id, "start", start)
149-
u.set("dhcp", dhcp_id, "leasetime", args["leasetime"])
150-
u.set("dhcp", dhcp_id, "interface", args['interface'])
151-
u.set("dhcp", dhcp_id, "force", '1' if args.get('force', False) else '0')
152-
u.set("dhcp", dhcp_id, "instance", "ns_dnsmasq")
153-
if 'ns_binding' in args:
154-
u.set("dhcp", dhcp_id, "ns_binding", args.get('ns_binding'))
155-
opts = []
156-
for opt in args['options']:
157-
k = list(opt.keys())[0]
158-
v = opt[k].strip().rstrip(',')
159-
if v:
160-
if k.isdigit(): # custom option, no "option" preffix
161-
opts.append(f"{k},{v}")
162-
else:
163-
opts.append(f"option:{k},{v}")
164-
u.set("dhcp", dhcp_id, "dhcp_option", opts)
165-
u.set("dhcp", dhcp_id, "ignore", '0')
166182
u.save("dhcp")
167183
return {"interface": args["interface"]}
168184

169185
def get_interface(args):
170186
u = EUci()
171-
ret = {"interface": args["interface"], "options": [], "first": "", "last": "", "active": False}
172-
try:
173-
items = utils.get_all_by_type(u, "dhcp", "dhcp")
174-
for i in items:
175-
if items[i].get('interface') == args['interface']:
176-
dhcp = u.get_all('dhcp', i)
177-
if not dhcp:
178-
raise Exception # fallback to default
179-
except:
180-
dhcp = {"start": 100, "limit": 150, "leasetime": "12h", "dhcpv4": "disabled", "dhcp_option": []}
187+
ret = {"interface": args["interface"], "options": [], "active": False, "ranges": []}
181188
try:
182189
interface = u.get_all("network", args["interface"])
183190
except:
184191
interface = {}
185192

193+
items = utils.get_all_by_type(u, "dhcp", "dhcp")
194+
dhcp_sections = []
195+
for i in items:
196+
if items[i].get('interface') == args['interface'] and items[i].get('ignore', '0') != '1':
197+
dhcp_sections.append(u.get_all('dhcp', i))
198+
199+
if not dhcp_sections:
200+
dhcp_sections = [{"start": 100, "limit": 150, "leasetime": "12h", "dhcpv4": "disabled", "dhcp_option": []}]
201+
202+
# get options from the first dhcp section since they are the same for all sections
203+
dhcp = dhcp_sections[0]
204+
186205
if 'ipaddr' in interface:
187-
(ret['first'], ret['last']) = conf_to_range(interface['ipaddr'], interface['netmask'], dhcp.get('start', 100), dhcp.get('limit', 150))
206+
for ds in dhcp_sections:
207+
(first, last) = conf_to_range(interface['ipaddr'], interface['netmask'], ds.get('start', 100), ds.get('limit', 150))
208+
ret['ranges'].append({"first": first, "last": last})
209+
188210
ret["leasetime"] = dhcp.get("leasetime")
189211
ret["active"] = dhcp.get("dhcpv4", "server") == "server"
190212
# handle existing active configurations:
@@ -201,6 +223,10 @@ def get_interface(args):
201223
tmp[0] = tmp[0].removeprefix('option:')
202224
ret["options"].append({tmp[0]: ",".join(tmp[1:])})
203225
ret['ns_binding'] = int(dhcp.get('ns_binding', 0))
226+
if 'ipaddr' in interface and 'netmask' in interface:
227+
network = ipaddress.ip_network(f"{interface['ipaddr']}/{interface['netmask']}", strict=False)
228+
ret['class_start_ip'] = str(network.network_address + 1)
229+
ret['class_end_ip'] = str(network.broadcast_address - 1)
204230

205231
return ret
206232

@@ -261,7 +287,6 @@ def is_reserved(u, mac, exclude_lease_id=''):
261287

262288
return False
263289

264-
265290
def count_ip_occurrences(e_uci, ip):
266291
occurrences = 0
267292
for item in utils.get_all_by_type(e_uci, 'dhcp', 'host'):
@@ -272,7 +297,6 @@ def count_ip_occurrences(e_uci, ip):
272297

273298
return occurrences
274299

275-
276300
def add_static_lease(args):
277301
u = EUci()
278302
if is_reserved(u, args["macaddr"]):
@@ -346,8 +370,10 @@ if cmd == 'list':
346370
"get-interface": {"interface": "lan"},
347371
"edit-interface": {
348372
"interface": "lan",
349-
"first": "192.168.100.2",
350-
"last": "192.168.100.150",
373+
"ranges": [
374+
{"first": "192.168.100.2", "last": "192.168.100.150"},
375+
{"first": "192.168.100.200", "last": "192.168.100.220"}
376+
],
351377
"leasetime": "12h",
352378
"active": True,
353379
"force": True,

0 commit comments

Comments
 (0)