""" mod_networkd.py -- systemd-networkd configuration for VLAN interfaces. Generates .netdev and .network files for each VLAN, removes legacy conflicting files, and triggers a networkd reload when content changes. """ import subprocess from pathlib import Path import mod_shared as shared import mod_validation as validation NETWORKD_DIR = Path("/etc/systemd/network") # =================================================================== # Build # =================================================================== def build_netdev(vlan, vid, iface): return "\n".join([ "# Generated by core.py -- do not edit manually.", "# Edit config.json and re-run: sudo python3 core.py --apply", "", "[NetDev]", f"Name={iface}", "Kind=vlan", "", "[VLAN]", f"Id={vid}", "", ]) def build_network(vlan, vid, iface, all_vlan_ids): network = shared.network_for(vlan) prefix = network.prefixlen lines = [ "# Generated by core.py -- do not edit manually.", "# Edit config.json and re-run: sudo python3 core.py --apply", "", "[Match]", f"Name={iface}", "", "[Network]", "DHCP=no", "LinkLocalAddressing=no", ] for ident in vlan["server_identities"]: lines.append(f"# {ident['description']}") lines.append(f"Address={ident['ip']}/{prefix}") if shared.is_physical(vlan): lines.append("") for v in all_vlan_ids: if v != 1: lines.append(f"VLAN={iface}.{v}") lines.append("") return "\n".join(lines) # =================================================================== # Apply # =================================================================== def find_legacy_files(managed_interfaces): to_remove = [] for pattern in ("*.network", "*.netdev"): for f in NETWORKD_DIR.glob(pattern): if f.name.startswith(f"10-{shared.PRODUCT_NAME}-"): continue try: content = f.read_text() except OSError: continue for iface in managed_interfaces: if f"Name={iface}" in content: to_remove.append(f) break return to_remove def apply_networkd(data, dry_run=False, only_if_changed=False): """Write systemd-networkd files and reload. If only_if_changed=True, write files only when content differs from disk and skip the networkd reload if nothing changed. Used by --apply mode. """ all_vlan_ids = [v.get('vlan_id') for v in data["vlans"] if not validation.is_wg(v)] managed_ifaces = [validation.derive_interface(v, data) for v in data["vlans"]] changed = False legacy = find_legacy_files(managed_ifaces) if legacy: print("Removing legacy networkd files:") for f in legacy: if not dry_run: f.unlink() changed = True print(f" {'[dry-run] would remove' if dry_run else 'Removed'}: {f}") print() for vlan in data["vlans"]: if validation.is_wg(vlan): continue iface = validation.derive_interface(vlan, data) vid = vlan.get('vlan_id') stem = shared.networkd_stem(vlan) if not shared.is_physical(vlan): netdev_path = NETWORKD_DIR / f"{stem}.netdev" netdev_content = build_netdev(vlan, vid, iface) if dry_run: print(f"# -- {netdev_path} (dry-run) --") print(netdev_content) else: existing = netdev_path.read_text() if netdev_path.exists() else None if existing != netdev_content: netdev_path.write_text(netdev_content) print(f"Written: {netdev_path}") changed = True elif not only_if_changed: print(f"Unchanged: {netdev_path}") network_path = NETWORKD_DIR / f"{stem}.network" network_content = build_network(vlan, vid, iface, all_vlan_ids) if dry_run: print(f"# -- {network_path} (dry-run) --") print(network_content) else: existing = network_path.read_text() if network_path.exists() else None if existing != network_content: network_path.write_text(network_content) print(f"Written: {network_path}") changed = True elif not only_if_changed: print(f"Unchanged: {network_path}") if not dry_run: if changed: print("Reloading systemd-networkd...") result = subprocess.run( ["networkctl", "reload"], capture_output=True, text=True ) if result.returncode != 0: print(f"WARNING: networkctl reload returned non-zero:\n{result.stderr.strip()}") else: print("systemd-networkd reloaded.") elif only_if_changed: print("systemd-networkd: no changes. Good.")