Skip to content

Trojan

Trojan is a password-authenticated proxy protocol that is meant to run inside TLS. A client opens a connection, sends a hash of its password followed by the address it wants, and from then on the connection carries that target’s bytes. etemenanki-app speaks it in both directions: a trojan inbound accepts Trojan clients, and a trojan outbound connects through a Trojan server. Both carry TCP and UDP.

Use this page when you set up a Trojan server, point etemenanki-app at an existing one, or move a Trojan setup over from Xray. The implementation is a port of Xray’s proxy/trojan, so the wire format is the same, and a password works the same way on either side.

The server below listens on every interface on port 443, terminates TLS with your certificate, accepts two users and sends their traffic straight out. A rule sends requests for private, loopback and link-local addresses to a blackhole. That rule matches only requests that name an IP address: the router does not resolve a domain before it matches cidr, so a request for a name that resolves to a private address, such as localhost, still goes out through direct.

server.toml
# A Trojan server over TLS on port 443, with two users and a direct exit.
# Replace the certificate paths and the passwords before you use it.
[log]
level = "info"
[[inbound]]
tag = "trojan-in"
protocol = "trojan"
listen = "0.0.0.0"
port = 443
# Trojan sends the password hash in the clear: always put TLS under it.
[inbound.stream]
network = "tls"
[inbound.stream.tls]
cert_file = "/etc/etemenanki/fullchain.pem"
key_file = "/etc/etemenanki/privkey.pem"
[inbound.settings]
users = [
{ password = "replace-with-a-long-random-password", email = "alice@example.com" },
{ password = "replace-with-another-long-random-password", email = "bob@example.com" },
]
[[outbound]]
tag = "direct"
protocol = "freedom"
[[outbound]]
tag = "block"
protocol = "blackhole"
[route]
default = "direct"
# Refuse requests for private, loopback and link-local addresses. This rule
# only sees requests that name an IP: a domain is not resolved before routing.
[[route.rule]]
outbound = "block"
cidr = [
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "169.254.0.0/16",
"fc00::/7", "fe80::/10", "::1/128",
]

The matching client runs a local SOCKS proxy and sends everything through the server. server is the address to dial; tls.server_name is the name the server’s certificate is checked against. You can leave server_name out when server is already that name.

client.toml
[[inbound]]
tag = "socks-in"
protocol = "socks"
listen = "127.0.0.1"
port = 1080
[[outbound]]
tag = "trojan-out"
protocol = "trojan"
server = "203.0.113.10"
port = 443
[outbound.stream]
network = "tls"
[outbound.stream.tls]
server_name = "proxy.example.com"
[outbound.settings]
password = "replace-with-a-long-random-password"
[route]
default = "trojan-out"

Check both files with etemenanki-app --test -c <file> before you start them. To generate a password, run openssl rand -base64 32 and use the output as is.

A Trojan inbound takes the common inbound keys (tag, protocol, listen, port, stream, sniffing and the rest), described in Inbounds, plus this [inbound.settings] table:

KeyTypeRequiredDefaultDescription
usersarray of tablesno[]The accounts this inbound accepts, one table per user. An empty or missing list still passes --test, but then every connection is refused with trojan: invalid user. There is no top-level password key, and Xray's clients and fallbacks keys fail the config as unknown fields.
users[].passwordstringyes—The user's secret. The client sends the lowercase hex of SHA224(password), and the server looks that hash up among its users. Any string is accepted, including an empty one, so use a long random value. If two users share a password, the one listed last wins. flow and level are rejected as unknown fields.
users[].emailstringno""A label for the user, carried with each of the user's flows as its user name. It never goes on the wire and need not be an e-mail address. etemenanki-app has no route rule and no traffic counter that reads it. katana, which builds its users from the panel instead of from this table, fills the same label itself and uses it to attribute traffic to panel users.

users is an array of tables. Both TOML spellings work:

[inbound.settings]
users = [
{ password = "replace-with-a-long-random-password", email = "alice@example.com" },
{ password = "replace-with-another-long-random-password" },
]

etemenanki-app hashes every password once, when the config is built. To add or remove a user, edit the file and let hot reload pick it up. A reload replaces the whole generation, so it drops every connection in flight, not only those of the user you changed.

A Trojan outbound needs the common outbound keys server and port, and usually a [outbound.stream] block; see Outbounds. Its [outbound.settings] table has one key:

KeyTypeRequiredDefaultDescription
passwordstringyes—The password the server knows you by. The outbound sends the lowercase hex of SHA224(password) at the start of every connection it opens. Without it the outbound fails to build with an invalid settings: missing field error that names password. It is the only key: any other, such as email, is an error.

A missing server fails with outbound trojan-out: missing server, and a missing port with outbound trojan-out: missing port. The outbound has no multiplexing option: every TCP flow it carries opens its own connection to the server.

A Trojan outbound has a TCP address to probe, so it can be a member of a balancer.

Trojan runs over any of the stream transports. Pick one of the TLS shapes:

network security What goes on the wire Extra keys
"tls" not needed TLS over TCP. The classic Trojan setup, and what Xray calls network: "tcp" with security: "tls". none
"ws" "tls" WebSocket inside TLS ws.path (default "/"), ws.host
"grpc" "tls" gRPC (HTTP/2) inside TLS grpc.service_name (required)
"tcp" (default) none Plain TCP. The password hash is visible to anyone on the path. none

On an inbound, every TLS shape needs tls.cert_file and tls.key_file. Under ws and grpc, only security = "tls" turns TLS on: a [stream.tls] table without it is accepted and ignored, and the transport runs in plaintext. On an outbound, the certificate is checked against the system roots by default. Set tls.ca_file to trust a private CA instead, or tls.allow_insecure = true to skip the check; the two cannot be combined. The Transports page covers every key.

[inbound.stream]
network = "ws"
security = "tls"
[inbound.stream.ws]
path = "/trojan"
[inbound.stream.tls]
cert_file = "/etc/etemenanki/fullchain.pem"
key_file = "/etc/etemenanki/privkey.pem"

When listen is a path starting with /, the inbound listens on a Unix socket and speaks plain Trojan on it, for example behind a local proxy that terminates TLS. Leave port out; with a socket path it is an error (a unix socket listen has no port; remove port). A Unix socket carries no transport, so any [inbound.stream] network other than tcp, and any security other than "none", is refused:

inbound trojan-in: protocol trojan over a unix socket does not support stream network "tls"

A Unix socket gives no client address, so source_cidr rules never match these connections.

Every Trojan connection is one request. The client’s first bytes are the header; the server answers nothing, and either relays or closes.

flowchart TB
  A["Transport accepted: TLS, ws or grpc"] --> B["Read the header: hash, command, address"]
  B --> C{"Hash matches a user?"}
  C -- no --> X["Close the connection, no reply"]
  C -- yes --> D{"Command"}
  D -- "0x03 UDP" --> U["UDP association: each packet routed on its own address"]
  D -- "0x01 CONNECT" --> E{"Address is v1.mux.cool?"}
  E -- yes --> M["mux.cool carrier: each sub-flow routed separately"]
  E -- no --> T["TCP flow: routed, then relayed"]

The header on the wire:

Field Size Content
User hash 56 bytes Lowercase hex of SHA224(password)
CRLF 2 bytes \r\n
Command 1 byte 0x01 CONNECT, 0x03 UDP ASSOCIATE
Address variable SOCKS5 encoding: type (0x01 IPv4, 0x03 domain, 0x04 IPv6), address, port
CRLF 2 bytes \r\n

The payload follows the header directly; the client does not wait for an answer before sending it.

etemenanki-app has no fallbacks option. Xray can hand a connection that is not valid Trojan to a web server, so that a probe sees an ordinary site. Here, a wrong password ends the connection after the header, and bytes that are not Trojan at all end it as soon as the server sees that the 56 hash bytes are not followed by CRLF. fallbacks in [inbound.settings] fails the config as an unknown field.

With network = "ws", the WebSocket layer answers before Trojan does: a WebSocket upgrade request for another path, or for another Host when ws.host is set, gets an HTTP 404. An ordinary HTTP request that is not a WebSocket upgrade gets no page; the connection is closed. etemenanki-app cannot serve a website on a Trojan port.

A CONNECT request becomes a TCP flow that the router matches on its address, port, network, inbound tag and client address. When the address is an IP and sniffing is on (the default), the server first reads the start of the payload, for up to 300 milliseconds, to find a TLS server name or an HTTP Host, so domain rules still match. A domain address is never sniffed. If the chosen outbound cannot connect, the server closes the client’s connection; Trojan has no error reply.

A client starts a UDP association by sending command 0x03. The address in the header is not used; each packet that follows carries its own:

Field Size
Address variable, SOCKS5 encoding
Length 2 bytes, big-endian
CRLF 2 bytes
Payload Length bytes, at most 8192

The inbound always serves UDP; there is no switch to turn it off. Every packet is routed on its own destination, so one association can reach several peers through different outbounds, and each reply goes back framed with the address it came from. UDP flows are never sniffed. The association ends when the client closes the connection, or after 300 seconds with no traffic in either direction.

To keep a Trojan inbound TCP-only, route its UDP to a blackhole. The association stays open and every packet is dropped. The rule also catches UDP sub-flows inside a mux carrier (XUDP), since they are routed as UDP too:

[[route.rule]]
outbound = "block"
inbound_tag = ["trojan-in"]
network = "udp"

On the client side, a Trojan outbound carries UDP too. Each UDP association the router sends to it opens one connection to the server, and all of that association’s packets share it.

Trojan has no mux command. A client that multiplexes, such as Xray with mux.enabled, instead sends a CONNECT to the reserved address v1.mux.cool, and the server treats that connection as a mux.cool carrier. This is automatic and has no setting. The address is compared without regard to case, and the port in that request is ignored.

Each sub-flow inside the carrier is routed on its own destination, as if it had arrived on a connection of its own, with the carrier’s user. A sub-flow to an IP address is sniffed only from the payload its opening frame carries; the server does not hold the carrier to wait for more. UDP sub-flows (XUDP) are served on the same carrier. One carrier holds at most 256 sub-flows at a time; the server declines a new sub-flow beyond that and keeps the carrier open.

This is server-side only. etemenanki-app’s own outbounds never multiplex.

The protocol code is a port of Xray’s proxy/trojan, and these shapes are exercised against a real Xray binary in the project’s tests:

Shape Tested against Xray
Xray client with mux on, over ws + TLS, to an etemenanki-app Trojan inbound End to end: a TCP stream through the mux carrier
The tls, ws and grpc transports, with and without TLS, with etemenanki-app as client and as server End to end, with VLESS on top; Trojan uses the same transports
XUDP inside a mux carrier End to end, over VLESS and VMess carriers; the Trojan carrier uses the same demultiplexer
etemenanki-app Trojan outbound to an Xray server Not tested against Xray. The outbound’s client code is tested only against the project’s own Trojan server code, for TCP and UDP.

Moving an Xray config over:

Xray etemenanki-app
inbound settings.clients[].password, email [inbound.settings] users[].password, email
clients[].level, flow not supported; unknown field
fallbacks not supported; unknown field
outbound settings.servers[0].address, port server, port on the [[outbound]] table
outbound settings.servers[0].password [outbound.settings] password
streamSettings.network = "tcp" + security = "tls" [.stream] network = "tls"
outbound mux.enabled = true not supported; the outbound opens one connection per flow

Coming from Xray covers the rest of the config.

Limit Value What happens past it
Time to send the first byte after the transport is up 10 seconds The connection closes.
Time to finish the header, counted from its first byte 10 seconds The connection closes.
Idle time on an open flow, association or mux carrier 300 seconds The connection closes.
UDP payload per packet sent by the client 8192 bytes The whole association ends.
Sub-flows per mux carrier 256 The new sub-flow is declined; the carrier stays open.

Per-inbound connection limits apply to Trojan like every stream inbound; see Limits.

A configuration error makes --test log configuration invalid: and a start log failed to start:, followed by the message below; both exit with status 1. The inbound in these messages is tagged trojan-in and the outbound trojan-out. An error inside a user entry prints a second line, in `users`, after the message.

Message Cause Fix
inbound trojan-in: invalid settings: missing field `password` A user entry has no password. Give every entry a password.
inbound trojan-in: invalid settings: unknown field `fallbacks`, expected `users` An Xray key, such as fallbacks or clients. Rename clients to users; remove fallbacks.
inbound trojan-in: invalid settings: invalid type: string "…", expected struct TrojanUserCfg users = ["password"]: a list of strings. Each user is a table: users = [{ password = "…" }].
outbound trojan-out: invalid settings: missing field `password` The outbound has no [outbound.settings] or no password in it. Add password.
outbound trojan-out: invalid settings: unknown field `email`, expected `password` An inbound-style key on the outbound. The outbound takes password only.
inbound trojan-in: tls stream needs tls.cert_file (or tls.key_file) A TLS shape without a certificate or key. Set tls.cert_file and tls.key_file.
No such file or directory (os error 2) A certificate or key path does not exist. The message does not name the file. Check cert_file and key_file.
no certificate in PEM bundle cert_file holds something other than a PEM certificate, often the key. Point cert_file at the certificate chain.
inbound trojan-in: unknown stream security "tsl" (expected "tls" or "none") A typo in security, on an inbound or an outbound. Write "tls".
outbound trojan-out: grpc stream needs grpc.service_name network = "grpc" without grpc.service_name, on either side. Set the same service_name on client and server.
outbound trojan-out: tls.allow_insecure and tls.ca_file cannot both be set Both verification overrides on one outbound. Keep one.

At run time, a refused connection is logged only at debug level, as one line per connection:

trojan connection from Some(198.51.100.7) ended: proxy core: trojan: invalid user

Set [log] level = "debug" to see these. Other reasons you may find after ended::

Reason Meaning
proxy core: trojan: not trojan protocol (missing CRLF after hash) The client does not speak Trojan, or speaks it inside TLS to a plain-TCP inbound.
proxy core: client did not complete its request in time The client sent part of the header and did not finish it within 10 seconds of its first byte.
inbound handshake timed out after 10s The client sent no Trojan bytes at all for 10 seconds after connecting.
proxy core: trojan: oversize payload A UDP packet over 8192 bytes.

A client that fails the TLS, WebSocket or gRPC handshake never reaches Trojan; that shows up as inbound transport failed:, also at debug level.