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:
@@ -24,11 +24,18 @@ from .nix import nix_build, nix_shell
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WifiConfig:
|
||||||
|
ssid: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SystemConfig:
|
class SystemConfig:
|
||||||
language: str | None = field(default=None)
|
language: str | None = field(default=None)
|
||||||
keymap: str | None = field(default=None)
|
keymap: str | None = field(default=None)
|
||||||
ssh_keys_path: list[str] | None = field(default=None)
|
ssh_keys_path: list[str] | None = field(default=None)
|
||||||
|
wifi_settings: list[WifiConfig] | None = field(default=None)
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
@@ -91,6 +98,12 @@ def flash_machine(
|
|||||||
) -> None:
|
) -> None:
|
||||||
system_config_nix: dict[str, Any] = {}
|
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:
|
||||||
if system_config.language not in list_possible_languages():
|
if system_config.language not in list_possible_languages():
|
||||||
raise ClanError(
|
raise ClanError(
|
||||||
@@ -214,6 +227,7 @@ def flash_command(args: argparse.Namespace) -> None:
|
|||||||
language=args.language,
|
language=args.language,
|
||||||
keymap=args.keymap,
|
keymap=args.keymap,
|
||||||
ssh_keys_path=args.ssh_pubkey,
|
ssh_keys_path=args.ssh_pubkey,
|
||||||
|
wifi_settings=None,
|
||||||
),
|
),
|
||||||
write_efi_boot_entries=args.write_efi_boot_entries,
|
write_efi_boot_entries=args.write_efi_boot_entries,
|
||||||
nix_options=args.option,
|
nix_options=args.option,
|
||||||
@@ -229,6 +243,12 @@ def flash_command(args: argparse.Namespace) -> None:
|
|||||||
print(keymap)
|
print(keymap)
|
||||||
return
|
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)
|
machine = Machine(opts.machine, flake=opts.flake)
|
||||||
if opts.confirm and not opts.dry_run:
|
if opts.confirm and not opts.dry_run:
|
||||||
disk_str = ", ".join(f"{name}={device}" for name, device in opts.disks.items())
|
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.
|
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(
|
parser.add_argument(
|
||||||
"--mode",
|
"--mode",
|
||||||
type=str,
|
type=str,
|
||||||
|
|||||||
60
pkgs/installer/base64.nix
Normal file
60
pkgs/installer/base64.nix
Normal 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 ]);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ let
|
|||||||
{ config, ... }:
|
{ config, ... }:
|
||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
self.clanModules.iwd
|
./iwd.nix
|
||||||
self.nixosModules.installer
|
self.nixosModules.installer
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
67
pkgs/installer/iwd.nix
Normal file
67
pkgs/installer/iwd.nix
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@ import { type JSX } from "solid-js";
|
|||||||
interface TextInputProps<T extends FieldValues, R extends ResponseData> {
|
interface TextInputProps<T extends FieldValues, R extends ResponseData> {
|
||||||
formStore: FormStore<T, R>;
|
formStore: FormStore<T, R>;
|
||||||
value: string;
|
value: string;
|
||||||
inputProps: JSX.HTMLAttributes<HTMLInputElement>;
|
inputProps: JSX.InputHTMLAttributes<HTMLInputElement>;
|
||||||
label: JSX.Element;
|
label: JSX.Element;
|
||||||
error?: string;
|
error?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
type?: string;
|
||||||
inlineLabel?: JSX.Element;
|
inlineLabel?: JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ export function TextInput<T extends FieldValues, R extends ResponseData>(
|
|||||||
<input
|
<input
|
||||||
{...props.inputProps}
|
{...props.inputProps}
|
||||||
value={props.value}
|
value={props.value}
|
||||||
type="text"
|
type={props.type ? props.type : "text"}
|
||||||
class="grow"
|
class="grow"
|
||||||
classList={{
|
classList={{
|
||||||
"input-disabled": props.formStore.submitting,
|
"input-disabled": props.formStore.submitting,
|
||||||
|
|||||||
@@ -2,11 +2,22 @@ import { callApi, OperationResponse } from "@/src/api";
|
|||||||
import { FileInput } from "@/src/components/FileInput";
|
import { FileInput } from "@/src/components/FileInput";
|
||||||
import { SelectInput } from "@/src/components/SelectInput";
|
import { SelectInput } from "@/src/components/SelectInput";
|
||||||
import { TextInput } from "@/src/components/TextInput";
|
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 { createQuery } from "@tanstack/solid-query";
|
||||||
import { For } from "solid-js";
|
import { createEffect, createSignal, For } from "solid-js";
|
||||||
import toast from "solid-toast";
|
import toast from "solid-toast";
|
||||||
|
|
||||||
|
interface Wifi extends FieldValues {
|
||||||
|
ssid: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface FlashFormValues extends FieldValues {
|
interface FlashFormValues extends FieldValues {
|
||||||
machine: {
|
machine: {
|
||||||
devicePath: string;
|
devicePath: string;
|
||||||
@@ -15,6 +26,7 @@ interface FlashFormValues extends FieldValues {
|
|||||||
disk: string;
|
disk: string;
|
||||||
language: string;
|
language: string;
|
||||||
keymap: string;
|
keymap: string;
|
||||||
|
wifi: Wifi[];
|
||||||
sshKeys: File[];
|
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(() => ({
|
const deviceQuery = createQuery(() => ({
|
||||||
queryKey: ["block_devices"],
|
queryKey: ["block_devices"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -86,6 +136,7 @@ export const Flash = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (values: FlashFormValues) => {
|
const handleSubmit = async (values: FlashFormValues) => {
|
||||||
|
console.log("Submit WiFi Networks:", values.wifi);
|
||||||
console.log(
|
console.log(
|
||||||
"Submit SSH Keys:",
|
"Submit SSH Keys:",
|
||||||
values.sshKeys.map((file) => file.name),
|
values.sshKeys.map((file) => file.name),
|
||||||
@@ -104,6 +155,10 @@ export const Flash = () => {
|
|||||||
language: values.language,
|
language: values.language,
|
||||||
keymap: values.keymap,
|
keymap: values.keymap,
|
||||||
ssh_keys_path: values.sshKeys.map((file) => file.name),
|
ssh_keys_path: values.sshKeys.map((file) => file.name),
|
||||||
|
wifi_settings: values.wifi.map((network) => ({
|
||||||
|
ssid: network.ssid,
|
||||||
|
password: network.password,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
write_efi_boot_entries: false,
|
write_efi_boot_entries: false,
|
||||||
@@ -250,6 +305,78 @@ export const Flash = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</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
|
<button
|
||||||
class="btn btn-error"
|
class="btn btn-error"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
Reference in New Issue
Block a user