import { randomUUID } from "node:crypto"; 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", "dual_radio", "built_in_upstream", "probing_built_in", "offline_hotspot", "degraded", "error" ]); export type NetworkMode = z.infer; const adapterSchema = z.object({ interface: z.string().max(64), available: z.boolean(), connected: z.boolean(), connection: z.string().max(128).nullable(), address: z.string().max(128).nullable() }); export const networkStatusSchema = z.object({ enabled: z.boolean(), mode: networkModeSchema, message: z.string().max(500), updatedAt: z.string(), lastTransitionAt: z.string(), 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 }); export type NetworkStatus = z.infer & { supported: boolean }; export const networkActionSchema = z.enum(["retry-upstream", "restore-hotspot"]); export type NetworkAction = z.infer; 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; const hotspotConfigurationRequestSchema = hotspotConfigurationSchema.extend({ id: z.string().uuid(), requestedAt: z.string(), actorUserId: z.number().int().positive() }); export type HotspotConfigurationRequest = z.infer; const commandSchema = z.object({ id: z.string().uuid(), action: networkActionSchema, requestedAt: z.string(), actorUserId: z.number().int().positive() }); export type NetworkCommand = z.infer; async function atomicJsonWrite(path: string, value: unknown, mode = 0o640): Promise { const temporary = join(dirname(path), `.${randomUUID()}.tmp`); await writeFile(temporary, `${JSON.stringify(value)}\n`, { mode }); await chmod(temporary, mode); await rename(temporary, path); } export async function readNetworkStatus(config: AppConfig): Promise { 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, mode: "starting" }; } } export async function requestNetworkAction( config: AppConfig, action: NetworkAction, actorUserId: number ): Promise { 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 = triggerHotspotConfiguration ): Promise { 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 { 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 { const content = await readFile(path, "utf8"); await rm(path, { force: true }); return hotspotConfigurationRequestSchema.parse(JSON.parse(content)); } export async function consumeNetworkCommand(path: string): Promise { try { const parsed = commandSchema.parse(JSON.parse(await readFile(path, "utf8"))); await writeFile(path, "", { mode: 0o640 }); return parsed; } catch { return null; } } function unsupportedStatus(message: string): NetworkStatus { const epoch = new Date(0).toISOString(); const adapter = { interface: "unconfigured", available: false, connected: false, connection: null, address: null }; return { supported: false, enabled: false, mode: "disabled", message, updatedAt: epoch, lastTransitionAt: epoch, probeDeadline: null, lastFailure: null, connectivity: "unknown", hotspotSsid: null, hotspot: adapter, upstream: adapter }; } export async function writeNetworkStatus(path: string, status: z.infer): Promise { await atomicJsonWrite(path, networkStatusSchema.parse(status)); await chmod(path, 0o644); }