diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index ee65b059..ddc79413 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -495,6 +495,12 @@ const migrateSchema = () => { ); addColumnIfNotExists("ssh_data", "docker_config", "TEXT"); + // Connection type columns for RDP/VNC/Telnet support + 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_credentials", "private_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT"); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 2bf276aa..51f6f167 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -52,6 +52,8 @@ export const sshData = sqliteTable("ssh_data", { userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), + // Connection type: ssh, rdp, vnc, telnet + connectionType: text("connection_type").notNull().default("ssh"), name: text("name"), ip: text("ip").notNull(), port: integer("port").notNull(), @@ -94,6 +96,10 @@ export const sshData = sqliteTable("ssh_data", { dockerConfig: text("docker_config"), terminalConfig: text("terminal_config"), quickActions: text("quick_actions"), + // RDP/VNC specific fields + domain: text("domain"), + security: text("security"), + ignoreCert: integer("ignore_cert", { mode: "boolean" }).default(false), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 6cde8e29..a341ea72 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -218,6 +218,7 @@ router.post( } const { + connectionType, name, folder, tags, @@ -244,6 +245,10 @@ router.post( dockerConfig, terminalConfig, forceKeyboardInteractive, + // RDP/VNC specific fields + domain, + security, + ignoreCert, } = hostData; if ( !isNonEmptyString(userId) || @@ -261,8 +266,10 @@ router.post( } const effectiveAuthType = authType || authMethod; + const effectiveConnectionType = connectionType || "ssh"; const sshDataObj: Record = { userId: userId, + connectionType: effectiveConnectionType, name, folder: folder || null, tags: Array.isArray(tags) ? tags.join(",") : tags || "", @@ -288,6 +295,10 @@ router.post( dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", + // RDP/VNC specific fields + domain: domain || null, + security: security || null, + ignoreCert: ignoreCert ? 1 : 0, }; if (effectiveAuthType === "password") { @@ -448,6 +459,7 @@ router.put( } const { + connectionType, name, folder, tags, @@ -474,6 +486,10 @@ router.put( dockerConfig, terminalConfig, forceKeyboardInteractive, + // RDP/VNC specific fields + domain, + security, + ignoreCert, } = hostData; if ( !isNonEmptyString(userId) || @@ -494,6 +510,7 @@ router.put( const effectiveAuthType = authType || authMethod; const sshDataObj: Record = { + connectionType: connectionType || "ssh", name, folder, tags: Array.isArray(tags) ? tags.join(",") : tags || "", @@ -519,6 +536,10 @@ router.put( dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", + // RDP/VNC specific fields + domain: domain || null, + security: security || null, + ignoreCert: ignoreCert ? 1 : 0, }; if (effectiveAuthType === "password") { diff --git a/src/types/index.ts b/src/types/index.ts index 43d1b525..4b7fe408 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,8 +25,11 @@ export interface DockerConfig { tlsKey?: string; } +export type HostConnectionType = "ssh" | "rdp" | "vnc" | "telnet"; + export interface SSHHost { id: number; + connectionType: HostConnectionType; name: string; ip: string; port: number; @@ -59,6 +62,10 @@ export interface SSHHost { statsConfig?: string; dockerConfig?: string; terminalConfig?: TerminalConfig; + // RDP/VNC specific fields + domain?: string; + security?: string; + ignoreCert?: boolean; createdAt: string; updatedAt: string; } @@ -73,6 +80,7 @@ export interface QuickActionData { } export interface SSHHostData { + connectionType?: HostConnectionType; name?: string; ip: string; port: number; @@ -99,6 +107,10 @@ export interface SSHHostData { statsConfig?: string | Record; dockerConfig?: DockerConfig | string; terminalConfig?: TerminalConfig; + // RDP/VNC specific fields + domain?: string; + security?: string; + ignoreCert?: boolean; } export interface SSHFolder { @@ -370,9 +382,11 @@ export interface TabContextTab { terminalRef?: any; initialTab?: string; connectionConfig?: { - type: "rdp" | "vnc" | "telnet"; - hostname: string; - port: number; + token: string; + protocol: "rdp" | "vnc" | "telnet"; + type?: "rdp" | "vnc" | "telnet"; + hostname?: string; + port?: number; username?: string; password?: string; domain?: string; diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index 9a8d313a..836b95c0 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -36,12 +36,10 @@ import { Loader2, Terminal, FolderOpen, - Monitor, } from "lucide-react"; import { Status } from "@/components/ui/shadcn-io/status"; import { BsLightning } from "react-icons/bs"; import { useTranslation } from "react-i18next"; -import { GuacamoleTestDialog } from "@/ui/desktop/apps/guacamole/GuacamoleTestDialog"; interface DashboardProps { onSelectView: (view: string) => void; @@ -689,22 +687,6 @@ export function Dashboard({ {t("dashboard.userProfile")} - - - - Test RDP/VNC - - - } - /> diff --git a/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx b/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx index 06955ae6..0780bcc2 100644 --- a/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx +++ b/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx @@ -15,8 +15,12 @@ import { Loader2 } from "lucide-react"; export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet"; export interface GuacamoleConnectionConfig { - type: GuacamoleConnectionType; - hostname: string; + // Pre-fetched token (preferred) - if provided, skip token fetch + token?: string; + protocol?: GuacamoleConnectionType; + // Legacy fields for backward compatibility (used if token not provided) + type?: GuacamoleConnectionType; + hostname?: string; port?: number; username?: string; password?: string; @@ -89,37 +93,45 @@ export const GuacamoleDisplay = forwardRef => { - const jwtToken = getCookie("jwt"); - if (!jwtToken) { - setConnectionError("Authentication required"); - return null; - } - - // First, get an encrypted token from the backend try { - const baseUrl = isDev - ? "http://localhost:30001" - : isElectron() - ? (window as { configuredServerUrl?: string }).configuredServerUrl || "http://127.0.0.1:30001" - : `${window.location.origin}`; + let token: string; - const response = await fetch(`${baseUrl}/guacamole/token`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${jwtToken}`, - }, - body: JSON.stringify(connectionConfig), - credentials: "include", - }); + // If token is pre-fetched, use it directly + if (connectionConfig.token) { + token = connectionConfig.token; + } else { + // Otherwise, fetch token from backend (legacy behavior) + const jwtToken = getCookie("jwt"); + if (!jwtToken) { + setConnectionError("Authentication required"); + return null; + } - if (!response.ok) { - const err = await response.json(); - throw new Error(err.error || "Failed to get connection token"); + 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 { token } = await response.json(); - // Build WebSocket URL with width/height/dpi as query parameters // These are passed as unencrypted settings to guacamole-lite // Use actual container dimensions, fall back to 720p @@ -353,7 +365,7 @@ export const GuacamoleDisplay = forwardRef - {t("guacamole.connecting", { type: connectionConfig.type.toUpperCase() })} + {t("guacamole.connecting", { type: (connectionConfig.protocol || connectionConfig.type || "remote").toUpperCase() })} diff --git a/src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx b/src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx deleted file mode 100644 index 75b27373..00000000 --- a/src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { useState } from "react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { PasswordInput } from "@/components/ui/password-input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Monitor, MonitorPlay, Terminal } from "lucide-react"; -import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext"; -import type { GuacamoleConnectionConfig } from "./GuacamoleDisplay"; - -interface GuacamoleTestDialogProps { - trigger?: React.ReactNode; -} - -export function GuacamoleTestDialog({ trigger }: GuacamoleTestDialogProps) { - const [isOpen, setIsOpen] = useState(false); - const { addTab } = useTabs(); - - const [connectionType, setConnectionType] = useState<"rdp" | "vnc" | "telnet">("rdp"); - const [hostname, setHostname] = useState(""); - const [port, setPort] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [domain, setDomain] = useState(""); - const [security, setSecurity] = useState("nla"); - - const defaultPorts = { rdp: "3389", vnc: "5900", telnet: "23" }; - - const handleConnect = () => { - if (!hostname) return; - - const config: GuacamoleConnectionConfig = { - type: connectionType, - hostname, - port: parseInt(port || defaultPorts[connectionType]), - username: username || undefined, - password: password || undefined, - domain: domain || undefined, - security: connectionType === "rdp" ? security : undefined, - "ignore-cert": true, - }; - - // Add a new tab for the remote desktop connection - const tabType = connectionType === "rdp" ? "rdp" : connectionType === "vnc" ? "vnc" : "rdp"; - const title = `${connectionType.toUpperCase()} - ${hostname}`; - - addTab({ - type: tabType, - title, - connectionConfig: config, - }); - - // Close the dialog - setIsOpen(false); - }; - - return ( - - - {trigger || ( - - )} - - - - - - Remote Connection - - - -
- { - setConnectionType(v as "rdp" | "vnc" | "telnet"); - setPort(""); - }}> - - - RDP - - - VNC - - - Telnet - - - - -
-
- - setHostname(e.target.value)} placeholder="192.168.1.100" /> -
-
- - setPort(e.target.value)} placeholder="3389" /> -
-
-
-
- - setDomain(e.target.value)} placeholder="WORKGROUP" /> -
-
- - -
-
-
-
- - setUsername(e.target.value)} placeholder="Administrator" /> -
-
- - setPassword(e.target.value)} /> -
-
-
- - -
-
- - setHostname(e.target.value)} placeholder="192.168.1.100" /> -
-
- - setPort(e.target.value)} placeholder="5900" /> -
-
-
- - setPassword(e.target.value)} /> -
-
- - -
-
- - setHostname(e.target.value)} placeholder="192.168.1.100" /> -
-
- - setPort(e.target.value)} placeholder="23" /> -
-
-
-
- - -
-
-
- ); -} - diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index e0041c1a..9e61c682 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -435,6 +435,7 @@ 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), port: z.coerce.number().min(1).max(65535), @@ -443,6 +444,10 @@ export function HostManagerEditor({ tags: z.array(z.string().min(1)).default([]), pin: z.boolean().default(false), authType: z.enum(["password", "key", "credential", "none"]), + // RDP/VNC specific fields + domain: z.string().optional(), + security: z.string().optional(), + ignoreCert: z.boolean().default(false), credentialId: z.number().optional().nullable(), overrideCredentialUsername: z.boolean().optional(), password: z.string().optional(), @@ -648,6 +653,7 @@ export function HostManagerEditor({ resolver: zodResolver(formSchema) as any, defaultValues: { name: "", + connectionType: "ssh" as const, ip: "", port: 22, username: "", @@ -682,6 +688,10 @@ export function HostManagerEditor({ tlsCert: "", tlsKey: "", }, + // RDP/VNC specific defaults + domain: "", + security: "", + ignoreCert: false, }, }); @@ -759,6 +769,7 @@ export function HostManagerEditor({ } const formData = { + connectionType: (cleanedHost.connectionType || "ssh") as "ssh" | "rdp" | "vnc" | "telnet", name: cleanedHost.name || "", ip: cleanedHost.ip || "", port: cleanedHost.port || 22, @@ -801,6 +812,10 @@ export function HostManagerEditor({ forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive), enableDocker: Boolean(cleanedHost.enableDocker), dockerConfig: parsedDockerConfig, + // RDP/VNC specific fields + domain: cleanedHost.domain || "", + security: cleanedHost.security || "", + ignoreCert: Boolean(cleanedHost.ignoreCert), }; if (defaultAuthType === "password") { @@ -828,6 +843,7 @@ export function HostManagerEditor({ } else { setAuthTab("password"); const defaultFormData = { + connectionType: "ssh" as const, name: "", ip: "", port: 22, @@ -863,6 +879,10 @@ export function HostManagerEditor({ tlsCert: "", tlsKey: "", }, + // RDP/VNC specific defaults + domain: "", + security: "", + ignoreCert: false, }; form.reset(defaultFormData); @@ -910,6 +930,7 @@ export function HostManagerEditor({ } const submitData: Record = { + connectionType: data.connectionType || "ssh", name: data.name, ip: data.ip, port: data.port, @@ -931,6 +952,10 @@ export function HostManagerEditor({ statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG, terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive), + // RDP/VNC specific fields + domain: data.domain || null, + security: data.security || null, + ignoreCert: Boolean(data.ignoreCert), }; submitData.credentialId = null; @@ -1230,23 +1255,69 @@ export function HostManagerEditor({ onValueChange={setActiveTab} className="w-full" > - - - {t("hosts.general")} - - - {t("hosts.terminal")} - - Docker - {t("hosts.tunnel")} - - {t("hosts.fileManager")} - - - {t("hosts.statistics")} - - + {/* Only show tabs if there's more than just the General tab (SSH has extra tabs) */} + {form.watch("connectionType") === "ssh" && ( + + + {t("hosts.general")} + + + {t("hosts.terminal")} + + Docker + {t("hosts.tunnel")} + + {t("hosts.fileManager")} + + + {t("hosts.statistics")} + + + )} + + {t("hosts.connectionType", "Connection Type")} + +
+ ( + + +
+ {[ + { value: "ssh", label: "SSH" }, + { value: "rdp", label: "RDP" }, + { value: "vnc", label: "VNC" }, + { value: "telnet", label: "Telnet" }, + ].map((option) => ( + + ))} +
+
+
+ )} + /> +
{t("hosts.connectionDetails")} @@ -1314,6 +1385,75 @@ export function HostManagerEditor({ }} /> + {/* RDP-specific fields */} + {form.watch("connectionType") === "rdp" && ( + <> + + {t("hosts.rdpSettings", "RDP Settings")} + +
+ ( + + {t("hosts.domain", "Domain")} + + + + + )} + /> + ( + + {t("hosts.security", "Security")} + + + )} + /> + ( + +
+ {t("hosts.ignoreCert", "Ignore Certificate")} +
+ + + +
+ )} + /> +
+ + )} {t("hosts.organization")} @@ -1456,49 +1596,54 @@ export function HostManagerEditor({ )} /> - - {t("hosts.authentication")} - - { - const newAuthType = value as - | "password" - | "key" - | "credential" - | "none"; - setAuthTab(newAuthType); - form.setValue("authType", newAuthType); - }} - className="flex-1 flex flex-col h-full min-h-0" - > - - - {t("hosts.password")} - - {t("hosts.key")} - - {t("hosts.credential")} - - {t("hosts.none")} - - - ( - - {t("hosts.password")} - - - - - )} - /> - + {/* Authentication section - only for SSH and Telnet */} + {(form.watch("connectionType") === "ssh" || form.watch("connectionType") === "telnet") && ( + <> + + {t("hosts.authentication")} + + { + const newAuthType = value as + | "password" + | "key" + | "credential" + | "none"; + setAuthTab(newAuthType); + form.setValue("authType", newAuthType); + }} + className="flex-1 flex flex-col h-full min-h-0" + > + + + {t("hosts.password")} + + {form.watch("connectionType") === "ssh" && ( + {t("hosts.key")} + )} + + {t("hosts.credential")} + + {t("hosts.none")} + + + ( + + {t("hosts.password")} + + + + + )} + /> + + + )} + {/* RDP/VNC password authentication - simpler than SSH */} + {(form.watch("connectionType") === "rdp" || form.watch("connectionType") === "vnc") && ( + <> + + {t("hosts.authentication")} + +
+ ( + + {t("hosts.password")} + + + + + )} + /> +
+ + )}
- {host.enableTerminal && ( + {/* Show connection type badge */} + {(host.connectionType === "rdp" || host.connectionType === "vnc") ? ( + + {host.connectionType === "rdp" ? ( + + ) : ( + + )} + {host.connectionType.toUpperCase()} + + ) : host.connectionType === "telnet" ? ( + + + Telnet + + ) : host.enableTerminal && (
- {host.enableTerminal && ( + {/* Show connect button for SSH/Telnet if enableTerminal, or always for RDP/VNC */} + {(host.enableTerminal || host.connectionType === "rdp" || host.connectionType === "vnc") && ( -

Open Terminal

+

{host.connectionType === "rdp" ? "Open RDP" : host.connectionType === "vnc" ? "Open VNC" : "Open Terminal"}

)} diff --git a/src/ui/desktop/navigation/TopNavbar.tsx b/src/ui/desktop/navigation/TopNavbar.tsx index 4838726c..10185444 100644 --- a/src/ui/desktop/navigation/TopNavbar.tsx +++ b/src/ui/desktop/navigation/TopNavbar.tsx @@ -374,6 +374,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; @@ -491,7 +493,9 @@ export function TopNavbar({ isDocker || isSshManager || isAdmin || - isUserProfile + isUserProfile || + isRdp || + isVnc ? () => handleTabClose(tab.id) : undefined } @@ -507,7 +511,9 @@ export function TopNavbar({ isDocker || isSshManager || isAdmin || - isUserProfile + isUserProfile || + isRdp || + isVnc } disableActivate={disableActivate} disableSplit={disableSplit} diff --git a/src/ui/desktop/navigation/hosts/Host.tsx b/src/ui/desktop/navigation/hosts/Host.tsx index f085f641..05da70d6 100644 --- a/src/ui/desktop/navigation/hosts/Host.tsx +++ b/src/ui/desktop/navigation/hosts/Host.tsx @@ -10,6 +10,8 @@ import { Pencil, ArrowDownUp, Container, + Monitor, + ScreenShare, } from "lucide-react"; import { DropdownMenu, @@ -18,7 +20,7 @@ import { DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext"; -import { getServerStatusById } from "@/ui/main-axios"; +import { getServerStatusById, getGuacamoleToken } from "@/ui/main-axios"; import type { HostProps } from "../../../../types"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; @@ -106,8 +108,38 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement { }; }, [host.id, shouldShowStatus]); - const handleTerminalClick = () => { - addTab({ type: "terminal", title, hostConfig: host }); + const handleTerminalClick = async () => { + const connectionType = host.connectionType || "ssh"; + + if (connectionType === "ssh" || connectionType === "telnet") { + addTab({ type: "terminal", title, hostConfig: host }); + } else if (connectionType === "rdp" || connectionType === "vnc") { + try { + // Get guacamole token for RDP/VNC connection + const tokenResponse = await getGuacamoleToken({ + protocol: connectionType, + hostname: host.ip, + port: host.port, + username: host.username, + password: host.password || "", + domain: host.domain, + security: host.security, + ignoreCert: host.ignoreCert, + }); + + addTab({ + type: connectionType, + title, + hostConfig: host, + connectionConfig: { + token: tokenResponse.token, + protocol: connectionType, + }, + }); + } catch (error) { + console.error(`Failed to get guacamole token for ${connectionType}:`, error); + } + } }; return ( @@ -127,13 +159,20 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {

- {host.enableTerminal && ( + {/* Show connect button for SSH/Telnet if enableTerminal, or always for RDP/VNC */} + {(host.enableTerminal || host.connectionType === "rdp" || host.connectionType === "vnc") && ( )} @@ -142,7 +181,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {