mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
v2.0.0 (#621)
* 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:
@@ -4,7 +4,7 @@ import cookieParser from "cookie-parser";
|
||||
import { getDb, DatabaseSaveTrigger } from "./database/db/index.js";
|
||||
import {
|
||||
recentActivity,
|
||||
sshData,
|
||||
hosts,
|
||||
hostAccess,
|
||||
dashboardPreferences,
|
||||
} from "./database/db/schema.js";
|
||||
@@ -176,7 +176,7 @@ app.get("/activity/recent", async (req, res) => {
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [terminal, file_manager, server_stats, tunnel, docker]
|
||||
* enum: [terminal, file_manager, server_stats, tunnel, docker, telnet, vnc, rdp]
|
||||
* hostId:
|
||||
* type: integer
|
||||
* hostName:
|
||||
@@ -219,11 +219,14 @@ app.post("/activity/log", async (req, res) => {
|
||||
"server_stats",
|
||||
"tunnel",
|
||||
"docker",
|
||||
"telnet",
|
||||
"vnc",
|
||||
"rdp",
|
||||
].includes(type)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', or 'docker'",
|
||||
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', 'docker', 'telnet', 'vnc', or 'rdp'",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -252,8 +255,8 @@ app.post("/activity/log", async (req, res) => {
|
||||
const ownedHosts = 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,
|
||||
);
|
||||
|
||||
@@ -3,11 +3,12 @@ import bodyParser from "body-parser";
|
||||
import multer from "multer";
|
||||
import cookieParser from "cookie-parser";
|
||||
import userRoutes from "./routes/users.js";
|
||||
import sshRoutes from "./routes/ssh.js";
|
||||
import hostRoutes from "./routes/host.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import snippetsRoutes from "./routes/snippets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import guacamoleRoutes from "../guacamole/routes.js";
|
||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import cors from "cors";
|
||||
@@ -28,7 +29,7 @@ import { parseUserAgent } from "../utils/user-agent-parser.js";
|
||||
import { getProxyAgent } from "../utils/proxy-agent.js";
|
||||
import {
|
||||
users,
|
||||
sshData,
|
||||
hosts,
|
||||
sshCredentials,
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
@@ -846,8 +847,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
|
||||
const sshHosts = await getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(eq(sshData.userId, userId));
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
const insertHost = exportDb.prepare(`
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -1267,13 +1268,13 @@ app.post(
|
||||
try {
|
||||
const existing = await mainDb
|
||||
.select()
|
||||
.from(sshData)
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.userId, userId),
|
||||
eq(sshData.ip, host.ip),
|
||||
eq(sshData.port, host.port),
|
||||
eq(sshData.username, host.username),
|
||||
eq(hosts.userId, userId),
|
||||
eq(hosts.ip, host.ip),
|
||||
eq(hosts.port, host.port),
|
||||
eq(hosts.username, host.username),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1341,7 +1342,7 @@ app.post(
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
await mainDb.insert(sshData).values(encrypted);
|
||||
await mainDb.insert(hosts).values(encrypted);
|
||||
result.summary.sshHostsImported++;
|
||||
} catch (hostError) {
|
||||
result.summary.errors.push(
|
||||
@@ -1759,11 +1760,12 @@ app.post("/database/restore", requireAdmin, async (req, res) => {
|
||||
});
|
||||
|
||||
app.use("/users", userRoutes);
|
||||
app.use("/ssh", sshRoutes);
|
||||
app.use("/host", hostRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/snippets", snippetsRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/guacamole", guacamoleRoutes);
|
||||
app.use("/network-topology", networkTopologyRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
|
||||
|
||||
@@ -427,9 +427,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
`);
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("DELETE FROM sessions WHERE expires_at < datetime('now')")
|
||||
.run();
|
||||
sqlite.prepare("DELETE FROM sessions").run();
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not clear expired sessions on startup", {
|
||||
operation: "db_init_session_cleanup_failed",
|
||||
@@ -474,6 +472,42 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('guac_enabled', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize guac_enabled setting", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('guac_url', 'guacd:4822')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize guac_url setting", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const addColumnIfNotExists = (
|
||||
@@ -591,6 +625,11 @@ const migrateSchema = () => {
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "docker_config", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
|
||||
addColumnIfNotExists("ssh_data", "domain", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "security", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "ignore_cert", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("ssh_data", "guacamole_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "notes", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER");
|
||||
|
||||
@@ -64,11 +64,12 @@ export const trustedDevices = sqliteTable("trusted_devices", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshData = sqliteTable("ssh_data", {
|
||||
export const hosts = sqliteTable("ssh_data", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
connectionType: text("connection_type").notNull().default("ssh"),
|
||||
name: text("name"),
|
||||
ip: text("ip").notNull(),
|
||||
port: integer("port").notNull(),
|
||||
@@ -124,9 +125,14 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
.default(false),
|
||||
defaultPath: text("default_path"),
|
||||
statsConfig: text("stats_config"),
|
||||
dockerConfig: text("docker_config"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
quickActions: text("quick_actions"),
|
||||
notes: text("notes"),
|
||||
domain: text("domain"),
|
||||
security: text("security"),
|
||||
ignoreCert: integer("ignore_cert", { mode: "boolean" }).default(false),
|
||||
guacamoleConfig: text("guacamole_config"),
|
||||
|
||||
useSocks5: integer("use_socks5", { mode: "boolean" }),
|
||||
socks5Host: text("socks5_host"),
|
||||
@@ -157,7 +163,7 @@ export const fileManagerRecent = sqliteTable("file_manager_recent", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
lastOpened: text("last_opened")
|
||||
@@ -172,7 +178,7 @@ export const fileManagerPinned = sqliteTable("file_manager_pinned", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
pinnedAt: text("pinned_at")
|
||||
@@ -187,7 +193,7 @@ export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
createdAt: text("created_at")
|
||||
@@ -246,7 +252,7 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
@@ -313,7 +319,7 @@ export const recentActivity = sqliteTable("recent_activity", {
|
||||
type: text("type").notNull(),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
hostName: text("host_name"),
|
||||
timestamp: text("timestamp")
|
||||
.notNull()
|
||||
@@ -327,7 +333,7 @@ export const commandHistory = sqliteTable("command_history", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
command: text("command").notNull(),
|
||||
executedAt: text("executed_at")
|
||||
.notNull()
|
||||
@@ -367,7 +373,7 @@ export const hostAccess = sqliteTable("host_access", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
|
||||
userId: text("user_id")
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
@@ -492,7 +498,7 @@ export const sessionRecordings = sqliteTable("session_recordings", {
|
||||
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
@@ -523,7 +529,7 @@ export const opksshTokens = sqliteTable("opkssh_tokens", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
|
||||
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
|
||||
privateKey: text("private_key", { length: 8192 }).notNull(),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import GuacamoleLite from "guacamole-lite";
|
||||
import { parse as parseUrl } from "url";
|
||||
import { guacLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import type { IncomingMessage } from "http";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
function parseGuacUrl(url: string): { host: string; port: number } {
|
||||
const parts = url.split(":");
|
||||
return {
|
||||
host: parts[0] || "localhost",
|
||||
port: parseInt(parts[1] || "4822", 10),
|
||||
};
|
||||
}
|
||||
|
||||
function readGuacdOptions(): { host: string; port: number } {
|
||||
let host = process.env.GUACD_HOST || "localhost";
|
||||
let port = parseInt(process.env.GUACD_PORT || "4822", 10);
|
||||
try {
|
||||
const db = getDb();
|
||||
const urlRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get() as { value: string } | undefined;
|
||||
if (urlRow?.value) {
|
||||
const parsed = parseGuacUrl(urlRow.value);
|
||||
host = parsed.host;
|
||||
port = parsed.port;
|
||||
}
|
||||
} catch {
|
||||
// DB not available yet, use env var defaults
|
||||
}
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
const GUAC_WS_PORT = 30008;
|
||||
|
||||
const websocketOptions = {
|
||||
port: GUAC_WS_PORT,
|
||||
};
|
||||
|
||||
const clientOptions = {
|
||||
crypt: {
|
||||
cypher: "AES-256-CBC",
|
||||
key: tokenService.getEncryptionKey(),
|
||||
},
|
||||
log: {
|
||||
level: "ERRORS",
|
||||
stdLog: (...args: unknown[]) => {
|
||||
guacLogger.info(args.join(" "));
|
||||
},
|
||||
errorLog: (...args: unknown[]) => {
|
||||
guacLogger.error(args.join(" "));
|
||||
},
|
||||
},
|
||||
allowedUnencryptedConnectionSettings: {
|
||||
rdp: ["width", "height", "dpi"],
|
||||
vnc: ["width", "height", "dpi"],
|
||||
telnet: ["width", "height"],
|
||||
},
|
||||
connectionDefaultSettings: {
|
||||
rdp: {
|
||||
security: "any",
|
||||
"ignore-cert": true,
|
||||
"enable-wallpaper": false,
|
||||
"enable-font-smoothing": true,
|
||||
"enable-desktop-composition": false,
|
||||
"disable-audio": false,
|
||||
"enable-drive": false,
|
||||
"resize-method": "display-update",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
dpi: 96,
|
||||
},
|
||||
vnc: {
|
||||
"swap-red-blue": false,
|
||||
cursor: "remote",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
telnet: {
|
||||
"terminal-type": "xterm-256color",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const _origConsoleLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
const msg = args[0];
|
||||
if (typeof msg === "string" && msg.startsWith("New client connection"))
|
||||
return;
|
||||
_origConsoleLog(...args);
|
||||
};
|
||||
|
||||
function createGuacServer(): GuacamoleLite {
|
||||
const guacdOptions = readGuacdOptions();
|
||||
const server = new GuacamoleLite(
|
||||
websocketOptions,
|
||||
guacdOptions,
|
||||
clientOptions,
|
||||
);
|
||||
|
||||
server.on(
|
||||
"open",
|
||||
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||
guacLogger.info("Guacamole connection opened", {
|
||||
operation: "guac_connection_open",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
server.on(
|
||||
"close",
|
||||
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||
guacLogger.info("Guacamole connection closed", {
|
||||
operation: "guac_connection_close",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
server.on(
|
||||
"error",
|
||||
(
|
||||
clientConnection: { connectionSettings?: Record<string, unknown> },
|
||||
error: Error,
|
||||
) => {
|
||||
guacLogger.error("Guacamole connection error", error, {
|
||||
operation: "guac_connection_error",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
let guacServer = createGuacServer();
|
||||
|
||||
export async function restartGuacServer(): Promise<void> {
|
||||
try {
|
||||
guacServer.close();
|
||||
} catch (err) {
|
||||
guacLogger.error("Error closing guac server during restart", err as Error);
|
||||
}
|
||||
guacServer = createGuacServer();
|
||||
}
|
||||
|
||||
export { guacServer, tokenService };
|
||||
@@ -0,0 +1,303 @@
|
||||
import express from "express";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { guacLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
|
||||
const router = express.Router();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
router.use(authManager.createAuthMiddleware());
|
||||
|
||||
/**
|
||||
* POST /guacamole/token
|
||||
* Generate an encrypted connection token for guacamole-lite
|
||||
*
|
||||
* Body: {
|
||||
* type: "rdp" | "vnc" | "telnet",
|
||||
* hostname: string,
|
||||
* port?: number,
|
||||
* username?: string,
|
||||
* password?: string,
|
||||
* domain?: string,
|
||||
* // Additional protocol-specific options
|
||||
* }
|
||||
*/
|
||||
router.post("/token", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { type, hostname, port, username, password, domain, ...options } =
|
||||
req.body;
|
||||
|
||||
if (!type || !hostname) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing required fields: type and hostname" });
|
||||
}
|
||||
|
||||
if (!["rdp", "vnc", "telnet"].includes(type)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid connection type. Must be rdp, vnc, or telnet",
|
||||
});
|
||||
}
|
||||
|
||||
let token: string;
|
||||
|
||||
switch (type) {
|
||||
case "rdp":
|
||||
token = tokenService.createRdpToken(
|
||||
hostname,
|
||||
username || "",
|
||||
password || "",
|
||||
{
|
||||
port: port || 3389,
|
||||
domain,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "vnc":
|
||||
token = tokenService.createVncToken(hostname, password, {
|
||||
port: port || 5900,
|
||||
...options,
|
||||
});
|
||||
break;
|
||||
case "telnet":
|
||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||
port: port || 23,
|
||||
...options,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: "Invalid connection type" });
|
||||
}
|
||||
|
||||
res.json({ token });
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to generate guacamole token", error, {
|
||||
operation: "guac_token_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to generate connection token" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /guacamole/connect-host/{hostId}:
|
||||
* post:
|
||||
* summary: Generate Guacamole connection token from host configuration
|
||||
* description: Fetches host configuration from database and generates a connection token for RDP/VNC/Telnet
|
||||
* tags:
|
||||
* - Guacamole
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Host ID to connect to
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connection token generated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* token:
|
||||
* type: string
|
||||
* description: Encrypted connection token
|
||||
* 400:
|
||||
* description: Invalid request or unsupported connection type
|
||||
* 403:
|
||||
* description: Access denied to host
|
||||
* 404:
|
||||
* description: Host not found
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post(
|
||||
"/connect-host/:hostId",
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const hostId = parseInt(req.params.hostId, 10);
|
||||
|
||||
if (!hostId || isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hostResults.length === 0) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const host = hostResults[0];
|
||||
|
||||
if (host.userId !== userId) {
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"read",
|
||||
);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
guacLogger.warn("User attempted to access host without permission", {
|
||||
operation: "guac_access_denied",
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
return res.status(403).json({ error: "Access denied to this host" });
|
||||
}
|
||||
}
|
||||
|
||||
const connectionType = (host.connectionType as string) || "ssh";
|
||||
if (!["rdp", "vnc", "telnet"].includes(connectionType)) {
|
||||
return res.status(400).json({
|
||||
error: `Connection type '${connectionType}' is not supported for remote desktop. Only RDP, VNC, and Telnet are supported.`,
|
||||
});
|
||||
}
|
||||
|
||||
let guacConfig: Record<string, unknown> = {};
|
||||
if (host.guacamoleConfig) {
|
||||
try {
|
||||
guacConfig =
|
||||
typeof host.guacamoleConfig === "string"
|
||||
? JSON.parse(host.guacamoleConfig as string)
|
||||
: (host.guacamoleConfig as Record<string, unknown>);
|
||||
} catch (error) {
|
||||
guacLogger.warn("Failed to parse guacamole config", {
|
||||
operation: "guac_config_parse_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let token: string;
|
||||
const hostname = host.ip as string;
|
||||
const port = host.port as number;
|
||||
const username = (host.username as string) || "";
|
||||
const password = (host.password as string) || "";
|
||||
const domain = (host.domain as string) || "";
|
||||
|
||||
switch (connectionType) {
|
||||
case "rdp":
|
||||
token = tokenService.createRdpToken(hostname, username, password, {
|
||||
port: port || 3389,
|
||||
domain,
|
||||
security: (host.security as string) || undefined,
|
||||
"ignore-cert": (host.ignoreCert as boolean) || false,
|
||||
...guacConfig,
|
||||
});
|
||||
break;
|
||||
case "vnc":
|
||||
token = tokenService.createVncToken(hostname, password, {
|
||||
port: port || 5900,
|
||||
...guacConfig,
|
||||
});
|
||||
break;
|
||||
case "telnet":
|
||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||
port: port || 23,
|
||||
...guacConfig,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: "Invalid connection type" });
|
||||
}
|
||||
|
||||
res.json({ token });
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to generate guacamole token for host", error, {
|
||||
operation: "guac_host_token_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to generate connection token" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /guacamole/status
|
||||
* Check if guacd is reachable
|
||||
*/
|
||||
router.get("/status", async (req, res) => {
|
||||
try {
|
||||
let guacdHost = process.env.GUACD_HOST || "localhost";
|
||||
let guacdPort = parseInt(process.env.GUACD_PORT || "4822", 10);
|
||||
try {
|
||||
const db = getDb();
|
||||
const urlRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get() as { value: string } | undefined;
|
||||
if (urlRow?.value) {
|
||||
const parts = urlRow.value.split(":");
|
||||
guacdHost = parts[0] || guacdHost;
|
||||
guacdPort = parseInt(parts[1] || String(guacdPort), 10);
|
||||
}
|
||||
} catch {
|
||||
// Fall back to env vars
|
||||
}
|
||||
|
||||
const net = await import("net");
|
||||
|
||||
const checkConnection = (): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
socket.setTimeout(3000);
|
||||
|
||||
socket.on("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.connect(guacdPort, guacdHost);
|
||||
});
|
||||
};
|
||||
|
||||
const isConnected = await checkConnection();
|
||||
|
||||
res.json({
|
||||
guacd: {
|
||||
host: guacdHost,
|
||||
port: guacdPort,
|
||||
status: isConnected ? "connected" : "disconnected",
|
||||
},
|
||||
websocket: {
|
||||
port: 30008,
|
||||
status: "running",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to check guacamole status", error, {
|
||||
operation: "guac_status_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to check status" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,181 @@
|
||||
import crypto from "crypto";
|
||||
import { guacLogger } from "../utils/logger.js";
|
||||
|
||||
export interface GuacamoleConnectionSettings {
|
||||
type: "rdp" | "vnc" | "telnet";
|
||||
settings: {
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
security?: string;
|
||||
"ignore-cert"?: boolean;
|
||||
"enable-wallpaper"?: boolean;
|
||||
"enable-drive"?: boolean;
|
||||
"drive-path"?: string;
|
||||
"create-drive-path"?: boolean;
|
||||
"swap-red-blue"?: boolean;
|
||||
cursor?: string;
|
||||
"terminal-type"?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuacamoleToken {
|
||||
connection: GuacamoleConnectionSettings;
|
||||
}
|
||||
|
||||
const CIPHER = "aes-256-cbc";
|
||||
const KEY_LENGTH = 32;
|
||||
|
||||
export class GuacamoleTokenService {
|
||||
private static instance: GuacamoleTokenService;
|
||||
private encryptionKey: Buffer;
|
||||
|
||||
private constructor() {
|
||||
this.encryptionKey = this.initializeKey();
|
||||
}
|
||||
|
||||
static getInstance(): GuacamoleTokenService {
|
||||
if (!GuacamoleTokenService.instance) {
|
||||
GuacamoleTokenService.instance = new GuacamoleTokenService();
|
||||
}
|
||||
return GuacamoleTokenService.instance;
|
||||
}
|
||||
|
||||
private initializeKey(): Buffer {
|
||||
const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY;
|
||||
if (existingKey) {
|
||||
if (existingKey.length === 64 && /^[0-9a-fA-F]+$/.test(existingKey)) {
|
||||
return Buffer.from(existingKey, "hex");
|
||||
}
|
||||
if (existingKey.length === KEY_LENGTH) {
|
||||
return Buffer.from(existingKey, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
if (jwtSecret) {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(jwtSecret + "_guacamole")
|
||||
.digest();
|
||||
}
|
||||
|
||||
guacLogger.warn(
|
||||
"No persistent encryption key found, generating random key",
|
||||
{
|
||||
operation: "guac_key_generation",
|
||||
},
|
||||
);
|
||||
return crypto.randomBytes(KEY_LENGTH);
|
||||
}
|
||||
|
||||
getEncryptionKey(): Buffer {
|
||||
return this.encryptionKey;
|
||||
}
|
||||
|
||||
encryptToken(tokenObject: GuacamoleToken): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(CIPHER, this.encryptionKey, iv);
|
||||
|
||||
let encrypted = cipher.update(
|
||||
JSON.stringify(tokenObject),
|
||||
"utf8",
|
||||
"base64",
|
||||
);
|
||||
encrypted += cipher.final("base64");
|
||||
|
||||
const data = {
|
||||
iv: iv.toString("base64"),
|
||||
value: encrypted,
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(data)).toString("base64");
|
||||
}
|
||||
|
||||
decryptToken(token: string): GuacamoleToken | null {
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
|
||||
const iv = Buffer.from(data.iv, "base64");
|
||||
const decipher = crypto.createDecipheriv(CIPHER, this.encryptionKey, iv);
|
||||
|
||||
let decrypted = decipher.update(data.value, "base64", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return JSON.parse(decrypted) as GuacamoleToken;
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to decrypt guacamole token", error, {
|
||||
operation: "guac_token_decrypt_error",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
createRdpToken(
|
||||
hostname: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
||||
): string {
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "rdp",
|
||||
settings: {
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
port: 3389,
|
||||
security: "nla",
|
||||
"ignore-cert": true,
|
||||
...options,
|
||||
},
|
||||
},
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
createVncToken(
|
||||
hostname: string,
|
||||
password?: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
||||
): string {
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "vnc",
|
||||
settings: {
|
||||
hostname,
|
||||
password,
|
||||
port: 5900,
|
||||
...options,
|
||||
},
|
||||
},
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
createTelnetToken(
|
||||
hostname: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
||||
): string {
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "telnet",
|
||||
settings: {
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
port: 23,
|
||||
...options,
|
||||
},
|
||||
},
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
+29
-3
@@ -112,9 +112,8 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
await authManager.initialize();
|
||||
DataCrypto.initialize();
|
||||
|
||||
const { OPKSSHBinaryManager } = await import(
|
||||
"./utils/opkssh-binary-manager.js"
|
||||
);
|
||||
const { OPKSSHBinaryManager } =
|
||||
await import("./utils/opkssh-binary-manager.js");
|
||||
try {
|
||||
await OPKSSHBinaryManager.ensureBinary();
|
||||
} catch (error) {
|
||||
@@ -143,6 +142,33 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
await import("./ssh/docker-console.js");
|
||||
await import("./dashboard.js");
|
||||
|
||||
// Initialize Guacamole server for RDP/VNC/Telnet support
|
||||
const { getDb: getDbForGuac } = await import("./database/db/index.js");
|
||||
const guacDb = getDbForGuac();
|
||||
const guacEnabledRow = guacDb.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
||||
.get() as { value: string } | undefined;
|
||||
const guacEnabled = guacEnabledRow
|
||||
? guacEnabledRow.value !== "false"
|
||||
: true;
|
||||
|
||||
if (process.env.ENABLE_GUACAMOLE !== "false" && guacEnabled) {
|
||||
try {
|
||||
await import("./guacamole/guacamole-server.js");
|
||||
systemLogger.info("Guacamole server initialized", {
|
||||
operation: "guac_init",
|
||||
});
|
||||
} catch (error) {
|
||||
systemLogger.warn(
|
||||
"Failed to initialize Guacamole server (guacd may not be available)",
|
||||
{
|
||||
operation: "guac_init_skip",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
systemLogger.success("Termix backend started successfully", {
|
||||
operation: "backend_init_complete",
|
||||
port: process.env.PORT || 4090,
|
||||
|
||||
@@ -254,5 +254,6 @@ export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
|
||||
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||
export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899");
|
||||
export const guacLogger = new Logger("GUACAMOLE", "🖼️", "#ff6b6b");
|
||||
|
||||
export const logger = systemLogger;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
hostAccess,
|
||||
roles,
|
||||
userRoles,
|
||||
sshData,
|
||||
hosts,
|
||||
users,
|
||||
} from "../database/db/schema.js";
|
||||
import { eq, and, or, isNull, gte, sql } from "drizzle-orm";
|
||||
@@ -167,8 +167,8 @@ class PermissionManager {
|
||||
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) {
|
||||
@@ -210,9 +210,9 @@ class PermissionManager {
|
||||
const access = sharedAccess[0];
|
||||
|
||||
const hostOwnerCheck = await db
|
||||
.select({ ownerId: sshData.userId })
|
||||
.from(sshData)
|
||||
.where(eq(sshData.id, hostId))
|
||||
.select({ ownerId: hosts.userId })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
|
||||
if (hostOwnerCheck.length > 0 && hostOwnerCheck[0].ownerId === userId) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
sshCredentials,
|
||||
hostAccess,
|
||||
userRoles,
|
||||
sshData,
|
||||
hosts,
|
||||
} from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
@@ -190,15 +190,27 @@ class SharedCredentialManager {
|
||||
const cred = sharedCred[0].shared_credentials;
|
||||
|
||||
if (cred.needsReEncryption) {
|
||||
databaseLogger.warn(
|
||||
"Shared credential needs re-encryption but cannot be accessed yet",
|
||||
{
|
||||
operation: "get_shared_credential_pending",
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
return null;
|
||||
await this.reEncryptSharedCredential(cred.id, userId);
|
||||
|
||||
const refreshed = await db
|
||||
.select()
|
||||
.from(sharedCredentials)
|
||||
.where(eq(sharedCredentials.id, cred.id))
|
||||
.limit(1);
|
||||
|
||||
if (refreshed.length === 0 || refreshed[0].needsReEncryption) {
|
||||
databaseLogger.warn(
|
||||
"Shared credential needs re-encryption but cannot be accessed yet",
|
||||
{
|
||||
operation: "get_shared_credential_pending",
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.decryptSharedCredential(refreshed[0], userDEK);
|
||||
}
|
||||
|
||||
return this.decryptSharedCredential(cred, userDEK);
|
||||
@@ -587,7 +599,7 @@ class SharedCredentialManager {
|
||||
const access = await db
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
|
||||
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
|
||||
.where(eq(hostAccess.id, cred.hostAccessId))
|
||||
.limit(1);
|
||||
|
||||
|
||||
@@ -23,7 +23,18 @@ export function detectPlatform(req: Request): DeviceType {
|
||||
return "mobile";
|
||||
}
|
||||
|
||||
if (userAgent.includes("Android")) {
|
||||
const isDesktopOS =
|
||||
userAgent.includes("Windows") ||
|
||||
userAgent.includes("Macintosh") ||
|
||||
userAgent.includes("Mac OS X") ||
|
||||
userAgent.includes("X11") ||
|
||||
userAgent.includes("Linux x86_64");
|
||||
|
||||
if (
|
||||
(userAgent.includes("Android") && !isDesktopOS) ||
|
||||
userAgent.includes("iPhone") ||
|
||||
userAgent.includes("iPad")
|
||||
) {
|
||||
return "mobile";
|
||||
}
|
||||
|
||||
|
||||
@@ -303,9 +303,8 @@ class UserCrypto {
|
||||
await this.storeKEKSalt(userId, newKekSalt);
|
||||
await this.storeEncryptedDEK(userId, newEncryptedDEK);
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
oldKEK.fill(0);
|
||||
@@ -341,9 +340,8 @@ class UserCrypto {
|
||||
await this.storeKEKSalt(userId, newKekSalt);
|
||||
await this.storeEncryptedDEK(userId, newEncryptedDEK);
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
newKEK.fill(0);
|
||||
@@ -418,9 +416,8 @@ class UserCrypto {
|
||||
},
|
||||
);
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to convert to OIDC encryption", error, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import {
|
||||
users,
|
||||
sshData,
|
||||
hosts,
|
||||
sshCredentials,
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
@@ -74,8 +74,8 @@ class UserDataExport {
|
||||
|
||||
const sshHosts = await getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(eq(sshData.userId, userId));
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
const processedSshHosts =
|
||||
format === "plaintext" && userDataKey
|
||||
? sshHosts.map((host) =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import {
|
||||
users,
|
||||
sshData,
|
||||
hosts,
|
||||
sshCredentials,
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
@@ -179,13 +179,13 @@ class UserDataImport {
|
||||
|
||||
const existing = await getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.userId, targetUserId),
|
||||
eq(sshData.ip, host.ip as string),
|
||||
eq(sshData.port, host.port as number),
|
||||
eq(sshData.username, host.username as string),
|
||||
eq(hosts.userId, targetUserId),
|
||||
eq(hosts.ip, host.ip as string),
|
||||
eq(hosts.port, host.port as number),
|
||||
eq(hosts.username, host.username as string),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -218,15 +218,13 @@ class UserDataImport {
|
||||
|
||||
if (existing.length > 0 && options.replaceExisting) {
|
||||
await getDb()
|
||||
.update(sshData)
|
||||
.set(processedHostData as unknown as typeof sshData.$inferInsert)
|
||||
.where(eq(sshData.id, existing[0].id));
|
||||
.update(hosts)
|
||||
.set(processedHostData as unknown as typeof hosts.$inferInsert)
|
||||
.where(eq(hosts.id, existing[0].id));
|
||||
} else {
|
||||
await getDb()
|
||||
.insert(sshData)
|
||||
.values(
|
||||
processedHostData as unknown as typeof sshData.$inferInsert,
|
||||
);
|
||||
.insert(hosts)
|
||||
.values(processedHostData as unknown as typeof hosts.$inferInsert);
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
|
||||
@@ -37,8 +37,7 @@ const buttonVariants = cva(
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ComponentProps<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
extends React.ComponentProps<"button">, VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
|
||||
+126
-2
@@ -810,6 +810,15 @@
|
||||
"globalSettingsSaved": "Global monitoring settings saved",
|
||||
"failedToSaveGlobalSettings": "Failed to save global monitoring settings",
|
||||
"failedToLoadGlobalSettings": "Failed to load global monitoring settings",
|
||||
"guacamoleIntegration": "Remote Desktop Integration (Guacamole)",
|
||||
"guacamoleIntegrationDesc": "Enable RDP, VNC, and Telnet connections via guacd. Requires a guacd instance to be running.",
|
||||
"enableGuacamole": "Enable RDP/VNC/Telnet support",
|
||||
"guacdUrl": "guacd URL (host:port)",
|
||||
"guacdUrlPlaceholder": "guacd:4822",
|
||||
"guacdUrlNote": "Changes to the guacd host/port require a server restart to take effect.",
|
||||
"guacamoleSettingsSaved": "Guacamole settings saved",
|
||||
"failedToSaveGuacamoleSettings": "Failed to save guacamole settings",
|
||||
"failedToLoadGuacamoleSettings": "Failed to load guacamole settings",
|
||||
"clampedToValidRange": "was adjusted to valid range",
|
||||
"sessionManagement": "Session Management",
|
||||
"loadingSessions": "Loading sessions...",
|
||||
@@ -875,11 +884,21 @@
|
||||
"importError": "Import error",
|
||||
"failedToImportJson": "Failed to import JSON file",
|
||||
"connectionDetails": "Connection Details",
|
||||
"connectionType": "Connection Type",
|
||||
"ssh": "SSH",
|
||||
"rdp": "Remote Desktop (RDP)",
|
||||
"vnc": "VNC",
|
||||
"telnet": "Telnet",
|
||||
"remoteDesktop": "Remote Desktop",
|
||||
"guacamoleSettings": "Remote Desktop Settings",
|
||||
"organization": "Organization",
|
||||
"ipAddress": "IP Address or Hostname",
|
||||
"ipRequired": "IP address is required",
|
||||
"portRequired": "Port is required",
|
||||
"port": "Port",
|
||||
"name": "Name",
|
||||
"username": "Username",
|
||||
"usernameRequired": "Username is required (SSH/Telnet only)",
|
||||
"folder": "Folder",
|
||||
"tags": "Tags",
|
||||
"pin": "Pin",
|
||||
@@ -988,6 +1007,8 @@
|
||||
"tunnel": "Tunnel",
|
||||
"fileManager": "File Manager",
|
||||
"serverStats": "Server Stats",
|
||||
"status": "Status",
|
||||
"statistics": "Statistics",
|
||||
"hostViewer": "Host Viewer",
|
||||
"enableServerStats": "Enable Server Stats",
|
||||
"enableServerStatsDesc": "Enable/disable server statistics collection for this host",
|
||||
@@ -1049,13 +1070,18 @@
|
||||
"statusCheckEnabledDesc": "Check if the server is online or offline",
|
||||
"statusCheckInterval": "Status Check Interval",
|
||||
"statusCheckIntervalDesc": "How often to check if host is online (5s - 1h)",
|
||||
"statusChecks": "Status Checks",
|
||||
"disableTcpPing": "Disable TCP Ping",
|
||||
"disableTcpPingDescription": "Turn off status checks (online/offline detection) for this host",
|
||||
"useGlobalStatusInterval": "Use global default",
|
||||
"useGlobalMetricsInterval": "Use global default",
|
||||
"usingGlobalDefault": "Using global default ({{value}}s)",
|
||||
"metricsCollection": "Metrics Collection",
|
||||
"metricsEnabled": "Enable Metrics Monitoring",
|
||||
"metricsEnabledDesc": "Collect CPU, RAM, disk, and other system statistics",
|
||||
"metricsInterval": "Metrics Collection Interval",
|
||||
"metricsIntervalDesc": "How often to collect server statistics (5s - 1h)",
|
||||
"metricsNotAvailableForConnectionType": "Server metrics are only available for SSH hosts. TCP ping status checks can still be enabled above.",
|
||||
"intervalSeconds": "seconds",
|
||||
"intervalMinutes": "minutes",
|
||||
"intervalValidation": "Monitoring intervals must be between 5 seconds and 1 hour (3600 seconds)",
|
||||
@@ -1243,6 +1269,7 @@
|
||||
"copyTunnelUrl": "Copy Tunnel URL",
|
||||
"copyServerStatsUrl": "Copy Server Stats URL",
|
||||
"copyDockerUrl": "Copy Docker URL",
|
||||
"copyRemoteDesktopUrl": "Copy Remote Desktop URL",
|
||||
"fullScreenUrlTooltip": "Copy URL to open this app in full-screen mode",
|
||||
"notEnabled": "Docker is not enabled for this host. Enable it in Host Settings to use Docker features.",
|
||||
"validating": "Validating Docker...",
|
||||
@@ -1361,7 +1388,103 @@
|
||||
"selectAll": "Select all",
|
||||
"deselectAll": "Deselect all",
|
||||
"useGlobalStatusDefault": "Use Global Default (Status)",
|
||||
"useGlobalMetricsDefault": "Use Global Default (Metrics)"
|
||||
"useGlobalMetricsDefault": "Use Global Default (Metrics)",
|
||||
"connectionType": "Connection Type",
|
||||
"rdp": "RDP",
|
||||
"vnc": "VNC",
|
||||
"telnet": "Telnet",
|
||||
"ssh": "SSH",
|
||||
"remoteDesktop": "Remote Desktop",
|
||||
"remoteDesktopSettings": "Remote Desktop Settings",
|
||||
"domain": "Domain",
|
||||
"securityMode": "Security Mode",
|
||||
"ignoreCert": "Ignore Certificate",
|
||||
"ignoreCertDesc": "Skip TLS certificate verification for this connection",
|
||||
"displaySettings": "Display Settings",
|
||||
"colorDepth": "Color Depth",
|
||||
"width": "Width",
|
||||
"height": "Height",
|
||||
"dpi": "DPI",
|
||||
"resizeMethod": "Resize Method",
|
||||
"forceLossless": "Force Lossless",
|
||||
"audioSettings": "Audio Settings",
|
||||
"disableAudio": "Disable Audio",
|
||||
"enableAudioInput": "Enable Audio Input",
|
||||
"rdpPerformance": "Performance",
|
||||
"enableWallpaper": "Enable Wallpaper",
|
||||
"enableTheming": "Enable Theming",
|
||||
"enableFontSmoothing": "Enable Font Smoothing",
|
||||
"enableFullWindowDrag": "Enable Full Window Drag",
|
||||
"enableDesktopComposition": "Enable Desktop Composition",
|
||||
"enableMenuAnimations": "Enable Menu Animations",
|
||||
"disableBitmapCaching": "Disable Bitmap Caching",
|
||||
"disableOffscreenCaching": "Disable Offscreen Caching",
|
||||
"disableGlyphCaching": "Disable Glyph Caching",
|
||||
"enableGfx": "Enable GFX",
|
||||
"deviceRedirection": "Device Redirection",
|
||||
"enablePrinting": "Enable Printing",
|
||||
"enableDrive": "Enable Drive Redirection",
|
||||
"driveName": "Drive Name",
|
||||
"drivePath": "Drive Path",
|
||||
"createDrivePath": "Create Drive Path",
|
||||
"disableDownload": "Disable Download",
|
||||
"disableUpload": "Disable Upload",
|
||||
"enableTouch": "Enable Touch",
|
||||
"rdpSession": "Session",
|
||||
"clientName": "Client Name",
|
||||
"consoleSession": "Console Session",
|
||||
"initialProgram": "Initial Program",
|
||||
"serverLayout": "Server Keyboard Layout",
|
||||
"timezone": "Timezone",
|
||||
"gatewaySettings": "Gateway",
|
||||
"gatewayHostname": "Gateway Hostname",
|
||||
"gatewayPort": "Gateway Port",
|
||||
"gatewayUsername": "Gateway Username",
|
||||
"gatewayPassword": "Gateway Password",
|
||||
"gatewayDomain": "Gateway Domain",
|
||||
"remoteApp": "RemoteApp",
|
||||
"remoteAppProgram": "Remote Application",
|
||||
"remoteAppDir": "Remote App Directory",
|
||||
"remoteAppArgs": "Remote App Arguments",
|
||||
"clipboardSettings": "Clipboard",
|
||||
"normalizeClipboard": "Normalize Clipboard",
|
||||
"disableCopy": "Disable Copy",
|
||||
"disablePaste": "Disable Paste",
|
||||
"vncSettings": "VNC Settings",
|
||||
"cursorMode": "Cursor Mode",
|
||||
"swapRedBlue": "Swap Red/Blue",
|
||||
"readOnly": "Read Only",
|
||||
"recordingSettings": "Recording",
|
||||
"recordingPath": "Recording Path",
|
||||
"recordingName": "Recording Name",
|
||||
"createRecordingPath": "Create Recording Path",
|
||||
"excludeOutput": "Exclude Output",
|
||||
"excludeMouse": "Exclude Mouse",
|
||||
"includeKeys": "Include Keys",
|
||||
"wakeOnLan": "Wake-on-LAN",
|
||||
"sendWolPacket": "Send WoL Packet",
|
||||
"wolMacAddr": "MAC Address",
|
||||
"wolBroadcastAddr": "Broadcast Address",
|
||||
"wolUdpPort": "UDP Port",
|
||||
"wolWaitTime": "Wait Time (seconds)",
|
||||
"connectionSettings": "Connection Settings",
|
||||
"rdpOnly": "RDP only",
|
||||
"vncOnly": "VNC only",
|
||||
"telnetTerminalSettings": "Terminal Settings",
|
||||
"terminalType": "Terminal Type",
|
||||
"guacFontName": "Font Name",
|
||||
"guacFontSize": "Font Size",
|
||||
"guacColorScheme": "Color Scheme",
|
||||
"guacBackspaceKey": "Backspace Key"
|
||||
},
|
||||
"guacamole": {
|
||||
"connecting": "Connecting to {{type}} session...",
|
||||
"rdpConnecting": "Connecting to RDP server...",
|
||||
"vncConnecting": "Connecting to VNC server...",
|
||||
"telnetConnecting": "Connecting to Telnet server...",
|
||||
"connectionError": "Connection error",
|
||||
"connectionFailed": "Connection failed",
|
||||
"failedToConnect": "Failed to get connection token"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Split Screen",
|
||||
@@ -1408,6 +1531,7 @@
|
||||
"connected": "Connected",
|
||||
"clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost.",
|
||||
"clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.",
|
||||
"clipboardHttpWarning": "Paste requires HTTPS. Use Ctrl+Shift+V or serve Termix over HTTPS.",
|
||||
"sshConnected": "SSH connection established",
|
||||
"authError": "Authentication failed: {{message}}",
|
||||
"unknownError": "Unknown error occurred",
|
||||
@@ -2196,7 +2320,7 @@
|
||||
"terminalSyntaxHighlightingDesc": "Automatically highlight commands, paths, IPs, and log levels in terminal output",
|
||||
"enableCommandPaletteShortcut": "Enable Command Palette Shortcut",
|
||||
"enableCommandPaletteShortcutDesc": "Double-tap left Shift to open the Command Palette for quick access to hosts",
|
||||
"enableTerminalSessionPersistence": "Persistent Terminal Sessions",
|
||||
"enableTerminalSessionPersistence": "Persistent Tabs/Sessions",
|
||||
"enableTerminalSessionPersistenceDesc": "Maintain SSH connections when switching tabs or closing the browser (may be unstable)"
|
||||
},
|
||||
"user": {
|
||||
|
||||
+6
-1
@@ -8,12 +8,13 @@ import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
|
||||
import "./i18n/i18n";
|
||||
import { isElectron } from "./ui/main-axios.ts";
|
||||
import HostManagerApp from "./ui/desktop/apps/HostManagerApp.tsx";
|
||||
import HostManagerApp from "./ui/desktop/apps/host-manager/HostManagerApp.tsx";
|
||||
import TerminalApp from "./ui/desktop/apps/features/terminal/TerminalApp.tsx";
|
||||
import FileManagerApp from "./ui/desktop/apps/features/file-manager/FileManagerApp.tsx";
|
||||
import TunnelApp from "./ui/desktop/apps/features/tunnel/TunnelApp.tsx";
|
||||
import ServerStatsApp from "./ui/desktop/apps/features/server-stats/ServerStatsApp.tsx";
|
||||
import DockerApp from "./ui/desktop/apps/features/docker/DockerApp.tsx";
|
||||
import GuacamoleApp from "@/ui/desktop/apps/features/guacamole/GuacamoleApp.tsx";
|
||||
|
||||
const FullscreenApp: React.FC = () => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
@@ -33,6 +34,10 @@ const FullscreenApp: React.FC = () => {
|
||||
return <ServerStatsApp hostId={hostId || undefined} />;
|
||||
case "docker":
|
||||
return <DockerApp hostId={hostId || undefined} />;
|
||||
case "rdp":
|
||||
case "vnc":
|
||||
case "telnet":
|
||||
return <GuacamoleApp hostId={hostId || undefined} />;
|
||||
default:
|
||||
return <DesktopApp />;
|
||||
}
|
||||
|
||||
Vendored
+108
@@ -0,0 +1,108 @@
|
||||
declare module "guacamole-common-js" {
|
||||
namespace Guacamole {
|
||||
class Client {
|
||||
constructor(tunnel: Tunnel);
|
||||
connect(data?: string): void;
|
||||
disconnect(): void;
|
||||
getDisplay(): Display;
|
||||
sendKeyEvent(pressed: number, keysym: number): void;
|
||||
sendMouseState(state: Mouse.State): void;
|
||||
setClipboard(stream: OutputStream, mimetype: string): void;
|
||||
createClipboardStream(mimetype: string): OutputStream;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
onerror: ((error: Status) => void) | null;
|
||||
onclipboard: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
}
|
||||
|
||||
class Display {
|
||||
getElement(): HTMLElement;
|
||||
getWidth(): number;
|
||||
getHeight(): number;
|
||||
scale(scale: number): void;
|
||||
onresize: (() => void) | null;
|
||||
}
|
||||
|
||||
class Tunnel {
|
||||
onerror: ((status: Status) => void) | null;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
}
|
||||
|
||||
class WebSocketTunnel extends Tunnel {
|
||||
constructor(url: string);
|
||||
}
|
||||
|
||||
class Mouse {
|
||||
constructor(element: HTMLElement);
|
||||
onmousedown: ((state: Mouse.State) => void) | null;
|
||||
onmouseup: ((state: Mouse.State) => void) | null;
|
||||
onmousemove: ((state: Mouse.State) => void) | null;
|
||||
onmouseout: ((state: Mouse.State) => void) | null;
|
||||
}
|
||||
|
||||
namespace Mouse {
|
||||
class State {
|
||||
constructor(
|
||||
x: number,
|
||||
y: number,
|
||||
left?: boolean,
|
||||
middle?: boolean,
|
||||
right?: boolean,
|
||||
up?: boolean,
|
||||
down?: boolean,
|
||||
);
|
||||
constructor(state: {
|
||||
x: number;
|
||||
y: number;
|
||||
left?: boolean;
|
||||
middle?: boolean;
|
||||
right?: boolean;
|
||||
up?: boolean;
|
||||
down?: boolean;
|
||||
});
|
||||
x: number;
|
||||
y: number;
|
||||
left: boolean;
|
||||
middle: boolean;
|
||||
right: boolean;
|
||||
up: boolean;
|
||||
down: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
class Keyboard {
|
||||
constructor(element: Document | HTMLElement);
|
||||
onkeydown: ((keysym: number) => void) | null;
|
||||
onkeyup: ((keysym: number) => void) | null;
|
||||
}
|
||||
|
||||
class Status {
|
||||
code: number;
|
||||
message: string;
|
||||
isError(): boolean;
|
||||
}
|
||||
|
||||
class InputStream {
|
||||
onblob: ((data: string) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
|
||||
class OutputStream {
|
||||
sendBlob(data: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
|
||||
class StringReader {
|
||||
constructor(stream: InputStream);
|
||||
ontext: ((text: string) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
|
||||
class StringWriter {
|
||||
constructor(stream: OutputStream);
|
||||
sendText(text: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
}
|
||||
|
||||
export default Guacamole;
|
||||
}
|
||||
+36
-4
@@ -2,9 +2,21 @@ import type { Client } from "ssh2";
|
||||
import type { Request } from "express";
|
||||
|
||||
// ============================================================================
|
||||
// SSH HOST TYPES
|
||||
// HOST TYPES (SSH, RDP, VNC, Telnet)
|
||||
// ============================================================================
|
||||
|
||||
export type ConnectionType = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
export type SSHAuthType = "password" | "key" | "credential" | "none" | "opkssh";
|
||||
export type GuacamoleAuthType = "password" | "credential";
|
||||
|
||||
export interface HostFeatureFlags {
|
||||
enableTerminal: boolean; // SSH, Telnet only
|
||||
enableTunnel: boolean; // SSH only
|
||||
enableFileManager: boolean; // SSH only
|
||||
enableDocker: boolean; // SSH only
|
||||
enableRemoteDesktop: boolean; // RDP, VNC only
|
||||
}
|
||||
|
||||
export interface JumpHost {
|
||||
hostId: number;
|
||||
}
|
||||
@@ -14,7 +26,7 @@ export interface QuickAction {
|
||||
snippetId: number;
|
||||
}
|
||||
|
||||
export interface SSHHost {
|
||||
export interface Host {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
@@ -62,6 +74,12 @@ export interface SSHHost {
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: ProxyNode[];
|
||||
|
||||
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
domain?: string;
|
||||
security?: string;
|
||||
ignoreCert?: boolean;
|
||||
guacamoleConfig?: string | Record<string, unknown>;
|
||||
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -87,7 +105,7 @@ export interface ProxyNode {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface SSHHostData {
|
||||
export interface HostData {
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
@@ -127,8 +145,18 @@ export interface SSHHostData {
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: ProxyNode[];
|
||||
|
||||
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
domain?: string;
|
||||
security?: string;
|
||||
ignoreCert?: boolean;
|
||||
guacamoleConfig?: Record<string, unknown> | null;
|
||||
dockerConfig?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export type SSHHost = Host;
|
||||
export type SSHHostData = HostData;
|
||||
|
||||
export interface SSHFolder {
|
||||
id: number;
|
||||
userId: string;
|
||||
@@ -415,12 +443,16 @@ export interface TabContextTab {
|
||||
| "file_manager"
|
||||
| "user_profile"
|
||||
| "docker"
|
||||
| "network_graph";
|
||||
| "network_graph"
|
||||
| "rdp"
|
||||
| "vnc"
|
||||
| "telnet";
|
||||
title: string;
|
||||
hostConfig?: SSHHost;
|
||||
terminalRef?: any;
|
||||
initialTab?: string;
|
||||
_updateTimestamp?: number;
|
||||
connectionConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface StatsConfig {
|
||||
metricsEnabled: boolean;
|
||||
metricsInterval: number;
|
||||
useGlobalMetricsInterval?: boolean;
|
||||
disableTcpPing?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
||||
@@ -75,4 +76,5 @@ export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
||||
metricsEnabled: true,
|
||||
metricsInterval: 30,
|
||||
useGlobalMetricsInterval: true,
|
||||
disableTcpPing: false,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, {
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { getAllServerStatuses, getSSHHosts } from "@/ui/main-axios";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
@@ -71,7 +72,13 @@ export function ServerStatusProvider({
|
||||
}
|
||||
});
|
||||
|
||||
setEnabledHostIds(enabled);
|
||||
setEnabledHostIds((prev) => {
|
||||
if (prev.size !== enabled.size) return enabled;
|
||||
for (const id of enabled) {
|
||||
if (!prev.has(id)) return enabled;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
return enabled;
|
||||
} catch (error) {
|
||||
return new Set<number>();
|
||||
@@ -125,14 +132,19 @@ export function ServerStatusProvider({
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const stableEnabledHostIds = useMemo(
|
||||
() => enabledHostIds,
|
||||
[[...enabledHostIds].sort().join(",")],
|
||||
);
|
||||
|
||||
const getStatus = useCallback(
|
||||
(hostId: number): StatusValue => {
|
||||
if (!enabledHostIds.has(hostId)) {
|
||||
if (!stableEnabledHostIds.has(hostId)) {
|
||||
return "offline";
|
||||
}
|
||||
return statuses.get(hostId)?.status || "degraded";
|
||||
},
|
||||
[statuses, enabledHostIds],
|
||||
[statuses, stableEnabledHostIds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -156,9 +156,8 @@ function AppContent({
|
||||
if (hostIdentifier) {
|
||||
const openTerminal = async () => {
|
||||
try {
|
||||
const { getSSHHostById, getSSHHosts } = await import(
|
||||
"@/ui/main-axios.ts"
|
||||
);
|
||||
const { getSSHHostById, getSSHHosts } =
|
||||
await import("@/ui/main-axios.ts");
|
||||
let host = null;
|
||||
|
||||
if (/^\d+$/.test(hostIdentifier)) {
|
||||
@@ -282,6 +281,9 @@ function AppContent({
|
||||
currentTabData?.type === "terminal" ||
|
||||
currentTabData?.type === "server_stats" ||
|
||||
currentTabData?.type === "file_manager" ||
|
||||
currentTabData?.type === "rdp" ||
|
||||
currentTabData?.type === "vnc" ||
|
||||
currentTabData?.type === "telnet" ||
|
||||
currentTabData?.type === "tunnel" ||
|
||||
currentTabData?.type === "docker" ||
|
||||
currentTabData?.type === "network_graph";
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
updatePasswordResetAllowed,
|
||||
getGlobalMonitoringSettings,
|
||||
updateGlobalMonitoringSettings,
|
||||
getGuacamoleSettings,
|
||||
updateGuacamoleSettings,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
|
||||
interface GeneralSettingsTabProps {
|
||||
allowRegistration: boolean;
|
||||
@@ -51,9 +54,10 @@ export function GeneralSettingsTab({
|
||||
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
|
||||
const [passwordResetLoading, setPasswordResetLoading] = React.useState(false);
|
||||
|
||||
// Global monitoring defaults
|
||||
const [statusInterval, setStatusInterval] = React.useState(60);
|
||||
const [metricsInterval, setMetricsInterval] = React.useState(30);
|
||||
const [statusInputValue, setStatusInputValue] = React.useState("60");
|
||||
const [metricsInputValue, setMetricsInputValue] = React.useState("30");
|
||||
const [statusUnit, setStatusUnit] = React.useState<"seconds" | "minutes">(
|
||||
"seconds",
|
||||
);
|
||||
@@ -62,59 +66,101 @@ export function GeneralSettingsTab({
|
||||
);
|
||||
const [monitoringLoading, setMonitoringLoading] = React.useState(false);
|
||||
|
||||
const [guacEnabled, setGuacEnabled] = React.useState(true);
|
||||
const [guacUrl, setGuacUrl] = React.useState("guacd:4822");
|
||||
const [guacLoading, setGuacLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
getGlobalMonitoringSettings()
|
||||
getGuacamoleSettings()
|
||||
.then((data) => {
|
||||
setStatusInterval(data.statusCheckInterval);
|
||||
setMetricsInterval(data.metricsInterval);
|
||||
setGuacEnabled(data.enabled);
|
||||
setGuacUrl(data.url);
|
||||
})
|
||||
.catch(() => {
|
||||
// Use defaults silently
|
||||
toast.error(t("admin.failedToLoadGuacamoleSettings"));
|
||||
});
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const saveMonitoringDebounce = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const saveGuacDebounce = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const saveMonitoringSettings = React.useCallback(
|
||||
(newStatus: number, newMetrics: number) => {
|
||||
if (saveMonitoringDebounce.current) {
|
||||
clearTimeout(saveMonitoringDebounce.current);
|
||||
const saveGuacSettings = React.useCallback(
|
||||
(newEnabled: boolean, newUrl: string) => {
|
||||
if (saveGuacDebounce.current) {
|
||||
clearTimeout(saveGuacDebounce.current);
|
||||
}
|
||||
saveMonitoringDebounce.current = setTimeout(async () => {
|
||||
setMonitoringLoading(true);
|
||||
saveGuacDebounce.current = setTimeout(async () => {
|
||||
setGuacLoading(true);
|
||||
try {
|
||||
await updateGlobalMonitoringSettings({
|
||||
statusCheckInterval: newStatus,
|
||||
metricsInterval: newMetrics,
|
||||
});
|
||||
toast.success(t("admin.globalSettingsSaved"));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.failedToSaveGlobalSettings");
|
||||
toast.error(errorMessage);
|
||||
await updateGuacamoleSettings({ enabled: newEnabled, url: newUrl });
|
||||
toast.success(t("admin.guacamoleSettingsSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.failedToSaveGuacamoleSettings"));
|
||||
} finally {
|
||||
setMonitoringLoading(false);
|
||||
setGuacLoading(false);
|
||||
}
|
||||
}, 800);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleStatusIntervalChange = (value: string) => {
|
||||
const num = parseInt(value) || 0;
|
||||
React.useEffect(() => {
|
||||
getGlobalMonitoringSettings()
|
||||
.then((data) => {
|
||||
setStatusInterval(data.statusCheckInterval);
|
||||
setMetricsInterval(data.metricsInterval);
|
||||
setStatusInputValue(String(data.statusCheckInterval));
|
||||
setMetricsInputValue(String(data.metricsInterval));
|
||||
})
|
||||
.catch(() => {
|
||||
// Use defaults silently
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveMonitoringSettings = React.useCallback(
|
||||
async (newStatus: number, newMetrics: number) => {
|
||||
setMonitoringLoading(true);
|
||||
try {
|
||||
await updateGlobalMonitoringSettings({
|
||||
statusCheckInterval: newStatus,
|
||||
metricsInterval: newMetrics,
|
||||
});
|
||||
toast.success(t("admin.globalSettingsSaved"));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.failedToSaveGlobalSettings");
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setMonitoringLoading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleStatusBlur = () => {
|
||||
const num = parseInt(statusInputValue) || 0;
|
||||
const seconds = statusUnit === "minutes" ? num * 60 : num;
|
||||
const clamped = Math.max(5, Math.min(3600, seconds));
|
||||
setStatusInterval(clamped);
|
||||
setStatusInputValue(
|
||||
statusUnit === "minutes"
|
||||
? String(Math.round(clamped / 60))
|
||||
: String(clamped),
|
||||
);
|
||||
saveMonitoringSettings(clamped, metricsInterval);
|
||||
};
|
||||
|
||||
const handleMetricsIntervalChange = (value: string) => {
|
||||
const num = parseInt(value) || 0;
|
||||
const handleMetricsBlur = () => {
|
||||
const num = parseInt(metricsInputValue) || 0;
|
||||
const seconds = metricsUnit === "minutes" ? num * 60 : num;
|
||||
const clamped = Math.max(5, Math.min(3600, seconds));
|
||||
setMetricsInterval(clamped);
|
||||
setMetricsInputValue(
|
||||
metricsUnit === "minutes"
|
||||
? String(Math.round(clamped / 60))
|
||||
: String(clamped),
|
||||
);
|
||||
saveMonitoringSettings(statusInterval, clamped);
|
||||
};
|
||||
|
||||
@@ -244,12 +290,9 @@ export function GeneralSettingsTab({
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={
|
||||
statusUnit === "minutes"
|
||||
? Math.round(statusInterval / 60)
|
||||
: statusInterval
|
||||
}
|
||||
onChange={(e) => handleStatusIntervalChange(e.target.value)}
|
||||
value={statusInputValue}
|
||||
onChange={(e) => setStatusInputValue(e.target.value)}
|
||||
onBlur={handleStatusBlur}
|
||||
disabled={monitoringLoading}
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -257,6 +300,11 @@ export function GeneralSettingsTab({
|
||||
value={statusUnit}
|
||||
onValueChange={(value: "seconds" | "minutes") => {
|
||||
setStatusUnit(value);
|
||||
setStatusInputValue(
|
||||
value === "minutes"
|
||||
? String(Math.round(statusInterval / 60))
|
||||
: String(statusInterval),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
@@ -280,12 +328,9 @@ export function GeneralSettingsTab({
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={
|
||||
metricsUnit === "minutes"
|
||||
? Math.round(metricsInterval / 60)
|
||||
: metricsInterval
|
||||
}
|
||||
onChange={(e) => handleMetricsIntervalChange(e.target.value)}
|
||||
value={metricsInputValue}
|
||||
onChange={(e) => setMetricsInputValue(e.target.value)}
|
||||
onBlur={handleMetricsBlur}
|
||||
disabled={monitoringLoading}
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -293,6 +338,11 @@ export function GeneralSettingsTab({
|
||||
value={metricsUnit}
|
||||
onValueChange={(value: "seconds" | "minutes") => {
|
||||
setMetricsUnit(value);
|
||||
setMetricsInputValue(
|
||||
value === "minutes"
|
||||
? String(Math.round(metricsInterval / 60))
|
||||
: String(metricsInterval),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
@@ -311,6 +361,55 @@ export function GeneralSettingsTab({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.guacamoleIntegration")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.guacamoleIntegrationDesc")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() =>
|
||||
window.open("https://docs.termix.site/remote-desktop", "_blank")
|
||||
}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={guacEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
const val = checked === true;
|
||||
setGuacEnabled(val);
|
||||
saveGuacSettings(val, guacUrl);
|
||||
}}
|
||||
disabled={guacLoading}
|
||||
/>
|
||||
{t("admin.enableGuacamole")}
|
||||
</label>
|
||||
{guacEnabled && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t("admin.guacdUrl")}</label>
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={guacUrl}
|
||||
placeholder={t("admin.guacdUrlPlaceholder")}
|
||||
disabled={guacLoading}
|
||||
onChange={(e) => {
|
||||
setGuacUrl(e.target.value);
|
||||
saveGuacSettings(guacEnabled, e.target.value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.guacdUrlNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
User,
|
||||
Github,
|
||||
Terminal,
|
||||
Monitor,
|
||||
Eye,
|
||||
MessagesSquare,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
EllipsisVertical,
|
||||
@@ -27,8 +30,14 @@ import { BiMoney, BiSupport } from "react-icons/bi";
|
||||
import { BsDiscord } from "react-icons/bs";
|
||||
import { GrUpdate } from "react-icons/gr";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { getRecentActivity, getSSHHosts } from "@/ui/main-axios.ts";
|
||||
import {
|
||||
getRecentActivity,
|
||||
getSSHHosts,
|
||||
getGuacamoleToken,
|
||||
logActivity,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type { RecentActivityItem } from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -62,6 +71,16 @@ interface SSHHost {
|
||||
statsConfig?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
domain?: string;
|
||||
security?: string;
|
||||
ignoreCert?: boolean;
|
||||
guacamoleConfig?: any;
|
||||
showTerminalInSidebar?: boolean;
|
||||
showFileManagerInSidebar?: boolean;
|
||||
showTunnelInSidebar?: boolean;
|
||||
showDockerInSidebar?: boolean;
|
||||
showServerStatsInSidebar?: boolean;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
@@ -204,10 +223,60 @@ export function CommandPalette({
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleHostTerminalClick = (host: SSHHost) => {
|
||||
const handleHostTerminalClick = async (host: SSHHost) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
|
||||
if (
|
||||
host.connectionType === "rdp" ||
|
||||
host.connectionType === "vnc" ||
|
||||
host.connectionType === "telnet"
|
||||
) {
|
||||
try {
|
||||
const protocol = host.connectionType as "rdp" | "vnc" | "telnet";
|
||||
const result = await getGuacamoleToken({
|
||||
protocol,
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
ignoreCert: host.ignoreCert,
|
||||
guacamoleConfig: host.guacamoleConfig as any,
|
||||
});
|
||||
|
||||
addTab({
|
||||
type: protocol,
|
||||
title,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: result.token,
|
||||
protocol,
|
||||
type: protocol,
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity(protocol, host.id, title);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to log ${protocol} activity:`, err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to get Guacamole token:", err);
|
||||
}
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
addTab({ type: "terminal", title, hostConfig: host });
|
||||
setIsOpen(false);
|
||||
};
|
||||
@@ -340,6 +409,9 @@ export function CommandPalette({
|
||||
shouldShowMetrics = true;
|
||||
}
|
||||
|
||||
const isSSH =
|
||||
!host.connectionType || host.connectionType === "ssh";
|
||||
|
||||
let hasTunnelConnections = false;
|
||||
try {
|
||||
const tunnelConnections = Array.isArray(
|
||||
@@ -356,13 +428,18 @@ export function CommandPalette({
|
||||
|
||||
const visibleButtons = [
|
||||
host.enableTerminal && (host.showTerminalInSidebar ?? true),
|
||||
host.enableFileManager &&
|
||||
isSSH &&
|
||||
host.enableFileManager &&
|
||||
(host.showFileManagerInSidebar ?? false),
|
||||
host.enableTunnel &&
|
||||
isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
(host.showTunnelInSidebar ?? false),
|
||||
host.enableDocker && (host.showDockerInSidebar ?? false),
|
||||
shouldShowMetrics &&
|
||||
isSSH &&
|
||||
host.enableDocker &&
|
||||
(host.showDockerInSidebar ?? false),
|
||||
isSSH &&
|
||||
shouldShowMetrics &&
|
||||
(host.showServerStatsInSidebar ?? false),
|
||||
].filter(Boolean).length;
|
||||
|
||||
@@ -395,11 +472,20 @@ export function CommandPalette({
|
||||
handleHostTerminalClick(host);
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-3 w-3" />
|
||||
{host.connectionType === "rdp" ? (
|
||||
<Monitor className="h-3 w-3" />
|
||||
) : host.connectionType === "vnc" ? (
|
||||
<Eye className="h-3 w-3" />
|
||||
) : host.connectionType === "telnet" ? (
|
||||
<MessagesSquare className="h-3 w-3" />
|
||||
) : (
|
||||
<Terminal className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{host.enableFileManager &&
|
||||
{isSSH &&
|
||||
host.enableFileManager &&
|
||||
(host.showFileManagerInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -413,7 +499,8 @@ export function CommandPalette({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{host.enableTunnel &&
|
||||
{isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
(host.showTunnelInSidebar ?? false) && (
|
||||
<Button
|
||||
@@ -428,7 +515,8 @@ export function CommandPalette({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{host.enableDocker &&
|
||||
{isSSH &&
|
||||
host.enableDocker &&
|
||||
(host.showDockerInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -442,7 +530,8 @@ export function CommandPalette({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{shouldShowMetrics &&
|
||||
{isSSH &&
|
||||
shouldShowMetrics &&
|
||||
(host.showServerStatsInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -484,13 +573,22 @@ export function CommandPalette({
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
{host.connectionType === "rdp" ? (
|
||||
<Monitor className="h-4 w-4" />
|
||||
) : host.connectionType === "vnc" ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : host.connectionType === "telnet" ? (
|
||||
<MessagesSquare className="h-4 w-4" />
|
||||
) : (
|
||||
<Terminal className="h-4 w-4" />
|
||||
)}
|
||||
<span className="flex-1">
|
||||
{t("hosts.openTerminal")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{shouldShowMetrics &&
|
||||
{isSSH &&
|
||||
shouldShowMetrics &&
|
||||
!(host.showServerStatsInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
@@ -505,7 +603,8 @@ export function CommandPalette({
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableFileManager &&
|
||||
{isSSH &&
|
||||
host.enableFileManager &&
|
||||
!(host.showFileManagerInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
@@ -520,7 +619,8 @@ export function CommandPalette({
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableTunnel &&
|
||||
{isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
!(host.showTunnelInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
@@ -536,7 +636,8 @@ export function CommandPalette({
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableDocker &&
|
||||
{isSSH &&
|
||||
host.enableDocker &&
|
||||
!(host.showDockerInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getServerMetricsById,
|
||||
registerMetricsViewer,
|
||||
sendMetricsHeartbeat,
|
||||
getGuacamoleToken,
|
||||
type RecentActivityItem,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
@@ -386,6 +387,108 @@ export function Dashboard({
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "telnet") {
|
||||
getGuacamoleToken({
|
||||
protocol: "telnet",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
ignoreCert: host.ignoreCert,
|
||||
guacamoleConfig: host.guacamoleConfig as any,
|
||||
})
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "telnet",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: result.token,
|
||||
protocol: "telnet",
|
||||
type: "telnet",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to get telnet token:", error);
|
||||
});
|
||||
} else if (item.type === "vnc") {
|
||||
getGuacamoleToken({
|
||||
protocol: "vnc",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
ignoreCert: host.ignoreCert,
|
||||
guacamoleConfig: host.guacamoleConfig as any,
|
||||
})
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "vnc",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: result.token,
|
||||
protocol: "vnc",
|
||||
type: "vnc",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to get vnc token:", error);
|
||||
});
|
||||
} else if (item.type === "rdp") {
|
||||
getGuacamoleToken({
|
||||
protocol: "rdp",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
ignoreCert: host.ignoreCert,
|
||||
guacamoleConfig: host.guacamoleConfig as any,
|
||||
})
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "rdp",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: result.token,
|
||||
protocol: "rdp",
|
||||
type: "rdp",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to get rdp token:", error);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
Server,
|
||||
ArrowDownUp,
|
||||
Container,
|
||||
Monitor,
|
||||
Eye,
|
||||
MessagesSquare,
|
||||
Network,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { type RecentActivityItem } from "@/ui/main-axios";
|
||||
@@ -83,6 +87,12 @@ export function RecentActivityCard({
|
||||
<ArrowDownUp size={20} className="shrink-0" />
|
||||
) : item.type === "docker" ? (
|
||||
<Container size={20} className="shrink-0" />
|
||||
) : item.type === "telnet" ? (
|
||||
<MessagesSquare size={20} className="shrink-0" />
|
||||
) : item.type === "vnc" ? (
|
||||
<Eye size={20} className="shrink-0" />
|
||||
) : item.type === "rdp" ? (
|
||||
<Monitor size={20} className="shrink-0" />
|
||||
) : (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -249,7 +249,7 @@ export function ConsoleTerminal({
|
||||
window.location.port === "");
|
||||
|
||||
const baseWsUrl = isDev
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30008`
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`
|
||||
: isElectronApp
|
||||
? (() => {
|
||||
const baseUrl =
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { GuacamoleDisplay } from "@/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import { FullScreenAppWrapper } from "@/ui/desktop/apps/FullScreenAppWrapper.tsx";
|
||||
import { getGuacamoleTokenFromHost } from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface GuacamoleAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GuacamoleAppInner
|
||||
hostId={parseInt(hostId!, 10)}
|
||||
hostConfig={hostConfig}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
interface GuacamoleAppInnerProps {
|
||||
hostId: number;
|
||||
hostConfig: any;
|
||||
}
|
||||
|
||||
const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||
hostId,
|
||||
hostConfig,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getGuacamoleTokenFromHost(hostId)
|
||||
.then((result) => setToken(result.token))
|
||||
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
|
||||
}, [hostId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">
|
||||
{t("guacamole.connecting", {
|
||||
type: (hostConfig.connectionType || "remote").toUpperCase(),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const protocol = hostConfig.connectionType as "rdp" | "vnc" | "telnet";
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<GuacamoleDisplay
|
||||
connectionConfig={{ token, protocol, type: protocol }}
|
||||
isVisible={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GuacamoleApp;
|
||||
@@ -0,0 +1,385 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getCookie, isElectron } from "@/ui/main-axios.ts";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||
|
||||
export interface GuacamoleConnectionConfig {
|
||||
token?: string;
|
||||
protocol?: GuacamoleConnectionType;
|
||||
type?: GuacamoleConnectionType;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface GuacamoleDisplayHandle {
|
||||
disconnect: () => void;
|
||||
sendKey: (keysym: number, pressed: boolean) => void;
|
||||
sendMouse: (x: number, y: number, buttonMask: number) => void;
|
||||
setClipboard: (data: string) => void;
|
||||
}
|
||||
|
||||
interface GuacamoleDisplayProps {
|
||||
connectionConfig: GuacamoleConnectionConfig;
|
||||
isVisible: boolean;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
export const GuacamoleDisplay = forwardRef<
|
||||
GuacamoleDisplayHandle,
|
||||
GuacamoleDisplayProps
|
||||
>(function GuacamoleDisplay(
|
||||
{ connectionConfig, isVisible, onConnect, onDisconnect, onError },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const displayRef = useRef<HTMLDivElement>(null);
|
||||
const clientRef = useRef<Guacamole.Client | null>(null);
|
||||
const scaleRef = useRef<number>(1);
|
||||
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
}
|
||||
},
|
||||
sendKey: (keysym: number, pressed: boolean) => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.sendKeyEvent(pressed ? 1 : 0, keysym);
|
||||
}
|
||||
},
|
||||
sendMouse: (x: number, y: number, buttonMask: number) => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.sendMouseState(
|
||||
new Guacamole.Mouse.State({
|
||||
x,
|
||||
y,
|
||||
left: !!(buttonMask & 1),
|
||||
middle: !!(buttonMask & 2),
|
||||
right: !!(buttonMask & 4),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
setClipboard: (data: string) => {
|
||||
if (clientRef.current) {
|
||||
const stream = clientRef.current.createClipboardStream("text/plain");
|
||||
const writer = new Guacamole.StringWriter(stream);
|
||||
writer.sendText(data);
|
||||
writer.sendEnd();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const getWebSocketUrl = useCallback(
|
||||
async (
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
let token: string;
|
||||
|
||||
if (connectionConfig.token) {
|
||||
token = connectionConfig.token;
|
||||
} else {
|
||||
const jwtToken = getCookie("jwt");
|
||||
if (!jwtToken) {
|
||||
onError?.("Authentication required");
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = isDev
|
||||
? "http://localhost:30001"
|
||||
: isElectron()
|
||||
? (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl || "http://127.0.0.1:30001"
|
||||
: `${window.location.origin}`;
|
||||
|
||||
const response = await fetch(`${baseUrl}/guacamole/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
},
|
||||
body: JSON.stringify(connectionConfig),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error || "Failed to get connection token");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
token = data.token;
|
||||
}
|
||||
|
||||
const width = connectionConfig.width || containerWidth || 1280;
|
||||
const height = connectionConfig.height || containerHeight || 720;
|
||||
const dpi = connectionConfig.dpi || 96;
|
||||
|
||||
const wsBase = isDev
|
||||
? `ws://localhost:30008`
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const base =
|
||||
(window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl || "http://127.0.0.1:30001";
|
||||
return `${base.startsWith("https://") ? "wss://" : "ws://"}${base.replace(/^https?:\/\//, "")}/guacamole/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/guacamole/websocket/`;
|
||||
|
||||
return `${wsBase}?token=${encodeURIComponent(token)}&width=${width}&height=${height}&dpi=${dpi}`;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
onError?.(errorMessage);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[connectionConfig, onError],
|
||||
);
|
||||
|
||||
const rescaleDisplay = useCallback((immediate: boolean = false) => {
|
||||
if (!clientRef.current || !containerRef.current) return;
|
||||
|
||||
const performRescale = () => {
|
||||
if (!clientRef.current || !containerRef.current) return;
|
||||
|
||||
const display = clientRef.current.getDisplay();
|
||||
const cWidth = containerRef.current.clientWidth;
|
||||
const cHeight = containerRef.current.clientHeight;
|
||||
const displayWidth = display.getWidth();
|
||||
const displayHeight = display.getHeight();
|
||||
|
||||
if (displayWidth > 0 && displayHeight > 0 && cWidth > 0 && cHeight > 0) {
|
||||
const scale = Math.min(cWidth / displayWidth, cHeight / displayHeight);
|
||||
scaleRef.current = scale;
|
||||
display.scale(scale);
|
||||
}
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
performRescale();
|
||||
} else {
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
resizeTimeoutRef.current = setTimeout(performRescale, 200);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
if (isConnectingRef.current) return;
|
||||
isConnectingRef.current = true;
|
||||
setIsConnecting(true);
|
||||
setIsReady(false);
|
||||
|
||||
let containerWidth = containerRef.current?.clientWidth || 0;
|
||||
let containerHeight = containerRef.current?.clientHeight || 0;
|
||||
|
||||
if (containerWidth < 100 || containerHeight < 100) {
|
||||
containerWidth = 1280;
|
||||
containerHeight = 720;
|
||||
}
|
||||
|
||||
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
|
||||
if (!wsUrl) {
|
||||
isConnectingRef.current = false;
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const tunnel = new Guacamole.WebSocketTunnel(wsUrl);
|
||||
const client = new Guacamole.Client(tunnel);
|
||||
clientRef.current = client;
|
||||
|
||||
const display = client.getDisplay();
|
||||
const displayElement = display.getElement();
|
||||
|
||||
if (displayRef.current) {
|
||||
displayRef.current.innerHTML = "";
|
||||
displayRef.current.appendChild(displayElement);
|
||||
}
|
||||
|
||||
display.onresize = () => {
|
||||
rescaleDisplay(true);
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
const mouse = new Guacamole.Mouse(displayElement);
|
||||
const sendMouseState = (state: Guacamole.Mouse.State) => {
|
||||
const scale = scaleRef.current;
|
||||
const adjustedX = Math.round(state.x / scale);
|
||||
const adjustedY = Math.round(state.y / scale);
|
||||
|
||||
const adjustedState = new Guacamole.Mouse.State(
|
||||
adjustedX,
|
||||
adjustedY,
|
||||
state.left,
|
||||
state.middle,
|
||||
state.right,
|
||||
state.up,
|
||||
state.down,
|
||||
) as Guacamole.Mouse.State;
|
||||
|
||||
client.sendMouseState(adjustedState);
|
||||
};
|
||||
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
|
||||
|
||||
const keyboard = new Guacamole.Keyboard(document);
|
||||
keyboard.onkeydown = (keysym: number) => {
|
||||
client.sendKeyEvent(1, keysym);
|
||||
};
|
||||
keyboard.onkeyup = (keysym: number) => {
|
||||
client.sendKeyEvent(0, keysym);
|
||||
};
|
||||
|
||||
client.onstatechange = (state: number) => {
|
||||
switch (state) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
setIsConnecting(true);
|
||||
break;
|
||||
case 2:
|
||||
break;
|
||||
case 3:
|
||||
setIsConnecting(false);
|
||||
onConnect?.();
|
||||
break;
|
||||
case 4:
|
||||
break;
|
||||
case 5:
|
||||
setIsConnecting(false);
|
||||
setIsReady(false);
|
||||
keyboard.onkeydown = null;
|
||||
keyboard.onkeyup = null;
|
||||
onDisconnect?.();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
client.onerror = (error: Guacamole.Status) => {
|
||||
const errorMessage = error.message || "Connection error";
|
||||
setIsConnecting(false);
|
||||
setIsReady(false);
|
||||
onError?.(errorMessage);
|
||||
};
|
||||
|
||||
client.onclipboard = (stream: Guacamole.InputStream, mimetype: string) => {
|
||||
if (mimetype === "text/plain") {
|
||||
const reader = new Guacamole.StringReader(stream);
|
||||
let data = "";
|
||||
reader.ontext = (text: string) => {
|
||||
data += text;
|
||||
};
|
||||
reader.onend = () => {
|
||||
navigator.clipboard.writeText(data).catch(() => {});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
client.connect();
|
||||
}, [getWebSocketUrl, onConnect, onDisconnect, onError, rescaleDisplay]);
|
||||
|
||||
const hasInitiatedRef = useRef(false);
|
||||
const isMountedRef = useRef(false);
|
||||
const isConnectingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
if (isVisible && !hasInitiatedRef.current) {
|
||||
hasInitiatedRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
if (isMountedRef.current) {
|
||||
connect();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isVisible, connect]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
hasInitiatedRef.current = false;
|
||||
isConnectingRef.current = false;
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
clientRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
rescaleDisplay(false);
|
||||
});
|
||||
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
const initialTimeout = setTimeout(() => rescaleDisplay(true), 100);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
clearTimeout(initialTimeout);
|
||||
};
|
||||
}, [rescaleDisplay]);
|
||||
|
||||
const connectingMessage = t("guacamole.connecting", {
|
||||
type: (
|
||||
connectionConfig.protocol ||
|
||||
connectionConfig.type ||
|
||||
"remote"
|
||||
).toUpperCase(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<div
|
||||
ref={displayRef}
|
||||
className="relative w-full h-full flex items-center justify-center"
|
||||
style={{
|
||||
cursor: isReady ? "none" : "default",
|
||||
visibility: isReady ? "visible" : "hidden",
|
||||
}}
|
||||
/>
|
||||
|
||||
<SimpleLoader visible={!isReady} message={connectingMessage} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
|
||||
interface SudoPasswordPopupProps {
|
||||
isOpen: boolean;
|
||||
hostPassword: string;
|
||||
backgroundColor: string;
|
||||
onConfirm: (password: string) => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function SudoPasswordPopup({
|
||||
isOpen,
|
||||
hostPassword,
|
||||
backgroundColor,
|
||||
onConfirm,
|
||||
onDismiss,
|
||||
}: SudoPasswordPopupProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
onConfirm(hostPassword);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [isOpen, onConfirm, onDismiss, hostPassword]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-4 right-4 z-50 backdrop-blur-sm border border-border rounded-lg shadow-lg p-4 min-w-[280px]"
|
||||
style={{ backgroundColor: backgroundColor }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-primary/10 rounded-full">
|
||||
<KeyRound className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-sm">
|
||||
{t("terminal.sudoPasswordPopupTitle", "Insert password?")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"terminal.sudoPasswordPopupHint",
|
||||
"Press Enter to insert, Esc to dismiss",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={onDismiss}>
|
||||
{t("terminal.sudoPasswordPopupDismiss", "Dismiss")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onConfirm(hostPassword)}
|
||||
>
|
||||
{t("terminal.sudoPasswordPopupConfirm", "Insert")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1499,7 +1499,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("terminal.clipboardReadFailed"));
|
||||
// fall through
|
||||
}
|
||||
if (window.location.protocol !== "https:" && !isElectron()) {
|
||||
toast.error(t("terminal.clipboardHttpWarning"));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -1679,19 +1682,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
if (!getUseRightClickCopyPaste()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (terminal.hasSelection()) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
await writeTextToClipboard(selection);
|
||||
terminal.clearSelection();
|
||||
}
|
||||
} else {
|
||||
const pasteText = await readTextFromClipboard();
|
||||
if (pasteText) terminal.paste(pasteText);
|
||||
if (terminal.hasSelection()) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
await writeTextToClipboard(selection);
|
||||
terminal.clearSelection();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Terminal operation failed:", error);
|
||||
} else {
|
||||
const text = await readTextFromClipboard();
|
||||
if (text) terminal.paste(text);
|
||||
}
|
||||
};
|
||||
element?.addEventListener("contextmenu", handleContextMenu);
|
||||
@@ -1832,6 +1831,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key.toLowerCase() === "v"
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
readTextFromClipboard().then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey) {
|
||||
const key = e.key.toLowerCase();
|
||||
const blockedKeys = ["w", "t", "n", "q"];
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager";
|
||||
import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager.tsx";
|
||||
import React from "react";
|
||||
|
||||
const HostManagerApp: React.FC = () => {
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
revokeHostAccess,
|
||||
getSSHHostById,
|
||||
notifyHostCreatedOrUpdated,
|
||||
getGuacamoleSettings,
|
||||
type Role,
|
||||
type AccessRecord,
|
||||
} from "@/ui/main-axios.ts";
|
||||
@@ -130,7 +131,9 @@ import { HostDockerTab } from "./tabs/HostDockerTab";
|
||||
import { HostTunnelTab } from "./tabs/HostTunnelTab";
|
||||
import { HostFileManagerTab } from "./tabs/HostFileManagerTab";
|
||||
import { HostStatisticsTab } from "./tabs/HostStatisticsTab";
|
||||
import { HostStatusTab } from "./tabs/HostStatusTab";
|
||||
import { HostSharingTab } from "./tabs/HostSharingTab";
|
||||
import { HostRemoteDesktopTab } from "./tabs/HostRemoteDesktopTab";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface User {
|
||||
@@ -176,11 +179,18 @@ export function HostManagerEditor({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [guacEnabled, setGuacEnabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setFormError(null);
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
getGuacamoleSettings()
|
||||
.then((data) => setGuacEnabled(data.enabled))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const [statusIntervalUnit, setStatusIntervalUnit] = useState<
|
||||
"seconds" | "minutes"
|
||||
>("seconds");
|
||||
@@ -265,10 +275,11 @@ export function HostManagerEditor({
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
connectionType: z.enum(["ssh", "rdp", "vnc", "telnet"]).default("ssh"),
|
||||
name: z.string().optional(),
|
||||
ip: z.string().min(1),
|
||||
ip: z.string().min(1, t("hosts.ipRequired", "IP address is required")),
|
||||
port: z.coerce.number().min(1).max(65535),
|
||||
username: z.string().min(1),
|
||||
username: z.string().optional(),
|
||||
folder: z.string().optional(),
|
||||
tags: z.array(z.string().min(1)).default([]),
|
||||
pin: z.boolean().default(false),
|
||||
@@ -350,6 +361,7 @@ export function HostManagerEditor({
|
||||
metricsEnabled: z.boolean().default(true),
|
||||
metricsInterval: z.number().min(5).max(3600).default(30),
|
||||
useGlobalMetricsInterval: z.boolean().default(true),
|
||||
disableTcpPing: z.boolean().default(false),
|
||||
})
|
||||
.default({
|
||||
enabledWidgets: [
|
||||
@@ -369,6 +381,7 @@ export function HostManagerEditor({
|
||||
metricsEnabled: true,
|
||||
metricsInterval: 30,
|
||||
useGlobalMetricsInterval: true,
|
||||
disableTcpPing: false,
|
||||
}),
|
||||
terminalConfig: z
|
||||
.object({
|
||||
@@ -436,6 +449,10 @@ export function HostManagerEditor({
|
||||
)
|
||||
.optional(),
|
||||
enableDocker: z.boolean().default(false),
|
||||
domain: z.string().optional(),
|
||||
security: z.string().optional(),
|
||||
ignoreCert: z.boolean().default(true),
|
||||
guacamoleConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
showTerminalInSidebar: z.boolean().default(true),
|
||||
showFileManagerInSidebar: z.boolean().default(false),
|
||||
showTunnelInSidebar: z.boolean().default(false),
|
||||
@@ -443,6 +460,20 @@ export function HostManagerEditor({
|
||||
showServerStatsInSidebar: z.boolean().default(false),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.connectionType !== "ssh") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.username || data.username.trim() === "") {
|
||||
if (data.authType !== "credential") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("hosts.usernameRequired", "Username is required"),
|
||||
path: ["username"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.authType === "none") {
|
||||
return;
|
||||
}
|
||||
@@ -458,25 +489,27 @@ export function HostManagerEditor({
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("hosts.passwordRequired"),
|
||||
message: t("hosts.passwordRequired", "Password is required"),
|
||||
path: ["password"],
|
||||
});
|
||||
}
|
||||
} else if (data.authType === "key") {
|
||||
if (
|
||||
!data.key ||
|
||||
(typeof data.key === "string" && data.key.trim() === "")
|
||||
(typeof data.key === "string" &&
|
||||
data.key.trim() === "" &&
|
||||
data.key !== "existing_key")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("hosts.sshKeyRequired"),
|
||||
message: t("hosts.sshKeyRequired", "SSH key is required"),
|
||||
path: ["key"],
|
||||
});
|
||||
}
|
||||
if (!data.keyType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("hosts.keyTypeRequired"),
|
||||
message: t("hosts.keyTypeRequired", "Key type is required"),
|
||||
path: ["keyType"],
|
||||
});
|
||||
}
|
||||
@@ -484,7 +517,7 @@ export function HostManagerEditor({
|
||||
if (!data.credentialId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("hosts.credentialRequired"),
|
||||
message: t("hosts.credentialRequired", "Credential is required"),
|
||||
path: ["credentialId"],
|
||||
});
|
||||
}
|
||||
@@ -510,6 +543,7 @@ export function HostManagerEditor({
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
connectionType: "ssh" as const,
|
||||
name: "",
|
||||
ip: "",
|
||||
port: 22,
|
||||
@@ -547,32 +581,80 @@ export function HostManagerEditor({
|
||||
socks5Password: "",
|
||||
socks5ProxyChain: [],
|
||||
enableDocker: false,
|
||||
domain: "",
|
||||
security: "any",
|
||||
ignoreCert: true,
|
||||
guacamoleConfig: {},
|
||||
},
|
||||
});
|
||||
|
||||
const watchedFields = form.watch();
|
||||
const formState = form.formState;
|
||||
const watchedConnectionType = form.watch("connectionType") || "ssh";
|
||||
|
||||
const prevConnectionTypeRef = useRef<string>("ssh");
|
||||
useEffect(() => {
|
||||
const prev = prevConnectionTypeRef.current;
|
||||
const current = watchedConnectionType;
|
||||
if (prev === current) return;
|
||||
prevConnectionTypeRef.current = current;
|
||||
|
||||
const portDefaults: Record<string, number> = {
|
||||
ssh: 22,
|
||||
rdp: 3389,
|
||||
vnc: 5900,
|
||||
telnet: 23,
|
||||
};
|
||||
const currentPort = form.getValues("port");
|
||||
const oldDefault = portDefaults[prev] || 22;
|
||||
if (currentPort === oldDefault) {
|
||||
form.setValue("port", portDefaults[current] || 22);
|
||||
}
|
||||
|
||||
if (current !== "ssh") {
|
||||
const currentStatsConfig = form.getValues("statsConfig");
|
||||
form.setValue("statsConfig", {
|
||||
...currentStatsConfig,
|
||||
metricsEnabled: false,
|
||||
disableTcpPing: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeTab !== "general" && current !== "ssh") {
|
||||
setActiveTab("general");
|
||||
}
|
||||
}, [watchedConnectionType]);
|
||||
|
||||
const isFormValid = React.useMemo(() => {
|
||||
const values = form.getValues();
|
||||
const errors = formState.errors;
|
||||
|
||||
if (!values.ip || !values.username || values.username.trim() === "")
|
||||
if (!watchedFields.ip) return false;
|
||||
|
||||
if (watchedFields.connectionType !== "ssh") {
|
||||
const port = Number(watchedFields.port);
|
||||
return !errors.ip && port >= 1 && port <= 65535;
|
||||
}
|
||||
|
||||
if (!watchedFields.username || watchedFields.username.trim() === "")
|
||||
return false;
|
||||
|
||||
if (authTab === "password") {
|
||||
return !!(values.password && values.password.trim() !== "");
|
||||
if (!watchedFields.password || watchedFields.password.trim() === "")
|
||||
return false;
|
||||
} else if (authTab === "key") {
|
||||
return !!(values.key && values.keyType);
|
||||
if (!watchedFields.key || !watchedFields.keyType) return false;
|
||||
} else if (authTab === "credential") {
|
||||
return !!values.credentialId;
|
||||
if (!watchedFields.credentialId) return false;
|
||||
} else if (authTab === "none") {
|
||||
return true;
|
||||
// No auth required
|
||||
} else if (authTab === "opkssh") {
|
||||
return true;
|
||||
// No auth required
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [watchedFields, authTab]);
|
||||
return Object.keys(errors).length === 0;
|
||||
}, [watchedFields, authTab, formState.errors]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateAuthFields = async () => {
|
||||
@@ -605,7 +687,10 @@ export function HostManagerEditor({
|
||||
}
|
||||
}
|
||||
} else if (authTab === "none") {
|
||||
form.setValue("password", "", { shouldValidate: true });
|
||||
const connectionType = form.getValues("connectionType");
|
||||
if (connectionType === "ssh") {
|
||||
form.setValue("password", "", { shouldValidate: true });
|
||||
}
|
||||
form.setValue("key", null, { shouldValidate: true });
|
||||
form.setValue("keyPassword", "", { shouldValidate: true });
|
||||
form.setValue("keyType", "auto", { shouldValidate: true });
|
||||
@@ -628,14 +713,16 @@ export function HostManagerEditor({
|
||||
useEffect(() => {
|
||||
if (editingHost) {
|
||||
const cleanedHost = { ...editingHost };
|
||||
if (cleanedHost.credentialId && cleanedHost.key) {
|
||||
cleanedHost.key = undefined;
|
||||
cleanedHost.keyPassword = undefined;
|
||||
cleanedHost.keyType = undefined;
|
||||
} else if (cleanedHost.credentialId && cleanedHost.password) {
|
||||
cleanedHost.password = undefined;
|
||||
} else if (cleanedHost.key && cleanedHost.password) {
|
||||
cleanedHost.password = undefined;
|
||||
if ((cleanedHost as any).connectionType === "ssh") {
|
||||
if (cleanedHost.credentialId && cleanedHost.key) {
|
||||
cleanedHost.key = undefined;
|
||||
cleanedHost.keyPassword = undefined;
|
||||
cleanedHost.keyType = undefined;
|
||||
} else if (cleanedHost.credentialId && cleanedHost.password) {
|
||||
cleanedHost.password = undefined;
|
||||
} else if (cleanedHost.key && cleanedHost.password) {
|
||||
cleanedHost.password = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultAuthType = (cleanedHost.authType ||
|
||||
@@ -669,6 +756,7 @@ export function HostManagerEditor({
|
||||
parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig };
|
||||
|
||||
const formData: Partial<FormData> = {
|
||||
connectionType: (cleanedHost as any).connectionType || "ssh",
|
||||
name: cleanedHost.name || "",
|
||||
ip: cleanedHost.ip || "",
|
||||
port: cleanedHost.port || 22,
|
||||
@@ -734,6 +822,21 @@ export function HostManagerEditor({
|
||||
? cleanedHost.socks5ProxyChain
|
||||
: [],
|
||||
enableDocker: Boolean(cleanedHost.enableDocker),
|
||||
domain: (cleanedHost as any).domain || "",
|
||||
security: (cleanedHost as any).security || "any",
|
||||
ignoreCert: (cleanedHost as any).ignoreCert ?? true,
|
||||
guacamoleConfig: (() => {
|
||||
const cfg = (cleanedHost as any).guacamoleConfig;
|
||||
if (!cfg) return {};
|
||||
if (typeof cfg === "string") {
|
||||
try {
|
||||
return JSON.parse(cfg);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return cfg;
|
||||
})(),
|
||||
showTerminalInSidebar: cleanedHost.showTerminalInSidebar ?? true,
|
||||
showFileManagerInSidebar: cleanedHost.showFileManagerInSidebar ?? false,
|
||||
showTunnelInSidebar: cleanedHost.showTunnelInSidebar ?? false,
|
||||
@@ -750,7 +853,11 @@ export function HostManagerEditor({
|
||||
setProxyMode("single");
|
||||
}
|
||||
|
||||
if (defaultAuthType === "password") {
|
||||
if (cleanedHost.connectionType !== "ssh") {
|
||||
if (cleanedHost.password) {
|
||||
formData.password = cleanedHost.password;
|
||||
}
|
||||
} else if (defaultAuthType === "password") {
|
||||
formData.password = cleanedHost.password || "";
|
||||
} else if (defaultAuthType === "key") {
|
||||
formData.key = editingHost.id ? "existing_key" : editingHost.key;
|
||||
@@ -774,6 +881,7 @@ export function HostManagerEditor({
|
||||
} else {
|
||||
setAuthTab("password");
|
||||
const defaultFormData: Partial<FormData> = {
|
||||
connectionType: "ssh" as const,
|
||||
name: "",
|
||||
ip: "",
|
||||
port: 22,
|
||||
@@ -799,6 +907,10 @@ export function HostManagerEditor({
|
||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||
forceKeyboardInteractive: false,
|
||||
enableDocker: false,
|
||||
domain: "",
|
||||
security: "any",
|
||||
ignoreCert: true,
|
||||
guacamoleConfig: {},
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: false,
|
||||
showTunnelInSidebar: false,
|
||||
@@ -834,23 +946,46 @@ export function HostManagerEditor({
|
||||
...data,
|
||||
};
|
||||
|
||||
if (
|
||||
data.terminalConfig?.sudoPasswordAutoFill &&
|
||||
data.terminalConfig?.sudoPassword
|
||||
) {
|
||||
submitData.sudoPassword = data.terminalConfig.sudoPassword;
|
||||
}
|
||||
(submitData as any).connectionType = data.connectionType;
|
||||
(submitData as any).domain = data.domain;
|
||||
(submitData as any).security = data.security;
|
||||
(submitData as any).ignoreCert = data.ignoreCert;
|
||||
(submitData as any).guacamoleConfig = data.guacamoleConfig;
|
||||
|
||||
if (data.authType !== "credential") {
|
||||
submitData.credentialId = undefined;
|
||||
}
|
||||
if (data.authType !== "password") {
|
||||
submitData.password = undefined;
|
||||
}
|
||||
if (data.authType !== "key") {
|
||||
if (data.connectionType !== "ssh") {
|
||||
submitData.authType = "none";
|
||||
submitData.key = undefined;
|
||||
submitData.keyPassword = undefined;
|
||||
submitData.keyType = undefined;
|
||||
submitData.credentialId = undefined;
|
||||
submitData.tunnelConnections = [];
|
||||
submitData.jumpHosts = [];
|
||||
(submitData as any).useSocks5 = false;
|
||||
(submitData as any).socks5ProxyChain = [];
|
||||
submitData.forceKeyboardInteractive = false;
|
||||
submitData.enableTunnel = false;
|
||||
submitData.enableFileManager = false;
|
||||
submitData.enableDocker = false;
|
||||
submitData.enableTerminal = true;
|
||||
} else {
|
||||
if (
|
||||
data.terminalConfig?.sudoPasswordAutoFill &&
|
||||
data.terminalConfig?.sudoPassword
|
||||
) {
|
||||
submitData.sudoPassword = data.terminalConfig.sudoPassword;
|
||||
}
|
||||
|
||||
if (data.authType !== "credential") {
|
||||
submitData.credentialId = undefined;
|
||||
}
|
||||
if (data.authType !== "password") {
|
||||
submitData.password = undefined;
|
||||
}
|
||||
if (data.authType !== "key") {
|
||||
submitData.key = undefined;
|
||||
submitData.keyPassword = undefined;
|
||||
submitData.keyType = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.authType === "key") {
|
||||
@@ -955,6 +1090,11 @@ export function HostManagerEditor({
|
||||
enableTerminal: "terminal",
|
||||
terminalConfig: "terminal",
|
||||
enableDocker: "docker",
|
||||
domain: "general",
|
||||
security: "general",
|
||||
ignoreCert: "general",
|
||||
guacamoleConfig: "remote_desktop",
|
||||
connectionType: "general",
|
||||
enableTunnel: "tunnel",
|
||||
tunnelConnections: "tunnel",
|
||||
enableFileManager: "file_manager",
|
||||
@@ -1194,6 +1334,50 @@ export function HostManagerEditor({
|
||||
: t("hosts.addHost")}
|
||||
</h3>
|
||||
</div>
|
||||
{guacEnabled && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="connectionType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormControl>
|
||||
<Tabs
|
||||
value={field.value || "ssh"}
|
||||
onValueChange={field.onChange}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="bg-button border border-edge-medium">
|
||||
<TabsTrigger
|
||||
value="ssh"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("hosts.ssh")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="rdp"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("hosts.rdp")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="vnc"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("hosts.vnc")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="telnet"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("hosts.telnet")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
@@ -1206,45 +1390,63 @@ export function HostManagerEditor({
|
||||
>
|
||||
{t("hosts.general")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="terminal"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("hosts.terminal")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="docker"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
Docker
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="tunnel"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("hosts.tunnel")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="file_manager"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("hosts.fileManager")}
|
||||
</TabsTrigger>
|
||||
{watchedConnectionType === "ssh" && (
|
||||
<>
|
||||
<TabsTrigger
|
||||
value="terminal"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("hosts.terminal")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="docker"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
Docker
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="tunnel"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("hosts.tunnel")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="file_manager"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("hosts.fileManager")}
|
||||
</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value="statistics"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("hosts.statistics")}
|
||||
{watchedConnectionType === "ssh"
|
||||
? t("hosts.statistics")
|
||||
: t("hosts.status")}
|
||||
</TabsTrigger>
|
||||
{!editingHost?.isShared && (
|
||||
<TabsTrigger value="sharing">
|
||||
{t("rbac.sharing")}
|
||||
{watchedConnectionType !== "ssh" && (
|
||||
<TabsTrigger
|
||||
value="remote_desktop"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("hosts.remoteDesktop")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{watchedConnectionType === "ssh" &&
|
||||
!editingHost?.isShared && (
|
||||
<TabsTrigger value="sharing">
|
||||
{t("rbac.sharing")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
<TabsContent value="general" className="pt-2">
|
||||
<HostGeneralTab
|
||||
form={form}
|
||||
connectionType={
|
||||
watchedConnectionType as "ssh" | "rdp" | "vnc" | "telnet"
|
||||
}
|
||||
authTab={authTab}
|
||||
setAuthTab={setAuthTab}
|
||||
keyInputMethod={keyInputMethod}
|
||||
@@ -1273,44 +1475,70 @@ export function HostManagerEditor({
|
||||
t={t}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="terminal" className="space-y-1">
|
||||
<HostTerminalTab form={form} snippets={snippets} t={t} />
|
||||
</TabsContent>
|
||||
<TabsContent value="docker" className="space-y-4">
|
||||
<HostDockerTab form={form} t={t} />
|
||||
</TabsContent>
|
||||
<TabsContent value="tunnel">
|
||||
<HostTunnelTab
|
||||
form={form}
|
||||
sshConfigDropdownOpen={sshConfigDropdownOpen}
|
||||
setSshConfigDropdownOpen={setSshConfigDropdownOpen}
|
||||
sshConfigInputRefs={sshConfigInputRefs}
|
||||
sshConfigDropdownRefs={sshConfigDropdownRefs}
|
||||
getFilteredSshConfigs={getFilteredSshConfigs}
|
||||
handleSshConfigClick={handleSshConfigClick}
|
||||
t={t}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="file_manager">
|
||||
<HostFileManagerTab form={form} t={t} />
|
||||
</TabsContent>
|
||||
{watchedConnectionType === "ssh" && (
|
||||
<>
|
||||
<TabsContent value="terminal" className="space-y-1">
|
||||
<HostTerminalTab form={form} snippets={snippets} t={t} />
|
||||
</TabsContent>
|
||||
<TabsContent value="docker" className="space-y-4">
|
||||
<HostDockerTab form={form} t={t} />
|
||||
</TabsContent>
|
||||
<TabsContent value="tunnel">
|
||||
<HostTunnelTab
|
||||
form={form}
|
||||
sshConfigDropdownOpen={sshConfigDropdownOpen}
|
||||
setSshConfigDropdownOpen={setSshConfigDropdownOpen}
|
||||
sshConfigInputRefs={sshConfigInputRefs}
|
||||
sshConfigDropdownRefs={sshConfigDropdownRefs}
|
||||
getFilteredSshConfigs={getFilteredSshConfigs}
|
||||
handleSshConfigClick={handleSshConfigClick}
|
||||
t={t}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="file_manager">
|
||||
<HostFileManagerTab form={form} t={t} />
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
<TabsContent value="statistics" className="space-y-6">
|
||||
<HostStatisticsTab
|
||||
form={form}
|
||||
statusIntervalUnit={statusIntervalUnit}
|
||||
setStatusIntervalUnit={setStatusIntervalUnit}
|
||||
metricsIntervalUnit={metricsIntervalUnit}
|
||||
setMetricsIntervalUnit={setMetricsIntervalUnit}
|
||||
snippets={snippets}
|
||||
t={t}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="sharing" className="space-y-6">
|
||||
<HostSharingTab
|
||||
hostId={editingHost?.id}
|
||||
isNewHost={!editingHost}
|
||||
/>
|
||||
{watchedConnectionType === "ssh" ? (
|
||||
<HostStatisticsTab
|
||||
form={form}
|
||||
statusIntervalUnit={statusIntervalUnit}
|
||||
setStatusIntervalUnit={setStatusIntervalUnit}
|
||||
metricsIntervalUnit={metricsIntervalUnit}
|
||||
setMetricsIntervalUnit={setMetricsIntervalUnit}
|
||||
snippets={snippets}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<HostStatusTab
|
||||
form={form}
|
||||
statusIntervalUnit={statusIntervalUnit}
|
||||
setStatusIntervalUnit={setStatusIntervalUnit}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
{watchedConnectionType !== "ssh" && (
|
||||
<TabsContent value="remote_desktop" className="space-y-4">
|
||||
<HostRemoteDesktopTab
|
||||
form={form}
|
||||
connectionType={
|
||||
watchedConnectionType as "rdp" | "vnc" | "telnet"
|
||||
}
|
||||
t={t}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
{watchedConnectionType === "ssh" && (
|
||||
<TabsContent value="sharing" className="space-y-6">
|
||||
<HostSharingTab
|
||||
hostId={editingHost?.id}
|
||||
isNewHost={!editingHost}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -41,6 +41,9 @@ import {
|
||||
refreshServerPolling,
|
||||
isElectron,
|
||||
getConfiguredServerUrl,
|
||||
getGuacamoleTokenFromHost,
|
||||
getGuacamoleToken,
|
||||
logActivity,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useServerStatus } from "@/ui/contexts/ServerStatusContext";
|
||||
import { toast } from "sonner";
|
||||
@@ -83,6 +86,9 @@ import {
|
||||
Plus,
|
||||
ListChecks,
|
||||
ChevronDown,
|
||||
Monitor,
|
||||
MessagesSquare,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
SSHHost,
|
||||
@@ -587,6 +593,7 @@ export function HostManagerViewer({
|
||||
const getSampleData = () => ({
|
||||
hosts: [
|
||||
{
|
||||
connectionType: "ssh",
|
||||
name: t("interface.webServerProduction"),
|
||||
ip: "192.168.1.100",
|
||||
port: 22,
|
||||
@@ -607,6 +614,7 @@ export function HostManagerViewer({
|
||||
defaultPath: "/var/www",
|
||||
},
|
||||
{
|
||||
connectionType: "ssh",
|
||||
name: t("interface.databaseServer"),
|
||||
ip: "192.168.1.101",
|
||||
port: 22,
|
||||
@@ -645,6 +653,7 @@ export function HostManagerViewer({
|
||||
},
|
||||
},
|
||||
{
|
||||
connectionType: "ssh",
|
||||
name: t("interface.developmentServer"),
|
||||
ip: "192.168.1.102",
|
||||
port: 2222,
|
||||
@@ -667,6 +676,66 @@ export function HostManagerViewer({
|
||||
sudoPassword: "dev_sudo_password",
|
||||
},
|
||||
{
|
||||
connectionType: "rdp",
|
||||
name: "Windows Server 2022",
|
||||
ip: "192.168.1.200",
|
||||
port: 3389,
|
||||
username: "Administrator",
|
||||
password: "windows_password",
|
||||
domain: "COMPANY",
|
||||
security: "nla",
|
||||
ignoreCert: false,
|
||||
folder: "Remote Desktop",
|
||||
tags: ["rdp", "windows", "production"],
|
||||
pin: false,
|
||||
notes: "Production Windows Server with RDP access",
|
||||
guacamoleConfig: {
|
||||
"enable-drive": true,
|
||||
"drive-path": "/shared",
|
||||
"create-drive-path": true,
|
||||
"server-layout": "en-us-qwerty",
|
||||
"resize-method": "display-update",
|
||||
},
|
||||
},
|
||||
{
|
||||
connectionType: "vnc",
|
||||
name: "Ubuntu Desktop",
|
||||
ip: "192.168.1.201",
|
||||
port: 5900,
|
||||
username: "vncuser",
|
||||
password: "vnc_password",
|
||||
folder: "Remote Desktop",
|
||||
tags: ["vnc", "linux", "desktop"],
|
||||
pin: false,
|
||||
notes: "Ubuntu desktop with VNC server",
|
||||
guacamoleConfig: {
|
||||
"color-depth": 24,
|
||||
cursor: "remote",
|
||||
"read-only": false,
|
||||
"clipboard-encoding": "UTF-8",
|
||||
},
|
||||
},
|
||||
{
|
||||
connectionType: "telnet",
|
||||
name: "Network Switch",
|
||||
ip: "192.168.1.254",
|
||||
port: 23,
|
||||
username: "admin",
|
||||
password: "switch_password",
|
||||
folder: "Infrastructure",
|
||||
tags: ["telnet", "network", "switch"],
|
||||
pin: false,
|
||||
notes: "Legacy network switch with Telnet access",
|
||||
guacamoleConfig: {
|
||||
"color-scheme": "green-black",
|
||||
"font-name": "monospace",
|
||||
"font-size": 12,
|
||||
scrollback: 1024,
|
||||
backspace: 127,
|
||||
},
|
||||
},
|
||||
{
|
||||
connectionType: "ssh",
|
||||
name: "Jump Host Server",
|
||||
ip: "10.0.0.50",
|
||||
port: 22,
|
||||
@@ -693,6 +762,7 @@ export function HostManagerViewer({
|
||||
],
|
||||
},
|
||||
{
|
||||
connectionType: "ssh",
|
||||
name: "Server with SOCKS5 Proxy",
|
||||
ip: "10.10.10.100",
|
||||
port: 22,
|
||||
@@ -713,6 +783,7 @@ export function HostManagerViewer({
|
||||
socks5Password: "proxypass",
|
||||
},
|
||||
{
|
||||
connectionType: "ssh",
|
||||
name: "Customized Terminal Server",
|
||||
ip: "192.168.1.150",
|
||||
port: 22,
|
||||
@@ -1388,34 +1459,47 @@ export function HostManagerViewer({
|
||||
)}
|
||||
{(() => {
|
||||
const statsConfig = (() => {
|
||||
if (!host.statsConfig) {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
if (
|
||||
typeof host.statsConfig === "object"
|
||||
) {
|
||||
return host.statsConfig;
|
||||
}
|
||||
try {
|
||||
return host.statsConfig
|
||||
? JSON.parse(host.statsConfig)
|
||||
: DEFAULT_STATS_CONFIG;
|
||||
} catch {
|
||||
return JSON.parse(host.statsConfig);
|
||||
} catch (e) {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
})();
|
||||
const shouldShowStatus =
|
||||
statsConfig.statusCheckEnabled !==
|
||||
false;
|
||||
const serverStatus = getStatus(host.id);
|
||||
const shouldShowStatus = ![
|
||||
false,
|
||||
"false",
|
||||
].includes(
|
||||
statsConfig.statusCheckEnabled,
|
||||
);
|
||||
|
||||
return shouldShowStatus ? (
|
||||
if (!shouldShowStatus) return null;
|
||||
|
||||
const serverStatus = getStatus(host.id);
|
||||
return (
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
) : null;
|
||||
);
|
||||
})()}
|
||||
{host.pin && (
|
||||
<Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
<h3 className="font-medium truncate text-sm">
|
||||
{host.name ||
|
||||
`${host.username}@${host.ip}`}
|
||||
(host.username
|
||||
? `${host.username}@${host.ip}`
|
||||
: host.ip)}
|
||||
</h3>
|
||||
{(host as any).isShared && (
|
||||
<Badge
|
||||
@@ -1429,9 +1513,11 @@ export function HostManagerViewer({
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.username}
|
||||
</p>
|
||||
{host.username && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.username}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
ID: {host.id}
|
||||
</p>
|
||||
@@ -1569,74 +1655,118 @@ export function HostManagerViewer({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
{host.enableTerminal && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"terminal",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
{t("hosts.copyTerminalUrl")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"file-manager",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
{t("hosts.copyFileManagerUrl")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"tunnel",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ArrowDownUp className="h-4 w-4 mr-2" />
|
||||
{t("hosts.copyTunnelUrl")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"server-stats",
|
||||
{(() => {
|
||||
const connType = (host as any)
|
||||
.connectionType;
|
||||
const isRemoteDesktop =
|
||||
connType === "rdp" ||
|
||||
connType === "vnc" ||
|
||||
connType === "telnet";
|
||||
|
||||
if (isRemoteDesktop) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
connType,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{connType === "rdp" ? (
|
||||
<Monitor className="h-4 w-4 mr-2" />
|
||||
) : connType === "vnc" ? (
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<MessagesSquare className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t(
|
||||
"hosts.copyRemoteDesktopUrl",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
{t("hosts.copyServerStatsUrl")}
|
||||
</DropdownMenuItem>
|
||||
{host.enableDocker && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"docker",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Container className="h-4 w-4 mr-2" />
|
||||
{t("hosts.copyDockerUrl")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{host.enableTerminal && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"terminal",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
{t(
|
||||
"hosts.copyTerminalUrl",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"file-manager",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
{t(
|
||||
"hosts.copyFileManagerUrl",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"tunnel",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ArrowDownUp className="h-4 w-4 mr-2" />
|
||||
{t("hosts.copyTunnelUrl")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"server-stats",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
{t(
|
||||
"hosts.copyServerStatsUrl",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{host.enableDocker && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"docker",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Container className="h-4 w-4 mr-2" />
|
||||
{t("hosts.copyDockerUrl")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
@@ -1671,185 +1801,339 @@ export function HostManagerViewer({
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.enableTerminal && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Terminal className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.terminalBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Network className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.tunnelBadge")}
|
||||
{host.tunnelConnections &&
|
||||
host.tunnelConnections.length > 0 && (
|
||||
<span className="ml-0.5">
|
||||
({host.tunnelConnections.length})
|
||||
</span>
|
||||
{(() => {
|
||||
const connType = (host as any)
|
||||
.connectionType;
|
||||
if (connType === "rdp") {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Monitor className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.rdp")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (connType === "vnc") {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Eye className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.vnc")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (connType === "telnet") {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<MessagesSquare className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.telnet")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{host.enableTerminal && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Terminal className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.terminalBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<FileEdit className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.fileManagerBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableDocker && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Container className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.docker")}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Network className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.tunnelBadge")}
|
||||
{host.tunnelConnections &&
|
||||
host.tunnelConnections.length >
|
||||
0 && (
|
||||
<span className="ml-0.5">
|
||||
(
|
||||
{
|
||||
host.tunnelConnections
|
||||
.length
|
||||
}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<FileEdit className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.fileManagerBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableDocker && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Container className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.docker")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1">
|
||||
{host.enableTerminal && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "terminal",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("hosts.openTerminal")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-emerald-500/10 hover:border-emerald-500/50 flex-1"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open File Manager</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "tunnel",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-orange-500/10 hover:border-orange-500/50 flex-1"
|
||||
>
|
||||
<ArrowDownUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Tunnels</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{host.enableDocker && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "docker",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-cyan-500/10 hover:border-cyan-500/50 flex-1"
|
||||
>
|
||||
<Container className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("hosts.openDocker")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "server_stats",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-purple-500/10 hover:border-purple-500/50 flex-1"
|
||||
>
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Server Details</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{(() => {
|
||||
const connType = (host as any)
|
||||
.connectionType;
|
||||
const isRemoteDesktop =
|
||||
connType === "rdp" ||
|
||||
connType === "vnc" ||
|
||||
connType === "telnet";
|
||||
|
||||
if (isRemoteDesktop) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.ip}:${host.port}`;
|
||||
|
||||
try {
|
||||
const protocol = connType as
|
||||
| "rdp"
|
||||
| "vnc"
|
||||
| "telnet";
|
||||
const result =
|
||||
await getGuacamoleToken({
|
||||
protocol,
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
ignoreCert:
|
||||
host.ignoreCert,
|
||||
guacamoleConfig:
|
||||
host.guacamoleConfig as any,
|
||||
});
|
||||
|
||||
addTab({
|
||||
type: protocol,
|
||||
title,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: result.token,
|
||||
protocol,
|
||||
type: protocol,
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert":
|
||||
host.ignoreCert,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity(
|
||||
protocol,
|
||||
host.id,
|
||||
title,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Failed to log ${protocol} activity:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to connect: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-indigo-500/10 hover:border-indigo-500/50 flex-1"
|
||||
>
|
||||
{connType === "rdp" ? (
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
) : connType === "vnc" ? (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<MessagesSquare className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("hosts.remoteDesktop")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{host.enableTerminal && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title =
|
||||
host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "terminal",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("hosts.openTerminal")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title =
|
||||
host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-emerald-500/10 hover:border-emerald-500/50 flex-1"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t("hosts.openFileManager")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title =
|
||||
host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "tunnel",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-orange-500/10 hover:border-orange-500/50 flex-1"
|
||||
>
|
||||
<ArrowDownUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("hosts.openTunnels")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{host.enableDocker && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title =
|
||||
host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "docker",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-cyan-500/10 hover:border-cyan-500/50 flex-1"
|
||||
>
|
||||
<Container className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("hosts.openDocker")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "server_stats",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-purple-500/10 hover:border-purple-500/50 flex-1"
|
||||
>
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("hosts.openServerStats")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,797 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import type { HostRemoteDesktopTabProps } from "./shared/tab-types";
|
||||
|
||||
function GuacField({
|
||||
form,
|
||||
path,
|
||||
label,
|
||||
description,
|
||||
type = "text",
|
||||
t,
|
||||
}: {
|
||||
form: any;
|
||||
path: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
type?: "text" | "number" | "password" | "switch";
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const fieldName = `guacamoleConfig.${path}` as any;
|
||||
|
||||
if (type === "switch") {
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={fieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{label}</FormLabel>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "password") {
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={fieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
value={field.value || ""}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={fieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type={type}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
type === "number"
|
||||
? e.target.value === ""
|
||||
? undefined
|
||||
: parseInt(e.target.value)
|
||||
: e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GuacSelect({
|
||||
form,
|
||||
path,
|
||||
label,
|
||||
options,
|
||||
placeholder,
|
||||
}: {
|
||||
form: any;
|
||||
path: string;
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const fieldName = `guacamoleConfig.${path}` as any;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={fieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<Select
|
||||
value={field.value || "auto"}
|
||||
onValueChange={(v) => field.onChange(v === "auto" ? "" : v)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={placeholder || label} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostRemoteDesktopTab({
|
||||
form,
|
||||
connectionType,
|
||||
t,
|
||||
}: HostRemoteDesktopTabProps) {
|
||||
const isRDP = connectionType === "rdp";
|
||||
const isVNC = connectionType === "vnc";
|
||||
const isTelnet = connectionType === "telnet";
|
||||
|
||||
return (
|
||||
<div className="pt-2 space-y-4">
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={["connection", "display"]}
|
||||
className="w-full"
|
||||
>
|
||||
{/* Connection Settings */}
|
||||
{isRDP && (
|
||||
<AccordionItem value="connection">
|
||||
<AccordionTrigger>{t("hosts.connectionSettings")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="security"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.securityMode")}</FormLabel>
|
||||
<Select
|
||||
value={field.value || "any"}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">Any</SelectItem>
|
||||
<SelectItem value="nla">NLA</SelectItem>
|
||||
<SelectItem value="nla-ext">NLA Extended</SelectItem>
|
||||
<SelectItem value="tls">TLS</SelectItem>
|
||||
<SelectItem value="vmconnect">VMConnect</SelectItem>
|
||||
<SelectItem value="rdp">RDP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ignoreCert"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t("hosts.ignoreCert")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.ignoreCertDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* Display Settings */}
|
||||
<AccordionItem value="display">
|
||||
<AccordionTrigger>{t("hosts.displaySettings")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
{!isTelnet && (
|
||||
<GuacSelect
|
||||
form={form}
|
||||
path="color-depth"
|
||||
label={t("hosts.colorDepth")}
|
||||
options={[
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "8", label: "8-bit" },
|
||||
{ value: "16", label: "16-bit" },
|
||||
{ value: "24", label: "24-bit" },
|
||||
{ value: "32", label: "32-bit" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<GuacField
|
||||
form={form}
|
||||
path="width"
|
||||
label={t("hosts.width")}
|
||||
type="number"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="height"
|
||||
label={t("hosts.height")}
|
||||
type="number"
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
{!isTelnet && (
|
||||
<>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="dpi"
|
||||
label={t("hosts.dpi")}
|
||||
type="number"
|
||||
t={t}
|
||||
/>
|
||||
<GuacSelect
|
||||
form={form}
|
||||
path="resize-method"
|
||||
label={t("hosts.resizeMethod")}
|
||||
options={[
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "display-update", label: "Display Update" },
|
||||
{ value: "reconnect", label: "Reconnect" },
|
||||
]}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="force-lossless"
|
||||
label={t("hosts.forceLossless")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Telnet Terminal Settings */}
|
||||
{isTelnet && (
|
||||
<AccordionItem value="telnet-terminal">
|
||||
<AccordionTrigger>
|
||||
{t("hosts.telnetTerminalSettings")}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<GuacSelect
|
||||
form={form}
|
||||
path="terminal-type"
|
||||
label={t("hosts.terminalType")}
|
||||
options={[
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "xterm", label: "xterm" },
|
||||
{ value: "xterm-256color", label: "xterm-256color" },
|
||||
{ value: "vt100", label: "VT100" },
|
||||
{ value: "vt220", label: "VT220" },
|
||||
]}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="font-name"
|
||||
label={t("hosts.guacFontName")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="font-size"
|
||||
label={t("hosts.guacFontSize")}
|
||||
type="number"
|
||||
t={t}
|
||||
/>
|
||||
<GuacSelect
|
||||
form={form}
|
||||
path="color-scheme"
|
||||
label={t("hosts.guacColorScheme")}
|
||||
options={[
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "black-white", label: "Black on White" },
|
||||
{ value: "white-black", label: "White on Black" },
|
||||
{ value: "gray-black", label: "Gray on Black" },
|
||||
{ value: "green-black", label: "Green on Black" },
|
||||
]}
|
||||
/>
|
||||
<GuacSelect
|
||||
form={form}
|
||||
path="backspace"
|
||||
label={t("hosts.guacBackspaceKey")}
|
||||
options={[
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "127", label: "DEL (127)" },
|
||||
{ value: "8", label: "BS (8)" },
|
||||
]}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* Audio Settings */}
|
||||
{(isRDP || isVNC) && (
|
||||
<AccordionItem value="audio">
|
||||
<AccordionTrigger>{t("hosts.audioSettings")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<GuacField
|
||||
form={form}
|
||||
path="disable-audio"
|
||||
label={t("hosts.disableAudio")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
{isRDP && (
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-audio-input"
|
||||
label={t("hosts.enableAudioInput")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* RDP Performance */}
|
||||
{isRDP && (
|
||||
<AccordionItem value="performance">
|
||||
<AccordionTrigger>{t("hosts.rdpPerformance")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-wallpaper"
|
||||
label={t("hosts.enableWallpaper")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-theming"
|
||||
label={t("hosts.enableTheming")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-font-smoothing"
|
||||
label={t("hosts.enableFontSmoothing")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-full-window-drag"
|
||||
label={t("hosts.enableFullWindowDrag")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-desktop-composition"
|
||||
label={t("hosts.enableDesktopComposition")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-menu-animations"
|
||||
label={t("hosts.enableMenuAnimations")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="disable-bitmap-caching"
|
||||
label={t("hosts.disableBitmapCaching")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="disable-offscreen-caching"
|
||||
label={t("hosts.disableOffscreenCaching")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="disable-glyph-caching"
|
||||
label={t("hosts.disableGlyphCaching")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-gfx"
|
||||
label={t("hosts.enableGfx")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* RDP Device Redirection */}
|
||||
{isRDP && (
|
||||
<AccordionItem value="device-redirection">
|
||||
<AccordionTrigger>{t("hosts.deviceRedirection")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-printing"
|
||||
label={t("hosts.enablePrinting")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-drive"
|
||||
label={t("hosts.enableDrive")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="drive-name"
|
||||
label={t("hosts.driveName")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="drive-path"
|
||||
label={t("hosts.drivePath")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="create-drive-path"
|
||||
label={t("hosts.createDrivePath")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="disable-download"
|
||||
label={t("hosts.disableDownload")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="disable-upload"
|
||||
label={t("hosts.disableUpload")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-touch"
|
||||
label={t("hosts.enableTouch")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* RDP Session */}
|
||||
{isRDP && (
|
||||
<AccordionItem value="session">
|
||||
<AccordionTrigger>{t("hosts.rdpSession")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<GuacField
|
||||
form={form}
|
||||
path="client-name"
|
||||
label={t("hosts.clientName")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="console"
|
||||
label={t("hosts.consoleSession")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="initial-program"
|
||||
label={t("hosts.initialProgram")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacSelect
|
||||
form={form}
|
||||
path="server-layout"
|
||||
label={t("hosts.serverLayout")}
|
||||
options={[
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "en-us-qwerty", label: "English (US) QWERTY" },
|
||||
{ value: "en-gb-qwerty", label: "English (UK) QWERTY" },
|
||||
{ value: "de-de-qwertz", label: "German QWERTZ" },
|
||||
{ value: "fr-fr-azerty", label: "French AZERTY" },
|
||||
{ value: "it-it-qwerty", label: "Italian QWERTY" },
|
||||
{ value: "sv-se-qwerty", label: "Swedish QWERTY" },
|
||||
{ value: "ja-jp-qwerty", label: "Japanese QWERTY" },
|
||||
{ value: "pt-br-qwerty", label: "Portuguese (BR) QWERTY" },
|
||||
{ value: "es-es-qwerty", label: "Spanish QWERTY" },
|
||||
{ value: "failsafe", label: "Failsafe (Unicode)" },
|
||||
]}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="timezone"
|
||||
label={t("hosts.timezone")}
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* RDP Gateway */}
|
||||
{isRDP && (
|
||||
<AccordionItem value="gateway">
|
||||
<AccordionTrigger>{t("hosts.gatewaySettings")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<GuacField
|
||||
form={form}
|
||||
path="gateway-hostname"
|
||||
label={t("hosts.gatewayHostname")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="gateway-port"
|
||||
label={t("hosts.gatewayPort")}
|
||||
type="number"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="gateway-username"
|
||||
label={t("hosts.gatewayUsername")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="gateway-password"
|
||||
label={t("hosts.gatewayPassword")}
|
||||
type="password"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="gateway-domain"
|
||||
label={t("hosts.gatewayDomain")}
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* RDP RemoteApp */}
|
||||
{isRDP && (
|
||||
<AccordionItem value="remoteapp">
|
||||
<AccordionTrigger>{t("hosts.remoteApp")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<GuacField
|
||||
form={form}
|
||||
path="remote-app"
|
||||
label={t("hosts.remoteAppProgram")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="remote-app-dir"
|
||||
label={t("hosts.remoteAppDir")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="remote-app-args"
|
||||
label={t("hosts.remoteAppArgs")}
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* Clipboard */}
|
||||
<AccordionItem value="clipboard">
|
||||
<AccordionTrigger>{t("hosts.clipboardSettings")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<GuacSelect
|
||||
form={form}
|
||||
path="normalize-clipboard"
|
||||
label={t("hosts.normalizeClipboard")}
|
||||
options={[
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "preserve", label: "Preserve" },
|
||||
{ value: "unix", label: "Unix (LF)" },
|
||||
{ value: "windows", label: "Windows (CRLF)" },
|
||||
]}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="disable-copy"
|
||||
label={t("hosts.disableCopy")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="disable-paste"
|
||||
label={t("hosts.disablePaste")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* VNC Specific */}
|
||||
{isVNC && (
|
||||
<AccordionItem value="vnc-specific">
|
||||
<AccordionTrigger>{t("hosts.vncSettings")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<GuacSelect
|
||||
form={form}
|
||||
path="cursor"
|
||||
label={t("hosts.cursorMode")}
|
||||
options={[
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "local", label: "Local" },
|
||||
{ value: "remote", label: "Remote" },
|
||||
]}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="swap-red-blue"
|
||||
label={t("hosts.swapRedBlue")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="read-only"
|
||||
label={t("hosts.readOnly")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* Recording */}
|
||||
<AccordionItem value="recording">
|
||||
<AccordionTrigger>{t("hosts.recordingSettings")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<GuacField
|
||||
form={form}
|
||||
path="recording-path"
|
||||
label={t("hosts.recordingPath")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="recording-name"
|
||||
label={t("hosts.recordingName")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="create-recording-path"
|
||||
label={t("hosts.createRecordingPath")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="recording-exclude-output"
|
||||
label={t("hosts.excludeOutput")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="recording-exclude-mouse"
|
||||
label={t("hosts.excludeMouse")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="recording-include-keys"
|
||||
label={t("hosts.includeKeys")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Wake-on-LAN */}
|
||||
<AccordionItem value="wol">
|
||||
<AccordionTrigger>{t("hosts.wakeOnLan")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<GuacField
|
||||
form={form}
|
||||
path="wol-send-packet"
|
||||
label={t("hosts.sendWolPacket")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="wol-mac-addr"
|
||||
label={t("hosts.wolMacAddr")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="wol-broadcast-addr"
|
||||
label={t("hosts.wolBroadcastAddr")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="wol-udp-port"
|
||||
label={t("hosts.wolUdpPort")}
|
||||
type="number"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="wol-wait-time"
|
||||
label={t("hosts.wolWaitTime")}
|
||||
type="number"
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import type { HostStatisticsTabProps } from "./shared/tab-types";
|
||||
|
||||
export function HostStatusTab({
|
||||
form,
|
||||
statusIntervalUnit,
|
||||
setStatusIntervalUnit,
|
||||
t,
|
||||
}: Pick<
|
||||
HostStatisticsTabProps,
|
||||
"form" | "statusIntervalUnit" | "setStatusIntervalUnit" | "t"
|
||||
>) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="statsConfig.statusCheckEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t("hosts.statusCheckEnabled")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.statusCheckEnabledDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("statsConfig.statusCheckEnabled") && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="statsConfig.useGlobalStatusInterval"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value ?? true}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
{t("hosts.useGlobalStatusInterval")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch("statsConfig.useGlobalStatusInterval") === false && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="statsConfig.statusCheckInterval"
|
||||
render={({ field }) => {
|
||||
const displayValue =
|
||||
statusIntervalUnit === "minutes"
|
||||
? Math.round((field.value || 30) / 60)
|
||||
: field.value || 30;
|
||||
|
||||
const handleIntervalChange = (value: string) => {
|
||||
const numValue = parseInt(value) || 0;
|
||||
const seconds =
|
||||
statusIntervalUnit === "minutes"
|
||||
? numValue * 60
|
||||
: numValue;
|
||||
field.onChange(seconds);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.statusCheckInterval")}</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
value={displayValue}
|
||||
onChange={(e) =>
|
||||
handleIntervalChange(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
<Select
|
||||
value={statusIntervalUnit}
|
||||
onValueChange={(value: "seconds" | "minutes") => {
|
||||
setStatusIntervalUnit(value);
|
||||
const currentSeconds = field.value || 30;
|
||||
if (value === "minutes") {
|
||||
const minutes = Math.round(currentSeconds / 60);
|
||||
field.onChange(minutes * 60);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="seconds">
|
||||
{t("hosts.intervalSeconds")}
|
||||
</SelectItem>
|
||||
<SelectItem value="minutes">
|
||||
{t("hosts.intervalMinutes")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t("hosts.statusCheckIntervalDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { SSHHost, Credential } from "@/types";
|
||||
|
||||
export interface HostGeneralTabProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
connectionType: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
authTab: "password" | "key" | "credential" | "none";
|
||||
setAuthTab: (value: "password" | "key" | "credential" | "none") => void;
|
||||
keyInputMethod: "upload" | "paste";
|
||||
@@ -75,6 +76,12 @@ export interface HostStatisticsTabProps {
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export interface HostRemoteDesktopTabProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
connectionType: "rdp" | "vnc" | "telnet";
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export interface HostSharingTabProps {
|
||||
hostId: number | undefined;
|
||||
isNewHost: boolean;
|
||||
|
||||
@@ -251,7 +251,10 @@ export function SSHToolsSidebar({
|
||||
tab.type === "server_stats" ||
|
||||
tab.type === "file_manager" ||
|
||||
tab.type === "tunnel" ||
|
||||
tab.type === "docker",
|
||||
tab.type === "docker" ||
|
||||
tab.type === "rdp" ||
|
||||
tab.type === "vnc" ||
|
||||
tab.type === "telnet",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -615,7 +615,7 @@ export function Auth({
|
||||
async function handleOIDCLogin() {
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
const authResponse = await getOIDCAuthorizeUrl();
|
||||
const authResponse = await getOIDCAuthorizeUrl(rememberMe);
|
||||
const { auth_url: authUrl } = authResponse;
|
||||
|
||||
if (!authUrl || authUrl === "undefined") {
|
||||
@@ -1236,16 +1236,30 @@ export function Auth({
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading
|
||||
? Spinner
|
||||
: t("auth.loginWithExternal")}
|
||||
</Button>
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="rememberMeOIDC"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(checked) =>
|
||||
setRememberMe(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="rememberMeOIDC">
|
||||
{t("auth.rememberMe")}
|
||||
</Label>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading
|
||||
? Spinner
|
||||
: t("auth.loginWithExternal")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
@@ -2,6 +2,10 @@ import React, { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { Terminal } from "@/ui/desktop/apps/features/terminal/Terminal.tsx";
|
||||
import { ServerStats as ServerView } from "@/ui/desktop/apps/features/server-stats/ServerStats.tsx";
|
||||
import { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
|
||||
import {
|
||||
GuacamoleDisplay,
|
||||
type GuacamoleConnectionConfig,
|
||||
} from "@/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
|
||||
import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.tsx";
|
||||
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
|
||||
@@ -14,6 +18,7 @@ import {
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
@@ -34,6 +39,7 @@ interface TabData {
|
||||
};
|
||||
};
|
||||
hostConfig?: any;
|
||||
connectionConfig?: GuacamoleConnectionConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -105,6 +111,9 @@ export function AppView({
|
||||
tab.type === "terminal" ||
|
||||
tab.type === "server_stats" ||
|
||||
tab.type === "file_manager" ||
|
||||
tab.type === "rdp" ||
|
||||
tab.type === "vnc" ||
|
||||
tab.type === "telnet" ||
|
||||
tab.type === "tunnel" ||
|
||||
tab.type === "docker" ||
|
||||
tab.type === "network_graph",
|
||||
@@ -376,6 +385,7 @@ export function AppView({
|
||||
>
|
||||
{t.type === "terminal" ? (
|
||||
<Terminal
|
||||
key={`term-${t.id}-${t.instanceId || ""}`}
|
||||
ref={t.terminalRef}
|
||||
hostConfig={t.hostConfig}
|
||||
isVisible={effectiveVisible}
|
||||
@@ -387,14 +397,35 @@ export function AppView({
|
||||
/>
|
||||
) : t.type === "server_stats" ? (
|
||||
<ServerView
|
||||
key={`stats-${t.id}-${t.instanceId || ""}`}
|
||||
hostConfig={t.hostConfig}
|
||||
title={t.title}
|
||||
isVisible={effectiveVisible}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
embedded
|
||||
/>
|
||||
) : t.type === "rdp" ||
|
||||
t.type === "vnc" ||
|
||||
t.type === "telnet" ? (
|
||||
t.connectionConfig ? (
|
||||
<GuacamoleDisplay
|
||||
key={`guac-${t.id}-${t.instanceId || ""}`}
|
||||
connectionConfig={t.connectionConfig}
|
||||
isVisible={effectiveVisible}
|
||||
onDisconnect={() => removeTab(t.id)}
|
||||
onError={(err) => {
|
||||
toast.error(err);
|
||||
removeTab(t.id);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-red-500">
|
||||
Missing connection configuration
|
||||
</div>
|
||||
)
|
||||
) : t.type === "network_graph" ? (
|
||||
<NetworkGraphCard
|
||||
key={`netgraph-${t.id}-${t.instanceId || ""}`}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
@@ -402,6 +433,7 @@ export function AppView({
|
||||
/>
|
||||
) : t.type === "tunnel" ? (
|
||||
<TunnelManager
|
||||
key={`tunnel-${t.id}-${t.instanceId || ""}`}
|
||||
hostConfig={t.hostConfig}
|
||||
title={t.title}
|
||||
isVisible={effectiveVisible}
|
||||
@@ -410,6 +442,7 @@ export function AppView({
|
||||
/>
|
||||
) : t.type === "docker" ? (
|
||||
<DockerManager
|
||||
key={`docker-${t.id}-${t.instanceId || ""}`}
|
||||
hostConfig={t.hostConfig}
|
||||
title={t.title}
|
||||
isVisible={effectiveVisible}
|
||||
@@ -419,6 +452,7 @@ export function AppView({
|
||||
/>
|
||||
) : (
|
||||
<FileManager
|
||||
key={`filemgr-${t.id}-${t.instanceId || ""}`}
|
||||
embedded
|
||||
initialHost={t.hostConfig}
|
||||
onClose={() => removeTab(t.id)}
|
||||
|
||||
@@ -36,30 +36,7 @@ import { Button } from "@/components/ui/button.tsx";
|
||||
import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx";
|
||||
import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import type { SSHFolder } from "@/types/index.ts";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: unknown[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
import type { SSHFolder, SSHHost } from "@/types/index.ts";
|
||||
|
||||
interface SidebarProps {
|
||||
disabled?: boolean;
|
||||
@@ -218,37 +195,41 @@ export function LeftSidebar({
|
||||
setTimeout(() => {
|
||||
setHosts(newHosts);
|
||||
prevHostsRef.current = newHosts;
|
||||
|
||||
newHosts.forEach((newHost) => {
|
||||
updateHostConfig(newHost.id, newHost);
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
} catch {
|
||||
setHostsError(t("leftSidebar.failedToLoadHosts"));
|
||||
}
|
||||
}, [updateHostConfig]);
|
||||
}, [t]);
|
||||
|
||||
const fetchHostsRef = React.useRef(fetchHosts);
|
||||
const fetchFolderMetadataRef = React.useRef(fetchFolderMetadata);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchHosts();
|
||||
fetchFolderMetadata();
|
||||
fetchHostsRef.current = fetchHosts;
|
||||
fetchFolderMetadataRef.current = fetchFolderMetadata;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchHostsRef.current();
|
||||
fetchFolderMetadataRef.current();
|
||||
const interval = setInterval(() => {
|
||||
fetchHosts();
|
||||
fetchFolderMetadata();
|
||||
fetchHostsRef.current();
|
||||
fetchFolderMetadataRef.current();
|
||||
}, 300000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHosts, fetchFolderMetadata]);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleHostsChanged = () => {
|
||||
fetchHosts();
|
||||
fetchFolderMetadata();
|
||||
fetchHostsRef.current();
|
||||
fetchFolderMetadataRef.current();
|
||||
};
|
||||
const handleCredentialsChanged = () => {
|
||||
fetchHosts();
|
||||
fetchHostsRef.current();
|
||||
};
|
||||
const handleFoldersChanged = () => {
|
||||
fetchFolderMetadata();
|
||||
fetchFolderMetadataRef.current();
|
||||
};
|
||||
window.addEventListener(
|
||||
"ssh-hosts:changed",
|
||||
@@ -276,7 +257,7 @@ export function LeftSidebar({
|
||||
handleFoldersChanged as EventListener,
|
||||
);
|
||||
};
|
||||
}, [fetchHosts, fetchFolderMetadata]);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||
@@ -549,7 +530,7 @@ export function LeftSidebar({
|
||||
const metadata = folderMetadata.get(folder);
|
||||
return (
|
||||
<FolderCard
|
||||
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
|
||||
key={`folder-${folder}`}
|
||||
folderName={folder}
|
||||
hosts={getSortedHosts(hostsByFolder[folder])}
|
||||
isFirst={idx === 0}
|
||||
|
||||
@@ -376,6 +376,8 @@ export function TopNavbar({
|
||||
const isSshManager = tab.type === "ssh_manager";
|
||||
const isAdmin = tab.type === "admin";
|
||||
const isUserProfile = tab.type === "user_profile";
|
||||
const isRdp = tab.type === "rdp";
|
||||
const isVnc = tab.type === "vnc";
|
||||
const isSplittable =
|
||||
isTerminal || isServer || isFileManager || isTunnel || isDocker;
|
||||
const disableSplit = !isSplittable;
|
||||
@@ -495,6 +497,8 @@ export function TopNavbar({
|
||||
isSshManager ||
|
||||
isAdmin ||
|
||||
isUserProfile ||
|
||||
isRdp ||
|
||||
isVnc ||
|
||||
tab.type === "network_graph"
|
||||
? () => handleTabClose(tab.id)
|
||||
: undefined
|
||||
@@ -512,6 +516,8 @@ export function TopNavbar({
|
||||
isSshManager ||
|
||||
isAdmin ||
|
||||
isUserProfile ||
|
||||
isRdp ||
|
||||
isVnc ||
|
||||
tab.type === "network_graph"
|
||||
}
|
||||
disableActivate={disableActivate}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { ButtonGroup } from "@/components/ui/button-group";
|
||||
import {
|
||||
EllipsisVertical,
|
||||
Terminal,
|
||||
Monitor,
|
||||
Eye,
|
||||
MessagesSquare,
|
||||
Server,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
@@ -18,7 +21,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
|
||||
import { getSSHHosts } from "@/ui/main-axios";
|
||||
import { getSSHHosts, getGuacamoleToken, logActivity } from "@/ui/main-axios";
|
||||
import type { HostProps } from "../../../../types";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -44,10 +47,16 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
setHost(initialHost);
|
||||
}, [initialHost]);
|
||||
|
||||
useEffect(() => {
|
||||
const hostIdRef = React.useRef(host.id);
|
||||
|
||||
React.useEffect(() => {
|
||||
hostIdRef.current = host.id;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleHostsChanged = async () => {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === host.id);
|
||||
const updatedHost = hosts.find((h) => h.id === hostIdRef.current);
|
||||
if (updatedHost) {
|
||||
setHost(updatedHost);
|
||||
}
|
||||
@@ -56,7 +65,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [host.id]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleShowTagsChanged = () => {
|
||||
@@ -70,16 +79,21 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
}, []);
|
||||
|
||||
const statsConfig = useMemo(() => {
|
||||
if (!host.statsConfig) {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
if (typeof host.statsConfig === "object") {
|
||||
return host.statsConfig;
|
||||
}
|
||||
try {
|
||||
return host.statsConfig
|
||||
? JSON.parse(host.statsConfig)
|
||||
: DEFAULT_STATS_CONFIG;
|
||||
} catch {
|
||||
return JSON.parse(host.statsConfig);
|
||||
} catch (e) {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
}, [host.statsConfig]);
|
||||
|
||||
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
|
||||
const shouldShowStatus = ![false, "false"].includes(
|
||||
statsConfig.statusCheckEnabled,
|
||||
);
|
||||
const shouldShowMetrics = statsConfig.metricsEnabled !== false;
|
||||
|
||||
const serverStatus = useHostStatus(host.id, shouldShowStatus);
|
||||
@@ -96,18 +110,67 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
}
|
||||
}, [host.tunnelConnections]);
|
||||
|
||||
const handleTerminalClick = () => {
|
||||
const handleTerminalClick = async () => {
|
||||
if (
|
||||
host.connectionType === "rdp" ||
|
||||
host.connectionType === "vnc" ||
|
||||
host.connectionType === "telnet"
|
||||
) {
|
||||
try {
|
||||
const protocol = host.connectionType as "rdp" | "vnc" | "telnet";
|
||||
const result = await getGuacamoleToken({
|
||||
protocol,
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
ignoreCert: host.ignoreCert,
|
||||
guacamoleConfig: host.guacamoleConfig as any,
|
||||
});
|
||||
addTab({
|
||||
type: protocol,
|
||||
title,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: result.token,
|
||||
protocol,
|
||||
type: protocol,
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity(protocol, host.id, title);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to log ${protocol} activity:`, err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to get Guacamole token:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
addTab({ type: "terminal", title, hostConfig: host });
|
||||
};
|
||||
|
||||
const isSSH = !host.connectionType || host.connectionType === "ssh";
|
||||
|
||||
const visibleButtons = [
|
||||
host.enableTerminal && (host.showTerminalInSidebar ?? true),
|
||||
host.enableFileManager && (host.showFileManagerInSidebar ?? false),
|
||||
host.enableTunnel &&
|
||||
isSSH && host.enableFileManager && (host.showFileManagerInSidebar ?? false),
|
||||
isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
(host.showTunnelInSidebar ?? false),
|
||||
host.enableDocker && (host.showDockerInSidebar ?? false),
|
||||
shouldShowMetrics && (host.showServerStatsInSidebar ?? false),
|
||||
isSSH && host.enableDocker && (host.showDockerInSidebar ?? false),
|
||||
isSSH && shouldShowMetrics && (host.showServerStatsInSidebar ?? false),
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
@@ -133,11 +196,20 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={handleTerminalClick}
|
||||
>
|
||||
<Terminal />
|
||||
{host.connectionType === "rdp" ? (
|
||||
<Monitor />
|
||||
) : host.connectionType === "vnc" ? (
|
||||
<Eye />
|
||||
) : host.connectionType === "telnet" ? (
|
||||
<MessagesSquare />
|
||||
) : (
|
||||
<Terminal />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{host.enableFileManager &&
|
||||
{isSSH &&
|
||||
host.enableFileManager &&
|
||||
(host.showFileManagerInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -150,7 +222,8 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{host.enableTunnel &&
|
||||
{isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
(host.showTunnelInSidebar ?? false) && (
|
||||
<Button
|
||||
@@ -164,29 +237,33 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{host.enableDocker && (host.showDockerInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "docker", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<Container />
|
||||
</Button>
|
||||
)}
|
||||
{isSSH &&
|
||||
host.enableDocker &&
|
||||
(host.showDockerInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "docker", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<Container />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{shouldShowMetrics && (host.showServerStatsInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "server_stats", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<Server />
|
||||
</Button>
|
||||
)}
|
||||
{isSSH &&
|
||||
shouldShowMetrics &&
|
||||
(host.showServerStatsInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "server_stats", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<Server />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -211,11 +288,20 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
onClick={handleTerminalClick}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
{host.connectionType === "rdp" ? (
|
||||
<Monitor className="h-4 w-4" />
|
||||
) : host.connectionType === "vnc" ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : host.connectionType === "telnet" ? (
|
||||
<MessagesSquare className="h-4 w-4" />
|
||||
) : (
|
||||
<Terminal className="h-4 w-4" />
|
||||
)}
|
||||
<span className="flex-1">{t("hosts.openTerminal")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{shouldShowMetrics &&
|
||||
{isSSH &&
|
||||
shouldShowMetrics &&
|
||||
!(host.showServerStatsInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
@@ -227,7 +313,8 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
<span className="flex-1">{t("hosts.openServerStats")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableFileManager &&
|
||||
{isSSH &&
|
||||
host.enableFileManager &&
|
||||
!(host.showFileManagerInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
@@ -239,7 +326,8 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
<span className="flex-1">{t("hosts.openFileManager")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableTunnel &&
|
||||
{isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
!(host.showTunnelInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
@@ -252,17 +340,19 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
<span className="flex-1">{t("hosts.openTunnels")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableDocker && !(host.showDockerInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "docker", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Container className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openDocker")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSSH &&
|
||||
host.enableDocker &&
|
||||
!(host.showDockerInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "docker", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Container className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openDocker")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
Server as ServerIcon,
|
||||
Folder as FolderIcon,
|
||||
User as UserIcon,
|
||||
Monitor as MonitorIcon,
|
||||
Eye as EyeIcon,
|
||||
MessagesSquare as MessageSquareIcon,
|
||||
Network,
|
||||
ArrowDownUp as TunnelIcon,
|
||||
Container as DockerIcon,
|
||||
@@ -172,6 +175,9 @@ export function Tab({
|
||||
tabType === "terminal" ||
|
||||
tabType === "server_stats" ||
|
||||
tabType === "file_manager" ||
|
||||
tabType === "rdp" ||
|
||||
tabType === "vnc" ||
|
||||
tabType === "telnet" ||
|
||||
tabType === "tunnel" ||
|
||||
tabType === "docker" ||
|
||||
tabType === "user_profile"
|
||||
@@ -181,7 +187,6 @@ export function Tab({
|
||||
const isTunnel = tabType === "tunnel";
|
||||
const isDocker = tabType === "docker";
|
||||
const isUserProfile = tabType === "user_profile";
|
||||
|
||||
const displayTitle =
|
||||
title ||
|
||||
(isServer
|
||||
@@ -194,7 +199,9 @@ export function Tab({
|
||||
? t("nav.docker")
|
||||
: isUserProfile
|
||||
? t("nav.userProfile")
|
||||
: t("nav.terminal"));
|
||||
: tabType === "rdp" || tabType === "vnc" || tabType === "telnet"
|
||||
? tabType.toUpperCase()
|
||||
: t("nav.terminal"));
|
||||
|
||||
const { base, suffix } = splitTitle(displayTitle);
|
||||
|
||||
@@ -219,6 +226,12 @@ export function Tab({
|
||||
<DockerIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : isUserProfile ? (
|
||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : tabType === "rdp" ? (
|
||||
<MonitorIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : tabType === "vnc" ? (
|
||||
<EyeIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : tabType === "telnet" ? (
|
||||
<MessageSquareIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<TerminalIcon className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -51,7 +53,6 @@ interface TabProviderProps {
|
||||
export function clearTermixSessionStorage() {
|
||||
localStorage.removeItem("termix_tabs");
|
||||
localStorage.removeItem("termix_currentTab");
|
||||
// Clear all session keys
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
@@ -65,7 +66,6 @@ export function clearTermixSessionStorage() {
|
||||
export function TabProvider({ children }: TabProviderProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tabs, setTabs] = useState<Tab[]>(() => {
|
||||
// Check if persistence is enabled (always enabled for mobile/Electron)
|
||||
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
||||
const isElectron =
|
||||
typeof window !== "undefined" && !!(window as any).electronAPI;
|
||||
@@ -85,10 +85,9 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
let maxId = 1;
|
||||
for (const tab of parsed) {
|
||||
if (tab.type === "home") continue;
|
||||
// Preserve instanceId from saved tab - critical for session lookup
|
||||
const restoredTab: Tab = {
|
||||
...tab,
|
||||
instanceId: tab.instanceId, // Preserve instanceId for session persistence
|
||||
instanceId: tab.instanceId,
|
||||
terminalRef:
|
||||
tab.type === "terminal"
|
||||
? React.createRef<{ disconnect?: () => void }>()
|
||||
@@ -115,7 +114,6 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const saved = localStorage.getItem("termix_currentTab");
|
||||
if (saved) {
|
||||
const parsed = parseInt(saved, 10);
|
||||
// Validate against restored tabs
|
||||
if (parsed && tabs.some((t) => t.id === parsed)) return parsed;
|
||||
}
|
||||
} catch {
|
||||
@@ -133,9 +131,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
});
|
||||
const nextTabId = useRef(initialMaxId);
|
||||
|
||||
// Save tabs to localStorage on change
|
||||
useEffect(() => {
|
||||
// Check if persistence is enabled (always enabled for mobile/Electron)
|
||||
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
||||
const isElectron =
|
||||
typeof window !== "undefined" && !!(window as any).electronAPI;
|
||||
@@ -144,14 +140,12 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const shouldSave = isMobile || isElectron || persistenceEnabled;
|
||||
|
||||
if (shouldSave) {
|
||||
// Serialize tabs, preserving instanceId for session persistence
|
||||
const serializable = tabs
|
||||
.filter((t) => t.type !== "home")
|
||||
.map(({ terminalRef, ...rest }) => rest);
|
||||
localStorage.setItem("termix_tabs", JSON.stringify(serializable));
|
||||
localStorage.setItem("termix_currentTab", String(currentTab));
|
||||
} else {
|
||||
// Clear saved tabs when persistence is disabled
|
||||
localStorage.removeItem("termix_tabs");
|
||||
localStorage.removeItem("termix_currentTab");
|
||||
}
|
||||
@@ -346,72 +340,92 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const updateHostConfig = (
|
||||
hostId: number,
|
||||
newHostConfig: {
|
||||
id: number;
|
||||
name?: string;
|
||||
username: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
},
|
||||
) => {
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) => {
|
||||
if (tab.hostConfig && tab.hostConfig.id === hostId) {
|
||||
if (tab.type === "ssh_manager") {
|
||||
const updateHostConfig = useCallback(
|
||||
(
|
||||
hostId: number,
|
||||
newHostConfig: {
|
||||
id: number;
|
||||
name?: string;
|
||||
username: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
},
|
||||
) => {
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) => {
|
||||
if (tab.hostConfig && tab.hostConfig.id === hostId) {
|
||||
if (tab.type === "ssh_manager") {
|
||||
return {
|
||||
...tab,
|
||||
hostConfig: {
|
||||
...newHostConfig,
|
||||
instanceId: tab.hostConfig.instanceId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...tab,
|
||||
hostConfig: {
|
||||
...newHostConfig,
|
||||
instanceId: tab.hostConfig.instanceId, // Preserve instanceId for session persistence
|
||||
instanceId: tab.hostConfig.instanceId,
|
||||
},
|
||||
title: newHostConfig.name?.trim()
|
||||
? newHostConfig.name
|
||||
: t("nav.hostTabTitle", {
|
||||
username: newHostConfig.username,
|
||||
ip: newHostConfig.ip,
|
||||
port: newHostConfig.port,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return tab;
|
||||
}),
|
||||
);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
return {
|
||||
...tab,
|
||||
hostConfig: {
|
||||
...newHostConfig,
|
||||
instanceId: tab.hostConfig.instanceId, // Preserve instanceId for session persistence
|
||||
},
|
||||
title: newHostConfig.name?.trim()
|
||||
? newHostConfig.name
|
||||
: t("nav.hostTabTitle", {
|
||||
username: newHostConfig.username,
|
||||
ip: newHostConfig.ip,
|
||||
port: newHostConfig.port,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return tab;
|
||||
}),
|
||||
);
|
||||
};
|
||||
const updateTab = useCallback(
|
||||
(tabId: number, updates: Partial<Omit<Tab, "id">>) => {
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
tab.id === tabId
|
||||
? { ...tab, ...updates, _updateTimestamp: Date.now() }
|
||||
: tab,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateTab = (tabId: number, updates: Partial<Omit<Tab, "id">>) => {
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
tab.id === tabId
|
||||
? { ...tab, ...updates, _updateTimestamp: Date.now() }
|
||||
: tab,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const value: TabContextType = {
|
||||
tabs,
|
||||
currentTab,
|
||||
allSplitScreenTab,
|
||||
addTab,
|
||||
removeTab,
|
||||
setCurrentTab,
|
||||
setSplitScreenTab,
|
||||
getTab,
|
||||
reorderTabs,
|
||||
updateHostConfig,
|
||||
updateTab,
|
||||
};
|
||||
const value: TabContextType = useMemo(
|
||||
() => ({
|
||||
tabs,
|
||||
currentTab,
|
||||
allSplitScreenTab,
|
||||
addTab,
|
||||
removeTab,
|
||||
setCurrentTab,
|
||||
setSplitScreenTab,
|
||||
getTab,
|
||||
reorderTabs,
|
||||
updateHostConfig,
|
||||
updateTab,
|
||||
}),
|
||||
[
|
||||
tabs,
|
||||
currentTab,
|
||||
allSplitScreenTab,
|
||||
addTab,
|
||||
removeTab,
|
||||
setSplitScreenTab,
|
||||
getTab,
|
||||
reorderTabs,
|
||||
updateHostConfig,
|
||||
updateTab,
|
||||
],
|
||||
);
|
||||
|
||||
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
|
||||
}
|
||||
|
||||
+280
-32
@@ -731,8 +731,9 @@ function getApiUrl(path: string, defaultPort: number): string {
|
||||
}
|
||||
|
||||
function initializeApiInstances() {
|
||||
// SSH Host Management API (port 30001)
|
||||
sshHostApi = createApiInstance(getApiUrl("/ssh", 30001), "SSH_HOST");
|
||||
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
|
||||
hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST");
|
||||
sshHostApi = hostApi;
|
||||
|
||||
// Tunnel Management API (port 30003)
|
||||
tunnelApi = createApiInstance(getApiUrl("/ssh", 30003), "TUNNEL");
|
||||
@@ -759,7 +760,9 @@ function initializeApiInstances() {
|
||||
dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
|
||||
}
|
||||
|
||||
// SSH Host Management API (port 30001)
|
||||
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
|
||||
export let hostApi: AxiosInstance;
|
||||
// Backward compatibility
|
||||
export let sshHostApi: AxiosInstance;
|
||||
|
||||
// Tunnel Management API (port 30003)
|
||||
@@ -1020,6 +1023,7 @@ export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
|
||||
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
try {
|
||||
const submitData = {
|
||||
connectionType: hostData.connectionType || "ssh",
|
||||
name: hostData.name || "",
|
||||
ip: hostData.ip,
|
||||
port: parseInt(hostData.port.toString()) || 22,
|
||||
@@ -1028,7 +1032,12 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
tags: hostData.tags || [],
|
||||
pin: Boolean(hostData.pin),
|
||||
authType: hostData.authType,
|
||||
password: hostData.authType === "password" ? hostData.password : null,
|
||||
password:
|
||||
hostData.connectionType !== "ssh"
|
||||
? hostData.password || null
|
||||
: hostData.authType === "password"
|
||||
? hostData.password
|
||||
: null,
|
||||
key: hostData.authType === "key" ? hostData.key : null,
|
||||
keyPassword: hostData.authType === "key" ? hostData.keyPassword : null,
|
||||
keyType: hostData.authType === "key" ? hostData.keyType : null,
|
||||
@@ -1050,8 +1059,13 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
quickActions: hostData.quickActions || [],
|
||||
sudoPassword: hostData.sudoPassword || null,
|
||||
statsConfig: hostData.statsConfig || null,
|
||||
dockerConfig: hostData.dockerConfig || null,
|
||||
terminalConfig: hostData.terminalConfig || null,
|
||||
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
|
||||
domain: hostData.domain || null,
|
||||
security: hostData.security || null,
|
||||
ignoreCert: Boolean(hostData.ignoreCert),
|
||||
guacamoleConfig: hostData.guacamoleConfig || null,
|
||||
notes: hostData.notes || "",
|
||||
useSocks5: Boolean(hostData.useSocks5),
|
||||
socks5Host: hostData.socks5Host || null,
|
||||
@@ -1096,6 +1110,7 @@ export async function updateSSHHost(
|
||||
): Promise<SSHHost> {
|
||||
try {
|
||||
const submitData = {
|
||||
connectionType: hostData.connectionType || "ssh",
|
||||
name: hostData.name || "",
|
||||
ip: hostData.ip,
|
||||
port: parseInt(hostData.port.toString()) || 22,
|
||||
@@ -1104,7 +1119,12 @@ export async function updateSSHHost(
|
||||
tags: hostData.tags || [],
|
||||
pin: Boolean(hostData.pin),
|
||||
authType: hostData.authType,
|
||||
password: hostData.authType === "password" ? hostData.password : null,
|
||||
password:
|
||||
hostData.connectionType !== "ssh"
|
||||
? hostData.password || null
|
||||
: hostData.authType === "password"
|
||||
? hostData.password
|
||||
: null,
|
||||
key: hostData.authType === "key" ? hostData.key : null,
|
||||
keyPassword: hostData.authType === "key" ? hostData.keyPassword : null,
|
||||
keyType: hostData.authType === "key" ? hostData.keyType : null,
|
||||
@@ -1126,8 +1146,13 @@ export async function updateSSHHost(
|
||||
quickActions: hostData.quickActions || [],
|
||||
sudoPassword: hostData.sudoPassword || null,
|
||||
statsConfig: hostData.statsConfig || null,
|
||||
dockerConfig: hostData.dockerConfig || null,
|
||||
terminalConfig: hostData.terminalConfig || null,
|
||||
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
|
||||
domain: hostData.domain || null,
|
||||
security: hostData.security || null,
|
||||
ignoreCert: Boolean(hostData.ignoreCert),
|
||||
guacamoleConfig: hostData.guacamoleConfig || null,
|
||||
notes: hostData.notes || "",
|
||||
useSocks5: Boolean(hostData.useSocks5),
|
||||
socks5Host: hostData.socks5Host || null,
|
||||
@@ -1511,7 +1536,6 @@ export async function connectSSH(
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
// Preserve connection logs from error response
|
||||
if (error?.response?.data?.connectionLogs) {
|
||||
const errorWithLogs = new Error(
|
||||
error?.response?.data?.error ||
|
||||
@@ -1520,7 +1544,6 @@ export async function connectSSH(
|
||||
);
|
||||
(errorWithLogs as any).connectionLogs =
|
||||
error.response.data.connectionLogs;
|
||||
// Also preserve other fields like requires_totp
|
||||
if (error.response.data.requires_totp) {
|
||||
(errorWithLogs as any).requires_totp = true;
|
||||
(errorWithLogs as any).sessionId = error.response.data.sessionId;
|
||||
@@ -1653,7 +1676,7 @@ export async function quickConnect(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<SSHHost> {
|
||||
try {
|
||||
const response = await authApi.post("/ssh/quick-connect", data);
|
||||
const response = await authApi.post("/host/quick-connect", data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "quick connect");
|
||||
@@ -2135,7 +2158,7 @@ export async function getRecentFiles(
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/ssh/file_manager/recent", {
|
||||
const response = await authApi.get("/host/file_manager/recent", {
|
||||
params: { hostId },
|
||||
});
|
||||
return response.data;
|
||||
@@ -2151,7 +2174,7 @@ export async function addRecentFile(
|
||||
name?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/ssh/file_manager/recent", {
|
||||
const response = await authApi.post("/host/file_manager/recent", {
|
||||
hostId,
|
||||
path,
|
||||
name,
|
||||
@@ -2168,7 +2191,7 @@ export async function removeRecentFile(
|
||||
path: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete("/ssh/file_manager/recent", {
|
||||
const response = await authApi.delete("/host/file_manager/recent", {
|
||||
data: { hostId, path },
|
||||
});
|
||||
return response.data;
|
||||
@@ -2182,7 +2205,7 @@ export async function getPinnedFiles(
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/ssh/file_manager/pinned", {
|
||||
const response = await authApi.get("/host/file_manager/pinned", {
|
||||
params: { hostId },
|
||||
});
|
||||
return response.data;
|
||||
@@ -2198,7 +2221,7 @@ export async function addPinnedFile(
|
||||
name?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/ssh/file_manager/pinned", {
|
||||
const response = await authApi.post("/host/file_manager/pinned", {
|
||||
hostId,
|
||||
path,
|
||||
name,
|
||||
@@ -2215,7 +2238,7 @@ export async function removePinnedFile(
|
||||
path: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete("/ssh/file_manager/pinned", {
|
||||
const response = await authApi.delete("/host/file_manager/pinned", {
|
||||
data: { hostId, path },
|
||||
});
|
||||
return response.data;
|
||||
@@ -2229,7 +2252,7 @@ export async function getFolderShortcuts(
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/ssh/file_manager/shortcuts", {
|
||||
const response = await authApi.get("/host/file_manager/shortcuts", {
|
||||
params: { hostId },
|
||||
});
|
||||
return response.data;
|
||||
@@ -2245,7 +2268,7 @@ export async function addFolderShortcut(
|
||||
name?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/ssh/file_manager/shortcuts", {
|
||||
const response = await authApi.post("/host/file_manager/shortcuts", {
|
||||
hostId,
|
||||
path,
|
||||
name,
|
||||
@@ -2262,7 +2285,7 @@ export async function removeFolderShortcut(
|
||||
path: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete("/ssh/file_manager/shortcuts", {
|
||||
const response = await authApi.delete("/host/file_manager/shortcuts", {
|
||||
data: { hostId, path },
|
||||
});
|
||||
return response.data;
|
||||
@@ -2319,7 +2342,6 @@ export async function startMetricsPolling(hostId: number): Promise<{
|
||||
const response = await statsApi.post(`/metrics/start/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
// Preserve connection logs from error response
|
||||
if (error?.response?.data?.connectionLogs) {
|
||||
const errorWithLogs = new Error(
|
||||
error?.response?.data?.error || error.message,
|
||||
@@ -2449,6 +2471,33 @@ export async function updateGlobalMonitoringSettings(settings: {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GUACAMOLE SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getGuacamoleSettings(): Promise<{
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/guacamole-settings");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch guacamole settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGuacamoleSettings(settings: {
|
||||
enabled?: boolean;
|
||||
url?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await authApi.patch("/users/guacamole-settings", settings);
|
||||
} catch (error) {
|
||||
handleApiError(error, "update guacamole settings");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUTHENTICATION
|
||||
// ============================================================================
|
||||
@@ -2708,9 +2757,13 @@ export async function changePassword(oldPassword: string, newPassword: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOIDCAuthorizeUrl(): Promise<OIDCAuthorize> {
|
||||
export async function getOIDCAuthorizeUrl(
|
||||
rememberMe = false,
|
||||
): Promise<OIDCAuthorize> {
|
||||
try {
|
||||
const response = await authApi.get("/users/oidc/authorize");
|
||||
const response = await authApi.get("/users/oidc/authorize", {
|
||||
params: { rememberMe },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get OIDC authorize URL");
|
||||
@@ -3201,7 +3254,7 @@ export async function migrateHostToCredential(
|
||||
|
||||
export async function getFoldersWithStats(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/ssh/db/folders/with-stats");
|
||||
const response = await authApi.get("/host/db/folders/with-stats");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch folders with statistics");
|
||||
@@ -3213,7 +3266,7 @@ export async function renameFolder(
|
||||
newName: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.put("/ssh/folders/rename", {
|
||||
const response = await authApi.put("/host/folders/rename", {
|
||||
oldName,
|
||||
newName,
|
||||
});
|
||||
@@ -3229,7 +3282,7 @@ export async function getSSHFolders(): Promise<SSHFolder[]> {
|
||||
operation: "fetch_ssh_folders",
|
||||
});
|
||||
|
||||
const response = await authApi.get("/ssh/folders");
|
||||
const response = await authApi.get("/host/folders");
|
||||
|
||||
sshLogger.success("SSH folders fetched successfully", {
|
||||
operation: "fetch_ssh_folders",
|
||||
@@ -3259,7 +3312,7 @@ export async function updateFolderMetadata(
|
||||
icon,
|
||||
});
|
||||
|
||||
await authApi.put("/ssh/folders/metadata", {
|
||||
await authApi.put("/host/folders/metadata", {
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
@@ -3289,7 +3342,7 @@ export async function deleteAllHostsInFolder(
|
||||
});
|
||||
|
||||
const response = await authApi.delete(
|
||||
`/ssh/folders/${encodeURIComponent(folderName)}/hosts`,
|
||||
`/host/folders/${encodeURIComponent(folderName)}/hosts`,
|
||||
);
|
||||
|
||||
sshLogger.success("All hosts in folder deleted successfully", {
|
||||
@@ -3596,7 +3649,15 @@ export interface UptimeInfo {
|
||||
export interface RecentActivityItem {
|
||||
id: number;
|
||||
userId: string;
|
||||
type: "terminal" | "file_manager" | "server_stats" | "tunnel" | "docker";
|
||||
type:
|
||||
| "terminal"
|
||||
| "file_manager"
|
||||
| "server_stats"
|
||||
| "tunnel"
|
||||
| "docker"
|
||||
| "telnet"
|
||||
| "vnc"
|
||||
| "rdp";
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
timestamp: string;
|
||||
@@ -3625,7 +3686,15 @@ export async function getRecentActivity(
|
||||
}
|
||||
|
||||
export async function logActivity(
|
||||
type: "terminal" | "file_manager" | "server_stats" | "tunnel" | "docker",
|
||||
type:
|
||||
| "terminal"
|
||||
| "file_manager"
|
||||
| "server_stats"
|
||||
| "tunnel"
|
||||
| "docker"
|
||||
| "rdp"
|
||||
| "vnc"
|
||||
| "telnet",
|
||||
hostId: number,
|
||||
hostName: string,
|
||||
): Promise<{ message: string; id: number | string }> {
|
||||
@@ -3743,11 +3812,193 @@ export async function unlinkOIDCFromPasswordAccount(
|
||||
}
|
||||
}
|
||||
|
||||
export interface GuacamoleTokenRequest {
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
security?: string;
|
||||
ignoreCert?: boolean;
|
||||
guacamoleConfig?: {
|
||||
colorDepth?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
resizeMethod?: string;
|
||||
forceLossless?: boolean;
|
||||
disableAudio?: boolean;
|
||||
enableAudioInput?: boolean;
|
||||
enableWallpaper?: boolean;
|
||||
enableTheming?: boolean;
|
||||
enableFontSmoothing?: boolean;
|
||||
enableFullWindowDrag?: boolean;
|
||||
enableDesktopComposition?: boolean;
|
||||
enableMenuAnimations?: boolean;
|
||||
disableBitmapCaching?: boolean;
|
||||
disableOffscreenCaching?: boolean;
|
||||
disableGlyphCaching?: boolean;
|
||||
disableGfx?: boolean;
|
||||
enablePrinting?: boolean;
|
||||
printerName?: string;
|
||||
enableDrive?: boolean;
|
||||
driveName?: string;
|
||||
drivePath?: string;
|
||||
createDrivePath?: boolean;
|
||||
disableDownload?: boolean;
|
||||
disableUpload?: boolean;
|
||||
enableTouch?: boolean;
|
||||
clientName?: string;
|
||||
console?: boolean;
|
||||
initialProgram?: string;
|
||||
serverLayout?: string;
|
||||
timezone?: string;
|
||||
gatewayHostname?: string;
|
||||
gatewayPort?: number;
|
||||
gatewayUsername?: string;
|
||||
gatewayPassword?: string;
|
||||
gatewayDomain?: string;
|
||||
remoteApp?: string;
|
||||
remoteAppDir?: string;
|
||||
remoteAppArgs?: string;
|
||||
normalizeClipboard?: string;
|
||||
disableCopy?: boolean;
|
||||
disablePaste?: boolean;
|
||||
cursor?: string;
|
||||
swapRedBlue?: boolean;
|
||||
readOnly?: boolean;
|
||||
recordingPath?: string;
|
||||
recordingName?: string;
|
||||
createRecordingPath?: boolean;
|
||||
recordingExcludeOutput?: boolean;
|
||||
recordingExcludeMouse?: boolean;
|
||||
recordingIncludeKeys?: boolean;
|
||||
wolSendPacket?: boolean;
|
||||
wolMacAddr?: string;
|
||||
wolBroadcastAddr?: string;
|
||||
wolUdpPort?: number;
|
||||
wolWaitTime?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuacamoleTokenResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
function toGuacamoleParams(
|
||||
config: GuacamoleTokenRequest["guacamoleConfig"],
|
||||
): Record<string, unknown> {
|
||||
if (!config) return {};
|
||||
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
const mappings: Record<string, string> = {
|
||||
colorDepth: "color-depth",
|
||||
resizeMethod: "resize-method",
|
||||
forceLossless: "force-lossless",
|
||||
disableAudio: "disable-audio",
|
||||
enableAudioInput: "enable-audio-input",
|
||||
enableWallpaper: "enable-wallpaper",
|
||||
enableTheming: "enable-theming",
|
||||
enableFontSmoothing: "enable-font-smoothing",
|
||||
enableFullWindowDrag: "enable-full-window-drag",
|
||||
enableDesktopComposition: "enable-desktop-composition",
|
||||
enableMenuAnimations: "enable-menu-animations",
|
||||
disableBitmapCaching: "disable-bitmap-caching",
|
||||
disableOffscreenCaching: "disable-offscreen-caching",
|
||||
disableGlyphCaching: "disable-glyph-caching",
|
||||
disableGfx: "disable-gfx",
|
||||
enablePrinting: "enable-printing",
|
||||
printerName: "printer-name",
|
||||
enableDrive: "enable-drive",
|
||||
driveName: "drive-name",
|
||||
drivePath: "drive-path",
|
||||
createDrivePath: "create-drive-path",
|
||||
disableDownload: "disable-download",
|
||||
disableUpload: "disable-upload",
|
||||
enableTouch: "enable-touch",
|
||||
clientName: "client-name",
|
||||
initialProgram: "initial-program",
|
||||
serverLayout: "server-layout",
|
||||
gatewayHostname: "gateway-hostname",
|
||||
gatewayPort: "gateway-port",
|
||||
gatewayUsername: "gateway-username",
|
||||
gatewayPassword: "gateway-password",
|
||||
gatewayDomain: "gateway-domain",
|
||||
remoteApp: "remote-app",
|
||||
remoteAppDir: "remote-app-dir",
|
||||
remoteAppArgs: "remote-app-args",
|
||||
normalizeClipboard: "normalize-clipboard",
|
||||
disableCopy: "disable-copy",
|
||||
disablePaste: "disable-paste",
|
||||
swapRedBlue: "swap-red-blue",
|
||||
readOnly: "read-only",
|
||||
recordingPath: "recording-path",
|
||||
recordingName: "recording-name",
|
||||
createRecordingPath: "create-recording-path",
|
||||
recordingExcludeOutput: "recording-exclude-output",
|
||||
recordingExcludeMouse: "recording-exclude-mouse",
|
||||
recordingIncludeKeys: "recording-include-keys",
|
||||
wolSendPacket: "wol-send-packet",
|
||||
wolMacAddr: "wol-mac-addr",
|
||||
wolBroadcastAddr: "wol-broadcast-addr",
|
||||
wolUdpPort: "wol-udp-port",
|
||||
wolWaitTime: "wol-wait-time",
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
const paramName = mappings[key] || key;
|
||||
if (typeof value === "boolean") {
|
||||
params[paramName] = value ? "true" : "false";
|
||||
} else {
|
||||
params[paramName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function getGuacamoleToken(
|
||||
request: GuacamoleTokenRequest,
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
const guacParams = toGuacamoleParams(request.guacamoleConfig);
|
||||
|
||||
const response = await authApi.post("/guacamole/token", {
|
||||
type: request.protocol,
|
||||
hostname: request.hostname,
|
||||
port: request.port,
|
||||
username: request.username,
|
||||
password: request.password,
|
||||
domain: request.domain,
|
||||
security: request.security,
|
||||
"ignore-cert": request.ignoreCert,
|
||||
...guacParams,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get guacamole token");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGuacamoleTokenFromHost(
|
||||
hostId: number,
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
const response = await authApi.post(`/guacamole/connect-host/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get guacamole token from host");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RBAC MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
// Role Management
|
||||
export async function getRoles(): Promise<{ roles: Role[] }> {
|
||||
try {
|
||||
const response = await rbacApi.get("/rbac/roles");
|
||||
@@ -3796,7 +4047,6 @@ export async function deleteRole(
|
||||
}
|
||||
}
|
||||
|
||||
// User-Role Management
|
||||
export async function getUserRoles(
|
||||
userId: string,
|
||||
): Promise<{ roles: UserRole[] }> {
|
||||
@@ -3836,14 +4086,13 @@ export async function removeRoleFromUser(
|
||||
}
|
||||
}
|
||||
|
||||
// Host Sharing Management
|
||||
export async function shareHost(
|
||||
hostId: number,
|
||||
shareData: {
|
||||
targetType: "user" | "role";
|
||||
targetUserId?: string;
|
||||
targetRoleId?: number;
|
||||
permissionLevel: "view"; // Only view permission is supported
|
||||
permissionLevel: "view";
|
||||
durationHours?: number;
|
||||
},
|
||||
): Promise<{ success: boolean }> {
|
||||
@@ -3932,7 +4181,6 @@ export async function connectDockerSession(
|
||||
if (error.response?.data?.requires_warpgate) {
|
||||
return error.response.data;
|
||||
}
|
||||
// Preserve connection logs from error response
|
||||
if (error?.response?.data?.connectionLogs) {
|
||||
const errorWithLogs = new Error(
|
||||
error?.response?.data?.error ||
|
||||
|
||||
@@ -549,7 +549,7 @@ export function Auth({
|
||||
setError(null);
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
const authResponse = await getOIDCAuthorizeUrl();
|
||||
const authResponse = await getOIDCAuthorizeUrl(rememberMe);
|
||||
const { auth_url: authUrl } = authResponse;
|
||||
|
||||
if (!authUrl || authUrl === "undefined") {
|
||||
@@ -908,6 +908,18 @@ export function Auth({
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.loginWithExternalDesc")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="rememberMeOIDC"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(checked) =>
|
||||
setRememberMe(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="rememberMeOIDC">
|
||||
{t("auth.rememberMe")}
|
||||
</Label>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
|
||||
@@ -36,6 +36,7 @@ interface SSHHost {
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
connectionType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
@@ -131,9 +132,10 @@ export function LeftSidebar({
|
||||
}, [search]);
|
||||
|
||||
const filteredHosts = useMemo(() => {
|
||||
if (!debouncedSearch.trim()) return hosts;
|
||||
const sshOnlyHosts = hosts.filter((h) => h.connectionType === "ssh");
|
||||
if (!debouncedSearch.trim()) return sshOnlyHosts;
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
return hosts.filter((h) => {
|
||||
return sshOnlyHosts.filter((h) => {
|
||||
const searchableText = [
|
||||
h.name || "",
|
||||
h.username,
|
||||
|
||||
Reference in New Issue
Block a user