Trojan
源码文件:27 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/src/trojan/mod.rsEtemenanki/protocols/src/trojan/protocol.rsEtemenanki/protocols/src/trojan/users.rsEtemenanki/protocols/src/trojan/core.rsEtemenanki/protocols/src/trojan/codec.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/mux/mod.rsEtemenanki/protocols/src/mux/demux.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/protocols/src/helpers/crypto.rsEtemenanki/protocols/src/helpers/address.rsEtemenanki/protocols/src/flow.rsEtemenanki/protocols/src/mux/frame.rsEtemenanki/concepts/src/client.rsEtemenanki/protocols/tests/unit/trojan/protocol.rsEtemenanki/protocols/tests/unit/trojan/core.rsEtemenanki/protocols/tests/unit/trojan/codec.rsEtemenanki/protocols/tests/unit/mux/demux.rsEtemenanki/protocols/tests/pipeline/trojan.rsEtemenanki/app/src/config.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/connector.rsEtemenanki/app/src/serve.rsEtemenanki/app/tests/integration/e2e_xray_mux.rskatana/src/inbound.rskatana/src/serve.rs
Trojan 是一个小巧的带认证代理协议。客户端只发送一个请求头,其中包含密码哈希、命令和目标地址。此后,连接上承载的要么是目标的字节流,要么是一串自带地址的 UDP 帧。服务端从不应答这个请求头:要么中继,要么关闭。
本页介绍 protocols/src/trojan/ 模块:protocol.rs 中的线格式原语、users.rs 中的用户表、core.rs 中的服务端协议核心 TrojanCore,以及 codec.rs 中的两个客户端 codec。本页面向修改这部分代码、或在其上构建新入站或出站的贡献者。这份代码移植自 Xray 的 proxy/trojan,线格式与之逐字节一致。
| 组件 | 负责 | 交给其他部分 |
|---|---|---|
protocol.rs |
编码和解析请求头与 UDP 包帧,既支持切片(sans-I/O),也支持异步流。计算用户密钥 hex(SHA224(password))。 |
地址编码,即 protocols/src/helpers/address.rs 中的 AddressCodec::SOCKS。 |
users.rs → Validator |
在构造时对每个密码只哈希一次,并把线上的哈希映射到用户,与每个条目都做常数时间比较。 | 添加或移除用户。用户变化时会构建新的 Validator。 |
core.rs → TrojanCore |
以状态机运行一条连接:请求头、可选的嗅探,然后进入 TCP 中继、UDP 关联或 mux.cool 承载连接。 | I/O、拨号和路由。由运行时及其 connector(连接器)执行协议核心发出的 effect。 |
codec.rs → TrojanStream、TrojanDatagram |
在通往上游服务器的流上,实现 TCP 流和 UDP 关联的客户端。 | 拨号到上游和 TLS。这些由客户端运行时和传输层负责。 |
Trojan 本身不做任何加密。机密性完全来自其下的传输层(TLS,或 TLS 之内的 WebSocket、gRPC);参见 TCP 与 TLS 传输。
所有多字节整数均为大端序。ADDR 是 AddressCodec::SOCKS,即端口在最后的 SOCKS5 地址布局。
客户端在连接开始时发送一次请求头,随后立即发送负载,不等待任何应答。
| 字段 | 大小 | 含义 |
|---|---|---|
| 用户密钥 | 56(HASH_LEN) |
SHA224(password) 的小写十六进制,以 ASCII 表示 |
CRLF |
2 | \r\n |
| 命令 | 1 | 0x01 CMD_TCP_CONNECT,0x03 CMD_UDP_ASSOCIATE |
| 地址 | 1 + 4、1 + 16 或 2 + n | 见下文 |
| 端口 | 2 | 目标端口 |
CRLF |
2 | \r\n |
| 负载 | 流的剩余部分 | 目标字节(CONNECT)或 UDP 包帧(ASSOCIATE) |
请求头最长为 REQUEST_HEADER_MAX = 56 + 2 + 1 + 259 + 2 = 320 字节,其中 259 是 AddressCodec::MAX_LEN(类型字节、长度字节、255 字节的域名和端口)。
用户密钥由 password_hash 生成。单元测试固定了密码 password 对应的值:
d63dc919e201d7bc4c825630d2cf25fdc93d4b2f0d46706d29038d01| 类型字节 | 地址字节 | 说明 |
|---|---|---|
0x01 |
4 | IPv4 |
0x03 |
1 个长度字节,随后是相应数量的字节 | 域名。必须是非空 UTF-8。以数字或 [ 开头且能解析为 IP 地址的字符串会折叠为 Remote::IpAddr;其他字符串只能包含 ASCII 字母、数字、-、. 和 _,否则以 invalid domain name: … 失败。 |
0x04 |
16 | IPv6 |
其他任何类型字节都以 unknown address type: <n>(InvalidData)失败。
UDP 包帧
Section titled “UDP 包帧”在 CMD_UDP_ASSOCIATE 请求头之后,请求头中的地址会被忽略。两个方向上的每个包都单独成帧:
| 字段 | 大小 | 含义 |
|---|---|---|
| 地址 | 可变 | ADDR 编码。上行:包的目标。下行:包来自的对端。 |
| 端口 | 2 | 目标端口或源端口 |
| 长度 | 2 | 负载长度,至多 MAX_LENGTH = 8192 |
CRLF |
2 | \r\n |
| 负载 | 长度 | 一个数据报 |
帧头至多 PACKET_HEADER_MAX = 259 + 2 + 2 = 263 字节,因此整个帧至多 263 + 8192 = 8455 字节,总能放进协议核心 16 KiB 的读缓冲区。
线格式原语(protocol.rs)
Section titled “线格式原语(protocol.rs)”pub const ADDR: AddressCodec = AddressCodec::SOCKS;pub const CRLF: [u8; 2] = [b'\r', b'\n'];pub const CMD_TCP_CONNECT: u8 = 0x01;pub const CMD_UDP_ASSOCIATE: u8 = 0x03;pub const HASH_LEN: usize = 56;pub const MAX_LENGTH: usize = 8192;pub const PACKET_HEADER_MAX: usize = AddressCodec::MAX_LEN + 2 + 2;pub const REQUEST_HEADER_MAX: usize = HASH_LEN + 2 + 1 + AddressCodec::MAX_LEN + 2;
pub fn password_hash(password: &str) -> [u8; HASH_LEN];pub fn command_for(network: DialNetwork) -> u8;
pub struct RequestHeader { pub hash: [u8; HASH_LEN], pub command: u8, pub destination: Destination,}
pub fn encode_request_header( hash: &[u8; HASH_LEN], command: u8, remote: &Remote, port: u16,) -> BytesMut;pub fn parse_request_header(buf: &[u8]) -> io::Result<Option<(RequestHeader, usize)>>;
pub struct Packet { pub dest: Destination, pub payload: Range<usize>, pub consumed: usize,}
pub fn packet_header(dest: &Destination, len: usize) -> BytesMut;pub fn packet_into(dest: &Destination, payload: &[u8], out: &mut Staging<'_>) -> Option<()>;pub fn parse_packet(buf: &[u8]) -> io::Result<Option<Packet>>;切片解析器遵循同一约定:Ok(None) 表示“缓冲区里只有请求头或帧的一部分,请继续送数据”;Ok(Some(..)) 返回解析出的条目及其占用的字节数;Err 表示这些字节永远不可能变得合法。parse_packet 以指向输入的 Range 返回负载,因此协议核心无需复制即可转发。
parse_request_header 返回的哈希未经校验。它根据命令设置目标的网络类型:CMD_TCP_CONNECT 对应 DialNetwork::Tcp,CMD_UDP_ASSOCIATE 对应 DialNetwork::Udp。由调用方 TrojanCore 用其用户表检查哈希。
packet_into 向 Staging 区域写入一个完整的帧,要么什么都不写。空间不足或负载长于 u16::MAX 时,它返回 None。
该模块还有异步流版本,对应 Xray 的 ConnReader 和 PacketReader:
pub async fn write_request_header<W: AsyncWrite + Unpin>( w: &mut W, hash: &[u8; HASH_LEN], command: u8, remote: &Remote, port: u16,) -> io::Result<()>;pub async fn read_request_header<R: AsyncRead + Unpin>(r: &mut R) -> io::Result<RequestHeader>;pub async fn write_packet<W: AsyncWrite + Unpin>( w: &mut W, remote: &Remote, port: u16, payload: &[u8],) -> io::Result<()>;pub async fn read_packet<R: AsyncRead + Unpin>(r: &mut R) -> io::Result<(Destination, Bytes)>;只有 protocols/tests/unit/trojan/protocol.rs 中的单元测试使用它们,作为与切片编码器对照的参考往返。协议核心和 codec 都使用切片函数。
用户与 Validator(users.rs)
Section titled “用户与 Validator(users.rs)”pub struct TrojanUser { pub password: CompactString, pub email: CompactString,}
pub struct TrojanServerConfig<T> { pub users: Vec<(TrojanUser, Arc<T>)>,}
pub struct Validator<T> { users: Vec<UserEntry<T>>,}
pub struct UserEntry<T> { pub hash: [u8; HASH_LEN], pub email: CompactString, pub data: Arc<T>,}
impl<T> Validator<T> { pub fn new(users: &[(TrojanUser, Arc<T>)]) -> Self; pub fn get(&self, hash: &[u8]) -> Option<&UserEntry<T>>;}T 是随用户带入路由的数据。etemenanki-app 使用 ()。katana 使用它的 UserTag,其 Trojan 节点构建每个 TrojanUser 时,以面板用户的 UUID 作为密码(XrayR 的惯例),以流量标签作为 email。
Validator::new 为每个用户只计算一次 password_hash,不保留明文密码。
Validator::get 使用 protocols/src/helpers/crypto.rs 中的 ct_eq,把线上的哈希与每个条目逐一比较:
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool;ct_eq 先检查长度,再调用 subtle::ConstantTimeEq,因此与已存储密钥共享长前缀的密钥,和完全不共享前缀的密钥,比较开销相同。循环中没有提前返回:无论结果如何,都会访问每一个条目。如果两个用户的密码相同,靠后的条目胜出。查找是线性扫描,开销随用户数增长。
TrojanServerConfig 只是用户列表的简单容器。pipeline 测试用它构建 Validator;app 和 katana 则直接构建用户列表。
TrojanCore(core.rs)
Section titled “TrojanCore(core.rs)”pub struct TrojanCore<T> { validator: Arc<Validator<T>>, sniff: bool, source: Option<IpAddr>, timing: Timing, prefix: SniffPrefix, state: State<T>,}
impl<T> TrojanCore<T> { pub const BUF_SIZE: usize = 16 * 1024; pub fn new(validator: Arc<Validator<T>>, sniff: bool, source: Option<IpAddr>) -> Self; pub fn is_established(&self) -> bool;}
impl<T: Send + Sync + 'static> ProxyCoreDecode for TrojanCore<T> { type Key = FlowKey; type Target = Flow<T>; type Error = io::Error; type TransportAddr = ();
const STAGING_RESERVE: usize = PACKET_HEADER_MAX.next_multiple_of(16) + downlink_overhead(Self::BUF_SIZE); const MAX_DATAGRAM: usize = super::protocol::MAX_LENGTH;
fn handle( &mut self, event: Event<'_, Self>, fx: &mut Effects<'_, Self>, ) -> Result<usize, io::Error>; fn held(&self) -> &[u8];}协议核心实现了 etemenanki-concepts 中的 ProxyCoreDecode;该 trait 及其 event 和 effect 词汇见 服务端协议核心。它使用 protocols/src/core/mod.rs 中的以下共享辅助类型:
| 辅助类型 | 在 TrojanCore 中的作用 |
|---|---|
Timing |
唯一的截止时间,按 Phase 设置:握手、嗅探窗口、中继空闲。 |
SniffPrefix |
在 CONNECT 目标为 IP 时暂存最初的负载字节,供 TLS 和 HTTP 嗅探器寻找域名。 |
Passthrough<FlowKey> |
TCP 中继的半关闭状态记录。 |
FlowKey |
Direct 表示连接自身的流,Sub(SubKey) 表示 mux 子流。 |
staging_full、handshake_timed_out |
两个协议核心级别的错误。 |
sniff 来自入站的嗅探设置。source 是客户端 IP(入站知道时才有),会写入每个 Flow 供路由使用。
私有的状态枚举是协议核心的中枢:
enum State<T> { Handshake, Sniff(Flow<T>), Tcp(Passthrough<FlowKey>), Udp { flow: Flow<T>, opened: bool }, Mux(Demux<T>), Done,}协议核心附加到流上的身份由私有的 user 方法构建:
NetworkUser { authorization: UserAuthorization::UsernamePassword { username: CompactString::from(email), password: CompactString::default(), }, user_data: data,}username 是用户的 email,可以为空。密码字段始终为空,因此不会有任何凭据进入路由、日志或 connector。
客户端 codec(codec.rs)
Section titled “客户端 codec(codec.rs)”pub struct TrojanStream { header: BytesMut,}impl TrojanStream { pub fn new(hash: &[u8; HASH_LEN], dest: &Destination) -> Self;}impl ProxyCoreEncodeHandshake for TrojanStream { type Target = Destination; type Error = io::Error; const STAGING_RESERVE: usize = RESERVE; // REQUEST_HEADER_MAX fn start(&mut self, out: &mut Staging<'_>) -> io::Result<Handshake>; fn reply(&mut self, _: &mut [u8], _: &mut Staging<'_>) -> io::Result<Reply>; fn finish(&mut self, _: &mut Staging<'_>) -> io::Result<()>;}impl ProxyCoreEncode for TrojanStream { fn seal(&mut self, plain: &[u8], out: &mut Staging<'_>) -> io::Result<usize>; fn open(&mut self, wire: &mut [u8]) -> io::Result<Opened>;}
pub struct TrojanDatagram { header: BytesMut,}impl TrojanDatagram { pub fn new(hash: &[u8; HASH_LEN]) -> Self;}impl ProxyCoreEncodeDatagram for TrojanDatagram { fn seal_to( &mut self, plain: &[u8], to: &Destination, out: &mut Staging<'_>, ) -> io::Result<Option<()>>; fn open_from(&mut self, wire: &mut [u8]) -> io::Result<OpenedFrom>;}TrojanDatagram 也以与 TrojanStream 相同的方式实现了 ProxyCoreEncodeHandshake。两个 codec 接收的都是用户密钥而非密码,因此调用方只需哈希一次。app 的 Trojan 出站在构建配置时调用 password_hash,并把密钥移入为每个流创建 codec 的闭包。
| 方法 | TrojanStream |
TrojanDatagram |
|---|---|---|
new |
为 dest 编码一个 CMD_TCP_CONNECT 请求头 |
与 Xray 一样,以占位地址 0.0.0.0:0 编码一个 CMD_UDP_ASSOCIATE 请求头 |
start |
暂存请求头并将其清空,返回 Handshake::Done |
同左 |
reply |
总是 Err:trojan: the server sends no handshake reply |
同左 |
finish |
什么都不暂存:由线路自身的 EOF 表达半关闭 | 同左 |
seal / seal_to |
原样复制明文 | 用 packet_into 暂存一个包帧,或返回 None |
open / open_from |
把整个切片作为一帧返回;切片为空时返回 NeedMore |
用 parse_packet 解析一帧,并把其来源作为对端返回 |
由于 start 返回 Handshake::Done,客户端运行时从不调用 reply。那个 Err 是为防备会调用它的运行时而设的。
服务端状态机
Section titled “服务端状态机”stateDiagram-v2 [*] --> Handshake Handshake --> Udp: 命令 0x03 Handshake --> Mux: CONNECT 到 v1.mux.cool Handshake --> Sniff: CONNECT 到 IP,开启嗅探 Handshake --> Tcp: 其他 CONNECT Sniff --> Tcp: 找到、耗尽、截止或 EOF Handshake --> Done: 传输层 EOF Udp --> Done: EOF 或出站结束 Mux --> Done: 传输层 EOF Tcp --> [*]: 两个方向均关闭或出站结束 Done --> [*]
离开 Handshake 的每条箭头,都只在整个请求头解析完毕、且 Validator::get 返回了用户之后才会发生。未知用户、格式错误的请求头或握手截止时间到期时,handle 改为返回 Err,由运行时结束连接。这些情况没有对应的状态转移。
| 状态 | 进入时的 Timing 阶段 |
截止时间 |
|---|---|---|
Handshake |
Phase::Handshake,由第一个 Event::Transport 设置 |
HANDSHAKE_TIMEOUT,10 秒 |
Sniff |
Phase::Sniff |
SNIFF_TIMEOUT,300 毫秒 |
Tcp、Udp、Mux |
Phase::Relay |
RELAY_IDLE_TIMEOUT,300 秒,每个字节事件都会重新设置 |
is_established() 在进入 Tcp、Udp 或 Mux 时变为 true,在 Sniff 中不会。因此 UDP 关联在请求头解析完成后即视为已建立,此时可能还没有收到任何包。
State::Handshake 中的 on_transport 依次执行:
- 对读缓冲区中未解析的部分调用
parse_request_header。遇到Ok(None)时返回Ok(0):不消费任何字节,由运行时继续读取。 - 调用
self.validator.get(&header.hash)。没有匹配时返回PermissionDenied,trojan: invalid user。 - 构建
Flow::new(header.destination, user, self.source)。 - 按命令和目标分支,顺序为:
CMD_UDP_ASSOCIATE,然后crate::mux::is_mux_destination,然后self.sniff && worth_sniffing(..),最后是普通 CONNECT。 - 返回
Ok(used),即只有请求头的长度。同一次读取中的负载字节留在缓冲区里,运行时会带着它们再次调用handle,此时已处于新状态。
TCP CONNECT
Section titled “TCP CONNECT”sequenceDiagram participant C as 客户端 participant R as 服务端运行时 participant K as TrojanCore participant O as 出站 C->>R: 请求头 + 首段负载 R->>K: Event::Transport K-->>R: Open Direct, SetDeadline 300 s R->>O: connector 拨号到路由选中的出站 R->>K: Event::Transport(负载) K-->>R: Forward Direct 0..n R->>O: 连接建立后写入负载 O->>R: 回复字节 R->>K: Event::Outbound K-->>R: 原样暂存 R->>C: 回复字节
open_tcp 推送 Effect::Open { key: FlowKey::Direct, target: flow },若有嗅探得到的前缀再推送覆盖它的 Effect::ForwardHeld,然后转入 State::Tcp(Passthrough::new(FlowKey::Direct))。在连接完成前推送的 Forward 会在运行时的 effect 队列中等待。无论成功还是失败,Trojan 都不向客户端发送任何内容:客户端看到的第一批字节来自目标。
开启嗅探时,State::Sniff 把每次读取的数据送入 SniffPrefix::push,并恰好消费它接收的部分(总计至多 SNIFF_LIMIT,即 4096 字节)。当结论为 Found 或 Exhausted,或嗅探截止时间到期、传输层 EOF 时,open_sniffed 根据前缀设置 flow.sniffed 并调用 open_tcp,后者用 ForwardHeld 转发暂存的字节。worth_sniffing 只对 Remote::IpAddr 为 true,因此域名目标直接进入 open_tcp。嗅探本身见 嗅探。
UDP 关联
Section titled “UDP 关联”sequenceDiagram participant C as 客户端 participant K as TrojanCore participant L as 数据报出站 C->>K: 请求头,命令 0x03 K-->>K: State::Udp, opened = false, Relay 阶段 C->>K: 发往 A 的帧 K-->>L: Open Direct 指向 A(仅第一帧) K-->>L: SendTo A, 负载范围 C->>K: 发往 B 的帧 K-->>L: SendTo B, 负载范围 L->>K: 来自 A 的 Event::Datagram K-->>C: packet_into:来源为 A 的帧 C->>K: 传输层 EOF K-->>L: Close Direct K-->>C: ShutdownTransport, Finish
在第一帧到达之前,关联的 Flow 保留请求头中的目标。收到第一帧时,协议核心以 flow.toward(packet.dest) 推送 Effect::Open:目标为第一个包的目标,用户和来源不变,sniffed: None。随后设置 opened = true。每一帧(包括第一帧)都变成一个 Effect::SendTo { key: FlowKey::Direct, to: packet.dest, range },其中 range 指向事件切片,因此负载从不被复制。一个 Event::Transport 可能携带多个帧。循环一直解析到 parse_packet 返回 Ok(None),并只把完整帧的字节报告为已消费。末尾不完整的帧留在读缓冲区中。
每个关联只打开一个出站。逐包路由是 connector 的职责。在 etemenanki-app 中,AppConnector 用一个 FanOutLink 响应 UDP Open,按每个包自己的目标分别路由;参见 出站。
回复以 Event::Datagram { from, data, .. } 到达。协议核心用 packet_into(&from, data, fx.staging()) 把它成帧,于是客户端能知道是哪个对端作答。
mux.cool 承载连接
Section titled “mux.cool 承载连接”Trojan 没有 mux 命令。开启多路复用的客户端(例如启用 mux.enabled 的 Xray)会发送一个普通的 CONNECT,目标为域名 v1.mux.cool。is_mux_destination 只比较域名,忽略 ASCII 大小写,也忽略端口(Xray 客户端拨号到 MUX_PORT = 9527)。随后协议核心转入 State::Mux(Demux::new(flow, self.sniff))。
sequenceDiagram participant C as Mux 客户端 participant K as TrojanCore participant D as Demux participant S as 子流出站 C->>K: 发往 v1.mux.cool 的请求头,然后是 mux 帧 K->>D: feed(data, 0, fx) D-->>S: Open Sub(id, generation), Forward K-->>C: 暂存 take_out()(拒绝) S->>K: Event::Outbound Sub K->>D: on_outbound K-->>C: 暂存 out()(Keep 帧)
从这里开始,协议核心只是一个薄适配层。Event::Transport 交给 Demux::feed,多路分解器为拒绝某个会话而排队的 End 帧会通过 take_out() 取出并暂存。feed 在解析之前会清空多路分解器的输出缓冲区,因此该缓冲区只包含本次读取产生的拒绝帧:先前某个子流事件已经暂存过的下行帧永远不会被再次发送。feed 只把完整的帧报告为已消费,因此跨读取拆分的帧会像不完整的 UDP 帧一样留在运行时的读缓冲区中。FlowKey::Sub 键上的出站字节、数据报和 EOF 分别交给 on_outbound、on_datagram 和 on_outbound_gone,它们各自用自己产生的帧替换输出缓冲区,协议核心通过原地读取 out() 暂存这些帧。held() 返回 Demux::held();该缓冲区只被 Demux::feed_chunks(VMess 承载连接)使用,所以对 Trojan 来说始终为空。承载连接自身的 Flow 为每个子流提供用户和来源。开启嗅探时,指向 IP 的子流只从其 New 帧的负载中嗅探,因为承载连接不会为了某一个子流而暂缓。mux 帧格式、MAX_SESSIONS 和 XUDP 见 mux.cool 与 XUDP。
sequenceDiagram participant P as 明文侧 participant R as ProxyClientRuntime participant K as TrojanStream participant U as 上游服务器 R->>U: 经传输层拨号 R->>K: start K-->>R: 请求头已暂存,Handshake::Done R->>U: 写入并 flush 请求头 P->>R: poll_write(plain) R->>K: seal,原样复制 R->>U: 字节 U->>R: 字节 R->>K: open,整个切片为一帧 R->>P: 明文
connector 的 future 只在请求头写入并 flush 之后才完成,因此在链式代理中,服务端一侧在请求头发出后就视为“已连接”,而不是等到目标应答。在 etemenanki-app 中,Trojan 出站是一个 ProxyClient<TROJAN_BUF, TrojanStream, TrojanDatagram>,其中 TROJAN_BUF = 16 KiB。流的网络类型为 DialNetwork::Udp 时选用 TrojanDatagram,否则选用 TrojanStream。每个 UDP 关联都会单独建立一条到服务器的连接。运行时见 客户端运行时。
| 不变量 | 机制 | 由测试固定 |
|---|---|---|
| 跨多次读取拆分的请求头只在完整后才解析,在此之前不打开任何东西。 | parse_request_header 在每个截断点都返回 Ok(None),协议核心返回 Ok(0)。 |
request_header_is_parsed_from_a_slice_once_whole(unit/trojan/protocol.rs),header_split_across_events_opens_once_complete(unit/trojan/core.rs) |
| 只消费请求头。同一次读取中到达的负载完整送达目标。 | on_transport 返回 used,运行时重新投递剩余部分。 |
header_split_across_events_opens_once_complete,tcp_request_roundtrip(unit/trojan/protocol.rs) |
| 偏移 56 处没有 CRLF 的前导数据,一旦缓冲了 58 字节就立即失败,而不是继续等待。 | 在偏移 HASH_LEN 处的 CRLF 检查返回 InvalidData。 |
rejects_missing_crlf,request_header_is_parsed_from_a_slice_once_whole |
| 未知密钥不打开任何东西,也不写出任何东西。 | Validator::get 返回 None,handle 在任何 effect 之前返回 PermissionDenied。 |
unknown_user_is_refused(unit/trojan/core.rs) |
| 用户查找会把线上的密钥与每个条目比较。 | 对每个条目使用 ct_eq(subtle),循环中没有提前返回。 |
没有测试固定;password_hash_vector 固定了比较所用的密钥。 |
| 服务端从不应答请求头。 | 握手分支只推送 effect,不暂存任何内容。之后暂存的只有中继的出站字节、成帧的数据报或 mux 帧。连接失败经由 Passthrough::on_outbound_gone 处理,不暂存任何内容。 |
connect_failure_ends_the_connection_without_a_reply(unit/trojan/core.rs) |
| 客户端从不等待应答。 | start 返回 Handshake::Done,reply 为错误。 |
stream_codec_sends_the_header_then_passes_bytes_through(unit/trojan/codec.rs) |
| 一个 UDP 关联恰好打开一个出站,在其第一帧时打开。 | State::Udp 中的 opened 标志。 |
udp_association_opens_on_the_first_packet_and_frames_replies(unit/trojan/core.rs) |
| 每个合法的 UDP 帧都能放进读缓冲区,因此帧永远不会卡住解析器。 | parse_packet 在等待负载之前就拒绝超过 MAX_LENGTH 的长度;8455 小于 BUF_SIZE。 |
rejects_oversize_udp_packet,udp_packets_are_parsed_by_range(unit/trojan/protocol.rs) |
| 不完整的 UDP 帧会被保留,而不是丢弃。 | parse_packet 返回 Ok(None),循环只把完整帧报告为已消费。 |
a_partial_packet_frame_waits_for_the_rest(unit/trojan/core.rs) |
| 回复帧标明发送它的对端。 | Event::Datagram 分支中的 packet_into(&from, ..)。 |
udp_association_opens_on_the_first_packet_and_frames_replies,new_server_vs_new_client_udp(pipeline/trojan.rs) |
| 处理事件期间暂存区永不溢出。 | STAGING_RESERVE 覆盖一个帧头(263,向上取整为 272)加上一次 BUF_SIZE 读取所需的 mux Keep 头(536),共 808 字节。运行时只在留有这些空间加上负载时才投递事件。越界即 staging_full,属于协议核心的 bug。 |
udp_packets_are_parsed_by_range 断言 PACKET_HEADER_MAX 足以容纳 255 字节的域名。 |
嗅探得到的前缀比它的 ForwardHeld 存活得更久。 |
SniffPrefix::clear 只在下一个字节事件时执行,而运行时在应用暂存转发之后才投递该事件。 |
sniffing_an_ip_target_holds_the_prefix_until_a_host_is_found(unit/trojan/core.rs) |
| 域名目标、UDP 关联和 mux 承载连接从不作为整体被嗅探。 | 握手分支中的 worth_sniffing;UDP 和 mux 分支排在它之前。 |
a_domain_target_is_never_sniffed;udp_association_opens_on_the_first_packet_and_frames_replies 在开启嗅探的情况下运行。 |
每个 mux 下行帧只暂存一次。一次上行读取只暂存它用来拒绝会话的 End 帧。 |
Demux::feed 在解析之前清空输出缓冲区,而每次子流调用都会替换它,因此协议核心通过读取 out() 暂存过的帧不会再次排队。 |
a_downlink_frame_is_not_sent_again_by_the_next_uplink(unit/mux/demux.rs) |
到 v1.mux.cool 的 CONNECT 就是承载连接,与端口无关。 |
is_mux_destination 只比较域名。 |
trojan_carries_mux_when_the_connect_names_the_carrier(unit/mux/demux.rs),trojan_mux_tcp_single_stream(app integration/e2e_xray_mux.rs) |
密码永远不会进入 Flow。 |
UserEntry 只存储哈希,user() 设置空密码。 |
header_split_across_events_opens_once_complete 检查了 username |
| 客户端恰好发出一次请求头。 | start 在暂存 header 后将其清空。 |
没有直接固定;stream_codec_sends_the_header_then_passes_bytes_through 和 datagram_codec_frames_each_packet_with_its_peer(unit/trojan/codec.rs)会把 start 暂存的那一个请求头解析回来。 |
失败路径与取消
Section titled “失败路径与取消”handle 返回 Err 会结束连接:运行时停止,app 以错误文本记录一行 debug 日志。其他所有结束方式都由 effect 决定。
| 事件 | 状态 | 结果 |
|---|---|---|
Transport,请求头格式错误 |
Handshake |
Err,InvalidData:trojan: not trojan protocol (missing CRLF after hash),或地址错误,例如 unknown address type: <n> |
Transport,未知密钥 |
Handshake |
Err,PermissionDenied:trojan: invalid user |
Deadline |
Handshake |
Err,TimedOut:client did not complete its request in time |
Deadline |
Sniff |
以已收集到的内容执行 open_sniffed |
Deadline |
Tcp、Udp、Mux |
Timing::expired 转入 Phase::Closing 并推送 Finish |
TransportEof |
Handshake、Done |
Finish |
TransportEof |
Sniff |
先打开,再经由中继执行 Shutdown { Direct } |
TransportEof |
Tcp |
Shutdown { Direct };出站也结束后 Finish |
OutboundEof |
Tcp |
ShutdownTransport;传输层也结束后 Finish |
TransportEof |
Udp |
若已打开则 Close { Direct },然后 ShutdownTransport、Finish |
TransportEof |
Mux |
对每个存活的子流 Close,然后 ShutdownTransport、Finish |
ConnectFailed、OutboundError |
Tcp |
Passthrough::on_outbound_gone:ShutdownTransport、Finish,不暂存任何内容 |
ConnectFailed、OutboundError |
Udp |
ShutdownTransport、Finish |
Sub 键上的 ConnectFailed、OutboundError、OutboundEof |
Mux |
Demux::on_outbound_gone:为该子流发送 End 帧并 Close;承载连接保持 |
SendFailed |
任意 | 以 debug 级别记录为 trojan: packet to … dropped: …;关联继续 |
Transport,帧长度超过 8192 |
Udp |
Err,InvalidData:trojan: oversize payload;整个关联结束 |
在客户端发送任何内容之前,TrojanCore 在 Handshake 状态下不会设置截止时间,因为运行时在第一次读取之前不投递任何事件。连接后一直沉默的客户端由协议核心之外的机制处理。在 is_established() 为 true 之前,app/src/serve.rs 中 app 的 drive 为运行时的每一步至多等待 HANDSHAKE_TIMEOUT,某一步未能按时到来时以 inbound handshake timed out after 10s 失败;参见 服务。katana 在 src/serve.rs 中的 drive 则用 HANDSHAKE_TIMEOUT 限制 TrojanCore<UserTag> 的整个握手过程。
取消是结构性的。协议核心不拥有任何任务、通道或计时器。丢弃运行时即丢弃协议核心、其暂存的字节、Demux,以及运行时持有的所有出站链路。
在客户端,TrojanDatagram::open_from 使用同一个 parse_packet。来自服务器的帧若格式错误或长于 MAX_LENGTH,即为 InvalidData 错误,链路失败。在 seal_to 之前,客户端运行时会腾出 STAGING_RESERVE 加上包大小的空间。如果即使在空缓冲区中也放不下该包,就以 frame larger than the client runtime's buffer 失败。在此之后,seal_to 总能放得下。
客户端 codec 不应用 MAX_LENGTH。在 app 的 16 KiB TROJAN_BUF 下,8193 到 16064 字节的包会被封装并发送,而 TrojanCore 服务端会以 trojan: oversize payload 拒绝该帧,从而结束整个关联。
| 常量 | 值 | 位置 | 含义 |
|---|---|---|---|
HASH_LEN |
56 字节 | protocol.rs |
十六进制 SHA-224 用户密钥 |
REQUEST_HEADER_MAX |
320 字节 | protocol.rs |
最长请求头;也是 codec 的 RESERVE |
PACKET_HEADER_MAX |
263 字节 | protocol.rs |
最长 UDP 帧头 |
MAX_LENGTH |
8192 字节 | protocol.rs |
每帧最大 UDP 负载 |
TrojanCore::BUF_SIZE |
16384 字节 | core.rs |
每条连接的读、暂存和临时缓冲区 |
TrojanCore::STAGING_RESERVE |
808 字节 | core.rs |
272 用于一个帧头,536 用于 mux 下行头 |
TrojanCore::MAX_DATAGRAM |
8192 字节 | core.rs |
可完整投递的最大出站数据报;超出部分由运行时截断 |
HANDSHAKE_TIMEOUT |
10 秒 | protocols/src/core/mod.rs |
请求头截止时间,在第一次读取时设置 |
SNIFF_TIMEOUT |
300 毫秒 | protocols/src/sniff/mod.rs |
嗅探窗口 |
SNIFF_LIMIT |
4096 字节 | protocols/src/sniff/mod.rs |
嗅探预算 |
RELAY_IDLE_TIMEOUT |
300 秒 | protocols/src/core/mod.rs |
TCP、UDP 和 mux 共用的空闲上限 |
MAX_SESSIONS |
256 | protocols/src/mux/demux.rs |
每个 mux 承载连接的子流数 |
TROJAN_BUF |
16384 字节 | app/src/outbound/mod.rs |
app 的 Trojan 出站中客户端运行时的缓冲区 |
| 测试 | 文件 | 固定的内容 |
|---|---|---|
password_hash_vector |
protocols/tests/unit/trojan/protocol.rs |
56 个小写十六进制字节,等于 Xray 的 hexSha224("password") |
tcp_request_roundtrip |
同上 | 编码并解析 CONNECT 请求头;其后的负载保持完整(对应 Xray 的 TestTCPRequest) |
udp_request_roundtrip |
同上 | ASSOCIATE 请求头加一个包帧(对应 TestUDPRequest) |
rejects_missing_crlf |
同上 | 哈希后的分隔符错误即报错 |
rejects_oversize_udp_packet |
同上 | 长度 MAX_LENGTH + 1 即报错 |
request_header_is_parsed_from_a_slice_once_whole |
同上 | 六个截断点处返回 Ok(None)、精确的 consumed,以及切片解析器的 CRLF 拒绝 |
udp_packets_are_parsed_by_range |
同上 | 连续的帧、负载范围、PACKET_HEADER_MAX 上界、超长拒绝 |
header_split_across_events_opens_once_complete |
protocols/tests/unit/trojan/core.rs |
握手截止时间、Open 目标(端口、网络类型、来源、username)、空闲截止时间,然后是 Forward |
unknown_user_is_refused |
同上 | PermissionDenied |
sniffing_an_ip_target_holds_the_prefix_until_a_host_is_found |
同上 | 嗅探截止时间、跨两次读取的前缀、ForwardHeld 0..41、前缀在下一个事件时清空 |
sniff_deadline_opens_with_what_was_collected |
同上 | Sniff 中截止时间到期时以 sniffed: None 打开并转发暂存的字节 |
a_domain_target_is_never_sniffed |
同上 | 域名 CONNECT 立即打开 |
relay_half_closes_and_finishes |
同上 | 原样下行、出站 EOF 时 ShutdownTransport、传输层 EOF 时 Shutdown 和 Finish |
connect_failure_ends_the_connection_without_a_reply |
同上 | ShutdownTransport、Finish,不暂存任何内容 |
udp_association_opens_on_the_first_packet_and_frames_replies |
同上 | 请求头即建立、一次 Open、每帧一次 SendTo、成帧的回复、关闭顺序 |
a_partial_packet_frame_waits_for_the_rest |
同上 | 半个帧时不消费任何字节 |
handshake_deadline_fails_the_connection |
同上 | TimedOut |
stream_codec_sends_the_header_then_passes_bytes_through |
protocols/tests/unit/trojan/codec.rs |
请求头可解析回来、原样 seal 和 open、reply 为错误 |
datagram_codec_frames_each_packet_with_its_peer |
同上 | ASSOCIATE 请求头、成帧的 seal_to、空间不足时返回 None 且不暂存任何内容、open_from 等待完整帧 |
trojan_carries_mux_when_the_connect_names_the_carrier |
protocols/tests/unit/mux/demux.rs |
到 v1.mux.cool:9527 的 CONNECT 成为承载连接;子流的打开、转发、成帧回复和关闭 |
a_downlink_frame_is_not_sent_again_by_the_next_uplink |
同上 | Trojan 和 VLESS 的暂存模式:原地从 out() 读取一个 UDP 回复,随后一次不拒绝任何会话的上行读取,使 take_out() 为空 |
new_server_vs_new_client_tcp |
protocols/tests/pipeline/trojan.rs |
通过回环地址运行真实的运行时:70,000 字节的回显和干净的半关闭 |
new_server_vs_new_client_udp |
同上 | 8 字节和 1500 字节的 UDP 回显,回复被归属到回显服务器 |
trojan_mux_tcp_single_stream |
app/tests/integration/e2e_xray_mux.rs |
开启 mux 的真实 Xray 客户端,经 ws 和 TLS 连接 app 的 Trojan 入站 |
在 Etemenanki workspace 中运行:
cargo test -p etemenanki-protocols trojancargo test -p etemenanki-protocols a_downlink_frame_is_not_sent_againcargo test -p etemenanki-app trojan_mux第一条命令会运行单元测试、pipeline 测试,以及 unit/mux/demux.rs 中的 Trojan 承载连接测试,因为它们的名称或路径中含有 trojan。第二条命令运行 mux 暂存测试,它的名称中不含 trojan。第三条命令会用 Go 从参考源码树构建 Xray;未安装 Go 时,它会输出一行 SKIP: 并跳过。测试套件的组织方式见 测试。