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.createServerto listen on the specified TCP port - Dynamic forwards: Uses
socksv5.createServerto 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 tosocket.write - Local to remote:
socket.on('data')pipes tochannel.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:
tabby-ssh/src/session/ssh.ts: Core session implementation containingaddPortForward,removePortForward, andsetupSocketChannelEventsthat bind the tunnel streamstabby-ssh/src/session/forwards.ts: Definition of theForwardedPortclass and itsstartLocalListener/stopLocalListenermethods for managing local and dynamic forward serverstabby-ssh/src/components/sshPortForwardingModal.component.ts: UI bridge that receives user-defined forward configs and passes them to the sessiontabby-ssh/src/components/sshPortForwardingConfig.component.ts: Angular component for editingForwardedPortConfigobjectstabby-ssh/src/api/interfaces.ts: TypeScript declarations forForwardedPortConfigand thePortForwardTypeenum
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 usingopenTCPForwardChannelfor 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 withstopLocalListeneror unregisters remote forwards withstopForwardingTCPPort.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →