feat: convert serial console into the serial plugin

This commit is contained in:
LukeGus committed 2026-09-23 23:06:37 -05:00
1 parent 708eedb861
commit 791d8656a1
121 files changed
+2216 -1425

No files matched your search

-195
View File
@@ -1,195 +0,0 @@
import { getErrorMessage } from "../utils/error-message.js";
import { WebSocketServer, WebSocket, type RawData } from "ws";
import { SerialPort } from "serialport";
import { AuthManager } from "../utils/auth-manager.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { sshLogger } from "../utils/logger.js";
import { parseWsMessage } from "../utils/ws-message.js";
import { extractWebSocketToken } from "../utils/ws-auth.js";
interface SerialConnectData {
path: string;
baudRate: number;
dataBits?: 5 | 6 | 7 | 8;
stopBits?: 1 | 2;
parity?: "none" | "even" | "odd";
}
const authManager = AuthManager.getInstance();
const wss = new WebSocketServer({ host: "127.0.0.1", port: 30011 });
wss.on("error", (error) => {
sshLogger.error("Serial WebSocket server error", error, {
operation: "wss_error",
});
});
wss.on("connection", async (ws: WebSocket, req) => {
let userId: string | undefined;
try {
const token = extractWebSocketToken(req);
if (!token) {
ws.close(1008, "Authentication required");
return;
}
const payload = await authManager.verifyJWTToken(token);
if (!payload?.userId || payload.pendingTOTP) {
ws.close(1008, "Authentication required");
return;
}
userId = payload.userId;
} catch {
ws.close(1008, "Authentication required");
return;
}
const dataKey = DataCrypto.getUserDataKey(userId);
if (!dataKey) {
ws.send(JSON.stringify({ type: "error", data: "Data locked" }));
ws.close(1008, "Data access required");
return;
}
let port: SerialPort | null = null;
const send = (msg: object) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
};
const cleanup = () => {
if (port?.isOpen) {
port.close();
}
port = null;
};
ws.on("message", async (raw: RawData) => {
let type: string;
let data: unknown;
try {
({ type, data } = parseWsMessage(raw));
} catch {
return;
}
try {
switch (type) {
case "list_ports": {
try {
const ports = await SerialPort.list();
send({ type: "ports_list", data: ports });
} catch (err) {
send({
type: "error",
data: getErrorMessage(err, "Failed to list ports"),
});
}
break;
}
case "connect": {
if (port?.isOpen) {
port.close();
port = null;
}
const cfg = data as SerialConnectData;
if (!cfg?.path || !cfg?.baudRate) {
send({ type: "error", data: "Missing port path or baud rate" });
break;
}
try {
port = new SerialPort({
path: cfg.path,
baudRate: cfg.baudRate,
dataBits: cfg.dataBits ?? 8,
stopBits: cfg.stopBits ?? 1,
parity: cfg.parity ?? "none",
autoOpen: false,
});
port.open((err) => {
if (err) {
sshLogger.error("Serial port open failed", err, {
operation: "serial_open",
path: cfg.path,
userId,
});
send({ type: "error", data: err.message });
port = null;
return;
}
sshLogger.info("Serial port opened", {
operation: "serial_open",
path: cfg.path,
baudRate: cfg.baudRate,
userId,
});
send({ type: "connected" });
});
port.on("data", (chunk: Buffer) => {
send({ type: "data", data: chunk.toString("binary") });
});
port.on("error", (err) => {
send({ type: "error", data: err.message });
});
port.on("close", () => {
send({ type: "disconnected" });
port = null;
});
} catch (err) {
send({
type: "error",
data: getErrorMessage(err, "Failed to open serial port"),
});
}
break;
}
case "input": {
if (!port?.isOpen) break;
const input = typeof data === "string" ? data : "";
if (!input) break;
port.write(Buffer.from(input, "binary"), (err) => {
if (err) {
send({ type: "error", data: err.message });
}
});
break;
}
case "disconnect": {
cleanup();
send({ type: "disconnected" });
break;
}
}
} catch (err) {
sshLogger.error("Error handling serial WebSocket message", err, {
operation: "serial_message_handler_error",
userId,
messageType: type,
});
send({ type: "error", data: "Failed to process message" });
}
});
ws.on("close", () => {
cleanup();
});
ws.on("error", () => {
cleanup();
});
});
+27 -1
View File
@@ -26,7 +26,7 @@ import * as serviceRegistry from "./service-registry.js";
import type { ServiceRegistration } from "./service-registry.js";
import * as secretRegistry from "./secret-registry.js";
import type { SecretRegistration } from "./secret-registry.js";
import { assertCapability } from "./permissions.js";
import { assertCapability, hasCapability } from "./permissions.js";
import { getActor, runAsActor } from "./actor.js";
import { DisposableBag } from "./disposables.js";
import {
@@ -322,6 +322,27 @@ export function createPluginContext(
},
);
const capabilitiesRequire = async (capability: string) => {
try {
await assertCapability(pluginId, capability, declared);
} catch (error) {
await writeAudit(
manifest,
{ action: "capability_require", details: () => capability },
{
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
},
);
throw error;
}
await writeAudit(
manifest,
{ action: "capability_require", details: () => capability },
{ success: true },
);
};
return {
pluginId,
manifest,
@@ -626,6 +647,11 @@ export function createPluginContext(
require: (permission) => createRbacMiddleware(manifest, permission),
},
capabilities: {
has: (capability) => hasCapability(pluginId, capability, declared),
require: capabilitiesRequire,
},
disposables: {
add: (dispose) => {
handle.bag.add(dispose, "plugin resource");
+6 -6
View File
@@ -309,12 +309,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// plugins/ssh-terminal, plugins/docker, plugins/host-metrics,
// plugins/file-manager and plugins/tmux-monitor.
// Every other plugin (AI, Proxmox, Remote Desktop, Fleets, Automations,
// Network Topology, Workspaces, Web Endpoint, Tunnels) is absent for the
// same reason: each one serves its routes under /plugin-api/<id>/ through
// ctx.http on activate, so disabling it answers 503 instead of leaving a
// dead import here. Automations' scheduler and tunnel autostart also
// start from their own activate() rather than here.
await import("./hosts/serial.js");
// Network Topology, Workspaces, Web Endpoint, Tunnels, Serial) is absent
// for the same reason: each one serves its routes under /plugin-api/<id>/
// (or a WS route under /plugin-ws/<id>/) through ctx on activate, so
// disabling it answers 503 instead of leaving a dead import here.
// Automations' scheduler and tunnel autostart also start from their own
// activate() rather than here.
await import("./services/dashboard.js");
await import("./services/homepage.js");
@@ -0,0 +1,106 @@
/**
* ctx.capabilities: a generic check for privileged code no ctx method wraps,
* used first by the serial plugin to gate opening a physical device. Every
* `require` call is audited, allowed or refused, the same as a guarded ctx
* method.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function manifestFor(pluginId: string, capabilities: string[]): PluginManifest {
return {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
} as PluginManifest;
}
function contextFor(pluginId: string, capabilities: string[]) {
const manifest = manifestFor(pluginId, capabilities);
const handle = createPluginHandle(pluginId, { activate: () => {} });
return { ctx: createPluginContext(manifest, handle), handle };
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
});
describe("ctx.capabilities", () => {
it("has() is false when the manifest never declared the capability", async () => {
grants.set("demo", ["device:serial"]);
const { ctx } = contextFor("demo", []);
await expect(ctx.capabilities.has("device:serial")).resolves.toBe(false);
});
it("has() is false when declared but not granted", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", ["device:serial"]);
await expect(ctx.capabilities.has("device:serial")).resolves.toBe(false);
});
it("has() is true when declared and granted", async () => {
grants.set("demo", ["device:serial"]);
const { ctx } = contextFor("demo", ["device:serial"]);
await expect(ctx.capabilities.has("device:serial")).resolves.toBe(true);
});
it("require() throws and audits a refusal without the capability", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(ctx.capabilities.require("device:serial")).rejects.toThrow(
/device:serial/,
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_capability_require",
success: false,
});
});
it("require() resolves and audits success when granted", async () => {
grants.set("demo", ["device:serial"]);
const { ctx } = contextFor("demo", ["device:serial"]);
await expect(
ctx.capabilities.require("device:serial"),
).resolves.toBeUndefined();
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_capability_require",
success: true,
});
});
});
-4
View File
@@ -30,10 +30,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
url: "http://localhost:30006",
description: "Dashboard server",
},
{
url: "http://localhost:30011",
description: "Serial connection server",
},
],
components: {
securitySchemes: {