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 (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:
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.
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.
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.