跳转到内容

传输层:TCP 与 TLS

源码文件:37 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/transports/mod.rs
  • Etemenanki/protocols/src/transports/accept.rs
  • Etemenanki/protocols/src/transports/connect.rs
  • Etemenanki/protocols/src/transports/stream.rs
  • Etemenanki/protocols/src/transports/keepalive.rs
  • Etemenanki/protocols/src/transports/tls/mod.rs
  • Etemenanki/protocols/src/transports/tls/config.rs
  • Etemenanki/protocols/src/transports/tls/stream.rs
  • Etemenanki/protocols/src/transports/grpc/settings.rs
  • Etemenanki/protocols/src/transports/grpc/liveness.rs
  • Etemenanki/protocols/src/transports/grpc/stream.rs
  • Etemenanki/protocols/src/helpers/address_family.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/dns/mod.rs
  • Etemenanki/protocols/src/hysteria/connection.rs
  • Etemenanki/environment/src/dial/mod.rs
  • Etemenanki/environment/src/dial/tcp.rs
  • Etemenanki/environment/src/dial/socket.rs
  • Etemenanki/concepts/src/link.rs
  • Etemenanki/concepts/src/client.rs
  • Etemenanki/app/src/transport.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/inbound/tun.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/app/src/outbound/proxy.rs
  • Etemenanki/app/src/outbound/freedom.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/protocols/tests/pipeline/transports.rs
  • Etemenanki/protocols/tests/support/mod.rs
  • Etemenanki/protocols/tests/unit/transports/accept.rs
  • Etemenanki/protocols/tests/unit/transports/tls_config.rs
  • Etemenanki/app/tests/unit/transport.rs
  • Etemenanki/app/tests/integration/e2e_xray.rs
  • katana/src/inbound.rs
  • katana/src/outbound/mod.rs
  • katana/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。

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;

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 按顺序做三件事:

  1. 对被接受的 socket 调用 set_keepalive(&tcp)。
  2. 在 within 中运行传输层自身的握手,within 用 tokio::time::timeout(TRANSPORT_HANDSHAKE_TIMEOUT, …) 包装该 future。
  3. 把得到的每条流交给 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,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(())

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"]
  1. 拒绝 UDP。 network 为 DialNetwork::Udp 的 Destination 会立即失败,错误为 io::ErrorKind::Unsupported,文本为 a proxy transport carries no datagrams of its own。其他所有 DialNetwork 值都按 TCP 拨号。
  2. 解析。 protocols/src/helpers/address_family.rs → destination_to_socketaddrs 以 connector 的 Resolver、AddressFamilyStrategy 和 FamilySupport::both() 调用 resolve_candidates("dial", …)。IP 字面量跳过解析器。所有解析结果都会保留,而不只是第一个,然后按策略过滤;PreferIpv4 和 PreferIpv6 用稳定排序重新排列,并把另一个地址族保留为后备。
  3. 连接。 environment/src/dial/tcp.rs → TcpDialer::connect_any 按顺序逐个尝试这些地址。每次尝试都受拨号器的连接超时(DEFAULT_CONNECT_TIMEOUT,10 秒)限制。第一次成功即采用;全部失败时,错误中列出每个地址及其失败原因。
  4. keepalive 与包装。 在连接好的 socket 上运行 set_keepalive,然后由 TransportKind::wrap 在其上叠加 TLS、WebSocket 或 gRPC。

TransportConnector 为 Destination 实现了 concepts crate 的 Connector:

concepts/src/link.rs
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.rs
type 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:

  1. 用 X509::stack_from_pem 解析 PEM 证书包。第一张证书是叶证书;如果一张都没有,就以 InvalidInput 和 no certificate in PEM bundle 失败。
  2. 用 PKey::private_key_from_pem 解析私钥。
  3. 以 SslAcceptor::mozilla_intermediate_v5(SslMethod::tls()) 为起点,这是 openssl crate 对 Mozilla intermediate 服务端配置(v5)的实现。它设置 NO_TLSV1 | NO_TLSV1_1,加载 RFC 7919 的 ffdhe2048 DH 组,并固定下文所列的密码套件列表。密钥交换组则沿用 OpenSSL 的默认值。
  4. 用 set_min_proto_version(Some(SslVersion::TLS1_2)) 显式把最低版本设为 TLS 1.2,使版本下限不只依赖选项位。TLS 1.3 保持启用。
  5. 装入私钥和叶证书,用 add_extra_chain_cert 添加证书包中其余的每张证书,并运行 check_private_key,这样与叶证书不匹配的私钥会在构建时就失败。
  6. 如果 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 地址比对。

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 客户端。

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。

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 默认值。

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

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>,
}

这些键面向用户的说明见传输层指南。

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。

#[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 监听器永远不会读取证书文件。

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 固定)
失败 错误 在哪里暴露
传输层握手超过 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 transports
cargo test -p etemenanki-protocols --lib transports
cargo test -p etemenanki-app --bin etemenanki-app transport