Add tabbed settings and system shell

This commit is contained in:
2026-07-31 21:30:51 +02:00
parent d663979ebb
commit c36e5e811f
20 changed files with 698 additions and 29 deletions
+72 -1
View File
@@ -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();
+124
View File
@@ -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);
}
});
}
+15
View File
@@ -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
});
}
+9 -3
View File
@@ -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 }
);
}