mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
fix: harden application trust boundaries (#1317)
This commit is contained in:
+18
@@ -3,3 +3,21 @@
|
|||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Please report any vulnerabilities to [GitHub Security](https://github.com/Termix-SSH/Termix/security/advisories).
|
Please report any vulnerabilities to [GitHub Security](https://github.com/Termix-SSH/Termix/security/advisories).
|
||||||
|
|
||||||
|
## External secret storage
|
||||||
|
|
||||||
|
By default, a single-container installation generates its keys in the Termix
|
||||||
|
data directory for ease of recovery. Production deployments that keep backups
|
||||||
|
or database files outside a trusted encrypted volume should set
|
||||||
|
`TERMIX_REQUIRE_EXTERNAL_SECRETS=true` and provide all four keys through a
|
||||||
|
secret manager:
|
||||||
|
|
||||||
|
- `JWT_SECRET` (at least 64 characters)
|
||||||
|
- `DATABASE_KEY` (64 hexadecimal characters)
|
||||||
|
- `ENCRYPTION_KEY` (64 hexadecimal characters)
|
||||||
|
- `INTERNAL_AUTH_TOKEN` (at least 32 characters)
|
||||||
|
|
||||||
|
Each value can instead be mounted as a Docker or Kubernetes secret and supplied
|
||||||
|
with its corresponding `_FILE` variable, such as `ENCRYPTION_KEY_FILE`.
|
||||||
|
Hardened mode fails closed instead of writing a replacement key beside the
|
||||||
|
encrypted database.
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ env:
|
|||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
DATA_DIR: /app/data
|
DATA_DIR: /app/data
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
TERMIX_REQUIRE_EXTERNAL_SECRETS: "false"
|
||||||
GUACD_RECORDING_PATH: /termix-data/session_recordings/guacamole
|
GUACD_RECORDING_PATH: /termix-data/session_recordings/guacamole
|
||||||
GUACD_RECORDING_BACKEND_PATH: /app/data/session_recordings/guacamole
|
GUACD_RECORDING_BACKEND_PATH: /app/data/session_recordings/guacamole
|
||||||
|
|
||||||
@@ -86,6 +87,9 @@ secrets:
|
|||||||
name: ""
|
name: ""
|
||||||
data:
|
data:
|
||||||
JWT_SECRET: ""
|
JWT_SECRET: ""
|
||||||
|
DATABASE_KEY: ""
|
||||||
|
ENCRYPTION_KEY: ""
|
||||||
|
INTERNAL_AUTH_TOKEN: ""
|
||||||
DATABASE_URL: ""
|
DATABASE_URL: ""
|
||||||
GUACAMOLE_ENCRYPTION_KEY: ""
|
GUACAMOLE_ENCRYPTION_KEY: ""
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -106,7 +106,7 @@ COPY --chown=node:node drizzle ./drizzle
|
|||||||
|
|
||||||
VOLUME ["/app/data"]
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 30007 30008 30009 30010 30011 30012
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
CMD wget -q -O /dev/null http://localhost:30001/health || exit 1
|
CMD wget -q -O /dev/null http://localhost:30001/health || exit 1
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ services:
|
|||||||
GUACD_HOST: "guacd"
|
GUACD_HOST: "guacd"
|
||||||
GUACD_TUNNEL_HOST: "termix"
|
GUACD_TUNNEL_HOST: "termix"
|
||||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||||
|
# Hardened deployments can require keys from environment variables or
|
||||||
|
# Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE,
|
||||||
|
# ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE.
|
||||||
|
# TERMIX_REQUIRE_EXTERNAL_SECRETS: "true"
|
||||||
# Trusted reverse-proxy authentication is disabled by default. When
|
# Trusted reverse-proxy authentication is disabled by default. When
|
||||||
# enabled, do not expose this container directly to untrusted clients.
|
# enabled, do not expose this container directly to untrusted clients.
|
||||||
# TRUSTED_PROXY_AUTH_ENABLED: "true"
|
# TRUSTED_PROXY_AUTH_ENABLED: "true"
|
||||||
|
|||||||
+12
-4
@@ -21,7 +21,7 @@ const net = require("net");
|
|||||||
const tls = require("tls");
|
const tls = require("tls");
|
||||||
const zlib = require("zlib");
|
const zlib = require("zlib");
|
||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const { URL } = require("url");
|
const { URL, pathToFileURL } = require("url");
|
||||||
const { fork, spawn } = require("child_process");
|
const { fork, spawn } = require("child_process");
|
||||||
const pty = require("node-pty");
|
const pty = require("node-pty");
|
||||||
const WebSocket = require("ws");
|
const WebSocket = require("ws");
|
||||||
@@ -1193,11 +1193,12 @@ function createWindow() {
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
webSecurity: false,
|
sandbox: true,
|
||||||
|
webSecurity: true,
|
||||||
preload: path.join(__dirname, "preload.js"),
|
preload: path.join(__dirname, "preload.js"),
|
||||||
partition: termixSessionPartition,
|
partition: termixSessionPartition,
|
||||||
allowRunningInsecureContent: true,
|
allowRunningInsecureContent: false,
|
||||||
webviewTag: true,
|
webviewTag: false,
|
||||||
offscreen: false,
|
offscreen: false,
|
||||||
},
|
},
|
||||||
show: true,
|
show: true,
|
||||||
@@ -1377,6 +1378,13 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
return { action: "deny" };
|
return { action: "deny" };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.on("will-navigate", (event, url) => {
|
||||||
|
const allowedUrl = isDev
|
||||||
|
? url.startsWith("http://localhost:5173/")
|
||||||
|
: url === pathToFileURL(path.join(appRoot, "dist", "index.html")).href;
|
||||||
|
if (!allowedUrl) event.preventDefault();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle("get-app-version", () => {
|
ipcMain.handle("get-app-version", () => {
|
||||||
|
|||||||
+24
-1
@@ -1,5 +1,28 @@
|
|||||||
const { contextBridge, ipcRenderer } = require("electron");
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
|
const ALLOWED_INVOKE_CHANNELS = new Set([
|
||||||
|
"check-electron-update",
|
||||||
|
"clear-remote-sync-config",
|
||||||
|
"get-desktop-settings",
|
||||||
|
"get-legacy-server-config",
|
||||||
|
"get-remote-sync-config",
|
||||||
|
"get-remote-sync-jwt",
|
||||||
|
"get-remote-sync-status",
|
||||||
|
"get-remote-sync-user-info",
|
||||||
|
"remote-sync-now",
|
||||||
|
"save-desktop-settings",
|
||||||
|
"save-remote-sync-config",
|
||||||
|
"save-remote-sync-jwt",
|
||||||
|
"test-server-connection",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function invokeAllowed(channel, ...args) {
|
||||||
|
if (!ALLOWED_INVOKE_CHANNELS.has(channel)) {
|
||||||
|
return Promise.reject(new Error(`IPC channel is not allowed: ${channel}`));
|
||||||
|
}
|
||||||
|
return ipcRenderer.invoke(channel, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", {
|
contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
||||||
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
||||||
@@ -103,7 +126,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
return () => ipcRenderer.removeListener(channel, listener);
|
return () => ipcRenderer.removeListener(channel, listener);
|
||||||
},
|
},
|
||||||
|
|
||||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
invoke: invokeAllowed,
|
||||||
});
|
});
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronClipboard", {
|
contextBridge.exposeInMainWorld("electronClipboard", {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const main = readFileSync("electron/main.cjs", "utf8");
|
||||||
|
const preload = readFileSync("electron/preload.js", "utf8");
|
||||||
|
|
||||||
|
describe("Electron security boundary", () => {
|
||||||
|
it("keeps the renderer sandbox and browser security enabled", () => {
|
||||||
|
expect(main).toContain("sandbox: true");
|
||||||
|
expect(main).toContain("webSecurity: true");
|
||||||
|
expect(main).toContain("allowRunningInsecureContent: false");
|
||||||
|
expect(main).toContain("webviewTag: false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose an unrestricted IPC invoke primitive", () => {
|
||||||
|
expect(preload).toContain("invoke: invokeAllowed");
|
||||||
|
expect(preload).not.toContain(
|
||||||
|
"invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2034,7 +2034,7 @@ httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const serverReady = new Promise<void>((resolve) => {
|
export const serverReady = new Promise<void>((resolve) => {
|
||||||
httpServer.listen(HTTP_PORT, async () => {
|
httpServer.listen(HTTP_PORT, "127.0.0.1", async () => {
|
||||||
if (!fs.existsSync(uploadsDir)) {
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -2083,7 +2083,7 @@ if (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
httpsServer.listen(sslConfig.port, () => {
|
httpsServer.listen(sslConfig.port, "127.0.0.1", () => {
|
||||||
databaseLogger.success(
|
databaseLogger.success(
|
||||||
`Backend is now also listening for HTTPS directly`,
|
`Backend is now also listening for HTTPS directly`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
HOST_ADDRESS_MISMATCH_MESSAGE,
|
HOST_ADDRESS_MISMATCH_MESSAGE,
|
||||||
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
||||||
} from "../terminal/host-identity.js";
|
} from "../terminal/host-identity.js";
|
||||||
|
import { extractWebSocketToken } from "../../utils/ws-auth.js";
|
||||||
|
|
||||||
const sshLogger = systemLogger;
|
const sshLogger = systemLogger;
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ interface SSHSession {
|
|||||||
const activeSessions = new Map<string, SSHSession>();
|
const activeSessions = new Map<string, SSHSession>();
|
||||||
|
|
||||||
const wss = new WebSocketServer({
|
const wss = new WebSocketServer({
|
||||||
host: "0.0.0.0",
|
host: "127.0.0.1",
|
||||||
port: 30009,
|
port: 30009,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -285,26 +286,7 @@ async function createJumpHostChain(
|
|||||||
}
|
}
|
||||||
|
|
||||||
wss.on("connection", async (ws: WebSocket, req) => {
|
wss.on("connection", async (ws: WebSocket, req) => {
|
||||||
let token: string | undefined;
|
const token = extractWebSocketToken(req);
|
||||||
|
|
||||||
const cookieHeader = req.headers.cookie;
|
|
||||||
if (cookieHeader) {
|
|
||||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
|
||||||
if (match) token = decodeURIComponent(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
|
||||||
token = authHeader.slice("Bearer ".length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
const urlObj = new URL(req.url || "", "http://localhost");
|
|
||||||
const qp = urlObj.searchParams.get("token");
|
|
||||||
if (qp) token = qp;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
ws.close(1008, "Authentication required");
|
ws.close(1008, "Authentication required");
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ registerDockerContainerRoutes(app, {
|
|||||||
|
|
||||||
const PORT = 30007;
|
const PORT = 30007;
|
||||||
|
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, "127.0.0.1", async () => {
|
||||||
try {
|
try {
|
||||||
await authManager.initialize();
|
await authManager.initialize();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -3128,7 +3128,7 @@ process.on("SIGTERM", () => {
|
|||||||
const PORT = 30004;
|
const PORT = 30004;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const server = app.listen(PORT, async () => {
|
const server = app.listen(PORT, "127.0.0.1", async () => {
|
||||||
try {
|
try {
|
||||||
await authManager.initialize();
|
await authManager.initialize();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ async function persistGuacamoleRecording(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const websocketOptions = {
|
const websocketOptions = {
|
||||||
|
host: "127.0.0.1",
|
||||||
port: GUAC_WS_PORT,
|
port: GUAC_WS_PORT,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3071,7 +3071,7 @@ process.on("SIGTERM", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const PORT = 30005;
|
const PORT = 30005;
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, "127.0.0.1", async () => {
|
||||||
try {
|
try {
|
||||||
await authManager.initialize();
|
await authManager.initialize();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AuthManager } from "../utils/auth-manager.js";
|
|||||||
import { DataCrypto } from "../utils/data-crypto.js";
|
import { DataCrypto } from "../utils/data-crypto.js";
|
||||||
import { sshLogger } from "../utils/logger.js";
|
import { sshLogger } from "../utils/logger.js";
|
||||||
import { parseWsMessage } from "../utils/ws-message.js";
|
import { parseWsMessage } from "../utils/ws-message.js";
|
||||||
|
import { extractWebSocketToken } from "../utils/ws-auth.js";
|
||||||
|
|
||||||
interface SerialConnectData {
|
interface SerialConnectData {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -16,7 +17,7 @@ interface SerialConnectData {
|
|||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
const wss = new WebSocketServer({ port: 30011 });
|
const wss = new WebSocketServer({ host: "127.0.0.1", port: 30011 });
|
||||||
|
|
||||||
wss.on("error", (error) => {
|
wss.on("error", (error) => {
|
||||||
sshLogger.error("Serial WebSocket server error", error, {
|
sshLogger.error("Serial WebSocket server error", error, {
|
||||||
@@ -28,26 +29,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
let userId: string | undefined;
|
let userId: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let token: string | undefined;
|
const token = extractWebSocketToken(req);
|
||||||
|
|
||||||
const cookieHeader = req.headers.cookie;
|
|
||||||
if (cookieHeader) {
|
|
||||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
|
||||||
if (match) token = decodeURIComponent(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
|
||||||
token = authHeader.slice("Bearer ".length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
const urlObj = new URL(req.url || "", "http://localhost");
|
|
||||||
const qp = urlObj.searchParams.get("token");
|
|
||||||
if (qp) token = qp;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
ws.close(1008, "Authentication required");
|
ws.close(1008, "Authentication required");
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
||||||
resolveServerJumpHosts,
|
resolveServerJumpHosts,
|
||||||
} from "./host-identity.js";
|
} from "./host-identity.js";
|
||||||
|
import { extractWebSocketToken } from "../../utils/ws-auth.js";
|
||||||
|
|
||||||
interface ConnectToHostData {
|
interface ConnectToHostData {
|
||||||
cols: number;
|
cols: number;
|
||||||
@@ -128,6 +129,7 @@ const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000;
|
|||||||
const userConnections = new Map<string, Set<WebSocket>>();
|
const userConnections = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
const wss = new WebSocketServer({
|
const wss = new WebSocketServer({
|
||||||
|
host: "127.0.0.1",
|
||||||
port: 30002,
|
port: 30002,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -299,25 +301,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let token: string | undefined;
|
const token = extractWebSocketToken(req);
|
||||||
|
|
||||||
const cookieHeader = req.headers.cookie;
|
|
||||||
if (cookieHeader) {
|
|
||||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
|
||||||
if (match) token = decodeURIComponent(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
|
||||||
token = authHeader.slice("Bearer ".length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
const qp = urlObj.searchParams.get("token");
|
|
||||||
if (qp) token = qp;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
ws.close(1008, "Authentication required");
|
ws.close(1008, "Authentication required");
|
||||||
|
|||||||
@@ -923,4 +923,4 @@ app.put("/tmux_monitor/:hostId/tags", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const PORT = 30010;
|
const PORT = 30010;
|
||||||
app.listen(PORT, () => {});
|
app.listen(PORT, "127.0.0.1", () => {});
|
||||||
|
|||||||
@@ -2,24 +2,14 @@ import type { IncomingMessage } from "http";
|
|||||||
import type { Duplex } from "stream";
|
import type { Duplex } from "stream";
|
||||||
import type { ClientChannel } from "ssh2";
|
import type { ClientChannel } from "ssh2";
|
||||||
import type { WebSocket } from "ws";
|
import type { WebSocket } from "ws";
|
||||||
|
import { extractWebSocketToken } from "../../utils/ws-auth.js";
|
||||||
|
|
||||||
const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
|
const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
|
||||||
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
||||||
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
|
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
|
||||||
|
|
||||||
export function extractRequestToken(req: IncomingMessage): string | undefined {
|
export function extractRequestToken(req: IncomingMessage): string | undefined {
|
||||||
const cookieHeader = req.headers.cookie;
|
return extractWebSocketToken(req);
|
||||||
if (cookieHeader) {
|
|
||||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
|
||||||
if (match) return decodeURIComponent(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
|
||||||
return authHeader.slice("Bearer ".length);
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendC2SError(ws: WebSocket, message: string): void {
|
export function sendC2SError(ws: WebSocket, message: string): void {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ c2sRelayWss.on("connection", (ws, req) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, "127.0.0.1", () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
initializeAutoStartTunnels();
|
initializeAutoStartTunnels();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ app.delete("/activity/reset", async (req, res) => {
|
|||||||
app.use("/service-links", dashboardServiceLinksRouter);
|
app.use("/service-links", dashboardServiceLinksRouter);
|
||||||
|
|
||||||
const PORT = 30006;
|
const PORT = 30006;
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, "127.0.0.1", async () => {
|
||||||
try {
|
try {
|
||||||
await authManager.initialize();
|
await authManager.initialize();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { IncomingMessage } from "http";
|
||||||
|
import { extractWebSocketToken } from "../../utils/ws-auth.js";
|
||||||
|
|
||||||
|
function request(headers: Record<string, string>): IncomingMessage {
|
||||||
|
return { headers } as IncomingMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("extractWebSocketToken", () => {
|
||||||
|
it("reads JWTs from the WebSocket subprotocol without using the URL", () => {
|
||||||
|
expect(
|
||||||
|
extractWebSocketToken(
|
||||||
|
request({ "sec-websocket-protocol": "termix.jwt.header.payload.sig" }),
|
||||||
|
),
|
||||||
|
).toBe("header.payload.sig");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the HttpOnly cookie over renderer-provided protocols", () => {
|
||||||
|
expect(
|
||||||
|
extractWebSocketToken(
|
||||||
|
request({
|
||||||
|
cookie: "jwt=cookie-token",
|
||||||
|
"sec-websocket-protocol": "termix.jwt.protocol-token",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("cookie-token");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,35 @@ class SystemCrypto {
|
|||||||
|
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
|
private async readExternalSecret(
|
||||||
|
name: string,
|
||||||
|
minimumLength: number,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const direct = process.env[name]?.trim();
|
||||||
|
if (direct && direct.length >= minimumLength) return direct;
|
||||||
|
|
||||||
|
const secretFile = process.env[`${name}_FILE`]?.trim();
|
||||||
|
if (!secretFile) return null;
|
||||||
|
const value = (await fs.readFile(secretFile, "utf8")).trim();
|
||||||
|
if (value.length < minimumLength) {
|
||||||
|
throw new Error(`${name}_FILE contains a secret that is too short`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireExternalSecret(name: string): never {
|
||||||
|
throw new Error(
|
||||||
|
`${name} must be supplied through ${name} or ${name}_FILE when TERMIX_REQUIRE_EXTERNAL_SECRETS=true`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseExternalHexKey(name: string, value: string): Buffer {
|
||||||
|
if (!/^[0-9a-f]{64}$/i.test(value)) {
|
||||||
|
throw new Error(`${name} must contain exactly 64 hexadecimal characters`);
|
||||||
|
}
|
||||||
|
return Buffer.from(value, "hex");
|
||||||
|
}
|
||||||
|
|
||||||
static getInstance(): SystemCrypto {
|
static getInstance(): SystemCrypto {
|
||||||
if (!this.instance) {
|
if (!this.instance) {
|
||||||
this.instance = new SystemCrypto();
|
this.instance = new SystemCrypto();
|
||||||
@@ -21,8 +50,8 @@ class SystemCrypto {
|
|||||||
|
|
||||||
async initializeJWTSecret(): Promise<void> {
|
async initializeJWTSecret(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const envSecret = process.env.JWT_SECRET;
|
const envSecret = await this.readExternalSecret("JWT_SECRET", 64);
|
||||||
if (envSecret && envSecret.length >= 64) {
|
if (envSecret) {
|
||||||
this.jwtSecret = envSecret;
|
this.jwtSecret = envSecret;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -39,7 +68,6 @@ class SystemCrypto {
|
|||||||
databaseLogger.success("JWT secret loaded from .env file", {
|
databaseLogger.success("JWT secret loaded from .env file", {
|
||||||
operation: "jwt_init_from_file_success",
|
operation: "jwt_init_from_file_success",
|
||||||
secretLength: jwtMatch[1].length,
|
secretLength: jwtMatch[1].length,
|
||||||
secretPrefix: jwtMatch[1].substring(0, 8) + "...",
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
@@ -56,6 +84,9 @@ class SystemCrypto {
|
|||||||
// expected - env file may not exist
|
// expected - env file may not exist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.TERMIX_REQUIRE_EXTERNAL_SECRETS === "true") {
|
||||||
|
this.requireExternalSecret("JWT_SECRET");
|
||||||
|
}
|
||||||
await this.generateAndGuideUser();
|
await this.generateAndGuideUser();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to initialize JWT secret", error, {
|
databaseLogger.error("Failed to initialize JWT secret", error, {
|
||||||
@@ -77,9 +108,9 @@ class SystemCrypto {
|
|||||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||||
const envPath = path.join(dataDir, ".env");
|
const envPath = path.join(dataDir, ".env");
|
||||||
|
|
||||||
const envKey = process.env.DATABASE_KEY;
|
const envKey = await this.readExternalSecret("DATABASE_KEY", 64);
|
||||||
if (envKey && envKey.length >= 64) {
|
if (envKey) {
|
||||||
this.databaseKey = Buffer.from(envKey, "hex");
|
this.databaseKey = this.parseExternalHexKey("DATABASE_KEY", envKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +128,9 @@ class SystemCrypto {
|
|||||||
// expected - env file may not exist
|
// expected - env file may not exist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.TERMIX_REQUIRE_EXTERNAL_SECRETS === "true") {
|
||||||
|
this.requireExternalSecret("DATABASE_KEY");
|
||||||
|
}
|
||||||
await this.generateAndGuideDatabaseKey();
|
await this.generateAndGuideDatabaseKey();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to initialize database key", error, {
|
databaseLogger.error("Failed to initialize database key", error, {
|
||||||
@@ -119,9 +153,9 @@ class SystemCrypto {
|
|||||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||||
const envPath = path.join(dataDir, ".env");
|
const envPath = path.join(dataDir, ".env");
|
||||||
|
|
||||||
const envKey = process.env.ENCRYPTION_KEY;
|
const envKey = await this.readExternalSecret("ENCRYPTION_KEY", 64);
|
||||||
if (envKey && envKey.length >= 64) {
|
if (envKey) {
|
||||||
this.encryptionKey = Buffer.from(envKey, "hex");
|
this.encryptionKey = this.parseExternalHexKey("ENCRYPTION_KEY", envKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +171,9 @@ class SystemCrypto {
|
|||||||
// expected - env file may not exist
|
// expected - env file may not exist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.TERMIX_REQUIRE_EXTERNAL_SECRETS === "true") {
|
||||||
|
this.requireExternalSecret("ENCRYPTION_KEY");
|
||||||
|
}
|
||||||
await this.generateAndGuideEncryptionKey();
|
await this.generateAndGuideEncryptionKey();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to initialize encryption key", error, {
|
databaseLogger.error("Failed to initialize encryption key", error, {
|
||||||
@@ -156,8 +193,8 @@ class SystemCrypto {
|
|||||||
|
|
||||||
async initializeInternalAuthToken(): Promise<void> {
|
async initializeInternalAuthToken(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const envToken = process.env.INTERNAL_AUTH_TOKEN;
|
const envToken = await this.readExternalSecret("INTERNAL_AUTH_TOKEN", 32);
|
||||||
if (envToken && envToken.length >= 32) {
|
if (envToken) {
|
||||||
this.internalAuthToken = envToken;
|
this.internalAuthToken = envToken;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -177,6 +214,9 @@ class SystemCrypto {
|
|||||||
// expected - env file may not exist
|
// expected - env file may not exist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.TERMIX_REQUIRE_EXTERNAL_SECRETS === "true") {
|
||||||
|
this.requireExternalSecret("INTERNAL_AUTH_TOKEN");
|
||||||
|
}
|
||||||
await this.generateAndGuideInternalAuthToken();
|
await this.generateAndGuideInternalAuthToken();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to initialize internal auth token", error, {
|
databaseLogger.error("Failed to initialize internal auth token", error, {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { IncomingMessage } from "http";
|
||||||
|
|
||||||
|
const JWT_PROTOCOL_PREFIX = "termix.jwt.";
|
||||||
|
|
||||||
|
export function extractWebSocketToken(
|
||||||
|
req: IncomingMessage,
|
||||||
|
): string | undefined {
|
||||||
|
const cookieHeader = req.headers.cookie;
|
||||||
|
if (cookieHeader) {
|
||||||
|
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||||
|
if (match) return decodeURIComponent(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
return authHeader.slice("Bearer ".length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocols = String(req.headers["sec-websocket-protocol"] || "")
|
||||||
|
.split(",")
|
||||||
|
.map((protocol) => protocol.trim());
|
||||||
|
const jwtProtocol = protocols.find((protocol) =>
|
||||||
|
protocol.startsWith(JWT_PROTOCOL_PREFIX),
|
||||||
|
);
|
||||||
|
return jwtProtocol?.slice(JWT_PROTOCOL_PREFIX.length);
|
||||||
|
}
|
||||||
@@ -313,6 +313,7 @@ function ConsoleTerminalInner({
|
|||||||
window.location.port === "");
|
window.location.port === "");
|
||||||
|
|
||||||
let baseWsUrl: string;
|
let baseWsUrl: string;
|
||||||
|
let wsProtocols: string[] = [];
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`;
|
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`;
|
||||||
} else if (isElectronApp) {
|
} else if (isElectronApp) {
|
||||||
@@ -331,12 +332,13 @@ function ConsoleTerminalInner({
|
|||||||
toast.error(t("errors.remoteServerRequired"));
|
toast.error(t("errors.remoteServerRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
baseWsUrl = resolvedUrl;
|
baseWsUrl = resolvedUrl.url;
|
||||||
|
wsProtocols = resolvedUrl.protocols;
|
||||||
} else {
|
} else {
|
||||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
|
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ws = new WebSocket(baseWsUrl);
|
const ws = new WebSocket(baseWsUrl, wsProtocols);
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
const cols = terminal.cols || 80;
|
const cols = terminal.cols || 80;
|
||||||
|
|||||||
@@ -250,17 +250,18 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
const origin = await resolveConnectionOrigin({
|
const origin = await resolveConnectionOrigin({
|
||||||
connectionType: connectionProtocol,
|
connectionType: connectionProtocol,
|
||||||
});
|
});
|
||||||
wsBase = await buildOriginWsUrl({
|
const target = await buildOriginWsUrl({
|
||||||
origin,
|
origin,
|
||||||
localPort: 30008,
|
localPort: 30008,
|
||||||
localPath: "/guacamole/websocket/",
|
localPath: "/guacamole/websocket/",
|
||||||
remotePath: "/guacamole/websocket/",
|
remotePath: "/guacamole/websocket/",
|
||||||
includeJwt: false,
|
includeJwt: false,
|
||||||
});
|
});
|
||||||
if (!wsBase) {
|
if (!target) {
|
||||||
onError?.(t("errors.remoteServerRequired"));
|
onError?.(t("errors.remoteServerRequired"));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
wsBase = target.url;
|
||||||
} else {
|
} else {
|
||||||
wsBase = buildGuacamoleWebSocketBaseUrl({
|
wsBase = buildGuacamoleWebSocketBaseUrl({
|
||||||
isDev,
|
isDev,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TriangleAlert } from "lucide-react";
|
import { TriangleAlert } from "lucide-react";
|
||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
|
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||||
import { useTheme } from "@/components/theme-provider";
|
import { useTheme } from "@/components/theme-provider";
|
||||||
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
|
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
|
||||||
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
|
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
|
||||||
@@ -102,9 +103,7 @@ export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
|
|||||||
const buildWsUrl = useCallback(() => {
|
const buildWsUrl = useCallback(() => {
|
||||||
// Serial is always local -- the device is physically attached to this
|
// Serial is always local -- the device is physically attached to this
|
||||||
// desktop machine, so it never routes through a remote server.
|
// desktop machine, so it never routes through a remote server.
|
||||||
const token = localStorage.getItem("jwt");
|
return "ws://127.0.0.1:30011";
|
||||||
const base = "ws://127.0.0.1:30011";
|
|
||||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const disconnectWs = useCallback(() => {
|
const disconnectWs = useCallback(() => {
|
||||||
@@ -124,7 +123,10 @@ export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(
|
||||||
|
url,
|
||||||
|
websocketAuthProtocols(localStorage.getItem("jwt")),
|
||||||
|
);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
|||||||
@@ -1201,6 +1201,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
window.location.port === "");
|
window.location.port === "");
|
||||||
|
|
||||||
let baseWsUrl: string;
|
let baseWsUrl: string;
|
||||||
|
let wsProtocols: string[] = [];
|
||||||
|
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
|
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
|
||||||
@@ -1223,7 +1224,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
isConnectingRef.current = false;
|
isConnectingRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
baseWsUrl = resolvedUrl;
|
baseWsUrl = resolvedUrl.url;
|
||||||
|
wsProtocols = resolvedUrl.protocols;
|
||||||
} else {
|
} else {
|
||||||
baseWsUrl = `${getBasePath()}/ssh/websocket/`;
|
baseWsUrl = `${getBasePath()}/ssh/websocket/`;
|
||||||
}
|
}
|
||||||
@@ -1246,7 +1248,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
connectionTimeoutRef.current = null;
|
connectionTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ws = new WebSocket(baseWsUrl);
|
const ws = new WebSocket(baseWsUrl, wsProtocols);
|
||||||
webSocketRef.current = ws;
|
webSocketRef.current = ws;
|
||||||
wasDisconnectedBySSH.current = false;
|
wasDisconnectedBySSH.current = false;
|
||||||
updateConnectionError(null);
|
updateConnectionError(null);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
|
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||||
|
|
||||||
export type ConnectionOrigin = "local" | "remote";
|
export type ConnectionOrigin = "local" | "remote";
|
||||||
|
|
||||||
@@ -75,6 +76,11 @@ async function getRemoteConnectionTarget(): Promise<RemoteConnectionTarget | nul
|
|||||||
* remote server is connected -- callers must show a blocking message
|
* remote server is connected -- callers must show a blocking message
|
||||||
* rather than attempting to connect.
|
* rather than attempting to connect.
|
||||||
*/
|
*/
|
||||||
|
export interface WebSocketConnectionTarget {
|
||||||
|
url: string;
|
||||||
|
protocols: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildOriginWsUrl({
|
export async function buildOriginWsUrl({
|
||||||
origin,
|
origin,
|
||||||
localPort,
|
localPort,
|
||||||
@@ -87,14 +93,13 @@ export async function buildOriginWsUrl({
|
|||||||
localPath: string;
|
localPath: string;
|
||||||
remotePath: string;
|
remotePath: string;
|
||||||
includeJwt?: boolean;
|
includeJwt?: boolean;
|
||||||
}): Promise<string | null> {
|
}): Promise<WebSocketConnectionTarget | null> {
|
||||||
if (origin === "local") {
|
if (origin === "local") {
|
||||||
let url = `ws://127.0.0.1:${localPort}${localPath}`;
|
const token = includeJwt ? localStorage.getItem("jwt") : null;
|
||||||
if (includeJwt) {
|
return {
|
||||||
const token = localStorage.getItem("jwt");
|
url: `ws://127.0.0.1:${localPort}${localPath}`,
|
||||||
if (token) url += `?token=${encodeURIComponent(token)}`;
|
protocols: websocketAuthProtocols(token),
|
||||||
}
|
};
|
||||||
return url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const remote = await getRemoteConnectionTarget();
|
const remote = await getRemoteConnectionTarget();
|
||||||
@@ -106,9 +111,8 @@ export async function buildOriginWsUrl({
|
|||||||
const wsHost = remote.serverUrl
|
const wsHost = remote.serverUrl
|
||||||
.replace(/^https?:\/\//, "")
|
.replace(/^https?:\/\//, "")
|
||||||
.replace(/\/$/, "");
|
.replace(/\/$/, "");
|
||||||
let url = `${wsProtocol}${wsHost}${remotePath}`;
|
return {
|
||||||
if (includeJwt && remote.jwt) {
|
url: `${wsProtocol}${wsHost}${remotePath}`,
|
||||||
url += `?token=${encodeURIComponent(remote.jwt)}`;
|
protocols: websocketAuthProtocols(includeJwt ? remote.jwt : null),
|
||||||
}
|
};
|
||||||
return url;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const JWT_PROTOCOL_PREFIX = "termix.jwt.";
|
||||||
|
|
||||||
|
export function websocketAuthProtocols(token: string | null): string[] {
|
||||||
|
return token ? [`${JWT_PROTOCOL_PREFIX}${token}`] : [];
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { RefreshCw, Usb, TriangleAlert } from "lucide-react";
|
import { RefreshCw, Usb, TriangleAlert } from "lucide-react";
|
||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
|
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||||
import type { SerialConfig } from "@/types/ui-types";
|
import type { SerialConfig } from "@/types/ui-types";
|
||||||
|
|
||||||
const BAUD_RATES = [
|
const BAUD_RATES = [
|
||||||
@@ -34,9 +35,7 @@ export function SerialPanel({ onConnect }: SerialPanelProps) {
|
|||||||
const buildWsUrl = () => {
|
const buildWsUrl = () => {
|
||||||
// Serial is always local -- the device is physically attached to this
|
// Serial is always local -- the device is physically attached to this
|
||||||
// desktop machine, so it never routes through a remote server.
|
// desktop machine, so it never routes through a remote server.
|
||||||
const token = localStorage.getItem("jwt");
|
return "ws://127.0.0.1:30011";
|
||||||
const base = "ws://127.0.0.1:30011";
|
|
||||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshPorts = useCallback(() => {
|
const refreshPorts = useCallback(() => {
|
||||||
@@ -48,7 +47,10 @@ export function SerialPanel({ onConnect }: SerialPanelProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(
|
||||||
|
url,
|
||||||
|
websocketAuthProtocols(localStorage.getItem("jwt")),
|
||||||
|
);
|
||||||
ws.onopen = () => ws.send(JSON.stringify({ type: "list_ports" }));
|
ws.onopen = () => ws.send(JSON.stringify({ type: "list_ports" }));
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -116,18 +116,21 @@ describe("buildOriginWsUrl", () => {
|
|||||||
|
|
||||||
it("carries the local JWT by default", async () => {
|
it("carries the local JWT by default", async () => {
|
||||||
// Every interactive channel on the embedded backend relies on this.
|
// Every interactive channel on the embedded backend relies on this.
|
||||||
const url = await buildOriginWsUrl({
|
const target = await buildOriginWsUrl({
|
||||||
origin: "local",
|
origin: "local",
|
||||||
localPort: 30009,
|
localPort: 30009,
|
||||||
localPath: "/docker/console/",
|
localPath: "/docker/console/",
|
||||||
remotePath: "/docker/console/",
|
remotePath: "/docker/console/",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(url).toBe("ws://127.0.0.1:30009/docker/console/?token=local-jwt");
|
expect(target).toEqual({
|
||||||
|
url: "ws://127.0.0.1:30009/docker/console/",
|
||||||
|
protocols: ["termix.jwt.local-jwt"],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits it only when a caller asks", async () => {
|
it("omits it only when a caller asks", async () => {
|
||||||
const url = await buildOriginWsUrl({
|
const target = await buildOriginWsUrl({
|
||||||
origin: "local",
|
origin: "local",
|
||||||
localPort: 30009,
|
localPort: 30009,
|
||||||
localPath: "/docker/console/",
|
localPath: "/docker/console/",
|
||||||
@@ -135,7 +138,10 @@ describe("buildOriginWsUrl", () => {
|
|||||||
includeJwt: false,
|
includeJwt: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(url).toBe("ws://127.0.0.1:30009/docker/console/");
|
expect(target).toEqual({
|
||||||
|
url: "ws://127.0.0.1:30009/docker/console/",
|
||||||
|
protocols: [],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not duplicate the Guacamole token on remote connections", async () => {
|
it("does not duplicate the Guacamole token on remote connections", async () => {
|
||||||
@@ -149,7 +155,7 @@ describe("buildOriginWsUrl", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const url = await buildOriginWsUrl({
|
const target = await buildOriginWsUrl({
|
||||||
origin: "remote",
|
origin: "remote",
|
||||||
localPort: 30008,
|
localPort: 30008,
|
||||||
localPath: "/guacamole/websocket/",
|
localPath: "/guacamole/websocket/",
|
||||||
@@ -157,19 +163,25 @@ describe("buildOriginWsUrl", () => {
|
|||||||
includeJwt: false,
|
includeJwt: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(url).toBe("wss://termix.example/guacamole/websocket/");
|
expect(target).toEqual({
|
||||||
|
url: "wss://termix.example/guacamole/websocket/",
|
||||||
|
protocols: [],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves the URL alone when there is no token stored", async () => {
|
it("leaves the URL alone when there is no token stored", async () => {
|
||||||
delete store.jwt;
|
delete store.jwt;
|
||||||
|
|
||||||
const url = await buildOriginWsUrl({
|
const target = await buildOriginWsUrl({
|
||||||
origin: "local",
|
origin: "local",
|
||||||
localPort: 30002,
|
localPort: 30002,
|
||||||
localPath: "",
|
localPath: "",
|
||||||
remotePath: "/ssh/websocket/",
|
remotePath: "/ssh/websocket/",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(url).toBe("ws://127.0.0.1:30002");
|
expect(target).toEqual({
|
||||||
|
url: "ws://127.0.0.1:30002",
|
||||||
|
protocols: [],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||||
|
|
||||||
|
describe("websocketAuthProtocols", () => {
|
||||||
|
it("moves a JWT into the WebSocket protocol header", () => {
|
||||||
|
expect(websocketAuthProtocols("header.payload.sig")).toEqual([
|
||||||
|
"termix.jwt.header.payload.sig",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not advertise an authentication protocol without a token", () => {
|
||||||
|
expect(websocketAuthProtocols(null)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user