From 96dea140beb614ce76a7faa6259a456697844b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Geoffrey=20=E2=80=9CFrogeye=E2=80=9D=20Preud=27homme?= Date: Sat, 8 Jun 2024 15:54:33 +0200 Subject: [PATCH] Make Wi-Fi semi-declarative --- flake.lock | 17 ++++++ flake.nix | 6 +-- os/wireless/apply.py | 56 ++++++++++++++++++++ os/wireless/default.nix | 61 ++++++++++++++-------- os/wireless/import.py | 111 +++------------------------------------- 5 files changed, 125 insertions(+), 126 deletions(-) create mode 100644 os/wireless/apply.py diff --git a/flake.lock b/flake.lock index c50a994..2508493 100644 --- a/flake.lock +++ b/flake.lock @@ -155,6 +155,22 @@ "type": "indirect" } }, + "displaylinknixpkgs": { + "locked": { + "lastModified": 1717533296, + "narHash": "sha256-TOxOpOYy/tQB+eYQOTPQXNeUmkMghLVDBO0Gc2nj/vs=", + "owner": "GeoffreyFrogeye", + "repo": "nixpkgs", + "rev": "99006b6f4cd24796b1ff6b6981b8f44c9cebd301", + "type": "github" + }, + "original": { + "owner": "GeoffreyFrogeye", + "ref": "displaylink-600", + "repo": "nixpkgs", + "type": "github" + } + }, "flake-compat": { "locked": { "lastModified": 1696426674, @@ -649,6 +665,7 @@ "root": { "inputs": { "disko": "disko", + "displaylinknixpkgs": "displaylinknixpkgs", "flake-utils": "flake-utils", "home-manager": "home-manager", "nix-on-droid": "nix-on-droid", diff --git a/flake.nix b/flake.nix index 97c498e..4eac293 100644 --- a/flake.nix +++ b/flake.nix @@ -95,8 +95,8 @@ repl = { type = "app"; program = "${pkgs.writeShellScript "vivarium-repl" '' - ${pkgs.nix}/bin/nix repl --expr 'let flake = builtins.getFlake "${self}"; in flake // flake.nixosConfigurations // rec { pkgs = import ${nixpkgs} {}; lib = pkgs.lib; }' - ''}"; + ${pkgs.lix}/bin/nix repl --expr 'let flake = builtins.getFlake "${self}"; in flake // flake.nixosConfigurations // rec { pkgs = import ${nixpkgs} {}; lib = pkgs.lib; }' + ''}"; }; }; } @@ -128,4 +128,4 @@ }; nixOnDroidConfigurations.sprinkles = lib.nixOnDroidConfiguration { }; } // (lib.flakeTools { inherit self; }); - } +} diff --git a/os/wireless/apply.py b/os/wireless/apply.py new file mode 100644 index 0000000..6599184 --- /dev/null +++ b/os/wireless/apply.py @@ -0,0 +1,56 @@ +""" +""" + +import json +import os +import re +import subprocess + +NETWORKS_FILE = "/etc/keys/wireless_networks.json" + + +def wpa_cli(command: list[str]) -> list[bytes]: + command.insert(0, "wpa_cli") + process = subprocess.run(command, stdout=subprocess.PIPE) + process.check_returncode() + lines = process.stdout.splitlines() + while lines[0].startswith(b"Selected interface"): + lines.pop(0) + return lines + + +network_numbers: dict[str, int] = dict() +networks_tsv = wpa_cli(["list_networks"]) +networks_tsv.pop(0) +for network_line in networks_tsv: + split = network_line.split(b"\t") + number = int(split[0]) + ssid_bytes = split[1] + ssid = re.sub( + rb"\\x([0-9a-f]{2})", + lambda d: int(d[1], base=16).to_bytes(), + ssid_bytes, + ).decode() + network_numbers[ssid] = number + +if os.path.isfile(NETWORKS_FILE): + with open(NETWORKS_FILE) as fd: + networks = json.load(fd) + + for network in networks: + ssid = network["ssid"] + if ssid in network_numbers: + number = network_numbers[ssid] + else: + number = int(wpa_cli(["add_network"])[0]) + number_str = str(number) + for key, value in network.items(): + if isinstance(value, str): + value_str = f'"{value}"' + elif isinstance(value, list): + value_str = " ".join(value) + else: + value_str = str(value) + ret = wpa_cli(["set_network", number_str, key, value_str]) + if ret[0] != b"OK": + raise RuntimeError(f"Couldn't set {key} for {ssid}, got {ret}") diff --git a/os/wireless/default.nix b/os/wireless/default.nix index edc20f7..fe85be5 100644 --- a/os/wireless/default.nix +++ b/os/wireless/default.nix @@ -1,34 +1,49 @@ { pkgs, lib, config, ... }: +let + importScript = pkgs.writers.writePython3 "install-wifi-import" + { + libraries = [ pkgs.python3Packages.pyaml ]; + } + (builtins.readFile ./import.py); + applyScript = pkgs.writers.writePython3 "install-wifi-apply" { } (builtins.readFile ./apply.py); +in { environment.systemPackages = [ (pkgs.writeShellApplication { name = "install-wifi"; + runtimeInputs = with pkgs; [ wpa_supplicant diffutils ]; text = '' temp="$(mktemp --directory --suffix="-install-wifi")" cd "$temp" - ${ - pkgs.writers.writePython3 "install-wifi-import" { - libraries = [ pkgs.python3Packages.pyaml ]; - } (builtins.readFile ./import.py) - } - sudo chown root:root wireless_networks.{env,json} - sudo chmod "u=r" wireless_networks.env - sudo chmod "u=r,g=r,o=r" wireless_networks.json + + # Save config for diffing later + wpa_cli save_config > /dev/null + cat <(sudo cat /run/wpa_supplicant/wpa_supplicant.conf) > old.conf + + # Export Wi-Fi config from pass + ${importScript} + + # Save on persistent storage for boot + sudo chown root:root wireless_networks.json + sudo chmod "u=r" wireless_networks.json sudo mkdir -p /etc/keys - sudo mv -f wireless_networks.{env,json} /etc/keys - cd - + sudo mv -f wireless_networks.json /etc/keys + + # Apply configuration + sudo ${applyScript} + + # Diff the config + wpa_cli save_config > /dev/null + cat <(sudo cat /run/wpa_supplicant/wpa_supplicant.conf) > new.conf + diff --color=auto -U 5 old.conf new.conf + + rm old.conf new.conf + cd / rmdir "$temp" - rb ''; - # This relies on multiple off-repo things: - # - pass password store with wifi/${name} entries, containing wpa_supplicant networks - # loosely converted to YAML (see import.py script) - # - In a (private) flake: - # inputs.wirelessNetworks.url = "path:/etc/keys/wireless_networks.json"; - # inputs.wirelessNetworks.flake = false; - # - In NixOS config (using flake inputs): - # networking.wireless.environmentFile = "/etc/keys/wireless_networks.env"; - # networking.wireless.networks = builtins.fromJSON (builtins.readFile wirelessNetworks); + # This relies on pass password store with wifi/${name} entries, + # containing wpa_supplicant networks loosely converted to YAML + # (see import.py script) }) ]; # wireless support via wpa_supplicant @@ -51,4 +66,10 @@ userControlled.enable = true; # Allow some control with wpa_cli }; services.chrony.serverOption = "offline"; + systemd.services.wifi_apply = { + after = [ "wpa_supplicant.service" ]; + requiredBy = [ "wpa_supplicant.service" ]; + path = with pkgs; [ wpa_supplicant ]; + script = "${applyScript}"; + }; } diff --git a/os/wireless/import.py b/os/wireless/import.py index e3ce59c..26f519d 100755 --- a/os/wireless/import.py +++ b/os/wireless/import.py @@ -9,7 +9,6 @@ into a format readable by Nix. # sha256 = "sha256:1la36n2f31j9s03v847ig6ny9lr875q3g7smnq33dcsmf2i5gd92"; # } -import hashlib import json import os import subprocess @@ -20,46 +19,6 @@ import yaml PASSWORD_STORE = os.environ["PASSWORD_STORE_DIR"] SUBFOLDER = "wifi" -SEPARATE_PASSWORDS = True - - -class Password: - all: list["Password"] = list() - - def __init__(self, path: str, content: str): - self.path = path - self.content = content - - Password.all.append(self) - - def var(self) -> str: - # return self.path.split("/")[-1].upper() - m = hashlib.sha256() - m.update(self.path.encode()) - return "p" + m.hexdigest().upper() - - def val(self) -> str: - return self.content - - def exists(self) -> bool: - return not not self.content - - def key(self) -> str: - if SEPARATE_PASSWORDS: - return f"@{self.var()}@" - else: - return self.val() - - @classmethod - def vars(cls) -> dict[str, str]: - vars = dict() - for password in cls.all: - if not password.content: - continue - var = password.var() - assert var not in vars, f"Duplicate key: {var}" - vars[var] = password.val() - return vars def list_networks() -> list[str]: @@ -75,28 +34,11 @@ def list_networks() -> list[str]: file = filename[:-4] path = os.path.join(SUBFOLDER, file) paths.append(path) + paths.sort() return paths -def format_wpa_supplicant_conf(conf: dict, indent: str = "") -> str: - lines = [] - for k, v in conf.items(): - if isinstance(v, str): - val = '"' + v.replace('"', '\\"') + '"' - elif isinstance(v, Password): - val = v.key() - elif isinstance(v, list): - assert all( - map(lambda i: isinstance(i, str), v) - ), "Only list of strings supported" - val = " ".join(v) - else: - val = str(v) - lines.append(f"{indent}{k}={val}") - return "\n".join(lines) - - -networks = {} +networks: list[dict[str, str | list[str] | int]] = list() for path in list_networks(): proc = subprocess.run(["pass", path], stdout=subprocess.PIPE) proc.check_returncode() @@ -104,61 +46,24 @@ for path in list_networks(): raw = proc.stdout.decode() split = raw.split("\n") - password = Password(path, split[0]) + password = split[0] data = yaml.safe_load("\n".join(split[1:])) or dict() - # print(path, data) # DEBUG # Helpers to prevent repetition suffixes = data.pop("suffixes", [""]) - data.setdefault("key_mgmt", ["WPA-PSK"] if password.exists() else ["NONE"]) + data.setdefault("key_mgmt", ["WPA-PSK"] if password else ["NONE"]) if password: if any(map(lambda m: "PSK" in m.split("-"), data["key_mgmt"])): data["psk"] = password - if "NONE" in data["key_mgmt"]: - data["wep_key0"] = password if any(map(lambda m: "EAP" in m.split("-"), data["key_mgmt"])): data["password"] = password assert "ssid" in data, f"{path}: Missing SSID" - - # # Output wpa_supplicant conf, for debug - # for suffix in suffixes: - # wpas = data.copy() - # wpas["ssid"] += suffix - # print(f"# {path}") - # print("network={") - # print(format_wpa_supplicant_conf(wpas, indent=" ")) - # print("}") - # print() - - # Convert to nix configuration - ssid = data.pop("ssid") - network = {} - key_mgmt = data.pop("key_mgmt", None) - psk = data.pop("psk", None) - priority = data.pop("priority", None) - # No support for hidden - # No support for extraConfig (all is assumed to be auth) - if key_mgmt: - network["authProtocols"] = key_mgmt - if psk: - network["psk"] = psk.key() - if data: - raise NotImplementedError( - f"{path}: Unhandled non-auth extra: {data}" - ) - else: - if data: - network["auth"] = format_wpa_supplicant_conf(data) - if priority: - network["priority"] = int(priority) + data.setdefault("disabled", 0) for suffix in suffixes: - networks[ssid + suffix] = network + network = data.copy() + network["ssid"] += suffix + networks.append(network) with open("wireless_networks.json", "w") as fd: json.dump(networks, fd, indent=4) - -with open("wireless_networks.env", "w") as fd: - if SEPARATE_PASSWORDS: - for k, v in Password.vars().items(): - print(f'{k}="{v}"', file=fd)