Configure hotspot credentials from dashboard
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+46
-1
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+85
-4
@@ -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<HTMLFormElement>) {
|
||||
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<NonNullable<NetworkStatus>["mode"], string> = {
|
||||
@@ -333,16 +380,39 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
</div>
|
||||
|
||||
{networkError && <div className="form-error" role="alert"><WarningCircle size={20} />{networkError}</div>}
|
||||
{networkNotice && <div className="network-notice" role="status"><CheckCircle size={20} />{networkNotice}</div>}
|
||||
{network?.lastFailure && <p className="network-failure"><WarningCircle size={18} /> Last issue: {network.lastFailure}</p>}
|
||||
<div className="settings-actions">
|
||||
<button className="primary-button" onClick={() => setNetworkAction("retry-upstream")} disabled={!network?.supported || requesting || network.mode === "probing_built_in"}>
|
||||
<button className="primary-button" onClick={() => setNetworkAction("retry-upstream")} disabled={!network?.enabled || requesting || network.mode === "probing_built_in"}>
|
||||
<ArrowClockwise size={22} /> Try upstream again
|
||||
</button>
|
||||
<button className="secondary-button text-button" onClick={() => setNetworkAction("restore-hotspot")} disabled={!network?.supported || requesting || network.mode === "offline_hotspot" || network.mode === "dual_radio"}>
|
||||
<button className="secondary-button text-button" onClick={() => setNetworkAction("restore-hotspot")} disabled={!network?.enabled || requesting || network.mode === "offline_hotspot" || network.mode === "dual_radio"}>
|
||||
<WifiHigh size={22} /> Restore hotspot
|
||||
</button>
|
||||
</div>
|
||||
{!network?.supported && <p className="settings-note">Run the network setup helper on the Pi after creating a saved AWUS upstream profile. Automatic switching stays disabled until then.</p>}
|
||||
|
||||
<form className="hotspot-form" onSubmit={prepareHotspotConfiguration}>
|
||||
<div className="hotspot-form-heading">
|
||||
<div><h3>Hotspot credentials</h3><p>Changing these settings may disconnect devices currently using the car hotspot.</p></div>
|
||||
{network?.hotspotSsid && <span>Current: {network.hotspotSsid}</span>}
|
||||
</div>
|
||||
<div className="hotspot-fields">
|
||||
<label>
|
||||
<span>Network name</span>
|
||||
<input value={hotspotSsid} onChange={(event) => { setHotspotSsid(event.target.value); setSsidTouched(true); }} maxLength={32} autoComplete="off" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>New password</span>
|
||||
<input type="password" value={hotspotPassword} onChange={(event) => setHotspotPassword(event.target.value)} minLength={8} maxLength={63} autoComplete="new-password" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Repeat password</span>
|
||||
<input type="password" value={hotspotPasswordAgain} onChange={(event) => setHotspotPasswordAgain(event.target.value)} minLength={8} maxLength={63} autoComplete="new-password" required />
|
||||
</label>
|
||||
<button className="secondary-button text-button" type="submit" disabled={requesting}>Save hotspot</button>
|
||||
</div>
|
||||
<p className="settings-note">The password is sent only to the local Pi configuration service and is stored by NetworkManager as a derived WPA key.</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="settings-panel">
|
||||
@@ -406,6 +476,17 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{confirmHotspot && <div className="modal-backdrop" role="presentation" onMouseDown={() => setConfirmHotspot(false)}>
|
||||
<div className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="hotspot-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<h2 id="hotspot-dialog-title">Apply new hotspot credentials?</h2>
|
||||
<p>The hotspot will become <strong>{hotspotSsid.trim()}</strong>. Connected devices may disconnect and must reconnect with the new password.</p>
|
||||
<div className="dialog-actions">
|
||||
<button className="secondary-button" onClick={() => setConfirmHotspot(false)}>Cancel</button>
|
||||
<button className="primary-button" onClick={() => void configureHotspot()}>Apply settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user