* 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
+17 -27
View File
@@ -7,7 +7,7 @@ import { db } from "../db/index.js";
import {
sshCredentials,
sshCredentialUsage,
sshData,
hosts,
hostAccess,
} from "../db/schema.js";
import { eq, and, desc, sql } from "drizzle-orm";
@@ -606,9 +606,8 @@ router.put(
userId,
);
const { SharedCredentialManager } = await import(
"../../utils/shared-credential-manager.js"
);
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.updateSharedCredentialsForOriginal(
parseInt(id),
@@ -691,17 +690,14 @@ router.delete(
const hostsUsingCredential = await db
.select()
.from(sshData)
.from(hosts)
.where(
and(
eq(sshData.credentialId, parseInt(id)),
eq(sshData.userId, userId),
),
and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)),
);
if (hostsUsingCredential.length > 0) {
await db
.update(sshData)
.update(hosts)
.set({
credentialId: null,
password: null,
@@ -710,10 +706,7 @@ router.delete(
authType: "password",
})
.where(
and(
eq(sshData.credentialId, parseInt(id)),
eq(sshData.userId, userId),
),
and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)),
);
for (const host of hostsUsingCredential) {
@@ -737,9 +730,8 @@ router.delete(
}
}
const { SharedCredentialManager } = await import(
"../../utils/shared-credential-manager.js"
);
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id));
@@ -837,7 +829,7 @@ router.post(
const credential = credentials[0];
await db
.update(sshData)
.update(hosts)
.set({
credentialId: parseInt(credentialId),
username: (credential.username as string) || "",
@@ -848,9 +840,7 @@ router.post(
keyType: null,
updatedAt: new Date().toISOString(),
})
.where(
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
);
.where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId)));
await db.insert(sshCredentialUsage).values({
credentialId: parseInt(credentialId),
@@ -917,17 +907,17 @@ router.get(
}
try {
const hosts = await db
const hostsUsingCredential = await db
.select()
.from(sshData)
.from(hosts)
.where(
and(
eq(sshData.credentialId, parseInt(credentialId)),
eq(sshData.userId, userId),
eq(hosts.credentialId, parseInt(credentialId)),
eq(hosts.userId, userId),
),
);
res.json(hosts.map((host) => formatSSHHostOutput(host)));
res.json(hostsUsingCredential.map((host) => formatSSHHostOutput(host)));
} catch (err) {
authLogger.error("Failed to fetch hosts using credential", err);
res.status(500).json({
@@ -1942,7 +1932,7 @@ router.post(
});
}
const targetHost = await SimpleDBOps.select(
db.select().from(sshData).where(eq(sshData.id, targetHostId)).limit(1),
db.select().from(hosts).where(eq(hosts.id, targetHostId)).limit(1),
"ssh_data",
userId,
);
@@ -2,7 +2,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import {
sshData,
hosts,
sshCredentials,
sshCredentialUsage,
fileManagerRecent,
@@ -90,6 +90,12 @@ function transformHostResponse(
socks5ProxyChain: host.socks5ProxyChain
? JSON.parse(host.socks5ProxyChain as string)
: [],
domain: host.domain || undefined,
security: host.security || undefined,
ignoreCert: !!host.ignoreCert,
guacamoleConfig: host.guacamoleConfig
? JSON.parse(host.guacamoleConfig as string)
: undefined,
};
}
@@ -139,12 +145,9 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
try {
const autostartHosts = await db
.select()
.from(sshData)
.from(hosts)
.where(
and(
eq(sshData.enableTunnel, true),
isNotNull(sshData.tunnelConnections),
),
and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)),
);
const result = autostartHosts
@@ -235,7 +238,7 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => {
.json({ error: "Invalid internal authentication token" });
}
const allHosts = await db.select().from(sshData);
const allHosts = await db.select().from(hosts);
const result = allHosts.map((host) => {
const tunnelConnections = host.tunnelConnections
@@ -334,6 +337,7 @@ router.post(
}
const {
connectionType,
name,
folder,
tags,
@@ -363,8 +367,13 @@ router.post(
jumpHosts,
quickActions,
statsConfig,
dockerConfig,
terminalConfig,
forceKeyboardInteractive,
domain,
security,
ignoreCert,
guacamoleConfig,
notes,
useSocks5,
socks5Host,
@@ -397,8 +406,10 @@ router.post(
}
const effectiveAuthType = authType || authMethod;
const effectiveConnectionType = connectionType || "ssh";
const sshDataObj: Record<string, unknown> = {
userId: userId,
connectionType: effectiveConnectionType,
name,
folder: folder || null,
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
@@ -431,12 +442,21 @@ router.post(
? statsConfig
: JSON.stringify(statsConfig)
: null,
dockerConfig: dockerConfig
? typeof dockerConfig === "string"
? dockerConfig
: JSON.stringify(dockerConfig)
: null,
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
: JSON.stringify(terminalConfig)
: null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
domain: domain || null,
security: security || null,
ignoreCert: ignoreCert ? 1 : 0,
guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null,
notes: notes || null,
sudoPassword: sudoPassword || null,
useSocks5: useSocks5 ? 1 : 0,
@@ -449,7 +469,13 @@ router.post(
: null,
};
if (effectiveAuthType === "password") {
// For non-SSH hosts (RDP, VNC, Telnet), always save password if provided
if (effectiveConnectionType !== "ssh") {
sshDataObj.password = password || null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (effectiveAuthType === "password") {
sshDataObj.password = password || null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
@@ -501,7 +527,7 @@ router.post(
try {
const result = await SimpleDBOps.insert(
sshData,
hosts,
"ssh_data",
sshDataObj,
userId,
@@ -532,7 +558,7 @@ router.post(
try {
const axios = (await import("axios")).default;
const statsPort = process.env.STATS_PORT || 30005;
const statsPort = 30005;
await axios.post(
`http://localhost:${statsPort}/host-updated`,
{ hostId: createdHost.id },
@@ -818,6 +844,7 @@ router.put(
}
const {
connectionType,
name,
folder,
tags,
@@ -847,8 +874,13 @@ router.put(
jumpHosts,
quickActions,
statsConfig,
dockerConfig,
terminalConfig,
forceKeyboardInteractive,
domain,
security,
ignoreCert,
guacamoleConfig,
notes,
useSocks5,
socks5Host,
@@ -884,6 +916,7 @@ router.put(
const effectiveAuthType = authType || authMethod;
const sshDataObj: Record<string, unknown> = {
connectionType: connectionType || "ssh",
name,
folder,
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
@@ -916,12 +949,21 @@ router.put(
? statsConfig
: JSON.stringify(statsConfig)
: null,
dockerConfig: dockerConfig
? typeof dockerConfig === "string"
? dockerConfig
: JSON.stringify(dockerConfig)
: null,
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
: JSON.stringify(terminalConfig)
: null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
domain: domain || null,
security: security || null,
ignoreCert: ignoreCert ? 1 : 0,
guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null,
notes: notes || null,
sudoPassword: sudoPassword || null,
useSocks5: useSocks5 ? 1 : 0,
@@ -934,7 +976,15 @@ router.put(
: null,
};
if (effectiveAuthType === "password") {
// For non-SSH hosts (RDP, VNC, Telnet), always save password if provided
if ((connectionType || "ssh") !== "ssh") {
if (password) {
sshDataObj.password = password;
}
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (effectiveAuthType === "password") {
if (password) {
sshDataObj.password = password;
}
@@ -1021,12 +1071,12 @@ router.put(
const hostRecord = await db
.select({
userId: sshData.userId,
credentialId: sshData.credentialId,
authType: sshData.authType,
userId: hosts.userId,
credentialId: hosts.credentialId,
authType: hosts.authType,
})
.from(sshData)
.where(eq(sshData.id, Number(hostId)))
.from(hosts)
.where(eq(hosts.id, Number(hostId)))
.limit(1);
if (hostRecord.length === 0) {
@@ -1072,9 +1122,9 @@ router.put(
}
await SimpleDBOps.update(
sshData,
hosts,
"ssh_data",
eq(sshData.id, Number(hostId)),
eq(hosts.id, Number(hostId)),
sshDataObj,
ownerId,
);
@@ -1082,8 +1132,8 @@ router.put(
const updatedHosts = await SimpleDBOps.select(
db
.select()
.from(sshData)
.where(eq(sshData.id, Number(hostId))),
.from(hosts)
.where(eq(hosts.id, Number(hostId))),
"ssh_data",
ownerId,
);
@@ -1110,7 +1160,7 @@ router.put(
try {
const axios = (await import("axios")).default;
const statsPort = process.env.STATS_PORT || 30005;
const statsPort = 30005;
await axios.post(
`http://localhost:${statsPort}/host-updated`,
{ hostId: parseInt(hostId) },
@@ -1186,62 +1236,67 @@ router.get(
const rawData = await db
.select({
id: sshData.id,
userId: sshData.userId,
name: sshData.name,
ip: sshData.ip,
port: sshData.port,
username: sshData.username,
folder: sshData.folder,
tags: sshData.tags,
pin: sshData.pin,
authType: sshData.authType,
password: sshData.password,
key: sshData.key,
keyPassword: sshData.keyPassword,
keyType: sshData.keyType,
enableTerminal: sshData.enableTerminal,
enableTunnel: sshData.enableTunnel,
tunnelConnections: sshData.tunnelConnections,
jumpHosts: sshData.jumpHosts,
enableFileManager: sshData.enableFileManager,
defaultPath: sshData.defaultPath,
autostartPassword: sshData.autostartPassword,
autostartKey: sshData.autostartKey,
autostartKeyPassword: sshData.autostartKeyPassword,
forceKeyboardInteractive: sshData.forceKeyboardInteractive,
statsConfig: sshData.statsConfig,
terminalConfig: sshData.terminalConfig,
sudoPassword: sshData.sudoPassword,
createdAt: sshData.createdAt,
updatedAt: sshData.updatedAt,
credentialId: sshData.credentialId,
overrideCredentialUsername: sshData.overrideCredentialUsername,
quickActions: sshData.quickActions,
notes: sshData.notes,
enableDocker: sshData.enableDocker,
showTerminalInSidebar: sshData.showTerminalInSidebar,
showFileManagerInSidebar: sshData.showFileManagerInSidebar,
showTunnelInSidebar: sshData.showTunnelInSidebar,
showDockerInSidebar: sshData.showDockerInSidebar,
showServerStatsInSidebar: sshData.showServerStatsInSidebar,
useSocks5: sshData.useSocks5,
socks5Host: sshData.socks5Host,
socks5Port: sshData.socks5Port,
socks5Username: sshData.socks5Username,
socks5Password: sshData.socks5Password,
socks5ProxyChain: sshData.socks5ProxyChain,
id: hosts.id,
userId: hosts.userId,
connectionType: hosts.connectionType,
name: hosts.name,
ip: hosts.ip,
port: hosts.port,
username: hosts.username,
folder: hosts.folder,
tags: hosts.tags,
pin: hosts.pin,
authType: hosts.authType,
password: hosts.password,
key: hosts.key,
keyPassword: hosts.keyPassword,
keyType: hosts.keyType,
enableTerminal: hosts.enableTerminal,
enableTunnel: hosts.enableTunnel,
tunnelConnections: hosts.tunnelConnections,
jumpHosts: hosts.jumpHosts,
enableFileManager: hosts.enableFileManager,
defaultPath: hosts.defaultPath,
autostartPassword: hosts.autostartPassword,
autostartKey: hosts.autostartKey,
autostartKeyPassword: hosts.autostartKeyPassword,
forceKeyboardInteractive: hosts.forceKeyboardInteractive,
statsConfig: hosts.statsConfig,
terminalConfig: hosts.terminalConfig,
sudoPassword: hosts.sudoPassword,
createdAt: hosts.createdAt,
updatedAt: hosts.updatedAt,
credentialId: hosts.credentialId,
overrideCredentialUsername: hosts.overrideCredentialUsername,
quickActions: hosts.quickActions,
notes: hosts.notes,
enableDocker: hosts.enableDocker,
showTerminalInSidebar: hosts.showTerminalInSidebar,
showFileManagerInSidebar: hosts.showFileManagerInSidebar,
showTunnelInSidebar: hosts.showTunnelInSidebar,
showDockerInSidebar: hosts.showDockerInSidebar,
showServerStatsInSidebar: hosts.showServerStatsInSidebar,
useSocks5: hosts.useSocks5,
socks5Host: hosts.socks5Host,
socks5Port: hosts.socks5Port,
socks5Username: hosts.socks5Username,
socks5Password: hosts.socks5Password,
socks5ProxyChain: hosts.socks5ProxyChain,
domain: hosts.domain,
security: hosts.security,
ignoreCert: hosts.ignoreCert,
guacamoleConfig: hosts.guacamoleConfig,
ownerId: sshData.userId,
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL AND ${sshData.userId} != ${userId}`,
ownerId: hosts.userId,
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL AND ${hosts.userId} != ${userId}`,
permissionLevel: hostAccess.permissionLevel,
expiresAt: hostAccess.expiresAt,
})
.from(sshData)
.from(hosts)
.leftJoin(
hostAccess,
and(
eq(hostAccess.hostId, sshData.id),
eq(hostAccess.hostId, hosts.id),
or(
eq(hostAccess.userId, userId),
roleIds.length > 0
@@ -1253,7 +1308,7 @@ router.get(
)
.where(
or(
eq(sshData.userId, userId),
eq(hosts.userId, userId),
and(
eq(hostAccess.userId, userId),
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
@@ -1361,10 +1416,14 @@ router.get(
return res.status(400).json({ error: "Invalid userId or hostId" });
}
try {
const data = await db
.select()
.from(sshData)
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
const data = await SimpleDBOps.select(
db
.select()
.from(hosts)
.where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
if (data.length === 0) {
sshLogger.warn("SSH host not found", {
@@ -1429,37 +1488,36 @@ router.get(
}
try {
const hosts = await SimpleDBOps.select(
const hostResults = await SimpleDBOps.select(
db
.select()
.from(sshData)
.where(
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
),
.from(hosts)
.where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
if (hosts.length === 0) {
if (hostResults.length === 0) {
return res.status(404).json({ error: "SSH host not found" });
}
const host = hosts[0];
const host = hostResults[0];
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
const exportData = {
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
const isRemoteDesktop = ["rdp", "vnc", "telnet"].includes(
exportedConnectionType,
);
const baseExportData = {
connectionType: exportedConnectionType,
name: resolvedHost.name,
ip: resolvedHost.ip,
port: resolvedHost.port,
username: resolvedHost.username,
authType: resolvedHost.authType,
password: resolvedHost.password || null,
key: resolvedHost.key || null,
keyPassword: resolvedHost.keyPassword || null,
keyType: resolvedHost.keyType || null,
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername: !!resolvedHost.overrideCredentialUsername,
folder: resolvedHost.folder,
tags:
typeof resolvedHost.tags === "string"
@@ -1467,44 +1525,68 @@ router.get(
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null,
enableTerminal: !!resolvedHost.enableTerminal,
enableTunnel: !!resolvedHost.enableTunnel,
enableFileManager: !!resolvedHost.enableFileManager,
enableDocker: !!resolvedHost.enableDocker,
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
defaultPath: resolvedHost.defaultPath,
sudoPassword: resolvedHost.sudoPassword || null,
tunnelConnections: resolvedHost.tunnelConnections
? JSON.parse(resolvedHost.tunnelConnections as string)
: [],
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
statsConfig: resolvedHost.statsConfig
? JSON.parse(resolvedHost.statsConfig as string)
: null,
terminalConfig: resolvedHost.terminalConfig
? JSON.parse(resolvedHost.terminalConfig as string)
: null,
forceKeyboardInteractive:
resolvedHost.forceKeyboardInteractive === "true",
useSocks5: !!resolvedHost.useSocks5,
socks5Host: resolvedHost.socks5Host || null,
socks5Port: resolvedHost.socks5Port || null,
socks5Username: resolvedHost.socks5Username || null,
socks5Password: resolvedHost.socks5Password || null,
socks5ProxyChain: resolvedHost.socks5ProxyChain
? JSON.parse(resolvedHost.socks5ProxyChain as string)
: null,
};
const exportData = isRemoteDesktop
? {
...baseExportData,
domain: resolvedHost.domain || null,
security: resolvedHost.security || null,
ignoreCert: !!resolvedHost.ignoreCert,
guacamoleConfig: resolvedHost.guacamoleConfig
? JSON.parse(resolvedHost.guacamoleConfig as string)
: null,
}
: {
...baseExportData,
authType: resolvedHost.authType,
key: resolvedHost.key || null,
keyPassword: resolvedHost.keyPassword || null,
keyType: resolvedHost.keyType || null,
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername:
!!resolvedHost.overrideCredentialUsername,
enableTerminal: !!resolvedHost.enableTerminal,
enableTunnel: !!resolvedHost.enableTunnel,
enableFileManager: !!resolvedHost.enableFileManager,
enableDocker: !!resolvedHost.enableDocker,
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
defaultPath: resolvedHost.defaultPath,
sudoPassword: resolvedHost.sudoPassword || null,
tunnelConnections: resolvedHost.tunnelConnections
? JSON.parse(resolvedHost.tunnelConnections as string)
: [],
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
statsConfig: resolvedHost.statsConfig
? JSON.parse(resolvedHost.statsConfig as string)
: null,
dockerConfig: resolvedHost.dockerConfig
? JSON.parse(resolvedHost.dockerConfig as string)
: null,
terminalConfig: resolvedHost.terminalConfig
? JSON.parse(resolvedHost.terminalConfig as string)
: null,
forceKeyboardInteractive:
resolvedHost.forceKeyboardInteractive === "true",
useSocks5: !!resolvedHost.useSocks5,
socks5Host: resolvedHost.socks5Host || null,
socks5Port: resolvedHost.socks5Port || null,
socks5Username: resolvedHost.socks5Username || null,
socks5Password: resolvedHost.socks5Password || null,
socks5ProxyChain: resolvedHost.socks5ProxyChain
? JSON.parse(resolvedHost.socks5ProxyChain as string)
: null,
};
sshLogger.success("Host exported with decrypted credentials", {
operation: "host_export",
hostId: parseInt(hostId),
@@ -1573,8 +1655,8 @@ router.delete(
try {
const hostToDelete = await db
.select()
.from(sshData)
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
.from(hosts)
.where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId)));
if (hostToDelete.length === 0) {
sshLogger.warn("SSH host not found for deletion", {
@@ -1618,8 +1700,8 @@ router.delete(
.where(eq(sessionRecordings.hostId, numericHostId));
await db
.delete(sshData)
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
.delete(hosts)
.where(and(eq(hosts.id, numericHostId), eq(hosts.userId, userId)));
databaseLogger.success("SSH host deleted", {
operation: "host_delete_success",
@@ -1629,7 +1711,7 @@ router.delete(
try {
const axios = (await import("axios")).default;
const statsPort = process.env.STATS_PORT || 30005;
const statsPort = 30005;
await axios.post(
`http://localhost:${statsPort}/host-deleted`,
{ hostId: numericHostId },
@@ -2522,9 +2604,9 @@ router.put(
try {
const updatedHosts = await SimpleDBOps.update(
sshData,
hosts,
"ssh_data",
and(eq(sshData.userId, userId), eq(sshData.folder, oldName)),
and(eq(hosts.userId, userId), eq(hosts.folder, oldName)),
{
folder: newName,
updatedAt: new Date().toISOString(),
@@ -2748,8 +2830,8 @@ router.delete(
try {
const hostsToDelete = await db
.select()
.from(sshData)
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
.from(hosts)
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
if (hostsToDelete.length === 0) {
return res.json({
@@ -2793,8 +2875,8 @@ router.delete(
}
await db
.delete(sshData)
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
.delete(hosts)
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
await db
.delete(sshFolders)
@@ -2806,7 +2888,7 @@ router.delete(
try {
const axios = (await import("axios")).default;
const statsPort = process.env.STATS_PORT || 30005;
const statsPort = 30005;
for (const host of hostsToDelete) {
try {
await axios.post(
@@ -2935,9 +3017,9 @@ router.patch(
try {
const ownedHosts = await db
.select({ id: sshData.id, statsConfig: sshData.statsConfig })
.from(sshData)
.where(and(inArray(sshData.id, hostIds), eq(sshData.userId, userId)));
.select({ id: hosts.id, statsConfig: hosts.statsConfig })
.from(hosts)
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)));
const ownedIds = ownedHosts.map((h) => h.id);
const unauthorizedIds = hostIds.filter(
@@ -2968,11 +3050,9 @@ router.patch(
if (Object.keys(simpleUpdates).length > 0) {
await db
.update(sshData)
.update(hosts)
.set(simpleUpdates)
.where(
and(inArray(sshData.id, ownedIds), eq(sshData.userId, userId)),
);
.where(and(inArray(hosts.id, ownedIds), eq(hosts.userId, userId)));
}
if (updates.statsConfig && typeof updates.statsConfig === "object") {
@@ -2983,9 +3063,9 @@ router.patch(
: {};
const merged = { ...existing, ...updates.statsConfig };
await db
.update(sshData)
.update(hosts)
.set({ statsConfig: JSON.stringify(merged) })
.where(and(eq(sshData.id, host.id), eq(sshData.userId, userId)));
.where(and(eq(hosts.id, host.id), eq(hosts.userId, userId)));
} catch (e) {
errors.push(`Failed to update statsConfig for host ${host.id}`);
}
@@ -3011,15 +3091,15 @@ router.post(
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hosts, overwrite } = req.body;
const { hosts: hostsToImport, overwrite } = req.body;
if (!Array.isArray(hosts) || hosts.length === 0) {
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
return res
.status(400)
.json({ error: "Hosts array is required and must not be empty" });
}
if (hosts.length > 100) {
if (hostsToImport.length > 100) {
return res
.status(400)
.json({ error: "Maximum 100 hosts allowed per import" });
@@ -3037,7 +3117,7 @@ router.post(
if (overwrite) {
try {
const allHosts = await SimpleDBOps.select<Record<string, unknown>>(
db.select().from(sshData).where(eq(sshData.userId, userId)),
db.select().from(hosts).where(eq(hosts.userId, userId)),
"ssh_data",
userId,
);
@@ -3051,23 +3131,34 @@ router.post(
}
}
for (let i = 0; i < hosts.length; i++) {
const hostData = hosts[i];
for (let i = 0; i < hostsToImport.length; i++) {
const hostData = hostsToImport[i];
try {
if (
!isNonEmptyString(hostData.ip) ||
!isValidPort(hostData.port) ||
!isNonEmptyString(hostData.username)
) {
const effectiveConnectionType = hostData.connectionType || "ssh";
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Missing required fields (ip, port, username)`,
`Host ${i + 1}: Missing required fields (ip, port)`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
!isNonEmptyString(hostData.username)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Username required for SSH connections`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType &&
!["password", "key", "credential", "none", "opkssh"].includes(
hostData.authType,
)
@@ -3080,6 +3171,7 @@ router.post(
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "password" &&
!isNonEmptyString(hostData.password)
) {
@@ -3090,7 +3182,11 @@ router.post(
continue;
}
if (hostData.authType === "key" && !isNonEmptyString(hostData.key)) {
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "key" &&
!isNonEmptyString(hostData.key)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Key required for key authentication`,
@@ -3098,7 +3194,11 @@ router.post(
continue;
}
if (hostData.authType === "credential" && !hostData.credentialId) {
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
!hostData.credentialId
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: credentialId required for credential authentication`,
@@ -3108,21 +3208,13 @@ router.post(
const sshDataObj: Record<string, unknown> = {
userId: userId,
name: hostData.name || `${hostData.username}@${hostData.ip}`,
connectionType: effectiveConnectionType,
name: hostData.name || `${hostData.username || ""}@${hostData.ip}`,
folder: hostData.folder || "Default",
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
ip: hostData.ip,
port: hostData.port,
username: hostData.username,
password: hostData.authType === "password" ? hostData.password : null,
authType: hostData.authType,
credentialId:
hostData.authType === "credential" ? hostData.credentialId : null,
key: hostData.authType === "key" ? hostData.key : null,
keyPassword:
hostData.authType === "key" ? hostData.keyPassword || null : null,
keyType:
hostData.authType === "key" ? hostData.keyType || "auto" : null,
username: hostData.username || null,
pin: hostData.pin || false,
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
@@ -3147,6 +3239,9 @@ router.post(
statsConfig: hostData.statsConfig
? JSON.stringify(hostData.statsConfig)
: null,
dockerConfig: hostData.dockerConfig
? JSON.stringify(hostData.dockerConfig)
: null,
terminalConfig: hostData.terminalConfig
? JSON.stringify(hostData.terminalConfig)
: null,
@@ -3168,21 +3263,51 @@ router.post(
updatedAt: new Date().toISOString(),
};
if (effectiveConnectionType !== "ssh") {
sshDataObj.password = hostData.password || null;
sshDataObj.authType = "password";
sshDataObj.credentialId = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
sshDataObj.domain = hostData.domain || null;
sshDataObj.security = hostData.security || null;
sshDataObj.ignoreCert = hostData.ignoreCert ? 1 : 0;
sshDataObj.guacamoleConfig = hostData.guacamoleConfig
? JSON.stringify(hostData.guacamoleConfig)
: null;
} else {
sshDataObj.password =
hostData.authType === "password" ? hostData.password : null;
sshDataObj.authType = hostData.authType || "password";
sshDataObj.credentialId =
hostData.authType === "credential" ? hostData.credentialId : null;
sshDataObj.key = hostData.authType === "key" ? hostData.key : null;
sshDataObj.keyPassword =
hostData.authType === "key" ? hostData.keyPassword || null : null;
sshDataObj.keyType =
hostData.authType === "key" ? hostData.keyType || "auto" : null;
sshDataObj.domain = null;
sshDataObj.security = null;
sshDataObj.ignoreCert = 0;
sshDataObj.guacamoleConfig = null;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing) {
await SimpleDBOps.update(
sshData,
hosts,
"ssh_data",
eq(sshData.id, existing.id),
eq(hosts.id, existing.id),
sshDataObj,
userId,
);
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
await SimpleDBOps.insert(sshData, "ssh_data", sshDataObj, userId);
await SimpleDBOps.insert(hosts, "ssh_data", sshDataObj, userId);
results.success++;
}
} catch (error) {
@@ -3204,6 +3329,104 @@ router.post(
},
);
/**
* @openapi
* /ssh/folders/{folderName}/hosts:
* delete:
* summary: Delete all hosts in a folder
* description: Deletes all hosts within a specific folder.
* tags:
* - SSH
* parameters:
* - in: path
* name: folderName
* required: true
* schema:
* type: string
* responses:
* 200:
* description: All hosts deleted successfully.
* 400:
* description: Invalid folder name.
* 500:
* description: Failed to delete hosts.
*/
router.delete(
"/folders/:folderName/hosts",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folderName = decodeURIComponent(
Array.isArray(req.params.folderName)
? req.params.folderName[0]
: req.params.folderName,
);
if (!folderName) {
return res.status(400).json({ error: "Folder name is required" });
}
try {
const hostsToDelete = await db
.select({ id: hosts.id })
.from(hosts)
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
if (hostsToDelete.length === 0) {
return res.json({ deletedCount: 0 });
}
const hostIds = hostsToDelete.map((h) => h.id);
for (const hostId of hostIds) {
await db
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.hostId, hostId));
await db
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.hostId, hostId));
await db
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.hostId, hostId));
await db
.delete(commandHistory)
.where(eq(commandHistory.hostId, hostId));
await db
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.hostId, hostId));
await db
.delete(recentActivity)
.where(eq(recentActivity.hostId, hostId));
await db.delete(hostAccess).where(eq(hostAccess.hostId, hostId));
await db
.delete(sessionRecordings)
.where(eq(sessionRecordings.hostId, hostId));
}
await db
.delete(hosts)
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
databaseLogger.success("All hosts in folder deleted", {
operation: "delete_folder_hosts",
userId,
folderName,
deletedCount: hostsToDelete.length,
});
res.json({ deletedCount: hostsToDelete.length });
} catch (error) {
sshLogger.error("Failed to delete hosts in folder", error, {
operation: "delete_folder_hosts",
userId,
folderName,
});
res.status(500).json({ error: "Failed to delete hosts in folder" });
}
},
);
/**
* @openapi
* /ssh/autostart/enable:
@@ -3270,8 +3493,8 @@ router.post(
const sshConfig = await db
.select()
.from(sshData)
.where(and(eq(sshData.id, sshConfigId), eq(sshData.userId, userId)));
.from(hosts)
.where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId)));
if (sshConfig.length === 0) {
sshLogger.warn("SSH config not found for autostart enable", {
@@ -3309,8 +3532,8 @@ router.post(
) {
const endpointHosts = await db
.select()
.from(sshData)
.where(eq(sshData.userId, userId));
.from(hosts)
.where(eq(hosts.userId, userId));
const endpointHost = endpointHosts.find(
(h) =>
@@ -3349,14 +3572,14 @@ router.post(
}
await db
.update(sshData)
.update(hosts)
.set({
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
autostartKeyPassword: decryptedConfig.keyPassword || null,
tunnelConnections: updatedTunnelConnections,
})
.where(eq(sshData.id, sshConfigId));
.where(eq(hosts.id, sshConfigId));
try {
await DatabaseSaveTrigger.triggerSave();
@@ -3429,13 +3652,13 @@ router.delete(
try {
await db
.update(sshData)
.update(hosts)
.set({
autostartPassword: null,
autostartKey: null,
autostartKeyPassword: null,
})
.where(and(eq(sshData.id, sshConfigId), eq(sshData.userId, userId)));
.where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId)));
res.json({
message: "AutoStart disabled successfully",
@@ -3475,13 +3698,13 @@ router.get(
try {
const autostartConfigs = await db
.select()
.from(sshData)
.from(hosts)
.where(
and(
eq(sshData.userId, userId),
eq(hosts.userId, userId),
or(
isNotNull(sshData.autostartPassword),
isNotNull(sshData.autostartKey),
isNotNull(hosts.autostartPassword),
isNotNull(hosts.autostartKey),
),
),
);
+23 -26
View File
@@ -3,7 +3,7 @@ import express from "express";
import { db } from "../db/index.js";
import {
hostAccess,
sshData,
hosts,
users,
roles,
userRoles,
@@ -111,8 +111,8 @@ router.post(
const host = await db
.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)))
.limit(1);
if (host.length === 0) {
@@ -201,9 +201,8 @@ router.post(
.delete(sharedCredentials)
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
const { SharedCredentialManager } = await import(
"../../utils/shared-credential-manager.js"
);
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
if (targetType === "user") {
await sharedCredManager.createSharedCredentialForUser(
@@ -244,9 +243,8 @@ router.post(
expiresAt,
});
const { SharedCredentialManager } = await import(
"../../utils/shared-credential-manager.js"
);
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
if (targetType === "user") {
@@ -336,8 +334,8 @@ router.delete(
try {
const host = await db
.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)))
.limit(1);
if (host.length === 0) {
@@ -404,8 +402,8 @@ router.get(
try {
const host = await db
.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)))
.limit(1);
if (host.length === 0) {
@@ -484,21 +482,21 @@ router.get(
const sharedHosts = await db
.select({
id: sshData.id,
name: sshData.name,
ip: sshData.ip,
port: sshData.port,
username: sshData.username,
folder: sshData.folder,
tags: sshData.tags,
id: hosts.id,
name: hosts.name,
ip: hosts.ip,
port: hosts.port,
username: hosts.username,
folder: hosts.folder,
tags: hosts.tags,
permissionLevel: hostAccess.permissionLevel,
expiresAt: hostAccess.expiresAt,
grantedBy: hostAccess.grantedBy,
ownerUsername: users.username,
})
.from(hostAccess)
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
.innerJoin(users, eq(sshData.userId, users.id))
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
.innerJoin(users, eq(hosts.userId, users.id))
.where(
and(
eq(hostAccess.userId, userId),
@@ -978,12 +976,11 @@ router.post(
const hostsSharedWithRole = await db
.select()
.from(hostAccess)
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
.where(eq(hostAccess.roleId, roleId));
const { SharedCredentialManager } = await import(
"../../utils/shared-credential-manager.js"
);
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
for (const { host_access, ssh_data } of hostsSharedWithRole) {
+3 -5
View File
@@ -632,17 +632,15 @@ router.post(
const snippet = snippetResult[0];
const { Client } = await import("ssh2");
const { sshData, sshCredentials } = await import("../db/schema.js");
const { hosts, sshCredentials } = await import("../db/schema.js");
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
const hostResult = await SimpleDBOps.select(
db
.select()
.from(sshData)
.where(
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
),
.from(hosts)
.where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
+140 -40
View File
@@ -1,11 +1,12 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { restartGuacServer } from "../../guacamole/guacamole-server.js";
import crypto from "crypto";
import { db } from "../db/index.js";
import {
users,
sessions,
sshData,
hosts,
sshCredentials,
fileManagerRecent,
fileManagerPinned,
@@ -265,7 +266,7 @@ async function deleteUserAndRelatedData(userId: string): Promise<void> {
await db.delete(commandHistory).where(eq(commandHistory.userId, userId));
await db.delete(sshData).where(eq(sshData.userId, userId));
await db.delete(hosts).where(eq(hosts.userId, userId));
await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId));
await db.delete(networkTopology).where(eq(networkTopology.userId, userId));
@@ -789,6 +790,12 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
* description: Returns the OIDC authorization URL.
* tags:
* - Users
* parameters:
* - in: query
* name: rememberMe
* schema:
* type: boolean
* description: Whether to extend the session to 30 days instead of 2 hours.
* responses:
* 200:
* description: OIDC authorization URL.
@@ -799,29 +806,10 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
*/
router.get("/oidc/authorize", async (req, res) => {
try {
const { rememberMe } = req.query;
const origin = getRequestOriginWithForceHTTPS(req);
const backendCallbackUri = `${origin}/users/oidc/callback`;
authLogger.info("OIDC authorize request headers", {
protocol: req.protocol,
host: req.get("Host"),
origin: req.get("Origin"),
referer: req.get("Referer"),
"x-forwarded-proto": req.get("X-Forwarded-Proto"),
"x-forwarded-host": req.get("X-Forwarded-Host"),
"x-forwarded-port": req.get("X-Forwarded-Port"),
secure: req.secure,
calculatedOrigin: origin,
backendCallbackUri: backendCallbackUri,
});
authLogger.info(
`\n${"=".repeat(68)}\n` +
` OIDC CALLBACK URL - Register this in your OAuth provider:\n` +
` ${backendCallbackUri}\n` +
`${"=".repeat(68)}`,
);
const envConfig = getOIDCConfigFromEnv();
let config;
@@ -860,12 +848,12 @@ router.get("/oidc/authorize", async (req, res) => {
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.run(`oidc_frontend_origin_${state}`, frontendOrigin);
authLogger.info("OIDC authorization initiated", {
operation: "oidc_authorize",
backendCallbackUri,
frontendOrigin,
referer,
});
db.$client
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.run(
`oidc_remember_me_${state}`,
rememberMe === "true" ? "true" : "false",
);
const authUrl = new URL(config.authorization_url);
authUrl.searchParams.set("client_id", config.client_id);
@@ -898,10 +886,6 @@ router.get("/oidc/authorize", async (req, res) => {
*/
router.get("/oidc/callback", async (req, res) => {
const { code, state } = req.query;
authLogger.info("OIDC login callback received", {
operation: "oidc_login_request",
state,
});
if (!isNonEmptyString(code) || !isNonEmptyString(state)) {
return res.status(400).json({ error: "Code and state are required" });
@@ -913,6 +897,9 @@ router.get("/oidc/callback", async (req, res) => {
const storedFrontendOriginRow = db.$client
.prepare("SELECT value FROM settings WHERE key = ?")
.get(`oidc_frontend_origin_${state}`);
const storedRememberMeRow = db.$client
.prepare("SELECT value FROM settings WHERE key = ?")
.get(`oidc_remember_me_${state}`);
if (!storedBackendCallbackRow || !storedFrontendOriginRow) {
return res
@@ -925,6 +912,8 @@ router.get("/oidc/callback", async (req, res) => {
).value as string;
const frontendOrigin = (storedFrontendOriginRow as Record<string, unknown>)
.value as string;
const storedRememberMe =
(storedRememberMeRow as Record<string, unknown> | null)?.value === "true";
try {
const storedNonce = db.$client
@@ -951,12 +940,6 @@ router.get("/oidc/callback", async (req, res) => {
);
}
authLogger.info("OIDC token exchange attempt", {
operation: "oidc_token_exchange",
backendCallbackUri,
frontendOrigin,
});
const tokenResponse = await fetch(config.token_url, {
method: "POST",
headers: {
@@ -998,6 +981,9 @@ router.get("/oidc/callback", async (req, res) => {
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`oidc_frontend_origin_${state}`);
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`oidc_remember_me_${state}`);
let userInfo: Record<string, unknown> = null;
const userInfoUrls: string[] = [];
@@ -1320,6 +1306,7 @@ router.get("/oidc/callback", async (req, res) => {
const token = await authManager.generateJWTToken(userRecord.id, {
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
rememberMe: storedRememberMe,
});
authLogger.success("OIDC login successful", {
@@ -1334,7 +1321,9 @@ router.get("/oidc/callback", async (req, res) => {
const maxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
: 2 * 60 * 60 * 1000;
: storedRememberMe
? 30 * 24 * 60 * 60 * 1000
: 2 * 60 * 60 * 1000;
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
@@ -2488,7 +2477,7 @@ router.post("/complete-reset", async (req, res) => {
.delete(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
await db.delete(snippets).where(eq(snippets.userId, userId));
await db.delete(sshData).where(eq(sshData.userId, userId));
await db.delete(hosts).where(eq(hosts.userId, userId));
await db
.delete(sshCredentials)
.where(eq(sshCredentials.userId, userId));
@@ -4126,4 +4115,115 @@ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => {
}
});
/**
* @openapi
* /users/guacamole-settings:
* get:
* summary: Get Guacamole settings
* description: Returns current guacd enabled status and host:port URL. No authentication required.
* tags:
* - Users
* responses:
* 200:
* description: Guacamole settings.
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* url:
* type: string
* 500:
* description: Failed to get guacamole settings.
*/
router.get("/guacamole-settings", async (req, res) => {
try {
const enabledRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
.get() as { value: string } | undefined;
const urlRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
.get() as { value: string } | undefined;
res.json({
enabled: enabledRow ? enabledRow.value !== "false" : true,
url: urlRow ? urlRow.value : "guacd:4822",
});
} catch (err) {
authLogger.error("Failed to get guacamole settings", err);
res.status(500).json({ error: "Failed to get guacamole settings" });
}
});
/**
* @openapi
* /users/guacamole-settings:
* patch:
* summary: Update Guacamole settings
* description: Admin-only. Updates guacd enabled status and/or host:port URL.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* url:
* type: string
* responses:
* 200:
* description: Guacamole settings updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update guacamole settings.
*/
router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const { enabled, url } = req.body;
if (typeof enabled === "boolean") {
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_enabled', ?)",
)
.run(enabled ? "true" : "false");
}
if (typeof url === "string") {
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_url', ?)",
)
.run(url);
try {
await restartGuacServer();
} catch (err) {
authLogger.error("Failed to restart guac server after URL update", err);
}
}
const enabledRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
.get() as { value: string } | undefined;
const urlRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
.get() as { value: string } | undefined;
res.json({
enabled: enabledRow ? enabledRow.value !== "false" : true,
url: urlRow ? urlRow.value : "guacd:4822",
});
} catch (err) {
authLogger.error("Failed to update guacamole settings", err);
res.status(500).json({ error: "Failed to update guacamole settings" });
}
});
export default router;