feat: added -r and -l support for tunnels

This commit is contained in:
LukeGus
2026-01-15 02:23:13 -06:00
parent 8eeef84b8a
commit 004ddcb2bb
5 changed files with 112 additions and 16 deletions

View File

@@ -828,15 +828,22 @@ async function connectSSHTunnel(
return;
}
const tunnelType = tunnelConfig.tunnelType || "remote";
const tunnelFlag = tunnelType === "local" ? "-L" : "-R";
const portMapping =
tunnelType === "local"
? `${tunnelConfig.sourcePort}:${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`
: `${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}`;
let tunnelCmd: string;
if (
resolvedEndpointCredentials.authMethod === "key" &&
resolvedEndpointCredentials.sshKey
) {
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && exec -a "${tunnelMarker}" ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} && rm -f ${keyFilePath}`;
tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && exec -a "${tunnelMarker}" ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes ${tunnelFlag} ${portMapping} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} && rm -f ${keyFilePath}`;
} else {
tunnelCmd = `exec -a "${tunnelMarker}" sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
tunnelCmd = `exec -a "${tunnelMarker}" sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes ${tunnelFlag} ${portMapping} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
}
conn.exec(tunnelCmd, (err, stream) => {
@@ -1302,7 +1309,9 @@ async function killRemoteTunnelByMarker(
}
conn.on("ready", () => {
const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*-R.*${tunnelConfig.endpointPort})' | grep -v grep`;
const tunnelType = tunnelConfig.tunnelType || "remote";
const tunnelFlag = tunnelType === "local" ? "-L" : "-R";
const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*${tunnelFlag})' | grep -v grep`;
conn.exec(checkCmd, (_err, stream) => {
let foundProcesses = false;
@@ -1323,8 +1332,8 @@ async function killRemoteTunnelByMarker(
const killCmds = [
`pkill -TERM -f '${tunnelMarker}'`,
`sleep 1 && pkill -f 'ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
`sleep 1 && pkill -f 'sshpass.*ssh.*-R.*${tunnelConfig.endpointPort}'`,
`sleep 1 && pkill -f 'ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
`sleep 1 && pkill -f 'sshpass.*ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}'`,
`sleep 2 && pkill -9 -f '${tunnelMarker}'`,
];
@@ -1929,6 +1938,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
tunnelConnection.endpointHost,
tunnelConnection.endpointPort,
),
tunnelType: tunnelConnection.tunnelType || "remote",
sourceHostId: host.id,
tunnelIndex: tunnelIndex,
hostName: host.name || `${host.username}@${host.ip}`,

View File

@@ -891,6 +891,13 @@
"autoStartContainer": "Auto Start on Container Launch",
"autoStartDesc": "Automatically start this tunnel when the container launches",
"addConnection": "Add Tunnel Connection",
"tunnelType": "Tunnel Type",
"tunnelTypeLocal": "Local (-L)",
"tunnelTypeRemote": "Remote (-R)",
"tunnelTypeLocalDesc": "Forward local port to remote endpoint",
"tunnelTypeRemoteDesc": "Forward remote port to local machine",
"tunnelForwardDescriptionLocal": "This tunnel will forward traffic from local port {{sourcePort}} to port {{endpointPort}} on the endpoint machine.",
"tunnelForwardDescriptionRemote": "This tunnel will forward traffic from port {{sourcePort}} on the source machine (current connection details in general tab) to port {{endpointPort}} on the endpoint machine.",
"sshpassRequired": "Sshpass Required For Password Authentication",
"sshpassRequiredDesc": "For password authentication in tunnels, sshpass must be installed on the system.",
"otherInstallMethods": "Other installation methods:",

View File

@@ -203,6 +203,7 @@ export interface CredentialData {
// ============================================================================
export interface TunnelConnection {
tunnelType?: "local" | "remote";
sourcePort: number;
endpointPort: number;
endpointHost: string;
@@ -220,6 +221,7 @@ export interface TunnelConnection {
export interface TunnelConfig {
name: string;
tunnelType?: "local" | "remote";
sourceHostId: number;
tunnelIndex: number;

View File

@@ -293,9 +293,18 @@ export function HostManagerEditor({
tunnelConnections: z
.array(
z.object({
tunnelType: z
.enum(["local", "remote"])
.default("remote")
.optional(),
sourcePort: z.coerce.number().min(1).max(65535),
endpointPort: z.coerce.number().min(1).max(65535),
endpointHost: z.string().min(1),
endpointPassword: z.string().optional(),
endpointKey: z.string().optional(),
endpointKeyPassword: z.string().optional(),
endpointAuthType: z.string().optional(),
endpointKeyType: z.string().optional(),
maxRetries: z.coerce.number().min(0).max(100).default(3),
retryInterval: z.coerce.number().min(1).max(3600).default(10),
autoStart: z.boolean().default(false),
@@ -667,7 +676,10 @@ export function HostManagerEditor({
enableFileManager: Boolean(cleanedHost.enableFileManager),
defaultPath: cleanedHost.defaultPath || "/",
tunnelConnections: Array.isArray(cleanedHost.tunnelConnections)
? cleanedHost.tunnelConnections
? cleanedHost.tunnelConnections.map((conn: any) => ({
...conn,
tunnelType: conn.tunnelType || "remote",
}))
: [],
jumpHosts: Array.isArray(cleanedHost.jumpHosts)
? cleanedHost.jumpHosts

View File

@@ -142,6 +142,57 @@ export function HostTunnelTab({
{t("hosts.remove")}
</Button>
</div>
<div className="mb-4">
<FormField
control={form.control}
name={`tunnelConnections.${index}.tunnelType`}
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.tunnelType")}</FormLabel>
<FormControl>
<div className="flex gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
value="local"
checked={field.value === "local"}
onChange={() => field.onChange("local")}
className="w-4 h-4 text-primary border-input focus:ring-ring"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">
{t("hosts.tunnelTypeLocal")}
</span>
<span className="text-xs text-muted-foreground">
{t("hosts.tunnelTypeLocalDesc")}
</span>
</div>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
value="remote"
checked={field.value === "remote"}
onChange={() =>
field.onChange("remote")
}
className="w-4 h-4 text-primary border-input focus:ring-ring"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">
{t("hosts.tunnelTypeRemote")}
</span>
<span className="text-xs text-muted-foreground">
{t("hosts.tunnelTypeRemoteDesc")}
</span>
</div>
</label>
</div>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-12 gap-4">
<FormField
control={form.control}
@@ -254,16 +305,29 @@ export function HostTunnelTab({
</div>
<p className="text-sm text-muted-foreground mt-2">
{t("hosts.tunnelForwardDescription", {
sourcePort:
form.watch(
`tunnelConnections.${index}.sourcePort`,
) || "22",
endpointPort:
form.watch(
`tunnelConnections.${index}.endpointPort`,
) || "224",
})}
{form.watch(
`tunnelConnections.${index}.tunnelType`,
) === "local"
? t("hosts.tunnelForwardDescriptionLocal", {
sourcePort:
form.watch(
`tunnelConnections.${index}.sourcePort`,
) || "22",
endpointPort:
form.watch(
`tunnelConnections.${index}.endpointPort`,
) || "224",
})
: t("hosts.tunnelForwardDescriptionRemote", {
sourcePort:
form.watch(
`tunnelConnections.${index}.sourcePort`,
) || "22",
endpointPort:
form.watch(
`tunnelConnections.${index}.endpointPort`,
) || "224",
})}
</p>
<div className="grid grid-cols-12 gap-4 mt-4">
@@ -337,6 +401,7 @@ export function HostTunnelTab({
field.onChange([
...field.value,
{
tunnelType: "remote",
sourcePort: 22,
endpointPort: 224,
endpointHost: "",