diff --git a/.github/workflows/nixos-pr-build.yml b/.github/workflows/nixos-pr-build.yml index c8c4c181c..19e74b444 100644 --- a/.github/workflows/nixos-pr-build.yml +++ b/.github/workflows/nixos-pr-build.yml @@ -121,7 +121,7 @@ jobs: extra-conf: | extra-system-features = big-parallel extra-substituters = https://cache.pifinder.eu/pifinder - extra-trusted-public-keys = pifinder:VkemNaMqXDcsYlpONItSvOOcBIa1vEfnpyqdetr3gck= + extra-trusted-public-keys = pifinder:8UU/O3oLkaJHHUyqEcPGl+9F1m4MqDca39Ewl49jBmE= - name: Attic login for push env: diff --git a/python/PiFinder/nixos_migration_wifi.py b/python/PiFinder/nixos_migration_wifi.py new file mode 100644 index 000000000..03b0e688e --- /dev/null +++ b/python/PiFinder/nixos_migration_wifi.py @@ -0,0 +1,276 @@ +"""Convert wpa_supplicant.conf to NetworkManager keyfiles. + +Runs during the pre-migration phase on Debian, before reboot into the +initramfs. Keyfiles get staged into the initramfs build dir and the +init script just copies them into the new rootfs — much safer than +generating them in busybox shell after the rootfs has been formatted. +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +import uuid +from pathlib import Path +from typing import Callable, Iterable, List, Optional + + +WPA_NETWORK_OPEN = re.compile(r"^\s*network\s*=\s*\{") +WPA_NETWORK_CLOSE = re.compile(r"^\s*\}") +WPA_KEY_VALUE = re.compile(r"^\s*([a-zA-Z0-9_]+)\s*=\s*(.*?)\s*$") + +HEX_PSK_RE = re.compile(r"^[0-9a-fA-F]{64}$") +HEX_STRING_RE = re.compile(r"^(?:[0-9a-fA-F]{2})+$") + + +class Network: + __slots__ = ("ssid", "psk") + + def __init__(self, ssid: str, psk: Optional[str]) -> None: + self.ssid = ssid + self.psk = psk + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, Network) + and self.ssid == other.ssid + and self.psk == other.psk + ) + + def __repr__(self) -> str: + psk_repr = "None" if self.psk is None else f"<{len(self.psk)}c>" + return f"Network(ssid={self.ssid!r}, psk={psk_repr})" + + +def _unquote(value: str) -> str: + """Strip a single surrounding pair of double quotes if present.""" + if len(value) >= 2 and value.startswith('"') and value.endswith('"'): + return value[1:-1] + return value + + +def _parse_ssid(value: str) -> str: + """Decode a wpa_supplicant ssid value. + + Quoted values are plain strings. Unquoted values are hex-encoded byte + strings per wpa_supplicant syntax — decode them here, or the network + name ends up mangled; surrogateescape keeps non-UTF-8 SSID bytes + round-trippable into the keyfile byte list. + """ + if len(value) >= 2 and value.startswith('"') and value.endswith('"'): + return value[1:-1] + if HEX_STRING_RE.fullmatch(value): + return bytes.fromhex(value).decode("utf-8", "surrogateescape") + return value + + +def parse_wpa_supplicant_conf(text: str) -> List[Network]: + """Parse the subset of wpa_supplicant.conf we care about. + + Recognises `network={ ... }` blocks containing `ssid=` and `psk=`. + Quoted values get their outer quotes stripped. Unquoted PSKs (the + 64-hex-char pre-shared-key form) are kept verbatim — NetworkManager + accepts both. Networks without an SSID are skipped. + + Returns the networks in declaration order. + """ + networks: List[Network] = [] + in_net = False + ssid: Optional[str] = None + psk: Optional[str] = None + + for raw_line in text.splitlines(): + line = raw_line.split("#", 1)[0].strip() + if not line: + continue + + if WPA_NETWORK_OPEN.match(line): + in_net = True + ssid = None + psk = None + continue + + if WPA_NETWORK_CLOSE.match(line): + if in_net and ssid is not None: + networks.append(Network(ssid=ssid, psk=psk)) + in_net = False + ssid = None + psk = None + continue + + if not in_net: + continue + + match = WPA_KEY_VALUE.match(line) + if not match: + continue + key, value = match.group(1), match.group(2) + if key == "ssid": + ssid = _parse_ssid(value) + elif key == "psk": + psk = _unquote(value) + + return networks + + +def ssid_to_bytelist(ssid: str) -> str: + """Encode an SSID as a NetworkManager keyfile byte list (`97;112;...`). + + NM's keyfile format documents exactly two ssid forms: a plain string and + a semicolon-separated list of DECIMAL byte values. Anything else (hex, + 0x-prefixed or not) is silently kept as a literal-string SSID, mangling + the network name so the device can never join it. surrogateescape + restores non-UTF-8 bytes captured from wpa_supplicant's hex ssid form. + """ + return "".join(f"{b};" for b in ssid.encode("utf-8", "surrogateescape")) + + +def escape_keyfile_value(value: str) -> str: + """Escape characters that have meaning in NM keyfile values. + + Backslash and semicolon are the only special characters in a plain + string value. (The byte-list format uses the semicolon as separator, + but we don't use that for the PSK.) + """ + return value.replace("\\", "\\\\").replace(";", "\\;") + + +_SAFE_FN_CHARS = re.compile(r"[^A-Za-z0-9._-]") + + +def sanitize_filename(ssid: str) -> str: + """Build a safe filename for a connection keyfile from an SSID. + + Non-alphanumeric characters (except `.`, `_`, `-`) are replaced with + `_`. The result is also guarded against the empty string and `.`/`..` + so a hostile or odd SSID can't escape the connections directory. + """ + cleaned = _SAFE_FN_CHARS.sub("_", ssid) + if cleaned in ("", ".", ".."): + return "wifi" + return cleaned + + +def build_keyfile( + ssid: str, + psk: Optional[str], + connection_uuid: Optional[str] = None, +) -> str: + """Build a NetworkManager keyfile body for a single WiFi connection. + + `psk=None` produces an open-network keyfile (no [wifi-security]). + `connection_uuid=None` generates a fresh v4 UUID. + """ + if connection_uuid is None: + connection_uuid = str(uuid.uuid4()) + + id_escaped = escape_keyfile_value(ssid) + ssid_encoded = ssid_to_bytelist(ssid) + + lines = [ + "[connection]", + f"id={id_escaped}", + f"uuid={connection_uuid}", + "type=wifi", + "autoconnect=true", + "", + "[wifi]", + "mode=infrastructure", + f"ssid={ssid_encoded}", + "", + ] + + if psk is not None and psk != "": + psk_escaped = escape_keyfile_value(psk) + lines.extend( + [ + "[wifi-security]", + "key-mgmt=wpa-psk", + f"psk={psk_escaped}", + "", + ] + ) + + lines.extend( + [ + "[ipv4]", + "method=auto", + "", + "[ipv6]", + "method=auto", + "", + ] + ) + return "\n".join(lines) + + +def emit_keyfiles( + networks: Iterable[Network], + output_dir: Path, + uuid_factory: Callable[[], str] = lambda: str(uuid.uuid4()), +) -> List[Path]: + """Write a keyfile per network into `output_dir`. + + Filenames are based on a sanitised SSID; collisions get a numeric + suffix. Files are mode 0600. Returns the list of paths written. + """ + output_dir.mkdir(parents=True, exist_ok=True) + written: List[Path] = [] + used: set = set() + + for net in networks: + base = sanitize_filename(net.ssid) + name = base + n = 1 + while name in used: + n += 1 + name = f"{base}_{n}" + used.add(name) + + path = output_dir / f"{name}.nmconnection" + body = build_keyfile(net.ssid, net.psk, connection_uuid=uuid_factory()) + path.write_text(body) + os.chmod(path, 0o600) + written.append(path) + + return written + + +def _main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser( + description=( + "Convert wpa_supplicant.conf to NetworkManager keyfiles for " + "the PiFinder NixOS migration." + ) + ) + parser.add_argument( + "--wpa-conf", + required=True, + help="Path to the source wpa_supplicant.conf file.", + ) + parser.add_argument( + "--out", + required=True, + help="Directory to write the .nmconnection files into (created if absent).", + ) + args = parser.parse_args(argv) + + src = Path(args.wpa_conf) + if not src.exists(): + print(f"wpa_supplicant.conf not found at {src}", file=sys.stderr) + return 0 + + networks = parse_wpa_supplicant_conf(src.read_text()) + if not networks: + print(f"No networks parsed from {src}", file=sys.stderr) + return 0 + + written = emit_keyfiles(networks, Path(args.out)) + print(f"Wrote {len(written)} keyfile(s) to {args.out}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/python/scripts/nixos_migration.sh b/python/scripts/nixos_migration.sh index 740928318..3aea219de 100755 --- a/python/scripts/nixos_migration.sh +++ b/python/scripts/nixos_migration.sh @@ -209,6 +209,21 @@ fi cp "${INIT_SCRIPT}" "${INITRAMFS_DIR}/init" chmod +x "${INITRAMFS_DIR}/init" +# Pre-stage NetworkManager keyfiles from the live wpa_supplicant.conf. +# Generating them here (with Python, on the full Debian system) rather +# than in the busybox initramfs lets us unit-test the conversion. The +# init script just copies these into the new rootfs. +WIFI_STAGED_DIR="${INITRAMFS_DIR}/wifi-staged" +WPA_CONF="/etc/wpa_supplicant/wpa_supplicant.conf" +if [ -f "${WPA_CONF}" ]; then + PIFINDER_PYTHON_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + PYTHONPATH="${PIFINDER_PYTHON_ROOT}" python3 \ + -m PiFinder.nixos_migration_wifi \ + --wpa-conf "${WPA_CONF}" \ + --out "${WIFI_STAGED_DIR}" \ + || fail 5 "WiFi keyfile generation failed" +fi + # Metadata: paths + sizes so init script knows where to find things cat > "${INITRAMFS_DIR}/migration_meta" </dev/null || true fi +# Any NM keyfiles the user already had on Debian — preserved as-is. NM_SRC="${MOUNT_ROOT}/etc/NetworkManager/system-connections" if [ -d "${NM_SRC}" ]; then - mkdir -p /tmp/wifi/nm-connections cp -a "${NM_SRC}/." /tmp/wifi/nm-connections/ 2>/dev/null || true fi @@ -317,101 +320,15 @@ show 70 "Migrating WiFi" NM_DIR="${MOUNT_NEW}/etc/NetworkManager/system-connections" mkdir -p "${NM_DIR}" -# NM keyfile helpers — SSID becomes hex bytes (safe for any content), -# PSK gets minimal keyfile escaping (backslash + semicolon), filename -# is sanitized so a hostile or unusual SSID can't escape NM_DIR. -ssid_to_hex() { - printf '%s' "$1" | od -An -tx1 | tr -d ' \n' | sed 's/\(..\)/\1;/g' -} -escape_kf() { - printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/;/\\;/g' -} -sanitize_fn() { - printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' -} - -if [ -f /tmp/wifi/wpa_supplicant.conf ]; then - SSID="" - PSK="" - IN_NET=0 - - while IFS= read -r line; do - line=$(printf '%s' "${line}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') - - case "${line}" in - network=*) - IN_NET=1 - SSID="" - PSK="" - ;; - "}") - if [ "${IN_NET}" = "1" ] && [ -n "${SSID}" ]; then - SSID_HEX=$(ssid_to_hex "${SSID}") - ID_ESC=$(escape_kf "${SSID}") - FN=$(sanitize_fn "${SSID}") - # Guard against empty/dotfile filenames after sanitization - case "${FN}" in - ""|.|..) FN="wifi" ;; - esac - NM_FILE="${NM_DIR}/${FN}.nmconnection" - - if [ -n "${PSK}" ]; then - PSK_ESC=$(escape_kf "${PSK}") - cat > "${NM_FILE}" < "${NM_FILE}" </dev/null || true + # The pre-stage Python runs as the app user on the old OS, and cp -a + # preserves that owner — NetworkManager refuses keyfiles not owned by + # root ("File owner (1000) is insecure"). We are root here; make them so. + chown -R 0:0 "${NM_DIR}" 2>/dev/null || true + chmod 600 "${NM_DIR}"/*.nmconnection 2>/dev/null || true fi sync diff --git a/python/tests/test_nixos_migration_wifi.py b/python/tests/test_nixos_migration_wifi.py new file mode 100644 index 000000000..9b621fb45 --- /dev/null +++ b/python/tests/test_nixos_migration_wifi.py @@ -0,0 +1,321 @@ +import re + +import pytest + +from PiFinder.nixos_migration_wifi import ( + Network, + _parse_ssid, + build_keyfile, + emit_keyfiles, + escape_keyfile_value, + parse_wpa_supplicant_conf, + sanitize_filename, + ssid_to_bytelist, +) + + +UUID_V4_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +) + + +@pytest.mark.unit +class TestParseWpaSupplicantConf: + def test_empty(self): + assert parse_wpa_supplicant_conf("") == [] + + def test_single_wpa_network(self): + conf = """ + ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev + update_config=1 + country=BE + + network={ + ssid="APME" + psk="hunter12" + } + """ + assert parse_wpa_supplicant_conf(conf) == [Network("APME", "hunter12")] + + def test_open_network_has_no_psk(self): + conf = """ + network={ + ssid="OpenNet" + key_mgmt=NONE + } + """ + assert parse_wpa_supplicant_conf(conf) == [Network("OpenNet", None)] + + def test_multiple_networks_preserve_order(self): + conf = """ + network={ + ssid="first" + psk="pw1" + } + network={ + ssid="second" + psk="pw2" + } + network={ + ssid="third" + psk="pw3" + } + """ + nets = parse_wpa_supplicant_conf(conf) + assert [n.ssid for n in nets] == ["first", "second", "third"] + + def test_hex_psk_preserved_verbatim(self): + hex_psk = "a" * 64 + conf = f""" + network={{ + ssid="HexPsk" + psk={hex_psk} + }} + """ + result = parse_wpa_supplicant_conf(conf) + assert result == [Network("HexPsk", hex_psk)] + + def test_unquoted_ssid_kept(self): + # Some configs use unquoted SSIDs for short alphanumerics. + conf = """ + network={ + ssid=plain + psk="pw" + } + """ + assert parse_wpa_supplicant_conf(conf) == [Network("plain", "pw")] + + def test_ssid_with_special_chars(self): + conf = """ + network={ + ssid="0x20" + psk="pw" + } + network={ + ssid="hackerspace.gent" + psk="pw2" + } + """ + nets = parse_wpa_supplicant_conf(conf) + assert nets == [ + Network("0x20", "pw"), + Network("hackerspace.gent", "pw2"), + ] + + def test_network_without_ssid_skipped(self): + conf = """ + network={ + psk="orphan" + } + network={ + ssid="valid" + psk="pw" + } + """ + assert parse_wpa_supplicant_conf(conf) == [Network("valid", "pw")] + + def test_comments_and_blank_lines_ignored(self): + conf = """ + # global comment + ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev + + network={ + # inner comment + ssid="commented" + psk="pw" # trailing comment + } + """ + assert parse_wpa_supplicant_conf(conf) == [Network("commented", "pw")] + + +@pytest.mark.unit +class TestSsidToBytelist: + def test_ascii(self): + assert ssid_to_bytelist("APME") == "65;80;77;69;" + + def test_bytes_are_decimal(self): + # The whole point of this function — NM only parses DECIMAL byte + # lists; hex (with or without 0x prefix) is silently kept as a + # literal-string SSID and the network name is mangled. + assert ssid_to_bytelist("apollo") == "97;112;111;108;108;111;" + + def test_utf8_bytes(self): + # SSID can contain non-ASCII; should be encoded as utf-8 bytes + assert ssid_to_bytelist("é") == "195;169;" + + def test_empty(self): + assert ssid_to_bytelist("") == "" + + def test_non_utf8_bytes_round_trip(self): + # A hex wpa ssid holding non-UTF-8 bytes survives parse -> encode. + assert ssid_to_bytelist(_parse_ssid("ff00")) == "255;0;" + + +@pytest.mark.unit +class TestParseSsidValue: + def test_quoted_is_plain_string(self): + assert _parse_ssid('"apollo"') == "apollo" + + def test_unquoted_hex_is_decoded(self): + # wpa_supplicant stores SSIDs with special characters as unquoted + # hex strings; these must be decoded, not used as the name. + assert _parse_ssid("61706f6c6c6f") == "apollo" + + def test_quoted_hex_lookalike_stays_verbatim(self): + # A network genuinely NAMED like a hex string is quoted in + # wpa_supplicant, so it must not be decoded. + assert _parse_ssid('"61706f"') == "61706f" + + def test_non_hex_unquoted_stays_verbatim(self): + assert _parse_ssid("abc") == "abc" + + def test_hex_ssid_end_to_end(self): + conf = """ + network={ + ssid=61706f6c6c6f + psk="hunter12" + } + """ + assert parse_wpa_supplicant_conf(conf) == [Network("apollo", "hunter12")] + + +@pytest.mark.unit +class TestEscapeKeyfileValue: + def test_plain(self): + assert escape_keyfile_value("hunter12") == "hunter12" + + def test_semicolon_escaped(self): + assert escape_keyfile_value("a;b") == "a\\;b" + + def test_backslash_escaped(self): + assert escape_keyfile_value("a\\b") == "a\\\\b" + + def test_backslash_before_semicolon(self): + # Backslash must be escaped first so we don't double-escape the + # semicolon escape sequence we just produced. + assert escape_keyfile_value("\\;") == "\\\\\\;" + + +@pytest.mark.unit +class TestSanitizeFilename: + def test_plain(self): + assert sanitize_filename("APME") == "APME" + + def test_dot_preserved(self): + assert sanitize_filename("hackerspace.gent") == "hackerspace.gent" + + def test_slash_replaced(self): + assert sanitize_filename("a/b") == "a_b" + + def test_pathy_chars_replaced(self): + assert sanitize_filename("../etc/passwd") == ".._etc_passwd" + + def test_empty_becomes_wifi(self): + assert sanitize_filename("") == "wifi" + + def test_dot_becomes_wifi(self): + assert sanitize_filename(".") == "wifi" + + def test_dotdot_becomes_wifi(self): + assert sanitize_filename("..") == "wifi" + + +@pytest.mark.unit +class TestBuildKeyfile: + def test_contains_required_sections(self): + body = build_keyfile("APME", "hunter12", connection_uuid="fixed-uuid") + assert "[connection]" in body + assert "[wifi]" in body + assert "[wifi-security]" in body + assert "[ipv4]" in body + assert "[ipv6]" in body + + def test_uuid_present(self): + body = build_keyfile("APME", "pw", connection_uuid="abc-123") + assert "uuid=abc-123" in body + + def test_uuid_generated_when_not_provided(self): + body = build_keyfile("APME", "pw") + match = re.search(r"^uuid=(.+)$", body, re.MULTILINE) + assert match + assert UUID_V4_RE.match(match.group(1)) + + def test_ssid_encoded_as_decimal_bytelist(self): + body = build_keyfile("APME", "pw", connection_uuid="x") + assert "ssid=65;80;77;69;" in body + + def test_open_network_omits_security(self): + body = build_keyfile("OpenNet", None, connection_uuid="x") + assert "[wifi-security]" not in body + assert "key-mgmt" not in body + assert "psk=" not in body + + def test_empty_psk_treated_as_open(self): + body = build_keyfile("OpenNet", "", connection_uuid="x") + assert "[wifi-security]" not in body + + def test_psk_with_semicolon_escaped(self): + body = build_keyfile("S", "p;w", connection_uuid="x") + assert "psk=p\\;w" in body + + def test_psk_with_backslash_escaped(self): + body = build_keyfile("S", "p\\w", connection_uuid="x") + assert "psk=p\\\\w" in body + + def test_ipv4_method_auto(self): + body = build_keyfile("S", "pw", connection_uuid="x") + assert "[ipv4]\nmethod=auto" in body + + def test_id_uses_ssid(self): + body = build_keyfile("MyNet", "pw", connection_uuid="x") + assert "id=MyNet" in body + + +@pytest.mark.unit +class TestEmitKeyfiles: + def test_writes_one_file_per_network(self, tmp_path): + nets = [Network("a", "pw"), Network("b", None), Network("c", "pw3")] + written = emit_keyfiles(nets, tmp_path) + assert len(written) == 3 + assert {p.name for p in written} == { + "a.nmconnection", + "b.nmconnection", + "c.nmconnection", + } + + def test_file_mode_is_600(self, tmp_path): + emit_keyfiles([Network("a", "pw")], tmp_path) + mode = (tmp_path / "a.nmconnection").stat().st_mode & 0o777 + assert mode == 0o600 + + def test_creates_output_dir(self, tmp_path): + target = tmp_path / "nested" / "wifi" + emit_keyfiles([Network("a", "pw")], target) + assert (target / "a.nmconnection").exists() + + def test_collision_suffix(self, tmp_path): + # Two SSIDs that sanitize to the same filename + nets = [Network("a/b", "pw"), Network("a.b", "pw")] + # sanitize: "a/b" -> "a_b", "a.b" -> "a.b" (different) — pick another pair + nets = [Network("a/b", "pw"), Network("a;b", "pw")] + # Both sanitize to "a_b" + written = emit_keyfiles(nets, tmp_path) + names = sorted(p.name for p in written) + assert names == ["a_b.nmconnection", "a_b_2.nmconnection"] + + def test_each_file_gets_unique_uuid(self, tmp_path): + nets = [Network("a", "pw"), Network("b", "pw")] + emit_keyfiles(nets, tmp_path) + u1 = (tmp_path / "a.nmconnection").read_text() + u2 = (tmp_path / "b.nmconnection").read_text() + uuid1 = re.search(r"^uuid=(.+)$", u1, re.MULTILINE).group(1) + uuid2 = re.search(r"^uuid=(.+)$", u2, re.MULTILINE).group(1) + assert uuid1 != uuid2 + assert UUID_V4_RE.match(uuid1) + assert UUID_V4_RE.match(uuid2) + + def test_deterministic_with_injected_uuid(self, tmp_path): + nets = [Network("a", "pw")] + emit_keyfiles(nets, tmp_path, uuid_factory=lambda: "fixed-uuid-1234") + body = (tmp_path / "a.nmconnection").read_text() + assert "uuid=fixed-uuid-1234" in body