mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: convert serial console into the serial plugin
This commit is contained in:
1 parent
708eedb861
commit
791d8656a1
121 files changed
+2216
-1425
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,10 +30,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
url: "http://localhost:30006",
|
||||
description: "Dashboard server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30011",
|
||||
description: "Serial connection server",
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
|
||||
Reference in new issue
Block a user