Skip to content

HTTP proxy

The http protocol is a classic HTTP/1.1 forward proxy, the kind that browsers, curl, package managers and most SDKs understand through an http_proxy or https_proxy setting. etemenanki-app implements both sides:

  • the inbound accepts CONNECT tunnels and plain http:// requests from local clients;
  • the outbound is a CONNECT client, so you can send routed traffic through an upstream HTTP proxy, or an HTTPS proxy when you put it on a TLS transport.

HTTP proxies carry TCP only. If your clients need UDP, use a SOCKS inbound instead.

A local proxy that requires a password and sends everything straight out:

config.toml
[[inbound]]
tag = "http-in"
protocol = "http"
listen = "127.0.0.1"
port = 8080
[[inbound.settings.accounts]]
user = "alice"
pass = "replace-with-a-long-random-password"
[[outbound]]
tag = "direct"
protocol = "freedom"

Check it with etemenanki-app --test -c config.toml, start it, then point a client at it:

Terminal window
# HTTPS URL: curl opens a CONNECT tunnel to example.com:443.
curl -x http://alice:replace-with-a-long-random-password@127.0.0.1:8080 https://example.com/
# Plain HTTP URL: curl sends "GET http://example.com/ HTTP/1.1" and the proxy forwards it.
curl -x http://127.0.0.1:8080 -U alice:replace-with-a-long-random-password http://example.com/
# Force a CONNECT tunnel even for a plain HTTP URL.
curl -p -x http://alice:replace-with-a-long-random-password@127.0.0.1:8080 http://example.com/

Without the [[inbound.settings.accounts]] block, the proxy accepts anyone who can reach the port. See Authentication before you listen on anything other than loopback.

These keys go under [inbound.settings]. The common inbound keys (tag, listen, port, sniffing, stream, address_family) are described on Inbounds.

KeyTypeRequiredDefaultDescription
accountsarray of tablesno[]Accounts checked against the client's Proxy-Authorization: Basic header. Empty means an open proxy: every client is accepted without credentials. When set, a request without a matching account is answered 407.
accounts[].userstringyes—Username, case-sensitive. Must not contain :, because the Basic credential is split at its first colon; such a user can never log in. If two entries share a username, the later one wins.
accounts[].passstringyes—Password. It must match exactly, including case, and it may contain :. Both user and pass are required in every entry; a missing one fails the config with missing field.
allow_transparentboolnofalseAccept plain requests in origin-form (GET /path) and take the target from the Host header, port 80 if it has none. When false, such a request is answered 400. CONNECT requests and absolute-form http:// or https:// requests are served either way.

Unknown keys are rejected, so a typo stops the config instead of silently leaving a default in place:

configuration invalid: inbound http-in: invalid settings: unknown field `allow_transparant`, expected `accounts` or `allow_transparent`

The inbound reads one request head, decides what kind of request it is, and then either opens a tunnel, forwards the request, or answers with an error and closes.

flowchart TD
  A["Read request head"] --> B{"Credentials OK?"}
  B -- "no" --> R407["407, close"]
  B -- "yes, or open proxy" --> C{"Method is CONNECT?"}
  C -- "yes" --> D{"IP target and sniffing on?"}
  D -- "yes" --> E["200 at once, sniff first bytes"] --> F["Route and connect"]
  D -- "no" --> G["Route and connect"] --> H{"Connected?"}
  H -- "yes" --> R200["200, tunnel"]
  H -- "no" --> R502["502, close"]
  C -- "no" --> I{"Target is an http:// or https:// URL?"}
  I -- "yes" --> J["Rewrite to origin form and forward"]
  I -- "no" --> K{"allow_transparent?"}
  K -- "yes" --> J
  K -- "no" --> R400["400, close"]

A CONNECT host:port request opens a TCP tunnel to host:port. This is how clients reach HTTPS sites, and anything else that is not plain HTTP, through the proxy.

  • The target is the request’s authority, for example example.com:443. If it has no port, etemenanki uses 443.
  • A domain stays a domain: the router sees example.com, and the outbound resolves it (or passes it on, if the outbound is another proxy).
  • An IPv6 target must be in brackets, [2001:db8::1]:443. An unbracketed IPv6 address is refused as ambiguous and the connection closes.
  • Once the outbound is connected, the inbound answers HTTP/1.1 200 Connection established and relays bytes both ways until either side closes. If the outbound cannot connect, the inbound answers 502 Bad Gateway and closes.

The one exception to “200 after connecting” is sniffing, below.

When a client sends CONNECT 203.0.113.10:443, the router only has an IP address to match on, so domain rules and geosite lists cannot apply. With sniffing = true (the inbound default), etemenanki reads the TLS SNI or HTTP Host from the client’s first bytes and hands that name to the router.

A tunnelling client sends nothing until it has seen the 200, so for an IP target with sniffing on, the order changes:

  1. The inbound answers 200 Connection established immediately, before routing or connecting.
  2. It reads the client’s first bytes until it finds a TLS SNI or HTTP Host, 4 KiB have arrived, or 300 ms have passed, whichever comes first.
  3. It routes on the sniffed name, if any, connects, and forwards the bytes it already read.

The sniffed name is used for routing only. The outbound still connects to the IP address the client asked for.

Plain (non-CONNECT) requests are never sniffed: the proxy already knows the host from the request itself.

A request such as GET http://example.com/path?q=1 HTTP/1.1 is an absolute-form request. The proxy forwards it to the origin itself instead of opening a tunnel:

  1. It routes and connects to the target host. If the URL has no port, it uses 80, or 443 for an https:// URL.
  2. It rewrites the request line to origin form: GET /path?q=1 HTTP/1.1. The version is always HTTP/1.1, whatever the client wrote.
  3. It sets Host to the URL’s authority.
  4. It removes the proxy and hop-by-hop headers: Proxy-Connection, Proxy-Authenticate, Proxy-Authorization, TE, Trailers, Transfer-Encoding, Upgrade, Connection and Keep-Alive, plus any header named in the client’s Connection header.
  5. It appends Connection: close and sends the rewritten head, followed by whatever request body the client sends.
  6. It relays the origin’s response back unchanged.

Only the first request on a client connection is served this way. After forwarding it, the proxy passes bytes through untouched, and the Connection: close it added makes the origin close the connection after its response. Clients then open a new proxy connection for the next request, which is what curl and browsers do when they see the connection close.

If the target cannot be reached, the proxy closes the connection without sending a response. curl reports this as an empty reply from the server.

Because Upgrade and Connection are removed, a WebSocket or any other protocol upgrade cannot work through plain forwarding. Clients that need one must use CONNECT, as browsers do. An https:// URL in a plain request only changes the default port: the request is still forwarded in plain text, so clients use CONNECT for HTTPS.

A request whose target is only a path, GET /index.html HTTP/1.1, is what a client sends to a web server, not to a proxy. By default the inbound answers it with 400 Bad Request and closes. The same happens to any other target that is not an http:// or https:// URL, for example OPTIONS * or an ftp:// URL.

Set allow_transparent = true to accept such requests. The proxy then takes the target from the Host header (port 80 if Host has none) and forwards the request as described above. This is useful when plain HTTP traffic reaches the proxy without the client knowing about it, for example through a firewall redirect. It only works for plain HTTP: TLS traffic redirected to this port is not an HTTP request and fails to parse. With allow_transparent = true, a request that has neither an absolute URL nor a Host header is dropped without a response.

[[inbound]]
tag = "http-transparent"
protocol = "http"
listen = "127.0.0.1"
port = 8080
[inbound.settings]
allow_transparent = true

When accounts has at least one entry, every request, CONNECT or plain, must carry a matching Proxy-Authorization: Basic header. A missing, malformed or wrong credential gets:

HTTP/1.1 407 Proxy Authentication Required
Proxy-Authenticate: Basic realm="proxy"
Connection: close

and the connection closes. Browsers respond by asking the user for a username and password.

Details worth knowing:

  • Only the Basic scheme is accepted, written exactly Basic or basic and followed by a space. BASIC or any other spelling is treated as a missing credential.
  • The decoded credential is split at its first :. A password may contain colons; a username may not.
  • Usernames and passwords are case-sensitive.
  • The proxy removes Proxy-Authorization from forwarded plain requests, so the origin never sees the credential.
  • Credentials are sent in clear text on a plain TCP listener. Put the inbound on TLS (see Transports) when clients reach it over an untrusted network.
Status Sent when Then
200 Connection established A CONNECT whose outbound connected, or immediately for a CONNECT to an IP target with sniffing on The tunnel relays bytes
400 Bad Request A plain request whose target is not an http:// or https:// URL (origin form such as /index.html) while allow_transparent = false Connection closes
407 Proxy Authentication Required accounts is set and the request has no matching Basic credential Connection closes
502 Bad Gateway A CONNECT whose outbound failed, when no 200 was sent yet Connection closes
No response Malformed request head, head over 64 KiB, more than 128 headers, a missing, bad or ambiguous target, a plain request whose target cannot be reached, or a head not finished within 10 seconds Connection closes

Responses to plain requests other than these come from the origin, not from the proxy.

Limit Value What happens
Request head size 64 KiB The connection closes with http head exceeds maximum size in the debug log
Headers per request 128 The request is treated as malformed and the connection closes
Time to send the request head 10 s The connection closes
Sniffing window 300 ms or 4 KiB The flow is routed with what was read

The idle timeout and the per-inbound connection limits apply to every protocol and are described on Limits. Connections that end with an error are logged at debug level as http connection from … ended: … with the reason.

The HTTP inbound can run on any stream transport, not only plain TCP:

[inbound.stream] Result
omitted, or network = "tcp" A plain HTTP proxy
network = "tls" An HTTPS proxy: the client speaks TLS to the proxy, then HTTP proxy requests inside it
network = "ws" or "grpc", optionally with security = "tls" The HTTP proxy protocol inside WebSocket or gRPC, useful between two etemenanki instances

A TLS transport needs tls.cert_file and tls.key_file; without them --test reports inbound https-proxy: tls stream needs tls.cert_file. When listen is a Unix socket path, the inbound serves plain HTTP only: a network other than "tcp" or a security other than "none" is rejected. See Transports for every option.

config.toml
[[inbound]]
tag = "https-proxy"
protocol = "http"
listen = "0.0.0.0"
port = 8443
[inbound.stream]
network = "tls"
[inbound.stream.tls]
cert_file = "/etc/etemenanki/proxy.example.com.crt"
key_file = "/etc/etemenanki/proxy.example.com.key"
[[inbound.settings.accounts]]
user = "alice"
pass = "replace-with-a-long-random-password"
[[inbound.settings.accounts]]
user = "bob"
pass = "replace-with-another-long-random-password"
[[outbound]]
tag = "direct"
protocol = "freedom"

The http outbound sends each TCP flow through an upstream HTTP proxy with one CONNECT request per flow. The upstream can be any HTTP/1.1 proxy that supports CONNECT, including another etemenanki-app http inbound.

server and port are the upstream proxy’s address and are both required; the other common outbound keys are on Outbounds. These keys go under [outbound.settings]:

KeyTypeRequiredDefaultDescription
userstringno—Username for the upstream proxy. When set, every CONNECT carries Proxy-Authorization: Basic with user:pass. When absent, no credential is sent.
passstringno""Password for the upstream proxy. Used only together with user: without user it is ignored, and user without pass sends an empty password.

A local SOCKS proxy that sends everything through an upstream HTTPS proxy, except private address ranges, which go direct:

config.toml
[[inbound]]
tag = "socks-in"
protocol = "socks"
listen = "127.0.0.1"
port = 1080
# The upstream carries TCP only, so do not offer UDP ASSOCIATE.
[inbound.settings]
udp = false
[[outbound]]
tag = "upstream"
protocol = "http"
server = "proxy.example.com"
port = 8443
[outbound.stream]
network = "tls"
[outbound.settings]
user = "alice"
pass = "replace-with-a-long-random-password"
[[outbound]]
tag = "direct"
protocol = "freedom"
[route]
default = "upstream"
[[route.rule]]
outbound = "direct"
cidr = ["10.0.0.0/8", "192.168.0.0/16"]

Drop the [outbound.stream] block for a plain HTTP upstream. With network = "tls", the TLS server name is tls.server_name if you set it and server otherwise, and the certificate is checked against the system roots unless you set tls.ca_file or tls.allow_insecure. See Transports.

For a flow to example.com:443, the outbound connects to the upstream and sends:

CONNECT example.com:443 HTTP/1.1
Host: example.com:443
Proxy-Authorization: Basic YWxpY2U6cmVwbGFjZS13aXRoLWEtbG9uZy1yYW5kb20tcGFzc3dvcmQ=
Proxy-Connection: Keep-Alive
  • The target is sent as the flow has it: a domain stays a domain and the upstream resolves it, an IPv6 address is bracketed.
  • Proxy-Authorization is present only when user is set.
  • The flow counts as connected only after the upstream answers 200. Any other status fails the flow with proxy responded with status 407 (or whichever code came back). The inbound then reports the failure to its own client, for example as 502 on an HTTP inbound.
  • After the 200, the outbound relays bytes unchanged.
  • The whole request must fit in 1024 bytes. Even with a 253-character domain that leaves room for about 300 bytes of user:pass. A request over the limit fails the flow with http: CONNECT request exceeds the codec's reserve.

The HTTP outbound carries TCP only. UDP packets routed to it are dropped, and the failed attempt is logged at debug level as udp fan-out: opening an outbound failed: http carries no datagrams. If some of your inbounds accept UDP (SOCKS, Trojan, VLESS, VMess, Hysteria 2, TUN), add a routing rule with network = "udp" that sends UDP to an outbound that can carry it. See Routing.

Message Cause Fix
inbound http-in: invalid settings: unknown field … A misspelled key under [inbound.settings] Use accounts and allow_transparent only
inbound http-in: invalid settings: missing field `pass` An accounts entry without pass (or without user) Give every entry both keys
outbound upstream: invalid settings: unknown field `password`, expected `user` or `pass` Xray-style key names in the outbound Rename to user and pass
outbound upstream: missing server / missing port The upstream address is incomplete Set both server and port
inbound https-proxy: tls stream needs tls.cert_file network = "tls" without a certificate Set tls.cert_file and tls.key_file
inbound http-in: protocol http over a unix socket does not support stream network "tls" A non-TCP stream network on a Unix socket listener Remove the [inbound.stream] block, or listen on an IP
security = "tls" is not valid with network = "tcp" TLS over plain TCP written the WebSocket way Use network = "tls"
Client gets 400 Bad Request The client sent an origin-form request, not a proxy request Configure the client to use the proxy, or set allow_transparent = true
Client gets 407 although it sends credentials Wrong password, a username containing :, or a non-Basic scheme Check the account; use Basic authentication
proxy responded with status … in the debug log The upstream refused the CONNECT Check the outbound’s user and pass and the upstream’s access rules
http carries no datagrams in the debug log UDP was routed to an http outbound Route network = "udp" elsewhere