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")
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):
167 _warn(f
"board {name!r} has no ip — skipped")
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")
174 if ipaddress.ip_address(ip)
not in subnet:
175 _die(f
"board {name!r} ip {ip} is outside subnet {subnet}")
177 _die(f
"duplicate MAC {mac} on {name!r} and {seen_mac[mac]!r}")
179 _die(f
"duplicate IP {ip} on {name!r} and {seen_ip[ip]!r}")
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")
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
195 reservations.append((name, mac, ip))
219 boards: dict) -> str:
220 """The Test-GUI's reservations.json, from the same registry.
222 Schema is boardgui.dhcp.Reservation.to_json(): mac, ip, board_id, label.
223 Written sorted by IP so regenerating produces no spurious diffs.
226 for name, mac, ip
in reservations:
227 b = boards.get(name, {})
231 "board_id": b.get(
"board_id"),
234 entries.sort(key=
lambda e: ipaddress.ip_address(e[
"ip"]))
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,
241 return json.dumps(payload, indent=2) +
"\n"
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])
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)
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.",
259 "# No DNS listener — DHCP only.",
262 f
"# Serve ONLY the board NIC; never touch any other interface.",
263 f
"interface={interface}",
265 "except-interface=lo",
267 "# Sole DHCP server on this segment -> answer authoritatively.",
268 "dhcp-authoritative",
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.",
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}",
280 "# MAC->IP reservations (infinite lease); regenerated from [boards.*].",
281 f
"dhcp-hostsfile={hostsfile}",
283 f
"dhcp-leasefile={leasefile}",
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)
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:
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}")
339 hostsfile = (args.out_dir /
"star-dhcp.hosts").resolve()
342 args.out_dir.mkdir(parents=
True, exist_ok=
True)
344 print(f
"wrote {hostsfile}: {len(reservations)} reservation(s) from {cfg_path}")
347 if not args.interface:
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)
355 print(
"# ===== star-dhcp.conf =====")
357 print(
"# ===== star-dhcp.hosts =====")
359 if args.gui_reservations:
360 print(f
"# ===== {args.gui_reservations} =====")
363 print(f
"# {len(reservations)} reservation(s), subnet {subnet}, "
364 f
"pool {base}.{args.pool_start}-{args.pool_end}", file=sys.stderr)
367 args.out_dir.mkdir(parents=
True, exist_ok=
True)
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(
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}")
379 print(
"note: no reservations yet — every board will get a pool lease "
380 "until you fill in [boards.*].mac", file=sys.stderr)
str build_conf(*, str interface, ipaddress.IPv4Network subnet, int pool_start, int pool_end, str lease, Path hostsfile, str leasefile)