Dialers and socket policy
Source files: 39 · checked against Etemenanki 596916d · katana v3.0.1
Etemenanki/environment/Cargo.tomlEtemenanki/environment/src/lib.rsEtemenanki/environment/src/dial/mod.rsEtemenanki/environment/src/dial/socket.rsEtemenanki/environment/src/dial/tcp.rsEtemenanki/environment/src/dial/udp.rsEtemenanki/environment/src/dial/quic.rsEtemenanki/concepts/src/link.rsEtemenanki/concepts/src/core.rsEtemenanki/concepts/src/net.rsEtemenanki/concepts/src/runtime.rsEtemenanki/protocols/src/helpers/address_family.rsEtemenanki/protocols/src/transports/connect.rsEtemenanki/protocols/src/transports/keepalive.rsEtemenanki/protocols/src/dns/mod.rsEtemenanki/app/src/outbound/freedom.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/balancer.rsEtemenanki/app/src/instance.rsEtemenanki/protocols/src/socks/server.rsEtemenanki/protocols/src/socks/udp_link.rsEtemenanki/protocols/tests/unit/socks/server.rsEtemenanki/protocols/tests/pipeline/socks.rsEtemenanki/protocols/src/hysteria/connection.rsEtemenanki/protocols/src/hysteria/server/endpoint.rsEtemenanki/protocols/src/wireguard/connector.rsEtemenanki/protocols/src/wireguard/slot.rsEtemenanki/protocols/src/wireguard/device.rsEtemenanki/environment/tests/integration.rsEtemenanki/environment/tests/integration/tcp.rsEtemenanki/environment/tests/integration/udp.rsEtemenanki/environment/tests/integration/quic.rsEtemenanki/environment/tests/unit/dial/socket.rsEtemenanki/protocols/tests/unit/helpers/address_family.rskatana/src/outbound/freedom.rskatana/src/outbound/mod.rskatana/src/runtime.rskatana/src/manager/transport.rskatana/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.
Responsibilities
Section titled “Responsibilities”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.
TcpDialerandQuicDialertake resolvedSocketAddrs,UdpDialertakes address families, andDualStackUdpsends only to IP addresses. Nothing inenvironmentperforms a DNS lookup;DualStackUdprefuses a domain rather than resolving it. - The dialers never retry or race on their own.
TcpDialer::connect_anywalks the list it is given, in the order given; the order itself comes fromselect_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).
Key types
Section titled “Key types”SocketOptions and AddressFamily
Section titled “SocketOptions and AddressFamily”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("...").
Interface binding and marks per platform
Section titled “Interface binding and marks per platform”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
Section titled “SocketHook”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.
TcpDialer
Section titled “TcpDialer”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 tokioTcpSocketof that family, applies the policy through asocket2::SockRef, sets the keepalive idle time iftcp_keepaliveisSome, and bindsbind_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)openssocket(AddressFamily::of(&addr))and wrapssocket.connect(addr)intokio::time::timeout(self.connect_timeout, …). When the timer fires first the error isio::ErrorKind::TimedOutwithconnect to {addr} timed out.connect_any(addrs)callsconnecton 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.
UdpDialer
Section titled “UdpDialer”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:
Socket::new(family.domain(), Type::DGRAM, Some(Protocol::UDP))- For
V6only:set_only_v6(true) options.apply(&socket, family)(interface, mark, hook)set_nonblocking(true)- Bind
bind_address_for(family), orfamily.unspecified(), on port 0 UdpSocket::from_std
TcpSocket::new_v4()orTcpSocket::new_v6()options.apply(&SockRef::from(&socket), family)(interface, mark, hook)- If
tcp_keepaliveis set:set_tcp_keepalive(&TcpKeepalive::new().with_time(idle)) - If
bind_address_for(family)isSome: bind it on port 0 - Return the unconnected
TcpSocket;connectdoes the rest
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
Section titled “DualStackUdp”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 withio::ErrorKind::AddrNotAvailableandudp: 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
DatagramLinkthe link is addressed byDestination.poll_send_toaccepts only aDestinationwhoseremoteis an IP (Destination::socket_addr()isSome). A domain fails withio::ErrorKind::Unsupportedandudp: a plain dual-stack link cannot resolve a domain, which the server runtime delivers to the core asEvent::SendFailedwithout closing the key. Received peers come back asDestination::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.
QuicDialer and SocketWrap
Section titled “QuicDialer and SocketWrap”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)>;}endpointtakes a socket fromUdpDialer::bind(family)(so the wholeSocketOptionspolicy and the v6-only rule apply), hands it toquinn::Runtime::wrap_udp_socketonTokioRuntime, passes the result throughwrapif one is given, and builds a client-only endpoint withEndpoint::new_with_abstract_socket(EndpointConfig::default(), None, socket, Arc::new(TokioRuntime)).SocketWrapreplaces the socket underneath QUIC. It is meant for an obfuscation layer that must see every packet after QUIC has sealed it.connectmaps aconnect_withrefusal toInvalidInputand a failed handshake toConnectionRefused.dialreturns theEndpointtogether with theConnection, because dropping the endpoint closes the connection.
The TLS side (certificate verification, ALPN, transport parameters) is entirely the caller’s quinn::ClientConfig.
Dialer and DialTarget
Section titled “Dialer and DialTarget”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.
Address-family policy
Section titled “Address-family policy”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.
Parsing a strategy
Section titled “Parsing a strategy”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".
Candidate ordering
Section titled “Candidate ordering”resolve_candidates gets the raw answer first:
Remote::IpAddr(ip)is used as-is, without the resolver.Remote::Domain(name)goes throughresolver.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 isgetaddrinfo’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.
Errors from resolution
Section titled “Errors from resolution”| 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.
Data flow
Section titled “Data flow”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.
How the app and katana use it
Section titled “How the app and katana use it”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 isFamilySupport::both()), and keeps the first candidate in a per-associationHashMap. The packet goes out of the socket of that candidate’s family; if that family did not bind,DualStackUdpfails the send withAddrNotAvailable(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.ProxyServerRuntimeapplies 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 reportsOk(buf.len()), and the debug log readsfreedom: dropping a datagram to an unresolvable …. - IP targets go straight to
DualStackUdp, without the strategy filter. Only the choice of sockets reflects the strategy: underipv4_onlyno v6 socket exists, so a send to an IPv6 address fails withAddrNotAvailable.
parse_address_family maps an absent key to Auto and a bad value to outbound {tag}: invalid address_family {raw:?}.
| Caller | Dialer and options | Environment calls | Family policy |
|---|---|---|---|
src/outbound/freedom.rs → FreedomConnector::connect |
Dialer::new(SocketOptions::default()) |
tcp.connect_any |
destination_to_socketaddrs(dest, strategy, &resolver) |
FreedomConnector::bind_udp |
same | udp.bind_dual(self.families()) |
resolve_candidates("freedom", …, support, …) per name |
src/outbound/mod.rs → build_outbound (SOCKS, HTTP, VMess, VLESS, Shadowsocks) |
Dialer::new(SocketOptions::default()) inside TransportConnector::new(TransportKind::Tcp, …) |
tcp.connect_any |
the outbound’s address_family |
src/runtime.rs → build_outbounds, built-in direct and freedom tags |
FreedomConnector::new(resolver, AddressFamilyStrategy::Auto) |
as above | Auto |
katana has its own freedom outbound, not the app’s. Outbound::connect_stream calls FreedomConnector::connect, and Outbound::connect_datagram calls FreedomConnector::bind_udp. Its UDP link, also named ResolvingUdp, works like this:
- Capability from what bound.
bind_udpcheckssocket.v4()andsocket.v6()afterbind_dualand buildsFamilySupport::from_addrsfrom the families that are present. Names are resolved withresolve_candidates("freedom", …)under that capability, so the first candidate is always an address the association has a socket for. - IP targets are sent only if
strategy.allows(ip) && support.supports(ip). Otherwise the packet is dropped. - Name cache. At most
MAX_RESOLVED_NAMES(256) names per association. AVecDequerecords insertion order, and the oldest name is evicted first. - Failed names are remembered as
None, as in the app: any lookup error counts, not only a name with no usable address. - Drops report
Ok(buf.len())and logfreedom: dropping a datagram with no usable addressat debug level. - One lookup at a time, as in the app.
katana builds every TCP-carried proxy outbound on TransportConnector::new(TransportKind::Tcp, …), so it has no TLS, WebSocket or gRPC transport toward an upstream; WireGuard goes through its own tunnel instead. katana’s parse_address_family rejects a bad value with outbound {tag} invalid address_family {raw:?} (no colon after the tag, unlike the app). In tests/unit/outbound.rs, address_family_applies_to_every_protocol builds SOCKS, HTTP, VMess and VLESS outbounds with ipv6_only, and an_invalid_address_family_is_rejected_for_every_protocol checks that ipv7 is rejected by those four and by WireGuard.
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.
Invariants
Section titled “Invariants”| 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 |
Failure paths and cancellation
Section titled “Failure paths and cancellation”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
connectorconnect_anyfuture drops the in-flightTcpSocketand closes it. Addresses not yet tried are never touched. The boxed futures fromDialer::connectown clones of the dialers, so they can be dropped at any point without affecting theDialer. - 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_timeoutinTcpDialer::connect.bind,bind_dual,socketandendpointare 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 |
Limits
Section titled “Limits”| 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.
Changing this code
Section titled “Changing this code”- Adding a knob. Put it on
SocketOptionswith awith_*builder, apply it inapply(or in the dialer if it depends on the socket type), and give every platform acfgbranch. Where the platform has no equivalent, returnunsupported(…); do not skip it. Remember thatbind_dualturns 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_anysequential. Its doc comment states that it is not Happy Eyeballs. While attempts are sequential, the order fromselect_candidate_ipsalone 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, asResolvingUdpdoes in the app and in katana. - Downstream impact. katana consumes
etemenanki-environmentandetemenanki-protocolsfrom a private Cargo registry, at the versions its lockfile resolves, and keeps its own freedom outbound. A change toDualStackUdp,bind_dualor the address-family helpers needs a matching look at katana’ssrc/outbound/freedom.rs.