Skip to content

👻 XHTTP and HTTP/2

Improvements of XHTTP in Prizrak-Core. The flow governor and the send buffer are based on ideas by Medium1992 from mihomo-proxy-ros; the listener options follow Jolymmiles/Xray-core and Xray-core.

The client side of an HTTP/2 flow governor for XHTTP in h2 mode. The server sends data only as fast as the client reads it. Without the governor the server fills large HTTP/2 windows (4 MiB per stream in Go) and data nobody reads piles up in memory. The server needs no setup: the governor uses standard HTTP/2 flow control and works with any server.

How it works:

  • Window by consumption. The governor shows the server a 64 KiB window, caps the frame size at 16 KiB and measures RTT with its own PING once a second. The server gets credit back only while unread data stays below what the client reads in a couple of round trips.
  • Fast start. A new stream starts with a 256 KiB window or the window the connection recently needed. Until the first RTT is measured, the window opens with every credit.
  • Path model from min_rtt. On Linux and Android the governor reads TCP_INFO from the kernel (rtt, min_rtt, rttvar) and holds a download window to at most 1.25 × highest read rate × min_rtt. On other systems it uses its PING measurements.
  • Works with a server-side governor. The same governor exists on the server side in Jolymmiles/Xray-core (the h2Flow option). A governed client announces the default 65535 window, which tells the server that a governor sits on the other side too, so the server does not hold back what it sends. The governor can therefore be on the client, on the server or on both: downloads do not stall and throughput stays the same. The Prizrak-Core listener can run the server side too, see Listener below.

Off by default, enabled per proxy with an h2-flow block:

xhttp-opts:
h2-flow:
enabled: true
max-stream-receive-window: 16777216 # receive window per stream, bytes
max-connection-receive-window: 33554432 # receive window per connection, bytes
  • enabled turns the governor on.
  • Receive windows:
    • optional, from 65535 bytes to 1 GiB;
    • unset, Go’s default windows apply (4 MiB per stream);
    • with the governor they are a ceiling and the real window follows reading;
    • without it they are fixed windows.
  • h2-flow-control: true still works as a shorthand for h2-flow.enabled: true. When both are set, h2-flow.enabled wins.
  • Downloads through download-settings use the same h2-flow block.

Works only in h2 mode; it has no effect for h3 and http/1.1.

To enable h2-flow for Remnawave subscription users, use the Remnawave host mapper; see XHTTP Flow Control on the Remnawave integration page.

Long-lived HTTP/2 streams keep their send buffer open for the whole connection: XHTTP in stream-one/stream-up modes and gRPC. Its size is reduced from 512 KiB to 32 KiB through the legiz-ru/prizrak-http fork on top of metacubex/http v0.1.8. With many concurrent tunnels this saves noticeable memory at the same speed. Nothing to configure.

In packet-up mode Prizrak-Core can send its upload requests with GET, as Xray does with "uplinkHTTPMethod": "GET". The data travels in the request body, in a header or in a cookie, chosen by uplink-data-placement. The Prizrak-Core listener accepts such requests from Xray clients as well.

xhttp-opts:
path: /x
mode: packet-up
uplink-http-method: GET
uplink-data-placement: header # body, header or cookie
  • GET works in packet-up mode only. In stream-one and stream-up the config fails to load with an error.
  • The method is case-insensitive: get and GET are the same.
  • uplink-data-key defaults as in Xray: X-Data for header and x_data for cookie. Set it only when the server uses another name.
  • For header and cookie one upload is 4 KiB when sc-max-each-post-bytes is unset: the Xray server accepts headers up to 8 KiB. The Xray client defaults to 1 MB there and needs that lowered by hand.

Xray option mapping:

Xray (xhttpSettings)Prizrak-Core (xhttp-opts)
"mode": "packet-up"mode: packet-up
"uplinkHTTPMethod": "GET"uplink-http-method: GET
"uplinkDataPlacement": "header"uplink-data-placement: header
"uplinkDataKey": "X-Data"uplink-data-key: X-Data
"scMaxEachPostBytes": "4000"sc-max-each-post-bytes: 4000

The XHTTP client in h2 mode dials one HTTP/2 connection to a server at a time. Before, requests that started together, as when a page opens, each opened a connection of their own: 16 requests on a fresh connection made 13 connections, and 16 while a TLS handshake hung (as when a censor drops the flow after the ClientHello). A burst of handshakes is a pattern DPI looks for, and it went past reuse-settings.max-connections. Now such requests wait for one dial and share its connection; another connection opens only when the server’s stream limit is reached. Nothing to configure. It is the same fix as -tags http2legacy in Xray builds (XTLS/Xray-core#6797).

In packet-up mode an upload is also sent again on a new connection when the server or a CDN retires the old one with GOAWAY; before, the upload failed and the connection dropped (as Xray since XTLS/Xray-core#6632).

uplink-http-method: OPTIONS works with the Prizrak-Core listener as with Xray since v26.10.31 (XTLS/Xray-core#7290): an OPTIONS request with Access-Control-Request-Method is a CORS preflight, one without it is an upload. Before, the listener answered every OPTIONS as a preflight and dropped the data. The client sends any method it is given, OPTIONS included; the server must be Xray v26.10.31 or newer, or Prizrak-Core after v1.19.33-r8.

Listener: governor, HTTP/2 settings and Mux.Cool KeepAlive

Section titled “Listener: governor, HTTP/2 settings and Mux.Cool KeepAlive”

The VLESS listener’s xhttp-config takes three server options, as in Jolymmiles/Xray-core and Xray-core:

listeners:
- name: vless-xhttp
type: vless
# ...
xhttp-config:
path: /x
mux-keep-alive-secs: "20-25" # poke an idle packet-up / stream-up downlink
mux-keep-alive-bytes: "100-500"
h2-flow:
enabled: true
# max-stream-receive-window: 6291456
# max-connection-receive-window: 6291456
# max-connection-send-window: 4194304 # -1: no limit
# queue-cuts: true
h2-params:
max-concurrent-streams: 256
# max-read-frame-size: 16384
# send-ping-timeout: 30s
  • h2-flow: the server side of the flow governor. Off by default; HTTP/2 only (TLS, REALITY and h2c).
    • Upload windows max-stream-receive-window and max-connection-receive-window: from 65535 bytes to 1 GiB; unset, 6 MiB with the governor and Go’s 1 MiB without.
    • max-connection-send-window bounds what one connection’s client holds of the server’s data, unread or as open window: 4 MiB by default, -1 lifts the limit. A client with a governor of its own is not held to it.
    • queue-cuts (on by default) keeps a download at its rate beside other traffic on the same path; false turns the cuts off.
  • h2-params: HTTP/2 settings of the server, as h2Params in Xray (XTLS/Xray-core#7295): max-concurrent-streams, max-read-frame-size, max-receive-buffer-per-connection, max-receive-buffer-per-stream, max-decoder-header-table-size, max-encoder-header-table-size, send-ping-timeout, ping-timeout, write-byte-timeout (durations like 30s or a number of seconds), strict-max-concurrent-requests, permit-prohibited-cipher-suites. Unset fields keep Go’s defaults. A window set in h2-flow wins over the one here. With h2-flow on, max-concurrent-streams is at most 4096.
  • mux-keep-alive-secs and mux-keep-alive-bytes. When a client sends mux.cool over XHTTP and its packet-up or stream-up download carries nothing for that many seconds, the server sends a KeepAlive frame with random padding of that size (at most 1024 bytes). Clients discard it, and CDNs and reverse proxies do not cut the download as idle. Off by default; stream-one is never poked.
  • A wrong value fails the listener when the config loads, before it opens a port.

Option mapping:

Xray (xhttpSettings)Prizrak-Core (xhttp-config)
"h2Flow": {"enabled": true}h2-flow: {enabled: true}
"h2Flow": {"maxConnectionSendWindow": -1}h2-flow: {max-connection-send-window: -1}
"h2Flow": {"queueCuts": false}h2-flow: {queue-cuts: false}
"h2Params": {"MaxConcurrentStreams": 256}h2-params: {max-concurrent-streams: 256}
"muxKeepAliveSecs": "20-25"mux-keep-alive-secs: "20-25"
"muxKeepAliveBytes": "100-500"mux-keep-alive-bytes: "100-500"