mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-09-13 10:49:03 +00:00
* Guacd, Docker-Compose, RDP (#475) * fix select edit host but not update view (#438) * fix: Checksum issue with chocolatey * fix: Remove homebrew old stuff * Add Korean translation (#439) Co-authored-by: 송준우 <2484@coreit.co.kr> * feat: Automate flatpak * fix: Add imagemagik to electron builder to resolve build error * fix: Build error with runtime repo flag * fix: Flatpak runtime error and install freedesktop ver warning * fix: Flatpak runtime error and install freedesktop ver warning * feat: Re-add homebrew cask and move scripts to backend * fix: No sandbox flag issue * fix: Change name for electron macos cask output * fix: Sandbox error with Linux * fix: Remove comming soon for app stores in readme * Adding Comment at the end of the public_key on the host on deploy (#440) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * -Add New Interface for Credential DB -Add Credential Name as a comment into the server authorized_key file --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Sudo auto fill password (#441) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Feature Sudo password auto-fill; * Fix locale json shema; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Added Italian Language; (#445) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Auto collapse snippet folders (#448) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * feat: Add collapsable snippets (customizable in user profile) * Translations (#447) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; * Fix translations; Removed duplicate keys, synchronised other languages using English as the source, translated added keys, fixed inaccurate translations. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Remove PTY-level keepalive (#449) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Remove PTY-level keepalive to prevent unwanted terminal output; use SSH-level keepalive instead --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Guacamole support for RDP, VNC, and Telnet connections - Implemented WebSocket support for Guacamole in Nginx configuration. - Added REST API endpoints for generating connection tokens and checking guacd status. - Created Guacamole server using guacamole-lite for handling connections. - Developed frontend components for testing RDP/VNC connections and displaying the remote session. - Updated package dependencies to include guacamole-common-js and guacamole-lite. - Enhanced logging for Guacamole operations. * feat: enhance Guacamole support with RDP and VNC connection settings and UI updates * feat: Seperate server stats and tunnel management (improved both UI's) then started initial docker implementation * fix: finalize adding docker to db * fix: merge syntax errors * feat: implement mouse coordinate adjustment based on scale factor in GuacamoleDisplay * feat: add TypeScript definitions for guacamole-common-js module * feat: enhance Mouse.State constructor to accept optional parameters and object destructuring * feat: Add support for RDP and VNC connections in SSH host management - Introduced connectionType field to differentiate between SSH, RDP, VNC, and Telnet in host data structures. - Updated backend routes to handle RDP/VNC specific fields: domain, security, and ignoreCert. - Enhanced the HostManagerEditor to include RDP/VNC specific settings and authentication options. - Implemented token retrieval for RDP/VNC connections using Guacamole API. - Updated UI components to reflect connection type changes and provide appropriate connection buttons. - Removed the GuacamoleTestDialog component as its functionality is integrated into the HostManagerEditor. - Adjusted the TopNavbar and Host components to accommodate new connection types and their respective actions. * feat: Enhance Guacamole integration with extended configuration options - Added detailed Guacamole configuration interface for RDP/VNC/Telnet connections, including display, audio, performance, and session settings. - Implemented logging for token requests and received options for better debugging. - Updated HostManagerEditor to support new Guacamole configuration fields with validation and default values. - Integrated Guacamole configuration parsing in HostManagerViewer and Host components. - Enhanced API requests to include extended Guacamole configuration parameters in the token request. - Refactored code to convert camelCase configuration keys to kebab-case for compatibility with Guacamole API. * feat: merge guacd into 2.0.0 and improve UI for host manager and made general bug fixes --------- Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: rename api routes and files * feat: improve guacd ui/backend * feat: improve guacd ui/backend * fix: state persistance issues causing refresh * feat: improge guacd connections, fixed telnet not opening, and improved general guacd integration * feat: continue improving integration also with bug fixes * Merge 2.0.0 with 2.0.0 that includes bug fixes (#620) * Guacd, Docker-Compose, RDP (#475) * fix select edit host but not update view (#438) * fix: Checksum issue with chocolatey * fix: Remove homebrew old stuff * Add Korean translation (#439) Co-authored-by: 송준우 <2484@coreit.co.kr> * feat: Automate flatpak * fix: Add imagemagik to electron builder to resolve build error * fix: Build error with runtime repo flag * fix: Flatpak runtime error and install freedesktop ver warning * fix: Flatpak runtime error and install freedesktop ver warning * feat: Re-add homebrew cask and move scripts to backend * fix: No sandbox flag issue * fix: Change name for electron macos cask output * fix: Sandbox error with Linux * fix: Remove comming soon for app stores in readme * Adding Comment at the end of the public_key on the host on deploy (#440) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * -Add New Interface for Credential DB -Add Credential Name as a comment into the server authorized_key file --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Sudo auto fill password (#441) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Feature Sudo password auto-fill; * Fix locale json shema; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Added Italian Language; (#445) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Auto collapse snippet folders (#448) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * feat: Add collapsable snippets (customizable in user profile) * Translations (#447) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; * Fix translations; Removed duplicate keys, synchronised other languages using English as the source, translated added keys, fixed inaccurate translations. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Remove PTY-level keepalive (#449) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Remove PTY-level keepalive to prevent unwanted terminal output; use SSH-level keepalive instead --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Guacamole support for RDP, VNC, and Telnet connections - Implemented WebSocket support for Guacamole in Nginx configuration. - Added REST API endpoints for generating connection tokens and checking guacd status. - Created Guacamole server using guacamole-lite for handling connections. - Developed frontend components for testing RDP/VNC connections and displaying the remote session. - Updated package dependencies to include guacamole-common-js and guacamole-lite. - Enhanced logging for Guacamole operations. * feat: enhance Guacamole support with RDP and VNC connection settings and UI updates * feat: Seperate server stats and tunnel management (improved both UI's) then started initial docker implementation * fix: finalize adding docker to db * fix: merge syntax errors * feat: implement mouse coordinate adjustment based on scale factor in GuacamoleDisplay * feat: add TypeScript definitions for guacamole-common-js module * feat: enhance Mouse.State constructor to accept optional parameters and object destructuring * feat: Add support for RDP and VNC connections in SSH host management - Introduced connectionType field to differentiate between SSH, RDP, VNC, and Telnet in host data structures. - Updated backend routes to handle RDP/VNC specific fields: domain, security, and ignoreCert. - Enhanced the HostManagerEditor to include RDP/VNC specific settings and authentication options. - Implemented token retrieval for RDP/VNC connections using Guacamole API. - Updated UI components to reflect connection type changes and provide appropriate connection buttons. - Removed the GuacamoleTestDialog component as its functionality is integrated into the HostManagerEditor. - Adjusted the TopNavbar and Host components to accommodate new connection types and their respective actions. * feat: Enhance Guacamole integration with extended configuration options - Added detailed Guacamole configuration interface for RDP/VNC/Telnet connections, including display, audio, performance, and session settings. - Implemented logging for token requests and received options for better debugging. - Updated HostManagerEditor to support new Guacamole configuration fields with validation and default values. - Integrated Guacamole configuration parsing in HostManagerViewer and Host components. - Enhanced API requests to include extended Guacamole configuration parameters in the token request. - Refactored code to convert camelCase configuration keys to kebab-case for compatibility with Guacamole API. * feat: merge guacd into 2.0.0 and improve UI for host manager and made general bug fixes --------- Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: rename api routes and files * feat: improve guacd ui/backend * feat: improve guacd ui/backend * fix: state persistance issues causing refresh * feat: improge guacd connections, fixed telnet not opening, and improved general guacd integration * feat: continue improving integration also with bug fixes --------- Co-authored-by: Wesley Reid <starhound@lostsouls.org> Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: allow customizing guacd backened url * fix: ssh route mistmatching and guacamole url not changing * chore: increment ver * feat: change default to work with default compose, added splits creen support, updated readmes * fix: linux app not starting due to better sqlite isuses, improved copy/paste system so no context menu, added oidc remember me toggle, improved OS detection for sessions, flatpak invalid key, and sharing hosts with other users errors * fix: global settings not setting * chore: update compose * feat: improve the global status input * chore: cleanup files * chore: update export/improt with new host fields * fix: file manager and docker not loading properly --------- Co-authored-by: Wesley Reid <starhound@lostsouls.org> Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
673 lines
19 KiB
TypeScript
673 lines
19 KiB
TypeScript
import { Client as SSHClient } from "ssh2";
|
|
import { WebSocketServer, WebSocket } from "ws";
|
|
import { parse as parseUrl } from "url";
|
|
import { AuthManager } from "../utils/auth-manager.js";
|
|
import { hosts, sshCredentials } from "../database/db/schema.js";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { getDb } from "../database/db/index.js";
|
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
|
import { systemLogger } from "../utils/logger.js";
|
|
import type { SSHHost } from "../../types/index.js";
|
|
|
|
const sshLogger = systemLogger;
|
|
|
|
interface SSHSession {
|
|
client: SSHClient;
|
|
stream: import("ssh2").ClientChannel | null;
|
|
isConnected: boolean;
|
|
containerId?: string;
|
|
shell?: string;
|
|
hostId?: number;
|
|
}
|
|
|
|
const activeSessions = new Map<string, SSHSession>();
|
|
|
|
const wss = new WebSocketServer({
|
|
host: "0.0.0.0",
|
|
port: 30009,
|
|
verifyClient: async (info) => {
|
|
try {
|
|
const url = parseUrl(info.req.url || "", true);
|
|
const token = url.query.token as string;
|
|
|
|
if (!token) {
|
|
return false;
|
|
}
|
|
|
|
const authManager = AuthManager.getInstance();
|
|
const decoded = await authManager.verifyJWTToken(token);
|
|
|
|
if (!decoded || !decoded.userId) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
});
|
|
|
|
async function detectShell(
|
|
session: SSHSession,
|
|
containerId: string,
|
|
): Promise<string> {
|
|
const shells = ["bash", "sh", "ash"];
|
|
|
|
for (const shell of shells) {
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
session.client.exec(
|
|
`docker exec ${containerId} which ${shell}`,
|
|
(err, stream) => {
|
|
if (err) return reject(err);
|
|
|
|
let output = "";
|
|
stream.on("data", (data: Buffer) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
stream.on("close", (code: number) => {
|
|
if (code === 0 && output.trim()) {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`Shell ${shell} not found`));
|
|
}
|
|
});
|
|
|
|
stream.stderr.on("data", () => {
|
|
// Ignore stderr
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
return shell;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return "sh";
|
|
}
|
|
|
|
async function createJumpHostChain(
|
|
jumpHosts: Array<{ hostId: number }>,
|
|
userId: string,
|
|
): Promise<SSHClient | null> {
|
|
if (!jumpHosts || jumpHosts.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
let currentClient: SSHClient | null = null;
|
|
|
|
for (let i = 0; i < jumpHosts.length; i++) {
|
|
const jumpHostId = jumpHosts[i].hostId;
|
|
|
|
const jumpHostData = await SimpleDBOps.select(
|
|
getDb()
|
|
.select()
|
|
.from(hosts)
|
|
.where(and(eq(hosts.id, jumpHostId), eq(hosts.userId, userId))),
|
|
"ssh_data",
|
|
userId,
|
|
);
|
|
|
|
if (jumpHostData.length === 0) {
|
|
throw new Error(`Jump host ${jumpHostId} not found`);
|
|
}
|
|
|
|
const jumpHost = jumpHostData[0] as unknown as SSHHost;
|
|
if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) {
|
|
try {
|
|
jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts);
|
|
} catch (e) {
|
|
sshLogger.error("Failed to parse jump hosts", e, {
|
|
hostId: jumpHost.id,
|
|
});
|
|
jumpHost.jumpHosts = [];
|
|
}
|
|
}
|
|
|
|
let resolvedCredentials: {
|
|
password?: string;
|
|
sshKey?: string;
|
|
keyPassword?: string;
|
|
authType?: string;
|
|
} = {
|
|
password: jumpHost.password,
|
|
sshKey: jumpHost.key,
|
|
keyPassword: jumpHost.keyPassword,
|
|
authType: jumpHost.authType,
|
|
};
|
|
|
|
if (jumpHost.credentialId) {
|
|
const credentials = await SimpleDBOps.select(
|
|
getDb()
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.id, jumpHost.credentialId as number),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
),
|
|
"ssh_credentials",
|
|
userId,
|
|
);
|
|
|
|
if (credentials.length > 0) {
|
|
const credential = credentials[0];
|
|
resolvedCredentials = {
|
|
password: credential.password as string | undefined,
|
|
sshKey: credential.privateKey as string | undefined,
|
|
keyPassword: credential.keyPassword as string | undefined,
|
|
authType: credential.authType as string | undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
const client = new SSHClient();
|
|
|
|
const config: Record<string, unknown> = {
|
|
host: jumpHost.ip?.replace(/^\[|\]$/g, "") || jumpHost.ip,
|
|
port: jumpHost.port || 22,
|
|
username: jumpHost.username,
|
|
tryKeyboard: true,
|
|
readyTimeout: 60000,
|
|
keepaliveInterval: 30000,
|
|
keepaliveCountMax: 120,
|
|
tcpKeepAlive: true,
|
|
tcpKeepAliveInitialDelay: 30000,
|
|
};
|
|
|
|
if (
|
|
resolvedCredentials.authType === "password" &&
|
|
resolvedCredentials.password
|
|
) {
|
|
config.password = resolvedCredentials.password;
|
|
} else if (
|
|
resolvedCredentials.authType === "key" &&
|
|
resolvedCredentials.sshKey
|
|
) {
|
|
const cleanKey = resolvedCredentials.sshKey
|
|
.trim()
|
|
.replace(/\r\n/g, "\n")
|
|
.replace(/\r/g, "\n");
|
|
config.privateKey = Buffer.from(cleanKey, "utf8");
|
|
if (resolvedCredentials.keyPassword) {
|
|
config.passphrase = resolvedCredentials.keyPassword;
|
|
}
|
|
}
|
|
|
|
if (currentClient) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
currentClient!.forwardOut(
|
|
"127.0.0.1",
|
|
0,
|
|
jumpHost.ip,
|
|
jumpHost.port || 22,
|
|
(err, stream) => {
|
|
if (err) return reject(err);
|
|
config.sock = stream;
|
|
resolve();
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
client.on("ready", () => resolve());
|
|
client.on("error", reject);
|
|
client.connect(config);
|
|
});
|
|
|
|
currentClient = client;
|
|
}
|
|
|
|
return currentClient;
|
|
}
|
|
|
|
wss.on("connection", async (ws: WebSocket, req) => {
|
|
const userId = (req as unknown as { userId: string }).userId;
|
|
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
|
|
sshLogger.info("Docker console WebSocket connected", {
|
|
operation: "docker_console_connect",
|
|
sessionId,
|
|
userId,
|
|
});
|
|
|
|
let sshSession: SSHSession | null = null;
|
|
|
|
const wsPingInterval = setInterval(() => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.ping();
|
|
}
|
|
}, 30000);
|
|
|
|
ws.on("message", async (data) => {
|
|
try {
|
|
const message = JSON.parse(data.toString());
|
|
|
|
switch (message.type) {
|
|
case "connect": {
|
|
const { hostConfig, containerId, shell, cols, rows } =
|
|
message.data as {
|
|
hostConfig: SSHHost;
|
|
containerId: string;
|
|
shell?: string;
|
|
cols?: number;
|
|
rows?: number;
|
|
};
|
|
|
|
if (
|
|
typeof hostConfig.jumpHosts === "string" &&
|
|
hostConfig.jumpHosts
|
|
) {
|
|
try {
|
|
hostConfig.jumpHosts = JSON.parse(hostConfig.jumpHosts);
|
|
} catch (e) {
|
|
sshLogger.error("Failed to parse jump hosts", e, {
|
|
hostId: hostConfig.id,
|
|
});
|
|
hostConfig.jumpHosts = [];
|
|
}
|
|
}
|
|
|
|
if (!hostConfig || !containerId) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "error",
|
|
message: "Host configuration and container ID are required",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!hostConfig.enableDocker) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "error",
|
|
message:
|
|
"Docker is not enabled for this host. Enable it in Host Settings.",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let resolvedCredentials: {
|
|
password?: string;
|
|
sshKey?: string;
|
|
keyPassword?: string;
|
|
authType?: string;
|
|
} = {
|
|
password: hostConfig.password,
|
|
sshKey: hostConfig.key,
|
|
keyPassword: hostConfig.keyPassword,
|
|
authType: hostConfig.authType,
|
|
};
|
|
|
|
if (hostConfig.credentialId) {
|
|
const credentials = await SimpleDBOps.select(
|
|
getDb()
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.id, hostConfig.credentialId as number),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
),
|
|
"ssh_credentials",
|
|
userId,
|
|
);
|
|
|
|
if (credentials.length > 0) {
|
|
const credential = credentials[0];
|
|
resolvedCredentials = {
|
|
password: credential.password as string | undefined,
|
|
sshKey: credential.privateKey as string | undefined,
|
|
keyPassword: credential.keyPassword as string | undefined,
|
|
authType: credential.authType as string | undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
const client = new SSHClient();
|
|
|
|
const config: Record<string, unknown> = {
|
|
host: hostConfig.ip?.replace(/^\[|\]$/g, "") || hostConfig.ip,
|
|
port: hostConfig.port || 22,
|
|
username: hostConfig.username,
|
|
tryKeyboard: true,
|
|
readyTimeout: 60000,
|
|
keepaliveInterval: 30000,
|
|
keepaliveCountMax: 120,
|
|
tcpKeepAlive: true,
|
|
tcpKeepAliveInitialDelay: 30000,
|
|
};
|
|
|
|
if (
|
|
resolvedCredentials.authType === "password" &&
|
|
resolvedCredentials.password
|
|
) {
|
|
config.password = resolvedCredentials.password;
|
|
} else if (
|
|
resolvedCredentials.authType === "key" &&
|
|
resolvedCredentials.sshKey
|
|
) {
|
|
const cleanKey = resolvedCredentials.sshKey
|
|
.trim()
|
|
.replace(/\r\n/g, "\n")
|
|
.replace(/\r/g, "\n");
|
|
config.privateKey = Buffer.from(cleanKey, "utf8");
|
|
if (resolvedCredentials.keyPassword) {
|
|
config.passphrase = resolvedCredentials.keyPassword;
|
|
}
|
|
}
|
|
|
|
if (hostConfig.jumpHosts && hostConfig.jumpHosts.length > 0) {
|
|
const jumpClient = await createJumpHostChain(
|
|
hostConfig.jumpHosts,
|
|
userId,
|
|
);
|
|
if (jumpClient) {
|
|
const stream = await new Promise<import("ssh2").ClientChannel>(
|
|
(resolve, reject) => {
|
|
jumpClient.forwardOut(
|
|
"127.0.0.1",
|
|
0,
|
|
hostConfig.ip,
|
|
hostConfig.port || 22,
|
|
(err, stream) => {
|
|
if (err) return reject(err);
|
|
resolve(stream);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
config.sock = stream;
|
|
}
|
|
}
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
client.on("ready", () => resolve());
|
|
client.on("error", reject);
|
|
client.connect(config);
|
|
});
|
|
|
|
sshSession = {
|
|
client,
|
|
stream: null,
|
|
isConnected: true,
|
|
containerId,
|
|
hostId: hostConfig.id,
|
|
};
|
|
|
|
activeSessions.set(sessionId, sshSession);
|
|
|
|
let shellToUse = shell || "bash";
|
|
|
|
if (shell) {
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
client.exec(
|
|
`docker exec ${containerId} which ${shell}`,
|
|
(err, stream) => {
|
|
if (err) return reject(err);
|
|
|
|
let output = "";
|
|
stream.on("data", (data: Buffer) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
stream.on("close", (code: number) => {
|
|
if (code === 0 && output.trim()) {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`Shell ${shell} not available`));
|
|
}
|
|
});
|
|
|
|
stream.stderr.on("data", () => {
|
|
// Ignore stderr
|
|
});
|
|
},
|
|
);
|
|
});
|
|
} catch {
|
|
sshLogger.warn(
|
|
`Requested shell ${shell} not found, detecting available shell`,
|
|
{
|
|
operation: "shell_validation",
|
|
sessionId,
|
|
containerId,
|
|
requestedShell: shell,
|
|
},
|
|
);
|
|
shellToUse = await detectShell(sshSession, containerId);
|
|
}
|
|
} else {
|
|
shellToUse = await detectShell(sshSession, containerId);
|
|
}
|
|
|
|
sshSession.shell = shellToUse;
|
|
|
|
const execCommand = `docker exec -it ${containerId} /bin/${shellToUse}`;
|
|
sshLogger.info("Attaching to Docker container", {
|
|
operation: "docker_attach",
|
|
sessionId,
|
|
userId,
|
|
hostId: hostConfig.id,
|
|
containerId,
|
|
});
|
|
|
|
client.exec(
|
|
execCommand,
|
|
{
|
|
pty: {
|
|
term: "xterm-256color",
|
|
cols: cols || 80,
|
|
rows: rows || 24,
|
|
},
|
|
},
|
|
(err, stream) => {
|
|
if (err) {
|
|
sshLogger.error("Failed to create docker exec", err, {
|
|
operation: "docker_exec",
|
|
sessionId,
|
|
containerId,
|
|
});
|
|
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "error",
|
|
message: `Failed to start console: ${err.message}`,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
sshSession!.stream = stream;
|
|
sshLogger.success("Docker container attached", {
|
|
operation: "docker_attach_success",
|
|
sessionId,
|
|
userId,
|
|
hostId: hostConfig.id,
|
|
containerId,
|
|
});
|
|
|
|
stream.on("data", (data: Buffer) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "output",
|
|
data: data.toString("utf8"),
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
stream.stderr.on("data", () => {
|
|
// stderr output ignored
|
|
});
|
|
|
|
stream.on("close", () => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "disconnected",
|
|
message: "Console session ended",
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (sshSession) {
|
|
sshSession.client.end();
|
|
activeSessions.delete(sessionId);
|
|
}
|
|
});
|
|
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "connected",
|
|
data: {
|
|
shell: shellToUse,
|
|
requestedShell: shell,
|
|
shellChanged: shell && shell !== shellToUse,
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
} catch (error) {
|
|
sshLogger.error("Failed to connect to container", error, {
|
|
operation: "console_connect",
|
|
sessionId,
|
|
containerId: message.data.containerId,
|
|
});
|
|
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "error",
|
|
message:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to connect to container",
|
|
}),
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "input": {
|
|
if (sshSession && sshSession.stream) {
|
|
sshSession.stream.write(message.data);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "resize": {
|
|
if (sshSession && sshSession.stream) {
|
|
const { cols, rows } = message.data;
|
|
sshSession.stream.setWindow(rows, cols, rows, cols);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "disconnect": {
|
|
if (sshSession) {
|
|
if (sshSession.stream) {
|
|
sshSession.stream.end();
|
|
}
|
|
sshSession.client.end();
|
|
activeSessions.delete(sessionId);
|
|
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "disconnected",
|
|
message: "Disconnected from container",
|
|
}),
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "ping": {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: "pong" }));
|
|
}
|
|
break;
|
|
}
|
|
|
|
default:
|
|
sshLogger.warn("Unknown message type", {
|
|
operation: "ws_message",
|
|
type: message.type,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
sshLogger.error("WebSocket message error", error, {
|
|
operation: "ws_message",
|
|
sessionId,
|
|
});
|
|
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "error",
|
|
message: error instanceof Error ? error.message : "An error occurred",
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
ws.on("close", () => {
|
|
clearInterval(wsPingInterval);
|
|
sshLogger.info("Docker console disconnected", {
|
|
operation: "docker_console_disconnect",
|
|
sessionId,
|
|
userId,
|
|
hostId: sshSession?.hostId,
|
|
containerId: sshSession?.containerId,
|
|
});
|
|
if (sshSession) {
|
|
if (sshSession.stream) {
|
|
sshSession.stream.end();
|
|
}
|
|
sshSession.client.end();
|
|
activeSessions.delete(sessionId);
|
|
}
|
|
});
|
|
|
|
ws.on("error", (error) => {
|
|
sshLogger.error("WebSocket error", error, {
|
|
operation: "ws_error",
|
|
sessionId,
|
|
});
|
|
|
|
if (sshSession) {
|
|
if (sshSession.stream) {
|
|
sshSession.stream.end();
|
|
}
|
|
sshSession.client.end();
|
|
activeSessions.delete(sessionId);
|
|
}
|
|
});
|
|
});
|
|
|
|
process.on("SIGTERM", () => {
|
|
activeSessions.forEach((session) => {
|
|
if (session.stream) {
|
|
session.stream.end();
|
|
}
|
|
session.client.end();
|
|
});
|
|
|
|
activeSessions.clear();
|
|
|
|
wss.close(() => {
|
|
process.exit(0);
|
|
});
|
|
});
|