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:
| Type | Routing | Use case |
|---|---|---|
| HTTP | subdomain (my-app.tunnel.example.com) | web apps, APIs, webhooks |
| TCP | allocated port (tunnel.example.com:15432) | databases, SSH, game servers |
| TLS | allocated 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 tunnelcapabilities.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 connectionscapabilities.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
- Client queries
GET /api/v1/infoto discover the QUIC port and server capabilities - Client connects to the server's QUIC port (default
4433) - Client opens an initial stream and sends a registration message with the tunnel ID, tunnel type, and auth token
- Server validates the token, checks for ID conflicts, and registers the tunnel
- For HTTP tunnels: server assigns a subdomain (
<tunnel_id>.<base_domain>) - For TCP/TLS tunnels: server binds a TCP port and returns the allocated port number
- 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-appinternet 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 headerX-Forwarded-Proto:httporhttpsX-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-dbinternet 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 withport_unavailableif 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:
| Flag | Env | Default | Description |
|---|---|---|---|
--stream-port-min | FUNNEL_STREAM_PORT_MIN | 10000 | lowest port for auto-allocation |
--stream-port-max | FUNNEL_STREAM_PORT_MAX | 60000 | highest port for auto-allocation |
Make sure these ports are open in your firewall. For example, with ufw:
sudo ufw allow 10000:60000/tcpOr restrict the range:
funnel-server --stream-port-min 20000 --stream-port-max 20100
sudo ufw allow 20000:20100/tcpHalf-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 tunnel | TCP tunnel | |
|---|---|---|
| Routing | subdomain (DNS label) | port number |
| DNS | needs wildcard DNS | no DNS needed beyond server hostname |
| URL | https://my-app.tunnel.example.com | tunnel.example.com:15432 |
| Protocol awareness | full HTTP: headers, status codes, proxy headers, WebSocket | none: raw bytes |
| Inspection | method, path, status logged | connection/disconnect logged |
| Use when | the local service speaks HTTP | the 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-appThis 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)