When you’re working directly with a server, web apps are useful. A few examples: Jupyter lets you run Python in a visual environment, Tensorboard lets you observe deep learning training runs, and tools like phpMyAdmin help you manage websites. The problem is, these apps typically aren’t directly accessible to web browsers, because exposing them to the web puts your server at risk.

The standard solution to this is SSH port forwarding. You create an SSH connection to the server, and then you tunnel arbitrary TCP connections over that encrypted SSH connection. This works, but it introduces friction and is often janky. Many entire “software-as-a-service” companies are devoted to providing smoother alternatives. For example, some companies give you a convenient way to log data to their servers, and they provide a safe web interface so that you never need to learn how to forward a port.

In this blog post I share two key improvements to SSH port forwarding that make it much more fun and reliable:

  1. Solve scenario: “I closed my laptop, then reopened it, and the connection stopped working”
  2. Incorporate SSH directly into the browser

To demonstrate this, I’m launching Outer Loop, a specialized web browser for Mac.

Change 1: Let me close my laptop

In terms of the information flow, port forwarding and SSH don’t really have the same shape. If we aren’t intentional, we will get a “round-peg-in-square-hole” situation.

When you use SSH more generally, the SSH session is stateful. It has a current working directory, it has running processes, it has environment variables. If you abandon an SSH session, you lose all of this.

When you use SSH for port forwarding, you aren’t relying on any of that. For you, an SSH session can just be a short-lived thing that may even get created and discarded multiple times while you interact with your web app.

So a first-class port forwarding library should treat SSH sessions as transient.

A figure showing a transient connection between your device's 'Port Forwarder' and the remote 'SSH Server'

When you perform port forwarding, there doesn’t always need to be a live connection with the server. Rather, the only thing constant is that you have a process that listens to a local port on your system. This process (I’ll call it the “port forwarder”) is responsible for creating an SSH session with the remote server, but that session can be created opportunistically. The port forwarder may wait and create it after a browser connects to the port. Then, after the browser navigation, the port forwarder might disconnect the session immediately, or it might keep it around. The mindset shift here is: reusing an SSH session is not essential, it’s just a performance heuristic.

Of course, it is more efficient to reuse sessions when possible. In fact, it’s worth going further than that: there is a big performance win if we pre-allocate a set of SSH channels, each representing a speculatively-created fresh TCP connection between the remote SSH server and the remote web app backend. This lets us quickly forward HTTP requests when the browser sends them. (Remember: when a page loads, there is one HTTP request per page, image, and script.)

The same figure as above, now showing multiple TCP connections within the server, and a corresponding channel pool

But these performance heuristics make fault tolerance more complicated. When there is some kind of network fault (e.g. “I close my laptop for 10 minutes”), we need to avoid sending browser HTTP requests into a channel of a closed session. Keeping sessions around, and in particular, keeping a pool of speculative channels, increases the chance that this will happen. If we send a request into a failed channel, we will quickly learn that the session has failed, but we will not know with 100% certainty that the HTTP request didn’t reach the web app backend. Perhaps the request reached the backend, and the fault occurred immediately afterward. So we can’t safely just re-send the request over a new session, because we risk doing a double-send, which in rare cases may be destructive. Thus, after sending an HTTP request into a closed session, we are forced to surface the broken connection to the user’s browser.

Fortunately, it is quite easy to solve this with simple pings, sending SSH_MSG_GLOBAL_REQUEST packets to the SSH server. The port forwarder can use a simple rule like, "If N minutes have passed since the last traffic, perform a ping and wait for the reply before using this session again." On my home internet connection to a GCP server, this ping introduces about 70 milliseconds of latency. If the session is found to be invalid, the server is still fast to respond, and we quickly set up a new SSH session and forward the TCP connection over that new session instead.

Independent of SSH, apps with long-running TCP connections will still have their connections broken by long laptop closes. Long-running TCP connections are inherently fragile, and any app that uses them is responsible for building in their own fault tolerance. The point here is to prevent the port-forwarding layer from introducing new faults or hangs, particularly in web app scenarios (which typically involve many very short TCP connections, a.k.a. HTTP requests).

Change 2: Create the obvious intuitive product

Today, using web apps over SSH requires you to open two windows. You have to open a new local terminal window and copy-paste a command like:
     ssh -L 24601:localhost:8889 mrcslws@lambda4.mycompany.com

Then you go to your browser window, and type "localhost:24601" in the address bar.

What if, instead, the browser itself is SSH-aware?

A browser window with server info in the upper-left corner. It's showing two tabs: Tensorboard and a Start Page. The Start Page shows a set of web apps running on the server. The image also shows a context menu, revealing that the app is performing port forwarding for these web apps.

This window connects to the server over SSH. Using this window, you can easily navigate to any private web apps via that server. You never have to think about the fact that you’re connecting to your own localhost. In this app, the term “localhost” actually refers to the server’s localhost, which means you can copy-paste URLs from an SSH terminal, for example when Jupyter or Tensorboard prints a launch URL to the terminal. You no longer have to choose your own arbitrary local port numbers; the app remembers the web apps you’ve connected to previously, and it carefully uses the same local port for that app over time, so the web app’s browser cookies are preserved. Port forwarding runs in multiple sandboxed processes, one for each server login. And you are free to use your own preferred web browser, using this app as a standalone port forwarder – just keep the app window open (or minimized).

Feel free to try it yourself.

Surprises

Going into this project, I assumed that connecting over SSH would sacrifice some performance, compared to connecting directly (and insecurely) via the web. I was surprised to see that, in practice, SSH is often faster. There are a couple of interesting discussion points here. First, with SSH, you typically already have an underlying TCP connection to the server, rather than having to create fresh ones, so that gives you a head start. Second, when we connect over SSH, we aren’t simply adding an extra SSH “layer of indirection”. We are replacing HTTPS (or rather, SSL) with SSH. We’re swapping out our authentication / encryption protocol; it’s a replacement, not an addition. If you’re doing big downloads or uploads, going over SSH will likely be slower, but I was surprised to see that in many scenarios it is actually faster.

There is a lot of low-hanging fruit in this space. We can make the experience of “working with a server” a lot more fun and accessible.

(Thanks to Rosanne Liu and Charlie Liu Lewis for reading and listening to drafts of this post.)