Contents

TCP (RAW)

The baseline transport: a raw stream, optionally dressed as HTTP.

RAW (the network name "raw" — the old name "tcp" still works, both normalize to the same transport in TransportProtocol.Build) is what every other transport is measured against. It adds no framing, no headers, no extra round trips: the proxy protocol’s bytes are the wire format.

The transport does almost nothing — that’s the design

Dial is a straight pipeline: open a system TCP socket, optionally wrap it in TLS or REALITY, optionally prepend a fake HTTP header, hand it back. There is no RAW-specific framing layer at all — if you use VLESS over RAW+REALITY, the first encrypted record already carries VLESS bytes.

transport/internet/tcp/dialer.go transport/internet/tcp/hub.go
// transport/internet/tcp/dialer.go (trimmed)
func Dial(ctx context.Context, dest net.Destination, streamSettings *internet.MemoryStreamConfig) (stat.Connection, error) {
	conn, err := internet.DialSystem(ctx, dest, streamSettings.SocketSettings)
	// … TLS / REALITY handshake, if configured …
	if tcpSettings.HeaderSettings != nil {
		headerConfig, _ := tcpSettings.HeaderSettings.GetInstance()
		auth, _ := internet.CreateConnectionAuthenticator(headerConfig)
		conn = auth.Client(conn) // fake HTTP wrapper
	}
	return stat.Connection(conn), nil
}

The listener mirrors this: ListenTCP accepts, wraps each connection in TLS/REALITY, then in the header authenticator, and passes it straight to the inbound handler — one goroutine per accepted connection.

Every app connection becomes its own TCP connection

RAW never multiplexes — each Dial call opens a fresh system socket, and each connection your apps make through a RAW outbound triggers exactly one Dial. Loading a page with 30 resources means 30 TCP handshakes (plus 30 TLS handshakes, if enabled) to your server. That per-connection cost is the whole reason Mux exists, and it’s why transports like SplitHTTP reuse HTTP clients instead. TCP Fast Open and other socket-level tricks that soften this are sockopt options, covered on Dialing & sockopts.

header=http writes one fake exchange, then gets out of the way

The HTTP header is one-shot cosmetics, not an HTTP implementation. The client’s first Write is preceded by a single fake request built from your config (Authenticator.GetClientWriter); the server’s first Write is preceded by a single fake response. After that both sides forget HTTP exists — every subsequent byte is the untouched proxy stream on a connection that opened looking like a long download:

transport/internet/headers/http/http.go transport/internet/headers/http/config.go
GET /download HTTP/1.1
Host: cdn.example.com
User-Agent: Mozilla/5.0 … Chrome/…
Connection: keep-alive

<proxy protocol bytes start immediately after the blank line>
{
	"network": "raw",
	"rawSettings": {
		"header": {
			"type": "http",
			"request": {
				"path": ["/download"],
				"headers": { "Host": ["cdn.example.com"] }
			},
			"response": {
				"headers": { "Content-Type": ["video/mpeg"] }
			}
		}
	}
}

Every value is picked per connection with dice.Roll — give path or any header multiple values and each new connection randomizes them. If you configure nothing, the defaults in infra/conf/transport_authenticators.go send a Chrome user agent with Host randomly chosen between www.baidu.com and www.bing.com — almost certainly not what your server is pretending to be, so always set Host yourself. And when you set headers, the entire default set is replaced, not merged (AuthenticatorRequest.Build) — add a User-Agent back if the disguise should still look like a browser.

header=http: one fake request/response pair, then the raw stream. Probes that miss the path get a canned 404.

The server checks the path and nothing else

Validation is a single check: is the request’s URL path in the configured path list? HeaderReader.Read parses the incoming fake request, compares req.URL.Path against expectedHeader.Uri, and ignores the method, the Host, and every other header entirely.

Anything the client sent after the \r\n\r\n in the same packets is preserved: HeaderReader returns the leftover bytes as a buffer that the wrapped connection drains before reading from the socket again, so the proxy protocol can start in the very first segment.

Don’t stack the fake header under TLS

In Dial, the header wrapper is applied after the TLS/REALITY handshake, so the fake request travels inside the encrypted stream. On the wire an observer sees TLS either way — the fake HTTP is invisible, and you pay its bytes and its parse-before-first-read on the server for nothing.

Don't

Add header=http to a TLS or REALITY stream

The fake GET is encrypted along with everything else. The censor still sees plain TLS; only your own server wastes time parsing make-believe HTTP.

Do

Pick one disguise per connection

Use header: { "type": "http" } only on plaintext RAW where an HTTP-shaped first packet is the disguise — or drop it and let TLS / REALITY be the camouflage.