Merge pull request 'clan-cli: Add --wifi option to set wifi credentials. clan-app: Add wifi settings form to flash view' (#1862) from Qubasa/clan-core:Qubasa-Qubasa-main into main

This commit is contained in:
clan-bot
2024-08-07 19:40:42 +00:00
6 changed files with 289 additions and 5 deletions

View File

@@ -24,11 +24,18 @@ from .nix import nix_build, nix_shell
log = logging.getLogger(__name__)
@dataclass
class WifiConfig:
ssid: str
password: str
@dataclass
class SystemConfig:
language: str | None = field(default=None)
keymap: str | None = field(default=None)
ssh_keys_path: list[str] | None = field(default=None)
wifi_settings: list[WifiConfig] | None = field(default=None)
@API.register
@@ -91,6 +98,12 @@ def flash_machine(
) -> None:
system_config_nix: dict[str, Any] = {}
if system_config.wifi_settings:
wifi_settings = {}
for wifi in system_config.wifi_settings:
wifi_settings[wifi.ssid] = {"password": wifi.password}
system_config_nix["clan"] = {"iwd": {"networks": wifi_settings}}
if system_config.language:
if system_config.language not in list_possible_languages():
raise ClanError(
@@ -214,6 +227,7 @@ def flash_command(args: argparse.Namespace) -> None:
language=args.language,
keymap=args.keymap,
ssh_keys_path=args.ssh_pubkey,
wifi_settings=None,
),
write_efi_boot_entries=args.write_efi_boot_entries,
nix_options=args.option,
@@ -229,6 +243,12 @@ def flash_command(args: argparse.Namespace) -> None:
print(keymap)
return
if args.wifi:
opts.system_config.wifi_settings = [
WifiConfig(ssid=ssid, password=password)
for ssid, password in args.wifi.items()
]
machine = Machine(opts.machine, flake=opts.flake)
if opts.confirm and not opts.dry_run:
disk_str = ", ".join(f"{name}={device}" for name, device in opts.disks.items())
@@ -277,6 +297,15 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
Mount is useful for updating an existing system without losing data.
"""
)
parser.add_argument(
"--wifi",
type=str,
nargs=2,
metavar=("ssid", "password"),
action=AppendDiskAction,
help="wifi network to connect to",
default={},
)
parser.add_argument(
"--mode",
type=str,

60
pkgs/installer/base64.nix Normal file
View File

@@ -0,0 +1,60 @@
{ lib, ... }:
{
toBase64 =
text:
let
inherit (lib)
sublist
mod
stringToCharacters
concatMapStrings
;
inherit (lib.strings) charToInt;
inherit (builtins)
substring
foldl'
genList
elemAt
length
concatStringsSep
stringLength
;
lookup = stringToCharacters "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
sliceN =
size: list: n:
sublist (n * size) size list;
pows = [
(64 * 64 * 64)
(64 * 64)
64
1
];
intSextets = i: map (j: mod (i / j) 64) pows;
compose =
f: g: x:
f (g x);
intToChar = elemAt lookup;
convertTripletInt = sliceInt: concatMapStrings intToChar (intSextets sliceInt);
sliceToInt = foldl' (acc: val: acc * 256 + val) 0;
convertTriplet = compose convertTripletInt sliceToInt;
join = concatStringsSep "";
convertLastSlice =
slice:
let
len = length slice;
in
if len == 1 then
(substring 0 2 (convertTripletInt ((sliceToInt slice) * 256 * 256))) + "=="
else if len == 2 then
(substring 0 3 (convertTripletInt ((sliceToInt slice) * 256))) + "="
else
"";
len = stringLength text;
nFullSlices = len / 3;
bytes = map charToInt (stringToCharacters text);
tripletAt = sliceN 3 bytes;
head = genList (compose convertTriplet tripletAt) nFullSlices;
tail = convertLastSlice (tripletAt nFullSlices);
in
join (head ++ [ tail ]);
}

View File

@@ -6,7 +6,7 @@ let
{ config, ... }:
{
imports = [
self.clanModules.iwd
./iwd.nix
self.nixosModules.installer
];

67
pkgs/installer/iwd.nix Normal file
View File

@@ -0,0 +1,67 @@
{
lib,
pkgs,
config,
...
}:
let
cfg = config.clan.iwd;
toBase64 = (pkgs.callPackage ./base64.nix { inherit lib; }).toBase64;
wifi_config = password: ''
[Security]
Passphrase=${password}
'';
in
{
options.clan.iwd = {
networks = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options = {
ssid = lib.mkOption {
type = lib.types.strMatching "^[a-zA-Z0-9._-]+$";
default = name;
description = "The name of the wifi network";
};
password = lib.mkOption {
type = lib.types.str;
description = "The password of the wifi network";
};
};
}
)
);
default = { };
description = "Wifi networks to predefine";
};
};
config = lib.mkMerge [
(lib.mkIf (cfg.networks != { }) {
# Systemd tmpfiles rule to create /var/lib/iwd/example.psk file
systemd.tmpfiles.rules = lib.mapAttrsToList (
_: value:
"f+~ /var/lib/iwd/${value.ssid}.psk 0600 root root - ${toBase64 (wifi_config value.password)}"
) cfg.networks;
})
{
# disable wpa supplicant
networking.wireless.enable = false;
# Use iwd instead of wpa_supplicant. It has a user friendly CLI
networking.wireless.iwd = {
enable = true;
settings = {
Network = {
EnableIPv6 = true;
RoutePriorityOffset = 300;
};
Settings.AutoConnect = true;
};
};
}
];
}

View File

@@ -4,10 +4,11 @@ import { type JSX } from "solid-js";
interface TextInputProps<T extends FieldValues, R extends ResponseData> {
formStore: FormStore<T, R>;
value: string;
inputProps: JSX.HTMLAttributes<HTMLInputElement>;
inputProps: JSX.InputHTMLAttributes<HTMLInputElement>;
label: JSX.Element;
error?: string;
required?: boolean;
type?: string;
inlineLabel?: JSX.Element;
}
@@ -36,7 +37,7 @@ export function TextInput<T extends FieldValues, R extends ResponseData>(
<input
{...props.inputProps}
value={props.value}
type="text"
type={props.type ? props.type : "text"}
class="grow"
classList={{
"input-disabled": props.formStore.submitting,

View File

@@ -2,11 +2,22 @@ import { callApi, OperationResponse } from "@/src/api";
import { FileInput } from "@/src/components/FileInput";
import { SelectInput } from "@/src/components/SelectInput";
import { TextInput } from "@/src/components/TextInput";
import { createForm, required, FieldValues } from "@modular-forms/solid";
import {
createForm,
required,
FieldValues,
setValue,
getValue,
} from "@modular-forms/solid";
import { createQuery } from "@tanstack/solid-query";
import { For } from "solid-js";
import { createEffect, createSignal, For } from "solid-js";
import toast from "solid-toast";
interface Wifi extends FieldValues {
ssid: string;
password: string;
}
interface FlashFormValues extends FieldValues {
machine: {
devicePath: string;
@@ -15,6 +26,7 @@ interface FlashFormValues extends FieldValues {
disk: string;
language: string;
keymap: string;
wifi: Wifi[];
sshKeys: File[];
}
@@ -30,6 +42,44 @@ export const Flash = () => {
},
});
/* ==== WIFI NETWORK ==== */
const [wifiNetworks, setWifiNetworks] = createSignal<Wifi[]>([]);
const [passwordVisibility, setPasswordVisibility] = createSignal<boolean[]>(
[],
);
createEffect(() => {
const formWifi = getValue(formStore, "wifi");
if (formWifi !== undefined) {
setWifiNetworks(formWifi as Wifi[]);
setPasswordVisibility(new Array(formWifi.length).fill(false));
}
});
const addWifiNetwork = () => {
const updatedNetworks = [...wifiNetworks(), { ssid: "", password: "" }];
setWifiNetworks(updatedNetworks);
setPasswordVisibility([...passwordVisibility(), false]);
setValue(formStore, "wifi", updatedNetworks);
};
const removeWifiNetwork = (index: number) => {
const updatedNetworks = wifiNetworks().filter((_, i) => i !== index);
setWifiNetworks(updatedNetworks);
const updatedVisibility = passwordVisibility().filter(
(_, i) => i !== index,
);
setPasswordVisibility(updatedVisibility);
setValue(formStore, "wifi", updatedNetworks);
};
const togglePasswordVisibility = (index: number) => {
const updatedVisibility = [...passwordVisibility()];
updatedVisibility[index] = !updatedVisibility[index];
setPasswordVisibility(updatedVisibility);
};
/* ==== END OF WIFI NETWORK ==== */
const deviceQuery = createQuery(() => ({
queryKey: ["block_devices"],
queryFn: async () => {
@@ -86,6 +136,7 @@ export const Flash = () => {
};
const handleSubmit = async (values: FlashFormValues) => {
console.log("Submit WiFi Networks:", values.wifi);
console.log(
"Submit SSH Keys:",
values.sshKeys.map((file) => file.name),
@@ -104,6 +155,10 @@ export const Flash = () => {
language: values.language,
keymap: values.keymap,
ssh_keys_path: values.sshKeys.map((file) => file.name),
wifi_settings: values.wifi.map((network) => ({
ssid: network.ssid,
password: network.password,
})),
},
dry_run: false,
write_efi_boot_entries: false,
@@ -250,6 +305,78 @@ export const Flash = () => {
</>
)}
</Field>
{/* WiFi Networks */}
<div class="mb-4">
<h3 class="text-lg font-semibold mb-2">WiFi Networks</h3>
<For each={wifiNetworks()}>
{(network, index) => (
<div class="flex gap-2 mb-2">
<Field
name={`wifi.${index()}.ssid`}
validate={[required("SSID is required")]}
>
{(field, props) => (
<TextInput
formStore={formStore}
inputProps={props}
label="SSID"
value={field.value ?? ""}
error={field.error}
required
/>
)}
</Field>
<Field
name={`wifi.${index()}.password`}
validate={[required("Password is required")]}
>
{(field, props) => (
<div class="relative w-full">
<TextInput
formStore={formStore}
inputProps={props}
type={
passwordVisibility()[index()] ? "text" : "password"
}
label="Password"
value={field.value ?? ""}
error={field.error}
required
/>
<button
type="button"
class="absolute inset-y-14 right-0 pr-3 flex items-center text-sm leading-5"
onClick={() => togglePasswordVisibility(index())}
>
<span class="material-icons">
{passwordVisibility()[index()]
? "visibility_off"
: "visibility"}
</span>
</button>
</div>
)}
</Field>
<button
type="button"
class="btn btn-error"
onClick={() => removeWifiNetwork(index())}
>
<span class="material-icons">delete</span>
</button>
</div>
)}
</For>
<button
type="button"
class="btn btn-primary"
onClick={addWifiNetwork}
>
<span class="material-icons">add</span> Add WiFi Network
</button>
</div>
<button
class="btn btn-error"
type="submit"