* 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>
This commit is contained in:
Luke Gustafson
2026-03-14 20:05:05 -05:00
committed by GitHub
parent a255a08903
commit e9e30cd318
85 changed files with 7053 additions and 2286 deletions
+4 -4
View File
@@ -2,7 +2,7 @@ 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 { sshData, sshCredentials } from "../database/db/schema.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";
@@ -24,7 +24,7 @@ const activeSessions = new Map<string, SSHSession>();
const wss = new WebSocketServer({
host: "0.0.0.0",
port: 30008,
port: 30009,
verifyClient: async (info) => {
try {
const url = parseUrl(info.req.url || "", true);
@@ -107,8 +107,8 @@ async function createJumpHostChain(
const jumpHostData = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(and(eq(sshData.id, jumpHostId), eq(sshData.userId, userId))),
.from(hosts)
.where(and(eq(hosts.id, jumpHostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
+26 -28
View File
@@ -4,7 +4,7 @@ import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import { getDb } from "../database/db/index.js";
import { sshData, sshCredentials } from "../database/db/schema.js";
import { hosts, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { logger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
@@ -136,20 +136,20 @@ async function resolveJumpHost(
userId: string,
): Promise<JumpHostConfig | null> {
try {
const hosts = await SimpleDBOps.select(
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
.from(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
if (hosts.length === 0) {
if (hostResults.length === 0) {
return null;
}
const host = hosts[0];
const host = hostResults[0];
if (host.credentialId) {
const credentials = await SimpleDBOps.select(
@@ -570,25 +570,24 @@ app.post("/docker/ssh/connect", async (req, res) => {
);
try {
const hosts = await SimpleDBOps.select(
getDb().select().from(sshData).where(eq(sshData.id, hostId)),
const hostResults = await SimpleDBOps.select(
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
"ssh_data",
userId,
);
if (hosts.length === 0) {
if (hostResults.length === 0) {
connectionLogs.push(
createConnectionLog("error", "docker_connecting", "Host not found"),
);
return res.status(404).json({ error: "Host not found", connectionLogs });
}
const host = hosts[0] as unknown as SSHHost;
const host = hostResults[0] as unknown as SSHHost;
if (host.userId !== userId) {
const { PermissionManager } = await import(
"../utils/permission-manager.js"
);
const { PermissionManager } =
await import("../utils/permission-manager.js");
const permissionManager = PermissionManager.getInstance();
const accessInfo = await permissionManager.canAccessHost(
userId,
@@ -693,9 +692,8 @@ app.post("/docker/ssh/connect", async (req, res) => {
if (userId !== ownerId) {
try {
const { SharedCredentialManager } = await import(
"../utils/shared-credential-manager.js"
);
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
host.id,
@@ -1678,14 +1676,14 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
if (session.hostId && session.userId) {
(async () => {
try {
const hosts = await SimpleDBOps.select(
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.from(hosts)
.where(
and(
eq(sshData.id, session.hostId!),
eq(sshData.userId, session.userId!),
eq(hosts.id, session.hostId!),
eq(hosts.userId, session.userId!),
),
),
"ssh_data",
@@ -1693,8 +1691,8 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
);
const hostName =
hosts.length > 0 && hosts[0].name
? hosts[0].name
hostResults.length > 0 && hostResults[0].name
? hostResults[0].name
: `${session.username}@${session.ip}:${session.port}`;
await axios.post(
@@ -1863,14 +1861,14 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
if (session.hostId && session.userId) {
(async () => {
try {
const hosts = await SimpleDBOps.select(
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.from(hosts)
.where(
and(
eq(sshData.id, session.hostId!),
eq(sshData.userId, session.userId!),
eq(hosts.id, session.hostId!),
eq(hosts.userId, session.userId!),
),
),
"ssh_data",
@@ -1878,8 +1876,8 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
);
const hostName =
hosts.length > 0 && hosts[0].name
? hosts[0].name
hostResults.length > 0 && hostResults[0].name
? hostResults[0].name
: `${session.username}@${session.ip}:${session.port}`;
await axios.post(
+137 -64
View File
@@ -4,7 +4,7 @@ import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import { getDb } from "../database/db/index.js";
import { sshCredentials, sshData } from "../database/db/schema.js";
import { sshCredentials, hosts } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { fileLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
@@ -179,20 +179,20 @@ async function resolveJumpHost(
userId: string,
): Promise<JumpHostConfig | null> {
try {
const hosts = await SimpleDBOps.select(
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
.from(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
if (hosts.length === 0) {
if (hostResults.length === 0) {
return null;
}
const host = hosts[0];
const host = hostResults[0];
if (host.credentialId) {
const credentials = await SimpleDBOps.select(
@@ -803,64 +803,137 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
let resolvedCredentials = { password, sshKey, keyPassword, authType };
if (credentialId && hostId && userId) {
try {
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
const hostRow = await getDb()
.select({ userId: hosts.userId })
.from(hosts)
.where(eq(hosts.id, hostId))
.limit(1);
const ownerId = hostRow[0]?.userId ?? null;
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
sshKey: credential.privateKey,
keyPassword: credential.keyPassword,
authType: credential.authType,
};
if (ownerId && userId !== ownerId) {
try {
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
hostId,
userId,
);
if (sharedCred) {
resolvedCredentials = {
password: sharedCred.password,
sshKey: sharedCred.key,
keyPassword: sharedCred.keyPassword,
authType: sharedCred.authType,
};
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Credentials resolved from shared credential store",
),
);
} else {
fileLogger.warn(`No shared credentials found for host ${hostId}`, {
operation: "ssh_credentials",
hostId,
userId,
});
connectionLogs.push(
createConnectionLog(
"warning",
"sftp_auth",
"No shared credentials found, using provided credentials",
),
);
}
} catch (error) {
fileLogger.warn(
`Failed to resolve shared credential for host ${hostId}`,
{
operation: "ssh_credentials",
hostId,
error: error instanceof Error ? error.message : "Unknown error",
},
);
connectionLogs.push(
createConnectionLog(
"info",
"warning",
"sftp_auth",
"Credentials resolved from credential store",
`Failed to resolve shared credentials: ${error instanceof Error ? error.message : "Unknown error"}`,
),
);
} else {
fileLogger.warn(`No credentials found for host ${hostId}`, {
}
} else if (ownerId) {
try {
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, ownerId),
),
),
"ssh_credentials",
ownerId,
);
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
sshKey: credential.privateKey,
keyPassword: credential.keyPassword,
authType: credential.authType,
};
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Credentials resolved from credential store",
),
);
} else {
fileLogger.warn(`No credentials found for host ${hostId}`, {
operation: "ssh_credentials",
hostId,
credentialId,
userId: ownerId,
});
connectionLogs.push(
createConnectionLog(
"warning",
"sftp_auth",
"No stored credentials found, using provided credentials",
),
);
}
} catch (error) {
fileLogger.warn(`Failed to resolve credentials for host ${hostId}`, {
operation: "ssh_credentials",
hostId,
credentialId,
userId,
error: error instanceof Error ? error.message : "Unknown error",
});
connectionLogs.push(
createConnectionLog(
"warning",
"sftp_auth",
"No stored credentials found, using provided credentials",
`Failed to resolve credentials: ${error instanceof Error ? error.message : "Unknown error"}`,
),
);
}
} catch (error) {
fileLogger.warn(`Failed to resolve credentials for host ${hostId}`, {
operation: "ssh_credentials",
hostId,
credentialId,
error: error instanceof Error ? error.message : "Unknown error",
});
connectionLogs.push(
createConnectionLog(
"warning",
"sftp_auth",
`Failed to resolve credentials: ${error instanceof Error ? error.message : "Unknown error"}`,
),
} else {
fileLogger.warn(
"Missing userId for credential resolution in file manager",
{
operation: "ssh_credentials",
hostId,
credentialId,
},
);
}
} else if (credentialId && hostId) {
@@ -1201,18 +1274,18 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
if (hostId && userId) {
(async () => {
try {
const hosts = await SimpleDBOps.select(
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
.from(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
const hostName =
hosts.length > 0 && hosts[0].name
? hosts[0].name
hostResults.length > 0 && hostResults[0].name
? hostResults[0].name
: `${username}@${ip}:${port}`;
const authManager = AuthManager.getInstance();
@@ -1844,14 +1917,14 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
if (session.hostId && session.userId) {
(async () => {
try {
const hosts = await SimpleDBOps.select(
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.from(hosts)
.where(
and(
eq(sshData.id, session.hostId!),
eq(sshData.userId, session.userId!),
eq(hosts.id, session.hostId!),
eq(hosts.userId, session.userId!),
),
),
"ssh_data",
@@ -1859,8 +1932,8 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
);
const hostName =
hosts.length > 0 && hosts[0].name
? hosts[0].name
hostResults.length > 0 && hostResults[0].name
? hostResults[0].name
: `${session.username}@${session.ip}:${session.port}`;
const authManager = AuthManager.getInstance();
@@ -2045,14 +2118,14 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
if (session.hostId && session.userId) {
(async () => {
try {
const hosts = await SimpleDBOps.select(
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.from(hosts)
.where(
and(
eq(sshData.id, session.hostId!),
eq(sshData.userId, session.userId!),
eq(hosts.id, session.hostId!),
eq(hosts.userId, session.userId!),
),
),
"ssh_data",
@@ -2060,8 +2133,8 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
);
const hostName =
hosts.length > 0 && hosts[0].name
? hosts[0].name
hostResults.length > 0 && hostResults[0].name
? hostResults[0].name
: `${session.username}@${session.ip}:${session.port}`;
await axios.post(
+9 -20
View File
@@ -1,6 +1,6 @@
import type { WebSocket } from "ws";
import { db } from "../database/db/index.js";
import { sshData } from "../database/db/schema.js";
import { hosts } from "../database/db/schema.js";
import { eq } from "drizzle-orm";
import { sshLogger } from "../utils/logger.js";
@@ -21,17 +21,6 @@ interface VerificationResponse {
}
export class SSHHostKeyVerifier {
/**
* Creates a hostVerifier callback for ssh2 Client.connect()
*
* @param hostId - Database ID of the host (null for quick connect)
* @param ip - IP address or hostname
* @param port - SSH port
* @param ws - WebSocket for user prompts (null for non-interactive connections)
* @param userId - User ID for logging
* @param isJumpHost - If true, auto-accepts without prompting
* @returns async hostVerifier callback
*/
static async createHostVerifier(
hostId: number | null,
ip: string,
@@ -63,8 +52,8 @@ export class SSHHostKeyVerifier {
return;
}
const host = await db.query.sshData.findFirst({
where: eq(sshData.id, hostId),
const host = await db.query.hosts.findFirst({
where: eq(hosts.id, hostId),
});
if (!host) {
@@ -153,11 +142,11 @@ export class SSHHostKeyVerifier {
if (host.hostKeyFingerprint === fingerprint) {
await db
.update(sshData)
.update(hosts)
.set({
hostKeyLastVerified: new Date().toISOString(),
})
.where(eq(sshData.id, hostId));
.where(eq(hosts.id, hostId));
sshLogger.info("Host key verified successfully", {
operation: "host_key_verified",
@@ -287,7 +276,7 @@ export class SSHHostKeyVerifier {
algorithm: string,
): Promise<void> {
await db
.update(sshData)
.update(hosts)
.set({
hostKeyFingerprint: fingerprint,
hostKeyType: keyType,
@@ -295,7 +284,7 @@ export class SSHHostKeyVerifier {
hostKeyFirstSeen: new Date().toISOString(),
hostKeyLastVerified: new Date().toISOString(),
})
.where(eq(sshData.id, hostId));
.where(eq(hosts.id, hostId));
}
private static async updateHostKey(
@@ -306,7 +295,7 @@ export class SSHHostKeyVerifier {
currentChangeCount: number,
): Promise<void> {
await db
.update(sshData)
.update(hosts)
.set({
hostKeyFingerprint: fingerprint,
hostKeyType: keyType,
@@ -314,7 +303,7 @@ export class SSHHostKeyVerifier {
hostKeyLastVerified: new Date().toISOString(),
hostKeyChangedCount: currentChangeCount + 1,
})
.where(eq(sshData.id, hostId));
.where(eq(hosts.id, hostId));
}
private static async promptUserForNewKey(
+3 -3
View File
@@ -123,7 +123,7 @@ async function checkOPKConfigExists(): Promise<{
return {
exists: false,
configPath,
error: `OPKSSH configuration is missing 'redirect_uris' field. This field must contain the Termix callback URL that you registered with your OAuth provider (e.g., http://localhost:8080/ssh/opkssh-callback for Docker). The static callback route will internally redirect to the dynamic route for proper URL rewriting.`,
error: `OPKSSH configuration is missing 'redirect_uris' field. This field must contain the Termix callback URL that you registered with your OAuth provider (e.g., http://localhost:8080/host/opkssh-callback for Docker). The static callback route will internally redirect to the dynamic route for proper URL rewriting.`,
};
}
@@ -179,7 +179,7 @@ export async function startOPKSSHAuth(
}
const requestId = randomUUID();
const remoteRedirectUri = `${requestOrigin}/ssh/opkssh-callback`;
const remoteRedirectUri = `${requestOrigin}/host/opkssh-callback`;
const session: Partial<OPKSSHAuthSession> = {
requestId,
@@ -352,7 +352,7 @@ function handleOPKSSHOutput(requestId: string, output: string): void {
/\/ssh\/opkssh-callback$/,
"",
);
const proxiedChooserUrl = `${baseUrl}/ssh/opkssh-chooser/${requestId}`;
const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`;
session.status = "waiting_for_auth";
session.ws.send(
+54 -21
View File
@@ -4,7 +4,7 @@ import cors from "cors";
import cookieParser from "cookie-parser";
import { Client, type ConnectConfig } from "ssh2";
import { getDb } from "../database/db/index.js";
import { sshData, sshCredentials } from "../database/db/schema.js";
import { hosts, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { statsLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
@@ -29,6 +29,15 @@ import {
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
import { connectionPool, withConnection } from "./ssh-connection-pool.js";
function supportsMetrics(host: SSHHostWithCredentials): boolean {
const connectionType = host.connectionType || "ssh";
return connectionType === "ssh";
}
function isTcpPingEnabled(statsConfig: StatsConfig): boolean {
return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
}
function createConnectionLog(
type: "info" | "success" | "warning" | "error",
stage: ConnectionStage,
@@ -62,20 +71,20 @@ async function resolveJumpHost(
userId: string,
): Promise<JumpHostConfig | null> {
try {
const hosts = await SimpleDBOps.select(
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
.from(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
if (hosts.length === 0) {
if (hostResults.length === 0) {
return null;
}
const host = hosts[0];
const host = hostResults[0];
if (host.credentialId) {
const credentials = await SimpleDBOps.select(
@@ -644,6 +653,7 @@ interface SSHHostWithCredentials {
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: ProxyNode[];
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
}
type StatusEntry = {
@@ -659,6 +669,7 @@ interface StatsConfig {
metricsEnabled: boolean;
metricsInterval: number;
useGlobalMetricsInterval?: boolean;
disableTcpPing?: boolean;
}
const DEFAULT_STATS_CONFIG: StatsConfig = {
@@ -783,9 +794,13 @@ class PollingManager {
const statusOnly = options?.statusOnly ?? false;
const viewerUserId = options?.viewerUserId;
const canCollectMetrics = supportsMetrics(host);
const enabledCollectors: string[] = [];
if (statsConfig.statusCheckEnabled) enabledCollectors.push("status");
if (!statusOnly && statsConfig.metricsEnabled) {
if (isTcpPingEnabled(statsConfig)) {
enabledCollectors.push("status");
}
if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) {
enabledCollectors.push(
"cpu",
"memory",
@@ -810,7 +825,7 @@ class PollingManager {
}
}
if (!statsConfig.statusCheckEnabled && !statsConfig.metricsEnabled) {
if (!isTcpPingEnabled(statsConfig) && !statsConfig.metricsEnabled) {
this.pollingConfigs.delete(host.id);
this.statusStore.delete(host.id);
this.metricsStore.delete(host.id);
@@ -823,14 +838,14 @@ class PollingManager {
viewerUserId,
};
if (statsConfig.statusCheckEnabled) {
if (isTcpPingEnabled(statsConfig)) {
const intervalMs = statsConfig.statusCheckInterval * 1000;
this.pollHostStatus(host, viewerUserId);
config.statusTimer = setInterval(() => {
const latestConfig = this.pollingConfigs.get(host.id);
if (latestConfig && latestConfig.statsConfig.statusCheckEnabled) {
if (latestConfig && isTcpPingEnabled(latestConfig.statsConfig)) {
this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId);
}
}, intervalMs);
@@ -838,14 +853,18 @@ class PollingManager {
this.statusStore.delete(host.id);
}
if (!statusOnly && statsConfig.metricsEnabled) {
if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) {
const intervalMs = statsConfig.metricsInterval * 1000;
await this.pollHostMetrics(host, viewerUserId);
config.metricsTimer = setInterval(() => {
const latestConfig = this.pollingConfigs.get(host.id);
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
if (
latestConfig &&
latestConfig.statsConfig.metricsEnabled &&
supportsMetrics(latestConfig.host)
) {
this.pollHostMetrics(latestConfig.host, latestConfig.viewerUserId);
}
}, intervalMs);
@@ -896,6 +915,15 @@ class PollingManager {
return;
}
if (!supportsMetrics(refreshedHost)) {
statsLogger.debug("Skipping metrics collection for non-SSH host", {
operation: "poll_host_metrics_skipped",
hostId: refreshedHost.id,
connectionType: refreshedHost.connectionType || "ssh",
});
return;
}
const config = this.pollingConfigs.get(refreshedHost.id);
if (!config || !config.statsConfig.metricsEnabled) {
return;
@@ -1170,14 +1198,14 @@ async function fetchAllHosts(
userId: string,
): Promise<SSHHostWithCredentials[]> {
try {
const hosts = await SimpleDBOps.select(
getDb().select().from(sshData).where(eq(sshData.userId, userId)),
const hostResults = await SimpleDBOps.select(
getDb().select().from(hosts).where(eq(hosts.userId, userId)),
"ssh_data",
userId,
);
const hostsWithCredentials: SSHHostWithCredentials[] = [];
for (const host of hosts) {
for (const host of hostResults) {
try {
const hostWithCreds = await resolveHostCredentials(host, userId);
if (hostWithCreds) {
@@ -1221,17 +1249,17 @@ async function fetchHostById(
return undefined;
}
const hosts = await SimpleDBOps.select(
getDb().select().from(sshData).where(eq(sshData.id, id)),
const hostResults = await SimpleDBOps.select(
getDb().select().from(hosts).where(eq(hosts.id, id)),
"ssh_data",
userId,
);
if (hosts.length === 0) {
if (hostResults.length === 0) {
return undefined;
}
const host = hosts[0];
const host = hostResults[0];
return await resolveHostCredentials(host, userId);
} catch (err) {
statsLogger.error(`Failed to fetch host ${id}`, err);
@@ -1757,6 +1785,10 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
os: string | null;
};
}> {
if (!supportsMetrics(host)) {
throw new Error("Metrics collection only supported for SSH hosts");
}
if (authFailureTracker.shouldSkip(host.id)) {
const reason = authFailureTracker.getSkipReason(host.id);
throw new Error(reason || "Authentication failed");
@@ -1935,7 +1967,8 @@ function tcpPing(
socket.once("data", (data) => {
clearTimeout(dataTimeout);
if (data.toString().startsWith("SSH-")) {
const dataStr = data.toString("utf8");
if (dataStr.startsWith("SSH-")) {
try {
socket.end("SSH-2.0-TermixHealthCheck\r\n");
} catch {
+93 -52
View File
@@ -3,7 +3,7 @@ import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
import { parse as parseUrl } from "url";
import axios from "axios";
import { getDb } from "../database/db/index.js";
import { sshCredentials, sshData } from "../database/db/schema.js";
import { sshCredentials, hosts } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { sshLogger, authLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
@@ -97,20 +97,20 @@ async function resolveJumpHost(
hostId,
});
try {
const hosts = await SimpleDBOps.select(
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
.from(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
if (hosts.length === 0) {
if (hostResults.length === 0) {
return null;
}
const host = hosts[0];
const host = hostResults[0];
if (host.credentialId) {
const credentials = await SimpleDBOps.select(
@@ -814,8 +814,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
const db = getDb();
const hostRow = await db
.select()
.from(sshData)
.where(eq(sshData.id, opksshData.hostId))
.from(hosts)
.where(eq(hosts.id, opksshData.hostId))
.limit(1);
if (!hostRow || hostRow.length === 0) {
sshLogger.error(
@@ -1057,55 +1057,96 @@ wss.on("connection", async (ws: WebSocket, req) => {
authType,
};
const authMethodNotAvailable = false;
if (credentialId && id && hostConfig.userId) {
try {
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, hostConfig.userId),
),
),
"ssh_credentials",
hostConfig.userId,
);
if (credentialId && id) {
const hostRow = await getDb()
.select({ userId: hosts.userId })
.from(hosts)
.where(eq(hosts.id, id))
.limit(1);
const ownerId = hostRow[0]?.userId ?? null;
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
username: (credential.username as string | undefined) || username,
password: credential.password as string | undefined,
key: credential.privateKey as string | undefined,
keyPassword: credential.keyPassword as string | undefined,
keyType: credential.keyType as string | undefined,
authType: credential.authType as string | undefined,
};
} else {
sshLogger.warn(`No credentials found for host ${id}`, {
if (ownerId && userId !== ownerId) {
try {
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
id,
userId,
);
if (sharedCred) {
resolvedCredentials = {
username: sharedCred.username || username,
password: sharedCred.password,
key: sharedCred.key,
keyPassword: sharedCred.keyPassword,
keyType: sharedCred.keyType,
authType: sharedCred.authType,
};
} else {
sshLogger.warn(`No shared credentials found for host ${id}`, {
operation: "ssh_credentials",
userId,
hostId: id,
});
}
} catch (error) {
sshLogger.warn(`Failed to resolve shared credential for host ${id}`, {
operation: "ssh_credentials",
hostId: id,
error: error instanceof Error ? error.message : "Unknown error",
});
}
} else if (ownerId) {
try {
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, ownerId),
),
),
"ssh_credentials",
ownerId,
);
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
username: (credential.username as string | undefined) || username,
password: credential.password as string | undefined,
key: credential.privateKey as string | undefined,
keyPassword: credential.keyPassword as string | undefined,
keyType: credential.keyType as string | undefined,
authType: credential.authType as string | undefined,
};
} else {
sshLogger.warn(`No credentials found for host ${id}`, {
operation: "ssh_credentials",
hostId: id,
credentialId,
userId: ownerId,
});
}
} catch (error) {
sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
operation: "ssh_credentials",
hostId: id,
credentialId,
userId: hostConfig.userId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
} catch (error) {
sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
} else {
sshLogger.warn("Missing userId for credential resolution in terminal", {
operation: "ssh_credentials",
hostId: id,
credentialId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
} else if (credentialId && id) {
sshLogger.warn("Missing userId for credential resolution in terminal", {
operation: "ssh_credentials",
hostId: id,
credentialId,
hasUserId: !!hostConfig.userId,
});
}
sshConn.on("ready", () => {
@@ -1400,14 +1441,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (id && hostConfig.userId) {
(async () => {
try {
const hosts = await SimpleDBOps.select(
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.from(hosts)
.where(
and(
eq(sshData.id, id),
eq(sshData.userId, hostConfig.userId!),
eq(hosts.id, id),
eq(hosts.userId, hostConfig.userId!),
),
),
"ssh_data",
@@ -1415,8 +1456,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
);
const hostName =
hosts.length > 0 && hosts[0].name
? hosts[0].name
hostResults.length > 0 && hostResults[0].name
? hostResults[0].name
: `${username}@${ip}:${port}`;
await axios.post(
+5 -7
View File
@@ -608,9 +608,8 @@ async function connectSSHTunnel(
tunnelConfig.requestingUserId &&
tunnelConfig.requestingUserId !== tunnelConfig.sourceUserId
) {
const { SharedCredentialManager } = await import(
"../utils/shared-credential-manager.js"
);
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
if (tunnelConfig.sourceHostId) {
@@ -1750,7 +1749,7 @@ app.post(
const internalAuthToken = await systemCrypto.getInternalAuthToken();
const allHostsResponse = await axios.get(
"http://localhost:30001/ssh/db/host/internal/all",
"http://localhost:30001/host/db/host/internal/all",
{
headers: {
"Content-Type": "application/json",
@@ -2028,7 +2027,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
const internalAuthToken = await systemCrypto.getInternalAuthToken();
const autostartResponse = await axios.get(
"http://localhost:30001/ssh/db/host/internal",
"http://localhost:30001/host/db/host/internal",
{
headers: {
"Content-Type": "application/json",
@@ -2038,7 +2037,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
);
const allHostsResponse = await axios.get(
"http://localhost:30001/ssh/db/host/internal/all",
"http://localhost:30001/host/db/host/internal/all",
{
headers: {
"Content-Type": "application/json",
@@ -2050,7 +2049,6 @@ async function initializeAutoStartTunnels(): Promise<void> {
const autostartHosts: SSHHost[] = autostartResponse.data || [];
const allHosts: SSHHost[] = allHostsResponse.data || [];
const autoStartTunnels: TunnelConfig[] = [];
tunnelLogger.info(
`Found ${autostartHosts.length} autostart hosts and ${allHosts.length} total hosts for endpointHost resolution`,
);