from __future__ import annotations import argparse import json import os from pathlib import Path import re import shutil import subprocess import uuid try: import pwd except ImportError: # pragma: no cover - allows Windows unit tests to import this module pwd = None # type: ignore[assignment] from scripts.image_config import read_config_path SOURCE_ROOT = Path(__file__).resolve().parents[1] SSH_POLICY_SOURCE = SOURCE_ROOT / "systemd" / "10-walnutpi-screen-hardening.conf" SSH_POLICY_TARGET = Path("/etc/ssh/sshd_config.d/10-walnutpi-screen-hardening.conf") SSH_RUNTIME_DIRECTORY = Path("/run/sshd") SUDOERS_TARGET = Path("/etc/sudoers.d/90-matrix-screen-controller-account") SUDOERS_MAIN = Path("/etc/sudoers") HOSTS_PATH = Path("/etc/hosts") SSH_EXPECTED = { "permitrootlogin": "no", "passwordauthentication": "yes", "kbdinteractiveauthentication": "no", "permitemptypasswords": "no", } def run(args: list[str], *, input_text: str | None = None) -> None: subprocess.run(args, input=input_text, text=True, check=True) def _nm_escape(value: str) -> str: return value.replace("\\", "\\\\").replace(";", "\\;") def _rooted(root: Path, path: Path) -> Path: return root / path.relative_to("/") def _remove_temporary(path: Path) -> None: if os.name == "nt" and path.exists(): path.chmod(0o600) path.unlink(missing_ok=True) def _atomic_write(path: Path, data: str, mode: int) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") try: temporary.write_text(data, encoding="utf-8", newline="\n") temporary.chmod(mode) os.replace(temporary, path) finally: _remove_temporary(temporary) def _sanitize_vendor_sudoers(data: str) -> str: forbidden = ( re.compile(r"^\s*Defaults\s+rootpw\s*(?:#.*)?$"), re.compile(r"^\s*pi\s+ALL\s*=\s*\(ALL\)\s+NOPASSWD\s*:\s*ALL\s*(?:#.*)?$"), ) lines = [line for line in data.splitlines() if not any(pattern.fullmatch(line) for pattern in forbidden)] return "\n".join(lines) + "\n" def _install_sudoers(path: Path, main_path: Path, username: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") main_temporary = main_path.with_name(f".{main_path.name}.{uuid.uuid4().hex}.tmp") try: temporary.write_text(f"{username} ALL=(ALL:ALL) ALL\n", encoding="utf-8", newline="\n") temporary.chmod(0o440) main_temporary.write_text( _sanitize_vendor_sudoers(main_path.read_text(encoding="utf-8")), encoding="utf-8", newline="\n", ) main_temporary.chmod(0o440) run(["visudo", "-cf", str(temporary)]) run(["visudo", "-cf", str(main_temporary)]) os.replace(main_temporary, main_path) os.replace(temporary, path) run(["visudo", "-c"]) finally: _remove_temporary(temporary) _remove_temporary(main_temporary) def _configure_hostname_mapping(path: Path, hostname: str) -> None: lines = path.read_text(encoding="utf-8").splitlines() replacement = f"127.0.1.1\t{hostname}" replaced = False for index, line in enumerate(lines): fields = line.split() if fields and fields[0] == "127.0.1.1": lines[index] = replacement replaced = True break if not replaced: lines.append(replacement) _atomic_write(path, "\n".join(lines) + "\n", 0o644) def configure_network(config: dict) -> None: wifi = config["wifi"] ipv4 = config["ipv4"] connection_uuid = str(uuid.uuid4()) lines = [ "[connection]", "id=matrix-screen", f"uuid={connection_uuid}", "type=wifi", "interface-name=wlan0", "autoconnect=true", "", "[wifi]", "mode=infrastructure", f"ssid={_nm_escape(wifi['ssid'])}", "", "[wifi-security]", "key-mgmt=wpa-psk", f"psk={_nm_escape(wifi['password'])}", "", "[ipv4]", ] if ipv4["mode"] == "dhcp": lines.extend(("method=auto", "")) else: dns = ";".join(ipv4["dns"]) + ";" lines.extend( ( f"address1={ipv4['address']}/{ipv4['prefix']},{ipv4['gateway']}", f"dns={dns}", "method=manual", "", ) ) lines.extend(("[ipv6]", "method=disabled", "")) root = Path("/etc/NetworkManager/system-connections") root.mkdir(parents=True, exist_ok=True) for candidate in root.glob("*.nmconnection"): candidate.unlink() target = root / "matrix-screen.nmconnection" target.write_text("\n".join(lines), encoding="utf-8", newline="\n") target.chmod(0o600) def configure_account(config: dict, *, root: Path = Path("/")) -> None: if pwd is None: raise RuntimeError("account provisioning requires the POSIX pwd module") account = config["account"] username = account["username"] try: pwd.getpwnam(username) except KeyError: pi = pwd.getpwnam("pi") groups = subprocess.check_output(["id", "-nG", "pi"], text=True).split() supplementary = ",".join(group for group in groups if group not in {"pi", username}) args = ["useradd", "--create-home", "--shell", "/bin/bash"] if supplementary: args.extend(("--groups", supplementary)) args.append(username) run(args) home = _rooted(root, Path(f"/home/{username}")) if Path(pi.pw_dir).is_dir() and not any(home.iterdir()): for source in Path(pi.pw_dir).iterdir(): if source.name not in {".ssh", ".bash_history"}: destination = home / source.name if source.is_dir(): shutil.copytree(source, destination, symlinks=True) elif source.is_file(): shutil.copy2(source, destination) run(["chown", "-R", f"{username}:{username}", str(home)]) run(["chpasswd"], input_text=f"{username}:{account['password']}\n") run(["usermod", "--append", "--groups", "sudo", username]) sudoers = _rooted(root, SUDOERS_TARGET) _install_sudoers(sudoers, _rooted(root, SUDOERS_MAIN), username) if username != "pi": run(["passwd", "--lock", "pi"]) def reset_identity(*, root: Path = Path("/")) -> None: _configure_hostname_mapping(_rooted(root, HOSTS_PATH), "matrix-screen") run(["hostnamectl", "set-hostname", "matrix-screen"]) run(["systemctl", "stop", "ssh.service"]) for key in _rooted(root, Path("/etc/ssh")).glob("ssh_host_*"): if key.is_file(): key.unlink() _rooted(root, Path("/etc/machine-id")).write_text("", encoding="ascii") dbus = _rooted(root, Path("/var/lib/dbus/machine-id")) if dbus.exists() or dbus.is_symlink(): dbus.unlink() run(["systemd-machine-id-setup"]) run(["ssh-keygen", "-A"]) def configure_ssh(*, root: Path = Path("/")) -> None: policy = SSH_POLICY_SOURCE.read_text(encoding="utf-8") target = _rooted(root, SSH_POLICY_TARGET) _atomic_write(target, policy, 0o644) runtime = _rooted(root, SSH_RUNTIME_DIRECTORY) runtime.mkdir(parents=True, exist_ok=True) runtime.chmod(0o755) run(["/usr/sbin/sshd", "-t"]) effective = subprocess.check_output(["/usr/sbin/sshd", "-T"], text=True) observed = {} for line in effective.splitlines(): key, separator, value = line.partition(" ") if separator and key in SSH_EXPECTED: observed[key] = value.strip() if observed != SSH_EXPECTED: raise RuntimeError(f"effective SSH policy is invalid: {observed!r}") run(["systemctl", "unmask", "ssh.service"]) run(["systemctl", "enable", "ssh.service"]) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("command", choices=("network", "account", "identity", "ssh", "print-public")) parser.add_argument("--config", type=Path, required=True) args = parser.parse_args() config = read_config_path(args.config) if args.command == "network": configure_network(config) elif args.command == "account": configure_account(config) elif args.command == "identity": reset_identity() elif args.command == "ssh": configure_ssh() else: public = { "software_version": config["software_version"], "username": config["account"]["username"], "ssid": config["wifi"]["ssid"], "ipv4": config["ipv4"], } print(json.dumps(public, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())