STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
generate_dhcp_config.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Generate dnsmasq DHCP config from the DAQ board registry.
3
4The board LAN is an isolated segment (unmanaged switch + wifi bridge, no router)
5that the DAQ server owns end-to-end, so the server runs dnsmasq as the sole DHCP
6authority. Each board's static IP is a *reservation* keyed by its MAC; unknown
7boards get a lease from a small dynamic pool so their MAC can be discovered and
8promoted to a reservation.
9
10Single source of truth is the `[boards.*]` table in the active DAQ config
11(the same TOML the rest of the stack reads): each board already carries `ip`
12and `board_id`; this reads the `mac` field added alongside them. IP is handed
13out unchanged (== 192.168.2.<board_id>), so DHCP replaces *how* a board gets its
14address, not *which* address — nothing downstream renumbers.
15
16Emits two files into --out-dir:
17 star-dhcp.conf stable base config (interface, pool, options); DHCP-only.
18 star-dhcp.hosts MAC->IP reservations, one per board that has a mac.
19
20--gui-reservations additionally writes the Test-GUI's reservations.json from
21the same table. The GUI carries its own bench DHCP server for stands with no
22DAQ host; pointing both at this registry keeps them from disagreeing about
23which board owns which address. dnsmasq here remains the authority -- the two
24must never serve the same wire at once.
25
26The systemd unit points dnsmasq at star-dhcp.conf, which references the hosts
27file via dhcp-hostsfile. Reservations reload on SIGHUP (systemctl reload) with
28no service bounce; base-config changes need a restart.
29
30--hosts-only rewrites just the reservations, atomically. star-dhcp-sync.path
31runs that (as root, from a root-owned copy of this script) whenever the
32deployed config.toml changes, then reloads dnsmasq -- so saving a board's `mac =`
33in the config editor is the whole procedure. A config this refuses leaves the
34served reservations exactly as they were.
35"""
36
37from __future__ import annotations
38
39import argparse
40import ipaddress
41import json
42import os
43import re
44import subprocess
45import sys
46from pathlib import Path
47
48try:
49 import tomllib as _toml # stdlib since Python 3.11
50except ModuleNotFoundError: # pragma: no cover - older Python on the Jetson
51 import tomli as _toml # type: ignore[no-redef]
52
53# Board LAN NIC on the DAQ host: by default, whichever interface holds an
54# address in the board subnet (star-rfs: enp4s0, 192.168.2.20/24). This used to
55# be a hardcoded adapter name from a laptop, which on any other host made
56# dnsmasq crash-loop. Override with --interface or $DHCP_INTERFACE.
57ENV_INTERFACE = os.environ.get("DHCP_INTERFACE")
58DEFAULT_POOL_START = 200
59DEFAULT_POOL_END = 250
60DEFAULT_LEASE = "2h"
61DEFAULT_LEASEFILE = "/var/lib/misc/star-dhcp.leases"
62
63_MAC_RE = re.compile(r"^([0-9a-f]{2}:){5}[0-9a-f]{2}$")
64
65
66def _die(msg: str) -> "NoReturn": # type: ignore[name-defined]
67 print(f"error: {msg}", file=sys.stderr)
68 raise SystemExit(1)
69
70
71def _warn(msg: str) -> None:
72 print(f"warning: {msg}", file=sys.stderr)
73
74
75def normalize_mac(raw: str) -> str | None:
76 """Return a lowercase colon-form MAC, or None if `raw` isn't a MAC.
77
78 Accepts colon- or hyphen-separated, any case. Blank -> None (no reservation
79 yet, not an error)."""
80 s = raw.strip().lower().replace("-", ":")
81 if not s:
82 return None
83 if not _MAC_RE.match(s):
84 _die(f"malformed MAC {raw!r} (want aa:bb:cc:dd:ee:ff)")
85 return s
86
87
88def resolve_subnet(cfg: dict) -> ipaddress.IPv4Network:
89 """Board-LAN subnet. Prefer [discovery].subnet, fall back to
90 [system.network].base_ip, else derive from the board IPs."""
91 disc = cfg.get("discovery", {})
92 if isinstance(disc, dict) and disc.get("subnet"):
93 return ipaddress.ip_network(disc["subnet"], strict=False)
94 net = cfg.get("system", {}).get("network", {}) if isinstance(cfg.get("system"), dict) else {}
95 if net.get("base_ip"):
96 return ipaddress.ip_network(f"{net['base_ip']}/24", strict=False)
97 ips = [b["ip"] for b in cfg.get("boards", {}).values() if isinstance(b, dict) and b.get("ip")]
98 if not ips:
99 _die("cannot determine board subnet: no [discovery].subnet, "
100 "[system.network].base_ip, or [boards.*].ip in config")
101 return ipaddress.ip_network(f"{ipaddress.ip_address(ips[0])}/24", strict=False)
102
103
104def detect_interface(subnet: ipaddress.IPv4Network) -> str:
105 """The interface holding an address in `subnet` (`ip -o -4 addr show`)."""
106 try:
107 out = subprocess.run(["ip", "-o", "-4", "addr", "show"], capture_output=True,
108 text=True, check=True).stdout
109 except (OSError, subprocess.CalledProcessError) as exc:
110 _die(f"cannot list interfaces ({exc}); pass --interface")
111 found = interfaces_in_subnet(out, subnet)
112 if len(found) != 1:
113 _die(f"expected one interface with an address in {subnet}, found "
114 f"{found or 'none'}; pass --interface (or set DHCP_INTERFACE)")
115 return found[0]
116
117
118def interfaces_in_subnet(ip_addr_output: str, subnet: ipaddress.IPv4Network) -> list[str]:
119 """Parse `ip -o -4 addr show`: names of interfaces with an address in subnet."""
120 found: list[str] = []
121 for line in ip_addr_output.splitlines():
122 parts = line.split()
123 if len(parts) < 4 or parts[2] != "inet":
124 continue
125 try:
126 addr = ipaddress.ip_interface(parts[3]).ip
127 except ValueError:
128 continue
129 if addr in subnet and parts[1] not in found:
130 found.append(parts[1])
131 return found
132
133
134def write_atomic(path: Path, text: str) -> None:
135 """dnsmasq may re-read at any moment; it must never see half a file."""
136 tmp = path.with_name(path.name + ".tmp")
137 tmp.write_text(text)
138 os.chmod(tmp, 0o644)
139 os.replace(tmp, path)
140
141
142def recovery_address(mac: str) -> str:
143 """Where a board sits when no DHCP server answered it:
144 169.254.<1 + mac[4] % 254>.<mac[5]>. Must match BoardNet::recoveryAddress
145 (firmware/Hotfire_Code/common/board_net.h) and firmware/tools/ota_upload.py."""
146 b = [int(x, 16) for x in mac.split(":")]
147 return f"169.254.{1 + b[4] % 254}.{b[5]}"
148
149
150def collect_reservations(cfg: dict, subnet: ipaddress.IPv4Network) -> list[tuple[str, str, str]]:
151 """(name, mac, ip) per board that has a usable mac. Boards without a mac are
152 skipped with a warning — that's the normal pre-discovery state."""
153 boards = cfg.get("boards", {})
154 if not isinstance(boards, dict) or not boards:
155 _warn("no [boards.*] table in config — emitting an empty reservations file")
156 return []
157
158 reservations: list[tuple[str, str, str]] = []
159 seen_mac: dict[str, str] = {}
160 seen_ip: dict[str, str] = {}
161 seen_recovery: dict[str, str] = {}
162 for name, b in boards.items():
163 if not isinstance(b, dict):
164 continue
165 ip = b.get("ip")
166 if not ip:
167 _warn(f"board {name!r} has no ip — skipped")
168 continue
169 mac = normalize_mac(str(b.get("mac", "")))
170 if mac is None:
171 _warn(f"board {name!r} ({ip}) has no mac yet — no static lease; "
172 "plug it in, read its MAC from the lease table, then fill it in")
173 continue
174 if ipaddress.ip_address(ip) not in subnet:
175 _die(f"board {name!r} ip {ip} is outside subnet {subnet}")
176 if mac in seen_mac:
177 _die(f"duplicate MAC {mac} on {name!r} and {seen_mac[mac]!r}")
178 if ip in seen_ip:
179 _die(f"duplicate IP {ip} on {name!r} and {seen_ip[ip]!r}")
180 # The board takes its ID from the address it is given (board_net.h), so
181 # a board_id that disagrees with the address is a board the server will
182 # file under the wrong ID.
183 bid = b.get("board_id")
184 if bid is not None and int(bid) != int(ip.rsplit(".", 1)[1]):
185 _die(f"board {name!r} has board_id {bid} but ip {ip}: the board takes "
186 f"its ID from its address, so these must agree")
187 rec = recovery_address(mac)
188 if rec in seen_recovery:
189 _die(f"{name!r} and {seen_recovery[rec]!r} would share the recovery "
190 f"address {rec} (their MACs end alike), so neither could be "
191 f"reflashed there if DHCP failed")
192 seen_recovery[rec] = name
193 seen_mac[mac] = name
194 seen_ip[ip] = name
195 reservations.append((name, mac, ip))
196 return reservations
197
198
199def sanitize_hostname(name: str) -> str:
200 """dnsmasq accepts a hostname label per reservation; keep it DNS-safe."""
201 h = re.sub(r"[^a-zA-Z0-9-]", "-", name).strip("-")
202 return h[:63] or "board"
203
204
205def build_hosts(reservations: list[tuple[str, str, str]]) -> str:
206 lines = [
207 "# GENERATED by generate_dhcp_config.py — do not edit by hand.",
208 "# Reservations reload on SIGHUP (systemctl reload star-dhcp). One per line:",
209 "# <mac>,<ip>,<hostname>,infinite",
210 "",
211 ]
212 for name, mac, ip in reservations:
213 lines.append(f"{mac},{ip},{sanitize_hostname(name)},infinite")
214 lines.append("")
215 return "\n".join(lines)
216
217
218def build_gui_reservations(reservations: list[tuple[str, str, str]],
219 boards: dict) -> str:
220 """The Test-GUI's reservations.json, from the same registry.
221
222 Schema is boardgui.dhcp.Reservation.to_json(): mac, ip, board_id, label.
223 Written sorted by IP so regenerating produces no spurious diffs.
224 """
225 entries = []
226 for name, mac, ip in reservations:
227 b = boards.get(name, {})
228 entries.append({
229 "mac": mac,
230 "ip": ip,
231 "board_id": b.get("board_id"),
232 "label": name,
233 })
234 entries.sort(key=lambda e: ipaddress.ip_address(e["ip"]))
235 payload = {
236 "_generated": "by daq-server/deploy/dhcp/generate_dhcp_config.py "
237 "--gui-reservations; edit [boards.*] in the DAQ config "
238 "instead. daq-server's dnsmasq is the DHCP authority.",
239 "reservations": entries,
240 }
241 return json.dumps(payload, indent=2) + "\n"
242
243
244def build_conf(*, interface: str, subnet: ipaddress.IPv4Network,
245 pool_start: int, pool_end: int, lease: str,
246 hostsfile: Path, leasefile: str) -> str:
247 hosts = list(subnet.hosts())
248 base = ".".join(str(subnet.network_address).split(".")[:3]) # /24 prefix
249 pool_lo = f"{base}.{pool_start}"
250 pool_hi = f"{base}.{pool_end}"
251 if ipaddress.ip_address(pool_lo) not in subnet or ipaddress.ip_address(pool_hi) not in subnet:
252 _die(f"pool {pool_lo}-{pool_hi} is outside subnet {subnet}")
253 netmask = str(subnet.netmask)
254 return "\n".join([
255 "# GENERATED by generate_dhcp_config.py — do not edit by hand.",
256 "# DHCP-only server for the isolated board LAN. dnsmasq is the sole DHCP",
257 "# authority on this segment; there is no router and no DNS here.",
258 "",
259 "# No DNS listener — DHCP only.",
260 "port=0",
261 "",
262 f"# Serve ONLY the board NIC; never touch any other interface.",
263 f"interface={interface}",
264 "bind-interfaces",
265 "except-interface=lo",
266 "",
267 "# Sole DHCP server on this segment -> answer authoritatively.",
268 "dhcp-authoritative",
269 "",
270 "# Hand out IP + netmask only. Suppress the router (opt 3) and DNS (opt 6)",
271 "# options: this is a flat L2 island with nothing to route to.",
272 "dhcp-option=3",
273 "dhcp-option=6",
274 "",
275 f"# Dynamic pool for unknown boards (clear of statics and the .100-.150",
276 f"# discovery range). Read a new board's MAC from the lease table, then",
277 f"# promote it to a reservation in [boards.*].",
278 f"dhcp-range={pool_lo},{pool_hi},{netmask},{lease}",
279 "",
280 "# MAC->IP reservations (infinite lease); regenerated from [boards.*].",
281 f"dhcp-hostsfile={hostsfile}",
282 "",
283 f"dhcp-leasefile={leasefile}",
284 "log-dhcp",
285 "",
286 ])
287
288
289def main(argv: list[str] | None = None) -> int:
290 ap = argparse.ArgumentParser(description=__doc__,
291 formatter_class=argparse.RawDescriptionHelpFormatter)
292 default_cfg = os.environ.get("DAQ_CONFIG", "config/config.toml")
293 ap.add_argument("--config", default=default_cfg,
294 help=f"DAQ config TOML (default: $DAQ_CONFIG or {default_cfg})")
295 ap.add_argument("--out-dir", default="/etc/star-dhcp", type=Path,
296 help="where to write star-dhcp.conf and star-dhcp.hosts "
297 "(default: /etc/star-dhcp)")
298 ap.add_argument("--interface", default=ENV_INTERFACE,
299 help="board-LAN NIC to serve (default: $DHCP_INTERFACE, else "
300 "the interface with an address in the board subnet)")
301 ap.add_argument("--hosts-only", action="store_true",
302 help="rewrite only star-dhcp.hosts (atomically), for "
303 "star-dhcp-sync; the base config is left alone")
304 ap.add_argument("--pool-start", type=int, default=DEFAULT_POOL_START,
305 help=f"last octet of pool start (default: {DEFAULT_POOL_START})")
306 ap.add_argument("--pool-end", type=int, default=DEFAULT_POOL_END,
307 help=f"last octet of pool end (default: {DEFAULT_POOL_END})")
308 ap.add_argument("--lease", default=DEFAULT_LEASE,
309 help=f"dynamic-pool lease time (default: {DEFAULT_LEASE})")
310 ap.add_argument("--leasefile", default=DEFAULT_LEASEFILE,
311 help=f"dnsmasq lease db path (default: {DEFAULT_LEASEFILE})")
312 ap.add_argument("--gui-reservations", type=Path, default=None,
313 help="also write the Test-GUI's reservations.json here, "
314 "from the same [boards.*] table (e.g. "
315 "Test-GUI/config/reservations.json)")
316 ap.add_argument("--check", action="store_true",
317 help="validate and print to stdout without writing files")
318 args = ap.parse_args(argv)
319
320 cfg_path = Path(args.config)
321 if not cfg_path.is_file():
322 _die(f"config not found: {cfg_path} (cwd {Path.cwd()})")
323 with cfg_path.open("rb") as fh:
324 cfg = _toml.load(fh)
325
326 subnet = resolve_subnet(cfg)
327 reservations = collect_reservations(cfg, subnet)
328
329 # Reservations must not sit inside the dynamic pool, or dnsmasq could hand a
330 # board's reserved address to someone else.
331 base = ".".join(str(subnet.network_address).split(".")[:3])
332 pool = {ipaddress.ip_address(f"{base}.{o}")
333 for o in range(args.pool_start, args.pool_end + 1)}
334 for name, _mac, ip in reservations:
335 if ipaddress.ip_address(ip) in pool:
336 _die(f"board {name!r} ip {ip} falls inside the dynamic pool "
337 f"{base}.{args.pool_start}-{args.pool_end}")
338
339 hostsfile = (args.out_dir / "star-dhcp.hosts").resolve()
340 hosts = build_hosts(reservations)
341 if args.hosts_only:
342 args.out_dir.mkdir(parents=True, exist_ok=True)
343 write_atomic(hostsfile, hosts)
344 print(f"wrote {hostsfile}: {len(reservations)} reservation(s) from {cfg_path}")
345 return 0
346
347 if not args.interface:
348 args.interface = detect_interface(subnet)
349 conf = build_conf(interface=args.interface, subnet=subnet,
350 pool_start=args.pool_start, pool_end=args.pool_end,
351 lease=args.lease, hostsfile=hostsfile,
352 leasefile=args.leasefile)
353
354 if args.check:
355 print("# ===== star-dhcp.conf =====")
356 print(conf)
357 print("# ===== star-dhcp.hosts =====")
358 print(hosts, end="")
359 if args.gui_reservations:
360 print(f"# ===== {args.gui_reservations} =====")
361 print(build_gui_reservations(reservations, cfg.get("boards", {})),
362 end="")
363 print(f"# {len(reservations)} reservation(s), subnet {subnet}, "
364 f"pool {base}.{args.pool_start}-{args.pool_end}", file=sys.stderr)
365 return 0
366
367 args.out_dir.mkdir(parents=True, exist_ok=True)
368 write_atomic(args.out_dir / "star-dhcp.conf", conf)
369 write_atomic(hostsfile, hosts)
370 print(f"wrote {args.out_dir/'star-dhcp.conf'} and {hostsfile}")
371 if args.gui_reservations:
372 args.gui_reservations.parent.mkdir(parents=True, exist_ok=True)
373 args.gui_reservations.write_text(
374 build_gui_reservations(reservations, cfg.get("boards", {})))
375 print(f"wrote {args.gui_reservations} (Test-GUI bench server)")
376 print(f"{len(reservations)} reservation(s), subnet {subnet}, "
377 f"pool {base}.{args.pool_start}-{args.pool_end}, lease {args.lease}")
378 if not reservations:
379 print("note: no reservations yet — every board will get a pool lease "
380 "until you fill in [boards.*].mac", file=sys.stderr)
381 return 0
382
383
384if __name__ == "__main__":
385 raise SystemExit(main())
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
Definition json.hpp:14471
list[str] interfaces_in_subnet(str ip_addr_output, ipaddress.IPv4Network subnet)
"NoReturn" _die(str msg)
str build_conf(*, str interface, ipaddress.IPv4Network subnet, int pool_start, int pool_end, str lease, Path hostsfile, str leasefile)
None write_atomic(Path path, str text)
list[tuple[str, str, str]] collect_reservations(dict cfg, ipaddress.IPv4Network subnet)
ipaddress.IPv4Network resolve_subnet(dict cfg)
str build_gui_reservations(list[tuple[str, str, str]] reservations, dict boards)
str detect_interface(ipaddress.IPv4Network subnet)
str|None normalize_mac(str raw)
str build_hosts(list[tuple[str, str, str]] reservations)
int main()