Configure hotspot credentials from dashboard

This commit is contained in:
2026-07-31 20:41:16 +02:00
parent e52d2690ee
commit d1239d66af
19 changed files with 443 additions and 23 deletions
+68 -8
View File
@@ -1,9 +1,13 @@
import { randomUUID } from "node:crypto";
import { chmod, readFile, rename, writeFile } from "node:fs/promises";
import { execFile } from "node:child_process";
import { chmod, readFile, rename, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { z } from "zod";
import type { AppConfig } from "./config.js";
const execFileAsync = promisify(execFile);
export const networkModeSchema = z.enum([
"disabled",
"starting",
@@ -33,6 +37,7 @@ export const networkStatusSchema = z.object({
probeDeadline: z.string().nullable(),
lastFailure: z.string().max(500).nullable(),
connectivity: z.enum(["full", "limited", "portal", "none", "unknown"]),
hotspotSsid: z.string().max(32).nullable(),
hotspot: adapterSchema,
upstream: adapterSchema
});
@@ -41,6 +46,24 @@ export type NetworkStatus = z.infer<typeof networkStatusSchema> & { supported: b
export const networkActionSchema = z.enum(["retry-upstream", "restore-hotspot"]);
export type NetworkAction = z.infer<typeof networkActionSchema>;
export const hotspotConfigurationSchema = z.object({
ssid: z
.string()
.trim()
.min(1)
.max(32)
.regex(/^[a-zA-Z0-9_. -]+$/, "Use letters, numbers, spaces, dots, hyphens, or underscores"),
password: z.string().min(8).max(63).regex(/^[\x20-\x7e]+$/, "Use printable characters only")
});
export type HotspotConfiguration = z.infer<typeof hotspotConfigurationSchema>;
const hotspotConfigurationRequestSchema = hotspotConfigurationSchema.extend({
id: z.string().uuid(),
requestedAt: z.string(),
actorUserId: z.number().int().positive()
});
export type HotspotConfigurationRequest = z.infer<typeof hotspotConfigurationRequestSchema>;
const commandSchema = z.object({
id: z.string().uuid(),
action: networkActionSchema,
@@ -49,20 +72,20 @@ const commandSchema = z.object({
});
export type NetworkCommand = z.infer<typeof commandSchema>;
async function atomicJsonWrite(path: string, value: unknown): Promise<void> {
async function atomicJsonWrite(path: string, value: unknown, mode = 0o640): Promise<void> {
const temporary = join(dirname(path), `.${randomUUID()}.tmp`);
await writeFile(temporary, `${JSON.stringify(value)}\n`, { mode: 0o640 });
await chmod(temporary, 0o640);
await writeFile(temporary, `${JSON.stringify(value)}\n`, { mode });
await chmod(temporary, mode);
await rename(temporary, path);
}
export async function readNetworkStatus(config: AppConfig): Promise<NetworkStatus> {
if (!config.network.enabled) {
return unsupportedStatus("Automatic network management is disabled until its hotspot and upstream profiles are configured.");
}
try {
return { ...networkStatusSchema.parse(JSON.parse(await readFile(config.network.statusPath, "utf8"))), supported: true };
} catch {
if (!config.network.enabled) {
return unsupportedStatus("Set the hotspot name and password below to enable automatic network management.");
}
return {
...unsupportedStatus("The network controller is starting or its status is unavailable."),
enabled: true,
@@ -76,12 +99,48 @@ export async function requestNetworkAction(
action: NetworkAction,
actorUserId: number
): Promise<NetworkCommand> {
if (!config.network.enabled) throw new Error("NETWORK_MANAGER_DISABLED");
if (!(await readNetworkStatus(config)).enabled) throw new Error("NETWORK_MANAGER_DISABLED");
const command = commandSchema.parse({ id: randomUUID(), action, requestedAt: new Date().toISOString(), actorUserId });
await atomicJsonWrite(config.network.commandPath, command);
return command;
}
export async function requestHotspotConfiguration(
config: AppConfig,
input: HotspotConfiguration,
actorUserId: number,
trigger: () => Promise<void> = triggerHotspotConfiguration
): Promise<HotspotConfigurationRequest> {
const request = hotspotConfigurationRequestSchema.parse({
...input,
id: randomUUID(),
requestedAt: new Date().toISOString(),
actorUserId
});
await writeFile(config.network.configurationRequestPath, `${JSON.stringify(request)}\n`, { flag: "wx", mode: 0o600 });
try {
await trigger();
} catch (error) {
await rm(config.network.configurationRequestPath, { force: true });
throw error;
}
return request;
}
async function triggerHotspotConfiguration(): Promise<void> {
await execFileAsync(
"/usr/bin/sudo",
["-n", "/usr/bin/systemctl", "start", "--no-block", "pi-car-companion-network-configure.service"],
{ timeout: 10_000, maxBuffer: 16 * 1024 }
);
}
export async function consumeHotspotConfigurationRequest(path: string): Promise<HotspotConfigurationRequest> {
const content = await readFile(path, "utf8");
await rm(path, { force: true });
return hotspotConfigurationRequestSchema.parse(JSON.parse(content));
}
export async function consumeNetworkCommand(path: string): Promise<NetworkCommand | null> {
try {
const parsed = commandSchema.parse(JSON.parse(await readFile(path, "utf8")));
@@ -105,6 +164,7 @@ function unsupportedStatus(message: string): NetworkStatus {
probeDeadline: null,
lastFailure: null,
connectivity: "unknown",
hotspotSsid: null,
hotspot: adapter,
upstream: adapter
};