the fast, reliable localhost tunneling solution


The PageKite Protocol

2011-03-17, 20:08

Note: This document is still a work in progress.

The PageKite protocol is the protocol which the front- and back-ends use to communicate. It handles service registration, authentication and tunneling session data between the two endpoints.

This document covers version 0.8 of the protocol.

Also defined are the services provided by a PageKite front-end (this may later be moved to a separate specification).


Philosophy

PageKite is a system to implement a dynamically configured reverse proxy, with the goal of making otherwise unreachable Internet servers visible to the wider Internet.

Each conceptual PageKite proxy actually has two parts: the front-end, which can communicate with the wider Internet (the clients) and the back-end, which can communicate directly with the servers. In fact, each back-end may connect to multiple front-ends and each front-end may serve multiple back-ends. The front-ends and the back-ends communicate with each other using the PageKite protocol: that is what this document describes.

The PageKite protocol is modeled after the traditional plain-text protocols of the wider Internet, borrowing most of its characteristics from HTTP. It is not the most elegant protocol out there, but it achieves our goals while remaining simple, flexible and familiar to anyone who has worked with the web.

Rather than create multiple versions of the protocol, only a minimal core set of features is required and the protocol defines a mechanism for advertising ad-hoc extensions during tunnel negotiation. Implementations will not be considered fully compliant if they require features outside the core to operate, but operators and users may obviously mandate whatever features they prefer, e.g. for security reasons.

As a rule, security is delegated to the industry-standard TLS, except for the ad-hoc challenge-response authentication method, which unlike TLS is considered a core feature.

Front-end discovery and dynamic DNS updates are also outside the scope of this document: although both will be required for any useful implementation, which methods protocols are chosen are considered an implementation detail (see Implementation Recommendations for our opinion).

Goals

The PageKite protocol has the following goals:

  • Work around limitations imposed by firewalls, NAT and port filters.
  • Simultaneously carry multiple bidirectional streams of data over a single TCP/IP connection.
  • Simplicity.
  • Flexibility.
  • Be application protocol agnostic.
  • Be easy to implement, understand and debug.
  • Gracefully degrade in constrained environments.
  • Make efficient use of available resources.

Non-goals

  • Real-time streaming features (UDP-style use cases).
  • Performance at the expense of human readability.
  • Performance at the expense of flexibility.
  • Reliability guarantees beyond those provided by TCP/IP.

Overview

The PageKite protocol may be segmented into a few layers, from the bottom up, like so:

  1. TCP/IP
  2. TLS (optional)
  3. Handshake protocol, Frames
  4. Chunks
  5. Application data

The bottom two layers, TCP/IP and TLS, are out of scope of this document. The last layer, application data, can be whatever you want it to be. This document concerns itself with the bits in the middle: the handshake, the frames and the chunks.

Note that whether or not implementations take advantage of TLS features for authentication in addition to (or instead of) those built into PageKite, is considered an implementation detail, outside the scope of this document.

Terminology

Back-end - Software implementing the end of a PageKite tunnel which is responsible for directly contacting the authoritative servers for a given incoming request. It does not need a public IP-address, it just needs to be able to communicate with a front-end that does.

Chunk - A fragment of data, along with the meta-data required to associate it with a particular user-initiated stream of communication (e.g. a single proxied HTTP request and response).

Frame - A fragment of data. The lowest-level unit of data transferred over a tunnel.

Front-end - Software implementing the end of a PageKite tunnel which receives incoming requests from the Internet. It generally has a publicly visible IP-address.

Handshake - The set-up phase of creating a PageKite tunnel.

Stream - A bidirectional sequence of bytes. Generally each data stream carried over a PageKite tunnel corresponds to a TCP/IP connection between a client and the PageKite front-end. Assuming all goes well, there will also be a corresponding TCP/IP connection between the PageKite back-end and some server.

Tunnel - An active TCP/IP connection between a PageKite front-end and a PageKite back-end.


The handshake

When creating a PageKite tunnel, the back-end creates an TCP/IP connection to its front-end of choice. (This connection may itself be proxied over SOCKS, Tor or an HTTP proxy, if the back-end software supports it.)

Before any user data is exchanged, a handshake takes place where the back-end speaks first, announcing which optional features it supports and requesting service for one or more (protocol, name) pairs. The front-end may accept, reject or challenge any pair.

Rejection is final, back-ends MAY NOT retry a rejected request without user interaction or a pause exceeding 15 minutes.

When challenged, the back-end SHOULD calculate a response and show the result in a subsequent handshake request.

If one or more requests are accepted, any subsequent data MUST consist only of Frames, as described below.

If all requests are denied, the front-end MAY close the connection, forcing the back-end to reconnect.

If all requests are denied and the front-end leaves the connection open, the back-end MAY immediately send another handshake request with responses to any challenges over the same TCP/IP connection.

Handshake format

The handshake is formatted as an HTTP/1.0 CONNECT request, with PageKite specific payload carried in HTTP-style headers. Lines are terminated with CR LF (\r\n), and as in HTTP the request ends with a blank line.

All headers are optional, headers may appear in any order, and unrecognized headers MUST be ignored.

Common headers

X-PageKite-Features: ZChunks

This header, if present, announces a willingness to accept compressed chunk data at the frame layer. A PageKite endpoint may send compressed chunks if and only if this header is present in the handshake.

Back-end request format

CONNECT PageKite:1 HTTP/1.0
X-PageKite-Features: ZChunks
X-PageKite-Replace: <session-id>
X-PageKite: <protocol>:<name>:<bsalt>:<fsalt>:<sig>

If present, the X-PageKite-Replace header tells the front-end that this connection is replacing an older session established by the same instance of PageKite. Upon seeing this header, the front-end MUST disconnect any active tunnels with that session-id, if and only if the exact same set of (protocol, name) pairs is being requested by the back-end.

Multiple X-PageKite headers may be present in a single request; each one attempts to register a single protocol on a given domain (so matching requests will be sent to this back-end). The individual fields of each X-PageKite request header are delimited by semicolons (':') and are as follows:

Protocols: The recognized protocols are currently http, https and raw. A protocol may be explicitly bound to a particular port by appending a dash and the decimal port number (e.g. http-80 or raw-22).

Names: Names may be any arbitrary DNS domain name. Ensuring a correct mapping between DNS names and the active front-end is outside the scope of this protocol (see recommendations).

BSalt: A 36 character random salt generated by the PageKite back-end. Valid characters are [0-9a-z].

FSalt: An optional 36 character random salt generated by the PageKite front-end. Valid characters are [0-9a-z]. This field is blank if no challenge has been received.

Sig: A 36 character shared-secret-based signature of all previous fields. Valid characters are [0-9a-z]. See below for details on how this is calculated.

Front-end response format

HTTP/1.1 200 OK
Connection: close
X-PageKite-Features: ZChunks
X-PageKite-SessionID: <session-id>
X-PageKite-OK: <protocol>:<name>:<srand>
X-PageKite-Invalid: <protocol>:<name>:<srand>
X-PageKite-Duplicate: <protocol>:<name>:<srand>
X-PageKite-SignThis: <protocol>:<name>:<srand>:<token>

The features and session IDs are as described above.

The OK, Invalid, Duplicate and SignThis are responses to the registration request above and follow the same format.

The OK response means the tunnel has been established, Invalid and Duplicate are rejections and SignThis is a challenge.

Challenge-response format

All X-PageKite requests must be signed, and the back-end and front-end (or front-end's authentication source) are assumed to have a shared secret, which is an arbitrary string of bytes.

The signature is calculated like so:

<sign> = <salt> + HEX(SHA1(<secret> + <payload> + <salt>))

Where + represents string concatenation, the <salt> is an 8 character random string (using characters [0-9a-z]) and the <payload> is the exact string preceding the signature in the X-PageKite request: <proto>:<host>:<bsalt>:<fsalt>.

Finally, the signature is truncated to 36 characters.

The PageKite authentication protocol is designed to allow front-ends to delegate authentication to a remote service, so they themselves do not have to know the shared secret for a given domain. The protocol is also designed to be at least somewhat resilient to clear-text transmission, in that a third party may be listening in on the exchange and might attempt to hijack a domain by replaying the authentication messages.

For this reason, the initial X-PageKite request SHOULD be rejected with a X-PageKite-SignThis response, where the front-end generates a new <fsalt> and demands the back-end re-sign the request including that data.

The generated <fsalt> SHOULD be unpredictable, but not completely random: the front-end SHOULD use a combination of random data, timestamps and similar cryptographic signatures to verify that this particular back-end actually just signed what was sent.

The final signature should thus give reasonably strong confidence that the back-end is actually in possession of the shared secret and able to use it to sign any arbitrary string of data.

Limitations: There is a window of opportunity for replay attacks, but how large it is, is controlled by the front-end's algorithm for generating <fsalt>. This protocol also does not provide any protection against active man-in-the-middle attacks. For these reasons, it is highly recommended that the entire exchange be wrapped in TLS and at least one end (usually the back-end) check the certificate of the other. If remote authentication servers are used, care should be taken to secure that communication channel as well.


The frames layer

Once the handshake has successfully completed, the TCP/IP connection (the tunnel) switches to passing frames back and forth.

Basic PageKite frames are compatible with HTTP/1.1 chunked encoding, and frame contents are called chunks.

Each frame begins with a hexadecimal number indicating the length of the frame data. The length prefix is terminated by a CR LF sequence (\r\n). The chunk (frame data) immediately follows the terminator and must be exactly as long as indicated by the prefix.

Thus a 16 byte frame might look like so (0x10 = 16 bytes of data):

10\r\n
1234567890abcdef

Python code for sending a valid frame is trivial:

sock.send('%x\r\n%s' % (len(chunk), chunk))

The main differences between PageKite frames and HTTP/1.1 chunked encoding are as follows:

  • A 0-length frame does not indicate the end of a stream.
  • Frames may travel in both directions over the PageKite connection.

Chunk compression

If, during the handshake, support for the optional ZChunks feature was announced by the remote end, chunks (frame contents) MAY be compressed using zlib or a compatible algorithm.

The same zlib compression context MUST be used to compress all data sent over a single PageKite connection; at the other end of the tunnel, a single decompression context is used for decompression. This allows the zlib dictionary to adapt over time to the traffic being carried by each individual tunnel.

Frames containing compressed chunks are formatted similarly to the basic frames described above, except signaling is added to the prefix to indicate that a chunk is compressed, data size after compression, and whether the dictionary was just reset or not.

Thus the length prefix is calculated as before (hexadecimal length of uncompressed data), but compression is indicated by appending the character Z followed by the hexadecimal length of the data after compression.

On connections supporting compression, the character R may be appended to any frame's length prefix to indicate that the compression dictionary has been reset. If present, the reset marker MUST be the last character preceding the CR LF terminator.

Another Python example:

zw = zlib.compressobj(5)
...
zf1 = zw.compress(chnk1) + zw.flush(zlib.Z_SYNC_FLUSH)
sock.send('%xZ%x\r\n%s' % (len(chnk1), len(zf1), zf1))
...
zw = zlib.compressobj(4)  # new dictionary!
...
zf2 = zw.compress(chnk2) + zw.flush(zlib.Z_SYNC_FLUSH)
sock.send('%xZ%xR\r\n%s' % (len(chnk2), len(zf2), zf2))

Might generate frames like so (made-up numbers):

10Zb\r\n
<compressed chunk>
10ZbR\r\n
<compressed chunk>

A compression-enabled PageKite tunnel should be expected to carry a mixture of compressed and uncompressed chunks.

Implementations are encouraged to take advantage of this and save CPU cycles by not attempting to compress data which is known to already have been compressed. (More importantly, this prevents chunks of random data from trashing the compression dictionary.)


The chunks layer

Chunks are fragments of user data (the data being proxied), prefixed with HTTP-style headers providing meta-data about the chunk.

As in HTTP, each header is a name: value pair, terminated by a CR LF (\r\n) sequence. The header ends with a blank line.

An example:

SID: 123\r\n
Host: foo.com\r\n
Proto: http\r\n
Port: 3000\r\n
RIP: 1.2.3.4\r\n
RPort: 12345\r\n
\r\n
<user data>

This chunk would signify the beginning of a new stream of data; an incoming HTTP request destined for http://foo.com:3000/, coming from the remote IP address 1.2.3.4 on port 12345.

As a rule, user data SHOULD be relayed unmodified, headers are used for "routing".

(An exception to this, is that if the protocol is known to be HTTP then back-ends SHOULD add the X-Forwarded-For: header to indicate the origin of the request.)

The following list of chunk headers should not be considered exhaustive; any unrecognized headers SHOULD simply be ignored. Conversely, PageKite implementations extending the protocol by adding new headers MUST continue to operate correctly even if the new headers are ignored.

(In order to prevent confusion, announcing optional features in the handshake and coordinating with the PageKite open source project on what names to give new features is recommended.)

Chunk header: SID

This is the Stream ID, identifying which stream of data this chunk belongs to.

Stream IDs are decimal integers. They are assigned by the front-end to one stream at a time.

Stream IDs MAY be recycled.

Back-ends which connect to multiple front-ends must take care NOT to assume that stream IDs are globally unique.

When a back-end sees an incoming chunk with a new stream ID (or previously closed), this indicates that the Host:, Proto: and Port: headers should be consulted and a new connection to the given server established.

Chunk header: Host

This header indicates the DNS name of the requested server.

Chunk header: Proto

This header indicates the protocol of this stream. It may be any string of text, but common values are http, https and raw.

Chunk header: Port

This header indicates the port of the requested server.

Chunk header: NOOP

This header indicates that the chunk data section should be silently discarded, not proxied.

Chunk header: EOF

This header indicates the end of a stream. As streams are bidirectional, the value of this header must be checked:

If it contains the character W, then no more data can be written (this data source will accept no more data).

If it contains the character R, then no more data will be made available for reading (this data source can no longer be read).

If the header contains neither R nor W, it should be interpreted as if it contained both (complete stream shutdown).

Chunk header: PING

This header requests that the other end immediately respond with some data (typically a NOOP chunk).

Chunk header: ZRST

This header requests the remote end reset the frame compression dictionary for this tunnel (this is used when a chunk fails to decompress correctly, which may be a hopeless state of affairs anyway).

Implementations not supporting ZChunks may ignore this header.

Chunk header: SPD

This header is used for flow control.

It is sent when an end-point does not want to receive any more data for a while on a given stream (this is always accompanied by a SID header).

The value of this header is a decimal integer estimating the bytes-per-second capacity of this particular stream. Responding to this header is optional.

However, keep in mind that multiple streams are sent over a single tunnel and each of them may have a different client/server pair, with different bandwidth characteristics. If rate limits for individual streams are ignored, implementations may resort to throttling the entire tunnel if or when congestion or buffer size becomes a problem - a slow client could thus slow down the entire site for everyone if this signal is ignored. So playing nice is recommended.


Services exposed by the front-end

FIXME

  • Listen for new tunnels, authenticate
  • Listen for HTTP clients, parse, choose tunnels
  • Listen for HTTPS clients, parse SNI, choose tunnels
  • Listen for HTTP CONNECT clients, choose tunnels
  • Optionally, listen on raw ports

Implementation recommendations

FIXME