funnel

Protocol

Wire protocol specification for funnel QUIC tunnels

Funnel uses a custom binary protocol over QUIC for tunnel communication. All structured data is serialized with MessagePack using named fields (rmp_serde::to_vec_named). The current protocol version is 1.

Transport

The QUIC connection uses ALPN identifier funnel. After the TLS handshake, the client opens a single bidirectional stream for the handshake exchange. Subsequent streams are opened by the server, one per incoming request (HTTP) or connection (TCP/stream).

Handshake

The client initiates the connection by sending a Handshake message on the first bidirectional stream.

Client to server

Handshake Message {
  +----------+-------------------+
  | version  | uint32            |
  +----------+-------------------+
  | token    | string (optional) |
  +----------+-------------------+
  | tunnels  | array<TunnelSpec> |
  +----------+-------------------+
}

TunnelSpec {
  +------------+-------------------------+
  | id         | string (3-63 chars)     |
  +------------+-------------------------+
  | type       | "http"|"stream"|"dgram" |
  +------------+-------------------------+
  | team       | string (optional)       |
  +------------+-------------------------+
  | local_port | uint16 (optional)       |
  +------------+-------------------------+
  | routing    | "port"|"sni" (optional) |
  +------------+-------------------------+
  | remote_port| uint16 (optional)       |
  +------------+-------------------------+
}

id is a validated DNS subdomain label: lowercase alphanumeric and hyphens, cannot start or end with a hyphen. If omitted from the CLI, an 8-character random ID is generated.

type values:

  • http: HTTP tunneling with subdomain routing
  • stream: raw TCP/TLS forwarding with port-based routing
  • dgram: UDP forwarding (reserved, not yet implemented)

Server to client

HandshakeResult Message {
  +----------+---------------------------+
  | version  | uint32                    |
  +----------+---------------------------+
  | server_id| string                    |
  +----------+---------------------------+
  | tunnels  | array<TunnelResult>       |
  +----------+---------------------------+
  | limits   | ServerLimits              |
  +----------+---------------------------+
}

TunnelResult {
  +---------------+-----------------------+
  | id            | string                |
  +---------------+-----------------------+
  | status        | "ok"|"error"          |
  +---------------+-----------------------+
  | remote_port   | uint16 (optional)     |
  +---------------+-----------------------+
  | public_url    | string (optional)     |
  +---------------+-----------------------+
  | error_code    | AppCode (optional)    |
  +---------------+-----------------------+
  | error_message | string (optional)     |
  +---------------+-----------------------+
}

ServerLimits {
  +----------------------+-------------------------------+
  | max_streams          | uint32 (default: 128)         |
  +----------------------+-------------------------------+
  | max_request_body     | uint64 (default: 64 MiB)      |
  +----------------------+-------------------------------+
  | dgram_mtu            | uint16 (optional)             |
  +----------------------+-------------------------------+
  | allowed_tunnel_types | array<string> (default: ["http"]) |
  +----------------------+-------------------------------+
}

After receiving the handshake result, the client validates that all requested tunnels have status ok. If any tunnel has an error, the client reports it and exits.

Framing

All messages are length-prefixed frames:

Frame {
  +--------+----------------------------+
  | length | uint32 (big-endian)        |
  +--------+----------------------------+
  | payload| length bytes               |
  +--------+----------------------------+
}

For metadata frames (handshake, request/response headers), the payload is MessagePack-encoded. The maximum metadata frame size is 1 MiB.

Request and response bodies are streamed directly after the metadata frame without framing, using the QUIC stream's own flow control.

Data streams

Once the handshake is complete, the server opens new QUIC bidirectional streams for incoming traffic. Every data stream starts with a DataHeader metadata frame that tells the client which type of traffic it carries.

DataHeader envelope

DataHeader (tagged union on "type") {
  +------+-------------------------------+
  | type | "http" | "stream"             |
  +------+-------------------------------+
  | ...  | type-specific fields          |
  +------+-------------------------------+
}

The type field determines how the client processes the stream. The client deserializes the metadata frame into the appropriate variant.

HTTP data streams

One QUIC stream per HTTP request/response cycle.

HttpRequest (type = "http") {
  +-------------+------------------------------+
  | type        | "http"                       |
  +-------------+------------------------------+
  | tunnel_id   | string                       |
  +-------------+------------------------------+
  | remote_addr | string (IP:port)             |
  +-------------+------------------------------+
  | method      | string (GET, POST, etc.)     |
  +-------------+------------------------------+
  | path        | string (path with query)     |
  +-------------+------------------------------+
  | headers     | map<string, array<string>>   |
  +-------------+------------------------------+
  | upgrade     | bool (default: false)        |
  +-------------+------------------------------+
}

The server sends the metadata frame, then streams the request body on the same QUIC stream.

HttpResponse {
  +----------+----------------------------+
  | status   | uint16 (HTTP status code)  |
  +----------+----------------------------+
  | headers  | map<string, array<string>> |
  +----------+----------------------------+
}

The client forwards the request to the local service, then sends the response metadata frame followed by the response body.

WebSocket upgrades

When upgrade is true, the tunnel switches to bidirectional byte streaming after the initial response metadata. Both sides pipe raw bytes between the QUIC stream and their respective TCP connections.

Stream data streams (TCP/TLS)

One QUIC stream per TCP connection. Used for raw TCP forwarding and TLS passthrough.

StreamHeader (type = "stream") {
  +-------------+------------------------------+
  | type        | "stream"                     |
  +-------------+------------------------------+
  | tunnel_id   | string                       |
  +-------------+------------------------------+
  | remote_addr | string (IP:port)             |
  +-------------+------------------------------+
  | server_port | uint16                       |
  +-------------+------------------------------+
  | sni         | string (optional)            |
  +-------------+------------------------------+
}

After the StreamHeader frame, the stream becomes a raw bidirectional byte pipe. No further framing. The client connects to the local service port and copies bytes in both directions:

remote client -> server TCP port -> QUIC stream -> client -> local service
local service -> client -> QUIC stream -> server TCP port -> remote client

Half-close semantics: TCP FIN from the remote is forwarded as QUIC finish() on the send side, and the client forwards it as TCP shutdown(Write) to the local service.

Port allocation: When a stream tunnel is registered in the handshake, the server binds a TCP listener:

  • If remote_port is specified and non-zero: bind that exact port. If unavailable, the tunnel registration fails with port_unavailable.
  • If remote_port is 0 or omitted: allocate from the configured range (default 10000-60000). The assigned port is returned in TunnelResult.remote_port.

sni field: Reserved for TLS passthrough routing. When present, the server matched the incoming connection's SNI hostname to determine which tunnel to forward to. Currently only port-based routing is implemented.

Error codes

Errors are categorized into three levels based on their scope and severity.

Connection codes

Used in Connection::close(). Fatal, all tunnels on the connection are torn down.

CodeValueDescription
NoError0x00clean shutdown
ProtocolError0x01invalid message format or unexpected frame
AuthFailed0x02token missing, invalid, or insufficient scope
VersionMismatch0x03incompatible protocol version
InternalError0x04server-side failure
ShuttingDown0x05server is shutting down gracefully

Stream codes

Used in SendStream::reset(). Affects a single request stream without tearing down the connection.

CodeValueDescription
NoError0x00clean stream close
TunnelGone0x01tunnel was deregistered during request
RateLimited0x02request rejected due to rate limiting
AccessDenied0x03insufficient permissions for this tunnel
Timeout0x04request exceeded the per-request timeout
LocalUnreachable0x05client could not reach the local service
BodyTooLarge0x06request body exceeds max_request_body

Application codes

Shared across the wire protocol, REST API error responses, and CLI --json output. Serialized as snake_case strings.

CodeDescription
tunnel_id_conflicttunnel ID is already in use
tunnel_id_invalidtunnel ID fails validation
unsupported_tunnel_typerequested tunnel type is not supported
port_unavailablerequested remote port is not available
auth_requiredno authentication token provided
auth_invalidtoken is invalid, revoked, or expired
scope_insufficienttoken lacks the required scope
user_deactivateduser account has been deactivated
team_not_foundreferenced team does not exist
team_membership_requireduser is not a member of the referenced team
tunnel_limit_exceededmaximum tunnel count reached
rate_limit_exceededtoo many requests
bad_requestmalformed request
not_foundresource not found
internal_errorserver-side failure

Connection lifecycle

  1. Client connects to the server QUIC port (default 4433) with ALPN funnel
  2. Client opens a bidirectional stream and sends a Handshake frame
  3. Server validates the token (must have tunnels scope), checks the user is active, and registers each tunnel
  4. For stream tunnels, the server binds a TCP listener on the allocated or requested port
  5. Server responds with HandshakeResult on the same stream (including allocated remote_port for stream tunnels)
  6. The connection stays open. The server opens new streams as HTTP requests or TCP connections arrive.
  7. On disconnect, the server deregisters all tunnels, stops any TCP listeners, and records session statistics (bytes in/out, request count, duration)

Serialization details

All structured messages use MessagePack with named fields for forward compatibility. Unknown fields are ignored during deserialization, so clients and servers at different minor versions can interoperate as long as the protocol version matches.

Enum variants are serialized as lowercase strings ("http", "ok", "error"). Application error codes use snake_case ("tunnel_id_conflict").

On this page