跳转到内容

Trojan

源码文件:27 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/trojan/mod.rs
  • Etemenanki/protocols/src/trojan/protocol.rs
  • Etemenanki/protocols/src/trojan/users.rs
  • Etemenanki/protocols/src/trojan/core.rs
  • Etemenanki/protocols/src/trojan/codec.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/mux/mod.rs
  • Etemenanki/protocols/src/mux/demux.rs
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/protocols/src/helpers/crypto.rs
  • Etemenanki/protocols/src/helpers/address.rs
  • Etemenanki/protocols/src/flow.rs
  • Etemenanki/protocols/src/mux/frame.rs
  • Etemenanki/concepts/src/client.rs
  • Etemenanki/protocols/tests/unit/trojan/protocol.rs
  • Etemenanki/protocols/tests/unit/trojan/core.rs
  • Etemenanki/protocols/tests/unit/trojan/codec.rs
  • Etemenanki/protocols/tests/unit/mux/demux.rs
  • Etemenanki/protocols/tests/pipeline/trojan.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/app/src/connector.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/app/tests/integration/e2e_xray_mux.rs
  • katana/src/inbound.rs
  • katana/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)失败。

在 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 的读缓冲区。

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 都使用切片函数。

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 则直接构建用户列表。

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。

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 是为防备会调用它的运行时而设的。

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 依次执行:

  1. 对读缓冲区中未解析的部分调用 parse_request_header。遇到 Ok(None) 时返回 Ok(0):不消费任何字节,由运行时继续读取。
  2. 调用 self.validator.get(&header.hash)。没有匹配时返回 PermissionDenied,trojan: invalid user。
  3. 构建 Flow::new(header.destination, user, self.source)。
  4. 按命令和目标分支,顺序为:CMD_UDP_ASSOCIATE,然后 crate::mux::is_mux_destination,然后 self.sniff && worth_sniffing(..),最后是普通 CONNECT。
  5. 返回 Ok(used),即只有请求头的长度。同一次读取中的负载字节留在缓冲区里,运行时会带着它们再次调用 handle,此时已处于新状态。
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。嗅探本身见 嗅探。

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()) 把它成帧,于是客户端能知道是哪个对端作答。

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 暂存的那一个请求头解析回来。

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 trojan
cargo test -p etemenanki-protocols a_downlink_frame_is_not_sent_again
cargo test -p etemenanki-app trojan_mux

第一条命令会运行单元测试、pipeline 测试,以及 unit/mux/demux.rs 中的 Trojan 承载连接测试,因为它们的名称或路径中含有 trojan。第二条命令运行 mux 暂存测试,它的名称中不含 trojan。第三条命令会用 Go 从参考源码树构建 Xray;未安装 Go 时,它会输出一行 SKIP: 并跳过。测试套件的组织方式见 测试。