跳转到内容

拨号器与 socket 策略

源码文件:39 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/environment/Cargo.toml
  • Etemenanki/environment/src/lib.rs
  • Etemenanki/environment/src/dial/mod.rs
  • Etemenanki/environment/src/dial/socket.rs
  • Etemenanki/environment/src/dial/tcp.rs
  • Etemenanki/environment/src/dial/udp.rs
  • Etemenanki/environment/src/dial/quic.rs
  • Etemenanki/concepts/src/link.rs
  • Etemenanki/concepts/src/core.rs
  • Etemenanki/concepts/src/net.rs
  • Etemenanki/concepts/src/runtime.rs
  • Etemenanki/protocols/src/helpers/address_family.rs
  • Etemenanki/protocols/src/transports/connect.rs
  • Etemenanki/protocols/src/transports/keepalive.rs
  • Etemenanki/protocols/src/dns/mod.rs
  • Etemenanki/app/src/outbound/freedom.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/app/src/balancer.rs
  • Etemenanki/app/src/instance.rs
  • Etemenanki/protocols/src/socks/server.rs
  • Etemenanki/protocols/src/socks/udp_link.rs
  • Etemenanki/protocols/tests/unit/socks/server.rs
  • Etemenanki/protocols/tests/pipeline/socks.rs
  • Etemenanki/protocols/src/hysteria/connection.rs
  • Etemenanki/protocols/src/hysteria/server/endpoint.rs
  • Etemenanki/protocols/src/wireguard/connector.rs
  • Etemenanki/protocols/src/wireguard/slot.rs
  • Etemenanki/protocols/src/wireguard/device.rs
  • Etemenanki/environment/tests/integration.rs
  • Etemenanki/environment/tests/integration/tcp.rs
  • Etemenanki/environment/tests/integration/udp.rs
  • Etemenanki/environment/tests/integration/quic.rs
  • Etemenanki/environment/tests/unit/dial/socket.rs
  • Etemenanki/protocols/tests/unit/helpers/address_family.rs
  • katana/src/outbound/freedom.rs
  • katana/src/outbound/mod.rs
  • katana/src/runtime.rs
  • katana/src/manager/transport.rs
  • katana/tests/unit/outbound.rs

etemenanki-environment 的 dial 模块是主机 socket 诞生的地方。它包含三个拨号器(TCP、UDP,以及受 feature 控制的 QUIC),它们都在创建 socket 之后、绑定或连接之前应用同一套 SocketOptions 策略;另外还有一个 Dialer,把 TCP 和 UDP 拨号器打包成一个 Connector。与之配套的是 etemenanki-protocols 中的 helpers::address_family,它把一个 Destination 转换成拨号器逐个尝试的有序地址列表。

本页面向修改其中任何一半的贡献者:新增 socket 选项、改变直连或代理出站访问网络的方式,或者把内核移植到新平台。本页涵盖 environment/src/dial/ 中的每个公开类型、地址族辅助函数,以及 etemenanki-app 和 katana 在生产中实际调用了其中哪些接口。

模块文档在主机策略与其他一切之间划了一条清晰的界线。拨号器负责第一列的内容,其余部分归调用方。

决策 负责方 位置
源地址、网络接口、包标记(mark)、TCP keepalive 空闲时间、逐 socket 钩子 主机策略 environment/src/dial/socket.rs → SocketOptions
socket 以哪个地址族打开、v6-only UDP、单次尝试的连接超时、依次尝试各地址 拨号器 TcpDialer、UdpDialer、QuicDialer
名称解析、缓存、由哪个解析器应答 路由策略 protocols/src/dns/mod.rs → Resolver
出站可以使用解析结果中的哪些地址族,以及按什么顺序 路由策略 protocols/src/helpers/address_family.rs
TLS、ALPN、QUIC 传输参数 协议策略 调用方的 ClientConfig(TCP 传输层、quinn::ClientConfig)

由此得出两个结论,二者都是有意为之:

  • 没有任何拨号器接受域名。 TcpDialer 和 QuicDialer 接受已解析的 SocketAddr,UdpDialer 接受地址族,DualStackUdp 只向 IP 地址发送。environment 中没有任何代码执行 DNS 查询;DualStackUdp 遇到域名时直接拒绝,而不是去解析它。
  • 拨号器从不自行重试或竞速。 TcpDialer::connect_any 按给定顺序逐个尝试给定的列表;顺序本身来自 select_candidate_ips。

crate 根通过 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, clippy::arithmetic_side_effects)] 对整个 crate 强制无 panic 风格,仅在 cfg(test) 下放宽。

environment/src/dial/socket.rs 定义了这套策略以及围绕它的少量词汇。

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AddressFamily {
V4,
V6,
}
impl AddressFamily {
pub fn of(addr: &SocketAddr) -> Self;
pub fn of_ip(ip: IpAddr) -> Self;
pub fn unspecified(self) -> IpAddr;
pub fn is_v6(self) -> bool;
pub(crate) fn domain(self) -> socket2::Domain;
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Interface {
Name(CompactString),
Index(NonZeroU32),
}
pub type SocketHook = Arc<dyn Fn(&Socket) -> io::Result<()> + Send + Sync>;
#[derive(Clone, Default)]
pub struct SocketOptions {
pub bind_address: Option<IpAddr>,
pub interface: Option<Interface>,
pub mark: Option<u32>,
pub tcp_keepalive: Option<Duration>,
pub hook: Option<SocketHook>,
}
impl SocketOptions {
pub fn new() -> Self;
pub fn with_bind_address(mut self, ip: IpAddr) -> Self;
pub fn with_interface(mut self, interface: Interface) -> Self;
pub fn with_mark(mut self, mark: u32) -> Self;
pub fn with_tcp_keepalive(mut self, idle: Duration) -> Self;
pub fn with_hook(mut self, hook: SocketHook) -> Self;
pub fn bind_address_for(&self, family: AddressFamily) -> Option<IpAddr>;
pub fn apply(&self, socket: &Socket, family: AddressFamily) -> io::Result<()>;
}

SocketOptions::default() 什么都不设置:不绑定地址、不绑定接口、不设 mark、使用平台的 keepalive、没有钩子。生产中的每个调用方传入的都是这个默认值(见 app 与 katana 如何使用)。

这五个选项在两个地方应用,因为不同类型的 socket 绑定方式不同:

字段 应用者 效果
bind_address 拨号器,通过 bind_address_for(family) 只绑定到同一地址族的 socket 上。v4 绑定地址不影响 v6 socket,由内核选择源地址,反之亦然。
interface SocketOptions::apply 把 socket 绑定到一个网络接口;见下方的平台表。
mark SocketOptions::apply SO_MARK,用于策略路由。
hook SocketOptions::apply,最后执行 在原始的 socket2::Socket 上运行任意代码。
tcp_keepalive 仅 TcpDialer::socket 用 socket2::TcpKeepalive::new().with_time(idle) 设置 keepalive 空闲时间。探测间隔和探测次数保持平台默认值。UDP 和 QUIC socket 忽略此项。

apply 依次处理接口、mark、钩子,并返回遇到的第一个错误。它的文档注释写明了约定:绑定地址由拨号器负责应用。

SocketOptions 手工实现了 Debug,因此钩子闭包打印为 hook: Some("...")。

主机支持哪些功能在编译期通过 cfg(target_os) 决定。平台不具备的选项一律报错,绝不静默忽略,因为静默忽略会让流量从错误的路径发出而无人察觉。

选项 Linux Android、Fuchsia macOS、iOS、tvOS、watchOS、visionOS 其他(Windows、BSD)
Interface::Name socket.bind_device(SO_BINDTODEVICE) socket.bind_device(SO_BINDTODEVICE) Unsupported:binding by interface name needs an interface index on Apple platforms Unsupported
Interface::Index bind_device_by_index_v4 或 _v6,按 socket 的地址族选择 Unsupported bind_device_by_index_v4 或 _v6(IP_BOUND_IF、IPV6_BOUND_IF) Unsupported
mark socket.set_mark(SO_MARK) socket.set_mark(SO_MARK) Unsupported Unsupported
bind_address、hook 支持 支持 支持 支持
tcp_keepalive 支持 支持 支持 支持

除 Apple 平台的接口名情形外,所有 Unsupported 都来自 unsupported(what),其格式为 {what} is not supported on {std::env::consts::OS},例如 a socket mark is not supported on macos 或 binding a socket to an interface is not supported on windows。

在 Linux 上,如果进程缺少相应的 capability(单元测试的注释提到 CAP_NET_ADMIN 或 CAP_NET_RAW),内核可能拒绝设置 mark 和绑定设备。拒绝会以 EPERM 返回,apply 原样返回为 io::ErrorKind::PermissionDenied。单元测试 mark_and_loopback_device_take_effect_or_are_refused_by_the_kernel 对 mark 和 Interface::Name("lo") 恰好接受这两种结果:成功或 PermissionDenied。

SocketHook 是为嵌入方准备的,主要是移动端 VPN 应用:它自己的出站 socket 必须绕开它所提供的隧道。

  • 在 Android 上,钩子对原始描述符调用 VpnService.protect(fd);
  • 在 Apple 平台上,钩子设置 socket 的绑定接口。

钩子在接口和 mark 之后、任何绑定或连接之前运行,作用于拨号器打开的每个 socket(TCP、UDP,以及 QUIC endpoint 底下的 UDP socket)。它的错误原样传播:hook_runs_on_apply_and_its_error_propagates 检查 vpn refused to protect the socket 作为完全相同的错误文本返回。

environment/src/dial/tcp.rs:

pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone)]
pub struct TcpDialer {
options: SocketOptions,
connect_timeout: Duration,
}
impl TcpDialer {
pub fn new(options: SocketOptions) -> Self;
pub fn with_connect_timeout(mut self, timeout: Duration) -> Self;
pub fn options(&self) -> &SocketOptions;
pub fn connect_timeout(&self) -> Duration;
pub fn socket(&self, family: AddressFamily) -> io::Result<TcpSocket>;
pub async fn connect(&self, addr: SocketAddr) -> io::Result<TcpStream>;
pub async fn connect_any(&self, addrs: &[SocketAddr]) -> io::Result<TcpStream>;
}

TcpDialer::default() 即 TcpDialer::new(SocketOptions::default()),使用 DEFAULT_CONNECT_TIMEOUT。

  • socket(family) 创建该地址族的 tokio TcpSocket,通过 socket2::SockRef 应用策略,若 tcp_keepalive 为 Some 则设置 keepalive 空闲时间,若有 bind_address_for(family) 则将其绑定在端口 0 上。返回的 socket 尚未连接,调用方如需其他选项仍可继续设置。
  • connect(addr) 打开 socket(AddressFamily::of(&addr)),并用 tokio::time::timeout(self.connect_timeout, …) 包裹 socket.connect(addr)。如果计时器先触发,错误为 io::ErrorKind::TimedOut,消息为 connect to {addr} timed out。
  • connect_any(addrs) 依次对每个地址调用 connect,返回第一个连接成功的 stream。

connect_any 有意采用顺序尝试。它的文档注释点明了它要解决的场景(双栈主机解析到一个自己无法到达的地址,例如过期的 AAAA 记录,或一条黑洞化的 v6 路由),并明确声明这不是 Happy Eyeballs:各次尝试从不竞速,单次尝试超时限定了最坏情况,且每次失败都会被保留,不会丢失原因。

flowchart TB
  start["connect_any(addrs)"] --> more{"还有下一个地址?"}
  more -- 是 --> attempt["connect(addr):socket(family),然后在 connect_timeout 内连接"]
  attempt -- "Ok(stream)" --> done["返回 Ok(stream)"]
  attempt -- "Err(e)" --> record["failures.push(addr: e)"]
  record --> more
  more -- "否,failures 为空" --> none["Err ConnectionRefused:none given"]
  more -- "否,有失败记录" --> all["Err ConnectionRefused:拼接所有失败"]

最终错误的 kind 总是 io::ErrorKind::ConnectionRefused,无论各次失败的原因是什么。消息为 failed to connect to any address (…),括号内要么是 none given(空切片),要么是用 ; 拼接的每个 {addr}: {error}。需要知道每次尝试错误 kind 的调用方,必须自己调用 connect。

environment/src/dial/udp.rs:

#[derive(Debug, Clone, Default)]
pub struct UdpDialer {
options: SocketOptions,
}
impl UdpDialer {
pub fn new(options: SocketOptions) -> Self;
pub fn options(&self) -> &SocketOptions;
pub fn bind(&self, family: AddressFamily) -> io::Result<UdpSocket>;
pub fn bind_dual(
&self,
families: impl IntoIterator<Item = AddressFamily>,
) -> io::Result<DualStackUdp>;
}

bind(family) 使用 socket2 而不是 tokio 构建 socket,因为 v6-only 标志和策略都必须在绑定之前设置:

  1. Socket::new(family.domain(), Type::DGRAM, Some(Protocol::UDP))
  2. 仅对 V6:set_only_v6(true)
  3. options.apply(&socket, family)(接口、mark、钩子)
  4. set_nonblocking(true)
  5. 在端口 0 上绑定 bind_address_for(family),没有则绑定 family.unspecified()
  6. UdpSocket::from_std

IPv6 socket 按设计是 v6-only 的。双栈 socket 会把 IPv4 对端报告为 v4 映射地址(::ffff:192.0.2.1),它与数据报的发送目标地址不再相等,上层所有按对端地址维护状态的逻辑都会因此匹配不上。所以 IPv4 有自己独立的 socket,由 bind_dual 把两者配成一对。

bind_dual(families) 为每个请求的地址族绑定一个 socket,并容忍某个地址族失败。bind 失败会以 debug 级别记录为 udp: no {family:?} socket: {e},该地址族留空。只有当所有地址族都没绑定成功时,它才返回 io::ErrorKind::AddrNotAvailable,消息为以下两种之一:

情形 消息
至少请求了一个地址族,但都未绑定成功 udp: no usable local socket in any requested family
迭代器为空 udp: no address family requested

DualStackUdp 由每个地址族至多一个 socket 组成,作为一条链路统一寻址。

#[derive(Debug)]
pub struct DualStackUdp {
v4: Option<UdpSocket>,
v6: Option<UdpSocket>,
}
impl DualStackUdp {
pub fn v4(&self) -> Option<&UdpSocket>;
pub fn v6(&self) -> Option<&UdpSocket>;
pub fn socket_for(&self, peer: &SocketAddr) -> io::Result<&UdpSocket>;
pub fn poll_send_to(
&self,
cx: &mut Context<'_>,
buf: &[u8],
to: SocketAddr,
) -> Poll<io::Result<usize>>;
pub fn poll_recv_from(
&self,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<SocketAddr>>;
pub async fn send_to(&self, buf: &[u8], to: SocketAddr) -> io::Result<usize>;
pub async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)>;
}
impl DatagramLink for DualStackUdp {
type Addr = Destination;
// poll_send_to(&mut self, cx, buf, to: &Destination)
// poll_recv_from(&mut self, cx, buf) -> Poll<io::Result<Destination>>
}
flowchart LR
  send["poll_send_to(buf, to)"] --> pick{"AddressFamily::of(to)"}
  pick -- V4 --> s4["v4 socket"]
  pick -- V6 --> s6["v6 socket"]
  pick -- "该地址族未绑定" --> err["Err AddrNotAvailable"]
  recv["poll_recv_from(buf)"] --> r4{"v4 socket 就绪?"}
  r4 -- 是 --> got["Ready(peer)"]
  r4 -- "否或未绑定" --> r6{"v6 socket 就绪?"}
  r6 -- 是 --> got
  r6 -- "否或未绑定" --> pend["Pending,在每个已绑定的 socket 上注册 waker"]
  • 发送走 socket_for(&to),即与对端地址族相同的 socket。如果该地址族没有绑定,调用失败,返回 io::ErrorKind::AddrNotAvailable 和 udp: no local socket in the family of {peer}。
  • 接收先轮询 v4 socket,再轮询 v6 socket,返回找到的第一个数据报。未就绪的 socket 会注册任务的 waker,因此任一地址族上到达的数据报都会唤醒任务。由于设置了 v6-only 标志,返回的对端地址就是真实的源地址,绝不会是 v4 映射地址。
  • 作为 DatagramLink 时,链路以 Destination 寻址。poll_send_to 只接受 remote 为 IP 的 Destination(即 Destination::socket_addr() 为 Some)。域名会以 io::ErrorKind::Unsupported 和 udp: a plain dual-stack link cannot resolve a domain 失败,服务端运行时把它作为 Event::SendFailed 交给协议核心,而不关闭该 key。收到的对端地址以 Destination::udp(addr) 返回。

concepts crate 的 UdpOutbound 对单个 socket 的行为与此相同(a plain UDP outbound cannot resolve a domain)。解析应放在持有解析器的包装层中,例如 freedom 出站的 ResolvingUdp。

feature quic environment/src/dial/quic.rs 只在启用该 crate 的 quic feature 时编译,该 feature 会引入 quinn(进而引入 rustls)。它默认关闭。没有任何 workspace 成员或 katana 依赖启用它:protocols/src/hysteria/connection.rs 中的 Hysteria 2 客户端自己绑定 socket,自己构建 quinn::Endpoint。因此 QuicDialer 只在显式开启该 feature 时(例如 --all-features)才会编译,也只有 environment 的集成测试调用它。

pub type SocketWrap =
Box<dyn FnOnce(Arc<dyn AsyncUdpSocket>) -> io::Result<Arc<dyn AsyncUdpSocket>> + Send>;
#[derive(Debug, Clone, Default)]
pub struct QuicDialer {
udp: UdpDialer,
}
impl QuicDialer {
pub fn new(options: SocketOptions) -> Self;
pub fn options(&self) -> &SocketOptions;
pub fn endpoint(
&self,
family: AddressFamily,
wrap: Option<SocketWrap>,
) -> io::Result<Endpoint>;
pub async fn connect(
&self,
endpoint: &Endpoint,
addr: SocketAddr,
server_name: &str,
config: ClientConfig,
) -> io::Result<Connection>;
pub async fn dial(
&self,
addr: SocketAddr,
server_name: &str,
config: ClientConfig,
wrap: Option<SocketWrap>,
) -> io::Result<(Endpoint, Connection)>;
}
  • endpoint 从 UdpDialer::bind(family) 获得 socket(因此完整的 SocketOptions 策略和 v6-only 规则都适用),通过 TokioRuntime 上的 quinn::Runtime::wrap_udp_socket 包装,如果提供了 wrap 则再经过它处理,然后用 Endpoint::new_with_abstract_socket(EndpointConfig::default(), None, socket, Arc::new(TokioRuntime)) 构建一个仅客户端的 endpoint。
  • SocketWrap 替换 QUIC 底层的 socket,用于必须在 QUIC 加密封装之后看到每个包的混淆层。
  • connect 把 connect_with 的拒绝映射为 InvalidInput,把握手失败映射为 ConnectionRefused。
  • dial 同时返回 Endpoint 和 Connection,因为丢弃 endpoint 会关闭连接。

TLS 方面(证书校验、ALPN、传输参数)完全由调用方的 quinn::ClientConfig 决定。

environment/src/dial/mod.rs 把两个日常使用的拨号器打包起来,并使其成为一个 Connector,从而让服务端协议核心的 Effect::Open 可以直接落到主机 socket 上。

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DialTarget {
Tcp(Vec<SocketAddr>),
Udp(Vec<AddressFamily>),
}
#[derive(Debug, Clone, Default)]
pub struct Dialer {
pub tcp: TcpDialer,
pub udp: UdpDialer,
}
impl Dialer {
pub fn new(options: SocketOptions) -> Self;
}
impl Connector<DialTarget> for Dialer {
type Stream = TcpStream;
type Datagram = DualStackUdp;
type Future = DialFuture<TcpStream, DualStackUdp>;
fn connect(&mut self, target: DialTarget) -> Self::Future;
}
impl Connector<SocketTarget> for Dialer {
type Stream = TcpStream;
type Datagram = UdpOutbound;
type Future = DialFuture<TcpStream, UdpOutbound>;
fn connect(&mut self, target: SocketTarget) -> Self::Future;
}
type DialFuture<S, D> = Pin<Box<dyn Future<Output = io::Result<Outbound<S, D>>> + Send>>;

Dialer::new(options) 把同一份 SocketOptions 克隆到两个拨号器中。每次 connect 都把拨号器克隆进一个 boxed future,因此 future 拥有它所需的一切,不借用 Dialer。

目标 connect 的行为 结果
DialTarget::Tcp(addrs) tcp.connect_any(&addrs) Outbound::Stream(TcpStream)
DialTarget::Udp(families) udp.bind_dual(families) Outbound::Datagram(DualStackUdp)
SocketTarget::Tcp(addr) tcp.connect(addr)(单个地址) Outbound::Stream(TcpStream)
SocketTarget::Udp { ipv6 } ipv6 为真时 udp.bind(V6),否则 udp.bind(V4) Outbound::Datagram(UdpOutbound)

SocketTarget 是 concepts crate 自己的目标类型(concepts/src/link.rs),它不含策略的 SocketConnector 也实现了该目标。Dialer 与 SocketConnector 有三点不同:它应用 SocketOptions 策略,用 connect_timeout 限定 TCP 连接时长,并以 v6-only 方式打开 IPv6 UDP socket。生产代码不使用这两个 Connector 实现:各出站在完成自己的解析步骤后,直接调用 dialer.tcp.connect_any 和 dialer.udp.bind_dual。这些实现由 environment 的集成测试覆盖,其中包括一次完整的 ProxyServerRuntime 运行。

protocols/src/helpers/address_family.rs 回答拨号器拒绝回答的问题:给定一个 Destination,可以尝试哪些 IP,按什么顺序。它把答案拆成两个相互独立的输入:

  • 策略(policy),即运维人员的要求:一个 AddressFamilyStrategy;
  • 能力(capability),即出站实际能从哪些地址族发出流量:一个 FamilySupport。

对于由内核路由的拨号器,能力由内核回答(它查询路由表,并以 ENETUNREACH 快速失败),因此这些调用方传入 FamilySupport::both()。没有路由表的用户态 netstack(例如 WireGuard 出站)则根据隧道本地地址推导能力。

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum AddressFamilyStrategy {
#[default]
Auto,
Ipv4Only,
Ipv6Only,
PreferIpv4,
PreferIpv6,
}
impl AddressFamilyStrategy {
pub fn allows(self, ip: IpAddr) -> bool;
pub fn as_str(self) -> &'static str;
}
impl FromStr for AddressFamilyStrategy {
type Err = AddressFamilyStrategyParseError;
fn from_str(s: &str) -> Result<Self, Self::Err>;
}
#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
#[error("unknown address family strategy")]
pub struct AddressFamilyStrategyParseError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FamilySupport {
ipv4: bool,
ipv6: bool,
}
impl FamilySupport {
pub fn both() -> Self;
pub fn from_addrs(addrs: &[IpAddr]) -> Self;
pub fn supports(self, ip: IpAddr) -> bool;
pub fn describe(self) -> &'static str;
}
pub async fn resolve_candidates(
context: &str,
dest: &Destination,
strategy: AddressFamilyStrategy,
support: FamilySupport,
resolver: &Resolver,
) -> io::Result<Vec<IpAddr>>;
pub fn select_candidate_ips(
resolved: Vec<IpAddr>,
strategy: AddressFamilyStrategy,
support: FamilySupport,
) -> Vec<IpAddr>;
pub fn no_candidate_error(
context: &str,
dest: &Destination,
strategy: AddressFamilyStrategy,
support: FamilySupport,
) -> io::Error;
pub async fn destination_to_socketaddrs(
dest: &Destination,
strategy: AddressFamilyStrategy,
resolver: &Resolver,
) -> io::Result<Vec<SocketAddr>>;

FamilySupport::default() 即 both()。describe() 返回 IPv4 and IPv6、IPv4 only、IPv6 only 或 no address family。

FromStr 在匹配前会去掉首尾空白、转为小写,并把 - 视为 _。无法匹配的输入得到 AddressFamilyStrategyParseError(unknown address family strategy),两个程序都会把它转换成配置错误。

变体 as_str() 接受的写法
Auto auto auto、空字符串
Ipv4Only ipv4_only ipv4、v4、4、ipv4_only、ipv4only
Ipv6Only ipv6_only ipv6、v6、6、ipv6_only、ipv6only
PreferIpv4 prefer_ipv4 prefer_ipv4、prefer_v4、ipv4_prefer、v4_prefer
PreferIpv6 prefer_ipv6 prefer_ipv6、prefer_v6、ipv6_prefer、v6_prefer

因此 " Prefer-IPv4 " 会被解析为 PreferIpv4。对一个标签为 direct、设置了 address_family = "either" 的 freedom 出站运行 etemenanki-app --test,会失败并报 outbound direct: invalid address_family "either"。

resolve_candidates 先获取原始答案:

  • Remote::IpAddr(ip) 原样使用,不经过解析器。
  • Remote::Domain(name) 经过 resolver.resolve(name):缓存条目未过期时直接从缓存应答,否则询问后端。解析器已经做过去重。使用系统后端时,顺序与 getaddrinfo 返回的一致,后者会应用主机的地址选择规则。使用配置的 UDP、DoT 或 DoH 服务器时,A 和 AAAA 查询并发执行(tokio::join!),A 记录的答案排在 AAAA 记录之前。

随后 select_candidate_ips 按 strategy.allows(ip) && support.supports(ip) 过滤并重新排序。prefer_* 的排序是基于单个比特的稳定排序,因此每个地址族内部保持解析器给出的顺序,另一个地址族仍留在列表中作为后备。

策略 解析器答案 [2001:db8::1, 192.0.2.1, 2001:db8::2],能力为 both()
Auto 2001:db8::1, 192.0.2.1, 2001:db8::2(不变)
Ipv4Only 192.0.2.1
Ipv6Only 2001:db8::1, 2001:db8::2
PreferIpv4 192.0.2.1, 2001:db8::1, 2001:db8::2
PreferIpv6 2001:db8::1, 2001:db8::2, 192.0.2.1

当能力为 FamilySupport::from_addrs(&[192.0.2.2])(仅 v4)时,即使是 Auto 也会丢弃所有 IPv6 候选地址。

destination_to_socketaddrs 是面向内核路由场景的快捷方式:先调用 resolve_candidates("dial", dest, strategy, FamilySupport::both(), resolver),再把每个 IP 与 dest.port 配对。它保留所有候选地址,而不像简单的 lookup_host(..).next() 那样只取一个,这正是 connect_any 能够回退的前提。

情形 Kind 消息
查询本身失败:getaddrinfo 出错,或在使用配置的服务器时没有返回任何地址且 A、AAAA 查询中至少有一个失败 以后端报告的为准 后端的错误,原样返回(使用配置的服务器时,为最后一个失败查询的错误)
查询成功但没有地址 NotFound dns: {host} did not resolve(来自 Resolver::resolve)
解析器返回空列表(防御性检查) NotFound {context}: destination did not resolve
已解析出地址(或给出的是 IP 字面量),但策略和能力把它们全部排除 AddrNotAvailable {context}: no usable {strategy} destination address for {remote}:{port}
同上,且能力比 both() 更窄 AddrNotAvailable 同上,后接 (local address supports {describe()})

对于内核路由的拨号器,能力子句被有意省略:此时能力总是“both”,附上它只会误导。

一次经过代理传输层的拨号按顺序展示了每个部件。protocols/src/transports/connect.rs 中的 TransportConnector::dial 是所有基于 TCP 的代理出站(SOCKS、HTTP、Trojan、VLESS、VMess、Shadowsocks;不包括 Hysteria 2 和 WireGuard)都会走的路径:

sequenceDiagram
  participant T as TransportConnector
  participant F as address_family
  participant R as Resolver
  participant D as TcpDialer
  participant K as 内核
  T->>F: destination_to_socketaddrs(dest, strategy, resolver)
  opt remote 是域名
    F->>R: resolve(domain)
    R-->>F: 去重后的 IpAddr 列表
  end
  F-->>T: 过滤并排序后的候选地址,与 dest.port 配对
  T->>D: connect_any(addrs)
  loop 逐个地址,直到有一个连接成功
    D->>K: socket(family),应用策略,绑定
    D->>K: 在 connect_timeout 内连接
    K-->>D: stream,或记入 failures 的错误
  end
  D-->>T: TcpStream,或列出所有失败的 ConnectionRefused
  T->>T: set_keepalive(tcp),然后包装为 TLS、WS 或 gRPC

TransportConnector::dial 会一开始就以 Unsupported(a proxy transport carries no datagrams of its own)拒绝 UDP 目标,因为代理的 UDP 是承载在其 stream 之内的。连接成功后它调用 transports::keepalive::set_keepalive,设置空闲时间 TCP_KEEPALIVE_IDLE(120 秒)、间隔 TCP_KEEPALIVE_INTERVAL(30 秒)和 TCP_KEEPALIVE_RETRIES(3),平台拒绝时只在 debug 级别记录日志。这与 SocketOptions::tcp_keepalive 无关,默认选项不设置后者。

两个程序都没有在配置中暴露 SocketOptions。生产中拨号器打开的每个 socket 都使用 SocketOptions::default(),被调用的拨号器方法只有 TcpDialer::connect_any 和 UdpDialer::bind_dual。可配置的部分是每个出站的 address_family,它会转换成一个 AddressFamilyStrategy;面向用户的说明见 出站。

调用方 拨号器与选项 environment 调用 地址族策略
app/src/outbound/freedom.rs → FreedomConnector(TCP 流) Dialer::new(SocketOptions::default()) tcp.connect_any destination_to_socketaddrs(&dest, strategy, &resolver)
FreedomConnector(UDP 流) 同上 udp.bind_dual(self.families()) 逐包处理,经由 ResolvingUdp
app/src/outbound/mod.rs → build_transport(SOCKS、HTTP、Trojan、VLESS、VMess、Shadowsocks 出站) TransportConnector::new 内部的 Dialer::default() tcp.connect_any 使用出站策略的 destination_to_socketaddrs

FreedomConnector 实现了 Connector<Flow>。UDP 流在拨号时从不解析任何名称:它为策略允许的每个地址族绑定一个 socket(Ipv4Only 得到 [V4],Ipv6Only 得到 [V6],其余三种得到 [V4, V6]),并返回包装了 DualStackUdp 的 ResolvingUdp。

  • 域名目标。 链路在第一个发往某名称的包到来时,用 destination_to_socketaddrs 解析它(因此能力为 FamilySupport::both()),并把第一个候选地址保存在每个关联各自的 HashMap 中。包从与该候选地址同族的 socket 发出;如果该地址族没有绑定,DualStackUdp 会以 AddrNotAvailable(udp: no local socket in the family of …)使发送失败。
  • 一次只进行一个查询。 链路最多持有一个查询 future,并以其名称标记。查询进行期间,发往该名称的发送返回 Pending,发往其他尚未缓存名称的发送也一样。ProxyServerRuntime 严格按顺序应用 effect,因此排在挂起发送之后的一切也都要等待该查询。
  • 解析失败的名称会被记为 None。这包括任何查询错误(含超时),以及没有可用地址的名称。发往它们的包会被丢弃,发送报告 Ok(buf.len()),debug 日志为 freedom: dropping a datagram to an unresolvable …。
  • IP 目标直接交给 DualStackUdp,不经过策略过滤。只有 socket 的选择体现了策略:在 ipv4_only 下不存在 v6 socket,因此发往 IPv6 地址会以 AddrNotAvailable 失败。

parse_address_family 把缺失的键映射为 Auto,把非法值映射为 outbound {tag}: invalid address_family {raw:?}。

有几个子系统自行打开 socket。新增的 SocketOptions 选项不会作用到它们,除非同时修改它们。

Socket 位置 地址族处理
入站监听器 app/src/instance.rs → bind_inbound,katana src/manager/transport.rs 监听地址
发往配置的 DNS 服务器的查询 protocols/src/dns/mod.rs UDP 查询绑定与服务器地址同族的通配地址并连接到服务器;DoT 和 DoH 使用 TcpStream::connect(server);系统后端调用 getaddrinfo,自身不打开 socket
负载均衡器健康探测 app/src/balancer.rs → probe destination_to_socketaddrs(…, Auto, …),然后在一个总超时内对每个地址执行 TcpStream::connect
SOCKS 服务端 UDP 中继 protocols/src/socks/server.rs → SocksInbound::associate、ExpectedSender 绑定在配置的 udp_bind 地址上,否则绑定在控制连接的本地 IP 上,端口 0。中继只接收来自控制连接 IP 的数据报(经 Unix socket 时,则是请求中给出的确切地址和端口),比较时使用规范形式(见关联的客户端)。如果关联的客户端所在的地址族是绑定地址收不到的(hears:IPv4 地址或 IPv4 映射地址只收 IPv4,:: 两者都收,其他 IPv6 地址只收 IPv6),则在绑定任何 socket 之前以 0x02 拒绝该关联(由 protocols/tests/unit/socks/server.rs 中的 a_relay_that_cannot_hear_the_client_is_refused 和 protocols/tests/pipeline/socks.rs 中的 udp_association_refuses_a_relay_that_cannot_hear_the_client 固定)
SOCKS 出站 UDP socket app/src/outbound/mod.rs,katana src/outbound/mod.rs 绑定在中继地址的地址族上;SocksUdpLink 只保留来自中继的数据报。从 etemenanki-protocols 2.0.2 起它以规范形式比较地址;katana v3.0.1 基于 2.0.1,比较的是原始的 SocketAddr。socket 与中继同属一个地址族时,两种比较都恰好只接受中继的回复
Hysteria 2 客户端 protocols/src/hysteria/connection.rs → Hy2Conn::connect、bind_socket destination_to_socketaddrs,然后是它自己的顺序循环;每次尝试绑定与该地址同族的通配地址,绑定、QUIC 握手和认证共用 CONNECT_TIMEOUT(10 秒)
Hysteria 2 服务端 protocols/src/hysteria/server/endpoint.rs 监听地址
WireGuard endpoint protocols/src/wireguard/device.rs → WgDevice::start 用 tokio::net::lookup_host 解析对端 endpoint 并取第一个答案,然后绑定该地址族的通配地址并连接 socket
WireGuard 隧道内 TCP(用户态 netstack,非主机 socket) protocols/src/wireguard/connector.rs,protocols/src/wireguard/slot.rs → connect_tcp_any 使用 FamilySupport::from_addrs(local_addrs) 调用 resolve_candidates,然后是顺序循环,每个地址 TCP_CONNECT_ATTEMPT_TIMEOUT(10 秒)

Hysteria 和 WireGuard 的循环遵循与 connect_any 相同的规则:顺序尝试、每次尝试限时、最终错误包含所有失败。它们的最终错误不同:Hysteria 返回 ConnectionRefused 和 hysteria2: no address answered (…),WireGuard 返回 TimedOut 和 wireguard: tunnel TCP connect failed for all resolved addresses (…)。负载均衡器的探测更简单:它在第一个连接成功的地址处停止,只报告可用或不可用。

不变量 保证方式 测试覆盖
策略在创建之后、绑定或连接之前应用 TcpDialer::socket 和 UdpDialer::bind 在绑定前调用 SocketOptions::apply;connect 总是经过 socket 没有测试检查顺序;connects_and_binds_the_requested_source_address(environment/tests/integration/tcp.rs)检查绑定地址生效
绑定地址只绑定同族的 socket SocketOptions::bind_address_for 用 AddressFamily::of_ip 过滤 bind_address_applies_only_to_its_own_family(environment/tests/unit/dial/socket.rs)
不支持的选项是错误,绝不是空操作 各操作系统 cfg 分支下的 bind_interface、bind_interface_index、set_mark 返回 ErrorKind::Unsupported 没有测试触及 Unsupported 分支,它们在 Linux 上不会编译;mark_and_loopback_device_take_effect_or_are_refused_by_the_kernel 覆盖 Linux 一侧(生效或 PermissionDenied)
钩子的错误会中止该 socket apply 返回 hook(socket)? hook_runs_on_apply_and_its_error_propagates
钩子的函数体绝不出现在日志中 手工实现的 Debug for SocketOptions debug_hides_the_hook_body
单次连接尝试不会拖住其余尝试 connect 中的 tokio::time::timeout(connect_timeout, …) 没有测试触发计时器;connect_any_falls_through_a_dead_address 使用一个立即拒绝的端口
一个不可达地址不会让整个目标无法访问 connect_any 尝试每个候选地址;解析保留所有答案 connect_any_falls_through_a_dead_address;auto_keeps_resolver_order_when_both_families_are_supported
不丢失任何失败原因 connect_any 拼接每个 {addr}: {e};空列表时报 none given connect_any_falls_through_a_dead_address
UDP 对端以其发送目标地址的形式报告 v6 socket 设置 set_only_v6(true);v4 有自己的 socket v6_socket_is_v6_only_and_dual_stack_picks_by_family(environment/tests/integration/udp.rs)将每个回复的源地址与 echo 服务器地址比较;它不回读 IPV6_V6ONLY
数据报从与对端同族的 socket 发出 DualStackUdp::socket_for v6_socket_is_v6_only_and_dual_stack_picks_by_family(在仅 v4 的链路上发送 v6 得到 AddrNotAvailable)
双栈绑定仅在一个都没绑定时失败 bind_dual 跳过失败的地址族,最后检查两个槽位 只覆盖空输入情形:v6_socket_is_v6_only_and_dual_stack_picks_by_family 检查 bind_dual([]) 报错;没有测试让某个地址族失败
environment 从不解析名称 DatagramLink for DualStackUdp 以 Unsupported 拒绝域名 environment 中没有专门的测试;DialTarget::Udp 运行时测试只向 IP 发送
prefer_* 只重排、绝不丢弃另一地址族 select_candidate_ips 中的稳定 sort_by_key prefer_ipv4_keeps_ipv6_as_fallback(protocols/tests/unit/helpers/address_family.rs)
即使在 Auto 下,能力也会过滤 过滤条件中的 support.supports(ip) auto_skips_families_without_a_local_address
内核路由的拨号器只受策略限制 调用方传入 FamilySupport::both() a_kernel_routed_dialer_is_limited_by_policy_alone;the_capability_clause_is_omitted_for_a_kernel_routed_dialer

TcpDialer 和 UdpDialer 不派生任务,也不持有锁。每个操作要么是同步的(bind、bind_dual、socket、endpoint),要么是由调用方持有的单个 future。唯一的例外是 QuicDialer::endpoint 构建的 quinn::Endpoint:quinn 会在 tokio 运行时上派生自己的驱动任务,其生命周期与 endpoint 相同。

  • 取消。 丢弃 connect 或 connect_any future 会丢弃正在进行的 TcpSocket 并关闭它。尚未尝试的地址不会被触碰。Dialer::connect 返回的 boxed future 持有拨号器的克隆,因此可以在任意时刻丢弃,不影响 Dialer。
  • 部分构造。 apply、keepalive 或绑定失败的 socket 会在返回前被丢弃,因此不会泄漏配置了一半的 socket。
  • 超时。 拨号器中唯一的计时器是 TcpDialer::connect 中的单次尝试 connect_timeout。bind、bind_dual、socket 和 endpoint 是同步系统调用,不等待网络。

调用方可能看到的错误汇总:

来源 Kind 消息
TcpDialer::connect,计时器触发 TimedOut connect to {addr} timed out
TcpDialer::connect_any ConnectionRefused failed to connect to any address ({addr}: {e}; …) 或 (none given)
UdpDialer::bind_dual AddrNotAvailable udp: no usable local socket in any requested family / udp: no address family requested
DualStackUdp::socket_for AddrNotAvailable udp: no local socket in the family of {peer}
DatagramLink for DualStackUdp Unsupported udp: a plain dual-stack link cannot resolve a domain
在不支持该选项的平台上调用 SocketOptions::apply Unsupported {what} is not supported on {os},或 Apple 平台的接口名消息
SocketOptions::apply,内核拒绝 以操作系统报告为准,通常为 PermissionDenied 操作系统错误
QuicDialer::connect InvalidInput(配置或地址错误)、ConnectionRefused(握手失败) quinn 的错误文本
常量 值 定义位置 含义
DEFAULT_CONNECT_TIMEOUT 10 秒 environment/src/dial/tcp.rs 单次连接尝试的超时;可用 TcpDialer::with_connect_timeout 覆盖
connect_any 的最坏情况 候选地址数 × connect_timeout 由顺序循环推出 另加解析时间
TCP_KEEPALIVE_IDLE / TCP_KEEPALIVE_INTERVAL / TCP_KEEPALIVE_RETRIES 120 秒 / 30 秒 / 3 protocols/src/transports/keepalive.rs 由 TransportConnector::dial 在连接之后应用
UDP 源端口 0(临时端口) UdpDialer::bind 每次绑定都向内核申请一个新端口
MAX_RESOLVED_NAMES 256 katana src/outbound/freedom.rs katana 中每个直连 UDP 关联缓存的名称数

environment 的测试运行在真实的回环 socket 上。environment/tests/integration.rs 总是引入 TCP 和 UDP 模块,只在 cfg(feature = "quic") 下引入 QUIC 模块;单元测试通过 #[path] 挂载到 socket.rs 上。

测试 文件 覆盖内容
bind_address_applies_only_to_its_own_family environment/tests/unit/dial/socket.rs bind_address_for 对本地址族返回地址,对另一地址族返回 None
family_of_addresses 同上 AddressFamily::of 和 unspecified
hook_runs_on_apply_and_its_error_propagates 同上 每次 apply 运行一次钩子;其错误文本原样返回
mark_and_loopback_device_take_effect_or_are_refused_by_the_kernel 同上(仅 Linux) with_mark(7) 和 Interface::Name("lo") 要么生效,要么以 PermissionDenied 失败
debug_hides_the_hook_body 同上 Debug 打印 hook: Some("...")
connects_and_binds_the_requested_source_address environment/tests/integration/tcp.rs 绑定地址成为连接的源地址
connect_any_falls_through_a_dead_address 同上 回退到第二个地址;错误文本包含不可达地址;空输入报 none given
dialer_is_a_connector_for_the_runtime 同上 Connector::<DialTarget> 产出可用的 Outbound::Stream
v6_socket_is_v6_only_and_dual_stack_picks_by_family environment/tests/integration/udp.rs 按地址族收发、真实的对端地址、缺失地址族时的 AddrNotAvailable、bind_dual([]) 失败
dual_stack_link_serves_a_proxy_runtime 同上 一个玩具协议核心(TinyUdp)在 ProxyServerRuntime 下打开 DialTarget::Udp,并双向中继一个数据报
socket_target_connector_binds_one_family 同上 ipv6: false 时 Connector::<SocketTarget> 绑定一个 IPv4 socket
dials_a_quic_server_over_loopback environment/tests/integration/quic.rs(feature quic) QuicDialer::dial 遵守绑定地址并能承载一个 stream
a_socket_wrap_sees_the_endpoint_socket 同上 构建 endpoint 时会调用 SocketWrap
parses_address_family_strategy_aliases protocols/tests/unit/helpers/address_family.rs ipv4-only 和 prefer_ipv6 可以解析;either 不行
auto_skips_families_without_a_local_address 同上 Auto 下能力仍会过滤
auto_keeps_resolver_order_when_both_families_are_supported 同上 Auto 不重排
ipv4_only_filters_to_ipv4 同上 Ipv4Only 丢弃 IPv6
prefer_ipv4_keeps_ipv6_as_fallback 同上 稳定重排
a_kernel_routed_dialer_is_limited_by_policy_alone 同上 FamilySupport::both() 不过滤任何地址
the_capability_clause_is_omitted_for_a_kernel_routed_dialer 同上 只有能力更窄时,错误文本才附加 local address supports IPv4 only

QUIC 测试需要启用该 feature:运行 cargo test -p etemenanki-environment --features quic。workspace 门槛命令 cargo clippy --workspace --all-targets --all-features 会编译它们但不运行,cargo test --workspace 则跳过它们。mark 与设备测试不会回读选项:无论内核应用了选项还是以 PermissionDenied 拒绝,它都会通过。

  • 新增选项。 把它加到 SocketOptions 上并提供 with_* 构建方法,在 apply 中应用(如果依赖 socket 类型,则在拨号器中应用),并为每个平台提供 cfg 分支。平台没有对应功能时,返回 unsupported(…),不要跳过。注意 bind_dual 会把这类错误变成被跳过的地址族,而且 不经过拨号器的 socket 中列出的 socket 不会受到该选项影响。
  • 保持 connect_any 顺序执行。 它的文档注释声明它不是 Happy Eyeballs。只要尝试是顺序的,流使用哪个地址就完全由 select_candidate_ips 给出的顺序决定,错误中也为每个尝试过的地址列出一条。改为竞速会让每个出站的这两点都发生变化。
  • 让解析留在 environment 之外。 带解析功能的链路属于持有解析器的程序,就像 app 和 katana 中的 ResolvingUdp 那样。
  • 下游影响。 katana 从私有 Cargo registry 消费 etemenanki-environment 和 etemenanki-protocols,版本以其 lockfile 解析结果为准,并保留自己的 freedom 出站。修改 DualStackUdp、bind_dual 或地址族辅助函数时,需要相应检查 katana 的 src/outbound/freedom.rs。