Thought About Tunneled Web Apps

Thought About Tunneled Web Apps
Photo by Paul Pastourmatzis / Unsplash

A common challenge when self-hosting apps at home is accessing them remotely - that is, when you’re not on your home network. While a VPN is often the primary solution (and I agree it works well most of the time), I frequently encounter situations where connecting to my home VPN isn't possible. This is because my home router sits behind Dual Stack Lite (Carrier Grade NAT).

Carrier Grade Nope

This means my home network has a public IPv6 address, or even a range of addresses, but shares a single IPv4 address with many other customers. Consequently, if I’m connected to an IPv4-only network - still common in places like hotel Wi-Fi - establishing a VPN connection becomes difficult.

To be fair, I understand the reasoning behind this practice; IPv4 addresses are scarce and therefore expensive. The vast majority of internet users don’t require externally accessible IPv4 addresses. However, those who do need remote access (like myself) often prefer to avoid the cost of a business-class connection.

A straightforward solution is to set up an inexpensive VPS with both IPv4 and IPv6 connectivity and connect it to your home network via a persistent VPN tunnel. An Nginx reverse proxy can then forward traffic through this VPN to your local apps. However, this approach introduces a security risk: if someone gains access to my public server, they could potentially gain unauthorized access to my home network as well.

UNO Reverse Card

For fun, I started exploring an alternative solution leveraging the application layer. The core concept involves using WebSockets to create a bi-directional communication channel.

Here’s how it would work:

  • The public server hosts the static website assets and also functions as a WebSocket server.
  • The local app establishes a persistent connection to this public WebSocket server.
  • Clients connect directly to the same public WebSocket server.
  • When a client sends a message, the public server forwards it to the designated local app.
  • Conversely, when the local app responds, the public server relays that message back to the originating client.
  • The public server notifies the local app about changes of connected / disconnected clients

A key feature I want to integrate is a “local bypass” mechanism. If a client can directly connect to the local app (for example, because it’s on the same Wi-Fi network), the WebSocket connection should skip the public server entirely. This optimizes performance and reduces latency when a direct connection is possible.

The protocol

1. Client Behavior

Clients are the applications (e.g., web browsers) that users interact with. Their connection process is as follows:

  • Ask The Public Server: Initially, all clients do a HTTP request to the public server to requests the current address of the local app.
  • Conditional Connection:
    • Direct Connection Possible: If a direct connection to the local app is feasible (e.g., on the same network), the client establishes a WebSocket connection directly to the local app, operating as a “direct client.”
    • Indirect Connection Required: Otherwise, the client connects to the public server and communicates through it.

2. Public Server Behavior

The public server acts as a central relay point and manages connections:

  • Client Connection Handling: When a new client connects:
    • Local App Identification: If the connection is from the local app (authenticated via a shared secret), the server designates it as an “exit node.”
    • Standard Client Registration: For all other clients, the server assigns a unique ID and notifies the local app about the new client.
  • Message Forwarding:
    • Client-to-Local App: When a client sends a message, the public server forwards it to the local app, including the client’s assigned ID.
    • Local App-to-Client: When the local app responds, the server relays that message back to the corresponding client using its unique ID.

3. Local App Behavior

The local app manages both direct and proxied connections:

  • Direct Client Handling: When a “direct client” connects directly, the app adds it to its internal list of connected clients.
  • Proxied Client Updates: When receiving a client update from the public server, the app adds the new client to its list as a “proxied client.”
  • Message Processing:
    • Public Server Socket Messages: When receiving a message through the WebSocket connection with the public server, the app finds the corresponding client by ID and handles the message accordingly.
    • Direct Client Socket Messages: When receiving a message directly from a connected client, the app processes it immediately.
  • Message Transmission:
    • Direct Clients: When sending a message to a direct client, the app emits the message directly through its established WebSocket connection.
    • Proxied Clients: When sending a message to a proxied client, the app forwards the message to the public server along with the client’s ID.

Why doesn't this already exist?

Well it does, there are lots of existing solutions:

  • ngrok
  • rathole, a good example is explained here
  • You could also set up a SSH reverse tunnel like explained here.

And the second reason: My idea is very janky, complicated and only suited as a fun little learning project:

  • WebSocket Limitation: It currently supports only WebSockets. Exposing existing HTTP web apps or APIs would require additional logic to wrap them, all communication is forced through websocket.
  • Security Concerns: The VPS terminates the traffic, creating a potential man-in-the-middle vulnerability. End-to-end encryption between the client browser and the local app would be necessary to mitigate this risk.
  • Connection Reliability: WebSockets are inherently fragile. Mobile networks fluctuate, IP addresses change frequently, and proxies often terminate idle WebSocket connections. This means both the Local App and Public Server will need robust logic for message queuing, duplicate prevention on reconnects, and cleanup of dead connections.
  • Double Deployment: A new app version would need to be deployed twice, once to the VPS (the static assets and maybe fixes for the websocket logic) and once on the local side.