feat: convert ssh tunnels into a plugin

This commit is contained in:
LukeGus committed 2026-09-23 17:27:28 -05:00
1 parent c85c1fdb86
commit 1682861853
220 files changed
+36928 -11374

No files matched your search

-1
View File
@@ -30,7 +30,6 @@ const RESOURCE_TYPES = [
"session",
"api_key",
"setting",
"tunnel",
];
type AdminAuditLogSectionProps = {
-1
View File
@@ -19,7 +19,6 @@ import { useRailItems, type RailItemDef } from "./rail-items";
export type CoreRailView =
| "hosts"
| "credentials"
| "port-forwarding"
| "termix-id"
| "quick-connect"
| "serial"
-1
View File
@@ -25,7 +25,6 @@ import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
/** Core badge labels; plugin tabs are labelled by their registered title. */
const CORE_TYPE_LABELS: Record<string, string> = {
files: "Files",
tunnel: "Tunnel",
};
/** Saved connection tabs: the ones reopened after login. */
+1 -402
View File
@@ -26,7 +26,6 @@ import {
Globe,
Info,
Layers, // --- tmux-monitor ---
Network,
Palette,
Pencil,
Plus,
@@ -42,9 +41,6 @@ import {
createSSHHost,
updateSSHHost,
getSnippets,
subscribeTunnelStatuses,
connectTunnel,
disconnectTunnel,
getUserInfo,
getVaultProfiles,
getHostPassword,
@@ -63,7 +59,7 @@ import {
type SavedCustomTheme,
} from "@/api/open-tabs-api";
import type { Host, VaultProfile } from "@/types/ui-types";
import type { SSHHost, TunnelStatus } from "@/types";
import type { SSHHost } from "@/types";
import { useTabsSafe } from "@/shell/TabContext";
import { updatePluginHostSettings } from "@/api/plugins-api";
import {
@@ -97,7 +93,6 @@ import {
SecretReferenceHint,
SecretSourceManager,
} from "./SecretSourceManager";
import { findHostByTunnelEndpoint } from "@/features/tunnel/tunnel-endpoints";
import {
toCredentialOption,
type CredentialOption,
@@ -177,10 +172,6 @@ export function HostEditor({
const [saving, setSaving] = useState(false);
const [snippets, setSnippets] = useState<{ id: number; name: string }[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<
Record<string, TunnelStatus>
>({});
const [connectingTunnel, setConnectingTunnel] = useState<number | null>(null);
const [isOidcUser, setIsOidcUser] = useState(false);
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
const [showVaultManager, setShowVaultManager] = useState(false);
@@ -299,12 +290,6 @@ export function HostEditor({
};
}, [form.vncAuthType, form.vncPassword, host?.id, adminTargetUserId]);
useEffect(() => {
if (activeTab !== "tunnels") return;
const unsub = subscribeTunnelStatuses((s) => setTunnelStatuses(s));
return unsub;
}, [activeTab]);
/**
* Writes each plugin's host-scope values through its own route.
*
@@ -2130,392 +2115,6 @@ export function HostEditor({
</>
)}
{activeTab === "tunnels" && (
<>
<SectionCard
title={t("hosts.tunnelSettings")}
icon={<Network className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableTunneling")}
description={
<>
{t("hosts.enableTunnelingDesc")}{" "}
<a
href="https://docs.termix.site/features/networking/tunnels"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</>
}
>
<FakeSwitch
checked={form.enableTunnel}
onChange={(v) => setField("enableTunnel", v)}
/>
</SettingRow>
<div className="text-xs text-muted-foreground p-3 bg-muted/30 border border-border space-y-1">
<p>{t("hosts.tunnelRequirementsText")}</p>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.serverTunnelsSection")}
icon={<Network className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("serverTunnels", [
...form.serverTunnels,
{
mode: "local" as const,
sourcePort: 8080,
endpointHost: "",
endpointPort: 80,
bindHost: "127.0.0.1",
maxRetries: 3,
retryInterval: 10,
autoStart: false,
},
])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addTunnelBtn")}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
{form.serverTunnels.length === 0 && (
<p className="text-[10px] text-muted-foreground/50 px-1">
{t("hosts.noTunnelsConfigured")}
</p>
)}
{form.serverTunnels.map((tun, i) => {
const endpointValue = (tun.endpointHost ?? "").trim();
const selectedEndpointHost = findHostByTunnelEndpoint(
hosts,
endpointValue,
);
const directEndpoint =
!endpointValue ||
endpointValue === "127.0.0.1" ||
endpointValue === "localhost";
const endpointInputId = `server-tunnel-endpoint-${host?.id ?? "new"}-${i}`;
const hostLabel =
host?.name ||
(host ? `${host.username}@${host.ip}` : "new");
const tunnelName = `${host?.id ?? "new"}::${i}::${hostLabel}::${tun.sourcePort}::${endpointValue}::${tun.endpointPort}`;
const tunnelStatus = tunnelStatuses[tunnelName]?.status as
string | undefined;
const isConnected = tunnelStatus === "connected";
return (
<div
key={i}
className="flex flex-col gap-3 p-3 border border-border bg-muted/20 relative group"
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-muted-foreground">
{t("hosts.tunnelLabel", { number: i + 1 })}
</span>
<div
className={`size-1.5 rounded-full shrink-0 ${
isConnected
? "bg-accent-brand shadow-[0_0_4px_rgba(251,146,60,0.4)]"
: tunnelStatus === "error"
? "bg-red-400"
: "bg-muted-foreground/25"
}`}
title={tunnelStatus ?? "not connected"}
/>
{host && (
<Button
variant="outline"
size="sm"
disabled={connectingTunnel === i}
className={`h-6 text-[10px] px-2 ${isConnected ? "border-destructive/40 text-destructive hover:bg-destructive/10" : "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"}`}
onClick={async () => {
setConnectingTunnel(i);
try {
if (isConnected) {
await disconnectTunnel(tunnelName);
toast.success(
t("hosts.tunnelDisconnected"),
);
} else {
await connectTunnel({
name: tunnelName,
mode: tun.mode,
sourceHostId: Number(host.id),
tunnelIndex: i,
hostName: host.name,
sourceIP: host.ip,
sourceSSHPort:
host.sshPort ?? host.port,
sourceUsername: form.username,
sourcePassword:
form.password || undefined,
sourceAuthMethod: form.authType,
sourceSSHKey: form.key || undefined,
sourceKeyPassword:
form.keyPassword || undefined,
sourceCredentialId: form.credentialId
? Number(form.credentialId)
: undefined,
endpointIP: directEndpoint
? host.ip
: (selectedEndpointHost?.ip ??
endpointValue),
endpointSSHPort:
directEndpoint || selectedEndpointHost
? directEndpoint
? (host.sshPort ?? host.port)
: (selectedEndpointHost?.sshPort ??
selectedEndpointHost?.port ??
22)
: 22,
endpointHost: endpointValue,
endpointUsername: directEndpoint
? form.username
: (selectedEndpointHost?.username ??
""),
endpointAuthMethod:
selectedEndpointHost?.authType ??
"none",
endpointCredentialId:
selectedEndpointHost?.credentialId
? Number(
selectedEndpointHost.credentialId,
)
: undefined,
sourcePort: tun.sourcePort,
endpointPort: tun.endpointPort ?? 0,
bindHost: tun.bindHost ?? "127.0.0.1",
maxRetries: tun.maxRetries ?? 3,
retryInterval: tun.retryInterval ?? 10,
autoStart: tun.autoStart ?? false,
isPinned: false,
});
toast.success(
t("hosts.tunnelConnecting"),
);
}
} catch {
toast.error(
isConnected
? t("hosts.failedToDisconnectTunnel")
: t("hosts.failedToConnectTunnel"),
);
} finally {
setConnectingTunnel(null);
}
}}
>
{connectingTunnel === i
? "..."
: isConnected
? t("hosts.disconnectBtn")
: t("hosts.connectBtn")}
</Button>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive"
onClick={() =>
setField(
"serverTunnels",
form.serverTunnels.filter(
(_, idx) => idx !== i,
),
)
}
>
{t("common.delete")}
</Button>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.tunnelType")}
</label>
<div className="flex gap-2">
{(["remote", "local", "dynamic"] as const).map(
(m) => (
<button
key={m}
onClick={() => {
const updated = [...form.serverTunnels];
updated[i] = { ...updated[i], mode: m };
setField("serverTunnels", updated);
}}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${tun.mode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m}
</button>
),
)}
</div>
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
{tun.mode === "local"
? t("hosts.tunnelModeLocalDesc")
: tun.mode === "remote"
? t("hosts.tunnelModeRemoteDesc")
: t("hosts.tunnelModeDynamicDesc")}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{tun.mode !== "dynamic" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.endpointHost")}
</label>
<Input
className="h-7 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
list={endpointInputId}
placeholder={t("hosts.endpointHostPlaceholder")}
value={tun.endpointHost ?? ""}
onChange={(e) => {
const updated = [...form.serverTunnels];
updated[i] = {
...updated[i],
endpointHost: e.target.value,
};
setField("serverTunnels", updated);
}}
/>
<datalist id={endpointInputId}>
{hosts
.filter((h) => h.enableSsh)
.map((h) => (
<option key={h.id} value={h.ip}>
{h.name || h.ip} ({h.ip})
</option>
))}
</datalist>
</div>
)}
{tun.mode !== "dynamic" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.endpointPort")}
</label>
<Input
className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
type="number"
value={tun.endpointPort}
onChange={(e) => {
const updated = [...form.serverTunnels];
updated[i] = {
...updated[i],
endpointPort: Number(e.target.value),
};
setField("serverTunnels", updated);
}}
/>
</div>
)}
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.bindHost")}
</label>
<Input
className="h-7 text-xs"
placeholder="127.0.0.1"
value={tun.bindHost ?? ""}
onChange={(e) => {
const updated = [...form.serverTunnels];
updated[i] = {
...updated[i],
bindHost: e.target.value,
};
setField("serverTunnels", updated);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.sourcePort")}
</label>
<Input
className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
type="number"
value={tun.sourcePort}
onChange={(e) => {
const updated = [...form.serverTunnels];
updated[i] = {
...updated[i],
sourcePort: Number(e.target.value),
};
setField("serverTunnels", updated);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.maxRetries")}
</label>
<Input
className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
type="number"
value={tun.maxRetries}
onChange={(e) => {
const updated = [...form.serverTunnels];
updated[i] = {
...updated[i],
maxRetries: Number(e.target.value),
};
setField("serverTunnels", updated);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.retryIntervalS")}
</label>
<Input
className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
type="number"
value={tun.retryInterval}
onChange={(e) => {
const updated = [...form.serverTunnels];
updated[i] = {
...updated[i],
retryInterval: Number(e.target.value),
};
setField("serverTunnels", updated);
}}
/>
</div>
</div>
<SettingRow
label={t("hosts.autoStartLabel")}
description={t("hosts.autoStartDesc")}
>
<FakeSwitch
checked={tun.autoStart}
onChange={(v) => {
const updated = [...form.serverTunnels];
updated[i] = { ...updated[i], autoStart: v };
setField("serverTunnels", updated);
}}
/>
</SettingRow>
</div>
);
})}
</div>
</SectionCard>
</>
)}
{(() => {
const section = getHostEditorSection(activeTab);
if (!section) return null;
-4
View File
@@ -221,7 +221,6 @@ export function createHostEditorForm(
pollInterval: 60,
nodeName: null,
}) as Host["proxmoxStatsConfig"],
enableTunnel: host?.enableTunnel ?? false,
defaultPath: host?.defaultPath ?? "/",
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
inheritTerminalAppearance:
@@ -290,7 +289,6 @@ export function createHostEditorForm(
environmentVariables:
host?.terminalConfig?.environmentVariables ??
([] as { key: string; value: string }[]),
serverTunnels: host?.serverTunnels ?? ([] as Host["serverTunnels"]),
jumpHosts: host?.jumpHosts ?? ([] as { hostId: string }[]),
portKnockSequence:
host?.portKnockSequence ??
@@ -506,7 +504,6 @@ export function buildHostEditorPayload(
enableTerminal: form.enableTerminal,
enableSessionLogging: form.enableSessionLogging,
enableCommandHistory: form.enableCommandHistory,
enableTunnel: form.enableTunnel,
enableFileManager: form.enableFileManager,
scpLegacy: form.scpLegacy,
enableDocker: form.enableDocker,
@@ -606,7 +603,6 @@ export function buildHostEditorPayload(
// a number, and a string id does not compare equal on Postgres/MySQL.
jumpHosts: form.jumpHosts.map((j) => ({ hostId: Number(j.hostId) })),
portKnockSequence: form.portKnockSequence,
tunnelConnections: form.serverTunnels,
quickActions: form.quickActions.map((a) => ({
name: a.name,
snippetId: Number(a.snippetId),
+2 -2
View File
@@ -27,7 +27,7 @@ const GROUPS: { key: FieldGroup; label: string }[] = [
{ key: "connection", label: "groupConnection" },
{ key: "notes", label: "groupNotes" },
{ key: "tags", label: "groupTags" },
{ key: "tunnels", label: "groupTunnels" },
{ key: "proxy", label: "groupProxy" },
{ key: "jumpHosts", label: "groupJumpHosts" },
{ key: "quickActions", label: "groupQuickActions" },
{ key: "featureFlags", label: "groupFeatureFlags" },
@@ -38,7 +38,7 @@ const DEFAULT_GROUPS: FieldGroup[] = [
"connection",
"notes",
"tags",
"tunnels",
"proxy",
"jumpHosts",
"quickActions",
"featureFlags",
+3 -15
View File
@@ -1,16 +1,10 @@
/* eslint-disable react-refresh/only-export-components */
import { useEffect, useRef, type ComponentType, type ReactNode } from "react";
import {
KeyRound,
Network,
Settings,
SquareTerminal,
Terminal,
} from "lucide-react";
import { KeyRound, Settings, SquareTerminal, Terminal } from "lucide-react";
import { byOrderThenId, createRegistry } from "@/lib/registry";
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
export type CoreHostTabId = "general" | "ssh" | "terminal" | "tunnels";
export type CoreHostTabId = "general" | "ssh" | "terminal";
export type HostTabId = CoreHostTabId | (string & {});
export type CredentialTabId = "general" | "auth";
@@ -66,7 +60,7 @@ export const hostEditorSectionList = sections.list;
export const useHostEditorSections = sections.useList;
export const resetHostEditorSections = sections.reset;
const CORE_SSH_GROUP = new Set<string>(["ssh", "terminal", "tunnels"]);
const CORE_SSH_GROUP = new Set<string>(["ssh", "terminal"]);
/** Whether a tab lives in the SSH group's second strip. */
export function isSshGroupTab(id: string): boolean {
@@ -159,12 +153,6 @@ export function makeHostSshSubTabs(
icon: <SquareTerminal className="size-3" />,
order: 10,
},
{
id: "tunnels",
label: t("hosts.tabTunnels"),
icon: <Network className="size-3" />,
order: 20,
},
],
"ssh",
t,
-2
View File
@@ -153,7 +153,6 @@ function hostPassesFilters(host: Host, filters: FilterState): boolean {
const ok =
(filters.features.includes("terminal") && host.enableTerminal) ||
(filters.features.includes("fileManager") && host.enableFileManager) ||
(filters.features.includes("tunnel") && host.enableTunnel) ||
(filters.features.includes("docker") && host.enableDocker);
if (!ok) return false;
}
@@ -797,7 +796,6 @@ export function HostsPanel({
[
["terminal", "Terminal"],
["fileManager", "FileManager"],
["tunnel", "Tunnel"],
["docker", "Docker"],
] as const
).map(([val, key]) => (
-9
View File
@@ -1,9 +0,0 @@
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
export function PortForwardingPanel() {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-2">
<C2STunnelPresetManager />
</div>
);
}
+1 -21
View File
@@ -35,7 +35,6 @@ import type React from "react";
import { isElectron } from "@/lib/electron";
import { RemoteSyncPanel } from "@/settings/RemoteSyncPanel.tsx";
import { shouldForceLocalPreferenceStorage } from "@/settings/remote-sync-state";
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { VersionBadge } from "@/components/version-badge";
@@ -48,7 +47,6 @@ import {
DialogFooter,
} from "@/components/dialog";
import {
Activity,
AlertCircle,
CheckCircle2,
ChevronDown,
@@ -92,13 +90,7 @@ import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences"
import { ConnectionDefaultsSettings } from "./ConnectionDefaultsSettings";
type UserProfileSection =
| "account"
| "interface"
| "appearance"
| "security"
| "api-keys"
| "data"
| "c2s-tunnels";
"account" | "interface" | "appearance" | "security" | "api-keys" | "data";
const THEMES: { id: ThemeId; preview: string }[] = [
{ id: "system", preview: "auto" },
@@ -2763,18 +2755,6 @@ export function UserProfilePanel({
</div>
</AccordionSection>
{isElectron() && (
<AccordionSection
id="c2s-tunnels"
label={t("newUi.sidebar.userProfile.sectionC2sTunnels")}
icon={<Activity className="size-3.5" />}
open={openSections.has("c2s-tunnels")}
onToggle={() => toggle("c2s-tunnels")}
>
<C2STunnelPresetManager />
</AccordionSection>
)}
{/* Delete account dialog */}
<Dialog
open={showDeleteConfirm}
+2 -4
View File
@@ -2,7 +2,7 @@ export type FieldGroup =
| "connection"
| "notes"
| "tags"
| "tunnels"
| "proxy"
| "jumpHosts"
| "quickActions"
| "featureFlags"
@@ -29,8 +29,7 @@ export const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
],
notes: ["notes"],
tags: ["tags", "pin"],
tunnels: [
"tunnelConnections",
proxy: [
"useSocks5",
"socks5Host",
"socks5Port",
@@ -44,7 +43,6 @@ export const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
"enableCommandHistory",
"enableTerminalToolbar",
"enableAiAssistant",
"enableTunnel",
"enableFileManager",
"enableDocker",
"enableWebUi",
-8
View File
@@ -5,7 +5,6 @@ import {
Hammer,
KeyRound,
LayoutPanelLeft,
Network,
Plug,
ScrollText,
Server,
@@ -80,13 +79,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
labelKey: "nav.credentials",
separatorAfter: true,
},
{
id: "port-forwarding",
icon: Network,
labelKey: "nav.portForwarding",
electronOnly: true,
separatorAfter: true,
},
{
id: "termix-id",
icon: Fingerprint,
-22
View File
@@ -15,7 +15,6 @@ import {
Link,
MemoryStick,
MoreHorizontal,
Network,
Pencil,
Pin,
Share2,
@@ -125,13 +124,6 @@ export function getSshActions(host: Host): {
order: number;
}[] {
return [
host.enableSsh &&
host.enableTunnel && {
type: "tunnel" as TabType,
icon: Network,
label: "Tunnel",
order: 40,
},
// --- tmux-monitor --- opt-in per host, off by default
host.enableSsh &&
host.enableTerminal &&
@@ -806,20 +798,6 @@ export function HostItem({
{t("hosts.copyLink")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{host.enableSsh && host.enableTunnel && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
writeClipboardText(
`${window.location.origin}?view=tunnel&hostId=${host.id}`,
);
toast.success(t("hosts.tunnelUrlCopied"));
}}
>
<Network className="size-3.5 mr-2" />
{t("hosts.copyTunnelUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh &&
host.enableTerminal &&
host.enableTmuxMonitor && (
-13
View File
@@ -17,7 +17,6 @@ import {
FolderOpen,
FolderSearch,
Loader2,
Network,
Plus,
Server,
Terminal,
@@ -1169,18 +1168,6 @@ export function SidebarTree({
value: false,
icon: FolderSearch,
},
{
labelKey: "hosts.enableTunnelsFeature",
field: "enableTunnel",
value: true,
icon: Network,
},
{
labelKey: "hosts.disableTunnelsFeature",
field: "enableTunnel",
value: false,
icon: Network,
},
{
labelKey: "hosts.enableDockerFeature",
field: "enableDocker",