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
CONNECTtunnels and plainhttp://requests from local clients; - the outbound is a
CONNECTclient, 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.
Minimal example
Section titled “Minimal example”A local proxy that requires a password and sends everything straight out:
[[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:
# 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.
Inbound
Section titled “Inbound”Settings
Section titled “Settings”These keys go under [inbound.settings]. The common inbound keys (tag, listen, port, sniffing, stream, address_family) are described on Inbounds.
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
accounts | array of tables | no | [] | 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[].user | string | yes | — | 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[].pass | string | yes | — | 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_transparent | bool | no | false | Accept 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`How a request is handled
Section titled “How a request is handled”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"]
CONNECT tunnels
Section titled “CONNECT tunnels”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 establishedand relays bytes both ways until either side closes. If the outbound cannot connect, the inbound answers502 Bad Gatewayand closes.
The one exception to “200 after connecting” is sniffing, below.
Sniffing IP targets
Section titled “Sniffing IP targets”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:
- The inbound answers
200 Connection establishedimmediately, before routing or connecting. - 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. - 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.
Plain HTTP requests
Section titled “Plain HTTP requests”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:
- It routes and connects to the target host. If the URL has no port, it uses 80, or 443 for an
https://URL. - It rewrites the request line to origin form:
GET /path?q=1 HTTP/1.1. The version is alwaysHTTP/1.1, whatever the client wrote. - It sets
Hostto the URL’s authority. - It removes the proxy and hop-by-hop headers:
Proxy-Connection,Proxy-Authenticate,Proxy-Authorization,TE,Trailers,Transfer-Encoding,Upgrade,ConnectionandKeep-Alive, plus any header named in the client’sConnectionheader. - It appends
Connection: closeand sends the rewritten head, followed by whatever request body the client sends. - 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.
Transparent (origin-form) requests
Section titled “Transparent (origin-form) requests”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 = trueAuthentication
Section titled “Authentication”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 RequiredProxy-Authenticate: Basic realm="proxy"Connection: closeand the connection closes. Browsers respond by asking the user for a username and password.
Details worth knowing:
- Only the
Basicscheme is accepted, written exactlyBasicorbasicand followed by a space.BASICor 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-Authorizationfrom 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.
Responses at a glance
Section titled “Responses at a glance”| 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.
Limits
Section titled “Limits”| 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.
Transports
Section titled “Transports”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.
[[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"# An https:// proxy URL makes curl speak TLS to the proxy itself.curl -x https://alice:replace-with-a-long-random-password@proxy.example.com:8443 https://example.com/
# With a private CA or a self-signed certificate on the proxy:curl --proxy-cacert /path/to/ca.pem \ -x https://alice:replace-with-a-long-random-password@proxy.example.com:8443 https://example.com/Outbound
Section titled “Outbound”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.
Settings
Section titled “Settings”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]:
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
user | string | no | — | Username for the upstream proxy. When set, every CONNECT carries Proxy-Authorization: Basic with user:pass. When absent, no credential is sent. |
pass | string | no | "" | Password for the upstream proxy. Used only together with user: without user it is ignored, and user without pass sends an empty password. |
Example
Section titled “Example”A local SOCKS proxy that sends everything through an upstream HTTPS proxy, except private address ranges, which go direct:
[[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.
What the outbound sends
Section titled “What the outbound sends”For a flow to example.com:443, the outbound connects to the upstream and sends:
CONNECT example.com:443 HTTP/1.1Host: example.com:443Proxy-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-Authorizationis present only whenuseris set.- The flow counts as connected only after the upstream answers
200. Any other status fails the flow withproxy responded with status 407(or whichever code came back). The inbound then reports the failure to its own client, for example as502on 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 withhttp: CONNECT request exceeds the codec's reserve.
No UDP
Section titled “No UDP”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.
Common errors
Section titled “Common errors”| 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 |