diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..637dded --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +setup-network/build/ +setup-network/dist/ +setup-network/*.spec +setup-network/.venv/ diff --git a/README.md b/README.md index e69de29..14cadf6 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,60 @@ +# retro-alcoi + +Tools for configuring PCs at a retro gaming fair. + +## Utilities + +### setup-network + +Assigns a static IP address and hostname to a Debian 13 machine based on a +machine number (1–254). Designed for an isolated local network with no gateway +or DNS required. + +``` +sudo ./setup-network +``` + +Example: + +``` +sudo ./setup-network 4 +# → IP: 10.0.1.4/24, hostname: retro-alcoi-4 +``` + +See [`setup-network/`](setup-network/) for source and build instructions. + +#### Quick start + +```bash +cd setup-network/ +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +bash build.sh +# Binary is at dist/setup-network +``` + +Copy `dist/setup-network` and `dist/config.ini` to a USB stick, then on each +fair PC: + +```bash +sudo ./setup-network 4 +``` + +#### Configuration + +Edit `config.ini` (alongside the binary) before distributing: + +```ini +[network] +subnet_root = 10.0.1 +hostname_prefix = retro-alcoi- +``` + +#### How it works + +1. Detects the first wired Ethernet interface (skips WiFi, bridges, veth). +2. Sets the hostname via `hostnamectl` and updates `/etc/hosts`. +3. Configures the static IP via `nmcli` if available, otherwise falls back to + `/etc/network/interfaces`. + +Re-running with the same or a different machine number is safe (idempotent). diff --git a/setup-network/__pycache__/setup_network.cpython-313.pyc b/setup-network/__pycache__/setup_network.cpython-313.pyc new file mode 100644 index 0000000..12ec1ad Binary files /dev/null and b/setup-network/__pycache__/setup_network.cpython-313.pyc differ diff --git a/setup-network/build.sh b/setup-network/build.sh new file mode 100755 index 0000000..0803dfe --- /dev/null +++ b/setup-network/build.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Navigate to setup-network/ regardless of where we're called from +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Activate virtualenv if present, otherwise require pyinstaller in PATH +if [[ -f .venv/bin/activate ]]; then + source .venv/bin/activate + echo "Activated .venv" +elif ! command -v pyinstaller &>/dev/null; then + echo "Error: pyinstaller not found. Either create a .venv or install pyinstaller." >&2 + echo " python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" >&2 + exit 1 +fi + +echo "Building setup-network binary..." +pyinstaller --onefile --clean --name setup-network setup_network.py + +echo "Copying config.ini to dist/..." +cp config.ini dist/ + +echo "" +echo "Build complete. Output:" +echo " ${SCRIPT_DIR}/dist/setup-network" +echo " ${SCRIPT_DIR}/dist/config.ini" diff --git a/setup-network/config.ini b/setup-network/config.ini new file mode 100644 index 0000000..ca198c1 --- /dev/null +++ b/setup-network/config.ini @@ -0,0 +1,3 @@ +[network] +subnet_root = 10.0.1 +hostname_prefix = retro-alcoi- diff --git a/setup-network/requirements.txt b/setup-network/requirements.txt new file mode 100644 index 0000000..ef376ca --- /dev/null +++ b/setup-network/requirements.txt @@ -0,0 +1 @@ +pyinstaller diff --git a/setup-network/setup_network.py b/setup-network/setup_network.py new file mode 100755 index 0000000..a0a7b0e --- /dev/null +++ b/setup-network/setup_network.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +""" +setup-network: Assign a static IP and hostname to a Debian 13 machine +based on a machine number (1-254) for use at a retro gaming fair. + +Usage: sudo ./setup-network +Example: sudo ./setup-network 4 + → IP: 10.0.1.4/24, hostname: retro-alcoi-4 +""" + +import argparse +import configparser +import os +import re +import shutil +import subprocess +import sys + + +def get_script_dir(): + """Return the directory containing the script or binary.""" + if getattr(sys, "frozen", False): + return os.path.dirname(sys.executable) + return os.path.dirname(os.path.abspath(__file__)) + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Assign a static IP and hostname based on a machine number." + ) + parser.add_argument( + "machine_num", + type=int, + help="Machine number (1-254). Sets IP to ./24 " + "and hostname to .", + ) + args = parser.parse_args() + if not 1 <= args.machine_num <= 254: + parser.error("machine_num must be between 1 and 254") + return args + + +def check_root(): + if os.geteuid() != 0: + print("Error: this script must be run as root (use sudo).", file=sys.stderr) + sys.exit(1) + + +def load_config(): + config_path = os.path.join(get_script_dir(), "config.ini") + cfg = configparser.ConfigParser() + cfg.read(config_path) + subnet_root = cfg.get("network", "subnet_root", fallback="10.0.1") + hostname_prefix = cfg.get("network", "hostname_prefix", fallback="retro-alcoi-") + return subnet_root, hostname_prefix + + +def detect_wired_iface(): + """Detect the first wired (non-virtual, non-bridge, non-WiFi) Ethernet interface.""" + net_dir = "/sys/class/net" + candidates = [] + preferred = [] + + for name in os.listdir(net_dir): + iface_dir = os.path.join(net_dir, name) + + # Skip virtual/loopback + if name.startswith("veth") or name == "lo": + continue + + # Must be Ethernet (type == 1) + type_file = os.path.join(iface_dir, "type") + try: + with open(type_file) as f: + if f.read().strip() != "1": + continue + except OSError: + continue + + # Skip WiFi interfaces + if os.path.isdir(os.path.join(iface_dir, "wireless")): + continue + + # Skip bridge interfaces (docker0, br-*) + if os.path.isdir(os.path.join(iface_dir, "bridge")): + continue + + candidates.append(name) + # Prefer PCI-named Ethernet interfaces + if name.startswith("eth") or re.match(r"en.*s", name): + preferred.append(name) + + pool = preferred if preferred else candidates + if not pool: + print("Error: no wired Ethernet interface detected.", file=sys.stderr) + sys.exit(1) + + pool.sort() + return pool[0] + + +def set_hostname(hostname): + print(f" Setting hostname to '{hostname}'...") + subprocess.run(["hostnamectl", "set-hostname", hostname], check=True) + + hosts_path = "/etc/hosts" + with open(hosts_path) as f: + content = f.read() + + new_line = f"127.0.1.1\t{hostname}" + pattern = re.compile(r"^127\.0\.1\.1\s+.*$", re.MULTILINE) + + if pattern.search(content): + content = pattern.sub(new_line, content) + else: + if not content.endswith("\n"): + content += "\n" + content += new_line + "\n" + + with open(hosts_path, "w") as f: + f.write(content) + + print(f" /etc/hosts updated with: {new_line}") + + +def configure_via_nmcli(iface, ip_cidr): + print(f" Configuring via nmcli (interface: {iface}, address: {ip_cidr})...") + + # Delete existing connection if present (idempotent) + subprocess.run( + ["nmcli", "connection", "delete", "retro-static"], + capture_output=True, + ) + + subprocess.run( + [ + "nmcli", "connection", "add", + "type", "ethernet", + "con-name", "retro-static", + "ifname", iface, + "ipv4.method", "manual", + "ipv4.addresses", ip_cidr, + "ipv4.gateway", "", + "ipv4.dns", "", + "ipv4.dns-search", "", + "ipv6.method", "disabled", + "connection.autoconnect", "yes", + ], + check=True, + ) + + subprocess.run(["nmcli", "connection", "up", "retro-static"], check=True) + print(" nmcli connection 'retro-static' is up.") + + +def configure_via_interfaces(iface, ip_cidr): + ip, prefix = ip_cidr.split("/") + prefix = int(prefix) + + # Calculate netmask from prefix length + mask_int = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF + netmask = ".".join(str((mask_int >> (8 * i)) & 0xFF) for i in reversed(range(4))) + + print(f" Configuring via /etc/network/interfaces (interface: {iface}, IP: {ip}, netmask: {netmask})...") + + interfaces_path = "/etc/network/interfaces" + with open(interfaces_path) as f: + content = f.read() + + # Remove existing stanza for this interface + stanza_pattern = re.compile( + r"(?:^auto\s+" + re.escape(iface) + r"\s*\n)?" + r"^(?:allow-hotplug\s+" + re.escape(iface) + r"\s*\n)?" + r"^iface\s+" + re.escape(iface) + r"\s+.*?(?=\n(?:auto|allow-hotplug|iface|source|\Z))", + re.MULTILINE | re.DOTALL, + ) + content = stanza_pattern.sub("", content) + + # Remove any remaining bare 'auto ' line + content = re.sub(r"^auto\s+" + re.escape(iface) + r"\s*\n", "", content, flags=re.MULTILINE) + + # Append new static stanza + stanza = ( + f"\nauto {iface}\n" + f"iface {iface} inet static\n" + f" address {ip}\n" + f" netmask {netmask}\n" + ) + content = content.rstrip("\n") + "\n" + stanza + + with open(interfaces_path, "w") as f: + f.write(content) + + print(f" /etc/network/interfaces updated.") + + subprocess.run(["ifdown", iface], capture_output=True) + subprocess.run(["ifup", iface], check=True) + print(f" Interface {iface} brought up.") + + +def configure_network(iface, ip_cidr): + if shutil.which("nmcli"): + configure_via_nmcli(iface, ip_cidr) + else: + configure_via_interfaces(iface, ip_cidr) + + +def main(): + args = parse_args() + check_root() + + subnet_root, hostname_prefix = load_config() + machine_num = args.machine_num + ip = f"{subnet_root}.{machine_num}" + ip_cidr = f"{ip}/24" + hostname = f"{hostname_prefix}{machine_num}" + + print(f"=== setup-network ===") + print(f" Machine number : {machine_num}") + print(f" IP address : {ip_cidr}") + print(f" Hostname : {hostname}") + print() + + iface = detect_wired_iface() + print(f" Detected wired interface: {iface}") + print() + + print("[1/2] Setting hostname...") + set_hostname(hostname) + print() + + print("[2/2] Configuring network...") + configure_network(iface, ip_cidr) + print() + + print("Done. Verify with:") + print(f" ip addr show {iface}") + print(f" hostname") + + +if __name__ == "__main__": + main()