SSH Port Forwarding in Tabby: Data Flow and Implementation Guide

Tabby implements SSH port forwarding through a three-layer architecture where UI configuration creates ForwardedPort instances that establish TCP listeners or SSH channels, wiring local sockets to remote servers via setupSocketChannelEvents in tabby-ssh/src/session/ssh.ts.

SSH port forwarding in Tabby enables secure tunneling of network traffic through established SSH connections. The Eugeny/tabby repository implements this feature in the tabby-ssh package, routing data from local TCP sockets through encrypted SSH channels using the russh library. Understanding this data flow reveals how local, remote, and dynamic forwards handle bi-directional byte streams between client applications and remote hosts.

Architecture Overview

Tabby organizes SSH port forwarding into three distinct logical layers, each handling a specific phase of the tunnel lifecycle.

The UI and configuration layer manages user input through Angular components. When you create a forwarding rule in the Port-forwarding dialog, [SSHPortForwardingModal.component.ts](https://github.com/Eugeny/tabby/blob/master/tabby-ssh/src/components/sshPortForwardingModal.component.ts) constructs a ForwardedPortConfig object and passes it to the active SSH session.

The session handling layer processes these configurations in SSHSession. The addPortForward method receives the config, instantiates a concrete ForwardedPort object, and determines whether to start a local listener or request a remote forward from the server.

The data tunnel layer manages the actual byte streams. For local and dynamic forwards, ForwardedPort.startLocalListener creates a TCP or SOCKSv5 server. Each incoming connection triggers openTCPForwardChannel to open a russh channel, while setupSocketChannelEvents wires the socket and channel streams together for full-duplex communication.

Local and Dynamic Port Forwarding Data Flow

Local and dynamic port forwards follow an identical data path through the Tabby stack, differing only in the type of local server created.

Step 1: Configuration Capture

When a user defines a forward in the UI modal, the component emits a forwardAdded event carrying a ForwardedPortConfig object. This configuration specifies the forward type (local or dynamic), local binding address, local port, and target destination.

Step 2: Session Registration

The modal component converts the configuration into a ForwardedPort instance using Object.assign(new ForwardedPort(), config) and calls session.addPortForward(fw). According to the source in tabby-ssh/src/session/ssh.ts, this method detects PortForwardType.Local or PortForwardType.Dynamic and invokes fw.startLocalListener() to begin listening.

Step 3: Local Server Initialization

In [tabby-ssh/src/session/forwards.ts](https://github.com/Eugeny/tabby/blob/master/tabby-ssh/src/session/forwards.ts), startLocalListener creates the appropriate server:

  • Local forwards: Uses Node.js net.createServer to listen on the specified TCP port
  • Dynamic forwards: Uses socksv5.createServer to establish a SOCKSv5 proxy

Step 4: Connection Handling

When a local client connects to the listening port, the server callback receives accept, reject, source address/port, and target address/port parameters. The session immediately opens a TCP forward channel through the SSH connection:

const channel = await this.ssh.activateChannel(
    await this.ssh.openTCPForwardChannel({
        addressToConnectTo: targetAddress,
        portToConnectTo: targetPort,
        originatorAddress: sourceAddress ?? '127.0.0.1',
        originatorPort: sourcePort ?? 0,
    })
)

The openTCPForwardChannel method sends a direct-tcpip request to the remote SSH server, establishing the tunnel endpoint.

Step 5: Stream Wiring

The setupSocketChannelEvents function (lines 53-99 in ssh.ts) creates the bi-directional data pipe:

  • Remote to local: channel.data$ pipes to socket.write
  • Local to remote: socket.on('data') pipes to channel.write
  • Lifecycle management: EOF, close, and error handlers on both streams ensure clean teardown when either side disconnects

This wiring creates a transparent byte tunnel where application data flows through the local socket, across the encrypted SSH channel, and emerges at the target address on the remote network.

Step 6: Cleanup

When the user removes a forward rule, SSHSession.removePortForward calls fw.stopLocalListener() to close the TCP server and removes the entry from the session's forwardedPorts registry.

Remote Port Forwarding Data Flow

Remote port forwarding reverses the connection direction, allowing the remote SSH server to accept connections and tunnel them back to the local machine.

When addPortForward detects PortForwardType.Remote, it invokes ssh.forwardTCPPort(host, port) instead of starting a local listener. This registers the forward with the remote SSH server, which then listens on the specified host and port.

The actual data tunnel creation happens automatically when the remote server receives a connection. The server forwards the connection to the Tabby client as a standard SSH channel, which the client handles using the same setupSocketChannelEvents logic to wire the channel to a local socket connection.

Implementation Examples

Adding a Local Forward Programmatically

You can create port forwards directly through the TypeScript API without using the UI:

import { SSHSession } from 'tabby-ssh/src/session/ssh';
import { ForwardedPort } from 'tabby-ssh/src/session/forwards';
import { PortForwardType } from 'tabby-ssh/src/api';

// Assume `session` is an authenticated SSHSession instance
const fw = new ForwardedPort();
fw.type = PortForwardType.Local;
fw.host = '127.0.0.1';
fw.port = 8080;                 // Local listening port
fw.targetAddress = '10.0.0.5'; // Destination host on remote network
fw.targetPort = 80;              // Destination port

await session.addPortForward(fw);

This triggers the full flow: startLocalListener creates the net.Server, and each client connection opens a direct-tcpip channel through setupSocketChannelEvents.

Removing a Forward

To terminate a forward and close all active tunnels:

await session.removePortForward(fw);   // Cleans up local listeners or remote registrations

UI Component Integration

The Angular template for configuring forwards in profile settings:

ssh-port-forwarding-config(
  [(model)]="sessionProfile.forwardedPorts"
  (forwardAdded)="onForwardAdded($event)"
  (forwardRemoved)="onForwardRemoved($event)"
)

The component emits configuration objects that SSHPortForwardingModal.component.ts transforms into ForwardedPort instances for the session.

Key Source Files

Understanding the complete data flow requires examining these specific files in the Eugeny/tabby repository:

Summary

  • Tabby implements SSH port forwarding across three layers: UI configuration (SSHPortForwardingModal.component.ts), session management (SSHSession.addPortForward), and data tunneling (setupSocketChannelEvents).
  • Local and dynamic forwards create TCP or SOCKSv5 servers via ForwardedPort.startLocalListener, then open russh channels using openTCPForwardChannel for each incoming connection.
  • Remote forwards register with the SSH server via forwardTCPPort, with the server initiating tunnel connections back to the client.
  • Bi-directional data flow occurs through setupSocketChannelEvents, which wires socket streams to SSH channel streams for full-duplex communication.
  • Cleanup happens through removePortForward, which stops local listeners with stopLocalListener or unregisters remote forwards with stopForwardingTCPPort.

Frequently Asked Questions

How does Tabby handle bi-directional data flow in SSH tunnels?

Tabby uses the setupSocketChannelEvents function in tabby-ssh/src/session/ssh.ts to create a full-duplex pipe. It attaches channel.data$ to socket.write for remote-to-local traffic, and socket.on('data') to channel.write for local-to-remote traffic. Error and close handlers on both sides ensure the tunnel closes cleanly if either the local socket or SSH channel terminates.

What is the difference between local and remote port forwarding implementation in Tabby?

Local port forwarding uses ForwardedPort.startLocalListener to create a local TCP or SOCKSv5 server, then opens a direct-tcpip channel to the remote server for each connection. Remote port forwarding calls ssh.forwardTCPPort to register the forward with the remote SSH server, which then listens on the target port and initiates connections back to the Tabby client when traffic arrives.

How does dynamic port forwarding (SOCKS) differ from local forwarding in the codebase?

Dynamic forwards use the same code path as local forwards through addPortForward, but startLocalListener creates a socksv5.createServer instead of a plain net.createServer. The SOCKS server handles the initial protocol negotiation, then follows the same channel opening and stream wiring logic as standard local forwards.

Which russh library methods does Tabby use to establish SSH channels for port forwarding?

Tabby uses ssh.openTCPForwardChannel to send direct-tcpip requests for local/dynamic forwards, and ssh.forwardTCPPort to request remote port forwarding. Both methods return channel objects that setupSocketChannelEvents binds to local sockets using the activateChannel helper for stream management.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →