diff --git a/.env.example b/.env.example index def0257..0a81385 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ VERSION_FILE=/opt/pi-car-companion/current/REVISION NETWORK_MANAGER_ENABLED=false NETWORK_STATUS_PATH=/var/lib/pi-car-companion/network-status.json NETWORK_COMMAND_PATH=/var/lib/pi-car-companion/network-command.json +NETWORK_CONFIGURATION_REQUEST_PATH=/var/lib/pi-car-companion/network-configuration-request.json NETWORK_HOTSPOT_INTERFACE=wlan0 NETWORK_UPSTREAM_INTERFACE=wlan1 NETWORK_HOTSPOT_CONNECTION=pi-car-hotspot diff --git a/PLAN.md b/PLAN.md index 837d961..bc075ec 100644 --- a/PLAN.md +++ b/PLAN.md @@ -322,7 +322,7 @@ The repository contains a working MVP and tracks remaining delivery work here. S ### Milestone 5 — Artifacts and operational actions - [ ] Implement controlled uploads/downloads/deletion, hashing, quotas, and metadata UI. -- [~] Add Wi-Fi refresh/reconnect with capability detection. A disabled-by-default dual-radio NetworkManager controller, 60-second recovery watchdog, fixed dashboard actions, and status UI are implemented; destructive failover and unplug/replug behavior still require staged Pi and MG4 validation. +- [~] Add Wi-Fi refresh/reconnect with capability detection. A disabled-by-default dual-radio NetworkManager controller, 60-second recovery watchdog, fixed dashboard actions, hotspot credential configuration, and status UI are implemented; destructive failover and unplug/replug behavior still require staged Pi and MG4 validation. - [ ] Add log pruning and companion-service restart jobs. - [ ] Add reboot and shutdown with explicit action-bound confirmation and narrowly scoped privilege configuration. - [ ] Exercise storage exhaustion, job timeout, service restart, and network-loss recovery. diff --git a/README.md b/README.md index 3e632c1..c99ac63 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ Important values: - `ADB_PATH` and `ADB_TIMEOUT_MS`: executable path and per-command timeout - `NETWORK_MANAGER_ENABLED`: enables the separately supervised NetworkManager controller after explicit setup - `NETWORK_STATUS_PATH` and `NETWORK_COMMAND_PATH`: validated state and fixed-action request files +- `NETWORK_CONFIGURATION_REQUEST_PATH`: mode-0600 one-time hotspot configuration request consumed by the root service - `NETWORK_HOTSPOT_INTERFACE` / `NETWORK_UPSTREAM_INTERFACE`: default to built-in `wlan0` and USB `wlan1` - `NETWORK_PROBE_TIMEOUT_SECONDS`: maximum built-in upstream attempt before restoring the offline hotspot @@ -175,7 +176,9 @@ Then create the recovery hotspot and enable its controller: sudo pi-car-companion-network-setup ``` -The helper requires `wlan1` to be connected before it enables switching, asks interactively for a unique hotspot password, and never places that password in the application configuration. Settings then shows the current mode and offers **Try upstream again** and **Restore hotspot** controls. +The helper requires `wlan1` to be connected before it enables switching, asks interactively for a unique hotspot password, and never places that password in the application configuration. Alternatively, an authenticated administrator can configure or rotate the hotspot SSID and password directly in **Settings → Car network**. The dashboard uses a narrowly scoped root service; the one-time password request is deleted before processing and NetworkManager stores only the derived WPA key. + +Settings also shows the current mode and offers **Try upstream again** and **Restore hotspot** controls. Applying new hotspot credentials can disconnect the dashboard, after which devices must reconnect using the new values. Behavior by mode: diff --git a/install.sh b/install.sh index 2386f90..e5146cd 100755 --- a/install.sh +++ b/install.sh @@ -115,6 +115,7 @@ install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-network-setup" /usr/loca install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion.service" /etc/systemd/system/pi-car-companion.service install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service +install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service install -m 0440 "${SCRIPT_DIR}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null install -d -m 0755 /etc/avahi/services @@ -138,6 +139,7 @@ ADB_TIMEOUT_MS=3000 NETWORK_MANAGER_ENABLED=false NETWORK_STATUS_PATH=${DATA_DIR}/network-status.json NETWORK_COMMAND_PATH=${DATA_DIR}/network-command.json +NETWORK_CONFIGURATION_REQUEST_PATH=${DATA_DIR}/network-configuration-request.json NETWORK_HOTSPOT_INTERFACE=wlan0 NETWORK_UPSTREAM_INTERFACE=wlan1 NETWORK_HOTSPOT_CONNECTION=pi-car-hotspot @@ -157,6 +159,7 @@ ensure_env_default() { ensure_env_default NETWORK_MANAGER_ENABLED false ensure_env_default NETWORK_STATUS_PATH "${DATA_DIR}/network-status.json" ensure_env_default NETWORK_COMMAND_PATH "${DATA_DIR}/network-command.json" +ensure_env_default NETWORK_CONFIGURATION_REQUEST_PATH "${DATA_DIR}/network-configuration-request.json" ensure_env_default NETWORK_HOTSPOT_INTERFACE wlan0 ensure_env_default NETWORK_UPSTREAM_INTERFACE wlan1 ensure_env_default NETWORK_HOTSPOT_CONNECTION pi-car-hotspot diff --git a/scripts/pi-car-companion-update b/scripts/pi-car-companion-update index acb13aa..508be2e 100755 --- a/scripts/pi-car-companion-update +++ b/scripts/pi-car-companion-update @@ -60,6 +60,7 @@ install_integration_files() { install -m 0644 "${source_release}/systemd/pi-car-companion.service" /etc/systemd/system/pi-car-companion.service install -m 0644 "${source_release}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service install -m 0644 "${source_release}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service + install -m 0644 "${source_release}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service install -m 0440 "${source_release}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion } diff --git a/server/src/app.ts b/server/src/app.ts index 210003e..60699c9 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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 { 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); diff --git a/server/src/config.ts b/server/src/config.ts index d6eab75..f15dbf6 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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, diff --git a/server/src/network-configure.ts b/server/src/network-configure.ts new file mode 100644 index 0000000..e86ecc9 --- /dev/null +++ b/server/src/network-configure.ts @@ -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 { + 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 { + 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 { + const values: Record = { + 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 { + 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; +} diff --git a/server/src/network-controller.ts b/server/src/network-controller.ts index c862a82..ce9d46c 100644 --- a/server/src/network-controller.ts +++ b/server/src/network-controller.ts @@ -42,6 +42,14 @@ async function field(device: string, name: string): Promise { } } +async function connectionField(connection: string, name: string): Promise { + try { + return (await nmcli(["--get-values", name, "connection", "show", connection])).split("\n")[0]?.trim() || null; + } catch { + return null; + } +} + async function inspectAdapter(device: string): Promise { 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 }); diff --git a/server/src/network.ts b/server/src/network.ts index c06432d..fd61c51 100644 --- a/server/src/network.ts +++ b/server/src/network.ts @@ -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 & { supported: b 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, @@ -49,20 +72,20 @@ const commandSchema = z.object({ }); export type NetworkCommand = z.infer; -async function atomicJsonWrite(path: string, value: unknown): Promise { +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: 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 { - 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 { - 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 = 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"))); @@ -105,6 +164,7 @@ function unsupportedStatus(message: string): NetworkStatus { probeDeadline: null, lastFailure: null, connectivity: "unknown", + hotspotSsid: null, hotspot: adapter, upstream: adapter }; diff --git a/server/tests/app.test.ts b/server/tests/app.test.ts index d704af8..f5677a5 100644 --- a/server/tests/app.test.ts +++ b/server/tests/app.test.ts @@ -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 () => { diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 52af6dc..9f7f017 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -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" }); }); }); diff --git a/server/tests/network.test.ts b/server/tests/network.test.ts index a28be29..84cce88 100644 --- a/server/tests/network.test.ts +++ b/server/tests/network.test.ts @@ -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(); + }); }); diff --git a/server/tests/update.test.ts b/server/tests/update.test.ts index a79bc37..2c89379 100644 --- a/server/tests/update.test.ts +++ b/server/tests/update.test.ts @@ -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 } }; diff --git a/systemd/pi-car-companion-network-configure.service b/systemd/pi-car-companion-network-configure.service new file mode 100644 index 0000000..f1e539b --- /dev/null +++ b/systemd/pi-car-companion-network-configure.service @@ -0,0 +1,23 @@ +[Unit] +Description=Configure Pi Car Companion hotspot +After=NetworkManager.service +Wants=NetworkManager.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/pi-car-companion/companion.env +Environment=HOME=/var/lib/pi-car-companion +WorkingDirectory=/opt/pi-car-companion/current +ExecStart=/usr/bin/node /opt/pi-car-companion/current/server/dist/network-configure.js +TimeoutStartSec=60s +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictRealtime=true +LockPersonality=true +SystemCallArchitectures=native +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK diff --git a/systemd/pi-car-companion.sudoers b/systemd/pi-car-companion.sudoers index 74a5215..b108efe 100644 --- a/systemd/pi-car-companion.sudoers +++ b/systemd/pi-car-companion.sudoers @@ -1 +1,2 @@ pi-companion ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pi-car-companion-update.service +pi-companion ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pi-car-companion-network-configure.service diff --git a/web/src/App.tsx b/web/src/App.tsx index 9be55fc..482c96d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState, type FormEvent } from "react"; import { ArrowClockwise, CheckCircle, @@ -232,6 +232,12 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) { const [requesting, setRequesting] = useState(false); const [confirming, setConfirming] = useState(false); const [networkAction, setNetworkAction] = useState<"retry-upstream" | "restore-hotspot" | null>(null); + const [hotspotSsid, setHotspotSsid] = useState("MG4-Companion"); + const [hotspotPassword, setHotspotPassword] = useState(""); + const [hotspotPasswordAgain, setHotspotPasswordAgain] = useState(""); + const [ssidTouched, setSsidTouched] = useState(false); + const [confirmHotspot, setConfirmHotspot] = useState(false); + const [networkNotice, setNetworkNotice] = useState(""); const running = update?.state === "checking" || update?.state === "building"; const refreshUpdate = useCallback(async () => { @@ -262,6 +268,10 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) { return () => window.clearInterval(interval); }, [refreshNetwork, refreshUpdate]); + useEffect(() => { + if (!ssidTouched && network?.hotspotSsid) setHotspotSsid(network.hotspotSsid); + }, [network?.hotspotSsid, ssidTouched]); + async function startUpdate() { setConfirming(false); setRequesting(true); @@ -292,6 +302,43 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) { } } + function prepareHotspotConfiguration(event: FormEvent) { + event.preventDefault(); + setNetworkError(""); + setNetworkNotice(""); + if (!/^[a-zA-Z0-9_. -]{1,32}$/.test(hotspotSsid.trim())) { + setNetworkError("Use 1-32 letters, numbers, spaces, dots, hyphens, or underscores for the hotspot name"); + return; + } + if (hotspotPassword.length < 8 || hotspotPassword.length > 63 || !/^[\x20-\x7e]+$/.test(hotspotPassword)) { + setNetworkError("Use 8-63 printable characters for the hotspot password"); + return; + } + if (hotspotPassword !== hotspotPasswordAgain) { + setNetworkError("The hotspot passwords do not match"); + return; + } + setConfirmHotspot(true); + } + + async function configureHotspot() { + setConfirmHotspot(false); + setRequesting(true); + setNetworkError(""); + setNetworkNotice(""); + try { + await post("/api/network/configuration", { ssid: hotspotSsid.trim(), password: hotspotPassword }, csrfToken); + setHotspotPassword(""); + setHotspotPasswordAgain(""); + setNetworkNotice("Hotspot configuration accepted. Reconnect using the new name and password if this page disconnects."); + window.setTimeout(() => void refreshNetwork(), 2_000); + } catch (caught) { + setNetworkError(caught instanceof ApiError ? caught.message : "The hotspot could not be configured"); + } finally { + setRequesting(false); + } + } + const revision = update?.installedRevision?.slice(0, 12) ?? "Unavailable"; const statusLabel = !update ? "Loading" : update.state === "current" ? "Up to date" : update.state === "success" ? "Updated" : update.state === "failed" ? "Needs attention" : running ? "Updating" : "Ready"; const modeLabels: Record["mode"], string> = { @@ -333,16 +380,39 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) { {networkError &&
{networkError}
} + {networkNotice &&
{networkNotice}
} {network?.lastFailure &&

Last issue: {network.lastFailure}

}
- -
- {!network?.supported &&

Run the network setup helper on the Pi after creating a saved AWUS upstream profile. Automatic switching stays disabled until then.

} + +
+
+

Hotspot credentials

Changing these settings may disconnect devices currently using the car hotspot.

+ {network?.hotspotSsid && Current: {network.hotspotSsid}} +
+
+ + + + +
+

The password is sent only to the local Pi configuration service and is stored by NetworkManager as a derived WPA key.

+
@@ -406,6 +476,17 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) { } + + {confirmHotspot &&
setConfirmHotspot(false)}> +
event.stopPropagation()}> +

Apply new hotspot credentials?

+

The hotspot will become {hotspotSsid.trim()}. Connected devices may disconnect and must reconnect with the new password.

+
+ + +
+
+
} ); } diff --git a/web/src/api.ts b/web/src/api.ts index 39f99cb..434f8a2 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -76,6 +76,7 @@ export type NetworkStatus = { probeDeadline: string | null; lastFailure: string | null; connectivity: "full" | "limited" | "portal" | "none" | "unknown"; + hotspotSsid: string | null; hotspot: NetworkAdapterStatus; upstream: NetworkAdapterStatus; }; diff --git a/web/src/styles.css b/web/src/styles.css index 1413ae5..c3b8c14 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -116,7 +116,16 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .mode-error strong, .mode-degraded strong { color: var(--danger); } .network-failure { display: flex; align-items: flex-start; gap: 8px; margin: 0 0 16px; color: #ffc8bf; font-size: 0.82rem; line-height: 1.45; } .network-failure svg { flex: none; } +.network-notice { display: flex; align-items: center; gap: 9px; margin: 0 0 16px; color: var(--accent); } .text-button { width: auto; padding: 0 20px; font-size: inherit; } +.hotspot-form { margin-top: 26px; padding-top: 24px; border-top: 1px solid var(--line); } +.hotspot-form-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; } +.hotspot-form-heading h3 { margin: 0 0 6px; font-size: 1.05rem; } +.hotspot-form-heading p { margin: 0; color: var(--muted); font-size: 0.83rem; } +.hotspot-form-heading > span { color: var(--accent); font: 0.75rem/1.4 ui-monospace, "Cascadia Code", monospace; } +.hotspot-fields { display: grid; grid-template-columns: 1fr 1fr 1fr auto; align-items: end; gap: 12px; margin-top: 20px; } +.hotspot-fields label { display: grid; gap: 7px; color: var(--muted); font-size: 0.78rem; } +.hotspot-fields input { width: 100%; min-height: 52px; padding: 0 13px; color: var(--text); background: #10140f; border: 1px solid #465043; border-radius: 9px; } .settings-title { display: flex; align-items: flex-start; gap: 18px; } .settings-title h2, .compact-panel h2 { margin-bottom: 8px; font-size: 1.45rem; } .settings-title p { max-width: 620px; margin: 0; color: var(--muted); line-height: 1.5; } @@ -157,6 +166,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .metrics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .settings-layout { grid-template-columns: 1fr; } .network-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .hotspot-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 767px) { @@ -181,6 +191,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .metrics-grid, .system-strip { grid-template-columns: 1fr; } .update-facts { grid-template-columns: 1fr; } .network-overview { grid-template-columns: 1fr; } + .hotspot-fields { grid-template-columns: 1fr; } .settings-actions, .dialog-actions { flex-direction: column; } .settings-actions button, .dialog-actions button { width: 100%; } .skeleton-grid { grid-template-columns: 1fr; }