SOCKS5 support (#452)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* SOCKS5 support

Adding single and chain socks5 proxy support

* fix: cleanup files

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
This commit was merged in pull request #452.
This commit is contained in:
Denis
2025-12-20 09:35:40 +07:00
committed by GitHub
parent 94651107c1
commit ab1c63a4f6
18 changed files with 1342 additions and 284 deletions

View File

@@ -70,6 +70,14 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx";
import {
TERMINAL_THEMES,
TERMINAL_FONTS,
@@ -79,8 +87,8 @@ import {
DEFAULT_TERMINAL_CONFIG,
} from "@/constants/terminal-themes";
import { TerminalPreview } from "@/ui/desktop/apps/terminal/TerminalPreview.tsx";
import type { TerminalConfig } from "@/types";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import type { TerminalConfig, SSHHost, Credential } from "@/types";
import { Plus, X, Check, ChevronsUpDown, Save } from "lucide-react";
interface JumpHostItemProps {
jumpHost: { hostId: number };
@@ -278,46 +286,6 @@ function QuickActionItem({
);
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: Array<{
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}>;
jumpHosts?: Array<{
hostId: number;
}>;
quickActions?: Array<{
name: string;
snippetId: number;
}>;
statsConfig?: StatsConfig;
terminalConfig?: TerminalConfig;
createdAt: string;
updatedAt: string;
credentialId?: number;
}
interface SSHManagerHostEditorProps {
editingHost?: SSHHost | null;
onFormSubmit?: (updatedHost?: SSHHost) => void;
@@ -331,12 +299,11 @@ export function HostManagerEditor({
const [folders, setFolders] = useState<string[]>([]);
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [credentials, setCredentials] = useState<
Array<{ id: number; username: string; authType: string }>
>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [snippets, setSnippets] = useState<
Array<{ id: number; name: string; content: string }>
>([]);
const [proxyMode, setProxyMode] = useState<"single" | "chain">("single");
const [authTab, setAuthTab] = useState<
"password" | "key" | "credential" | "none"
@@ -370,7 +337,7 @@ export function HostManagerEditor({
getSnippets(),
]);
setHosts(hostsData);
setCredentials(credentialsData);
setCredentials(credentialsData as Credential[]);
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
const uniqueFolders = [
@@ -567,6 +534,22 @@ export function HostManagerEditor({
}),
)
.default([]),
useSocks5: z.boolean().optional(),
socks5Host: z.string().optional(),
socks5Port: z.coerce.number().min(1).max(65535).optional(),
socks5Username: z.string().optional(),
socks5Password: z.string().optional(),
socks5ProxyChain: z
.array(
z.object({
host: z.string().min(1),
port: z.number().min(1).max(65535),
type: z.union([z.literal(4), z.literal(5)]),
username: z.string().optional(),
password: z.string().optional(),
}),
)
.optional(),
enableDocker: z.boolean().default(false),
})
.superRefine((data, ctx) => {
@@ -604,11 +587,7 @@ export function HostManagerEditor({
});
}
} else if (data.authType === "credential") {
if (
!data.credentialId ||
(typeof data.credentialId === "string" &&
data.credentialId.trim() === "")
) {
if (!data.credentialId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("hosts.credentialRequired"),
@@ -660,6 +639,12 @@ export function HostManagerEditor({
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
useSocks5: false,
socks5Host: "",
socks5Port: 1080,
socks5Username: "",
socks5Password: "",
socks5ProxyChain: [],
enableDocker: false,
},
});
@@ -677,7 +662,7 @@ export function HostManagerEditor({
}
}
}
}, [authTab, credentials, form.getValues, form.setValue]);
}, [authTab, credentials, form]);
useEffect(() => {
if (editingHost) {
@@ -701,13 +686,13 @@ export function HostManagerEditor({
: "none";
setAuthTab(defaultAuthType);
let parsedStatsConfig = DEFAULT_STATS_CONFIG;
let parsedStatsConfig: StatsConfig = DEFAULT_STATS_CONFIG;
try {
if (cleanedHost.statsConfig) {
parsedStatsConfig =
typeof cleanedHost.statsConfig === "string"
? JSON.parse(cleanedHost.statsConfig)
: cleanedHost.statsConfig;
: (cleanedHost.statsConfig as StatsConfig);
}
} catch (error) {
console.error("Failed to parse statsConfig:", error);
@@ -715,7 +700,7 @@ export function HostManagerEditor({
parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig };
const formData = {
const formData: Partial<FormData> = {
name: cleanedHost.name || "",
ip: cleanedHost.ip || "",
port: cleanedHost.port || 22,
@@ -724,7 +709,7 @@ export function HostManagerEditor({
tags: Array.isArray(cleanedHost.tags) ? cleanedHost.tags : [],
pin: Boolean(cleanedHost.pin),
authType: defaultAuthType as "password" | "key" | "credential" | "none",
credentialId: null,
credentialId: cleanedHost.credentialId,
overrideCredentialUsername: Boolean(
cleanedHost.overrideCredentialUsername,
),
@@ -756,9 +741,27 @@ export function HostManagerEditor({
: [],
},
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
useSocks5: Boolean(cleanedHost.useSocks5),
socks5Host: cleanedHost.socks5Host || "",
socks5Port: cleanedHost.socks5Port || 1080,
socks5Username: cleanedHost.socks5Username || "",
socks5Password: cleanedHost.socks5Password || "",
socks5ProxyChain: Array.isArray(cleanedHost.socks5ProxyChain)
? cleanedHost.socks5ProxyChain
: [],
enableDocker: Boolean(cleanedHost.enableDocker),
};
// Determine proxy mode based on existing data
if (
Array.isArray(cleanedHost.socks5ProxyChain) &&
cleanedHost.socks5ProxyChain.length > 0
) {
setProxyMode("chain");
} else {
setProxyMode("single");
}
if (defaultAuthType === "password") {
formData.password = cleanedHost.password || "";
} else if (defaultAuthType === "key") {
@@ -776,14 +779,13 @@ export function HostManagerEditor({
| "ssh-rsa-sha2-256"
| "ssh-rsa-sha2-512") || "auto";
} else if (defaultAuthType === "credential") {
formData.credentialId =
cleanedHost.credentialId || "existing_credential";
formData.credentialId = cleanedHost.credentialId;
}
form.reset(formData);
form.reset(formData as FormData);
} else {
setAuthTab("password");
const defaultFormData = {
const defaultFormData: Partial<FormData> = {
name: "",
ip: "",
port: 22,
@@ -811,9 +813,9 @@ export function HostManagerEditor({
enableDocker: false,
};
form.reset(defaultFormData);
form.reset(defaultFormData as FormData);
}
}, [editingHost?.id]);
}, [editingHost, form]);
useEffect(() => {
const focusTimer = setTimeout(() => {
@@ -826,6 +828,8 @@ export function HostManagerEditor({
}, [editingHost]);
const onSubmit = async (data: FormData) => {
await form.trigger();
console.log("onSubmit called with data:", data);
try {
isSubmittingRef.current = true;
setFormError(null);
@@ -855,66 +859,45 @@ export function HostManagerEditor({
}
}
const submitData: Record<string, unknown> = {
name: data.name,
ip: data.ip,
port: data.port,
username: data.username,
folder: data.folder || "",
tags: data.tags || [],
pin: Boolean(data.pin),
authType: data.authType,
overrideCredentialUsername: Boolean(data.overrideCredentialUsername),
enableTerminal: Boolean(data.enableTerminal),
enableDocker: Boolean(data.enableDocker),
enableTunnel: Boolean(data.enableTunnel),
enableFileManager: Boolean(data.enableFileManager),
defaultPath: data.defaultPath || "/",
tunnelConnections: data.tunnelConnections || [],
jumpHosts: data.jumpHosts || [],
quickActions: data.quickActions || [],
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive),
const submitData: Partial<SSHHost> = {
...data,
};
if (proxyMode === "single") {
submitData.socks5ProxyChain = [];
} else if (proxyMode === "chain") {
submitData.socks5Host = "";
submitData.socks5Port = 1080;
submitData.socks5Username = "";
submitData.socks5Password = "";
}
submitData.credentialId = null;
submitData.password = null;
submitData.key = null;
submitData.keyPassword = null;
submitData.keyType = null;
if (data.authType !== "credential") {
submitData.credentialId = undefined;
}
if (data.authType !== "password") {
submitData.password = undefined;
}
if (data.authType !== "key") {
submitData.key = undefined;
submitData.keyPassword = undefined;
submitData.keyType = undefined;
}
if (data.authType === "credential") {
if (
data.credentialId === "existing_credential" &&
editingHost &&
editingHost.id
) {
delete submitData.credentialId;
} else {
submitData.credentialId = data.credentialId;
}
} else if (data.authType === "password") {
submitData.password = data.password;
} else if (data.authType === "key") {
if (data.authType === "key") {
if (data.key instanceof File) {
const keyContent = await data.key.text();
submitData.key = keyContent;
submitData.key = await data.key.text();
} else if (data.key === "existing_key") {
delete submitData.key;
} else {
submitData.key = data.key;
}
submitData.keyPassword = data.keyPassword;
submitData.keyType = data.keyType;
}
let savedHost;
if (editingHost && editingHost.id) {
savedHost = await updateSSHHost(editingHost.id, submitData);
savedHost = await updateSSHHost(editingHost.id, submitData as any);
toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name }));
} else {
savedHost = await createSSHHost(submitData);
savedHost = await createSSHHost(submitData as any);
toast.success(t("hosts.hostAddedSuccessfully", { name: data.name }));
}
@@ -959,7 +942,9 @@ export function HostManagerEditor({
notifyHostCreatedOrUpdated(savedHost.id);
}
} catch (error) {
toast.error(t("hosts.failedToSaveHost"));
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(t("hosts.failedToSaveHost") + ": " + errorMessage);
console.error("Failed to save host:", error);
} finally {
isSubmittingRef.current = false;
@@ -1499,7 +1484,8 @@ export function HostManagerEditor({
<span
className="truncate"
title={
field.value?.name || t("hosts.upload")
(field.value as File)?.name ||
t("hosts.upload")
}
>
{field.value === "existing_key"
@@ -1507,7 +1493,7 @@ export function HostManagerEditor({
: field.value
? editingHost
? t("hosts.updateKey")
: field.value.name
: (field.value as File).name
: t("hosts.upload")}
</span>
</Button>
@@ -1546,8 +1532,6 @@ export function HostManagerEditor({
dropCursor: false,
allowMultipleSelections: false,
highlightSelectionMatches: false,
searchKeymap: false,
scrollPastEnd: false,
}}
extensions={[
EditorView.theme({
@@ -1796,159 +1780,405 @@ export function HostManagerEditor({
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="socks5">
<AccordionTrigger>
{t("hosts.socks5Proxy")}
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<Alert>
<AlertDescription>
{t("hosts.socks5Description")}
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="useSocks5"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>{t("hosts.enableSocks5")}</FormLabel>
<FormDescription>
{t("hosts.enableSocks5Description")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.watch("useSocks5") && (
<div className="space-y-4">
<div className="space-y-2">
<FormLabel>
{t("hosts.socks5ProxyMode")}
</FormLabel>
<div className="flex gap-2">
<Button
type="button"
variant={
proxyMode === "single"
? "default"
: "outline"
}
onClick={() => setProxyMode("single")}
className="flex-1"
>
{t("hosts.socks5UseSingleProxy")}
</Button>
<Button
type="button"
variant={
proxyMode === "chain"
? "default"
: "outline"
}
onClick={() => setProxyMode("chain")}
className="flex-1"
>
{t("hosts.socks5UseProxyChain")}
</Button>
</div>
</div>
{proxyMode === "single" && (
<div className="space-y-4 p-4 border rounded-lg">
<FormField
control={form.control}
name="socks5Host"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.socks5Host")}
</FormLabel>
<FormControl>
<Input
placeholder="proxy.example.com"
{...field}
/>
</FormControl>
<FormDescription>
{t("hosts.socks5HostDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="socks5Port"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.socks5Port")}
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="1080"
{...field}
onChange={(e) =>
field.onChange(
parseInt(e.target.value) || 1080,
)
}
/>
</FormControl>
<FormDescription>
{t("hosts.socks5PortDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="socks5Username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.socks5Username")} (
{t("hosts.optional")})
</FormLabel>
<FormControl>
<Input
placeholder={t("hosts.username")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="socks5Password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.socks5Password")} (
{t("hosts.optional")})
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("hosts.password")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
)}
{proxyMode === "chain" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<FormLabel>
{t("hosts.socks5ProxyChain")}
</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const currentChain =
form.watch("socks5ProxyChain") || [];
form.setValue("socks5ProxyChain", [
...currentChain,
{
host: "",
port: 1080,
type: 5 as 4 | 5,
username: "",
password: "",
},
]);
}}
>
<Plus className="h-4 w-4 mr-2" />
{t("hosts.addProxyNode")}
</Button>
</div>
{(form.watch("socks5ProxyChain") || [])
.length === 0 && (
<div className="text-sm text-muted-foreground text-center p-4 border rounded-lg border-dashed">
{t("hosts.noProxyNodes")}
</div>
)}
{(form.watch("socks5ProxyChain") || []).map(
(node: any, index: number) => (
<div
key={index}
className="p-4 border rounded-lg space-y-3 relative"
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">
{t("hosts.proxyNode")} {index + 1}
</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const currentChain =
form.watch("socks5ProxyChain") ||
[];
form.setValue(
"socks5ProxyChain",
currentChain.filter(
(_: any, i: number) =>
i !== index,
),
);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<FormLabel>
{t("hosts.socks5Host")}
</FormLabel>
<Input
placeholder="proxy.example.com"
value={node.host}
onChange={(e) => {
const currentChain =
form.watch(
"socks5ProxyChain",
) || [];
const newChain = [
...currentChain,
];
newChain[index] = {
...newChain[index],
host: e.target.value,
};
form.setValue(
"socks5ProxyChain",
newChain,
);
}}
/>
</div>
<div className="space-y-2">
<FormLabel>
{t("hosts.socks5Port")}
</FormLabel>
<Input
type="number"
placeholder="1080"
value={node.port}
onChange={(e) => {
const currentChain =
form.watch(
"socks5ProxyChain",
) || [];
const newChain = [
...currentChain,
];
newChain[index] = {
...newChain[index],
port:
parseInt(e.target.value) ||
1080,
};
form.setValue(
"socks5ProxyChain",
newChain,
);
}}
/>
</div>
</div>
<div className="space-y-2">
<FormLabel>
{t("hosts.proxyType")}
</FormLabel>
<Select
value={String(node.type)}
onValueChange={(value) => {
const currentChain =
form.watch("socks5ProxyChain") ||
[];
const newChain = [...currentChain];
newChain[index] = {
...newChain[index],
type: parseInt(value) as 4 | 5,
};
form.setValue(
"socks5ProxyChain",
newChain,
);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="4">
SOCKS4
</SelectItem>
<SelectItem value="5">
SOCKS5
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<FormLabel>
{t("hosts.socks5Username")} (
{t("hosts.optional")})
</FormLabel>
<Input
placeholder={t("hosts.username")}
value={node.username || ""}
onChange={(e) => {
const currentChain =
form.watch(
"socks5ProxyChain",
) || [];
const newChain = [
...currentChain,
];
newChain[index] = {
...newChain[index],
username: e.target.value,
};
form.setValue(
"socks5ProxyChain",
newChain,
);
}}
/>
</div>
<div className="space-y-2">
<FormLabel>
{t("hosts.socks5Password")} (
{t("hosts.optional")})
</FormLabel>
<PasswordInput
placeholder={t("hosts.password")}
value={node.password || ""}
onChange={(e) => {
const currentChain =
form.watch(
"socks5ProxyChain",
) || [];
const newChain = [
...currentChain,
];
newChain[index] = {
...newChain[index],
password: e.target.value,
};
form.setValue(
"socks5ProxyChain",
newChain,
);
}}
/>
</div>
</div>
</div>
),
)}
</div>
)}
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</TabsContent>
<TabsContent value="terminal" className="space-y-1">
<FormField
control={form.control}
name="enableTerminal"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enableTerminal")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t("hosts.enableTerminalDesc")}
</FormDescription>
</FormItem>
)}
/>
<Alert className="mt-4 mb-4">
<AlertDescription>
{t("hosts.terminalCustomizationNotice")}
</AlertDescription>
</Alert>
<h1 className="text-xl font-semibold mt-7">
{t("hosts.terminalCustomization")}
</h1>
<Accordion type="multiple" className="w-full">
<TabsContent value="terminal">
<Accordion
type="multiple"
className="w-full"
defaultValue={["appearance", "behavior", "advanced"]}
>
<AccordionItem value="appearance">
<AccordionTrigger>
{t("hosts.appearance")}
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{t("hosts.themePreview")}
</label>
<TerminalPreview
theme={form.watch("terminalConfig.theme")}
fontSize={form.watch("terminalConfig.fontSize")}
fontFamily={form.watch("terminalConfig.fontFamily")}
cursorStyle={form.watch(
"terminalConfig.cursorStyle",
)}
cursorBlink={form.watch(
"terminalConfig.cursorBlink",
)}
letterSpacing={form.watch(
"terminalConfig.letterSpacing",
)}
lineHeight={form.watch("terminalConfig.lineHeight")}
/>
</div>
<FormField
control={form.control}
name="terminalConfig.theme"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.theme")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t("hosts.selectTheme")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(TERMINAL_THEMES).map(
([key, theme]) => (
<SelectItem key={key} value={key}>
{theme.name}
</SelectItem>
),
)}
</SelectContent>
</Select>
<FormDescription>
{t("hosts.chooseColorTheme")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.fontFamily"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.fontFamily")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t("hosts.selectFont")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{TERMINAL_FONTS.map((font) => (
<SelectItem
key={font.value}
value={font.value}
>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{t("hosts.selectFontDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.fontSize"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.fontSizeValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={8}
max={24}
step={1}
value={[field.value]}
onValueChange={([value]) =>
field.onChange(value)
}
/>
</FormControl>
<FormDescription>
{t("hosts.adjustFontSize")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.letterSpacing"