Skip to content

Commit cb65e5b

Browse files
authored
Add TCP listener for DNS resolver (#5113) (#5133)
1 parent 680a55a commit cb65e5b

7 files changed

Lines changed: 165 additions & 149 deletions

File tree

nym-vpn-core/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add TCP listener for local DNS resolver (https://github.com/nymtech/nym-vpn-client/pull/5113)
13+
1014
### Changed
1115

1216
- [macOS] Use endpoint-security framework directly instead of parsing eslogger output (https://github.com/nymtech/nym-vpn-client/pull/4749)

nym-vpn-core/crates/nym-vpn-lib/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,13 @@ nym-routing.workspace = true
142142
nym-dns.workspace = true
143143
nym-firewall.workspace = true
144144
nym-firewall-config.workspace = true
145-
socket2.workspace = true
146145
windows = { workspace = true, features = ["Win32_NetworkManagement_Ndis"] }
147146
winreg.workspace = true
148147

149148
# Desktop-specific dependencies
150149
[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))'.dependencies]
151150
hickory-server = { workspace = true, features = ["resolver"] }
151+
socket2.workspace = true
152152
nym-routing.workspace = true
153153
nym-dns.workspace = true
154154
nym-cgroup.workspace = true

nym-vpn-core/crates/nym-vpn-lib/src/resolver/mod.rs

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,23 @@
1616
mod unix;
1717

1818
#[cfg(any(target_os = "macos", target_os = "linux"))]
19-
pub(crate) use unix::{flush_system_cache, new_random_socket};
19+
pub(crate) use unix::flush_system_cache;
2020

2121
#[cfg(windows)]
2222
mod windows;
2323

2424
#[cfg(windows)]
25-
pub(crate) use windows::{flush_system_cache, new_random_socket};
25+
pub(crate) use windows::flush_system_cache;
2626

2727
#[cfg(test)]
2828
mod tests;
2929

30+
mod tcp;
31+
use tcp::new_tcp_listener;
32+
33+
mod udp;
34+
use udp::new_random_socket;
35+
3036
use async_trait::async_trait;
3137
use hickory_server::{
3238
ServerFuture,
@@ -55,7 +61,7 @@ use std::{
5561
time::{Duration, Instant},
5662
};
5763
use tokio::{
58-
net::UdpSocket,
64+
net::{TcpListener, UdpSocket},
5965
sync::{Mutex, mpsc, oneshot},
6066
};
6167
use tokio_util::{either::Either, sync::CancellationToken};
@@ -111,6 +117,10 @@ const TTL_SECONDS: u32 = 3;
111117
/// belongs to the documentation range so should never be reachable.
112118
const RESOLVED_ADDR: Ipv4Addr = Ipv4Addr::new(198, 51, 100, 1);
113119

120+
/// Timeout for TCP client connections.
121+
/// Any client that does not send any DNS requests within the given timeout will be dropped.
122+
pub const TCP_CLIENT_TIMEOUT: Duration = Duration::from_secs(60);
123+
114124
/// Resolver errors
115125
#[derive(thiserror::Error, Debug)]
116126
pub enum Error {
@@ -370,16 +380,27 @@ impl LocalResolver {
370380
) -> Result<(ResolverHandle, tokio::task::JoinHandle<()>), Error> {
371381
let (tx, rx) = mpsc::unbounded_channel();
372382

373-
let (resolver_socket, loopback_alias) =
383+
let (udp_socket, loopback_alias) =
374384
new_random_socket(DNS_LISTEN_PORT, use_random_loopback).await?;
375-
let resolver_addr = resolver_socket.local_addr().map_err(Error::GetSocketAddr)?;
385+
let resolver_addr = udp_socket.local_addr().map_err(Error::GetSocketAddr)?;
376386

377-
let mut server = Self::new_server(resolver_socket, tx.clone()).await?;
387+
// Attempt to bind TCP listener to the same port as UDP, but don't fail if it's not possible.
388+
let tcp_listener = new_tcp_listener(resolver_addr)
389+
.inspect_err(|_err| {
390+
tracing::warn!("Failed to bind TCP socket to {resolver_addr}");
391+
})
392+
.ok();
393+
let is_tcp_available = tcp_listener.is_some();
394+
395+
let mut server = Self::new_server(udp_socket, tcp_listener, tx.clone()).await?;
378396

379397
let cloned_shutdown_token = shutdown_token.child_token();
380398
let cloned_tx = tx.clone();
381399
let dns_server_task = tokio::spawn(async move {
382-
tracing::info!("Running DNS resolver on {resolver_addr}");
400+
tracing::info!(
401+
"Running DNS resolver on {resolver_addr} ({})",
402+
if is_tcp_available { "udp, tcp" } else { "udp" }
403+
);
383404

384405
loop {
385406
tokio::select! {
@@ -405,15 +426,19 @@ impl LocalResolver {
405426
tracing::error!("DNS server unexpectedly stopped: {err}");
406427
tracing::debug!("Attempting to restart server");
407428

408-
let socket = match UdpSocket::bind(resolver_addr).await {
429+
let udp_socket = match UdpSocket::bind(resolver_addr).await {
409430
Ok(socket) => socket,
410431
Err(e) => {
411-
tracing::error!("Failed to bind DNS server to {resolver_addr}: {e}");
432+
tracing::error!("Failed to bind UDP socket to {resolver_addr}: {e}");
412433
break;
413434
}
414435
};
415436

416-
match Self::new_server(socket, cloned_tx.clone()).await {
437+
let tcp_listener = TcpListener::bind(resolver_addr).await.inspect_err(|err| {
438+
tracing::warn!("Failed to bind TCP socket to {resolver_addr}: {err}");
439+
}).ok();
440+
441+
match Self::new_server(udp_socket, tcp_listener, cloned_tx.clone()).await {
417442
Ok(new_server) => {
418443
server = new_server;
419444
}
@@ -452,10 +477,14 @@ impl LocalResolver {
452477

453478
async fn new_server(
454479
server_socket: UdpSocket,
480+
tcp_listener: Option<TcpListener>,
455481
tx: mpsc::UnboundedSender<ResolverMessage>,
456482
) -> Result<ServerFuture<ResolverImpl>, Error> {
457483
let mut server = ServerFuture::new(ResolverImpl { tx });
458484
server.register_socket(server_socket);
485+
if let Some(tcp_listener) = tcp_listener {
486+
server.register_listener(tcp_listener, TCP_CLIENT_TIMEOUT);
487+
}
459488
Ok(server)
460489
}
461490

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright 2026 Nym Technologies SA <contact@nymtech.net>
2+
// SPDX-License-Identifier: GPL-3.0-only
3+
4+
use std::net::SocketAddr;
5+
6+
use socket2::{Domain, Protocol, SockAddr, Socket, Type};
7+
use tokio::net::TcpListener;
8+
9+
const DEFAULT_BACKLOG: i32 = 128;
10+
11+
pub fn new_tcp_listener(socket_addr: SocketAddr) -> std::io::Result<TcpListener> {
12+
let domain = Domain::for_address(socket_addr);
13+
let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP)).inspect_err(|err| {
14+
tracing::warn!("Failed to open TCP socket: {err}");
15+
})?;
16+
17+
// SO_NONBLOCK is required for turning this into a tokio socket.
18+
socket.set_nonblocking(true).inspect_err(|err| {
19+
tracing::warn!("Failed to set TCP socket as nonblocking: {err}");
20+
})?;
21+
22+
// SO_REUSEADDR allows us to bind to `127.x.y.z` even if another socket is bound to `0.0.0.0`.
23+
// Best-effort: allow binding even if wildcard is in use. Windows semantics differ but
24+
// this is harmless.
25+
socket.set_reuse_address(true).inspect_err(|err| {
26+
tracing::warn!("Failed to set SO_REUSEADDR on TCP socket: {err}");
27+
})?;
28+
29+
let sa = SockAddr::from(socket_addr);
30+
socket.bind(&sa).inspect_err(|err| {
31+
tracing::warn!("Failed to bind TCP socket to {socket_addr}: {err}");
32+
})?;
33+
34+
socket.listen(DEFAULT_BACKLOG).inspect_err(|err| {
35+
tracing::warn!("Failed to listen TCP socket: {err}");
36+
})?;
37+
38+
let tcp_listener =
39+
TcpListener::from_std(std::net::TcpListener::from(socket)).expect("socket is non-blocking");
40+
41+
Ok(tcp_listener)
42+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright 2026 Nym Technologies SA <contact@nymtech.net>
2+
// SPDX-License-Identifier: GPL-3.0-only
3+
4+
use std::net::{IpAddr, Ipv4Addr};
5+
6+
use socket2::{Domain, Protocol, SockAddr, Socket, Type};
7+
use tokio::net::UdpSocket;
8+
9+
#[cfg(unix)]
10+
use crate::resolver::unix::RandomLoopbackAlias;
11+
#[cfg(windows)]
12+
use crate::resolver::windows::RandomLoopbackAlias;
13+
use crate::resolver::{BoxedLoopbackAlias, Error, LoopbackAlias};
14+
15+
pub async fn new_random_socket(
16+
port: u16,
17+
use_random_loopback: bool,
18+
) -> Result<(UdpSocket, Option<BoxedLoopbackAlias>), Error> {
19+
for attempt in 0.. {
20+
let (ip, alias): (IpAddr, Option<BoxedLoopbackAlias>) = match attempt {
21+
..3 if !use_random_loopback => continue,
22+
..3 => match RandomLoopbackAlias::assign().await {
23+
Ok(random) => (random.addr(), Some(Box::new(random) as BoxedLoopbackAlias)),
24+
Err(_) => continue,
25+
},
26+
3 => (IpAddr::from(Ipv4Addr::LOCALHOST), None),
27+
4.. => break,
28+
};
29+
30+
let sock = match Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)) {
31+
Ok(sock) => sock,
32+
Err(err) => {
33+
tracing::error!("Failed to open IPv4/UDP socket: {err}");
34+
continue;
35+
}
36+
};
37+
38+
// SO_NONBLOCK is required for turning this into a tokio socket.
39+
if let Err(err) = sock.set_nonblocking(true) {
40+
tracing::warn!("Failed to set UDP socket as nonblocking: {err}");
41+
continue;
42+
}
43+
44+
// SO_REUSEADDR enables us to bind to `127.x.y.z` even if another socket is bound to `0.0.0.0`.
45+
// Best-effort: allow binding even if wildcard is in use. Windows semantics differ but
46+
// this is harmless.
47+
if let Err(err) = sock.set_reuse_address(true) {
48+
tracing::warn!("Failed to set SO_REUSEADDR on UDP socket: {err}");
49+
}
50+
51+
let sa = SockAddr::from(std::net::SocketAddr::new(ip, port));
52+
if let Err(err) = sock.bind(&sa) {
53+
tracing::warn!("Failed to bind UDP socket to {ip}: {err}");
54+
// Ensure we clean up the alias before retrying.
55+
if let Some(alias) = alias {
56+
alias.unassign().await;
57+
}
58+
continue;
59+
}
60+
61+
let socket =
62+
UdpSocket::from_std(std::net::UdpSocket::from(sock)).expect("socket is non-blocking");
63+
return Ok((socket, alias));
64+
}
65+
66+
Err(Error::UdpBind)
67+
}

nym-vpn-core/crates/nym-vpn-lib/src/resolver/unix.rs

Lines changed: 7 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,28 @@
22
// Copyright 2025 Nym Technologies SA <contact@nymtech.net>
33
// SPDX-License-Identifier: GPL-3.0-only
44

5-
use crate::resolver::{BoxedLoopbackAlias, Error, LoopbackAlias, random_loopback_ipv4};
5+
use std::{io, net::IpAddr};
6+
67
use async_trait::async_trait;
7-
use std::{
8-
io,
9-
net::{IpAddr, Ipv4Addr},
10-
};
11-
use tokio::{net::UdpSocket, task::JoinHandle};
8+
use tokio::task::JoinHandle;
129
use tokio_util::sync::{CancellationToken, DropGuard};
1310

11+
use crate::resolver::{LoopbackAlias, random_loopback_ipv4};
12+
1413
/// Loopback interface name.
1514
#[cfg(target_os = "macos")]
1615
const LOOPBACK: &str = "lo0";
1716
#[cfg(target_os = "linux")]
1817
const LOOPBACK: &str = "lo";
1918

20-
struct RandomLoopbackAlias {
19+
pub struct RandomLoopbackAlias {
2120
addr: IpAddr,
2221
drop_guard: DropGuard,
2322
unassign_task: JoinHandle<()>,
2423
}
2524

2625
impl RandomLoopbackAlias {
27-
async fn assign() -> io::Result<Self> {
26+
pub async fn assign() -> io::Result<Self> {
2827
let addr = random_loopback_ipv4();
2928

3029
assign_loopback_alias(addr).await.inspect_err(|e| {
@@ -136,74 +135,6 @@ impl LoopbackAlias for RandomLoopbackAlias {
136135
}
137136
}
138137

139-
pub(crate) async fn new_random_socket(
140-
port: u16,
141-
use_random_loopback: bool,
142-
) -> Result<(UdpSocket, Option<BoxedLoopbackAlias>), Error> {
143-
use nix::{
144-
fcntl,
145-
sys::socket::{self, AddressFamily, SockFlag, SockProtocol, SockType, SockaddrStorage},
146-
};
147-
use std::os::fd::AsRawFd;
148-
149-
for attempt in 0.. {
150-
let (socket_addr, on_drop): (IpAddr, Option<BoxedLoopbackAlias>) = match attempt {
151-
..3 if !use_random_loopback => continue,
152-
153-
..3 => match RandomLoopbackAlias::assign().await {
154-
Ok(random) => (random.addr(), Some(Box::new(random) as BoxedLoopbackAlias)),
155-
Err(_) => continue,
156-
},
157-
158-
3 => (IpAddr::from(Ipv4Addr::LOCALHOST), None),
159-
160-
4.. => break,
161-
};
162-
163-
let sock = match socket::socket(
164-
AddressFamily::Inet,
165-
SockType::Datagram,
166-
SockFlag::empty(),
167-
SockProtocol::Udp,
168-
) {
169-
Ok(sock) => sock,
170-
Err(error) => {
171-
tracing::error!("Failed to open IPv4/UDP socket: {error}");
172-
continue;
173-
}
174-
};
175-
176-
// SO_NONBLOCK is required for turning this into a tokio socket.
177-
if let Err(error) = fcntl::fcntl(&sock, fcntl::F_SETFL(fcntl::OFlag::O_NONBLOCK)) {
178-
tracing::warn!("Failed to set socket as nonblocking: {error}");
179-
continue;
180-
}
181-
182-
// SO_REUSEADDR allows us to bind to `127.x.y.z` even if another socket is bound to
183-
// `0.0.0.0`.
184-
if let Err(error) = socket::setsockopt(&sock, socket::sockopt::ReuseAddr, &true) {
185-
tracing::warn!("Failed to set SO_REUSEADDR on resolver socket: {error}");
186-
}
187-
188-
let sin = SockaddrStorage::from(std::net::SocketAddr::new(socket_addr, port));
189-
190-
match socket::bind(sock.as_raw_fd(), &sin) {
191-
Ok(()) => {
192-
let socket = UdpSocket::from_std(sock.into()).expect("socket is non-blocking");
193-
return Ok((socket, on_drop));
194-
}
195-
Err(err) => {
196-
tracing::warn!("Failed to bind DNS server to {socket_addr}: {err}");
197-
if let Some(on_drop) = on_drop {
198-
on_drop.unassign().await;
199-
}
200-
}
201-
}
202-
}
203-
204-
Err(Error::UdpBind)
205-
}
206-
207138
pub(crate) fn flush_system_cache() {
208139
#[cfg(target_os = "macos")]
209140
{

0 commit comments

Comments
 (0)