Add resilient dual-radio network failover
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { chmod, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { AppConfig } from "./config.js";
|
||||
|
||||
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"]),
|
||||
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>;
|
||||
|
||||
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): Promise<void> {
|
||||
const temporary = join(dirname(path), `.${randomUUID()}.tmp`);
|
||||
await writeFile(temporary, `${JSON.stringify(value)}\n`, { mode: 0o640 });
|
||||
await chmod(temporary, 0o640);
|
||||
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 {
|
||||
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 (!config.network.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 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",
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user