diff --git a/pkgs/clan-cli/clan_cli/__init__.py b/pkgs/clan-cli/clan_cli/__init__.py index 1e01225c4..b81e600b9 100644 --- a/pkgs/clan-cli/clan_cli/__init__.py +++ b/pkgs/clan-cli/clan_cli/__init__.py @@ -6,12 +6,12 @@ from pathlib import Path from types import ModuleType # These imports are unused, but necessary for @API.register to run once. -from .api import admin, directory, disk, mdns_discovery, modules +from .api import admin, directory, disk, iwd, mdns_discovery, modules from .arg_actions import AppendOptionAction from .clan import show, update # API endpoints that are not used in the cli. -__all__ = ["directory", "mdns_discovery", "modules", "update", "disk", "admin"] +__all__ = ["directory", "mdns_discovery", "modules", "update", "disk", "admin", "iwd"] from . import ( backups, diff --git a/pkgs/clan-cli/clan_cli/api/iwd.py b/pkgs/clan-cli/clan_cli/api/iwd.py new file mode 100644 index 000000000..63501608e --- /dev/null +++ b/pkgs/clan-cli/clan_cli/api/iwd.py @@ -0,0 +1,100 @@ +from dataclasses import dataclass +from pathlib import Path + +from clan_cli.clan_uri import FlakeId +from clan_cli.errors import ClanError +from clan_cli.facts.generate import generate_facts +from clan_cli.inventory import ( + IwdConfig, + IwdConfigNetwork, + ServiceIwd, + ServiceIwdRole, + ServiceIwdRoleDefault, + ServiceMeta, + load_inventory_eval, + save_inventory, +) +from clan_cli.machines.machines import Machine +from clan_cli.secrets.sops import ( + maybe_get_public_key, + maybe_get_user_or_machine, +) + +from . import API + + +def instance_name(machine_name: str) -> str: + return f"{machine_name}_wifi_0_" + + +@API.register +def get_iwd_service(base_url: str, machine_name: str) -> ServiceIwd | None: + """ + Return the admin service of a clan. + + There is only one admin service. This might be changed in the future + """ + inventory = load_inventory_eval(base_url) + return inventory.services.iwd.get(instance_name(machine_name)) + + +@dataclass +class NetworkConfig: + ssid: str + password: str + + +@API.register +def set_iwd_service_for_machine( + base_url: str, machine_name: str, networks: dict[str, NetworkConfig] +) -> None: + """ + Set the admin service of a clan + Every machine is by default part of the admin service via the 'all' tag + """ + _instance_name = instance_name(machine_name) + + inventory = load_inventory_eval(base_url) + + instance = ServiceIwd( + meta=ServiceMeta(name="wifi_0"), + roles=ServiceIwdRole( + default=ServiceIwdRoleDefault( + machines=[machine_name], + ) + ), + config=IwdConfig( + networks={k: IwdConfigNetwork(v.ssid) for k, v in networks.items()} + ), + ) + + inventory.services.iwd[_instance_name] = instance + + save_inventory( + inventory, + base_url, + f"Set iwd service: '{_instance_name}'", + ) + + pubkey = maybe_get_public_key() + if not pubkey: + # TODO: do this automatically + # pubkey = generate_key() + raise ClanError(msg="No public key found. Please initialize your key.") + + registered_key = maybe_get_user_or_machine(Path(base_url), pubkey) + if not registered_key: + # TODO: do this automatically + # username = os.getlogin() + # add_user(Path(base_url), username, pubkey, force=False) + raise ClanError(msg="Your public key is not registered for use with this clan.") + + password_dict = {f"iwd.{net.ssid}": net.password for net in networks.values()} + for net in networks.values(): + generate_facts( + service=f"iwd.{net.ssid}", + machines=[Machine(machine_name, FlakeId(base_url))], + regenerate=True, + # Just returns the password + prompt=lambda service, _msg: password_dict[service], + ) diff --git a/pkgs/clan-cli/clan_cli/facts/generate.py b/pkgs/clan-cli/clan_cli/facts/generate.py index e0e28a506..74c073a2e 100644 --- a/pkgs/clan-cli/clan_cli/facts/generate.py +++ b/pkgs/clan-cli/clan_cli/facts/generate.py @@ -68,7 +68,7 @@ def generate_service_facts( secret_facts_store: SecretStoreBase, public_facts_store: FactStoreBase, tmpdir: Path, - prompt: Callable[[str], str], + prompt: Callable[[str, str], str], ) -> bool: service_dir = tmpdir / service # check if all secrets exist and generate them if at least one is missing @@ -93,7 +93,9 @@ def generate_service_facts( else: generator = machine.facts_data[service]["generator"]["finalScript"] if machine.facts_data[service]["generator"]["prompt"]: - prompt_value = prompt(machine.facts_data[service]["generator"]["prompt"]) + prompt_value = prompt( + service, machine.facts_data[service]["generator"]["prompt"] + ) env["prompt_value"] = prompt_value if sys.platform == "linux": cmd = bubblewrap_cmd(generator, facts_dir, secrets_dir) @@ -137,7 +139,7 @@ def generate_service_facts( return True -def prompt_func(text: str) -> str: +def prompt_func(service: str, text: str) -> str: print(f"{text}: ") return read_multiline_input() @@ -147,7 +149,7 @@ def _generate_facts_for_machine( service: str | None, regenerate: bool, tmpdir: Path, - prompt: Callable[[str], str] = prompt_func, + prompt: Callable[[str, str], str] = prompt_func, ) -> bool: local_temp = tmpdir / machine.name local_temp.mkdir() @@ -189,7 +191,7 @@ def generate_facts( machines: list[Machine], service: str | None, regenerate: bool, - prompt: Callable[[str], str] = prompt_func, + prompt: Callable[[str, str], str] = prompt_func, ) -> bool: was_regenerated = False with TemporaryDirectory() as tmp: diff --git a/pkgs/clan-cli/clan_cli/inventory/__init__.py b/pkgs/clan-cli/clan_cli/inventory/__init__.py index 17ab54d07..a79b29e10 100644 --- a/pkgs/clan-cli/clan_cli/inventory/__init__.py +++ b/pkgs/clan-cli/clan_cli/inventory/__init__.py @@ -25,6 +25,8 @@ from clan_cli.nix import nix_eval from .classes import ( AdminConfig, Inventory, + IwdConfig, + IwdConfigNetwork, # Machine classes Machine, MachineDeploy, @@ -40,6 +42,10 @@ from .classes import ( ServiceBorgbackupRole, ServiceBorgbackupRoleClient, ServiceBorgbackupRoleServer, + # IWD + ServiceIwd, + ServiceIwdRole, + ServiceIwdRoleDefault, ServiceMeta, # Single Disk service ServiceSingleDisk, @@ -73,6 +79,12 @@ __all__ = [ "ServiceAdminRole", "ServiceAdminRoleDefault", "AdminConfig", + # IWD service, + "ServiceIwd", + "ServiceIwdRole", + "ServiceIwdRoleDefault", + "IwdConfig", + "IwdConfigNetwork", ] diff --git a/pkgs/clan-cli/clan_cli/secrets/sops.py b/pkgs/clan-cli/clan_cli/secrets/sops.py index 281d2c46e..72640d967 100644 --- a/pkgs/clan-cli/clan_cli/secrets/sops.py +++ b/pkgs/clan-cli/clan_cli/secrets/sops.py @@ -75,7 +75,7 @@ def get_user_name(flake_dir: Path, user: str) -> str: print(f"{flake_dir / user} already exists") -def ensure_user_or_machine(flake_dir: Path, pub_key: str) -> SopsKey: +def maybe_get_user_or_machine(flake_dir: Path, pub_key: str) -> SopsKey | None: key = SopsKey(pub_key, username="") folders = [sops_users_folder(flake_dir), sops_machines_folder(flake_dir)] @@ -88,8 +88,15 @@ def ensure_user_or_machine(flake_dir: Path, pub_key: str) -> SopsKey: key.username = user.name return key - msg = f"Your sops key is not yet added to the repository. Please add it with 'clan secrets users add youruser {pub_key}' (replace youruser with your user name)" - raise ClanError(msg) + return None + + +def ensure_user_or_machine(flake_dir: Path, pub_key: str) -> SopsKey: + key = maybe_get_user_or_machine(flake_dir, pub_key) + if not key: + msg = f"Your sops key is not yet added to the repository. Please add it with 'clan secrets users add youruser {pub_key}' (replace youruser with your user name)" + raise ClanError(msg) + return key def default_sops_key_path() -> Path: @@ -99,15 +106,30 @@ def default_sops_key_path() -> Path: return user_config_dir() / "sops" / "age" / "keys.txt" -def ensure_sops_key(flake_dir: Path) -> SopsKey: +def maybe_get_public_key() -> str | None: key = os.environ.get("SOPS_AGE_KEY") if key: - return ensure_user_or_machine(flake_dir, get_public_key(key)) + return get_public_key(key) path = default_sops_key_path() if path.exists(): - return ensure_user_or_machine(flake_dir, get_public_key(path.read_text())) - msg = "No sops key found. Please generate one with 'clan secrets key generate'." - raise ClanError(msg) + return get_public_key(path.read_text()) + + return None + + +def maybe_get_sops_key(flake_dir: Path) -> SopsKey | None: + pub_key = maybe_get_public_key() + if pub_key: + return maybe_get_user_or_machine(flake_dir, pub_key) + return None + + +def ensure_sops_key(flake_dir: Path) -> SopsKey: + pub_key = maybe_get_public_key() + if not pub_key: + msg = "No sops key found. Please generate one with 'clan secrets key generate'." + raise ClanError(msg) + return ensure_user_or_machine(flake_dir, pub_key) @contextmanager diff --git a/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx b/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx index c83a21b41..b6bdb69ec 100644 --- a/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx +++ b/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx @@ -4,7 +4,7 @@ import { BackButton } from "@/src/components/BackButton"; import { FileInput } from "@/src/components/FileInput"; import { SelectInput } from "@/src/components/SelectInput"; import { TextInput } from "@/src/components/TextInput"; -import { createForm, getValue, reset } from "@modular-forms/solid"; +import { createForm, FieldValues, getValue, reset } from "@modular-forms/solid"; import { useParams } from "@solidjs/router"; import { createQuery } from "@tanstack/solid-query"; import { createSignal, For, Show, Switch, Match } from "solid-js"; @@ -608,8 +608,75 @@ export const MachineDetails = () => { when={query.data} fallback={} > - {(data) => } + {(data) => ( + <> + + + + )} ); }; + +interface WifiForm extends FieldValues { + ssid: string; + password: string; +} + +interface MachineWifiProps { + base_url: string; + machine_name: string; +} +function MachineWifi(props: MachineWifiProps) { + const [formStore, { Form, Field }] = createForm(); + + const handleSubmit = async (values: WifiForm) => { + console.log("submitting", values); + const r = await callApi("set_iwd_service_for_machine", { + base_url: props.base_url, + machine_name: props.machine_name, + networks: { + [values.ssid]: { ssid: values.ssid, password: values.password }, + }, + }); + }; + return ( +
+

MachineWifi

+
+ + {(field, props) => ( + + )} + + + {(field, props) => ( + + )} + + +
+
+ ); +}