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.
This commit is contained in:
starhound
2025-12-19 16:08:27 -05:00
parent 3ac7ad0bd7
commit 776f581377
12 changed files with 540 additions and 353 deletions

View File

@@ -495,6 +495,12 @@ const migrateSchema = () => {
); );
addColumnIfNotExists("ssh_data", "docker_config", "TEXT"); 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", "private_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT"); addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");

View File

@@ -52,6 +52,8 @@ export const sshData = sqliteTable("ssh_data", {
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
// Connection type: ssh, rdp, vnc, telnet
connectionType: text("connection_type").notNull().default("ssh"),
name: text("name"), name: text("name"),
ip: text("ip").notNull(), ip: text("ip").notNull(),
port: integer("port").notNull(), port: integer("port").notNull(),
@@ -94,6 +96,10 @@ export const sshData = sqliteTable("ssh_data", {
dockerConfig: text("docker_config"), dockerConfig: text("docker_config"),
terminalConfig: text("terminal_config"), terminalConfig: text("terminal_config"),
quickActions: text("quick_actions"), 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") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),

View File

@@ -218,6 +218,7 @@ router.post(
} }
const { const {
connectionType,
name, name,
folder, folder,
tags, tags,
@@ -244,6 +245,10 @@ router.post(
dockerConfig, dockerConfig,
terminalConfig, terminalConfig,
forceKeyboardInteractive, forceKeyboardInteractive,
// RDP/VNC specific fields
domain,
security,
ignoreCert,
} = hostData; } = hostData;
if ( if (
!isNonEmptyString(userId) || !isNonEmptyString(userId) ||
@@ -261,8 +266,10 @@ router.post(
} }
const effectiveAuthType = authType || authMethod; const effectiveAuthType = authType || authMethod;
const effectiveConnectionType = connectionType || "ssh";
const sshDataObj: Record<string, unknown> = { const sshDataObj: Record<string, unknown> = {
userId: userId, userId: userId,
connectionType: effectiveConnectionType,
name, name,
folder: folder || null, folder: folder || null,
tags: Array.isArray(tags) ? tags.join(",") : tags || "", tags: Array.isArray(tags) ? tags.join(",") : tags || "",
@@ -288,6 +295,10 @@ router.post(
dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null, dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
// RDP/VNC specific fields
domain: domain || null,
security: security || null,
ignoreCert: ignoreCert ? 1 : 0,
}; };
if (effectiveAuthType === "password") { if (effectiveAuthType === "password") {
@@ -448,6 +459,7 @@ router.put(
} }
const { const {
connectionType,
name, name,
folder, folder,
tags, tags,
@@ -474,6 +486,10 @@ router.put(
dockerConfig, dockerConfig,
terminalConfig, terminalConfig,
forceKeyboardInteractive, forceKeyboardInteractive,
// RDP/VNC specific fields
domain,
security,
ignoreCert,
} = hostData; } = hostData;
if ( if (
!isNonEmptyString(userId) || !isNonEmptyString(userId) ||
@@ -494,6 +510,7 @@ router.put(
const effectiveAuthType = authType || authMethod; const effectiveAuthType = authType || authMethod;
const sshDataObj: Record<string, unknown> = { const sshDataObj: Record<string, unknown> = {
connectionType: connectionType || "ssh",
name, name,
folder, folder,
tags: Array.isArray(tags) ? tags.join(",") : tags || "", tags: Array.isArray(tags) ? tags.join(",") : tags || "",
@@ -519,6 +536,10 @@ router.put(
dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null, dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
// RDP/VNC specific fields
domain: domain || null,
security: security || null,
ignoreCert: ignoreCert ? 1 : 0,
}; };
if (effectiveAuthType === "password") { if (effectiveAuthType === "password") {

View File

@@ -25,8 +25,11 @@ export interface DockerConfig {
tlsKey?: string; tlsKey?: string;
} }
export type HostConnectionType = "ssh" | "rdp" | "vnc" | "telnet";
export interface SSHHost { export interface SSHHost {
id: number; id: number;
connectionType: HostConnectionType;
name: string; name: string;
ip: string; ip: string;
port: number; port: number;
@@ -59,6 +62,10 @@ export interface SSHHost {
statsConfig?: string; statsConfig?: string;
dockerConfig?: string; dockerConfig?: string;
terminalConfig?: TerminalConfig; terminalConfig?: TerminalConfig;
// RDP/VNC specific fields
domain?: string;
security?: string;
ignoreCert?: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -73,6 +80,7 @@ export interface QuickActionData {
} }
export interface SSHHostData { export interface SSHHostData {
connectionType?: HostConnectionType;
name?: string; name?: string;
ip: string; ip: string;
port: number; port: number;
@@ -99,6 +107,10 @@ export interface SSHHostData {
statsConfig?: string | Record<string, unknown>; statsConfig?: string | Record<string, unknown>;
dockerConfig?: DockerConfig | string; dockerConfig?: DockerConfig | string;
terminalConfig?: TerminalConfig; terminalConfig?: TerminalConfig;
// RDP/VNC specific fields
domain?: string;
security?: string;
ignoreCert?: boolean;
} }
export interface SSHFolder { export interface SSHFolder {
@@ -370,9 +382,11 @@ export interface TabContextTab {
terminalRef?: any; terminalRef?: any;
initialTab?: string; initialTab?: string;
connectionConfig?: { connectionConfig?: {
type: "rdp" | "vnc" | "telnet"; token: string;
hostname: string; protocol: "rdp" | "vnc" | "telnet";
port: number; type?: "rdp" | "vnc" | "telnet";
hostname?: string;
port?: number;
username?: string; username?: string;
password?: string; password?: string;
domain?: string; domain?: string;

View File

@@ -36,12 +36,10 @@ import {
Loader2, Loader2,
Terminal, Terminal,
FolderOpen, FolderOpen,
Monitor,
} from "lucide-react"; } from "lucide-react";
import { Status } from "@/components/ui/shadcn-io/status"; import { Status } from "@/components/ui/shadcn-io/status";
import { BsLightning } from "react-icons/bs"; import { BsLightning } from "react-icons/bs";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { GuacamoleTestDialog } from "@/ui/desktop/apps/guacamole/GuacamoleTestDialog";
interface DashboardProps { interface DashboardProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
@@ -689,22 +687,6 @@ export function Dashboard({
{t("dashboard.userProfile")} {t("dashboard.userProfile")}
</span> </span>
</Button> </Button>
<GuacamoleTestDialog
trigger={
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
>
<Monitor
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span className="font-semibold text-sm mt-2">
Test RDP/VNC
</span>
</Button>
}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -15,8 +15,12 @@ import { Loader2 } from "lucide-react";
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet"; export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
export interface GuacamoleConnectionConfig { export interface GuacamoleConnectionConfig {
type: GuacamoleConnectionType; // Pre-fetched token (preferred) - if provided, skip token fetch
hostname: string; token?: string;
protocol?: GuacamoleConnectionType;
// Legacy fields for backward compatibility (used if token not provided)
type?: GuacamoleConnectionType;
hostname?: string;
port?: number; port?: number;
username?: string; username?: string;
password?: string; password?: string;
@@ -89,14 +93,20 @@ export const GuacamoleDisplay = forwardRef<GuacamoleDisplayHandle, GuacamoleDisp
})); }));
const getWebSocketUrl = useCallback(async (containerWidth: number, containerHeight: number): Promise<string | null> => { const getWebSocketUrl = useCallback(async (containerWidth: number, containerHeight: number): Promise<string | null> => {
try {
let token: string;
// 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"); const jwtToken = getCookie("jwt");
if (!jwtToken) { if (!jwtToken) {
setConnectionError("Authentication required"); setConnectionError("Authentication required");
return null; return null;
} }
// First, get an encrypted token from the backend
try {
const baseUrl = isDev const baseUrl = isDev
? "http://localhost:30001" ? "http://localhost:30001"
: isElectron() : isElectron()
@@ -118,7 +128,9 @@ export const GuacamoleDisplay = forwardRef<GuacamoleDisplayHandle, GuacamoleDisp
throw new Error(err.error || "Failed to get connection token"); throw new Error(err.error || "Failed to get connection token");
} }
const { token } = await response.json(); const data = await response.json();
token = data.token;
}
// Build WebSocket URL with width/height/dpi as query parameters // Build WebSocket URL with width/height/dpi as query parameters
// These are passed as unencrypted settings to guacamole-lite // These are passed as unencrypted settings to guacamole-lite
@@ -353,7 +365,7 @@ export const GuacamoleDisplay = forwardRef<GuacamoleDisplayHandle, GuacamoleDisp
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-primary" /> <Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t("guacamole.connecting", { type: connectionConfig.type.toUpperCase() })} {t("guacamole.connecting", { type: (connectionConfig.protocol || connectionConfig.type || "remote").toUpperCase() })}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" className="gap-2">
<Monitor className="w-4 h-4" />
Test RDP/VNC
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Monitor className="w-5 h-5" />
Remote Connection
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Tabs value={connectionType} onValueChange={(v) => {
setConnectionType(v as "rdp" | "vnc" | "telnet");
setPort("");
}}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="rdp" className="gap-1">
<MonitorPlay className="w-4 h-4" /> RDP
</TabsTrigger>
<TabsTrigger value="vnc" className="gap-1">
<Monitor className="w-4 h-4" /> VNC
</TabsTrigger>
<TabsTrigger value="telnet" className="gap-1">
<Terminal className="w-4 h-4" /> Telnet
</TabsTrigger>
</TabsList>
<TabsContent value="rdp" className="space-y-3 mt-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Hostname / IP</Label>
<Input value={hostname} onChange={(e) => setHostname(e.target.value)} placeholder="192.168.1.100" />
</div>
<div className="space-y-1.5">
<Label>Port</Label>
<Input value={port} onChange={(e) => setPort(e.target.value)} placeholder="3389" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Domain (optional)</Label>
<Input value={domain} onChange={(e) => setDomain(e.target.value)} placeholder="WORKGROUP" />
</div>
<div className="space-y-1.5">
<Label>Security</Label>
<Select value={security} onValueChange={setSecurity}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nla">NLA (Windows 10/11)</SelectItem>
<SelectItem value="tls">TLS</SelectItem>
<SelectItem value="rdp">RDP (legacy)</SelectItem>
<SelectItem value="any">Auto-negotiate</SelectItem>
<SelectItem value="vmconnect">Hyper-V</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Username</Label>
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Administrator" />
</div>
<div className="space-y-1.5">
<Label>Password</Label>
<PasswordInput value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
</div>
</TabsContent>
<TabsContent value="vnc" className="space-y-3 mt-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Hostname / IP</Label>
<Input value={hostname} onChange={(e) => setHostname(e.target.value)} placeholder="192.168.1.100" />
</div>
<div className="space-y-1.5">
<Label>Port</Label>
<Input value={port} onChange={(e) => setPort(e.target.value)} placeholder="5900" />
</div>
</div>
<div className="space-y-1.5">
<Label>Password</Label>
<PasswordInput value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
</TabsContent>
<TabsContent value="telnet" className="space-y-3 mt-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Hostname / IP</Label>
<Input value={hostname} onChange={(e) => setHostname(e.target.value)} placeholder="192.168.1.100" />
</div>
<div className="space-y-1.5">
<Label>Port</Label>
<Input value={port} onChange={(e) => setPort(e.target.value)} placeholder="23" />
</div>
</div>
</TabsContent>
</Tabs>
<Button onClick={handleConnect} disabled={!hostname} className="w-full">
Connect
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -435,6 +435,7 @@ export function HostManagerEditor({
const formSchema = z const formSchema = z
.object({ .object({
connectionType: z.enum(["ssh", "rdp", "vnc", "telnet"]).default("ssh"),
name: z.string().optional(), name: z.string().optional(),
ip: z.string().min(1), ip: z.string().min(1),
port: z.coerce.number().min(1).max(65535), port: z.coerce.number().min(1).max(65535),
@@ -443,6 +444,10 @@ export function HostManagerEditor({
tags: z.array(z.string().min(1)).default([]), tags: z.array(z.string().min(1)).default([]),
pin: z.boolean().default(false), pin: z.boolean().default(false),
authType: z.enum(["password", "key", "credential", "none"]), 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(), credentialId: z.number().optional().nullable(),
overrideCredentialUsername: z.boolean().optional(), overrideCredentialUsername: z.boolean().optional(),
password: z.string().optional(), password: z.string().optional(),
@@ -648,6 +653,7 @@ export function HostManagerEditor({
resolver: zodResolver(formSchema) as any, resolver: zodResolver(formSchema) as any,
defaultValues: { defaultValues: {
name: "", name: "",
connectionType: "ssh" as const,
ip: "", ip: "",
port: 22, port: 22,
username: "", username: "",
@@ -682,6 +688,10 @@ export function HostManagerEditor({
tlsCert: "", tlsCert: "",
tlsKey: "", tlsKey: "",
}, },
// RDP/VNC specific defaults
domain: "",
security: "",
ignoreCert: false,
}, },
}); });
@@ -759,6 +769,7 @@ export function HostManagerEditor({
} }
const formData = { const formData = {
connectionType: (cleanedHost.connectionType || "ssh") as "ssh" | "rdp" | "vnc" | "telnet",
name: cleanedHost.name || "", name: cleanedHost.name || "",
ip: cleanedHost.ip || "", ip: cleanedHost.ip || "",
port: cleanedHost.port || 22, port: cleanedHost.port || 22,
@@ -801,6 +812,10 @@ export function HostManagerEditor({
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
enableDocker: Boolean(cleanedHost.enableDocker), enableDocker: Boolean(cleanedHost.enableDocker),
dockerConfig: parsedDockerConfig, dockerConfig: parsedDockerConfig,
// RDP/VNC specific fields
domain: cleanedHost.domain || "",
security: cleanedHost.security || "",
ignoreCert: Boolean(cleanedHost.ignoreCert),
}; };
if (defaultAuthType === "password") { if (defaultAuthType === "password") {
@@ -828,6 +843,7 @@ export function HostManagerEditor({
} else { } else {
setAuthTab("password"); setAuthTab("password");
const defaultFormData = { const defaultFormData = {
connectionType: "ssh" as const,
name: "", name: "",
ip: "", ip: "",
port: 22, port: 22,
@@ -863,6 +879,10 @@ export function HostManagerEditor({
tlsCert: "", tlsCert: "",
tlsKey: "", tlsKey: "",
}, },
// RDP/VNC specific defaults
domain: "",
security: "",
ignoreCert: false,
}; };
form.reset(defaultFormData); form.reset(defaultFormData);
@@ -910,6 +930,7 @@ export function HostManagerEditor({
} }
const submitData: Record<string, unknown> = { const submitData: Record<string, unknown> = {
connectionType: data.connectionType || "ssh",
name: data.name, name: data.name,
ip: data.ip, ip: data.ip,
port: data.port, port: data.port,
@@ -931,6 +952,10 @@ export function HostManagerEditor({
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG, statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG, terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive),
// RDP/VNC specific fields
domain: data.domain || null,
security: data.security || null,
ignoreCert: Boolean(data.ignoreCert),
}; };
submitData.credentialId = null; submitData.credentialId = null;
@@ -1230,6 +1255,8 @@ export function HostManagerEditor({
onValueChange={setActiveTab} onValueChange={setActiveTab}
className="w-full" className="w-full"
> >
{/* Only show tabs if there's more than just the General tab (SSH has extra tabs) */}
{form.watch("connectionType") === "ssh" && (
<TabsList> <TabsList>
<TabsTrigger value="general"> <TabsTrigger value="general">
{t("hosts.general")} {t("hosts.general")}
@@ -1246,7 +1273,51 @@ export function HostManagerEditor({
{t("hosts.statistics")} {t("hosts.statistics")}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
)}
<TabsContent value="general" className="pt-2"> <TabsContent value="general" className="pt-2">
<FormLabel className="mb-3 font-bold">
{t("hosts.connectionType", "Connection Type")}
</FormLabel>
<div className="grid grid-cols-12 gap-4 mb-4">
<FormField
control={form.control}
name="connectionType"
render={({ field }) => (
<FormItem className="col-span-12">
<FormControl>
<div className="flex gap-2">
{[
{ value: "ssh", label: "SSH" },
{ value: "rdp", label: "RDP" },
{ value: "vnc", label: "VNC" },
{ value: "telnet", label: "Telnet" },
].map((option) => (
<Button
key={option.value}
type="button"
variant={field.value === option.value ? "default" : "outline"}
size="sm"
onClick={() => {
field.onChange(option.value);
// Update default port based on connection type
const defaultPorts: Record<string, number> = {
ssh: 22,
rdp: 3389,
vnc: 5900,
telnet: 23,
};
form.setValue("port", defaultPorts[option.value] || 22);
}}
>
{option.label}
</Button>
))}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
<FormLabel className="mb-3 font-bold"> <FormLabel className="mb-3 font-bold">
{t("hosts.connectionDetails")} {t("hosts.connectionDetails")}
</FormLabel> </FormLabel>
@@ -1314,6 +1385,75 @@ export function HostManagerEditor({
}} }}
/> />
</div> </div>
{/* RDP-specific fields */}
{form.watch("connectionType") === "rdp" && (
<>
<FormLabel className="mb-3 mt-3 font-bold">
{t("hosts.rdpSettings", "RDP Settings")}
</FormLabel>
<div className="grid grid-cols-12 gap-4">
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel>{t("hosts.domain", "Domain")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.domain", "WORKGROUP")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="security"
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel>{t("hosts.security", "Security")}</FormLabel>
<Select
value={field.value || "any"}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectSecurity", "Select security")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="any">{t("hosts.securityAny", "Any")}</SelectItem>
<SelectItem value="nla">{t("hosts.securityNla", "NLA")}</SelectItem>
<SelectItem value="nla-ext">{t("hosts.securityNlaExt", "NLA Extended")}</SelectItem>
<SelectItem value="tls">{t("hosts.securityTls", "TLS")}</SelectItem>
<SelectItem value="vmconnect">{t("hosts.securityVmconnect", "VMConnect")}</SelectItem>
<SelectItem value="rdp">{t("hosts.securityRdp", "RDP")}</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
<FormField
control={form.control}
name="ignoreCert"
render={({ field }) => (
<FormItem className="col-span-4 flex flex-row items-center justify-between rounded-lg border p-3 mt-6">
<div className="space-y-0.5">
<FormLabel>{t("hosts.ignoreCert", "Ignore Certificate")}</FormLabel>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</>
)}
<FormLabel className="mb-3 mt-3 font-bold"> <FormLabel className="mb-3 mt-3 font-bold">
{t("hosts.organization")} {t("hosts.organization")}
</FormLabel> </FormLabel>
@@ -1456,6 +1596,9 @@ export function HostManagerEditor({
)} )}
/> />
</div> </div>
{/* Authentication section - only for SSH and Telnet */}
{(form.watch("connectionType") === "ssh" || form.watch("connectionType") === "telnet") && (
<>
<FormLabel className="mb-3 mt-3 font-bold"> <FormLabel className="mb-3 mt-3 font-bold">
{t("hosts.authentication")} {t("hosts.authentication")}
</FormLabel> </FormLabel>
@@ -1476,7 +1619,9 @@ export function HostManagerEditor({
<TabsTrigger value="password"> <TabsTrigger value="password">
{t("hosts.password")} {t("hosts.password")}
</TabsTrigger> </TabsTrigger>
{form.watch("connectionType") === "ssh" && (
<TabsTrigger value="key">{t("hosts.key")}</TabsTrigger> <TabsTrigger value="key">{t("hosts.key")}</TabsTrigger>
)}
<TabsTrigger value="credential"> <TabsTrigger value="credential">
{t("hosts.credential")} {t("hosts.credential")}
</TabsTrigger> </TabsTrigger>
@@ -1847,6 +1992,33 @@ export function HostManagerEditor({
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
</>
)}
{/* RDP/VNC password authentication - simpler than SSH */}
{(form.watch("connectionType") === "rdp" || form.watch("connectionType") === "vnc") && (
<>
<FormLabel className="mb-3 mt-3 font-bold">
{t("hosts.authentication")}
</FormLabel>
<div className="grid grid-cols-12 gap-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="col-span-12">
<FormLabel>{t("hosts.password")}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.password")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</>
)}
</TabsContent> </TabsContent>
<TabsContent value="terminal" className="space-y-1"> <TabsContent value="terminal" className="space-y-1">
<FormField <FormField

View File

@@ -61,7 +61,10 @@ import {
HardDrive, HardDrive,
Globe, Globe,
FolderOpen, FolderOpen,
Monitor,
ScreenShare,
} from "lucide-react"; } from "lucide-react";
import { getGuacamoleToken } from "@/ui/main-axios.ts";
import type { import type {
SSHHost, SSHHost,
SSHFolder, SSHFolder,
@@ -1371,7 +1374,28 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
)} )}
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{host.enableTerminal && ( {/* Show connection type badge */}
{(host.connectionType === "rdp" || host.connectionType === "vnc") ? (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
{host.connectionType === "rdp" ? (
<Monitor className="h-2 w-2 mr-0.5" />
) : (
<ScreenShare className="h-2 w-2 mr-0.5" />
)}
{host.connectionType.toUpperCase()}
</Badge>
) : host.connectionType === "telnet" ? (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Terminal className="h-2 w-2 mr-0.5" />
Telnet
</Badge>
) : host.enableTerminal && (
<Badge <Badge
variant="outline" variant="outline"
className="text-xs px-1 py-0" className="text-xs px-1 py-0"
@@ -1450,30 +1474,66 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
</div> </div>
<div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1"> <div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1">
{host.enableTerminal && ( {/* Show connect button for SSH/Telnet if enableTerminal, or always for RDP/VNC */}
{(host.enableTerminal || host.connectionType === "rdp" || host.connectionType === "vnc") && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={(e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
const title = host.name?.trim() const title = host.name?.trim()
? host.name ? host.name
: `${host.username}@${host.ip}:${host.port}`; : `${host.username}@${host.ip}:${host.port}`;
const connectionType = host.connectionType || "ssh";
if (connectionType === "ssh" || connectionType === "telnet") {
addTab({ addTab({
type: "terminal", type: "terminal",
title, title,
hostConfig: host, hostConfig: host,
}); });
} else if (connectionType === "rdp" || connectionType === "vnc") {
try {
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);
toast.error(`Failed to connect to ${connectionType.toUpperCase()} host`);
}
}
}} }}
className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1" className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1"
> >
{host.connectionType === "rdp" ? (
<Monitor className="h-3.5 w-3.5" />
) : host.connectionType === "vnc" ? (
<ScreenShare className="h-3.5 w-3.5" />
) : (
<Terminal className="h-3.5 w-3.5" /> <Terminal className="h-3.5 w-3.5" />
)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Open Terminal</p> <p>{host.connectionType === "rdp" ? "Open RDP" : host.connectionType === "vnc" ? "Open VNC" : "Open Terminal"}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}

View File

@@ -374,6 +374,8 @@ export function TopNavbar({
const isSshManager = tab.type === "ssh_manager"; const isSshManager = tab.type === "ssh_manager";
const isAdmin = tab.type === "admin"; const isAdmin = tab.type === "admin";
const isUserProfile = tab.type === "user_profile"; const isUserProfile = tab.type === "user_profile";
const isRdp = tab.type === "rdp";
const isVnc = tab.type === "vnc";
const isSplittable = const isSplittable =
isTerminal || isServer || isFileManager || isTunnel || isDocker; isTerminal || isServer || isFileManager || isTunnel || isDocker;
const disableSplit = !isSplittable; const disableSplit = !isSplittable;
@@ -491,7 +493,9 @@ export function TopNavbar({
isDocker || isDocker ||
isSshManager || isSshManager ||
isAdmin || isAdmin ||
isUserProfile isUserProfile ||
isRdp ||
isVnc
? () => handleTabClose(tab.id) ? () => handleTabClose(tab.id)
: undefined : undefined
} }
@@ -507,7 +511,9 @@ export function TopNavbar({
isDocker || isDocker ||
isSshManager || isSshManager ||
isAdmin || isAdmin ||
isUserProfile isUserProfile ||
isRdp ||
isVnc
} }
disableActivate={disableActivate} disableActivate={disableActivate}
disableSplit={disableSplit} disableSplit={disableSplit}

View File

@@ -10,6 +10,8 @@ import {
Pencil, Pencil,
ArrowDownUp, ArrowDownUp,
Container, Container,
Monitor,
ScreenShare,
} from "lucide-react"; } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
@@ -18,7 +20,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext"; 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 type { HostProps } from "../../../../types";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
@@ -106,8 +108,38 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
}; };
}, [host.id, shouldShowStatus]); }, [host.id, shouldShowStatus]);
const handleTerminalClick = () => { const handleTerminalClick = async () => {
const connectionType = host.connectionType || "ssh";
if (connectionType === "ssh" || connectionType === "telnet") {
addTab({ type: "terminal", title, hostConfig: host }); 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 ( return (
@@ -127,13 +159,20 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
</p> </p>
<ButtonGroup className="flex-shrink-0"> <ButtonGroup className="flex-shrink-0">
{host.enableTerminal && ( {/* Show connect button for SSH/Telnet if enableTerminal, or always for RDP/VNC */}
{(host.enableTerminal || host.connectionType === "rdp" || host.connectionType === "vnc") && (
<Button <Button
variant="outline" variant="outline"
className="!px-2 border-1 border-dark-border" className="!px-2 border-1 border-dark-border"
onClick={handleTerminalClick} onClick={handleTerminalClick}
> >
{host.connectionType === "rdp" ? (
<Monitor />
) : host.connectionType === "vnc" ? (
<ScreenShare />
) : (
<Terminal /> <Terminal />
)}
</Button> </Button>
)} )}
@@ -142,7 +181,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
<Button <Button
variant="outline" variant="outline"
className={`!px-2 border-1 border-dark-border ${ className={`!px-2 border-1 border-dark-border ${
host.enableTerminal ? "rounded-tl-none rounded-bl-none" : "" (host.enableTerminal || host.connectionType === "rdp" || host.connectionType === "vnc") ? "rounded-tl-none rounded-bl-none" : ""
}`} }`}
> >
<EllipsisVertical /> <EllipsisVertical />
@@ -154,6 +193,9 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
side="right" side="right"
className="w-56 bg-dark-bg border-dark-border text-white" className="w-56 bg-dark-bg border-dark-border text-white"
> >
{/* SSH-specific menu items */}
{(!host.connectionType || host.connectionType === "ssh") && (
<>
{shouldShowMetrics && ( {shouldShowMetrics && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
@@ -198,6 +240,8 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
<span className="flex-1">Open Docker</span> <span className="flex-1">Open Docker</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
addTab({ addTab({

View File

@@ -838,6 +838,7 @@ export async function getSSHHosts(): Promise<SSHHost[]> {
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> { export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
try { try {
const submitData = { const submitData = {
connectionType: hostData.connectionType || "ssh",
name: hostData.name || "", name: hostData.name || "",
ip: hostData.ip, ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22, port: parseInt(hostData.port.toString()) || 22,
@@ -873,6 +874,10 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
: null, : null,
terminalConfig: hostData.terminalConfig || null, terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
// RDP/VNC specific fields
domain: hostData.domain || null,
security: hostData.security || null,
ignoreCert: Boolean(hostData.ignoreCert),
}; };
if (!submitData.enableTunnel) { if (!submitData.enableTunnel) {
@@ -910,6 +915,7 @@ export async function updateSSHHost(
): Promise<SSHHost> { ): Promise<SSHHost> {
try { try {
const submitData = { const submitData = {
connectionType: hostData.connectionType || "ssh",
name: hostData.name || "", name: hostData.name || "",
ip: hostData.ip, ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22, port: parseInt(hostData.port.toString()) || 22,
@@ -945,6 +951,10 @@ export async function updateSSHHost(
: null, : null,
terminalConfig: hostData.terminalConfig || null, terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
// RDP/VNC specific fields
domain: hostData.domain || null,
security: hostData.security || null,
ignoreCert: Boolean(hostData.ignoreCert),
}; };
if (!submitData.enableTunnel) { if (!submitData.enableTunnel) {
@@ -3121,3 +3131,40 @@ export async function unlinkOIDCFromPasswordAccount(
throw handleApiError(error, "unlink OIDC from password account"); throw handleApiError(error, "unlink OIDC from password account");
} }
} }
// Guacamole API functions
export interface GuacamoleTokenRequest {
protocol: "rdp" | "vnc" | "telnet";
hostname: string;
port?: number;
username?: string;
password?: string;
domain?: string;
security?: string;
ignoreCert?: boolean;
}
export interface GuacamoleTokenResponse {
token: string;
}
export async function getGuacamoleToken(
request: GuacamoleTokenRequest,
): Promise<GuacamoleTokenResponse> {
try {
// Use authApi (port 30001 without /ssh prefix) since guacamole routes are at /guacamole
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,
});
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token");
}
}