Add tabbed settings and system shell
This commit is contained in:
@@ -15,14 +15,17 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/websocket": "^11.3.0",
|
||||
"argon2": "^0.44.0",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"fastify": "^5.4.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"zod": "^4.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.17.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.20.3"
|
||||
}
|
||||
}
|
||||
|
||||
+72
-1
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
|
||||
import argon2 from "argon2";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import websocket from "@fastify/websocket";
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppConfig } from "./config.js";
|
||||
@@ -28,6 +29,8 @@ import {
|
||||
requestNetworkAction
|
||||
} from "./network.js";
|
||||
import { collectAdbStatus } from "./adb.js";
|
||||
import { openShell } from "./shell.js";
|
||||
import { systemPowerActionSchema, triggerSystemPower } from "./system.js";
|
||||
import "./types.js";
|
||||
|
||||
const usernameSchema = z
|
||||
@@ -114,6 +117,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
app.decorateRequest("authUser", null);
|
||||
await app.register(cookie);
|
||||
await app.register(rateLimit, { global: false });
|
||||
await app.register(websocket);
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
jobRunner.close();
|
||||
@@ -405,6 +409,37 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
return readUpdateStatus(config);
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/system/update/check",
|
||||
{ config: { rateLimit: { max: 6, timeWindow: "10 minutes" } } },
|
||||
async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
const current = await readUpdateStatus(config);
|
||||
if (!current.supported) {
|
||||
return reply.code(503).send({
|
||||
error: { code: "UPDATES_UNAVAILABLE", message: "Install the system service before checking for updates" }
|
||||
});
|
||||
}
|
||||
if (["checking", "building"].includes(current.state)) {
|
||||
return reply.code(409).send({ error: { code: "UPDATE_IN_PROGRESS", message: "An update task is already running" } });
|
||||
}
|
||||
try {
|
||||
await triggerSystemUpdate("check");
|
||||
audit(database, {
|
||||
actorUserId: request.authUser!.id,
|
||||
action: "system.update-check",
|
||||
result: "success",
|
||||
ipAddress: clientAddress(request)
|
||||
});
|
||||
return reply.code(202).send({ queued: true });
|
||||
} catch {
|
||||
return reply.code(503).send({
|
||||
error: { code: "UPDATE_CHECK_FAILED", message: "The update check could not be started" }
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/system/update",
|
||||
{ config: { rateLimit: { max: 2, timeWindow: "10 minutes" } } },
|
||||
@@ -421,7 +456,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
}
|
||||
|
||||
try {
|
||||
await triggerSystemUpdate();
|
||||
await triggerSystemUpdate("install");
|
||||
audit(database, {
|
||||
actorUserId: request.authUser!.id,
|
||||
action: "system.update",
|
||||
@@ -445,6 +480,42 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/system/power",
|
||||
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },
|
||||
async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
const parsed = z.object({ action: systemPowerActionSchema }).safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: "INVALID_POWER_ACTION", message: "That power action is not available" } });
|
||||
}
|
||||
try {
|
||||
await triggerSystemPower(parsed.data.action);
|
||||
audit(database, {
|
||||
actorUserId: request.authUser!.id,
|
||||
action: `system.${parsed.data.action}`,
|
||||
result: "success",
|
||||
ipAddress: clientAddress(request)
|
||||
});
|
||||
return reply.code(202).send({ queued: true, action: parsed.data.action });
|
||||
} catch {
|
||||
audit(database, {
|
||||
actorUserId: request.authUser!.id,
|
||||
action: `system.${parsed.data.action}`,
|
||||
result: "failure",
|
||||
ipAddress: clientAddress(request)
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { code: "POWER_ACTION_FAILED", message: "The Pi could not schedule that power action" }
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get("/api/system/shell", { websocket: true }, (socket, request) => {
|
||||
openShell(socket, request, config);
|
||||
});
|
||||
|
||||
app.get("/api/events", async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
reply.hijack();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { FastifyRequest } from "fastify";
|
||||
import { dirname } from "node:path";
|
||||
import * as pty from "node-pty";
|
||||
import type { WebSocket } from "ws";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { CSRF_COOKIE, safeEqual } from "./security.js";
|
||||
|
||||
type ClientMessage =
|
||||
| { type: "authenticate"; csrfToken: string }
|
||||
| { type: "input"; data: string }
|
||||
| { type: "resize"; cols: number; rows: number };
|
||||
|
||||
function send(socket: WebSocket, value: Record<string, unknown>): void {
|
||||
if (socket.readyState === 1) socket.send(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function trustedOrigin(request: FastifyRequest, config: AppConfig): boolean {
|
||||
const origin = request.headers.origin;
|
||||
if (!origin) return config.nodeEnv === "test";
|
||||
try {
|
||||
const parsed = new URL(origin);
|
||||
return (
|
||||
(["http:", "https:"].includes(parsed.protocol) && Boolean(request.headers.host) && parsed.host === request.headers.host) ||
|
||||
config.allowedOrigins.has(origin)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function openShell(socket: WebSocket, request: FastifyRequest, config: AppConfig): void {
|
||||
if (!request.authUser || !trustedOrigin(request, config)) {
|
||||
socket.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
let terminal: pty.IPty | null = null;
|
||||
const authenticationTimer = setTimeout(() => socket.close(1008, "Authentication timed out"), 5_000);
|
||||
const lifetimeTimer = setTimeout(() => socket.close(1000, "Terminal session expired"), 30 * 60 * 1_000);
|
||||
|
||||
const closeTerminal = () => {
|
||||
clearTimeout(authenticationTimer);
|
||||
clearTimeout(lifetimeTimer);
|
||||
if (terminal) {
|
||||
try {
|
||||
terminal.kill();
|
||||
} catch {
|
||||
// The shell may already have exited.
|
||||
}
|
||||
terminal = null;
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("close", closeTerminal);
|
||||
socket.on("error", closeTerminal);
|
||||
socket.on("message", (raw) => {
|
||||
const serialized = Array.isArray(raw)
|
||||
? Buffer.concat(raw).toString("utf8")
|
||||
: raw instanceof ArrayBuffer
|
||||
? Buffer.from(new Uint8Array(raw)).toString("utf8")
|
||||
: raw.toString("utf8");
|
||||
if (Buffer.byteLength(serialized) > 16_384) {
|
||||
socket.close(1009, "Message too large");
|
||||
return;
|
||||
}
|
||||
|
||||
let message: ClientMessage;
|
||||
try {
|
||||
message = JSON.parse(serialized) as ClientMessage;
|
||||
} catch {
|
||||
socket.close(1003, "Invalid terminal message");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!terminal) {
|
||||
const cookieToken = request.cookies[CSRF_COOKIE];
|
||||
if (
|
||||
message.type !== "authenticate" ||
|
||||
typeof message.csrfToken !== "string" ||
|
||||
!cookieToken ||
|
||||
!safeEqual(cookieToken, message.csrfToken)
|
||||
) {
|
||||
socket.close(1008, "Authentication failed");
|
||||
return;
|
||||
}
|
||||
clearTimeout(authenticationTimer);
|
||||
const shellHome = dirname(config.databasePath);
|
||||
terminal = pty.spawn("/bin/bash", ["--noprofile", "--norc"], {
|
||||
name: "xterm-256color",
|
||||
cols: 100,
|
||||
rows: 28,
|
||||
cwd: shellHome,
|
||||
env: {
|
||||
HOME: shellHome,
|
||||
LANG: process.env.LANG ?? "C.UTF-8",
|
||||
PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
SHELL: "/bin/bash",
|
||||
TERM: "xterm-256color",
|
||||
USER: "pi-companion"
|
||||
}
|
||||
});
|
||||
terminal.onData((data) => send(socket, { type: "output", data }));
|
||||
terminal.onExit(({ exitCode }) => {
|
||||
terminal = null;
|
||||
send(socket, { type: "exit", exitCode });
|
||||
socket.close(1000, "Shell exited");
|
||||
});
|
||||
send(socket, { type: "ready" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "input" && typeof message.data === "string" && message.data.length <= 8_192) {
|
||||
terminal.write(message.data);
|
||||
} else if (
|
||||
message.type === "resize" &&
|
||||
Number.isInteger(message.cols) &&
|
||||
Number.isInteger(message.rows) &&
|
||||
message.cols >= 20 && message.cols <= 240 &&
|
||||
message.rows >= 5 && message.rows <= 100
|
||||
) {
|
||||
terminal.resize(message.cols, message.rows);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { z } from "zod";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const systemPowerActionSchema = z.enum(["reboot", "poweroff"]);
|
||||
export type SystemPowerAction = z.infer<typeof systemPowerActionSchema>;
|
||||
|
||||
export async function triggerSystemPower(action: SystemPowerAction): Promise<void> {
|
||||
await execFileAsync("/usr/bin/sudo", ["-n", "/usr/local/sbin/pi-car-companion-power", action], {
|
||||
timeout: 10_000,
|
||||
maxBuffer: 16 * 1024
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { AppConfig } from "./config.js";
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const updateStatusSchema = z.object({
|
||||
state: z.enum(["idle", "checking", "building", "current", "success", "failed"]),
|
||||
state: z.enum(["idle", "checking", "available", "building", "current", "success", "failed"]),
|
||||
message: z.string().max(500),
|
||||
fromRevision: z.string().max(64).nullable().default(null),
|
||||
toRevision: z.string().max(64).nullable().default(null),
|
||||
@@ -56,10 +56,16 @@ export async function readUpdateStatus(config: AppConfig): Promise<UpdateStatus>
|
||||
}
|
||||
}
|
||||
|
||||
export async function triggerSystemUpdate(): Promise<void> {
|
||||
export async function triggerSystemUpdate(mode: "check" | "install" = "install"): Promise<void> {
|
||||
await execFileAsync(
|
||||
"/usr/bin/sudo",
|
||||
["-n", "/usr/bin/systemctl", "start", "--no-block", "pi-car-companion-update.service"],
|
||||
[
|
||||
"-n",
|
||||
"/usr/bin/systemctl",
|
||||
"start",
|
||||
"--no-block",
|
||||
mode === "check" ? "pi-car-companion-update-check.service" : "pi-car-companion-update.service"
|
||||
],
|
||||
{ timeout: 10_000, maxBuffer: 16 * 1024 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,6 +179,29 @@ describe("authentication boundary", () => {
|
||||
});
|
||||
expect(update.statusCode).toBe(503);
|
||||
expect(update.json()).toMatchObject({ error: { code: "UPDATES_UNAVAILABLE" } });
|
||||
|
||||
const check = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/system/update/check",
|
||||
headers: {
|
||||
...mutationHeaders(token),
|
||||
cookie: `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${sessionCookie?.value ?? ""}`
|
||||
}
|
||||
});
|
||||
expect(check.statusCode).toBe(503);
|
||||
expect(check.json()).toMatchObject({ error: { code: "UPDATES_UNAVAILABLE" } });
|
||||
|
||||
const invalidPower = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/system/power",
|
||||
headers: {
|
||||
...mutationHeaders(token),
|
||||
cookie: `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${sessionCookie?.value ?? ""}`
|
||||
},
|
||||
payload: { action: "factory-reset" }
|
||||
});
|
||||
expect(invalidPower.statusCode).toBe(400);
|
||||
expect(invalidPower.json()).toMatchObject({ error: { code: "INVALID_POWER_ACTION" } });
|
||||
});
|
||||
|
||||
it("does not expose update state without authentication", async () => {
|
||||
|
||||
@@ -58,6 +58,32 @@ describe("system update status", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a check-only result when a newer revision is available", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-car-update-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const statusPath = join(directory, "status.json");
|
||||
const versionPath = join(directory, "REVISION");
|
||||
await writeFile(
|
||||
statusPath,
|
||||
JSON.stringify({
|
||||
state: "available",
|
||||
message: "A newer release is available.",
|
||||
fromRevision: "a".repeat(40),
|
||||
toRevision: "b".repeat(40),
|
||||
updatedAt: "2026-07-31T18:00:00.000Z"
|
||||
})
|
||||
);
|
||||
await writeFile(versionPath, `${"a".repeat(40)}\n`);
|
||||
|
||||
await expect(readUpdateStatus(config(statusPath, versionPath))).resolves.toMatchObject({
|
||||
state: "available",
|
||||
fromRevision: "a".repeat(40),
|
||||
toRevision: "b".repeat(40),
|
||||
installedRevision: "a".repeat(40),
|
||||
supported: true
|
||||
});
|
||||
});
|
||||
|
||||
it("treats missing or malformed status files as unsupported", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-car-update-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
|
||||
Reference in New Issue
Block a user