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");
// 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");

View File

@@ -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`),

View File

@@ -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<string, unknown> = {
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<string, unknown> = {
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") {

View File

@@ -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<string, unknown>;
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;

View File

@@ -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")}
</span>
</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>

View File

@@ -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<GuacamoleDisplayHandle, GuacamoleDisp
}));
const getWebSocketUrl = useCallback(async (containerWidth: number, containerHeight: number): Promise<string | null> => {
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<GuacamoleDisplayHandle, GuacamoleDisp
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="text-muted-foreground">
{t("guacamole.connecting", { type: connectionConfig.type.toUpperCase() })}
{t("guacamole.connecting", { type: (connectionConfig.protocol || connectionConfig.type || "remote").toUpperCase() })}
</span>
</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
.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<string, unknown> = {
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"
>
<TabsList>
<TabsTrigger value="general">
{t("hosts.general")}
</TabsTrigger>
<TabsTrigger value="terminal">
{t("hosts.terminal")}
</TabsTrigger>
<TabsTrigger value="docker">Docker</TabsTrigger>
<TabsTrigger value="tunnel">{t("hosts.tunnel")}</TabsTrigger>
<TabsTrigger value="file_manager">
{t("hosts.fileManager")}
</TabsTrigger>
<TabsTrigger value="statistics">
{t("hosts.statistics")}
</TabsTrigger>
</TabsList>
{/* Only show tabs if there's more than just the General tab (SSH has extra tabs) */}
{form.watch("connectionType") === "ssh" && (
<TabsList>
<TabsTrigger value="general">
{t("hosts.general")}
</TabsTrigger>
<TabsTrigger value="terminal">
{t("hosts.terminal")}
</TabsTrigger>
<TabsTrigger value="docker">Docker</TabsTrigger>
<TabsTrigger value="tunnel">{t("hosts.tunnel")}</TabsTrigger>
<TabsTrigger value="file_manager">
{t("hosts.fileManager")}
</TabsTrigger>
<TabsTrigger value="statistics">
{t("hosts.statistics")}
</TabsTrigger>
</TabsList>
)}
<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">
{t("hosts.connectionDetails")}
</FormLabel>
@@ -1314,6 +1385,75 @@ export function HostManagerEditor({
}}
/>
</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">
{t("hosts.organization")}
</FormLabel>
@@ -1456,49 +1596,54 @@ export function HostManagerEditor({
)}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">
{t("hosts.authentication")}
</FormLabel>
<Tabs
value={authTab}
onValueChange={(value) => {
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"
>
<TabsList>
<TabsTrigger value="password">
{t("hosts.password")}
</TabsTrigger>
<TabsTrigger value="key">{t("hosts.key")}</TabsTrigger>
<TabsTrigger value="credential">
{t("hosts.credential")}
</TabsTrigger>
<TabsTrigger value="none">{t("hosts.none")}</TabsTrigger>
</TabsList>
<TabsContent value="password">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.password")}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.password")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
{/* Authentication section - only for SSH and Telnet */}
{(form.watch("connectionType") === "ssh" || form.watch("connectionType") === "telnet") && (
<>
<FormLabel className="mb-3 mt-3 font-bold">
{t("hosts.authentication")}
</FormLabel>
<Tabs
value={authTab}
onValueChange={(value) => {
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"
>
<TabsList>
<TabsTrigger value="password">
{t("hosts.password")}
</TabsTrigger>
{form.watch("connectionType") === "ssh" && (
<TabsTrigger value="key">{t("hosts.key")}</TabsTrigger>
)}
<TabsTrigger value="credential">
{t("hosts.credential")}
</TabsTrigger>
<TabsTrigger value="none">{t("hosts.none")}</TabsTrigger>
</TabsList>
<TabsContent value="password">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.password")}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.password")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="key">
<Tabs
value={keyInputMethod}
@@ -1847,6 +1992,33 @@ export function HostManagerEditor({
</AccordionContent>
</AccordionItem>
</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 value="terminal" className="space-y-1">
<FormField

View File

@@ -61,7 +61,10 @@ import {
HardDrive,
Globe,
FolderOpen,
Monitor,
ScreenShare,
} from "lucide-react";
import { getGuacamoleToken } from "@/ui/main-axios.ts";
import type {
SSHHost,
SSHFolder,
@@ -1371,7 +1374,28 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
)}
<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
variant="outline"
className="text-xs px-1 py-0"
@@ -1450,30 +1474,66 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
</div>
<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>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
onClick={async (e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "terminal",
title,
hostConfig: host,
});
const connectionType = host.connectionType || "ssh";
if (connectionType === "ssh" || connectionType === "telnet") {
addTab({
type: "terminal",
title,
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"
>
<Terminal className="h-3.5 w-3.5" />
{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" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open Terminal</p>
<p>{host.connectionType === "rdp" ? "Open RDP" : host.connectionType === "vnc" ? "Open VNC" : "Open Terminal"}</p>
</TooltipContent>
</Tooltip>
)}

View File

@@ -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}

View File

@@ -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 {
</p>
<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
variant="outline"
className="!px-2 border-1 border-dark-border"
onClick={handleTerminalClick}
>
<Terminal />
{host.connectionType === "rdp" ? (
<Monitor />
) : host.connectionType === "vnc" ? (
<ScreenShare />
) : (
<Terminal />
)}
</Button>
)}
@@ -142,7 +181,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
<Button
variant="outline"
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 />
@@ -154,49 +193,54 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
side="right"
className="w-56 bg-dark-bg border-dark-border text-white"
>
{shouldShowMetrics && (
<DropdownMenuItem
onClick={() =>
addTab({ type: "server", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Server className="h-4 w-4" />
<span className="flex-1">Open Server Stats</span>
</DropdownMenuItem>
)}
{host.enableFileManager && (
<DropdownMenuItem
onClick={() =>
addTab({ type: "file_manager", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<FolderOpen className="h-4 w-4" />
<span className="flex-1">Open File Manager</span>
</DropdownMenuItem>
)}
{host.enableTunnel && (
<DropdownMenuItem
onClick={() =>
addTab({ type: "tunnel", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<ArrowDownUp className="h-4 w-4" />
<span className="flex-1">Open Tunnels</span>
</DropdownMenuItem>
)}
{host.enableDocker && (
<DropdownMenuItem
onClick={() =>
addTab({ type: "docker", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Container className="h-4 w-4" />
<span className="flex-1">Open Docker</span>
</DropdownMenuItem>
{/* SSH-specific menu items */}
{(!host.connectionType || host.connectionType === "ssh") && (
<>
{shouldShowMetrics && (
<DropdownMenuItem
onClick={() =>
addTab({ type: "server", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Server className="h-4 w-4" />
<span className="flex-1">Open Server Stats</span>
</DropdownMenuItem>
)}
{host.enableFileManager && (
<DropdownMenuItem
onClick={() =>
addTab({ type: "file_manager", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<FolderOpen className="h-4 w-4" />
<span className="flex-1">Open File Manager</span>
</DropdownMenuItem>
)}
{host.enableTunnel && (
<DropdownMenuItem
onClick={() =>
addTab({ type: "tunnel", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<ArrowDownUp className="h-4 w-4" />
<span className="flex-1">Open Tunnels</span>
</DropdownMenuItem>
)}
{host.enableDocker && (
<DropdownMenuItem
onClick={() =>
addTab({ type: "docker", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Container className="h-4 w-4" />
<span className="flex-1">Open Docker</span>
</DropdownMenuItem>
)}
</>
)}
<DropdownMenuItem
onClick={() =>

View File

@@ -838,6 +838,7 @@ export async function getSSHHosts(): Promise<SSHHost[]> {
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,
@@ -873,6 +874,10 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
: null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
// RDP/VNC specific fields
domain: hostData.domain || null,
security: hostData.security || null,
ignoreCert: Boolean(hostData.ignoreCert),
};
if (!submitData.enableTunnel) {
@@ -910,6 +915,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,
@@ -945,6 +951,10 @@ export async function updateSSHHost(
: null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
// RDP/VNC specific fields
domain: hostData.domain || null,
security: hostData.security || null,
ignoreCert: Boolean(hostData.ignoreCert),
};
if (!submitData.enableTunnel) {
@@ -3121,3 +3131,40 @@ export async function unlinkOIDCFromPasswordAccount(
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");
}
}