From d343ec2583e0ce4c639a6b7bedd7bb96e1975e69 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Tue, 11 Nov 2025 14:23:57 +0100 Subject: [PATCH] feat: add MAC address provisioning for secondary ethernet port - Added step_set_eth1_mac() to generate and configure random locally administered MAC addresses - Made --name argument optional and conditional based on selected provisioning steps - Updated documentation to include MAC configuration in production deployment checklist --- README.md | 1 + src/provision.py | 72 ++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index de95ff4..c6eb326 100644 --- a/README.md +++ b/README.md @@ -50,4 +50,5 @@ For production, the devices need to be provisoned uniquely - set channel name etc. in bumble-auracast/src/auracast/.env - execute the update service scripts - start the application (script if custom device, server and frontend if ui version) + - set mac add of secondary eth port in /etc/systemd/network/10-eth1-mac.link - activate overlayfs (?) -probably not because we need persistent storage for stream states diff --git a/src/provision.py b/src/provision.py index b757005..3ae1454 100644 --- a/src/provision.py +++ b/src/provision.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import ipaddress, os, re, subprocess, tempfile, json, datetime, shlex +import secrets from pathlib import Path from dotenv import load_dotenv @@ -101,6 +102,48 @@ def step_wireguard_provision(iot_host: str, client_name: str): scp_and_enable(iot_host, cfg) return {"wg_name": name, "wg_iface": iface} +def step_set_eth1_mac(iot_host: str): + """Generate a random locally administered MAC (02:xx:xx:xx:xx:xx) for eth1 and + configure it via systemd .link on the remote device. Prints that a reboot is required. + + Returns a dict with the chosen mac. + """ + mac = "02:" + ":".join(f"{secrets.randbelow(256):02x}" for _ in range(5)) + + remote = ( + "set -e\n" + "sudo mkdir -p /etc/systemd/network\n" + "sudo tee /etc/systemd/network/10-eth1-mac.link >/dev/null <<'EOF'\n" + "[Match]\n" + "Driver=smsc95xx\n" + "\n" + "[Link]\n" + f"MACAddress={mac}\n" + "EOF\n" + ) + + ssh_cmd = ["ssh", "-p", str(SSH_PORT)] + if SSH_KEY: + ssh_cmd += ["-i", SSH_KEY] + ssh_cmd += [f"{SSH_USER}@{iot_host}", remote] + + proc = subprocess.run(ssh_cmd, check=False, capture_output=True, text=True) + stdout = (proc.stdout or "").strip() + stderr = (proc.stderr or "").strip() + + if proc.returncode != 0: + print(f"❌ set eth1 mac: failed rc={proc.returncode}: {stderr}") + else: + print(f"✅ set eth1 mac: configured {mac} at /etc/systemd/network/10-eth1-mac.link") + print("â„šī¸ Reboot is required for the MAC change to take effect.") + + return { + "rc": proc.returncode, + "mac": mac, + "out": stdout[-500:], + "err": stderr[-500:], + } + def step_set_hostname(iot_host: str, hostname: str | None): """Set hostname on the device by running the project's provision script over SSH. @@ -326,11 +369,11 @@ def main(): ) ) ap.add_argument("iot_host", help="Local/LAN IP or hostname of the IoT device reachable via SSH") - ap.add_argument("-n", "--name", required=True, help="Device name to use for both hostname and WireGuard client") + ap.add_argument("-n", "--name", required=False, help="Device name to use for both hostname and WireGuard client") ap.add_argument( "--steps", nargs="+", - choices=["pull", "wg", "hostname", "update_app", "start_app", "finish", "all"], + choices=["pull", "wg", "hostname", "mac", "update_app", "start_app", "finish", "all"], default=["all"], help="Which steps to run. Default: all", ) @@ -343,15 +386,20 @@ def main(): # Validate wg-easy env get_env_auth() - # Derive device name: if numeric, prefix with 'summitwave-beacon' - name = args.name - if re.fullmatch(r"\d+", name): - name = f"summitwave-beacon{name}" - # Normalize steps steps = args.steps if "all" in steps: - steps = ["pull", "hostname", "wg", "update_app", "start_app", "finish"] + steps = ["pull", "hostname", "mac", "wg", "update_app", "start_app", "finish"] + + # Validate required args per step + name = args.name + if any(s in steps for s in ("wg", "hostname")) and not name: + print("error: --name is required for steps: wg, hostname") + raise SystemExit(2) + + # Derive device name: if numeric, prefix with 'summitwave-beacon' + if name and re.fullmatch(r"\d+", name): + name = f"summitwave-beacon{name}" # Gather device facts once (may change after hostname step, but we at least log the initial state) facts = get_device_facts(args.iot_host) @@ -382,6 +430,14 @@ def main(): **wg_info, }) + if "mac" in steps: + mac_info = step_set_eth1_mac(args.iot_host) + write_provision_log({ + "action": "mac", + **get_device_facts(args.iot_host), + **mac_info, + }) + if "update_app" in steps: upd_info = step_update_app(args.iot_host) write_provision_log({