跳转到内容

Trojan

Trojan 是一种以密码认证、设计上运行在 TLS 之内的代理协议。客户端建立连接后,先发送密码的哈希,再发送要访问的地址,此后连接上承载的就是该目标的数据。etemenanki-app 在两个方向上都实现了它:trojan 入站接受 Trojan 客户端,trojan 出站经由 Trojan 服务器转发。两者都承载 TCP 和 UDP。

当你要搭建 Trojan 服务器、让 etemenanki-app 连接已有的 Trojan 服务器,或者把 Trojan 配置从 Xray 迁移过来时,请参考本页。实现移植自 Xray 的 proxy/trojan,线路格式相同,同一个密码在两边的效果也一样。

下面的服务端在所有网卡的 443 端口监听,用你的证书终结 TLS,接受两个用户,并把他们的流量直接发出。一条规则把发往私有、回环和链路本地地址的请求送到 blackhole。这条规则只匹配直接写明 IP 地址的请求:路由器在匹配 cidr 之前不会解析域名,所以对一个解析到私有地址的域名(例如 localhost)的请求仍会经 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",
]

对应的客户端在本地运行一个 SOCKS 代理,并把所有流量经服务端发出。server 是要拨号的地址;tls.server_name 是用来校验服务端证书的名称。当 server 本身就是这个名称时,可以省略 server_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"

启动前先用 etemenanki-app --test -c <file> 检查两个文件。生成密码可以运行 openssl rand -base64 32,直接使用其输出即可。

Trojan 入站接受通用的入站键(tag、protocol、listen、port、stream、sniffing 等,见入站),外加下面这个 [inbound.settings] 表:

键类型必填默认值说明
usersarray of tables否[]该入站接受的账户,每个用户一个表。列表为空或不写时 --test 仍能通过,但此后每个连接都会以 trojan: invalid user 被拒绝。没有顶层的 password 键;Xray 的 clients 和 fallbacks 键会作为未知字段导致配置失败。
users[].passwordstring是—用户的密钥。客户端发送 SHA224(password) 的小写十六进制值,服务端在用户中查找这个哈希。任何字符串都会被接受,包括空字符串,所以请使用足够长的随机值。如果两个用户密码相同,以排在后面的为准。flow 和 level 会作为未知字段被拒绝。
users[].emailstring否""用户的标签,作为用户名随该用户的每个流一起传递。它不会发送到线路上,也不必是电子邮件地址。etemenanki-app 中没有任何路由规则或流量计数器会读取它。katana 根据面板而不是这个表来构建用户,它会自行填写同一个标签,并用它把流量归属到对应的面板用户。

users 是一个表数组,两种 TOML 写法都可以:

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

etemenanki-app 在构建配置时对每个密码计算一次哈希。要增删用户,修改配置文件,由热重载生效即可。重载会替换整个 generation(一代实例),因此会断开所有进行中的连接,而不只是被修改用户的连接。

Trojan 出站需要通用的出站键 server 和 port,通常还需要一个 [outbound.stream] 块,见出站。它的 [outbound.settings] 表只有一个键:

键类型必填默认值说明
passwordstring是—服务端用来识别你的密码。出站在它打开的每个连接开头发送 SHA224(password) 的小写十六进制值。缺少它时出站无法构建,并报出指明 password 的 invalid settings: missing field 错误。这是唯一的键,写入任何其他键(例如 email)都会报错。

缺少 server 时报错 outbound trojan-out: missing server,缺少 port 时报错 outbound trojan-out: missing port。该出站没有多路复用选项:它承载的每个 TCP 流都会单独建立一条到服务端的连接。

Trojan 出站有可供探测的 TCP 地址,因此可以作为负载均衡器的成员。

Trojan 可以运行在任意一种流式传输层上。请从下面的 TLS 形态中选择一种:

network security 线路上的内容 额外的键
"tls" 不需要 TCP 上的 TLS。经典的 Trojan 部署方式,相当于 Xray 中的 network: "tcp" 加 security: "tls"。 无
"ws" "tls" TLS 内的 WebSocket ws.path(默认 "/")、ws.host
"grpc" "tls" TLS 内的 gRPC(HTTP/2) grpc.service_name(必填)
"tcp"(默认) 无 明文 TCP。路径上的任何人都能看到密码哈希。 无

在入站上,每种 TLS 形态都需要 tls.cert_file 和 tls.key_file。在 ws 和 grpc 下,只有 security = "tls" 才会启用 TLS:没有它时,[stream.tls] 表会被接受但被忽略,传输层以明文运行。在出站上,默认用系统根证书校验服务端证书。设置 tls.ca_file 可改为信任私有 CA,设置 tls.allow_insecure = true 则跳过校验;两者不能同时使用。传输层页面介绍了所有键。

[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"

当 listen 是以 / 开头的路径时,入站会监听 Unix socket,并在其上使用明文 Trojan,例如放在一个负责终结 TLS 的本地代理后面。此时不要写 port;配合 socket 路径写了 port 会报错(a unix socket listen has no port; remove port)。Unix socket 不承载传输层,因此 [inbound.stream] 中除 tcp 以外的任何 network,以及除 "none" 以外的任何 security,都会被拒绝:

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

Unix socket 不提供客户端地址,因此 source_cidr 规则永远不会匹配这些连接。

每个 Trojan 连接就是一个请求。客户端最先发送的字节是请求头;服务端不作任何应答,要么开始中继,要么关闭连接。

flowchart TB
  A["传输层已接受:TLS、ws 或 grpc"] --> B["读取请求头:哈希、命令、地址"]
  B --> C{"哈希匹配某个用户?"}
  C -- 否 --> X["关闭连接,不作应答"]
  C -- 是 --> D{"命令"}
  D -- "0x03 UDP" --> U["UDP 关联:每个数据包按自己的地址路由"]
  D -- "0x01 CONNECT" --> E{"地址是 v1.mux.cool?"}
  E -- 是 --> M["mux.cool 载体:每个子流分别路由"]
  E -- 否 --> T["TCP 流:先路由,再中继"]

请求头在线路上的格式:

字段 长度 内容
用户哈希 56 字节 SHA224(password) 的小写十六进制
CRLF 2 字节 \r\n
命令 1 字节 0x01 CONNECT,0x03 UDP ASSOCIATE
地址 可变 SOCKS5 编码:类型(0x01 IPv4,0x03 域名,0x04 IPv6)、地址、端口
CRLF 2 字节 \r\n

载荷紧跟在请求头之后;客户端发送载荷前不会等待任何应答。

etemenanki-app 没有 fallbacks 选项。Xray 可以把不是合法 Trojan 的连接转交给一个 Web 服务器,让探测者看到一个普通网站。而在这里,密码错误的连接会在请求头之后被结束;完全不是 Trojan 的字节,一旦服务端发现 56 字节哈希之后不是 CRLF,连接就会被结束。在 [inbound.settings] 中写 fallbacks 会作为未知字段导致配置失败。

使用 network = "ws" 时,WebSocket 层先于 Trojan 应答:请求其他路径的 WebSocket 升级请求,或者在设置了 ws.host 时 Host 不符的升级请求,会收到 HTTP 404。不是 WebSocket 升级的普通 HTTP 请求得不到任何页面,连接直接被关闭。etemenanki-app 无法在 Trojan 端口上提供网站。

CONNECT 请求会成为一个 TCP 流,由路由器按其地址、端口、网络、入站标签和客户端地址进行匹配。当地址是 IP 且 sniffing 开启(默认开启)时,服务端会先读取载荷开头,最多等待 300 毫秒,从中找出 TLS 服务器名或 HTTP Host,使域名规则依然能匹配。域名地址永远不会被嗅探。如果选中的出站无法建立连接,服务端会关闭客户端的连接;Trojan 没有错误应答。

客户端发送命令 0x03 来建立 UDP 关联。请求头中的地址不会被使用;之后的每个数据包都携带自己的地址:

字段 长度
地址 可变,SOCKS5 编码
长度 2 字节,大端序
CRLF 2 字节
载荷 Length 字节,最多 8192

入站始终提供 UDP 服务,没有关闭它的开关。每个数据包都按自己的目标地址路由,因此一个关联可以经由不同的出站到达多个对端,每个回复也会带上其来源地址封装后返回。UDP 流永远不会被嗅探。客户端关闭连接,或者双向都没有流量达 300 秒时,关联结束。

若要让 Trojan 入站只承载 TCP,可以把它的 UDP 路由到 blackhole。关联会保持打开,但所有数据包都会被丢弃。这条规则同样能捕获 mux 载体内的 UDP 子流(XUDP),因为它们也按 UDP 路由:

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

在客户端一侧,Trojan 出站同样承载 UDP。路由器发给它的每个 UDP 关联都会建立一条到服务端的连接,该关联的所有数据包共用这条连接。

Trojan 没有 mux 命令。进行多路复用的客户端(例如开启了 mux.enabled 的 Xray)改为向保留地址 v1.mux.cool 发送 CONNECT,服务端会把这条连接当作 mux.cool 载体处理。这一过程是自动的,没有相应设置。比较该地址时不区分大小写,请求中的端口会被忽略。

载体内的每个子流都按自己的目标地址路由,如同它是通过一条独立连接到达的一样,并沿用载体的用户。发往 IP 地址的子流只会根据其首帧携带的载荷进行嗅探;服务端不会为了等待更多数据而阻塞载体。UDP 子流(XUDP)在同一个载体上提供服务。一个载体同时最多容纳 256 个子流;超出时服务端会拒绝新的子流,并保持载体打开。

这只是服务端功能。etemenanki-app 自己的出站从不进行多路复用。

协议代码移植自 Xray 的 proxy/trojan,以下形态在项目测试中与真实的 Xray 二进制进行了验证:

形态 与 Xray 的测试情况
开启 mux 的 Xray 客户端,经 ws + TLS 连接 etemenanki-app 的 Trojan 入站 端到端:一条经 mux 载体的 TCP 流
tls、ws 和 grpc 传输层,带或不带 TLS,etemenanki-app 分别作为客户端和服务端 端到端,上层为 VLESS;Trojan 使用相同的传输层
mux 载体内的 XUDP 端到端,基于 VLESS 和 VMess 载体;Trojan 载体使用相同的解复用器
etemenanki-app 的 Trojan 出站连接 Xray 服务端 未与 Xray 测试。出站的客户端代码仅与项目自身的 Trojan 服务端代码进行了 TCP 和 UDP 测试。

迁移 Xray 配置:

Xray etemenanki-app
入站 settings.clients[].password、email [inbound.settings] users[].password、email
clients[].level、flow 不支持;未知字段
fallbacks 不支持;未知字段
出站 settings.servers[0].address、port [[outbound]] 表上的 server、port
出站 settings.servers[0].password [outbound.settings] password
streamSettings.network = "tcp" + security = "tls" [.stream] network = "tls"
出站 mux.enabled = true 不支持;出站为每个流单独建立一条连接

配置的其余部分见从 Xray 迁移。

限制项 值 超出后
传输层建立后发送第一个字节的时间 10 秒 关闭连接。
从第一个字节算起完成请求头的时间 10 秒 关闭连接。
已打开的流、关联或 mux 载体的空闲时间 300 秒 关闭连接。
客户端发送的每个 UDP 包的载荷 8192 字节 整个关联结束。
每个 mux 载体的子流数 256 拒绝新的子流;载体保持打开。

与所有流式入站一样,按入站设置的连接限制同样适用于 Trojan;见限制。

配置错误时,--test 会记录 configuration invalid:,启动时会记录 failed to start:,后面跟着下表中的消息;两者都以状态码 1 退出。这些消息中入站的标签为 trojan-in,出站为 trojan-out。用户条目内的错误会在消息之后多打印一行 in `users`。

消息 原因 解决方法
inbound trojan-in: invalid settings: missing field `password` 某个用户条目没有 password。 为每个条目设置 password。
inbound trojan-in: invalid settings: unknown field `fallbacks`, expected `users` 使用了 Xray 的键,例如 fallbacks 或 clients。 把 clients 改名为 users;删除 fallbacks。
inbound trojan-in: invalid settings: invalid type: string "…", expected struct TrojanUserCfg 写成了 users = ["password"],即字符串列表。 每个用户都是一个表:users = [{ password = "…" }]。
outbound trojan-out: invalid settings: missing field `password` 出站没有 [outbound.settings],或其中没有 password。 添加 password。
outbound trojan-out: invalid settings: unknown field `email`, expected `password` 在出站上写了入站才有的键。 出站只接受 password。
inbound trojan-in: tls stream needs tls.cert_file(或 tls.key_file) TLS 形态缺少证书或私钥。 设置 tls.cert_file 和 tls.key_file。
No such file or directory (os error 2) 证书或私钥路径不存在。消息中不会给出文件名。 检查 cert_file 和 key_file。
no certificate in PEM bundle cert_file 中不是 PEM 证书,常见的是误放了私钥。 让 cert_file 指向证书链。
inbound trojan-in: unknown stream security "tsl" (expected "tls" or "none") 入站或出站的 security 拼写错误。 写成 "tls"。
outbound trojan-out: grpc stream needs grpc.service_name 任意一侧使用了 network = "grpc" 但没有 grpc.service_name。 在客户端和服务端设置相同的 service_name。
outbound trojan-out: tls.allow_insecure and tls.ca_file cannot both be set 同一个出站上同时设置了两种校验覆盖选项。 只保留一个。

运行时,被拒绝的连接只在 debug 级别记录,每个连接一行:

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

设置 [log] level = "debug" 才能看到这些日志。ended: 之后可能出现的其他原因:

原因 含义
proxy core: trojan: not trojan protocol (missing CRLF after hash) 客户端说的不是 Trojan,或者对明文 TCP 入站套了 TLS 再说 Trojan。
proxy core: client did not complete its request in time 客户端发送了部分请求头,但在第一个字节后 10 秒内没有发完。
inbound handshake timed out after 10s 客户端连接后 10 秒内没有发送任何 Trojan 字节。
proxy core: trojan: oversize payload 某个 UDP 包超过 8192 字节。

TLS、WebSocket 或 gRPC 握手失败的客户端根本到不了 Trojan 层;这种情况会记录为 inbound transport failed:,同样在 debug 级别。