mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
fix: resolve backend build errors, preserve sudo file read buffers, and format code (#798)
* fix: resolve backend TypeScript build errors and preserve sudo file read buffers * style: format code * fix: ssh2 ESM import failure --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
services:
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
|
||||
+31
-10
@@ -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,7 +615,8 @@ function startBackendServer() {
|
||||
logToFile(" backendCwd exists:", fs.existsSync(backendCwd));
|
||||
|
||||
backendProcess = fork(entryPath, [], {
|
||||
cwd: fs.existsSync(backendCwd) && fs.statSync(backendCwd).isDirectory()
|
||||
cwd:
|
||||
fs.existsSync(backendCwd) && fs.statSync(backendCwd).isDirectory()
|
||||
? backendCwd
|
||||
: dataDir,
|
||||
env: {
|
||||
@@ -1089,7 +1099,9 @@ ipcMain.handle("get-embedded-server-status", () => {
|
||||
});
|
||||
|
||||
// OIDC System Browser Authentication (RFC 8252)
|
||||
ipcMain.handle("oidc-system-browser-auth", async (_event, authUrl, callbackPort) => {
|
||||
ipcMain.handle(
|
||||
"oidc-system-browser-auth",
|
||||
async (_event, authUrl, callbackPort) => {
|
||||
const http = require("http");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -1101,13 +1113,18 @@ ipcMain.handle("oidc-system-browser-auth", async (_event, authUrl, callbackPort)
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(`<html><body><h2>${success === "true" ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`);
|
||||
res.end(
|
||||
`<html><body><h2>${success === "true" ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`,
|
||||
);
|
||||
|
||||
server.close();
|
||||
if (success === "true") {
|
||||
resolve({ success: true, token });
|
||||
} else {
|
||||
resolve({ success: false, error: error || "Authentication failed" });
|
||||
resolve({
|
||||
success: false,
|
||||
error: error || "Authentication failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1117,12 +1134,16 @@ ipcMain.handle("oidc-system-browser-auth", async (_event, authUrl, callbackPort)
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(() => {
|
||||
setTimeout(
|
||||
() => {
|
||||
server.close();
|
||||
reject(new Error("OIDC authentication timed out"));
|
||||
}, 5 * 60 * 1000);
|
||||
});
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("get-server-config", () => {
|
||||
try {
|
||||
|
||||
+3
-1
@@ -47,7 +47,9 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>window.__TERMIX_BASE_PATH__ = "";</script>
|
||||
<script>
|
||||
window.__TERMIX_BASE_PATH__ = "";
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -139,10 +139,7 @@ async function resolveJumpHost(
|
||||
): Promise<JumpHostConfig | null> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,10 +75,7 @@ async function resolveJumpHost(
|
||||
): Promise<JumpHostConfig | null> {
|
||||
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,
|
||||
|
||||
+14
-15
@@ -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<ClientType | null> {
|
||||
): Promise<SSHClientType | null> {
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+22
-3
@@ -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();
|
||||
|
||||
@@ -737,7 +737,11 @@ function CardItem({
|
||||
/>
|
||||
)}
|
||||
{slot.id === "quick_actions" && (
|
||||
<QuickActionsCard onOpenSingletonTab={onOpenSingletonTab} hosts={hosts} onOpenTab={onOpenTab} />
|
||||
<QuickActionsCard
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
hosts={hosts}
|
||||
onOpenTab={onOpenTab}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "host_status" && (
|
||||
<HostStatusCard
|
||||
@@ -1395,7 +1399,11 @@ export function DashboardTab({
|
||||
/>
|
||||
)}
|
||||
{slot.id === "quick_actions" && (
|
||||
<QuickActionsCard onOpenSingletonTab={onOpenSingletonTab} hosts={hosts} onOpenTab={onOpenTab} />
|
||||
<QuickActionsCard
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
hosts={hosts}
|
||||
onOpenTab={onOpenTab}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "host_status" && (
|
||||
<HostStatusCard
|
||||
|
||||
@@ -395,7 +395,11 @@ export const GuacamoleDisplay = forwardRef<
|
||||
Guacamole.AudioPlayer.getInstance(stream, mimetype);
|
||||
};
|
||||
|
||||
client.onfile = (stream: Guacamole.InputStream, mimetype: string, filename: string) => {
|
||||
client.onfile = (
|
||||
stream: Guacamole.InputStream,
|
||||
mimetype: string,
|
||||
filename: string,
|
||||
) => {
|
||||
const reader = new Guacamole.BlobReader(stream, mimetype);
|
||||
reader.onend = () => {
|
||||
const blob = reader.getBlob();
|
||||
|
||||
@@ -1981,7 +1981,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
|
||||
const unicode11Addon = new Unicode11Addon();
|
||||
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
||||
const url = uri.startsWith("http://") || uri.startsWith("https://")
|
||||
const url =
|
||||
uri.startsWith("http://") || uri.startsWith("https://")
|
||||
? uri
|
||||
: `https://${uri}`;
|
||||
window.open(url, "_blank");
|
||||
@@ -2148,7 +2149,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") !== "false";
|
||||
localStorage.getItem("enableTerminalSessionPersistence") !==
|
||||
"false";
|
||||
if (
|
||||
!persistenceEnabled &&
|
||||
sessionIdRef.current &&
|
||||
|
||||
Reference in New Issue
Block a user