Configure hotspot credentials from dashboard
This commit is contained in:
+68
-8
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user