diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index dc42e76d..9a2db205 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -1,4 +1,3 @@
-services:
services:
termix:
image: ghcr.io/lukegus/termix:latest
diff --git a/electron/main.cjs b/electron/main.cjs
index ce8602de..aceef2c9 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -563,12 +563,21 @@ async function clearElectronJwtCookiesAtStartup() {
function getBackendPaths() {
if (isDev) {
const backendDir = path.join(appRoot, "dist", "backend", "backend");
- return { entryPath: path.join(backendDir, "starter.js"), backendCwd: backendDir };
+ return {
+ entryPath: path.join(backendDir, "starter.js"),
+ backendCwd: backendDir,
+ };
}
// fork() does not go through Electron's asar redirector — use the unpacked path
- const unpackedRoot = appRoot.replace(/app\.asar(?!\.unpacked)/, "app.asar.unpacked");
+ const unpackedRoot = appRoot.replace(
+ /app\.asar(?!\.unpacked)/,
+ "app.asar.unpacked",
+ );
const backendDir = path.join(unpackedRoot, "dist", "backend", "backend");
- return { entryPath: path.join(backendDir, "starter.js"), backendCwd: backendDir };
+ return {
+ entryPath: path.join(backendDir, "starter.js"),
+ backendCwd: backendDir,
+ };
}
function getBackendDataDir() {
@@ -606,9 +615,10 @@ function startBackendServer() {
logToFile(" backendCwd exists:", fs.existsSync(backendCwd));
backendProcess = fork(entryPath, [], {
- cwd: fs.existsSync(backendCwd) && fs.statSync(backendCwd).isDirectory()
- ? backendCwd
- : dataDir,
+ cwd:
+ fs.existsSync(backendCwd) && fs.statSync(backendCwd).isDirectory()
+ ? backendCwd
+ : dataDir,
env: {
...process.env,
DATA_DIR: dataDir,
@@ -1089,40 +1099,51 @@ ipcMain.handle("get-embedded-server-status", () => {
});
// OIDC System Browser Authentication (RFC 8252)
-ipcMain.handle("oidc-system-browser-auth", async (_event, authUrl, callbackPort) => {
- const http = require("http");
+ipcMain.handle(
+ "oidc-system-browser-auth",
+ async (_event, authUrl, callbackPort) => {
+ const http = require("http");
- return new Promise((resolve, reject) => {
- const server = http.createServer((req, res) => {
- const url = new URL(req.url, `http://localhost:${callbackPort}`);
- if (url.pathname === "/oidc-callback") {
- const success = url.searchParams.get("success");
- const error = url.searchParams.get("error");
- const token = url.searchParams.get("token");
+ return new Promise((resolve, reject) => {
+ const server = http.createServer((req, res) => {
+ const url = new URL(req.url, `http://localhost:${callbackPort}`);
+ if (url.pathname === "/oidc-callback") {
+ const success = url.searchParams.get("success");
+ const error = url.searchParams.get("error");
+ const token = url.searchParams.get("token");
- res.writeHead(200, { "Content-Type": "text/html" });
- res.end(`
${success === "true" ? "Authentication successful!" : "Authentication failed."}
You can close this tab and return to Termix.
`);
+ res.writeHead(200, { "Content-Type": "text/html" });
+ res.end(
+ `${success === "true" ? "Authentication successful!" : "Authentication failed."}
You can close this tab and return to Termix.
`,
+ );
- server.close();
- if (success === "true") {
- resolve({ success: true, token });
- } else {
- resolve({ success: false, error: error || "Authentication failed" });
+ server.close();
+ if (success === "true") {
+ resolve({ success: true, token });
+ } else {
+ resolve({
+ success: false,
+ error: error || "Authentication failed",
+ });
+ }
}
- }
- });
+ });
- server.listen(callbackPort, "127.0.0.1", () => {
- shell.openExternal(authUrl);
- });
+ server.listen(callbackPort, "127.0.0.1", () => {
+ shell.openExternal(authUrl);
+ });
- // Timeout after 5 minutes
- setTimeout(() => {
- server.close();
- reject(new Error("OIDC authentication timed out"));
- }, 5 * 60 * 1000);
- });
-});
+ // Timeout after 5 minutes
+ setTimeout(
+ () => {
+ server.close();
+ reject(new Error("OIDC authentication timed out"));
+ },
+ 5 * 60 * 1000,
+ );
+ });
+ },
+);
ipcMain.handle("get-server-config", () => {
try {
diff --git a/index.html b/index.html
index f774a2b1..94725365 100644
--- a/index.html
+++ b/index.html
@@ -47,7 +47,9 @@
-
+
diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts
index d8b6fc16..201ab6e1 100644
--- a/src/backend/ssh/docker.ts
+++ b/src/backend/ssh/docker.ts
@@ -139,10 +139,7 @@ async function resolveJumpHost(
): Promise {
try {
const hostResults = await SimpleDBOps.select(
- getDb()
- .select()
- .from(hosts)
- .where(eq(hosts.id, hostId)),
+ getDb().select().from(hosts).where(eq(hosts.id, hostId)),
"ssh_data",
userId,
);
@@ -160,8 +157,10 @@ async function resolveJumpHost(
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
- const sharedCred =
- await sharedCredManager.getSharedCredentialForUser(hostId, userId);
+ const sharedCred = await sharedCredManager.getSharedCredentialForUser(
+ hostId,
+ userId,
+ );
if (sharedCred) {
return {
...host,
diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts
index 93e02331..f95c45b0 100644
--- a/src/backend/ssh/file-manager.ts
+++ b/src/backend/ssh/file-manager.ts
@@ -454,21 +454,33 @@ function execWithSudo(
command: string,
sudoPassword: string,
): Promise<{ stdout: string; stderr: string; code: number }> {
+ return execWithSudoBuffer(session, command, sudoPassword).then((result) => ({
+ stdout: result.stdout.toString("utf8"),
+ stderr: result.stderr,
+ code: result.code,
+ }));
+}
+
+function execWithSudoBuffer(
+ session: SSHSession,
+ command: string,
+ sudoPassword: string,
+): Promise<{ stdout: Buffer; stderr: string; code: number }> {
return new Promise((resolve) => {
const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'");
const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`;
execChannel(session, sudoCommand, (err, stream) => {
if (err) {
- resolve({ stdout: "", stderr: err.message, code: 1 });
+ resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
return;
}
- let stdout = "";
+ const stdoutChunks: Buffer[] = [];
let stderr = "";
stream.on("data", (chunk: Buffer) => {
- stdout += chunk.toString();
+ stdoutChunks.push(chunk);
});
stream.stderr.on("data", (chunk: Buffer) => {
@@ -476,12 +488,22 @@ function execWithSudo(
});
stream.on("close", (code: number) => {
- stdout = stdout.replace(/\[sudo\] password for .+?:\s*/g, "");
+ let stdout = Buffer.concat(stdoutChunks);
+ const sudoPromptMatch = stdout
+ .toString("utf8", 0, Math.min(stdout.length, 256))
+ .match(/^\[sudo\] password for .+?:\s*/);
+ if (sudoPromptMatch) {
+ stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0]));
+ }
resolve({ stdout, stderr, code: code || 0 });
});
stream.on("error", (streamErr: Error) => {
- resolve({ stdout, stderr: streamErr.message, code: 1 });
+ resolve({
+ stdout: Buffer.concat(stdoutChunks),
+ stderr: streamErr.message,
+ code: 1,
+ });
});
});
});
@@ -3098,22 +3120,25 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
.includes("permission denied");
if (isPermissionDenied && sshConn.sudoPassword) {
- execWithSudo(
+ execWithSudoBuffer(
sshConn,
`cat '${escapedPath}'`,
sshConn.sudoPassword,
)
- .then(({ stdout, stderr, code: sudoCode }) => {
- if (sudoCode !== 0) {
+ .then((result) => {
+ if (result.code !== 0) {
return res.status(403).json({
- error: `Permission denied: ${stderr}`,
+ error: `Permission denied: ${result.stderr || result.stdout.toString("utf8")}`,
needsSudo: true,
});
}
- const sudoData = Buffer.from(stdout, "utf8");
+
+ const sudoData = result.stdout;
const isBinary = detectBinary(sudoData);
res.json({
- content: isBinary ? sudoData.toString("base64") : stdout,
+ content: isBinary
+ ? sudoData.toString("base64")
+ : sudoData.toString("utf8"),
isBinary,
size: sudoData.length,
});
diff --git a/src/backend/ssh/host-resolver.ts b/src/backend/ssh/host-resolver.ts
index 844da398..110969b2 100644
--- a/src/backend/ssh/host-resolver.ts
+++ b/src/backend/ssh/host-resolver.ts
@@ -83,13 +83,12 @@ export async function resolveHostById(
.select()
.from(hostAccess)
.where(
- and(
- eq(hostAccess.hostId, hostId),
- eq(hostAccess.userId, userId),
- ),
+ and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
)
.limit(1);
- const overrideCredId = accessRecords[0]?.overrideCredentialId as number | null;
+ const overrideCredId = accessRecords[0]?.overrideCredentialId as
+ | number
+ | null;
if (overrideCredId) {
const userCreds = await SimpleDBOps.select(
db
@@ -113,7 +112,11 @@ export async function resolveHostById(
if (!host.overrideCredentialUsername) {
host.username = cred.username;
}
- host.authType = cred.key ? "key" : cred.password ? "password" : "none";
+ host.authType = cred.key
+ ? "key"
+ : cred.password
+ ? "password"
+ : "none";
return host as unknown as SSHHost;
}
}
diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts
index ef7e3538..cf400a5a 100644
--- a/src/backend/ssh/server-stats.ts
+++ b/src/backend/ssh/server-stats.ts
@@ -75,10 +75,7 @@ async function resolveJumpHost(
): Promise {
try {
const hostResults = await SimpleDBOps.select(
- getDb()
- .select()
- .from(hosts)
- .where(eq(hosts.id, hostId)),
+ getDb().select().from(hosts).where(eq(hosts.id, hostId)),
"ssh_data",
userId,
);
@@ -96,8 +93,10 @@ async function resolveJumpHost(
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
- const sharedCred =
- await sharedCredManager.getSharedCredentialForUser(hostId, userId);
+ const sharedCred = await sharedCredManager.getSharedCredentialForUser(
+ hostId,
+ userId,
+ );
if (sharedCred) {
return {
...host,
diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts
index 305bb12e..50892ad9 100644
--- a/src/backend/ssh/terminal.ts
+++ b/src/backend/ssh/terminal.ts
@@ -1,15 +1,14 @@
import { WebSocketServer, WebSocket, type RawData } from "ws";
-import ssh2pkg from "ssh2";
-const { Client, BaseAgent, utils: ssh2Utils } = ssh2pkg;
-import type {
- Client as ClientType,
- ClientChannel,
- PseudoTtyOptions,
- ParsedKey,
- SignCallback,
- SigningRequestOptions,
- IdentityCallback,
+import ssh2Pkg, {
+ type Client as SSHClientType,
+ type ClientChannel,
+ type PseudoTtyOptions,
+ type ParsedKey,
+ type SignCallback,
+ type SigningRequestOptions,
+ type IdentityCallback,
} from "ssh2";
+const { Client, BaseAgent, utils: ssh2Utils } = ssh2Pkg;
import net from "net";
import dgram from "dgram";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
@@ -273,13 +272,13 @@ async function createJumpHostChain(
jumpHosts: Array<{ hostId: number }>,
userId: string,
socks5Config?: SOCKS5Config | null,
-): Promise {
+): Promise {
if (!jumpHosts || jumpHosts.length === 0) {
return null;
}
- let currentClient: ClientType | null = null;
- const clients: ClientType[] = [];
+ let currentClient: SSHClientType | null = null;
+ const clients: SSHClientType[] = [];
try {
const jumpHostConfigs = await Promise.all(
@@ -560,9 +559,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
});
let currentSessionId: string | null = null;
- let sshConn: ClientType | null = null;
+ let sshConn: SSHClientType | null = null;
let sshStream: ClientChannel | null = null;
- let lastJumpClient: ClientType | null = null;
+ let lastJumpClient: SSHClientType | null = null;
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
let totpPromptSent = false;
let totpTimeout: NodeJS.Timeout | null = null;
diff --git a/src/backend/ssh/tmux-helper.ts b/src/backend/ssh/tmux-helper.ts
index 424022c7..31677ff6 100644
--- a/src/backend/ssh/tmux-helper.ts
+++ b/src/backend/ssh/tmux-helper.ts
@@ -124,7 +124,10 @@ export async function waitForTmuxSession(
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
- await execCommand(conn, `tmux has-session -t ${shellEscape(sessionName)} 2>/dev/null`);
+ await execCommand(
+ conn,
+ `tmux has-session -t ${shellEscape(sessionName)} 2>/dev/null`,
+ );
return sessionName;
} catch {
// session not ready yet
diff --git a/src/backend/starter.ts b/src/backend/starter.ts
index 4569534f..7685a7dc 100644
--- a/src/backend/starter.ts
+++ b/src/backend/starter.ts
@@ -199,9 +199,8 @@ import {
operation: "shutdown",
});
try {
- const { saveMemoryDatabaseToFile } = await import(
- "./database/db/index.js"
- );
+ const { saveMemoryDatabaseToFile } =
+ await import("./database/db/index.js");
await saveMemoryDatabaseToFile();
systemLogger.info("Database saved to disk before exit", {
operation: "shutdown_db_saved",
diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx
index 7bbb7fa5..f5486f5b 100644
--- a/src/ui/auth/Auth.tsx
+++ b/src/ui/auth/Auth.tsx
@@ -654,13 +654,32 @@ export function Auth({ onLogin }: AuthProps) {
setOidcLoading(true);
try {
if (isElectron()) {
- const electronAPI = (window as unknown as { electronAPI?: { oidcSystemBrowserAuth?: (authUrl: string, port: number) => Promise<{ success: boolean; token?: string; error?: string }> } }).electronAPI;
+ const electronAPI = (
+ window as unknown as {
+ electronAPI?: {
+ oidcSystemBrowserAuth?: (
+ authUrl: string,
+ port: number,
+ ) => Promise<{
+ success: boolean;
+ token?: string;
+ error?: string;
+ }>;
+ };
+ }
+ ).electronAPI;
if (electronAPI?.oidcSystemBrowserAuth) {
const callbackPort = 17832 + Math.floor(Math.random() * 100);
- const authResponse = await getOIDCAuthorizeUrl(rememberMe, callbackPort);
+ const authResponse = await getOIDCAuthorizeUrl(
+ rememberMe,
+ callbackPort,
+ );
const { auth_url: authUrl } = authResponse;
if (!authUrl) throw new Error(t("errors.invalidAuthUrl"));
- const result = await electronAPI.oidcSystemBrowserAuth(authUrl, callbackPort);
+ const result = await electronAPI.oidcSystemBrowserAuth(
+ authUrl,
+ callbackPort,
+ );
if (result.success && result.token) {
localStorage.setItem("jwt_token", result.token);
window.location.reload();
diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx
index cadd4c56..abc5ab1d 100644
--- a/src/ui/dashboard/DashboardTab.tsx
+++ b/src/ui/dashboard/DashboardTab.tsx
@@ -737,7 +737,11 @@ function CardItem({
/>
)}
{slot.id === "quick_actions" && (
-
+
)}
{slot.id === "host_status" && (
)}
{slot.id === "quick_actions" && (
-
+
)}
{slot.id === "host_status" && (
{
+ client.onfile = (
+ stream: Guacamole.InputStream,
+ mimetype: string,
+ filename: string,
+ ) => {
const reader = new Guacamole.BlobReader(stream, mimetype);
reader.onend = () => {
const blob = reader.getBlob();
diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx
index 9768721c..46d5d736 100644
--- a/src/ui/features/terminal/Terminal.tsx
+++ b/src/ui/features/terminal/Terminal.tsx
@@ -1981,9 +1981,10 @@ const TerminalInner = forwardRef(
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
const unicode11Addon = new Unicode11Addon();
const webLinksAddon = new WebLinksAddon((_event, uri) => {
- const url = uri.startsWith("http://") || uri.startsWith("https://")
- ? uri
- : `https://${uri}`;
+ const url =
+ uri.startsWith("http://") || uri.startsWith("https://")
+ ? uri
+ : `https://${uri}`;
window.open(url, "_blank");
});
@@ -2148,7 +2149,8 @@ const TerminalInner = forwardRef(
}
const persistenceEnabled =
- localStorage.getItem("enableTerminalSessionPersistence") !== "false";
+ localStorage.getItem("enableTerminalSessionPersistence") !==
+ "false";
if (
!persistenceEnabled &&
sessionIdRef.current &&