client: pin outside socket egress on Windows/Linux via IP_UNICAST_IF - #527
client: pin outside socket egress on Windows/Linux via IP_UNICAST_IF#527kp-antonio-yang wants to merge 3 commits into
IP_UNICAST_IF#527Conversation
|
Code coverage summary for 0937705: ✅ Region coverage 70% passes |
0156339 to
c87e096
Compare
IP_UNICAST_IFIP_UNICAST_IF
Add platform/windows/egress.rs with GetBestInterfaceEx + IP_UNICAST_IF/IPV6_UNICAST_IF helpers. Pin the UDP and TCP outside sockets to the physical interface at creation time so routing-table changes (including the tunnel's own default route) cannot redirect outside traffic into the tunnel. Re-pin the UDP socket after each network-change event via a new OutsideIO::pin_egress_interface hook called from network_event_coordinator, using the interface index of the freshly validated server route. Swallow transient WSAEADDRNOTAVAIL/WSAEHOSTUNREACH send errors on UDP after a roam; the socket is unconnected so the next send re-selects a source address on the pinned interface.
Add disable_pin_egress_interface: bool (default false, i.e. pinning on) to Config and ClientConfig, gated on Windows desktop builds. Thread a pin_egress_interface: bool parameter through Udp::new, Tcp::new, initialize_routes, and network_event_coordinator so the IP_UNICAST_IF / IPV6_UNICAST_IF pin — both at socket creation and on network-change re-pins — is skipped when the flag is set.
Add platform/linux/egress.rs mirroring the Windows egress module. Uses a throw-away SOCK_DGRAM connect + getsockname + getifaddrs to find the best interface index, then applies IP_UNICAST_IF / IPV6_UNICAST_IF via setsockopt. Extend the UDP/TCP socket constructors and OutsideIO::pin_egress_interface trait method to cover both Linux and Windows (not(feature = "mobile")). Expand server_route_if_index and the network_event_coordinator re-pin logic to any(linux, windows).
c87e096 to
bc3108c
Compare
|
|
||
| // the route lookup that binds the local address happens there, | ||
| // and IP_UNICAST_IF has no effect on an already-connected socket. | ||
| #[cfg(all(windows, not(feature = "mobile")))] |
There was a problem hiding this comment.
Why do we need to have !mobile ?
Is not windows only cover desktop clients ?
There was a problem hiding this comment.
If windows developer cross compiling the android, it might be a issue. The mobile is not a platform gate, we need use !mobile to ensure this will not go into mobile.
There was a problem hiding this comment.
Per rust documentation, https://doc.rust-lang.org/reference/conditional-compilation.html#r-cfg.target_os
target_os should be set to Android, if some dev compiles Android from Windows host os.
Are you seeing different behavior ?
| use std::os::fd::RawFd; | ||
|
|
||
| /// Constrain `socket`'s egress to interface `if_index`. | ||
| pub fn set_unicast_if(fd: RawFd, if_index: u32, ipv6: bool) -> io::Result<()> { |
There was a problem hiding this comment.
For sockopt, follow the existing apputils module, which has absrtactions for different os'es
https://github.com/expressvpn/lightway/blob/main/lightway-app-utils/src/sockopt/ip_mtu_discover.rs
| maybe_sock: Option<TcpStream>, | ||
| #[cfg(all(linux, not(feature = "mobile")))] fwmark: u32, | ||
| #[cfg(all(windows, not(feature = "mobile")))] pin_egress_interface: bool, | ||
| #[cfg(all(any(linux, windows), not(feature = "mobile")))] pin_egress_interface: bool, |
There was a problem hiding this comment.
i think i understood why you added !mobile with windows, to avoid android ??
I suggest to create more cfg in
lightway/lightway-client/build.rs
Line 7 in ac8468c
It will be clear and avoid mistakes.
|
|
||
| // The route lookup that binds the local address happens at | ||
| // connect() and IP_UNICAST_IF has no effect on an already-connected | ||
| // socket, so pin must be applied before connect. |
There was a problem hiding this comment.
From windows documentation, i do not see the option should be set before connect
https://learn.microsoft.com/en-us/windows/win32/winsock/ipproto-ip-socket-options
Can you add the reference to this claim ?
If this is not true, we can ignore setting this now, our route manager will set this when first default route is added.
| }; | ||
|
|
||
| /// The interface index Windows would currently use to reach `dst`. | ||
| pub fn best_interface_index(dst: IpAddr) -> io::Result<u32> { |
There was a problem hiding this comment.
We are trying to replicate the code from routw manager here
https://github.com/expressvpn/lightway/blob/main/lightway-client/src/route_manager.rs#L248
In worst case, both implementation deviates and server route is set with 1 interface and the socket is pinned to different interface and it might break the connection.
Better to use same implementation.
Also if we confirm we can set UNICAST_IP after connection, we can get rid of this flow and pin egress only from route_manager.
Description
Pin the Windows outside socket to the physical egress interface using
IP_UNICAST_IF/IPV6_UNICAST_IFso that routing-table changes (including the tunnel's own default route) cannot redirect outside traffic back into the tunnel. Re-pin on every network-change event and swallow transient send errors that occur mid-roam. The encapsulation loop from outside packet back to tunnel cause issues in CVPN-2746, CVPN-2747 and CVPN-2748.This also apply on Linux, such that the roaming in
NoExecmode from wifi in different subnet works. But this is not perfect for Linux. BecauseIP_UNICAST_IFis evaluated on every send call on Windows (Winsock), and it overrides routing table decisions per-packet, regardless of whether the socket has previously calledconnect(). However,IP_UNICAST_IFsetssk->sk_bound_dev_if, only consulted during a route lookup on Linux. For a connected UDP socket, the route is cached insk_dst_cacheatconnect()time, re-callingsetsockopt(IP_UNICAST_IF, same_index)updates the flag but does not flush the route cache. #492 still needed.Change details
platform/windows/egress.rs(new): wrapsGetBestInterfaceEx+setsockopt(IP_UNICAST_IF/IPV6_UNICAST_IF)into three helpers —best_interface_index,set_unicast_if, andpin_to_peer_interface.io/outside/udp.rs: pins the UDP socket at creation; stores the currentpinned_if_index(AtomicU32) and implementsOutsideIO::pin_egress_interfaceto re-pin on network change; swallows transientWSAEADDRNOTAVAIL/WSAEHOSTUNREACHsend errors that arise mid-roam before the next send re-selects a source address.io/outside/tcp.rs: pins the TCP socket beforeconnect()(the option has no effect afterwards, so this is the only opportunity).io/outside.rs: addsOutsideIO::pin_egress_interface(Windows only, default no-op).lib.rs: callspin_egress_interfacefromnetwork_event_coordinatorafter each successful route refresh; extends theoutside_ioweak reference from Apple-only toany(apple, windows).route_manager.rs: addsRouteUpdater::server_route_if_index()to expose the interface index of the currently installed server route.Motivation and Context
On Windows without pinning, once the tunnel's default route is installed the OS may route outside packets back into the tunnel, causing a routing loop. Pinning the socket to the physical interface that currently reaches the server makes egress selection independent of the routing table. The pin is refreshed after each network-change event so that switching adapters (Wi-Fi → Ethernet, docking) is handled correctly.
How Has This Been Tested?
Types of changes
Checklist:
mainxenon/libxenon-srcis accompanied by a reference to the specific commit in the git changelog