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
+46 -1
View File
@@ -19,7 +19,13 @@ import {
} from "./security.js";
import { collectSystemStatus } from "./status.js";
import { readUpdateStatus, triggerSystemUpdate } from "./update.js";
import { networkActionSchema, readNetworkStatus, requestNetworkAction } from "./network.js";
import {
hotspotConfigurationSchema,
networkActionSchema,
readNetworkStatus,
requestHotspotConfiguration,
requestNetworkAction
} from "./network.js";
import { collectAdbStatus } from "./adb.js";
import "./types.js";
@@ -319,6 +325,45 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
return readNetworkStatus(config);
});
app.post(
"/api/network/configuration",
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },
async (request, reply) => {
if (!requireUser(request, reply)) return;
const parsed = hotspotConfigurationSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
error: {
code: "INVALID_HOTSPOT_CONFIGURATION",
message: parsed.error.issues[0]?.message ?? "The hotspot settings are invalid"
}
});
}
try {
const configuration = await requestHotspotConfiguration(config, parsed.data, request.authUser!.id);
audit(database, {
actorUserId: request.authUser!.id,
action: "network.configure-hotspot",
result: "success",
ipAddress: clientAddress(request),
details: { requestId: configuration.id }
});
return reply.code(202).send({ accepted: true, requestId: configuration.id });
} catch {
audit(database, {
actorUserId: request.authUser!.id,
action: "network.configure-hotspot",
result: "failure",
ipAddress: clientAddress(request),
details: { reason: "configuration_service_unavailable" }
});
return reply.code(503).send({
error: { code: "HOTSPOT_CONFIGURATION_UNAVAILABLE", message: "The hotspot configuration service is unavailable" }
});
}
}
);
app.post("/api/network/actions", async (request, reply) => {
if (!requireUser(request, reply)) return;
const parsed = z.object({ action: networkActionSchema }).safeParse(request.body);
+4 -1
View File
@@ -20,6 +20,7 @@ const schema = z.object({
NETWORK_MANAGER_ENABLED: booleanFromString,
NETWORK_STATUS_PATH: z.string().default("/var/lib/pi-car-companion/network-status.json"),
NETWORK_COMMAND_PATH: z.string().default("/var/lib/pi-car-companion/network-command.json"),
NETWORK_CONFIGURATION_REQUEST_PATH: z.string().default("/var/lib/pi-car-companion/network-configuration-request.json"),
ADB_ENABLED: booleanFromString,
ADB_PATH: z.string().min(1).default("adb"),
ADB_SERIAL: z.string().max(128).regex(/^[a-zA-Z0-9._:-]*$/).default(""),
@@ -40,6 +41,7 @@ export type AppConfig = {
enabled: boolean;
statusPath: string;
commandPath: string;
configurationRequestPath: string;
};
adb: AdbConfig;
};
@@ -71,7 +73,8 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
network: {
enabled: parsed.NETWORK_MANAGER_ENABLED,
statusPath: parsed.NETWORK_STATUS_PATH,
commandPath: parsed.NETWORK_COMMAND_PATH
commandPath: parsed.NETWORK_COMMAND_PATH,
configurationRequestPath: parsed.NETWORK_CONFIGURATION_REQUEST_PATH
},
adb: {
enabled: parsed.ADB_ENABLED,
+136
View File
@@ -0,0 +1,136 @@
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { chmod, chown, readFile, rename, stat, writeFile } from "node:fs/promises";
import { z } from "zod";
import { consumeHotspotConfigurationRequest, writeNetworkStatus } from "./network.js";
const config = z
.object({
NETWORK_CONFIGURATION_REQUEST_PATH: z.string().default("/var/lib/pi-car-companion/network-configuration-request.json"),
NETWORK_STATUS_PATH: z.string().default("/var/lib/pi-car-companion/network-status.json"),
NETWORK_HOTSPOT_INTERFACE: z.string().regex(/^[a-zA-Z0-9_.-]+$/).default("wlan0"),
NETWORK_UPSTREAM_INTERFACE: z.string().regex(/^[a-zA-Z0-9_.-]+$/).default("wlan1"),
NETWORK_HOTSPOT_CONNECTION: z.string().min(1).max(128).default("pi-car-hotspot"),
NETWORK_ENV_PATH: z.string().default("/etc/pi-car-companion/companion.env"),
NETWORK_PROFILE_DIR: z.string().default("/etc/NetworkManager/system-connections")
})
.parse(process.env);
async function command(executable: string, args: string[], timeoutMs = 20_000): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("Command timed out"));
}, timeoutMs);
child.stdout.on("data", (chunk: Buffer) => {
if (stdout.length < 32_768) stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk: Buffer) => {
if (stderr.length < 32_768) stderr += chunk.toString("utf8");
});
child.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0) resolve(stdout.trim());
else reject(new Error(stderr.trim() || `Command exited with status ${code ?? "unknown"}`));
});
});
}
async function derivePsk(ssid: string, password: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn("/usr/bin/wpa_passphrase", [ssid], { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("WPA key derivation timed out"));
}, 10_000);
child.stdout.on("data", (chunk: Buffer) => {
if (stdout.length < 4096) stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk: Buffer) => {
if (stderr.length < 4096) stderr += chunk.toString("utf8");
});
child.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timer);
const psk = stdout.match(/^\s*psk=([a-fA-F0-9]{64})$/m)?.[1];
if (code === 0 && psk) resolve(psk);
else reject(new Error(stderr.trim() || "The WPA key could not be derived"));
});
child.stdin.end(`${password}\n`);
});
}
async function updateEnvironment(path: string): Promise<void> {
const values: Record<string, string> = {
NETWORK_MANAGER_ENABLED: "true",
NETWORK_HOTSPOT_INTERFACE: config.NETWORK_HOTSPOT_INTERFACE,
NETWORK_UPSTREAM_INTERFACE: config.NETWORK_UPSTREAM_INTERFACE,
NETWORK_HOTSPOT_CONNECTION: config.NETWORK_HOTSPOT_CONNECTION,
NETWORK_PROBE_TIMEOUT_SECONDS: "60",
NETWORK_STABILITY_SECONDS: "10",
NETWORK_POLL_SECONDS: "5"
};
const metadata = await stat(path);
let content = await readFile(path, "utf8");
for (const [key, value] of Object.entries(values)) {
const expression = new RegExp(`^${key}=.*$`, "m");
content = expression.test(content) ? content.replace(expression, `${key}=${value}`) : `${content.trimEnd()}\n${key}=${value}\n`;
}
const temporary = `${path}.network-${randomUUID()}.tmp`;
await writeFile(temporary, content, { mode: 0o640 });
await chown(temporary, metadata.uid, metadata.gid);
await chmod(temporary, 0o640);
await rename(temporary, path);
}
async function writeFailure(message: string): Promise<void> {
const now = new Date().toISOString();
await writeNetworkStatus(config.NETWORK_STATUS_PATH, {
enabled: false,
mode: "error",
message: "Hotspot configuration failed. The previous network profile was left unchanged where possible.",
updatedAt: now,
lastTransitionAt: now,
probeDeadline: null,
lastFailure: message.slice(0, 500),
connectivity: "unknown",
hotspotSsid: null,
hotspot: { interface: config.NETWORK_HOTSPOT_INTERFACE, available: true, connected: false, connection: null, address: null },
upstream: { interface: config.NETWORK_UPSTREAM_INTERFACE, available: true, connected: false, connection: null, address: null }
});
}
try {
const request = await consumeHotspotConfigurationRequest(config.NETWORK_CONFIGURATION_REQUEST_PATH);
const psk = await derivePsk(request.ssid, request.password);
const profilePath = `${config.NETWORK_PROFILE_DIR}/${config.NETWORK_HOTSPOT_CONNECTION}.nmconnection`;
const temporaryProfile = `${profilePath}.${randomUUID()}.tmp`;
const profile = `[connection]\nid=${config.NETWORK_HOTSPOT_CONNECTION}\nuuid=${randomUUID()}\ntype=wifi\ninterface-name=${config.NETWORK_HOTSPOT_INTERFACE}\nautoconnect=false\n\n[wifi]\nband=bg\nmode=ap\nssid=${request.ssid}\n\n[wifi-security]\nkey-mgmt=wpa-psk\npsk=${psk}\n\n[ipv4]\naddress1=10.42.0.1/24\nmethod=shared\n\n[ipv6]\nmethod=disabled\n`;
await writeFile(temporaryProfile, profile, { mode: 0o600 });
await chmod(temporaryProfile, 0o600);
await command("/usr/bin/systemctl", ["stop", "pi-car-companion-network.service"]).catch(() => undefined);
await command("/usr/bin/nmcli", ["connection", "down", config.NETWORK_HOTSPOT_CONNECTION]).catch(() => undefined);
await command("/usr/bin/nmcli", ["connection", "delete", config.NETWORK_HOTSPOT_CONNECTION]).catch(() => undefined);
await rename(temporaryProfile, profilePath);
await command("/usr/bin/nmcli", ["connection", "load", profilePath]);
await updateEnvironment(config.NETWORK_ENV_PATH);
await command("/usr/bin/systemctl", ["enable", "pi-car-companion-network.service"]);
await command("/usr/bin/systemctl", ["restart", "pi-car-companion-network.service"]);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown configuration failure";
await writeFailure(message);
process.exitCode = 1;
}
+12 -2
View File
@@ -42,6 +42,14 @@ async function field(device: string, name: string): Promise<string | null> {
}
}
async function connectionField(connection: string, name: string): Promise<string | null> {
try {
return (await nmcli(["--get-values", name, "connection", "show", connection])).split("\n")[0]?.trim() || null;
} catch {
return null;
}
}
async function inspectAdapter(device: string): Promise<Adapter> {
const type = await field(device, "GENERAL.TYPE");
if (type !== "wifi") return { interface: device, available: false, connected: false, connection: null, address: null };
@@ -90,10 +98,11 @@ class NetworkController {
}
private async publish(): Promise<{ hotspot: Adapter; upstream: Adapter }> {
const [hotspot, upstream, internet] = await Promise.all([
const [hotspot, upstream, internet, hotspotSsid] = await Promise.all([
inspectAdapter(this.config.NETWORK_HOTSPOT_INTERFACE),
inspectAdapter(this.config.NETWORK_UPSTREAM_INTERFACE),
connectivity()
connectivity(),
connectionField(this.config.NETWORK_HOTSPOT_CONNECTION, "802-11-wireless.ssid")
]);
await writeNetworkStatus(this.config.NETWORK_STATUS_PATH, {
enabled: true,
@@ -104,6 +113,7 @@ class NetworkController {
probeDeadline: this.probeDeadline,
lastFailure: this.lastFailure,
connectivity: internet,
hotspotSsid,
hotspot,
upstream
});
+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
};
+11 -1
View File
@@ -20,7 +20,8 @@ function testConfig(): AppConfig {
network: {
enabled: false,
statusPath: `/tmp/pi-car-companion-test-${process.pid}-missing-network-status.json`,
commandPath: `/tmp/pi-car-companion-test-${process.pid}-network-command.json`
commandPath: `/tmp/pi-car-companion-test-${process.pid}-network-command.json`,
configurationRequestPath: `/tmp/pi-car-companion-test-${process.pid}-network-configuration.json`
},
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
};
@@ -210,6 +211,15 @@ describe("authentication boundary", () => {
payload: { action: "retry-upstream" }
});
expect(action.statusCode).toBe(503);
const invalidConfiguration = await app.inject({
method: "POST",
url: "/api/network/configuration",
headers: { ...mutationHeaders(token), cookie },
payload: { ssid: "bad/name", password: "short" }
});
expect(invalidConfiguration.statusCode).toBe(400);
expect(invalidConfiguration.json()).toMatchObject({ error: { code: "INVALID_HOTSPOT_CONFIGURATION" } });
});
it("exposes only allowlisted jobs and authenticated run history", async () => {
+2 -1
View File
@@ -26,7 +26,8 @@ describe("ADB configuration", () => {
expect(loadConfig({ NODE_ENV: "test" }).network).toEqual({
enabled: false,
statusPath: "/var/lib/pi-car-companion/network-status.json",
commandPath: "/var/lib/pi-car-companion/network-command.json"
commandPath: "/var/lib/pi-car-companion/network-command.json",
configurationRequestPath: "/var/lib/pi-car-companion/network-configuration-request.json"
});
});
});
+31 -2
View File
@@ -3,7 +3,14 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { AppConfig } from "../src/config.js";
import { consumeNetworkCommand, readNetworkStatus, requestNetworkAction } from "../src/network.js";
import {
consumeHotspotConfigurationRequest,
consumeNetworkCommand,
hotspotConfigurationSchema,
readNetworkStatus,
requestHotspotConfiguration,
requestNetworkAction
} from "../src/network.js";
const directories: string[] = [];
@@ -18,7 +25,12 @@ function config(directory: string, enabled = true): AppConfig {
allowedOrigins: new Set(),
updateStatusPath: join(directory, "update.json"),
versionFile: join(directory, "REVISION"),
network: { enabled, statusPath: join(directory, "status.json"), commandPath: join(directory, "command.json") },
network: {
enabled,
statusPath: join(directory, "status.json"),
commandPath: join(directory, "command.json"),
configurationRequestPath: join(directory, "configuration.json")
},
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
};
}
@@ -49,4 +61,21 @@ describe("network control boundary", () => {
await writeFile(testConfig.network.statusPath, '{"mode":"shell"}');
await expect(readNetworkStatus(testConfig)).resolves.toMatchObject({ supported: false, mode: "starting" });
});
it("validates and transfers hotspot credentials only through the root request file", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-car-network-test-"));
directories.push(directory);
const testConfig = config(directory, false);
expect(hotspotConfigurationSchema.safeParse({ ssid: "MG4 Car", password: "Strong pass!2" }).success).toBe(true);
expect(hotspotConfigurationSchema.safeParse({ ssid: "bad/name", password: "Strong pass!2" }).success).toBe(false);
expect(hotspotConfigurationSchema.safeParse({ ssid: "MG4 Car", password: "short" }).success).toBe(false);
await requestHotspotConfiguration(testConfig, { ssid: "MG4 Car", password: "Strong pass!2" }, 7, async () => undefined);
await expect(consumeHotspotConfigurationRequest(testConfig.network.configurationRequestPath)).resolves.toMatchObject({
ssid: "MG4 Car",
password: "Strong pass!2",
actorUserId: 7
});
await expect(readFile(testConfig.network.configurationRequestPath, "utf8")).rejects.toThrow();
});
});
+2 -1
View File
@@ -21,7 +21,8 @@ function config(updateStatusPath: string, versionFile: string): AppConfig {
network: {
enabled: false,
statusPath: join(tmpdir(), "missing-network-status.json"),
commandPath: join(tmpdir(), "network-command.json")
commandPath: join(tmpdir(), "network-command.json"),
configurationRequestPath: join(tmpdir(), "network-configuration.json")
},
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
};