UI: Init iwd service for single wifi

This commit is contained in:
Johannes Kirschbauer
2024-09-03 16:57:40 +02:00
parent f5e6bba637
commit 6e595c3f60
6 changed files with 220 additions and 17 deletions

View File

@@ -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,

View File

@@ -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],
)

View File

@@ -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:

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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={<span class="loading loading-lg"></span>}
>
{(data) => <MachineForm initialData={data()} />}
{(data) => (
<>
<MachineForm initialData={data()} />
<MachineWifi
base_url={activeURI() || ""}
machine_name={data().machine.name}
/>
</>
)}
</Show>
</div>
);
};
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<WifiForm>();
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 (
<div>
<h1>MachineWifi</h1>
<Form onSubmit={handleSubmit}>
<Field name="ssid">
{(field, props) => (
<TextInput
formStore={formStore}
inputProps={props}
label="Name"
value={field.value ?? ""}
error={field.error}
required
/>
)}
</Field>
<Field name="password">
{(field, props) => (
<TextInput
formStore={formStore}
inputProps={props}
label="Password"
value={field.value ?? ""}
error={field.error}
type="password"
required
/>
)}
</Field>
<button class="btn" type="submit">
<span>Submit</span>
</button>
</Form>
</div>
);
}