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

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,372 @@
import { useEffect, useState } from "react";
import { Network, Plus } from "lucide-react";
import {
useHosts,
useToast,
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
import {
Button,
FakeSwitch,
Input,
SectionCard,
SettingRow,
} from "@termix/plugin-sdk/ui";
import type { TunnelConnection } from "../shared/types";
import { serverTunnelName } from "../shared/tunnel-naming";
import {
connectTunnel,
disconnectTunnel,
subscribeTunnelStatuses,
type TunnelStatusMap,
} from "./api";
import {
connectRequestFor,
parseConnections,
tunnelMode,
} from "./host-tunnels";
type PluginSettingsForm = Record<string, Record<string, unknown>>;
function newTunnel(): TunnelConnection {
return {
scope: "s2s",
mode: "local",
sourcePort: 8080,
endpointHost: "",
endpointPort: 80,
bindHost: "127.0.0.1",
maxRetries: 3,
retryInterval: 10,
autoStart: false,
};
}
const MODE_DESCRIPTION_KEYS = {
local: "hosts.tunnelModeLocalDesc",
remote: "hosts.tunnelModeRemoteDesc",
dynamic: "hosts.tunnelModeDynamicDesc",
} as const;
const numberInputClass =
"h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none";
/**
* The host editor's Tunnels tab. Writes into the form's pluginSettings for
* this plugin, which the editor saves through the plugin's host settings
* route after the host itself.
*/
export function HostTunnelsSection({
form,
updateForm,
host,
}: HostEditorSectionProps) {
const { t } = useTranslation();
const toast = useToast();
const { hosts } = useHosts();
const [statuses, setStatuses] = useState<TunnelStatusMap>({});
const [busyIndex, setBusyIndex] = useState<number | null>(null);
useEffect(() => subscribeTunnelStatuses(setStatuses), []);
const settings = ((form?.pluginSettings as PluginSettingsForm | undefined)
?.tunnels ?? {}) as Record<string, unknown>;
const enabled = settings.enableTunnel === true;
const tunnels = parseConnections(settings.tunnelConnections);
const setSettings = (patch: Record<string, unknown>) =>
updateForm((current) => {
const all = (current.pluginSettings ?? {}) as PluginSettingsForm;
return {
...current,
pluginSettings: {
...all,
tunnels: { ...(all.tunnels ?? {}), ...patch },
},
};
});
const setTunnels = (next: TunnelConnection[]) =>
setSettings({ tunnelConnections: next });
const updateTunnel = (index: number, patch: Partial<TunnelConnection>) =>
setTunnels(
tunnels.map((tunnel, i) =>
i === index ? { ...tunnel, ...patch } : tunnel,
),
);
const sourceHost = host
? {
id: host.id,
name: (form?.name as string) || host.name,
username: (form?.username as string) || host.username,
ip: (form?.ip as string) || host.ip,
}
: null;
const toggle = async (index: number, connected: boolean) => {
if (!sourceHost) return;
const tunnel = tunnels[index];
setBusyIndex(index);
try {
if (connected) {
await disconnectTunnel(serverTunnelName(sourceHost, index, tunnel));
toast.success(t("hosts.tunnelDisconnected"));
} else {
await connectTunnel(connectRequestFor(sourceHost, index, tunnel));
toast.success(t("hosts.tunnelConnecting"));
}
} catch {
toast.error(
connected
? t("hosts.failedToDisconnectTunnel")
: t("hosts.failedToConnectTunnel"),
);
} finally {
setBusyIndex(null);
}
};
return (
<>
<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={enabled}
onChange={(value) => setSettings({ enableTunnel: value })}
/>
</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={() => setTunnels([...tunnels, newTunnel()])}
>
<Plus className="size-3 mr-1" /> {t("hosts.addTunnelBtn")}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
{tunnels.length === 0 && (
<p className="text-[10px] text-muted-foreground/50 px-1">
{t("hosts.noTunnelsConfigured")}
</p>
)}
{tunnels.map((tunnel, i) => {
const mode = tunnelMode(tunnel);
const endpointInputId = `server-tunnel-endpoint-${host?.id ?? "new"}-${i}`;
const status = sourceHost
? statuses[serverTunnelName(sourceHost, i, tunnel)]?.status
: undefined;
const isConnected = status === "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)]"
: status === "failed"
? "bg-red-400"
: "bg-muted-foreground/25"
}`}
title={status ?? t("tunnels.disconnected")}
/>
{sourceHost && (
<Button
variant="outline"
size="sm"
disabled={busyIndex === 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={() => toggle(i, isConnected)}
>
{busyIndex === 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={() =>
setTunnels(tunnels.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={() => updateTunnel(i, { mode: m })}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${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">
{t(MODE_DESCRIPTION_KEYS[mode])}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{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={tunnel.endpointHost ?? ""}
onChange={(e) =>
updateTunnel(i, { endpointHost: e.target.value })
}
/>
<datalist id={endpointInputId}>
{hosts
.filter((h) => h.enableSsh !== false)
.map((h) => (
<option key={h.id} value={h.ip}>
{h.name || h.ip} ({h.ip})
</option>
))}
</datalist>
</div>
)}
{mode !== "dynamic" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.endpointPort")}
</label>
<Input
className={numberInputClass}
type="number"
value={tunnel.endpointPort}
onChange={(e) =>
updateTunnel(i, {
endpointPort: Number(e.target.value),
})
}
/>
</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={tunnel.bindHost ?? ""}
onChange={(e) =>
updateTunnel(i, { bindHost: e.target.value })
}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.sourcePort")}
</label>
<Input
className={numberInputClass}
type="number"
value={tunnel.sourcePort}
onChange={(e) =>
updateTunnel(i, { sourcePort: Number(e.target.value) })
}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.maxRetries")}
</label>
<Input
className={numberInputClass}
type="number"
value={tunnel.maxRetries}
onChange={(e) =>
updateTunnel(i, { maxRetries: Number(e.target.value) })
}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.retryIntervalS")}
</label>
<Input
className={numberInputClass}
type="number"
value={tunnel.retryInterval}
onChange={(e) =>
updateTunnel(i, {
retryInterval: Number(e.target.value),
})
}
/>
</div>
</div>
<SettingRow
label={t("hosts.autoStartLabel")}
description={t("hosts.autoStartDesc")}
>
<FakeSwitch
checked={tunnel.autoStart}
onChange={(value) => updateTunnel(i, { autoStart: value })}
/>
</SettingRow>
</div>
);
})}
</div>
</SectionCard>
</>
);
}
+364
View File
@@ -0,0 +1,364 @@
import { useEffect, useState } from "react";
import {
AlertCircle,
Clock,
ExternalLink,
Network,
Play,
RefreshCw,
Settings,
Square,
Wifi,
WifiOff,
} from "lucide-react";
import {
useHost,
useToast,
useTranslation,
type PluginHostRecord,
} from "@termix/plugin-sdk/frontend";
import { Button, Card } from "@termix/plugin-sdk/ui";
import type { TunnelConnection, TunnelStatus } from "../shared/types";
import { serverTunnelName, tunnelHostLabel } from "../shared/tunnel-naming";
import {
cancelTunnel,
connectTunnel,
disconnectTunnel,
subscribeTunnelStatuses,
type TunnelStatusMap,
} from "./api";
import {
connectRequestFor,
hostTunnelSettings,
tunnelMode,
} from "./host-tunnels";
type StatusLabel =
"CONNECTED" | "CONNECTING" | "ERROR" | "WAITING" | "DISCONNECTED";
function statusLabel(status: TunnelStatus | undefined): StatusLabel {
const value = status?.status?.toUpperCase();
if (value === "CONNECTED") return "CONNECTED";
if (value === "CONNECTING" || value === "VERIFYING") return "CONNECTING";
if (value === "FAILED") return "ERROR";
if (value === "RETRYING" || value === "WAITING") return "WAITING";
return "DISCONNECTED";
}
const STATUS_TEXT_KEYS: Record<StatusLabel, string> = {
CONNECTED: "tunnels.connected",
CONNECTING: "tunnels.connecting",
ERROR: "tunnels.error",
WAITING: "tunnels.waiting",
DISCONNECTED: "tunnels.disconnected",
};
function TunnelCard({
host,
tunnel,
status,
isActing,
onAction,
}: {
host: PluginHostRecord;
tunnel: TunnelConnection;
status: TunnelStatus | undefined;
isActing: boolean;
onAction: (action: "connect" | "disconnect" | "cancel") => void;
}) {
const { t } = useTranslation();
const [showSettings, setShowSettings] = useState(false);
const label = statusLabel(status);
const isConnected = label === "CONNECTED";
const isConnecting = label === "CONNECTING";
const isError = label === "ERROR";
const isWaiting = label === "WAITING";
let statusColor = "text-muted-foreground border-border bg-muted/30";
if (isConnected)
statusColor = "text-accent-brand border-accent-brand/40 bg-accent-brand/10";
if (isConnecting)
statusColor = "text-blue-400 border-blue-400/40 bg-blue-400/10";
if (isError)
statusColor = "text-destructive border-destructive/40 bg-destructive/10";
if (isWaiting)
statusColor = "text-yellow-500 border-yellow-500/40 bg-yellow-500/10";
const mode = tunnelMode(tunnel);
const destination =
mode === "dynamic"
? t("tunnels.socksProxy")
: `${tunnel.endpointHost ?? ""}:${tunnel.endpointPort}`;
return (
<Card className="flex flex-col overflow-hidden p-0 gap-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-muted/10">
<div className="flex items-center gap-2">
<Network className="size-3.5 text-muted-foreground" />
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("tunnels.port")} {tunnel.sourcePort}
</span>
</div>
<div
className={`flex items-center gap-1.5 px-2 py-0.5 border text-[10px] font-bold uppercase ${statusColor}`}
>
{isConnecting || isActing ? (
<RefreshCw className="size-3 animate-spin" />
) : isConnected ? (
<Wifi className="size-3" />
) : isError ? (
<AlertCircle className="size-3" />
) : isWaiting ? (
<Clock className="size-3" />
) : (
<WifiOff className="size-3" />
)}
{isActing ? t("tunnels.working") : t(STATUS_TEXT_KEYS[label])}
</div>
</div>
<div className="px-4 py-4 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">
{t("tunnels.destination")}
</span>
<span
className="text-sm font-mono font-semibold truncate"
title={destination}
>
{destination}
</span>
<div className="flex items-center gap-1.5 mt-1">
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase">
{mode}
</span>
<span className="text-[10px] text-muted-foreground">
{t("tunnels.listensOn", { port: tunnel.sourcePort })}
</span>
</div>
</div>
{isError && status?.reason && (
<div className="flex items-start gap-2 p-2 bg-destructive/5 border border-destructive/20 text-destructive text-[10px]">
<AlertCircle className="size-3 mt-0.5 shrink-0" />
<span>{status.reason}</span>
</div>
)}
{showSettings && (
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-2 text-xs">
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.host")}
</span>
<span className="font-mono">{tunnelHostLabel(host)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.mode")}
</span>
<span className="uppercase font-bold">{mode}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.localPort")}
</span>
<span className="font-mono">{tunnel.sourcePort}</span>
</div>
{mode !== "dynamic" && (
<>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.remoteHost")}
</span>
<span className="font-mono">{tunnel.endpointHost}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.remotePort")}
</span>
<span className="font-mono">{tunnel.endpointPort}</span>
</div>
</>
)}
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.maxRetries")}
</span>
<span className="font-mono">{tunnel.maxRetries}</span>
</div>
</div>
)}
<div className="flex gap-2 mt-1">
{isConnected ? (
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive gap-1.5"
disabled={isActing}
onClick={() => onAction("disconnect")}
>
<Square className="size-3" />
{t("tunnels.stop")}
</Button>
) : isConnecting || isWaiting ? (
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-yellow-500 border-yellow-500/40 hover:bg-yellow-500/10 hover:text-yellow-500 gap-1.5"
disabled={isActing}
onClick={() => onAction("cancel")}
>
<Square className="size-3" />
{t("tunnels.cancel")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-accent-brand border-accent-brand/40 hover:bg-accent-brand/10 hover:text-accent-brand gap-1.5"
disabled={isActing}
onClick={() => onAction("connect")}
>
{isActing ? (
<RefreshCw className="size-3 animate-spin" />
) : (
<Play className="size-3" />
)}
{t("tunnels.start")}
</Button>
)}
<Button
variant={showSettings ? "secondary" : "ghost"}
size="icon"
className={`h-8 w-8 ${showSettings ? "bg-accent-brand/10 text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setShowSettings((s) => !s)}
>
<Settings className="size-3.5" />
</Button>
</div>
</div>
</Card>
);
}
/** A host's saved server tunnels, with live status and start/stop controls. */
export function TunnelTab({ host: given }: { host?: PluginHostRecord }) {
const { t } = useTranslation();
const toast = useToast();
const live = useHost(given?.id);
const host = live ?? given ?? null;
const [statuses, setStatuses] = useState<TunnelStatusMap>({});
const [acting, setActing] = useState<Record<string, boolean>>({});
useEffect(() => subscribeTunnelStatuses(setStatuses), []);
if (!host) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<div className="size-10 bg-muted/40 flex items-center justify-center">
<Network className="size-5 text-muted-foreground/30" />
</div>
<span className="text-sm font-semibold text-muted-foreground/60">
{t("tunnels.noHostSelected")}
</span>
</div>
);
}
const tunnels = hostTunnelSettings(host).connections;
const names = tunnels.map((tunnel, index) =>
serverTunnelName(host, index, tunnel),
);
const connectedCount = names.filter(
(name) => statuses[name]?.status === "connected",
).length;
const handleAction = async (
action: "connect" | "disconnect" | "cancel",
index: number,
) => {
const tunnel = tunnels[index];
if (!tunnel) return;
const name = names[index];
setActing((current) => ({ ...current, [name]: true }));
try {
if (action === "connect") {
await connectTunnel(connectRequestFor(host, index, tunnel));
toast.success(t("tunnels.clientTunnelStarted"));
} else if (action === "disconnect") {
await disconnectTunnel(name);
toast.info(t("tunnels.clientTunnelStopped"));
} else {
await cancelTunnel(name);
toast.info(t("tunnels.canceling"));
}
} catch {
toast.error(t("tunnels.manualControlError"));
} finally {
setActing((current) => ({ ...current, [name]: false }));
}
};
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex-1 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
<div className="flex items-center gap-3">
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
<Network className="size-5 text-accent-brand" />
</div>
<div>
<h1 className="text-2xl font-bold">{host.name}</h1>
<div className="flex items-center gap-2">
<span className="size-2 rounded-full bg-accent-brand" />
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("tunnels.activeCount", {
connected: connectedCount,
total: tunnels.length,
})}
</span>
</div>
</div>
</div>
<a
href="https://docs.termix.site/features/networking/tunnels"
target="_blank"
rel="noreferrer"
className="flex items-center justify-center size-9 text-muted-foreground hover:text-foreground transition-colors"
title={t("hosts.docsLink")}
>
<ExternalLink className="size-4" />
</a>
</Card>
{tunnels.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{tunnels.map((tunnel, index) => (
<TunnelCard
key={names[index]}
host={host}
tunnel={tunnel}
status={statuses[names[index]]}
isActing={acting[names[index]] ?? false}
onAction={(action) => handleAction(action, index)}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full gap-4 py-20">
<div className="opacity-10 flex flex-col items-center gap-4">
<Network className="size-16" />
<span className="text-xl font-bold uppercase tracking-widest">
{t("tunnels.noSshTunnels")}
</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-sm">
{t("tunnels.createFirstTunnelMessage")}
</p>
</div>
)}
</div>
</div>
);
}
+248
View File
@@ -0,0 +1,248 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import type {
C2STunnelPreset,
TunnelConnectRequest,
TunnelConnection,
TunnelStatus,
} from "../shared/types";
export type TunnelStatusMap = Record<string, TunnelStatus>;
let client: PluginApiClient | null = null;
/** Set from activate with app.api, cleared on deactivate. */
export function setTunnelsApi(api: PluginApiClient | null): void {
client = api;
}
function api(): PluginApiClient {
if (!client) throw new Error("The tunnels plugin is not active");
return client;
}
/**
* The server's own reason for a failed call, or the fallback. The routes
* always answer { error: string }.
*/
export function apiErrorMessage(error: unknown, fallback: string): string {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data;
if (typeof data?.error === "string" && data.error) return data.error;
return fallback;
}
// Tunnel status is a process-local, in-memory view, so reading it is always
// safe. connectTunnel/disconnectTunnel/cancelTunnel always target the local
// embedded backend: they address a host by numeric id, and a synced host has
// a different numeric id in each database (only its syncId matches), so
// routing them to a remote backend would need a local-to-remote id lookup
// that does not exist yet.
export async function getTunnelStatuses(): Promise<TunnelStatusMap> {
const response = await api().get<TunnelStatusMap>("/status");
return response.data || {};
}
/**
* Local statuses win a name collision with remote ones, since every control
* targets the local backend.
*/
export function mergeTunnelStatuses(
local: TunnelStatusMap,
remote: TunnelStatusMap,
): TunnelStatusMap {
return { ...remote, ...local };
}
export interface ServerSentEvent {
event: string;
data: string;
}
/** Splits a text/event-stream body into events. Returns what is left over. */
export function parseServerSentEvents(
buffer: string,
onEvent: (event: ServerSentEvent) => void,
): string {
const blocks = buffer.split(/\r?\n\r?\n/);
const rest = blocks.pop() ?? "";
for (const block of blocks) {
let event = "message";
const data: string[] = [];
for (const line of block.split(/\r?\n/)) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
}
if (data.length > 0) onEvent({ event, data: data.join("\n") });
}
return rest;
}
function streamUrl(): string | null {
const base = (client as unknown as { defaults?: { baseURL?: string } })
?.defaults?.baseURL;
if (base === undefined) return null;
return `${base.replace(/\/$/, "")}/plugin-api/tunnels/status/stream`;
}
export interface SubscribeOptions {
/** Replaces the default fetch, for tests. */
fetchImpl?: typeof fetch;
/** Statuses from a connected remote server, polled and merged in. */
fetchRemote?: () => Promise<TunnelStatusMap>;
pollIntervalMs?: number;
}
/**
* Live tunnel statuses over the plugin's event stream, reconnecting after a
* drop. Falls back to polling when the stream URL cannot be worked out.
*/
export function subscribeTunnelStatuses(
onStatuses: (statuses: TunnelStatusMap) => void,
onError?: () => void,
options: SubscribeOptions = {},
): () => void {
const controller = new AbortController();
const fetchImpl = options.fetchImpl ?? fetch;
const pollIntervalMs = options.pollIntervalMs ?? 5000;
let latestLocal: TunnelStatusMap = {};
let latestRemote: TunnelStatusMap = {};
const timers = new Set<ReturnType<typeof setTimeout>>();
const emit = () => onStatuses(mergeTunnelStatuses(latestLocal, latestRemote));
const wait = (ms: number) =>
new Promise<void>((resolve) => {
const timer = setTimeout(() => {
timers.delete(timer);
resolve();
}, ms);
timers.add(timer);
});
const url = streamUrl();
void (async () => {
while (!controller.signal.aborted) {
if (!url) {
try {
latestLocal = await getTunnelStatuses();
emit();
} catch {
onError?.();
}
await wait(pollIntervalMs);
continue;
}
const headers: Record<string, string> = {
Accept: "text/event-stream",
};
if (typeof window !== "undefined" && window.electronAPI?.isElectron) {
headers["X-Electron-App"] = "true";
const jwt = localStorage.getItem("jwt");
if (jwt) headers.Authorization = `Bearer ${jwt}`;
}
try {
const response = await fetchImpl(url, {
credentials: "include",
headers,
signal: controller.signal,
});
if (!response.ok || !response.body) throw new Error("stream failed");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!controller.signal.aborted) {
const { value, done } = await reader.read();
if (done) break;
buffer = parseServerSentEvents(
buffer + decoder.decode(value, { stream: true }),
(event) => {
if (event.event !== "statuses") return;
try {
latestLocal = JSON.parse(event.data) as TunnelStatusMap;
emit();
} catch {
onError?.();
}
},
);
}
if (!controller.signal.aborted) onError?.();
} catch {
if (!controller.signal.aborted) onError?.();
}
if (!controller.signal.aborted) await wait(1000);
}
})();
if (options.fetchRemote) {
const fetchRemote = options.fetchRemote;
void (async () => {
while (!controller.signal.aborted) {
try {
latestRemote = await fetchRemote();
emit();
} catch {
// A remote that is down just contributes nothing.
}
await wait(pollIntervalMs);
}
})();
}
return () => {
controller.abort();
for (const timer of timers) clearTimeout(timer);
timers.clear();
};
}
export async function connectTunnel(
request: TunnelConnectRequest,
): Promise<void> {
await api().post("/connect", request);
}
export async function disconnectTunnel(tunnelName: string): Promise<void> {
await api().post("/disconnect", { tunnelName });
}
export async function cancelTunnel(tunnelName: string): Promise<void> {
await api().post("/cancel", { tunnelName });
}
export async function getC2STunnelPresets(): Promise<C2STunnelPreset[]> {
const response = await api().get<C2STunnelPreset[]>("/presets");
return response.data || [];
}
export async function createC2STunnelPreset(data: {
name: string;
config: TunnelConnection[];
platform?: string;
computerName?: string;
}): Promise<C2STunnelPreset> {
const response = await api().post<C2STunnelPreset>("/presets", data);
return response.data;
}
export async function updateC2STunnelPreset(
id: number,
data: Partial<{
name: string;
config: TunnelConnection[];
platform: string;
computerName: string;
}>,
): Promise<C2STunnelPreset> {
const response = await api().put<C2STunnelPreset>(`/presets/${id}`, data);
return response.data;
}
export async function deleteC2STunnelPreset(id: number): Promise<void> {
await api().delete(`/presets/${id}`);
}
@@ -0,0 +1,70 @@
import type { TunnelConnection, TunnelMode } from "../shared/types";
export type ClientTunnel = TunnelConnection & {
localAddress: string;
remoteAddress: string;
bindHost: string;
sourceHostId?: number;
sourceHostSyncId?: string;
sourceHostName?: string;
displayName?: string;
lastStartedAt?: string;
lastTestedAt?: string;
lastError?: string;
};
export function getTunnelMode(tunnel: Partial<TunnelConnection>): TunnelMode {
return tunnel.mode || tunnel.tunnelType || "local";
}
export function normalizeClientTunnel(
tunnel: Partial<ClientTunnel>,
): ClientTunnel {
const mode = getTunnelMode(tunnel);
const metadata = tunnel as Partial<ClientTunnel>;
const localAddress =
tunnel.localAddress?.trim() || tunnel.bindHost?.trim() || "";
const remoteAddress =
tunnel.remoteAddress?.trim() || tunnel.targetHost?.trim() || "";
return {
...tunnel,
scope: "c2s",
mode,
tunnelType: mode === "dynamic" ? "local" : mode,
localAddress,
remoteAddress,
bindHost: localAddress,
targetHost: remoteAddress,
sourcePort: Number(tunnel.sourcePort) || 8080,
endpointPort: Number(tunnel.endpointPort) || 22,
sourceHostSyncId: metadata.sourceHostSyncId,
endpointHost: tunnel.endpointHost || tunnel.sourceHostName || "",
maxRetries: Number(tunnel.maxRetries) || 3,
retryInterval: Number(tunnel.retryInterval) || 10,
autoStart: Boolean(tunnel.autoStart),
displayName: metadata.displayName ?? "",
lastStartedAt: metadata.lastStartedAt,
lastTestedAt: metadata.lastTestedAt,
lastError: metadata.lastError,
};
}
export function stripClientTunnelDiagnostics(
tunnel: ClientTunnel,
): TunnelConnection {
const presetTunnel = normalizeClientTunnel(tunnel);
const displayName = presetTunnel.displayName?.trim();
if (displayName) {
presetTunnel.displayName = displayName;
} else {
delete presetTunnel.displayName;
}
delete presetTunnel.lastStartedAt;
delete presetTunnel.lastTestedAt;
delete presetTunnel.lastError;
return presetTunnel;
}
@@ -0,0 +1,65 @@
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
import type { TunnelConnectRequest, TunnelConnection } from "../shared/types";
import { serverTunnelName } from "../shared/tunnel-naming";
/** A host's tunnel settings, as the host payload's pluginSettings carries them. */
export function hostTunnelSettings(host: PluginHostRecord | null | undefined): {
enabled: boolean;
connections: TunnelConnection[];
} {
const settings = (
host?.pluginSettings as Record<string, Record<string, unknown>> | undefined
)?.tunnels;
return {
enabled: settings?.enableTunnel === true,
connections: parseConnections(settings?.tunnelConnections),
};
}
export function parseConnections(value: unknown): TunnelConnection[] {
let parsed = value;
if (typeof parsed === "string") {
try {
parsed = JSON.parse(parsed);
} catch {
return [];
}
}
return Array.isArray(parsed) ? (parsed as TunnelConnection[]) : [];
}
export function tunnelMode(
tunnel: Pick<TunnelConnection, "mode" | "tunnelType">,
): "local" | "remote" | "dynamic" {
return tunnel.mode ?? tunnel.tunnelType ?? "local";
}
/** What POST /connect takes for a host's saved tunnel. */
export function connectRequestFor(
host: {
id: string | number;
name?: string | null;
username?: string | null;
ip: string;
},
index: number,
tunnel: TunnelConnection,
): TunnelConnectRequest {
const mode = tunnelMode(tunnel);
return {
name: serverTunnelName(host, index, tunnel),
sourceHostId: Number(host.id),
tunnelIndex: index,
scope: tunnel.scope ?? "s2s",
mode,
tunnelType: mode === "remote" ? "remote" : "local",
bindHost: tunnel.bindHost,
targetHost: tunnel.targetHost,
endpointHost: (tunnel.endpointHost ?? "").trim(),
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort ?? 0,
maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval,
autoStart: tunnel.autoStart,
};
}
+104
View File
@@ -0,0 +1,104 @@
import type { ComponentType } from "react";
import { Network, Plug } from "lucide-react";
import type {
HomepageWidgetContribution,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { TunnelTab } from "./TunnelTab";
import { HostTunnelsSection } from "./HostTunnelsSection";
import {
PortForwardingPanel,
TunnelStandalone,
TunnelWidget,
TunnelWidgetEditForm,
} from "./views";
import { getTunnelStatuses, setTunnelsApi } from "./api";
import { hostTunnelSettings } from "./host-tunnels";
// Matches the homepage canvas grid.
const GRID_SIZE = 30;
function TunnelTabView({ host }: TabProps) {
return <TunnelTab host={host} />;
}
export function activate(app: TermixApp): void {
setTunnelsApi(app.api);
app.onDispose(() => setTunnelsApi(null));
// Keeps the "tunnel" tab type and ?view=tunnel links from before the
// conversion, so saved layouts and recent activity still open here.
app.registerTab("tunnel", TunnelTabView as ComponentType<TabProps>, {
icon: Network,
titleKey: "nav.tunnels",
persistent: true,
hostless: true,
standalone: TunnelStandalone,
activityTypes: ["tunnel"],
});
app.registerHostAction({
id: "tunnel",
titleKey: "nav.tunnels",
icon: Network,
kind: "open",
order: 40,
tabType: "tunnel",
copyUrlView: "tunnel",
when: (host) =>
host.enableSsh !== false && hostTunnelSettings(host).enabled,
});
app.registerHostEditorSection({
id: "tunnels",
group: "ssh",
titleKey: "hosts.tabTunnels",
icon: Network,
order: 20,
component: HostTunnelsSection,
});
app.registerPanel("port-forwarding", PortForwardingPanel);
app.registerRailItem({
id: "port-forwarding",
icon: Network,
titleKey: "nav.portForwarding",
kind: "panel",
electronOnly: true,
separatorAfter: true,
after: "credentials",
permission: "use",
});
app.registerSlotContribution("onboarding.features", {
actionId: "tunnels.feature",
titleKey: "onboarding.feature_tunnels",
descriptionKey: "onboarding.feature_tunnels_desc",
icon: Plug as ComponentType<{ className?: string }>,
});
app.registerHomepageWidget({
id: "tunnel_widget",
name: "Tunnel Manager",
description: "Embedded SSH tunnel manager for a configured host",
category: "system",
icon: <Network size={14} />,
defaultConfig: { hostId: 0 },
defaultSize: { w: GRID_SIZE * 16, h: GRID_SIZE * 10 },
minSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 },
component: TunnelWidget,
editFormComponent: TunnelWidgetEditForm,
} as HomepageWidgetContribution);
// The dashboard's active tunnel counter reads and opens these, and gets
// undefined back while the plugin is off.
app.registerAction("tunnels.statuses", (() => getTunnelStatuses()) as never, {
permission: "use",
});
app.registerAction(
"tunnels.open",
(() => app.tabs.openSingletonTab("tunnel")) as never,
{ permission: "use" },
);
}
@@ -0,0 +1,64 @@
import type { TunnelMode } from "../shared/types";
type Translate = (
key: string,
options?: Record<string, string | number>,
) => string;
export function getTunnelTypeForMode(mode: TunnelMode): "local" | "remote" {
return mode === "remote" ? "remote" : "local";
}
export function getTunnelPortLabels(
scope: "client" | "server",
mode: TunnelMode,
t: Translate,
) {
if (scope === "client") {
return {
sourcePortLabel:
mode === "remote" ? t("tunnels.remotePort") : t("tunnels.localPort"),
endpointPortLabel:
mode === "remote"
? t("tunnels.localPort")
: t("tunnels.destinationPort"),
};
}
return {
sourcePortLabel: t("tunnels.currentHostPort"),
endpointPortLabel: t("tunnels.endpointPort"),
};
}
export function getTunnelModeDescription(
scope: "client" | "server",
mode: TunnelMode,
ports: {
sourcePort: string | number;
endpointPort: string | number;
},
t: Translate,
) {
if (scope === "client") {
if (mode === "dynamic") {
return t("tunnels.forwardDescriptionClientDynamic", {
sourcePort: ports.sourcePort,
});
}
if (mode === "local") {
return t("tunnels.forwardDescriptionClientLocal", ports);
}
return t("tunnels.forwardDescriptionClientRemote", ports);
}
if (mode === "dynamic") {
return t("tunnels.forwardDescriptionServerDynamic", {
sourcePort: ports.sourcePort,
});
}
if (mode === "local") {
return t("tunnels.forwardDescriptionServerLocal", ports);
}
return t("tunnels.forwardDescriptionServerRemote", ports);
}
+151
View File
@@ -0,0 +1,151 @@
import { Network } from "lucide-react";
import {
useHosts,
useTranslation,
type PluginHostRecord,
type StandaloneViewProps,
} from "@termix/plugin-sdk/frontend";
import {
ConnectionScreen,
FullScreenAppWrapper,
Select2,
} from "@termix/plugin-sdk/ui";
import { TunnelTab } from "./TunnelTab";
import { C2STunnelPresetManager } from "./C2STunnelPresetManager";
import { hostTunnelSettings } from "./host-tunnels";
/** The `?view=tunnel` full-screen page for one host. */
export function TunnelStandalone({ hostId }: StandaloneViewProps) {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
const host = {
...hostConfig,
id: String(hostConfig.id),
} as unknown as PluginHostRecord;
return <TunnelTab host={host} />;
}}
</FullScreenAppWrapper>
);
}
/** The rail panel for the desktop app's client tunnels. */
export function PortForwardingPanel() {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-2">
<C2STunnelPresetManager />
</div>
);
}
export interface TunnelWidgetConfig {
hostId: number;
}
/** Homepage widget: the tunnel tab for one host. */
export function TunnelWidget({
widget,
config,
}: {
widget: { title?: string | null };
config: TunnelWidgetConfig;
}) {
const { t } = useTranslation();
const { hosts, loaded } = useHosts();
const placeholder = (text: string) => (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-xs text-muted-foreground/60">
<Network size={20} />
<span>{text}</span>
</div>
);
if (!config.hostId) return placeholder(t("homepage.widgetNoHostSelected"));
if (!loaded) return placeholder(t("homepage.loading"));
const host = hosts.find(
(candidate) =>
String(candidate.id) === String(config.hostId) &&
hostTunnelSettings(candidate).enabled,
);
if (!host) return placeholder(t("homepage.widgetNoHostSelected"));
return (
<div className="flex flex-col w-full h-full overflow-hidden">
{widget.title && (
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 shrink-0">
<span className="text-accent-brand shrink-0">
<Network size={11} />
</span>
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
{widget.title}
</span>
</div>
)}
<div
className="flex-1 overflow-hidden"
onMouseDown={(e) => e.stopPropagation()}
>
<TunnelTab host={host} />
</div>
</div>
);
}
/** The widget's edit form: pick a host with tunnels on. */
export function TunnelWidgetEditForm({
config,
onChange,
}: {
config: TunnelWidgetConfig;
onChange: (config: TunnelWidgetConfig) => void;
}) {
const { t } = useTranslation();
const { hosts } = useHosts();
const options = hosts.filter(
(host) => host.enableSsh !== false && hostTunnelSettings(host).enabled,
);
return (
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.host")}
</label>
<Select2
value={config.hostId || ""}
onChange={(e) =>
onChange({ ...config, hostId: Number(e.target.value) })
}
className="h-8 text-xs border border-border bg-background px-2"
>
<option value="">{t("homepage.selectHost")}</option>
{options.map((host) => (
<option key={host.id} value={host.id}>
{host.name || host.ip}
</option>
))}
</Select2>
</div>
);
}