Skip to content

Dialers and socket policy

Source files: 39 · checked against 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

The dial module of etemenanki-environment is where a host socket is born. It holds three dialers (TCP, UDP and, behind a feature, QUIC) that all apply one SocketOptions policy between creating a socket and binding or connecting it, plus a Dialer that bundles the TCP and UDP dialers as a Connector. Next to it, in etemenanki-protocols, helpers::address_family turns a Destination into the ordered list of addresses a dialer walks.

This page is for contributors who touch either half: adding a socket knob, changing how a direct or proxy outbound reaches the network, or porting the kernel to a platform. It covers every public type in environment/src/dial/, the address-family helpers, and exactly which of these calls etemenanki-app and katana make in production.

The module docs draw a hard line between host policy and everything else. The dialers own the first column; the rest belongs to their callers.

Decision Owner Where
Source address, interface, packet mark, TCP keepalive idle, a per-socket hook Host policy environment/src/dial/socket.rs → SocketOptions
Which address family a socket is opened in, v6-only UDP, per-attempt connect timeout, trying addresses in turn Dialers TcpDialer, UdpDialer, QuicDialer
Name resolution, caching, which resolver answers Routing policy protocols/src/dns/mod.rs → Resolver
Which resolved families an outbound may use, and in what order Routing policy protocols/src/helpers/address_family.rs
TLS, ALPN, QUIC transport parameters Protocol policy the caller’s ClientConfig (TCP transports, quinn::ClientConfig)

Two consequences follow, and both are deliberate:

  • No dialer accepts a name. TcpDialer and QuicDialer take resolved SocketAddrs, UdpDialer takes address families, and DualStackUdp sends only to IP addresses. Nothing in environment performs a DNS lookup; DualStackUdp refuses a domain rather than resolving it.
  • The dialers never retry or race on their own. TcpDialer::connect_any walks the list it is given, in the order given; the order itself comes from select_candidate_ips.

The crate root enforces a panic-free style for the whole crate with #![deny(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, clippy::arithmetic_side_effects)], relaxed only under cfg(test).

environment/src/dial/socket.rs defines the policy and the small vocabulary around it.

#[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() sets nothing: no bind, no interface, no mark, the platform’s keepalive, no hook. That default is what every production caller passes (see How the app and katana use it).

The five knobs are applied in two places, because binding differs per socket type:

Field Applied by Effect
bind_address the dialer, through bind_address_for(family) Bound only on a socket of the same family. A v4 bind address leaves v6 sockets to the kernel’s source selection, and vice versa.
interface SocketOptions::apply Ties the socket to one interface; see the platform table below.
mark SocketOptions::apply SO_MARK, for policy routing.
hook SocketOptions::apply, last Runs arbitrary code on the raw socket2::Socket.
tcp_keepalive TcpDialer::socket only Sets the keepalive idle time with socket2::TcpKeepalive::new().with_time(idle). Interval and probe count stay at the platform defaults. UDP and QUIC sockets ignore it.

apply runs interface, then mark, then hook, and returns the first error. Its doc comment states the contract: the bind address is the dialer’s to apply.

SocketOptions implements Debug by hand so the hook closure prints as hook: Some("...").

What the host supports is decided at compile time with cfg(target_os). A knob the platform lacks is an error, never a silent no-op, because a no-op would send traffic out of the wrong path without anyone noticing.

Knob Linux Android, Fuchsia macOS, iOS, tvOS, watchOS, visionOS Everything else (Windows, BSDs)
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 or _v6, chosen by the socket’s family Unsupported bind_device_by_index_v4 or _v6 (IP_BOUND_IF, IPV6_BOUND_IF) Unsupported
mark socket.set_mark (SO_MARK) socket.set_mark (SO_MARK) Unsupported Unsupported
bind_address, hook yes yes yes yes
tcp_keepalive yes yes yes yes

Every Unsupported except the Apple name case comes from unsupported(what), which formats {what} is not supported on {std::env::consts::OS}, for example a socket mark is not supported on macos or binding a socket to an interface is not supported on windows.

On Linux the kernel may refuse the mark and the device binding to a process without the needed capability (the unit test’s comment names CAP_NET_ADMIN or CAP_NET_RAW). A refusal comes back as EPERM, which apply returns unchanged as io::ErrorKind::PermissionDenied. The unit test mark_and_loopback_device_take_effect_or_are_refused_by_the_kernel accepts exactly those two outcomes, success or PermissionDenied, for a mark and for Interface::Name("lo").

SocketHook exists for embedders, chiefly a mobile VPN app whose own outbound sockets must stay out of the tunnel it provides:

  • on Android the hook calls VpnService.protect(fd) on the raw descriptor;
  • on Apple platforms it sets the socket’s bound interface.

The hook runs after interface and mark, before any bind or connect, on every socket a dialer opens (TCP, UDP and the UDP socket under a QUIC endpoint). Its error propagates unchanged: hook_runs_on_apply_and_its_error_propagates checks that vpn refused to protect the socket comes back as the exact error text.

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() is TcpDialer::new(SocketOptions::default()) with DEFAULT_CONNECT_TIMEOUT.

  • socket(family) creates a tokio TcpSocket of that family, applies the policy through a socket2::SockRef, sets the keepalive idle time if tcp_keepalive is Some, and binds bind_address_for(family) on port 0 if there is one. The socket is returned unconnected, so a caller that needs another option can still set it.
  • connect(addr) opens socket(AddressFamily::of(&addr)) and wraps socket.connect(addr) in tokio::time::timeout(self.connect_timeout, …). When the timer fires first the error is io::ErrorKind::TimedOut with connect to {addr} timed out.
  • connect_any(addrs) calls connect on each address in turn and returns the first stream that connects.

connect_any is sequential on purpose. Its doc comment names the case it exists for (a dual-stack host that resolves to an address it cannot reach, such as a stale AAAA record or a v6 route that black-holes) and says outright that this is not Happy Eyeballs: attempts are never raced, the per-attempt timeout bounds the worst case, and every failure is kept so the cause is not lost.

flowchart TB
  start["connect_any(addrs)"] --> more{"next address?"}
  more -- yes --> attempt["connect(addr): socket(family), then connect under connect_timeout"]
  attempt -- "Ok(stream)" --> done["return Ok(stream)"]
  attempt -- "Err(e)" --> record["failures.push(addr: e)"]
  record --> more
  more -- "no, failures empty" --> none["Err ConnectionRefused: none given"]
  more -- "no, failures recorded" --> all["Err ConnectionRefused: every failure, joined"]

The final error always has kind io::ErrorKind::ConnectionRefused, whatever the individual causes were. Its message is failed to connect to any address (…), where the parentheses hold either none given (an empty slice) or every {addr}: {error} joined with ; . A caller that needs the per-attempt kinds has to call connect itself.

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) builds the socket with socket2 rather than tokio, because the v6-only flag and the policy must be set before the bind:

  1. Socket::new(family.domain(), Type::DGRAM, Some(Protocol::UDP))
  2. For V6 only: set_only_v6(true)
  3. options.apply(&socket, family) (interface, mark, hook)
  4. set_nonblocking(true)
  5. Bind bind_address_for(family), or family.unspecified(), on port 0
  6. UdpSocket::from_std

The IPv6 socket is v6-only by design. A dual-stack socket would report an IPv4 peer back as a v4-mapped address (::ffff:192.0.2.1), which no longer equals the address the datagram was sent to. Every layer above that keys state by peer address would then miss. Instead, IPv4 gets its own socket and bind_dual pairs the two.

bind_dual(families) binds one socket per requested family and tolerates a family that fails. A failed bind is logged at debug level as udp: no {family:?} socket: {e} and that family is left empty. Only when no family bound at all does it return io::ErrorKind::AddrNotAvailable, with one of two messages:

Situation Message
At least one family requested, none bound udp: no usable local socket in any requested family
The iterator was empty udp: no address family requested

DualStackUdp is up to one socket per family, addressed as one link.

#[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 -- "family not bound" --> err["Err AddrNotAvailable"]
  recv["poll_recv_from(buf)"] --> r4{"v4 socket ready?"}
  r4 -- yes --> got["Ready(peer)"]
  r4 -- "no or not bound" --> r6{"v6 socket ready?"}
  r6 -- yes --> got
  r6 -- "no or not bound" --> pend["Pending, waker registered on each bound socket"]
  • Send goes out of socket_for(&to), the socket of the peer’s family. When that family was not bound, the call fails with io::ErrorKind::AddrNotAvailable and udp: no local socket in the family of {peer}.
  • Receive polls the v4 socket first, then the v6 socket, and returns the first datagram found. A socket that is not ready registers the task’s waker, so a datagram on either family wakes the task. The returned peer is the real source address, never v4-mapped, because of the v6-only flag.
  • As a DatagramLink the link is addressed by Destination. poll_send_to accepts only a Destination whose remote is an IP (Destination::socket_addr() is Some). A domain fails with io::ErrorKind::Unsupported and udp: a plain dual-stack link cannot resolve a domain, which the server runtime delivers to the core as Event::SendFailed without closing the key. Received peers come back as Destination::udp(addr).

The concepts crate’s UdpOutbound behaves the same way for a single socket (a plain UDP outbound cannot resolve a domain). Resolving belongs in a wrapper that owns a resolver, such as the freedom outbound’s ResolvingUdp.

feature quic environment/src/dial/quic.rs is compiled only with the crate’s quic feature, which pulls in quinn (and so rustls). The feature is off by default. No workspace member and no katana dependency enables it: the Hysteria 2 client in protocols/src/hysteria/connection.rs binds its own socket and builds its own quinn::Endpoint. QuicDialer is therefore compiled only when the feature is switched on (for example by --all-features), and only the environment’s integration tests call it.

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 takes a socket from UdpDialer::bind(family) (so the whole SocketOptions policy and the v6-only rule apply), hands it to quinn::Runtime::wrap_udp_socket on TokioRuntime, passes the result through wrap if one is given, and builds a client-only endpoint with Endpoint::new_with_abstract_socket(EndpointConfig::default(), None, socket, Arc::new(TokioRuntime)).
  • SocketWrap replaces the socket underneath QUIC. It is meant for an obfuscation layer that must see every packet after QUIC has sealed it.
  • connect maps a connect_with refusal to InvalidInput and a failed handshake to ConnectionRefused.
  • dial returns the Endpoint together with the Connection, because dropping the endpoint closes the connection.

The TLS side (certificate verification, ALPN, transport parameters) is entirely the caller’s quinn::ClientConfig.

environment/src/dial/mod.rs bundles the two everyday dialers and makes them a Connector, so a server core’s Effect::Open can land directly on a host 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) clones the same SocketOptions into both dialers. Each connect clones the dialers into a boxed future, so the future owns everything it needs and does not borrow the Dialer.

Target What connect does Result
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) (one address) Outbound::Stream(TcpStream)
SocketTarget::Udp { ipv6 } udp.bind(V6) if ipv6, else udp.bind(V4) Outbound::Datagram(UdpOutbound)

SocketTarget is the concepts crate’s own target (concepts/src/link.rs), which its policy-free SocketConnector also implements. Dialer differs from SocketConnector in three ways: it applies the SocketOptions policy, bounds the TCP connect with connect_timeout, and opens the IPv6 UDP socket v6-only. Neither Connector impl is used by production code: the outbounds call dialer.tcp.connect_any and dialer.udp.bind_dual directly, after their own resolution step. The impls are exercised by the environment’s integration tests, including a full ProxyServerRuntime run.

protocols/src/helpers/address_family.rs answers the question the dialers refuse to: given a Destination, which IPs may be tried, and in what order. It splits the answer into two independent inputs:

  • policy, what the operator asked for: an AddressFamilyStrategy;
  • capability, which families the outbound can actually source traffic from: a FamilySupport.

For a kernel-routed dialer the kernel answers capability (it consults the routing table and fails fast with ENETUNREACH), so those callers pass FamilySupport::both(). A userspace netstack with no routing table, such as the WireGuard outbound, derives it from its tunnel-local addresses instead.

#[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() is both(). describe() yields IPv4 and IPv6, IPv4 only, IPv6 only or no address family.

FromStr trims the input, lower-cases it and reads - as _ before matching. Anything else is AddressFamilyStrategyParseError (unknown address family strategy), which both programs turn into a config error.

Variant as_str() Accepted spellings
Auto auto auto, the empty string
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

So " Prefer-IPv4 " parses as PreferIpv4. Running etemenanki-app --test on a freedom outbound tagged direct with address_family = "either" fails with outbound direct: invalid address_family "either".

resolve_candidates gets the raw answer first:

  • Remote::IpAddr(ip) is used as-is, without the resolver.
  • Remote::Domain(name) goes through resolver.resolve(name), which answers from its cache while an entry is fresh and otherwise asks its backend. The resolver already de-duplicates. With the system backend the order is getaddrinfo’s, which applies the host’s address-selection rules. With a configured UDP, DoT or DoH server the A and AAAA queries run together (tokio::join!), and the A answers come before the AAAA answers.

select_candidate_ips then filters by strategy.allows(ip) && support.supports(ip) and reorders. The prefer_* sorts are stable sorts on a single bit, so each family keeps the resolver’s order internally and the other family stays in the list as a fallback.

Strategy Resolver answer [2001:db8::1, 192.0.2.1, 2001:db8::2], support both()
Auto 2001:db8::1, 192.0.2.1, 2001:db8::2 (unchanged)
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

With FamilySupport::from_addrs(&[192.0.2.2]) (a v4-only capability), even Auto drops every IPv6 candidate.

destination_to_socketaddrs is the kernel-routed shortcut: resolve_candidates("dial", dest, strategy, FamilySupport::both(), resolver), then each IP paired with dest.port. It keeps all candidates, unlike a bare lookup_host(..).next(), which is what makes connect_any’s fallback possible.

Situation Kind Message
The lookup itself fails: a getaddrinfo error, or, with a configured server, no address came back and at least one of the A and AAAA queries failed as the backend reports it the backend’s error, unchanged (with a configured server, the last failed query’s)
The lookup succeeds with no addresses NotFound dns: {host} did not resolve (from Resolver::resolve)
The resolver returns an empty list (defensive check) NotFound {context}: destination did not resolve
Addresses resolved (or an IP literal was given), but policy and capability rule all of them out AddrNotAvailable {context}: no usable {strategy} destination address for {remote}:{port}
As above, with a capability narrower than both() AddrNotAvailable the same, followed by (local address supports {describe()})

The capability clause is left out for a kernel-routed dialer on purpose: there it is always “both” and would mislead.

A dial through a proxy transport shows every piece in order. TransportConnector::dial in protocols/src/transports/connect.rs is the path every TCP-carried proxy outbound takes (SOCKS, HTTP, Trojan, VLESS, VMess, Shadowsocks; not Hysteria 2 or WireGuard):

sequenceDiagram
  participant T as TransportConnector
  participant F as address_family
  participant R as Resolver
  participant D as TcpDialer
  participant K as kernel
  T->>F: destination_to_socketaddrs(dest, strategy, resolver)
  opt remote is a domain
    F->>R: resolve(domain)
    R-->>F: Vec of IpAddr, de-duplicated
  end
  F-->>T: candidates filtered and ordered, paired with dest.port
  T->>D: connect_any(addrs)
  loop each address until one connects
    D->>K: socket(family), apply policy, bind
    D->>K: connect under connect_timeout
    K-->>D: stream, or an error recorded in failures
  end
  D-->>T: TcpStream, or ConnectionRefused listing every failure
  T->>T: set_keepalive(tcp), then wrap in TLS, WS or gRPC

TransportConnector::dial refuses a UDP destination up front with Unsupported (a proxy transport carries no datagrams of its own), since a proxy’s UDP rides inside its stream. After the connect it calls transports::keepalive::set_keepalive, which sets idle TCP_KEEPALIVE_IDLE (120 s), interval TCP_KEEPALIVE_INTERVAL (30 s) and TCP_KEEPALIVE_RETRIES (3) and only logs at debug level if the platform refuses. That is separate from SocketOptions::tcp_keepalive, which the default options leave unset.

Neither program exposes SocketOptions in its configuration. Every socket the dialers open in production uses SocketOptions::default(), and the only dialer methods called are TcpDialer::connect_any and UdpDialer::bind_dual. The configurable part is the per-outbound address_family, which becomes an AddressFamilyStrategy; see Outbounds for the user-facing side.

Caller Dialer and options Environment calls Family policy
app/src/outbound/freedom.rs → FreedomConnector (TCP flow) Dialer::new(SocketOptions::default()) tcp.connect_any destination_to_socketaddrs(&dest, strategy, &resolver)
FreedomConnector (UDP flow) same udp.bind_dual(self.families()) per packet, through ResolvingUdp
app/src/outbound/mod.rs → build_transport (SOCKS, HTTP, Trojan, VLESS, VMess, Shadowsocks outbounds) Dialer::default() inside TransportConnector::new tcp.connect_any destination_to_socketaddrs with the outbound’s strategy

FreedomConnector implements Connector<Flow>. A UDP flow never resolves anything at dial time: it binds one socket per family the strategy allows (Ipv4Only gives [V4], Ipv6Only gives [V6], the other three give [V4, V6]) and returns a ResolvingUdp, which wraps the DualStackUdp.

  • Domain targets. The link resolves a name on the first packet to it, with destination_to_socketaddrs (so the capability is FamilySupport::both()), and keeps the first candidate in a per-association HashMap. The packet goes out of the socket of that candidate’s family; if that family did not bind, DualStackUdp fails the send with AddrNotAvailable (udp: no local socket in the family of …).
  • One lookup at a time. The link holds at most one lookup future, tagged with its name. While it runs, a send to that name returns Pending, and so does a send to any other name not yet cached. ProxyServerRuntime applies effects strictly in order, so everything queued behind a pending send waits for the lookup too.
  • Failed names are remembered as None. This covers any lookup error, including a timeout, as well as a name with no usable address. Their packets are dropped, the send reports Ok(buf.len()), and the debug log reads freedom: dropping a datagram to an unresolvable ….
  • IP targets go straight to DualStackUdp, without the strategy filter. Only the choice of sockets reflects the strategy: under ipv4_only no v6 socket exists, so a send to an IPv6 address fails with AddrNotAvailable.

parse_address_family maps an absent key to Auto and a bad value to outbound {tag}: invalid address_family {raw:?}.

Sockets that do not go through the dialers

Section titled “Sockets that do not go through the dialers”

Several subsystems open sockets themselves. A new SocketOptions knob does not reach them unless they are changed too.

Socket Where Family handling
Inbound listeners app/src/instance.rs → bind_inbound, katana src/manager/transport.rs the listen address
DNS queries to a configured server protocols/src/dns/mod.rs UDP queries bind the wildcard of the server address’s family and connect to it; DoT and DoH use TcpStream::connect(server); the system backend calls getaddrinfo and opens no socket of its own
Balancer health probe app/src/balancer.rs → probe destination_to_socketaddrs(…, Auto, …), then TcpStream::connect per address under one overall timeout
SOCKS server UDP relay protocols/src/socks/server.rs → SocksInbound::associate, ExpectedSender bound on the configured udp_bind address, or else the control connection’s local IP, port 0. The relay hears only the control connection’s IP (over a Unix socket, the exact address and port the request names), compared in canonical form (see The association’s client). An association whose client is in a family the bind address does not hear (hears: an IPv4 or IPv4-mapped address hears IPv4, :: hears both, any other IPv6 address hears IPv6) is refused with 0x02 before any socket is bound (pinned by a_relay_that_cannot_hear_the_client_is_refused in protocols/tests/unit/socks/server.rs and udp_association_refuses_a_relay_that_cannot_hear_the_client in protocols/tests/pipeline/socks.rs)
SOCKS outbound UDP socket app/src/outbound/mod.rs, katana src/outbound/mod.rs bound in the relay’s family; SocksUdpLink keeps only datagrams from the relay. From etemenanki-protocols 2.0.2 it compares addresses in canonical form; katana v3.0.1 builds on 2.0.1, which compares the plain SocketAddr, and with a socket of the relay’s own family both accept exactly the relay’s replies
Hysteria 2 client protocols/src/hysteria/connection.rs → Hy2Conn::connect, bind_socket destination_to_socketaddrs, then its own sequential loop; each attempt binds the wildcard of the address’s family and gets CONNECT_TIMEOUT (10 s) for the bind, the QUIC handshake and authentication
Hysteria 2 server protocols/src/hysteria/server/endpoint.rs the listen address
WireGuard endpoint protocols/src/wireguard/device.rs → WgDevice::start resolves the peer endpoint with tokio::net::lookup_host and takes the first answer, then binds the wildcard of that family and connects the socket
WireGuard tunnelled TCP (userspace netstack, not a host socket) protocols/src/wireguard/connector.rs, protocols/src/wireguard/slot.rs → connect_tcp_any resolve_candidates with FamilySupport::from_addrs(local_addrs), then a sequential loop with TCP_CONNECT_ATTEMPT_TIMEOUT (10 s) per address

The Hysteria and WireGuard loops follow the same rule as connect_any: sequential attempts, a bounded time per attempt, and every failure in the final error. Their final errors differ: Hysteria returns ConnectionRefused with hysteria2: no address answered (…), and WireGuard returns TimedOut with wireguard: tunnel TCP connect failed for all resolved addresses (…). The balancer probe is simpler: it stops at the first address that connects and reports only up or down.

Invariant Enforced by Pinned by
Policy is applied after creation and before bind or connect TcpDialer::socket and UdpDialer::bind call SocketOptions::apply before binding; connect always goes through socket no test checks the order; connects_and_binds_the_requested_source_address (environment/tests/integration/tcp.rs) checks that the bind address takes effect
A bind address binds only sockets of its own family SocketOptions::bind_address_for filters with AddressFamily::of_ip bind_address_applies_only_to_its_own_family (environment/tests/unit/dial/socket.rs)
An unsupported knob is an error, never a no-op per-OS cfg variants of bind_interface, bind_interface_index, set_mark returning ErrorKind::Unsupported no test reaches the Unsupported branches, which do not compile on Linux; mark_and_loopback_device_take_effect_or_are_refused_by_the_kernel covers the Linux side (applied or PermissionDenied)
A hook’s error aborts the socket apply returns hook(socket)? hook_runs_on_apply_and_its_error_propagates
The hook body never appears in logs manual Debug for SocketOptions debug_hides_the_hook_body
One connect attempt cannot stall the rest tokio::time::timeout(connect_timeout, …) in connect no test fires the timer; connect_any_falls_through_a_dead_address uses a port that refuses at once
An unreachable address does not strand the destination connect_any walks every candidate; resolution keeps every answer connect_any_falls_through_a_dead_address; auto_keeps_resolver_order_when_both_families_are_supported
No failure cause is lost connect_any joins every {addr}: {e}; an empty list says none given connect_any_falls_through_a_dead_address
A UDP peer is reported as the address it was sent to v6 sockets are set_only_v6(true); v4 has its own socket v6_socket_is_v6_only_and_dual_stack_picks_by_family (environment/tests/integration/udp.rs) compares each reply’s source with the echo server’s address; it does not read IPV6_V6ONLY back
A datagram leaves from the socket of its peer’s family DualStackUdp::socket_for v6_socket_is_v6_only_and_dual_stack_picks_by_family (a v6 send on a v4-only link gives AddrNotAvailable)
A dual-stack bind fails only when nothing bound bind_dual skips failed families and checks both slots at the end only the empty case: v6_socket_is_v6_only_and_dual_stack_picks_by_family checks that bind_dual([]) is an error; no test makes one family fail
The environment never resolves names DatagramLink for DualStackUdp refuses a domain with Unsupported no dedicated test in environment; the DialTarget::Udp runtime test sends to IPs only
prefer_* reorders but never drops the other family stable sort_by_key in select_candidate_ips prefer_ipv4_keeps_ipv6_as_fallback (protocols/tests/unit/helpers/address_family.rs)
Capability filters even under Auto support.supports(ip) in the filter auto_skips_families_without_a_local_address
A kernel-routed dialer is limited by policy alone callers pass FamilySupport::both() a_kernel_routed_dialer_is_limited_by_policy_alone; the_capability_clause_is_omitted_for_a_kernel_routed_dialer

TcpDialer and UdpDialer spawn no tasks and hold no locks. Every operation is either synchronous (bind, bind_dual, socket, endpoint) or a single future owned by the caller. The one exception is the quinn::Endpoint that QuicDialer::endpoint builds: quinn spawns its own driver task on the tokio runtime, which lives as long as the endpoint.

  • Cancellation. Dropping a connect or connect_any future drops the in-flight TcpSocket and closes it. Addresses not yet tried are never touched. The boxed futures from Dialer::connect own clones of the dialers, so they can be dropped at any point without affecting the Dialer.
  • Partial construction. A socket that fails apply, keepalive or bind is dropped before it is returned, so no half-configured socket escapes.
  • Timeouts. The one timer in the dialers is the per-attempt connect_timeout in TcpDialer::connect. bind, bind_dual, socket and endpoint are synchronous system calls and do not wait on the network.

Errors a caller can see, in one place:

Source Kind Message
TcpDialer::connect, timer fired TimedOut connect to {addr} timed out
TcpDialer::connect_any ConnectionRefused failed to connect to any address ({addr}: {e}; …) or (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 on a platform without the knob Unsupported {what} is not supported on {os}, or the Apple interface-name message
SocketOptions::apply, kernel refusal as the OS reports, typically PermissionDenied the OS error
QuicDialer::connect InvalidInput (bad config or address), ConnectionRefused (handshake failed) quinn’s error text
Constant Value Defined in Meaning
DEFAULT_CONNECT_TIMEOUT 10 s environment/src/dial/tcp.rs Per connect attempt; override with TcpDialer::with_connect_timeout
Worst case of connect_any number of candidates × connect_timeout follows from the sequential loop Resolution time comes on top
TCP_KEEPALIVE_IDLE / TCP_KEEPALIVE_INTERVAL / TCP_KEEPALIVE_RETRIES 120 s / 30 s / 3 protocols/src/transports/keepalive.rs Applied by TransportConnector::dial after the connect
UDP source port 0 (ephemeral) UdpDialer::bind Every bind asks the kernel for a fresh port
MAX_RESOLVED_NAMES 256 katana src/outbound/freedom.rs Names cached per direct UDP association in katana

The environment’s tests run on real loopback sockets. environment/tests/integration.rs pulls in the TCP and UDP modules always and the QUIC module only under cfg(feature = "quic"); unit tests are attached to socket.rs with #[path].

Test File What it pins
bind_address_applies_only_to_its_own_family environment/tests/unit/dial/socket.rs bind_address_for returns the address for its family and None for the other
family_of_addresses same AddressFamily::of and unspecified
hook_runs_on_apply_and_its_error_propagates same the hook runs once per apply; its error text comes back unchanged
mark_and_loopback_device_take_effect_or_are_refused_by_the_kernel same (Linux only) with_mark(7) and Interface::Name("lo") either apply or fail with PermissionDenied
debug_hides_the_hook_body same Debug prints hook: Some("...")
connects_and_binds_the_requested_source_address environment/tests/integration/tcp.rs a bind address becomes the connection’s source
connect_any_falls_through_a_dead_address same fallback to the second address; error text names the dead address; empty input says none given
dialer_is_a_connector_for_the_runtime same Connector::<DialTarget> yields a working Outbound::Stream
v6_socket_is_v6_only_and_dual_stack_picks_by_family environment/tests/integration/udp.rs per-family send and receive, real peer addresses, AddrNotAvailable for a missing family, bind_dual([]) fails
dual_stack_link_serves_a_proxy_runtime same a toy core (TinyUdp) opens DialTarget::Udp under ProxyServerRuntime and relays a datagram both ways
socket_target_connector_binds_one_family same Connector::<SocketTarget> with ipv6: false binds an IPv4 socket
dials_a_quic_server_over_loopback environment/tests/integration/quic.rs (feature quic) QuicDialer::dial honours the bind address and carries a stream
a_socket_wrap_sees_the_endpoint_socket same SocketWrap is called while building the endpoint
parses_address_family_strategy_aliases protocols/tests/unit/helpers/address_family.rs ipv4-only and prefer_ipv6 parse; either does not
auto_skips_families_without_a_local_address same capability filters under Auto
auto_keeps_resolver_order_when_both_families_are_supported same Auto does not reorder
ipv4_only_filters_to_ipv4 same Ipv4Only drops IPv6
prefer_ipv4_keeps_ipv6_as_fallback same stable reordering
a_kernel_routed_dialer_is_limited_by_policy_alone same FamilySupport::both() filters nothing
the_capability_clause_is_omitted_for_a_kernel_routed_dialer same the error text adds local address supports IPv4 only only for a narrower capability

The QUIC tests need the feature: run cargo test -p etemenanki-environment --features quic. The workspace gate cargo clippy --workspace --all-targets --all-features compiles them but does not run them, and cargo test --workspace skips them. The mark and device test does not read the options back: it passes whether the kernel applies them or refuses them with PermissionDenied.

  • Adding a knob. Put it on SocketOptions with a with_* builder, apply it in apply (or in the dialer if it depends on the socket type), and give every platform a cfg branch. Where the platform has no equivalent, return unsupported(…); do not skip it. Remember that bind_dual turns such an error into a skipped family, and that the sockets listed in Sockets that do not go through the dialers will not see the knob.
  • Keep connect_any sequential. Its doc comment states that it is not Happy Eyeballs. While attempts are sequential, the order from select_candidate_ips alone decides which address a flow uses, and the error lists one entry per address tried. Racing attempts would change both for every outbound.
  • Keep resolution out of environment. A resolving link belongs to the program that owns the resolver, as ResolvingUdp does in the app and in katana.
  • Downstream impact. katana consumes etemenanki-environment and etemenanki-protocols from a private Cargo registry, at the versions its lockfile resolves, and keeps its own freedom outbound. A change to DualStackUdp, bind_dual or the address-family helpers needs a matching look at katana’s src/outbound/freedom.rs.