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; 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; let tunnelCmd: string;
if ( if (
resolvedEndpointCredentials.authMethod === "key" && resolvedEndpointCredentials.authMethod === "key" &&
resolvedEndpointCredentials.sshKey resolvedEndpointCredentials.sshKey
) { ) {
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`; 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 { } 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) => { conn.exec(tunnelCmd, (err, stream) => {
@@ -1302,7 +1309,9 @@ async function killRemoteTunnelByMarker(
} }
conn.on("ready", () => { 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) => { conn.exec(checkCmd, (_err, stream) => {
let foundProcesses = false; let foundProcesses = false;
@@ -1323,8 +1332,8 @@ async function killRemoteTunnelByMarker(
const killCmds = [ const killCmds = [
`pkill -TERM -f '${tunnelMarker}'`, `pkill -TERM -f '${tunnelMarker}'`,
`sleep 1 && pkill -f 'ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`, `sleep 1 && pkill -f 'ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
`sleep 1 && pkill -f 'sshpass.*ssh.*-R.*${tunnelConfig.endpointPort}'`, `sleep 1 && pkill -f 'sshpass.*ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}'`,
`sleep 2 && pkill -9 -f '${tunnelMarker}'`, `sleep 2 && pkill -9 -f '${tunnelMarker}'`,
]; ];
@@ -1929,6 +1938,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
tunnelConnection.endpointHost, tunnelConnection.endpointHost,
tunnelConnection.endpointPort, tunnelConnection.endpointPort,
), ),
tunnelType: tunnelConnection.tunnelType || "remote",
sourceHostId: host.id, sourceHostId: host.id,
tunnelIndex: tunnelIndex, tunnelIndex: tunnelIndex,
hostName: host.name || `${host.username}@${host.ip}`, hostName: host.name || `${host.username}@${host.ip}`,

View File

@@ -891,6 +891,13 @@
"autoStartContainer": "Auto Start on Container Launch", "autoStartContainer": "Auto Start on Container Launch",
"autoStartDesc": "Automatically start this tunnel when the container launches", "autoStartDesc": "Automatically start this tunnel when the container launches",
"addConnection": "Add Tunnel Connection", "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", "sshpassRequired": "Sshpass Required For Password Authentication",
"sshpassRequiredDesc": "For password authentication in tunnels, sshpass must be installed on the system.", "sshpassRequiredDesc": "For password authentication in tunnels, sshpass must be installed on the system.",
"otherInstallMethods": "Other installation methods:", "otherInstallMethods": "Other installation methods:",

View File

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

View File

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

View File

@@ -142,6 +142,57 @@ export function HostTunnelTab({
{t("hosts.remove")} {t("hosts.remove")}
</Button> </Button>
</div> </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"> <div className="grid grid-cols-12 gap-4">
<FormField <FormField
control={form.control} control={form.control}
@@ -254,7 +305,20 @@ export function HostTunnelTab({
</div> </div>
<p className="text-sm text-muted-foreground mt-2"> <p className="text-sm text-muted-foreground mt-2">
{t("hosts.tunnelForwardDescription", { {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: sourcePort:
form.watch( form.watch(
`tunnelConnections.${index}.sourcePort`, `tunnelConnections.${index}.sourcePort`,
@@ -337,6 +401,7 @@ export function HostTunnelTab({
field.onChange([ field.onChange([
...field.value, ...field.value,
{ {
tunnelType: "remote",
sourcePort: 22, sourcePort: 22,
endpointPort: 224, endpointPort: 224,
endpointHost: "", endpointHost: "",