传输层:TCP 与 TLS
源码文件:37 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/src/transports/mod.rsEtemenanki/protocols/src/transports/accept.rsEtemenanki/protocols/src/transports/connect.rsEtemenanki/protocols/src/transports/stream.rsEtemenanki/protocols/src/transports/keepalive.rsEtemenanki/protocols/src/transports/tls/mod.rsEtemenanki/protocols/src/transports/tls/config.rsEtemenanki/protocols/src/transports/tls/stream.rsEtemenanki/protocols/src/transports/grpc/settings.rsEtemenanki/protocols/src/transports/grpc/liveness.rsEtemenanki/protocols/src/transports/grpc/stream.rsEtemenanki/protocols/src/helpers/address_family.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/dns/mod.rsEtemenanki/protocols/src/hysteria/connection.rsEtemenanki/environment/src/dial/mod.rsEtemenanki/environment/src/dial/tcp.rsEtemenanki/environment/src/dial/socket.rsEtemenanki/concepts/src/link.rsEtemenanki/concepts/src/client.rsEtemenanki/app/src/transport.rsEtemenanki/app/src/config.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/inbound/tun.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/outbound/proxy.rsEtemenanki/app/src/outbound/freedom.rsEtemenanki/app/src/serve.rsEtemenanki/protocols/tests/pipeline/transports.rsEtemenanki/protocols/tests/support/mod.rsEtemenanki/protocols/tests/unit/transports/accept.rsEtemenanki/protocols/tests/unit/transports/tls_config.rsEtemenanki/app/tests/unit/transport.rsEtemenanki/app/tests/integration/e2e_xray.rskatana/src/inbound.rskatana/src/outbound/mod.rskatana/src/outbound/proxy.rs
etemenanki-protocols 中的 transports 模块位于 TCP socket 与协议之间。在接受一侧,InboundTransport 把一个被接受的 socket 变成一条或多条字节流,并把每条流交给一个 sink。在拨号一侧,TransportConnector 解析上游代理服务器、连接它,并用同样的层包装 socket。两侧都产出 TransportStream,因此每个服务端协议核心(core)和客户端 codec 都只需针对这一种流类型编写一次,永远不必知道下面是 TLS、WebSocket 还是 HTTP/2。
本页介绍该模块的框架(两个枚举、流类型、keepalive 和握手超时),完整介绍纯 TCP 层和 TLS 层,以及把 [inbound.stream] 或 [outbound.stream] 配置块映射到这些类型的应用代码。WebSocket 和 gRPC 层另有专页 传输层:WebSocket 与 gRPC;本页只在它们与 TCP、TLS 共享代码之处提及。
| 关注点 | 负责方 | 有意留给 |
|---|---|---|
为经过 InboundTransport::accept 或 TransportConnector::dial 的每个 TCP socket 设置 keepalive |
protocols/src/transports/keepalive.rs → set_keepalive |
无;在这两条路径上无条件应用 |
| 限制接受侧传输层握手的时长 | protocols/src/transports/accept.rs → within |
协议核心负责自己的握手 |
| 把一个 socket 变成一条或多条流 | InboundTransport::accept |
调用方的 sink,由它 spawn 每条流的 task |
| 解析并连接上游服务器 | TransportConnector::dial |
Resolver、AddressFamilyStrategy、TcpDialer |
| TLS 协议版本和密码套件策略 | protocols/src/transports/tls/config.rs |
调用方只选择证书、信任策略和 ALPN |
| 判断配置要求的是哪种传输层 | app/src/transport.rs → resolve_stream |
入站和出站构建器把 shape 转换为具体值 |
该模块从不解析代理协议、从不做路由,也从不统计字节。收到 TransportStream 的协议核心只看到 AsyncRead + AsyncWrite + Unpin,别无其他。
每种传输层都从一个 tokio TcpStream 开始。TLS 用 tokio_openssl::SslStream 包装它。WebSocket 和 gRPC 运行在 MaybeTlsStream 之上,它要么是纯 socket,要么是 TLS 流,因此有无 TLS 都共用同一条 I/O 路径。TransportStream 是最上层的枚举,其余代码看到的都是它。
flowchart BT tcp["TcpStream"] ssl["SslStream of TcpStream"] mts["MaybeTlsStream"] ws["WsStream of MaybeTlsStream"] grpc["GrpcStream"] ts["TransportStream"] tcp -- "Tcp" --> ts tcp --> ssl ssl -- "Tls" --> ts tcp -- "Plain" --> mts ssl -- "Tls" --> mts mts --> ws mts --> grpc ws -- "Ws" --> ts grpc -- "Grpc" --> ts
边上的标签是枚举变体:进入 TransportStream 的是 TransportStream::Tcp、TransportStream::Tls 等,进入 MaybeTlsStream 的是 MaybeTlsStream::Plain / MaybeTlsStream::Tls。
TransportStream
Section titled “TransportStream”protocols/src/transports/stream.rs → TransportStream
pub enum TransportStream { Tcp(TcpStream), Tls(Box<SslStream<TcpStream>>), Ws(Box<WsStream<MaybeTlsStream>>), Grpc(Box<GrpcStream>),}TransportStream 通过匹配变体、再经 Pin::new 把 poll_read、poll_write、poll_flush 和 poll_shutdown 转发给内部流,从而实现 AsyncRead 和 AsyncWrite。所有内部类型都是 Unpin,所以不需要 pin projection。它没有覆盖 poll_write_vectored,因此向量写入会退回默认的单缓冲区写入。
| 变体 | 内部类型 | 是否装箱 | 产出方 |
|---|---|---|---|
Tcp |
TcpStream |
否 | InboundTransport::Tcp、TransportKind::Tcp |
Tls |
SslStream<TcpStream> |
是,OpenSSL 的包装类型很大 | InboundTransport::Tls、TransportKind::Tls |
Ws |
WsStream<MaybeTlsStream> |
是 | InboundTransport::Ws、TransportKind::Ws |
Grpc |
GrpcStream |
是 | InboundTransport::Grpc、TransportKind::Grpc |
protocols/src/transports/accept.rs 还为接受侧定义了一个别名:
pub type Accepted = TransportStream;InboundTransport 与 accept
Section titled “InboundTransport 与 accept”protocols/src/transports/accept.rs → InboundTransport
#[derive(Clone)]pub enum InboundTransport { Tcp, Tls(ServerConfig), Ws { route: WsRoute, tls: Option<ServerConfig>, }, Grpc { paths: Arc<GrpcPaths>, tls: Option<ServerConfig>, },}
impl InboundTransport { pub fn ws(path: impl AsRef<str>, host: Option<&str>, tls: Option<ServerConfig>) -> Self; pub fn grpc(service: impl AsRef<str>, tls: Option<ServerConfig>) -> Self; pub async fn accept<F>(&self, tcp: TcpStream, mut sink: F) -> io::Result<()> where F: FnMut(Accepted);}InboundTransport 实现了 Clone,且克隆开销很小:ServerConfig 持有 Arc<SslAcceptor>,WsRoute 持有 Arc<str> 值,gRPC 路径位于一个 Arc 之后。应用为每个入站构建一个值,并为每个被接受的 socket 克隆一份,所以所有连接共享同一个 OpenSSL 上下文。
accept 按顺序做三件事:
- 对被接受的 socket 调用
set_keepalive(&tcp)。 - 在
within中运行传输层自身的握手,within用tokio::time::timeout(TRANSPORT_HANDSHAKE_TIMEOUT, …)包装该 future。 - 把得到的每条流交给
sink。
| 变体 | 受 TRANSPORT_HANDSHAKE_TIMEOUT 限制的步骤 |
within 标签 |
产出的流 | accept 何时返回 |
|---|---|---|---|---|
Tcp |
无,没有握手 | 无 | 恰好一条 | 调用 sink 之后立即返回 |
Tls |
ServerConfig::accept |
"tls" |
恰好一条 | 调用 sink 之后立即返回 |
Ws |
可选的 TLS,然后是 WsStream::accept(HTTP 升级) |
"websocket" |
恰好一条 | 调用 sink 之后立即返回 |
Grpc |
可选的 TLS,然后是由 configured_server_builder() 完成的 HTTP/2 服务端握手 |
"grpc" |
每条被接受的 HTTP/2 流一条 | HTTP/2 连接结束时 |
对于 Ws 和 Grpc,TLS 握手与其上一层共享同一个 10 秒预算,因为两者运行在同一个 within future 中。超时后的错误是 io::ErrorKind::TimedOut,文本为 "<label> handshake timed out",例如 tls handshake timed out。
该时限只覆盖传输层这一步。随后在产出的流上进行的协议握手(Trojan 密码、VLESS 头部)由协议层限制:对基于协议核心的协议,app/src/serve.rs → drive 用 etemenanki_protocols::core::HANDSHAKE_TIMEOUT(同样是 10 秒)监控它,SOCKS 驱动则有自己的超时(见服务入站)。两个预算互相独立:协议握手的计时要等 accept 把流交给 sink 之后才开始。
pub const TRANSPORT_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
async fn within<T>(what: &str, fut: impl Future<Output = io::Result<T>>) -> io::Result<T>;gRPC:每条 HTTP/2 流调用一次 sink
Section titled “gRPC:每条 HTTP/2 流调用一次 sink”对于 Grpc,accept 把完成握手的 h2::server::Connection 交给一个私有驱动:
async fn serve_h2<T, F>( mut conn: Connection<T, Bytes>, paths: &GrpcPaths, sink: &mut F,) -> io::Result<()>where T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, F: FnMut(Accepted);该驱动循环执行一个有三个分支的 tokio::select!:
conn.accept():一个新请求。GrpcPaths::classify把请求路径映射为GrpcMode::Gun(/<service>/Tun)或GrpcMode::Multi(/<service>/TunMulti)。未知路径收到带REFUSED_STREAM的RST_STREAM,循环继续。已知路径收到 gRPC 响应头,该流被包装为GrpcStream::served(send, recv, mode, &count)并交给sink。conn.accept()返回None时,循环以Ok(())结束;它返回错误时,整个连接以该错误结束。count.changed(),仅在有存活流时启用:某条流结束了。StreamCount是一个共享的存活流计数AtomicUsize加一个Notify;每个被服务的GrpcStream持有一个 guard,创建时递增计数,drop 时递减计数并发出通知。liveness.watch(idle):Liveness给不承载任何流的 HTTP/2 连接H2_IDLE_TIMEOUT(300 秒)时间来打开一条流,并每隔H2_KEEPALIVE_INTERVAL(60 秒)发送一个 PING,要求在H2_KEEPALIVE_TIMEOUT(20 秒)内得到应答。另外两个分支都会调用note_progress,重新开始空闲截止时间的计时。当Liveness判定对端已失联时,循环以Ok(())结束。
Liveness、gRPC 分帧和 HTTP/2 设置的细节见 传输层:WebSocket 与 gRPC。
sequenceDiagram
participant L as accept 循环
participant T as InboundTransport::accept
participant H as serve_h2
participant S as sink
L->>T: accept(tcp, sink)
T->>T: set_keepalive
T->>T: within("grpc", TLS + h2 handshake)
T->>H: serve_h2(conn, paths, sink)
loop 每条 HTTP/2 流
H->>H: classify(path)
alt 未知路径
H-->>H: RST_STREAM REFUSED_STREAM
else Tun 或 TunMulti
H->>S: sink(TransportStream::Grpc)
S-->>L: 为该流 spawn 一个 task
end
end
H-->>T: 连接结束或判定对端已失联
T-->>L: Ok(())
TransportKind 与 TransportConnector
Section titled “TransportKind 与 TransportConnector”protocols/src/transports/connect.rs → TransportKind、TransportConnector
#[derive(Clone)]pub enum TransportKind { Tcp, Tls(ClientConfig), Ws { target: WsTarget, tls: Option<ClientConfig>, }, Grpc { authority: Arc<str>, service: Arc<str>, mode: GrpcMode, user_agent: Option<Arc<str>>, tls: Option<ClientConfig>, },}
impl TransportKind { pub fn ws(host: impl AsRef<str>, path: impl AsRef<str>, tls: Option<ClientConfig>) -> Self; pub fn grpc( authority: impl AsRef<str>, service: impl AsRef<str>, tls: Option<ClientConfig>, ) -> Self; pub fn multi(mut self) -> Self; pub fn user_agent(mut self, agent: Option<&str>) -> Self; async fn wrap(&self, tcp: TcpStream) -> io::Result<TransportStream>;}
#[derive(Clone)]pub struct TransportConnector { kind: Arc<TransportKind>, dialer: Dialer, resolver: Resolver, strategy: AddressFamilyStrategy,}
impl TransportConnector { pub fn new( kind: TransportKind, dialer: Dialer, resolver: Resolver, strategy: AddressFamilyStrategy, ) -> Self; pub fn tcp() -> Self; pub async fn dial(&self, dest: &Destination) -> io::Result<TransportStream>;}TransportKind::grpc 初始为 GrpcMode::Gun,使用 DEFAULT_USER_AGENT(一个桌面版 Chrome 的 user agent)。multi 切换到 GrpcMode::Multi,user_agent(None) 去掉该头部。这两个构建方法作用于非 gRPC 的 kind 时什么也不做。应用两者都不调用,所以它的 gRPC 出站总是用默认 user agent 打开 /<service>/Tun;测试会直接调用 multi。TransportConnector::tcp() 是纯 TCP,使用 Dialer::default()、Resolver::default()(系统解析器)和 AddressFamilyStrategy::default()(Auto)。
dial 分四步执行:
flowchart LR
d["dial(dest)"] --> u{"dest.network 是 Udp?"}
u -- "是" --> e["Err Unsupported"]
u -- "否" --> r["destination_to_socketaddrs"]
r --> c["TcpDialer::connect_any"]
c --> k["set_keepalive"]
k --> w["TransportKind::wrap"]
w --> s["TransportStream"]
- 拒绝 UDP。
network为DialNetwork::Udp的Destination会立即失败,错误为io::ErrorKind::Unsupported,文本为a proxy transport carries no datagrams of its own。其他所有DialNetwork值都按 TCP 拨号。 - 解析。
protocols/src/helpers/address_family.rs→destination_to_socketaddrs以 connector 的Resolver、AddressFamilyStrategy和FamilySupport::both()调用resolve_candidates("dial", …)。IP 字面量跳过解析器。所有解析结果都会保留,而不只是第一个,然后按策略过滤;PreferIpv4和PreferIpv6用稳定排序重新排列,并把另一个地址族保留为后备。 - 连接。
environment/src/dial/tcp.rs→TcpDialer::connect_any按顺序逐个尝试这些地址。每次尝试都受拨号器的连接超时(DEFAULT_CONNECT_TIMEOUT,10 秒)限制。第一次成功即采用;全部失败时,错误中列出每个地址及其失败原因。 - keepalive 与包装。 在连接好的 socket 上运行
set_keepalive,然后由TransportKind::wrap在其上叠加 TLS、WebSocket 或 gRPC。
TransportConnector 为 Destination 实现了 concepts crate 的 Connector:
pub trait Connector<Target> { type Stream: AsyncRead + AsyncWrite + Unpin; type Datagram: DatagramLink; type Future: Future<Output = io::Result<Outbound<Self::Stream, Self::Datagram>>>;
fn connect(&mut self, target: Target) -> Self::Future;}
pub enum NoDatagram { Never(Infallible),}
// protocols/src/transports/connect.rstype DialFuture = Pin<Box<dyn Future<Output = io::Result<Outbound<TransportStream, NoDatagram>>> + Send>>;
impl Connector<Destination> for TransportConnector { type Stream = TransportStream; type Datagram = NoDatagram; type Future = DialFuture;
fn connect(&mut self, dest: Destination) -> DialFuture;}connect 把 connector 克隆(kind 只是一次 Arc 计数递增,外加 dialer 和 resolver 的克隆)进一个装箱的 'static + Send future,因此并发的拨号之间不共享任何可变状态。它把结果映射为 Outbound::Stream。NoDatagram 是不可实例化的类型,所以这个 connector 根本无法构造出 Outbound::Datagram。代理的 UDP 在其流内部传输:负责分帧的是客户端 codec,而不是传输层。
应用中每个基于流的客户端都以这个 connector 作为 concepts/src/client.rs → ProxyClientConnector 的 Conn 参数(app/src/outbound/proxy.rs 持有一个 ProxyClientConnector<BUF, Make<S, D>, TransportConnector, Destination>)。SOCKS 出站还会直接调用 dial 来打开它的 UDP ASSOCIATE 控制流。katana 从已发布的 crate 中使用 InboundTransport 和 TransportConnector:它的 src/inbound.rs → build_transport 根据面板下发的节点描述构建 InboundTransport,它的 src/outbound/proxy.rs 使用与应用相同的 ProxyClientConnector 结构。它的出站(src/outbound/mod.rs → build_outbound)总是使用 TransportKind::Tcp:katana v3.0.1 不构建 TLS、WebSocket 或 gRPC 拨号传输层。katana 从面板字段到这些类型的映射由它自己负责,本页不作介绍。
TLS:ServerConfig、ClientConfig、Alpn、VerifyMode
Section titled “TLS:ServerConfig、ClientConfig、Alpn、VerifyMode”TLS 通过 openssl 和 tokio-openssl 两个 crate 使用系统的 OpenSSL。该模块把 OpenSSL 构建器保持为私有:调用方选择证书材料、信任策略和 ALPN,而 protocols/src/transports/tls/config.rs 掌管配置模板和协议版本。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub enum Alpn { None, Http1, Http2, Http2ThenHttp1,}
#[derive(Clone, Debug, Eq, PartialEq)]pub enum VerifyMode { System, CustomCa(Vec<u8>), Insecure,}
#[derive(Clone)]pub struct ServerConfig { acceptor: Arc<SslAcceptor>,}
impl ServerConfig { pub fn from_pem(cert_pem: &[u8], key_pem: &[u8], alpn: Alpn) -> io::Result<Self>; pub async fn accept(&self, tcp: TcpStream) -> io::Result<SslStream<TcpStream>>;}
#[derive(Clone)]pub struct ClientConfig { connector: Arc<SslConnector>, sni: Arc<str>, verify_hostname: bool,}
impl ClientConfig { pub fn new(sni: impl AsRef<str>, verify: bool, alpn: Alpn) -> io::Result<Self>; pub fn with_verify_mode( sni: impl AsRef<str>, verify_mode: VerifyMode, alpn: Alpn, ) -> io::Result<Self>; pub async fn wrap(&self, tcp: TcpStream) -> io::Result<SslStream<TcpStream>>;}Alpn 映射到固定的线上字节:即 ALPN 编码所用的带长度前缀的协议名。
| 变体 | 常量 | 线上字节 |
|---|---|---|
Alpn::None |
无 | 不配置 ALPN |
Alpn::Http1 |
ALPN_HTTP1 |
\x08http/1.1 |
Alpn::Http2 |
ALPN_H2 |
\x02h2 |
Alpn::Http2ThenHttp1 |
ALPN_H2_HTTP1 |
\x02h2\x08http/1.1 |
配置了 ALPN 的服务端会安装一个选择回调,运行 select_next_proto(server_protos, client_protos)。客户端提供的协议没有交集时,回调返回 AlpnError::NOACK:握手不带 ALPN 扩展继续进行,而不是失败。配置了 ALPN 的客户端调用 set_alpn_protos 提供协议列表。Alpn::Http2ThenHttp1 存在于 API 中,但在当前版本下,workspace 和 katana 中都没有调用方使用它。
ServerConfig::from_pem 按以下顺序构建 acceptor:
- 用
X509::stack_from_pem解析 PEM 证书包。第一张证书是叶证书;如果一张都没有,就以InvalidInput和no certificate in PEM bundle失败。 - 用
PKey::private_key_from_pem解析私钥。 - 以
SslAcceptor::mozilla_intermediate_v5(SslMethod::tls())为起点,这是opensslcrate 对 Mozilla intermediate 服务端配置(v5)的实现。它设置NO_TLSV1 | NO_TLSV1_1,加载 RFC 7919 的ffdhe2048DH 组,并固定下文所列的密码套件列表。密钥交换组则沿用 OpenSSL 的默认值。 - 用
set_min_proto_version(Some(SslVersion::TLS1_2))显式把最低版本设为 TLS 1.2,使版本下限不只依赖选项位。TLS 1.3 保持启用。 - 装入私钥和叶证书,用
add_extra_chain_cert添加证书包中其余的每张证书,并运行check_private_key,这样与叶证书不匹配的私钥会在构建时就失败。 - 如果
alpn不是Alpn::None,安装 ALPN 选择回调。
两侧都没有自行设置密码套件列表,所以密码套件配置来自 openssl crate(当前版本的 Cargo.lock 中为 0.10.81),而不是本仓库:
| 一侧 | TLS 1.2 密码套件列表 | TLS 1.3 套件 |
|---|---|---|
服务端(mozilla_intermediate_v5) |
ECDHE-ECDSA-AES128-GCM-SHA256、ECDHE-RSA-AES128-GCM-SHA256、ECDHE-ECDSA-AES256-GCM-SHA384、ECDHE-RSA-AES256-GCM-SHA384、ECDHE-ECDSA-CHACHA20-POLY1305、ECDHE-RSA-CHACHA20-POLY1305、DHE-RSA-AES128-GCM-SHA256、DHE-RSA-AES256-GCM-SHA384 |
TLS_AES_128_GCM_SHA256、TLS_AES_256_GCM_SHA384、TLS_CHACHA20_POLY1305_SHA256 |
客户端(SslConnector::builder) |
DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:!aDSS:!SRP:!PSK |
OpenSSL 的默认值 |
两个构建器还都从该 crate 的通用上下文选项开始,其中包括 NO_COMPRESSION、NO_SSLV2 和 NO_SSLV3。升级 openssl crate 可能会改变这些列表;升级时请检查它们。
accept 为每个连接从共享上下文创建一个新的 Ssl,并用 SslStream::accept 驱动服务端握手。OpenSSL 错误会转换为 io::Error::other。
ClientConfig::with_verify_mode 以 SslConnector::builder(SslMethod::tls()) 为起点,它已经启用了对端校验并加载了默认信任路径;随后把最低版本设为 TLS 1.2。其余部分由校验策略决定:
VerifyMode |
证书链校验依据 | 是否校验主机名 | 构建器调用 |
|---|---|---|---|
System |
平台 / OpenSSL 默认信任库 | 是 | set_default_verify_paths |
CustomCa(pem) |
默认信任库,加上 pem 中的每张证书 |
是 | set_default_verify_paths,然后对每张证书调用 cert_store_mut().add_cert |
Insecure |
不校验 | 否 | set_verify(SslVerifyMode::NONE) |
CustomCa 是把给定的 CA 添加到系统根证书上,而不是替换它们。不含任何证书的证书包会以 InvalidInput 和 no certificate in CA PEM bundle 失败。
ClientConfig::new(sni, verify, alpn) 是简写:verify = true 表示 VerifyMode::System,false 表示 VerifyMode::Insecure。
主机名校验是按会话设置的,而不是在构建器上设置。OpenSSL 的 ConnectConfiguration 默认开启它,所以 ClientConfig 保存 verify_hostname,并在 Insecure 时由 wrap 调用 set_verify_hostname(false)。随后 wrap 调用 into_ssl(&self.sni),它把该名称作为 SNI 发送,并在开启主机名校验时用它校验证书。这两种行为都来自 openssl crate:
- DNS 名称会作为 SNI 发送,并与证书中的 DNS 名称比对,拒绝部分通配符;
- IP 字面量不会作为 SNI 发送(该扩展只承载主机名),而是与证书中的 IP 地址比对。
TLS 类型的其他使用者
Section titled “TLS 类型的其他使用者”ClientConfig 和 VerifyMode 并非传输层私有。protocols/src/dns/mod.rs → Resolver::new 为 DNS-over-TLS 后端(Alpn::None)和 DNS-over-HTTPS 后端(Alpn::Http1)构建 ClientConfig,配置了 CA 时使用 CustomCa,否则使用 System。protocols/src/hysteria/connection.rs 复用 VerifyMode 枚举作为其信任策略的表达方式,但会把它转换为 rustls 配置,因为这里的 QUIC 不运行在 OpenSSL 之上。因此,改变 VerifyMode 的含义会影响三处:TCP 传输层、DNS 解析器和 Hysteria 2 客户端。
MaybeTlsStream 与辅助函数
Section titled “MaybeTlsStream 与辅助函数”protocols/src/transports/tls/stream.rs
pub enum MaybeTlsStream { Plain(TcpStream), Tls(Box<SslStream<TcpStream>>),}
pub fn tcp_from_std(tcp: std::net::TcpStream) -> io::Result<TcpStream>;
pub async fn accept_optional_tcp( tcp: std::net::TcpStream, server: Option<ServerConfig>,) -> io::Result<MaybeTlsStream>;
pub async fn wrap_optional_tcp( tcp: TcpStream, client: Option<ClientConfig>,) -> io::Result<MaybeTlsStream>;MaybeTlsStream 转发 AsyncRead 和 AsyncWrite 的方式与 TransportStream 相同。accept.rs 和 connect.rs 各有一个私有的 maybe_tls,从服务端或客户端配置的 Option 构建它;WebSocket 和 gRPC 变体就是这样获得可选 TLS 的。
tcp_from_std 把一个 std socket 设为非阻塞,纳入 tokio 管理,并应用 set_keepalive。accept_optional_tcp(经由 tcp_from_std)和 wrap_optional_tcp 是 maybe_tls 的公开版本,接收所有权形式的配置。tcp_from_std 的文档注释称它是每个入站传输层都要经过的唯一转换点,但在当前版本中,workspace 和 katana 都没有调用这三个辅助函数:InboundTransport::accept 接收的是 tokio TcpStream,并自行设置 keepalive。
Keepalive
Section titled “Keepalive”protocols/src/transports/keepalive.rs
const TCP_KEEPALIVE_IDLE: Duration = Duration::from_secs(120);const TCP_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);const TCP_KEEPALIVE_RETRIES: u32 = 3;
pub fn set_keepalive(stream: &TcpStream);set_keepalive 通过 SockRef::from(stream) 应用一个带上述三个值的 socket2::TcpKeepalive。内核在静默 120 秒后发送第一个探测,之后每 30 秒重复一次;3 个探测都未得到应答后重置该 socket。因此,一个没有发送 FIN 就消失的对端会在最后一次通信后大约 210 秒被发现,而不是 Linux 默认的两个多小时。
该调用是尽力而为的。如果平台拒绝该选项,它会在 debug 级别记录 could not enable TCP keepalive: …,连接照常继续。keepalive 只能发现已失联的对端:一个会应答探测但不发送任何数据的存活对端,由这一层之上的空闲超时回收,而不是这里。已经发送过 FIN 的 socket 完全不会被探测。
environment/src/dial/socket.rs → SocketOptions 有自己的 tcp_keepalive: Option<Duration>,由 TcpDialer::socket 在连接前应用。对于经 TransportConnector 拨号的 socket,set_keepalive 在连接之后运行并覆盖它,因此上面的固定参数总是生效。应用传入的是 Dialer::default(),其中该字段为 None,所以目前两者并不冲突。
只有本模块中的这两条路径会调用 set_keepalive。不经过它们的 socket 不会获得这套参数:freedom 出站(app/src/outbound/freedom.rs → FreedomConnector)直接用 TcpDialer::connect_any 拨号目标,所以直连保持内核的 keepalive 默认值。
从配置到传输层
Section titled “从配置到传输层”app/src/transport.rs 存放入站和出站构建器共用的 stream 设置规则。inbound::build_inbound_transport 和 outbound::build_transport 几乎互为副本,只写进其中一个的规则,就是另一个缺失的规则。因此两者都调用 resolve_stream,各自只负责把得到的 StreamShape 转换为具体值。关于某种 network 需要哪些字段的新规则应放在 resolve_stream 中,而不是某个构建器里。
flowchart TB cfg["StreamConfig"] --> rs["resolve_stream"] rs --> tl["tls_layer"] rs --> shape["StreamShape"] shape --> ib["build_inbound_transport"] shape --> ob["build_transport"] ib --> it["InboundTransport"] ob --> tk["TransportKind"] tk --> tc["TransportConnector::new"] cfg -.->|"不带传输层的协议"| rej["reject_stream_settings"]
StreamConfig
Section titled “StreamConfig”app/src/config.rs → StreamConfig、TlsConfig。两者都带有 #[serde(deny_unknown_fields)],因此拼错的键会导致解析失败。
pub struct StreamConfig { pub network: Option<String>, pub security: Option<String>, pub tls: TlsConfig, pub ws: WsStreamConfig, pub grpc: GrpcStreamConfig,}
pub struct TlsConfig { pub server_name: Option<String>, pub allow_insecure: bool, pub ca_file: Option<String>, pub cert_file: Option<String>, pub key_file: Option<String>,}这些键面向用户的说明见传输层指南。
tls_layer:按 network 校验 security
Section titled “tls_layer:按 network 校验 security”pub fn tls_layer(network: &str, security: Option<&str>, ctx: &str) -> io::Result<bool>;tls_layer 回答的问题是“这个 network 下面是否要垫一层 TLS?”。它按 network 校验 security,而不是简单地与字面量 "tls" 比较,并且 fail closed(出错即拒绝)。如果一个无法识别的值被悄悄当作“不用 TLS”,监听器或拨号器就会违背运维者的本意以明文启动。唯一的症状将是握手失败,而此时代理凭据已经在网络上传输过了。
security 先去除首尾空白,再区分大小写进行匹配:None、"" 和 "none" 表示不用 TLS,"tls" 表示使用 TLS,其他任何值(包括 "TLS"、"reality" 和 "xtls")都是错误。
network |
未设置 security、"" 或 "none" |
security = "tls" |
其他任何 security |
|---|---|---|---|
tcp |
纯 TCP | 错误 | 错误 |
tls |
TLS | TLS(作为冗余写法接受) | 错误 |
ws |
明文 | WebSocket 之下垫 TLS | 错误 |
grpc |
明文 | HTTP/2 之下垫 TLS | 错误 |
| 其他任何值 | Ok(false),交给调用方处理 |
Ok(false),交给调用方处理 |
Ok(false),交给调用方处理 |
错误文本如下,其中 ctx 是 inbound <tag> 或 outbound <tag>:
<ctx>: unknown stream security "<value>" (expected "tls" or "none")<ctx>: security = "tls" is not valid with network = "tcp"; use network = "tls" for TLS over plain TCP (security = "tls" layers TLS under network = "ws" or "grpc")
network = "tcp" 加 security = "tls" 是 Xray 对 TLS over TCP 的写法。这里会拒绝它,并在错误信息中给出本地的写法,而不是按明文构建。与 network = "tls" 同时出现的冗余 security = "tls" 会被接受,因为从 Xray 迁移过来的配置常常两者都写,而把 TLS 说两遍并不矛盾。未知的 network 有意返回 Ok(false),即使 security 也无效,这样 resolve_stream 就能用它自己更清晰的信息报告该 network。
network 本身不会去除空白:" ws" 是未知的 network。
resolve_stream 与 StreamShape
Section titled “resolve_stream 与 StreamShape”#[derive(Debug, Clone, PartialEq, Eq)]pub enum StreamShape { Tcp, Tls, Ws { path: String, host: Option<String>, tls: bool, }, Grpc { service: String, authority: Option<String>, tls: bool, },}
pub fn resolve_stream(stream: &StreamConfig, ctx: &str) -> io::Result<StreamShape>;resolve_stream 把 network 默认为 "tcp",运行 tls_layer,然后构建 shape:
network |
Shape | 必填字段与默认值 | 错误 |
|---|---|---|---|
"tcp" |
StreamShape::Tcp |
无 | 无 |
"tls" |
StreamShape::Tls |
此处无;TLS 相关键由构建器读取 | 无 |
"ws" |
StreamShape::Ws |
path 默认为 "/";host 仍为可选 |
无 |
"grpc" |
StreamShape::Grpc |
必须通过 grpc.service_name 提供 service |
<ctx>: grpc stream needs grpc.service_name |
| 其他 | 无 | 无 | <ctx>: unknown stream network "<value>" |
app/src/inbound/mod.rs → build_inbound_transport(cfg: &InboundConfig) -> io::Result<InboundTransport>
| Shape | InboundTransport |
服务端 ALPN |
|---|---|---|
Tcp |
InboundTransport::Tcp |
无 |
Tls |
InboundTransport::Tls(ServerConfig::from_pem(…)) |
Alpn::None |
Ws { tls: true, .. } |
InboundTransport::ws(path, host, Some(…)) |
Alpn::Http1 |
Ws { tls: false, .. } |
InboundTransport::ws(path, host, None) |
无 |
Grpc { tls: true, .. } |
InboundTransport::grpc(service, Some(…)) |
Alpn::Http2 |
Grpc { tls: false, .. } |
InboundTransport::grpc(service, None) |
无 |
证书由 read_inbound_cert_key 读取,且只在 shape 需要 TLS 时读取。入站一侧只读取 tls.cert_file 和 tls.key_file;tls.server_name、tls.allow_insecure 和 tls.ca_file 能通过解析,但会被忽略。TLS 相关键本身也不决定任何事:设置了 cert_file 但没有 security = "tls" 的 ws 或 grpc stream 会以明文提供服务。它要求 tls.cert_file 和 tls.key_file 都存在,缺少其一时报告 inbound <tag>: tls stream needs tls.cert_file(或 tls.key_file)。gRPC 入站忽略 shape 中的 authority:服务端不检查客户端发送的 authority。WebSocket 入站没有 Host 回退;未设置 ws.host 时接受任何 Host。
transport_for 包装该构建器。在 Unix socket 的 listen 上,传输层没有意义,所以它以协议名 "<proto> over a unix socket" 调用 reject_stream_settings,并延迟运行构建器,使 Unix 监听器永远不会读取证书文件。
app/src/outbound/mod.rs → build_transport(cfg: &OutboundConfig, resolver: &Resolver) -> io::Result<TransportConnector>
| Shape | TransportKind |
客户端 ALPN | 使用的名称 |
|---|---|---|---|
Tcp |
TransportKind::Tcp |
无 | 无 |
Tls |
TransportKind::Tls(…) |
Alpn::None |
来自 require_sni 的 SNI |
Ws |
TransportKind::ws(host, path, tls) |
使用 TLS 时为 Alpn::Http1 |
Host:ws.host,否则 tls.server_name,否则 server |
Grpc |
TransportKind::grpc(authority, service, tls) |
使用 TLS 时为 Alpn::Http2 |
:authority:grpc.authority,否则 tls.server_name,否则 server |
require_sni 使用 tls.server_name,并回退到 server。两者都未设置时它拒绝猜测:<ctx>: <network> stream needs tls.server_name or server,其中 <network> 是 tls、ws+tls 或 grpc+tls。WebSocket 和 gRPC 的名称回退以同样的方式失败:<ctx>: ws stream needs ws.host or server、<ctx>: grpc stream needs grpc.authority or server。
client_tls_config 选择 VerifyMode:
tls.allow_insecure |
tls.ca_file |
VerifyMode |
|---|---|---|
false |
未设置 | System |
false |
已设置 | CustomCa(<file bytes>) |
true |
未设置 | Insecure |
true |
已设置 | 错误:outbound <tag>: tls.allow_insecure and tls.ca_file cannot both be set |
出站一侧从不读取 tls.cert_file 或 tls.key_file:ClientConfig 没有客户端证书,所以这些键能通过解析但会被忽略。
connector 获得 Dialer::default()、应用根据其 DNS 配置构建的解析器,以及来自出站 address_family 的策略(默认为 AddressFamilyStrategy::Auto;无法解析的值以 outbound <tag>: invalid address_family "<value>" 失败)。
reject_stream_settings
Section titled “reject_stream_settings”pub fn reject_stream_settings(stream: &StreamConfig, proto: &str, ctx: &str) -> io::Result<()>;有些协议从不查看 stream 配置块。SOCKS、Shadowsocks、Hysteria 2 和 TUN 入站要么自己掌管监听器,要么接在固定的传输层上,要么掌管一个网络接口;freedom、blackhole、wireguard 和 hysteria2 出站自行拨号。对于这些协议,构建器会调用 reject_stream_settings(通过每个构建器模块中一个小的 reject_stream 包装函数,由它提供 inbound <tag> 或 outbound <tag> 上下文)。它接受未设置、为空或为 "tcp" 的 network,以及未设置、为空或为 "none" 的 security(两者都去除首尾空白),否则以 <ctx>: protocol <proto> does not support stream network "<value>" 或 … does not support stream security "<value>" 失败。没有它,要求使用 WebSocket 的运维者只会得到一个裸端口,且没有任何报错。
| 不变量 | 由谁保证 | 由哪些测试固定 |
|---|---|---|
| 无论使用哪种传输层,协议核心看到的每条流都是同一种类型 | TransportStream 是唯一的 Accepted 类型,也是 TransportConnector 唯一的 Connector::Stream |
protocols/tests/pipeline/transports.rs 中的 tcp_round_trip、tls_round_trip、ws_round_trip、grpc_round_trip |
由 InboundTransport 接受或由 TransportConnector 拨出的 TCP socket 总会获得 keepalive 参数(尽力而为) |
InboundTransport::accept 开头以及 TransportConnector::dial 中 connect_any 之后的 set_keepalive |
没有测试固定 |
| 接受侧的传输层步骤不会超过 10 秒 | within 用 tokio::time::timeout(TRANSPORT_HANDSHAKE_TIMEOUT, …) 包装 TLS、升级和 HTTP/2 握手 |
没有测试固定 |
| 不打开任何流的 gRPC 连接会被释放 | serve_h2 中的 Liveness |
protocols/tests/unit/transports/accept.rs 中的 a_connection_that_opens_no_stream_is_given_up_on |
| 一个 gRPC 连接为每条 HTTP/2 流产出一条流 | serve_h2 为每个被接受的请求调用 sink |
protocols/tests/pipeline/transports.rs 中的 one_grpc_connection_carries_many_streams |
| TLS 服务端从不协商低于 TLS 1.2 的版本 | mozilla_intermediate_v5 加上 set_min_proto_version(Some(SslVersion::TLS1_2)) |
protocols/tests/unit/transports/tls_config.rs 中的 server_rejects_tls10、server_rejects_tls11、server_accepts_tls12、server_accepts_tls13 |
| 自定义 CA 扩展信任库,而不是跳过校验 | VerifyMode::CustomCa 先加载默认路径再添加证书;主机名校验保持开启 |
protocols/tests/pipeline/transports.rs 中的 tls_with_a_pinned_ca_round_trip |
| 代理传输层从不产出数据报 | dial 拒绝 DialNetwork::Udp;Datagram = NoDatagram 不可实例化 |
protocols/tests/pipeline/transports.rs 中的 transport_connector_refuses_udp |
无法识别的 security 永远不会意味着明文 |
对于未设置、""、"none" 和 "tls" 以外的每个值,tls_layer 都返回错误 |
app/tests/unit/transport.rs 中的 an_unknown_security_is_rejected_on_every_network、security_is_trimmed_before_matching |
Xray 的 tcp + tls 写法会被拒绝,并在错误信息中给出修正方法 |
tls_layer 的 "tcp" if requested 分支 |
app/tests/unit/transport.rs 中的 tcp_with_tls_is_rejected_and_names_the_fix |
network = "tls" 总是带 TLS;ws 和 grpc 只在要求时才带 |
tls_layer 的 match network |
app/tests/unit/transport.rs 中的 tls_network_carries_tls_with_or_without_a_redundant_security、ws_and_grpc_layer_tls_only_when_asked、tcp_without_security_is_plaintext |
未知的 network 由 resolve_stream 报告,而不是 tls_layer |
tls_layer 对它返回 Ok(false) |
app/tests/unit/transport.rs 中的 an_unknown_network_is_left_to_the_caller |
| 入站和出站应用相同的 stream 规则 | 两个构建器都经过 resolve_stream |
由结构保证;tls_layer 的测试覆盖两侧 |
| 无法承载传输层的协议会拒绝 stream 配置块 | reject_stream_settings |
app/tests/unit/transport.rs 中的 a_default_or_plain_tcp_stream_is_accepted、a_transport_the_protocol_cannot_honour_is_rejected、security_on_a_protocol_without_a_transport_is_rejected |
| 出站从不用猜测的名称校验证书 | 当 tls.server_name 和 server 都未设置时,require_sni 失败 |
没有测试固定 |
allow_insecure 与 ca_file 不能同时使用 |
client_tls_config |
对基于 TCP 的出站没有测试固定(Hysteria 2 出站自己的检查由 app/tests/unit/outbound.rs 中的 hysteria2_refuses_contradictory_certificate_settings 固定) |
失败路径与取消
Section titled “失败路径与取消”| 失败 | 错误 | 在哪里暴露 |
|---|---|---|
| 传输层握手超过 10 秒 | TimedOut,tls handshake timed out / websocket handshake timed out / grpc handshake timed out |
由 accept 返回;socket 被丢弃 |
TLS 握手失败(没有共同版本、ClientHello 有误) |
OpenSSL 错误,以 io::Error::other 表示 |
由 accept 返回 |
| 未知路径上的 gRPC 请求 | 无;该流以 REFUSED_STREAM 重置 |
连接继续提供服务 |
gRPC send_response 失败 |
无;跳过该流 | 连接继续提供服务 |
conn.accept() 返回 HTTP/2 错误 |
io::Error::other |
由 accept 返回;连接结束 |
app/src/serve.rs → serve_socket 在 debug 级别把 accept 返回的任何错误记录为 inbound transport failed: …,并且不再为该 socket 提供任何服务。已经交给 sink 的流在各自的 task 中运行。
accept 是一个普通的 future,因此 drop 它会取消它拥有的一切。在 sink 运行之前,它拥有的只是 socket 和未完成的握手。对于 gRPC,serve_h2 是驱动 HTTP/2 连接的唯一一方,所以一旦它被 drop,它产出的流就无法再收发数据。在应用中,accept future 和每个逐流 task 都在 spawn_scoped 下运行,并带有 generation(一代实例)的取消令牌;见服务入站。
| 失败 | 错误类型 | 文本 |
|---|---|---|
| UDP 目标 | Unsupported |
a proxy transport carries no datagrams of its own |
| 解析器失败 | 与 Resolver::resolve 返回的相同 |
见 DNS |
| 域名没有解析出任何地址 | NotFound |
dial: destination did not resolve |
| 地址族过滤后没有剩余地址 | AddrNotAvailable |
dial: no usable <strategy> destination address for <remote>:<port> |
| 所有地址都失败,无论原因 | ConnectionRefused |
failed to connect to any address (<addr>: <error>; …) |
| TLS 握手或校验失败 | io::Error::other |
OpenSSL 的错误栈 |
connect_any 从不返回单次尝试的错误:超过连接超时的尝试只会出现在该列表中,形如 <addr>: connect to <addr> timed out,而且即使每次尝试都超时,错误类型也是 ConnectionRefused。
connect 返回的 future 拥有它触及的一切,所以在任何 await 点 drop 它(客户端运行时放弃,或流被取消)都会关闭 socket 并放弃握手。
大多数配置错误都带有 inbound <tag> 或 outbound <tag>,如上文所列。ServerConfig::from_pem、ClientConfig::with_verify_mode 或证书文件读取内部产生的错误则没有:空的证书包只报告 no certificate in PEM bundle,非 PEM 的 CA 文件只报告 no certificate in CA PEM bundle,缺失的文件只报告操作系统错误。在这些函数中新增失败情形时,请记住运维者看到的错误不带 tag。
| 常量 | 值 | 定义位置 | 适用范围 |
|---|---|---|---|
TRANSPORT_HANDSHAKE_TIMEOUT |
10 秒 | protocols/src/transports/accept.rs |
接受侧的 TLS、WebSocket 升级和 HTTP/2 握手,按 socket 计 |
TCP_KEEPALIVE_IDLE |
120 秒 | protocols/src/transports/keepalive.rs |
发送第一个 keepalive 探测前的静默时长 |
TCP_KEEPALIVE_INTERVAL |
30 秒 | protocols/src/transports/keepalive.rs |
探测之间的间隔 |
TCP_KEEPALIVE_RETRIES |
3 | protocols/src/transports/keepalive.rs |
内核重置 socket 前允许未应答的探测次数 |
DEFAULT_CONNECT_TIMEOUT |
10 秒 | environment/src/dial/tcp.rs |
connect_any 中的每次 TCP 连接尝试 |
H2_IDLE_TIMEOUT |
300 秒 | protocols/src/transports/grpc/settings.rs |
没有存活流的被服务 gRPC 连接 |
H2_KEEPALIVE_INTERVAL |
60 秒 | protocols/src/transports/grpc/settings.rs |
被服务的 gRPC 连接发送 PING 的频率 |
H2_KEEPALIVE_TIMEOUT |
20 秒 | protocols/src/transports/grpc/settings.rs |
被服务的 gRPC 连接上对端应答 PING 的时限 |
| 最低 TLS 版本 | TLS 1.2 | protocols/src/transports/tls/config.rs |
ServerConfig 和 ClientConfig 两者 |
解析出多个地址时,最坏情况下的连接时间是地址数乘以 DEFAULT_CONNECT_TIMEOUT,因为 connect_any 中的尝试是顺序进行的,而不是竞速(这不是 Happy Eyeballs)。
| 文件 | 测试 | 固定的内容 |
|---|---|---|
protocols/tests/pipeline/transports.rs |
tcp_round_trip、tls_round_trip、tls_with_a_pinned_ca_round_trip、ws_round_trip、ws_over_tls_with_early_data_round_trip、ws_early_data_is_the_first_bytes_the_server_reads、ws_rejects_a_wrong_path_at_the_upgrade、grpc_round_trip、grpc_multi_mode_over_tls_round_trip、one_grpc_connection_carries_many_streams、transport_connector_refuses_udp |
每种 InboundTransport 都能接受对应 TransportKind 拨出的连接。assert_echo 发送 hello 和一个 200 000 字节的负载,关闭写方向,并期望流干净地结束。 |
protocols/tests/unit/transports/tls_config.rs |
server_accepts_tls12、server_accepts_tls13、server_rejects_tls10、server_rejects_tls11 |
服务端的版本下限,使用固定为单一版本的原始 SslConnector。 |
protocols/tests/unit/transports/accept.rs |
a_connection_that_opens_no_stream_is_given_up_on |
serve_h2 在 H2_IDLE_TIMEOUT 之后(而不是之前)放弃空闲的 HTTP/2 对端,在 tokio::io::duplex 上用暂停的时钟测试。 |
app/tests/unit/transport.rs |
tcp_without_security_is_plaintext、tcp_with_tls_is_rejected_and_names_the_fix、tls_network_carries_tls_with_or_without_a_redundant_security、ws_and_grpc_layer_tls_only_when_asked、an_unknown_security_is_rejected_on_every_network、security_is_trimmed_before_matching、an_unknown_network_is_left_to_the_caller、a_default_or_plain_tcp_stream_is_accepted、a_transport_the_protocol_cannot_honour_is_rejected、security_on_a_protocol_without_a_transport_is_rejected |
逐行覆盖 (network, security) 矩阵,以及 reject_stream_settings。 |
app/tests/integration/e2e_xray.rs |
app_client_tls_xray_server_tls、app_server_tls_xray_client_tls |
network = "tls" 与 Xray 的 tcp + tls 双向互通。未安装 Go 时这些测试会自行跳过。 |
pipeline 测试共用 protocols/tests/support 中的 self_signed_pem、tcp_dest 和 udp_dest。tls_pair 为 localhost 构建一个服务端和一个 Insecure 客户端;固定 CA 的测试则把服务端自己的证书作为 VerifyMode::CustomCa 传入。
cargo test -p etemenanki-protocols --test pipeline transportscargo test -p etemenanki-protocols --lib transportscargo test -p etemenanki-app --bin etemenanki-app transport