funnel

Architecture

How funnel tunnels work under the hood

Overview

Funnel is a client-server system where the client maintains a persistent QUIC connection to the server. The server exposes local services to the public internet through three tunnel types:

TypeRoutingUse case
HTTPsubdomain (my-app.tunnel.example.com)web apps, APIs, webhooks
TCPallocated port (tunnel.example.com:15432)databases, SSH, game servers
TLSallocated port (passthrough, no termination)mTLS, end-to-end encryption

All tunnel types share a single QUIC connection between client and server.

                         QUIC connection
  ┌────────┐          (persistent, muxed)          ┌────────┐
  │ Client │ ◄───────────────────────────────────► │ Server │
  └───┬────┘                                       └───┬────┘
      │                                                │
      │ HTTP / TCP / TLS                               │
      ▼                                                ▼
  ┌─────────┐                                   ┌───────────┐
  │ Local   │                                   │ Public    │
  │ Service │                                   │ Internet  │
  └─────────┘                                   └───────────┘

Why QUIC

Funnel uses QUIC instead of WebSocket or plain TCP for tunnel transport:

  • Per-request streams: each HTTP request or TCP connection gets its own QUIC stream. No head-of-line blocking. A slow response on one request doesn't delay others.
  • Built-in multiplexing: multiple concurrent requests share a single connection without framing overhead.
  • Connection migration: QUIC connections survive network changes (e.g. switching from WiFi to cellular).
  • 0-RTT reconnection: after the initial handshake, reconnections can send data immediately.

Server capabilities

Before connecting via QUIC, the client queries the server's REST API at GET /api/v1/info to discover:

  • quic_port: which port to connect to for the QUIC tunnel
  • capabilities.tunnel_types: which tunnel types the server accepts (["http"] by default, ["http", "tcp"] when tcp tunnels are enabled)
  • capabilities.tls: whether the server terminates TLS on public connections
  • capabilities.oauth_providers: which OAuth providers are configured for login (e.g. ["github"])

This is informational. The server is the sole enforcement point -- the client does not gatekeep based on capabilities. If a client requests a tunnel type the server doesn't support, the server rejects it in the handshake with a clear error code (unsupported_tunnel_type). The client treats server rejections as permanent errors and exits instead of retrying.

The handshake response also includes limits.allowed_tunnel_types as the authoritative in-band signal, following the same pattern as limits.dgram_mtu (absent means unsupported).

Connection flow

  1. Client queries GET /api/v1/info to discover the QUIC port and server capabilities
  2. Client connects to the server's QUIC port (default 4433)
  3. Client opens an initial stream and sends a registration message with the tunnel ID, tunnel type, and auth token
  4. Server validates the token, checks for ID conflicts, and registers the tunnel
  5. For HTTP tunnels: server assigns a subdomain (<tunnel_id>.<base_domain>)
  6. For TCP/TLS tunnels: server binds a TCP port and returns the allocated port number
  7. The QUIC connection stays open. The server opens new streams as traffic arrives.

HTTP tunnels

HTTP tunnels use subdomain routing. The server inspects the Host header of incoming requests and routes based on the first DNS label.

funnel http 3000 --id my-app
internet                          server                           client
   │                                │                                │
   │  GET my-app.tunnel.example.com │                                │
   │ ──────────────────────────────►│                                │
   │                                │  open QUIC stream              │
   │                                │  send HttpRequest metadata     │
   │                                │ ──────────────────────────────►│
   │                                │                                │ forward to
   │                                │                                │ localhost:3000
   │                                │  HttpResponse + body           │
   │                                │ ◄────────────────────────────  │
   │  HTTP response                 │                                │
   │ ◄──────────────────────────────│                                │

Subdomain routing: the server middleware inspects the Host header. If the subdomain matches a registered tunnel, the request goes to the tunnel proxy. Otherwise it falls through to the API routes.

DNS setup: requires a wildcard DNS record pointing to the server. For example, *.tunnel.example.com pointing to the server's IP address.

WebSocket forwarding

WebSocket upgrade requests are detected and handled specially. After the HTTP 101 response, the QUIC stream becomes a raw bidirectional byte pipe between the public client and the local service.

Proxy headers

The server adds standard proxy headers to forwarded HTTP requests:

  • X-Forwarded-For: client IP address (appended if already present)
  • X-Forwarded-Host: original host header
  • X-Forwarded-Proto: http or https
  • X-Real-IP: client IP address

TCP tunnels

TCP tunnels forward raw bytes without HTTP framing. Instead of subdomain routing, the server allocates a TCP port and listens for connections on it.

TCP tunnels must be explicitly enabled on the server with --enable-tcp-tunnels. They are disabled by default because they expose ports directly on the server.

funnel tcp 5432 --id my-db
internet                          server                           client
   │                                │                                │
   │  TCP connect to                │                                │
   │  tunnel.example.com:15432      │                                │
   │ ──────────────────────────────►│                                │
   │                                │  open QUIC stream              │
   │                                │  send StreamHeader metadata    │
   │                                │ ──────────────────────────────►│
   │                                │                                │ TCP connect to
   │                                │                                │ localhost:5432
   │                                │                                │
   │  ◄──────── bidirectional raw bytes ──────────────────────────►  │
   │                                │                                │

How port allocation works

When a TCP tunnel is registered, the server binds a TCP listener:

  • Specific port (--remote-port 15432): binds that exact port. Fails with port_unavailable if already in use.
  • Auto-assign (default): allocates a port from the server's configured range (default 10000-60000). The allocated port is returned in the handshake response and printed by the client.

The server reports the allocated port to the client, which displays it:

funnel tcp

  forwarding  localhost:5432
  tunnel id   my-db
  remote port 15432 (allocated)

Remote clients connect to <server_host>:<allocated_port>. Each incoming TCP connection gets its own QUIC stream for independent bidirectional byte relay.

Port range configuration

The server controls which ports are available for TCP tunnels:

FlagEnvDefaultDescription
--stream-port-minFUNNEL_STREAM_PORT_MIN10000lowest port for auto-allocation
--stream-port-maxFUNNEL_STREAM_PORT_MAX60000highest port for auto-allocation

Make sure these ports are open in your firewall. For example, with ufw:

sudo ufw allow 10000:60000/tcp

Or restrict the range:

funnel-server --stream-port-min 20000 --stream-port-max 20100
sudo ufw allow 20000:20100/tcp

Half-close semantics

TCP supports closing one direction while keeping the other open (FIN). Funnel preserves this end-to-end: a TCP FIN from the remote client becomes a QUIC stream finish, which the client forwards as a TCP shutdown to the local service. This matters for protocols like PostgreSQL's copy mode or custom wire protocols that use half-close for signaling.

What TCP tunnels are for

  • Databases: expose a local PostgreSQL, MySQL, or Redis instance
  • SSH: expose local SSH server for remote access
  • Game servers: expose a local game server on a specific port
  • Custom protocols: anything that speaks TCP

HTTP vs TCP: which to use

HTTP tunnelTCP tunnel
Routingsubdomain (DNS label)port number
DNSneeds wildcard DNSno DNS needed beyond server hostname
URLhttps://my-app.tunnel.example.comtunnel.example.com:15432
Protocol awarenessfull HTTP: headers, status codes, proxy headers, WebSocketnone: raw bytes
Inspectionmethod, path, status loggedconnection/disconnect logged
Use whenthe local service speaks HTTPthe local service speaks anything else

If the local service speaks HTTP, use an HTTP tunnel. You get subdomain routing, proxy headers, WebSocket support, and request logging. For everything else, use a TCP tunnel.

TLS tunnels

TLS tunnels are TCP tunnels where the traffic happens to be TLS-encrypted. The server does not terminate TLS; bytes are forwarded as-is. The local service handles the TLS handshake directly with the remote client.

funnel tls 8443 --id secure-app

This is identical to funnel tcp in terms of port allocation and data flow. The distinction is semantic: it signals that the traffic is TLS and the server should not attempt to inspect or modify it. The server is a blind relay.

Use TLS tunnels when:

  • the local service requires mutual TLS (mTLS) with the remote client
  • you need end-to-end encryption that the tunnel server cannot see
  • the local service manages its own certificates

Reconnection

If the QUIC connection drops, the client reconnects with exponential backoff (1s, 2s, 4s, 8s, ... up to 30s). During reconnection:

  • HTTP tunnel subdomains are unavailable (requests get 404)
  • TCP tunnel ports are released and reallocated on reconnection
  • Once reconnected, the same tunnel ID is re-registered (if still available)

On this page