Merge pull request 'UI: Init iwd service for single wifi' (#2033) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2024-09-03 15:30:05 +00:00
10 changed files with 470 additions and 267 deletions

View File

@@ -6,20 +6,18 @@ 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,
clan,
facts,
flash,
history,
machines,
secrets,
state,
vars,
@@ -29,7 +27,9 @@ from .clan_uri import FlakeId
from .custom_logger import setup_logging
from .dirs import get_clan_flake_toplevel_or_env
from .errors import ClanCmdError, ClanError
from .facts import cli as facts
from .hyperlink import help_hyperlink
from .machines import cli as machines
from .profiler import profile
from .ssh import cli as ssh_cli

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

@@ -1,133 +0,0 @@
# !/usr/bin/env python3
import argparse
from clan_cli.hyperlink import help_hyperlink
from .check import register_check_parser
from .generate import register_generate_parser
from .list import register_list_parser
from .upload import register_upload_parser
# takes a (sub)parser and configures it
def register_parser(parser: argparse.ArgumentParser) -> None:
subparser = parser.add_subparsers(
title="command",
description="the command to run",
help="the command to run",
required=True,
)
check_parser = subparser.add_parser(
"check",
help="check if facts are up to date",
epilog=(
f"""
This subcommand allows checking if all facts are up to date.
Examples:
$ clan facts check [MACHINE]
Will check facts for the specified machine.
For more detailed information, visit: {help_hyperlink("secrets", "https://docs.clan.lol/getting-started/secrets")}
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_check_parser(check_parser)
list_parser = subparser.add_parser(
"list",
help="list all facts",
epilog=(
f"""
This subcommand allows listing all public facts for a specific machine.
The resulting list will be a json string with the name of the fact as its key
and the fact itself as it's value.
This is how an example output might look like:
```
\u007b
"[FACT_NAME]": "[FACT]"
\u007d
```
Examples:
$ clan facts list [MACHINE]
Will list facts for the specified machine.
For more detailed information, visit: {help_hyperlink("secrets", "https://docs.clan.lol/getting-started/secrets")}
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_list_parser(list_parser)
parser_generate = subparser.add_parser(
"generate",
help="generate public and secret facts for machines",
epilog=(
f"""
This subcommand allows control of the generation of facts.
Often this function will be invoked automatically on deploying machines,
but there are situations the user may want to have more granular control,
especially for the regeneration of certain services.
A service is an included clan-module that implements facts generation functionality.
For example the zerotier module will generate private and public facts.
In this case the public fact will be the resulting zerotier-ip of the machine.
The secret fact will be the zerotier-identity-secret, which is used by zerotier
to prove the machine has control of the zerotier-ip.
Examples:
$ clan facts generate
Will generate facts for all machines.
$ clan facts generate [MACHINE]
Will generate facts for the specified machine.
$ clan facts generate [MACHINE] --service [SERVICE]
Will generate facts for the specified machine for the specified service.
$ clan facts generate --service [SERVICE] --regenerate
Will regenerate facts, if they are already generated for a specific service.
This is especially useful for resetting certain passwords while leaving the rest
of the facts for a machine in place.
For more detailed information, visit: {help_hyperlink("secrets", "https://docs.clan.lol/getting-started/secrets")}
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_generate_parser(parser_generate)
parser_upload = subparser.add_parser(
"upload",
help="upload secrets for machines",
epilog=(
f"""
This subcommand allows uploading secrets to remote machines.
If using sops as a secret backend it will upload the private key to the machine.
If using password store it uploads all the secrets you manage to the machine.
The default backend is sops.
Examples:
$ clan facts upload [MACHINE]
Will upload secrets to a specific machine.
For more detailed information, visit: {help_hyperlink("secrets", "https://docs.clan.lol/getting-started/secrets")}
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_upload_parser(parser_upload)

View File

@@ -0,0 +1,133 @@
# !/usr/bin/env python3
import argparse
from clan_cli.hyperlink import help_hyperlink
from .check import register_check_parser
from .generate import register_generate_parser
from .list import register_list_parser
from .upload import register_upload_parser
# takes a (sub)parser and configures it
def register_parser(parser: argparse.ArgumentParser) -> None:
subparser = parser.add_subparsers(
title="command",
description="the command to run",
help="the command to run",
required=True,
)
check_parser = subparser.add_parser(
"check",
help="check if facts are up to date",
epilog=(
f"""
This subcommand allows checking if all facts are up to date.
Examples:
$ clan facts check [MACHINE]
Will check facts for the specified machine.
For more detailed information, visit: {help_hyperlink("secrets", "https://docs.clan.lol/getting-started/secrets")}
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_check_parser(check_parser)
list_parser = subparser.add_parser(
"list",
help="list all facts",
epilog=(
f"""
This subcommand allows listing all public facts for a specific machine.
The resulting list will be a json string with the name of the fact as its key
and the fact itself as it's value.
This is how an example output might look like:
```
\u007b
"[FACT_NAME]": "[FACT]"
\u007d
```
Examples:
$ clan facts list [MACHINE]
Will list facts for the specified machine.
For more detailed information, visit: {help_hyperlink("secrets", "https://docs.clan.lol/getting-started/secrets")}
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_list_parser(list_parser)
parser_generate = subparser.add_parser(
"generate",
help="generate public and secret facts for machines",
epilog=(
f"""
This subcommand allows control of the generation of facts.
Often this function will be invoked automatically on deploying machines,
but there are situations the user may want to have more granular control,
especially for the regeneration of certain services.
A service is an included clan-module that implements facts generation functionality.
For example the zerotier module will generate private and public facts.
In this case the public fact will be the resulting zerotier-ip of the machine.
The secret fact will be the zerotier-identity-secret, which is used by zerotier
to prove the machine has control of the zerotier-ip.
Examples:
$ clan facts generate
Will generate facts for all machines.
$ clan facts generate [MACHINE]
Will generate facts for the specified machine.
$ clan facts generate [MACHINE] --service [SERVICE]
Will generate facts for the specified machine for the specified service.
$ clan facts generate --service [SERVICE] --regenerate
Will regenerate facts, if they are already generated for a specific service.
This is especially useful for resetting certain passwords while leaving the rest
of the facts for a machine in place.
For more detailed information, visit: {help_hyperlink("secrets", "https://docs.clan.lol/getting-started/secrets")}
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_generate_parser(parser_generate)
parser_upload = subparser.add_parser(
"upload",
help="upload secrets for machines",
epilog=(
f"""
This subcommand allows uploading secrets to remote machines.
If using sops as a secret backend it will upload the private key to the machine.
If using password store it uploads all the secrets you manage to the machine.
The default backend is sops.
Examples:
$ clan facts upload [MACHINE]
Will upload secrets to a specific machine.
For more detailed information, visit: {help_hyperlink("secrets", "https://docs.clan.lol/getting-started/secrets")}
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_upload_parser(parser_upload)

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

@@ -1,115 +0,0 @@
# !/usr/bin/env python3
import argparse
from .create import register_create_parser
from .delete import register_delete_parser
from .hardware import register_hw_generate
from .install import register_install_parser
from .list import register_list_parser
from .update import register_update_parser
# takes a (sub)parser and configures it
def register_parser(parser: argparse.ArgumentParser) -> None:
subparser = parser.add_subparsers(
title="command",
description="the command to run",
help="the command to run",
required=True,
)
update_parser = subparser.add_parser(
"update",
help="Update a machine",
epilog=(
"""
This subcommand provides an interface to update machines managed by clan.
Examples:
$ clan machines update [MACHINES]
Will update the specified machine [MACHINE], if [MACHINE] is omitted, the command
will attempt to update every configured machine.
To exclude machines being updated `clan.deployment.requireExplicitUpdate = true;`
can be set in the machine config.
For more detailed information, visit: https://docs.clan.lol/getting-started/deploy
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_update_parser(update_parser)
create_parser = subparser.add_parser("create", help="Create a machine")
register_create_parser(create_parser)
delete_parser = subparser.add_parser("delete", help="Delete a machine")
register_delete_parser(delete_parser)
list_parser = subparser.add_parser(
"list",
help="List machines",
epilog=(
"""
This subcommand lists all machines managed by this clan.
Examples:
$ clan machines list
Lists all the machines and their descriptions.
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_list_parser(list_parser)
generate_hw_parser = subparser.add_parser(
"hw-generate",
help="Generate hardware specifics for a machine",
description="""
Generates hardware specifics for a machine. Such as the host platform, available kernel modules, etc.
The target must be a Linux based system reachable via SSH.
""",
epilog=(
"""
Examples:
$ clan machines hw-generate [MACHINE] [TARGET_HOST]
Will generate hardware specifics for the the specified `[TARGET_HOST]` and place the result in hardware.nix for the given machine `[MACHINE]`.
For more detailed information, visit: https://docs.clan.lol/getting-started/configure/#machine-configuration
"""
),
)
register_hw_generate(generate_hw_parser)
install_parser = subparser.add_parser(
"install",
help="Install a machine",
description="""
Install a configured machine over the network.
The target must be a Linux based system reachable via SSH.
Installing a machine means overwriting the target's disk.
""",
epilog=(
"""
This subcommand provides an interface to install machines managed by clan.
Examples:
$ clan machines install [MACHINE] [TARGET_HOST]
Will install the specified machine [MACHINE], to the specified [TARGET_HOST].
$ clan machines install [MACHINE] --json [JSON]
Will install the specified machine [MACHINE] to the host exposed by
the deployment information of the [JSON] deployment string.
For information on how to set up the installer see: https://docs.clan.lol/getting-started/installer/
For more detailed information, visit: https://docs.clan.lol/getting-started/deploy
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_install_parser(install_parser)

View File

@@ -0,0 +1,115 @@
# !/usr/bin/env python3
import argparse
from .create import register_create_parser
from .delete import register_delete_parser
from .hardware import register_hw_generate
from .install import register_install_parser
from .list import register_list_parser
from .update import register_update_parser
# takes a (sub)parser and configures it
def register_parser(parser: argparse.ArgumentParser) -> None:
subparser = parser.add_subparsers(
title="command",
description="the command to run",
help="the command to run",
required=True,
)
update_parser = subparser.add_parser(
"update",
help="Update a machine",
epilog=(
"""
This subcommand provides an interface to update machines managed by clan.
Examples:
$ clan machines update [MACHINES]
Will update the specified machine [MACHINE], if [MACHINE] is omitted, the command
will attempt to update every configured machine.
To exclude machines being updated `clan.deployment.requireExplicitUpdate = true;`
can be set in the machine config.
For more detailed information, visit: https://docs.clan.lol/getting-started/deploy
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_update_parser(update_parser)
create_parser = subparser.add_parser("create", help="Create a machine")
register_create_parser(create_parser)
delete_parser = subparser.add_parser("delete", help="Delete a machine")
register_delete_parser(delete_parser)
list_parser = subparser.add_parser(
"list",
help="List machines",
epilog=(
"""
This subcommand lists all machines managed by this clan.
Examples:
$ clan machines list
Lists all the machines and their descriptions.
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_list_parser(list_parser)
generate_hw_parser = subparser.add_parser(
"hw-generate",
help="Generate hardware specifics for a machine",
description="""
Generates hardware specifics for a machine. Such as the host platform, available kernel modules, etc.
The target must be a Linux based system reachable via SSH.
""",
epilog=(
"""
Examples:
$ clan machines hw-generate [MACHINE] [TARGET_HOST]
Will generate hardware specifics for the the specified `[TARGET_HOST]` and place the result in hardware.nix for the given machine `[MACHINE]`.
For more detailed information, visit: https://docs.clan.lol/getting-started/configure/#machine-configuration
"""
),
)
register_hw_generate(generate_hw_parser)
install_parser = subparser.add_parser(
"install",
help="Install a machine",
description="""
Install a configured machine over the network.
The target must be a Linux based system reachable via SSH.
Installing a machine means overwriting the target's disk.
""",
epilog=(
"""
This subcommand provides an interface to install machines managed by clan.
Examples:
$ clan machines install [MACHINE] [TARGET_HOST]
Will install the specified machine [MACHINE], to the specified [TARGET_HOST].
$ clan machines install [MACHINE] --json [JSON]
Will install the specified machine [MACHINE] to the host exposed by
the deployment information of the [JSON] deployment string.
For information on how to set up the installer see: https://docs.clan.lol/getting-started/installer/
For more detailed information, visit: https://docs.clan.lol/getting-started/deploy
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_install_parser(install_parser)

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>
);
}