177 lines
5.8 KiB
TypeScript
177 lines
5.8 KiB
TypeScript
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<typeof networkModeSchema>;
|
|
|
|
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<typeof networkStatusSchema> & { supported: boolean };
|
|
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,
|
|
requestedAt: z.string(),
|
|
actorUserId: z.number().int().positive()
|
|
});
|
|
export type NetworkCommand = z.infer<typeof commandSchema>;
|
|
|
|
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 });
|
|
await chmod(temporary, mode);
|
|
await rename(temporary, path);
|
|
}
|
|
|
|
export async function readNetworkStatus(config: AppConfig): Promise<NetworkStatus> {
|
|
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<NetworkCommand> {
|
|
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")));
|
|
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<typeof networkStatusSchema>): Promise<void> {
|
|
await atomicJsonWrite(path, networkStatusSchema.parse(status));
|
|
await chmod(path, 0o644);
|
|
}
|