1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
//! Async UDP DNS server for the runtime's configured local domain.
//!
//! Listens on `127.0.0.1:{port}` and resolves container hostnames registered
//! via [`NetworkManager`]. Queries for unregistered names in that domain
//! get a NODATA response (NOERROR, no records); all other queries are
//! forwarded to upstream DNS. NODATA rather than NXDOMAIN because the domain
//! ends in `.local`: mDNSResponder also multicasts the query and ignores a
//! unicast NXDOMAIN, so the lookup would wait out the mDNS timeout instead
//! (`DnsForwarder::try_resolve_locally_or_nodata`).
use anyhow::{Context, Result};
use arcbox_net::NetworkManager;
use std::io;
use std::net::Ipv4Addr;
use std::sync::Arc;
use tokio::net::UdpSocket;
use tokio_util::sync::CancellationToken;
/// Async UDP DNS server backed by [`NetworkManager`]'s DNS forwarder.
///
/// Binding and serving are two steps: the socket is bound in
/// `start_control_plane`, before the runtime exists, and served from
/// `start_runtime_services` once the runtime's [`NetworkManager`] does.
/// Queries that arrive in between wait in the socket's receive buffer.
pub struct DnsService {
socket: UdpSocket,
}
impl DnsService {
/// Binds the UDP socket on `127.0.0.1`.
///
/// An explicitly requested port (`--dns-port` or `ARCBOX_DNS_PORT`, `0`
/// for any) must bind or startup fails, as for the Kubernetes proxy and
/// the SSH server. The profile's `default` port is best-effort with one
/// difference: `NETWORK_READY` promises DNS, so a taken default falls
/// back to an OS-allocated port instead of leaving the daemon without
/// DNS. Self-setup publishes whichever port was bound through
/// `/etc/resolver/<domain>`, and `abctl dns status` probes the port that
/// file names.
///
/// Called right after the daemon lease is held, so the previous daemon
/// has released its socket and a bind failure aborts startup before any
/// VM boots.
pub async fn bind_requested(requested: Option<u16>, default: u16) -> Result<Self> {
let socket = match requested {
Some(port) => bind_loopback(port)
.await
.with_context(|| bind_error(port))?,
None => match bind_loopback(default).await {
Ok(socket) => socket,
Err(error) if error.kind() == io::ErrorKind::AddrInUse => {
let socket = bind_loopback(0).await.with_context(|| bind_error(0))?;
let fallback = socket
.local_addr()
.context("Failed to read DNS service address")?
.port();
tracing::warn!(
port = default,
fallback,
"DNS port in use; listening on an OS-allocated port instead"
);
socket
}
Err(error) => Err(error).with_context(|| bind_error(default))?,
},
};
let actual_addr = socket
.local_addr()
.context("Failed to read DNS service address")?;
tracing::info!(%actual_addr, "DNS service bound");
Ok(Self { socket })
}
/// Returns the actual UDP port selected by the bound socket.
///
/// # Errors
///
/// Returns an error if the socket address cannot be queried.
pub fn host_port(&self) -> Result<u16> {
Ok(self
.socket
.local_addr()
.context("Failed to read DNS service address")?
.port())
}
/// Runs the DNS event loop on the socket [`Self::bind_requested`] bound,
/// resolving through `network_manager`.
///
/// This method never returns under normal operation. Each incoming UDP
/// packet is handled inline for local queries (fast path) or dispatched
/// to a blocking task for upstream forwarding (slow path).
pub async fn run(
self,
network_manager: Arc<NetworkManager>,
shutdown: CancellationToken,
) -> Result<()> {
let mut buf = [0u8; 512];
let socket = Arc::new(self.socket);
loop {
let (len, src) = tokio::select! {
result = socket.recv_from(&mut buf) => result?,
() = shutdown.cancelled() => {
tracing::info!("DNS service shutting down");
return Ok(());
}
};
// Fast path: local resolution or NODATA for the configured domain.
// Operates on a borrowed slice to avoid allocation.
if let Some(response) = network_manager.try_resolve_dns_or_nodata(&buf[..len]) {
if let Err(e) = socket.send_to(&response, src).await {
tracing::debug!("Failed to send DNS response to {}: {}", src, e);
}
continue;
}
// Slow path: forward to upstream DNS via blocking I/O.
// Only clone into owned buffer when actually needed.
let query = buf[..len].to_vec();
let nm = Arc::clone(&network_manager);
let sock = Arc::clone(&socket);
tokio::spawn(async move {
// Pre-build SERVFAIL before query is moved into spawn_blocking.
let servfail = build_servfail(&query);
let result = tokio::task::spawn_blocking(move || nm.handle_dns_query(&query))
.await
.ok()
.and_then(|r| r.ok());
match result {
Some(response) => {
if let Err(e) = sock.send_to(&response, src).await {
tracing::debug!("Failed to send DNS response to {}: {}", src, e);
}
}
None => {
// Send SERVFAIL so the client fails fast instead of timing out.
if let Some(servfail) = servfail {
let _ = sock.send_to(&servfail, src).await;
}
tracing::debug!("Upstream DNS forwarding failed for query from {}", src);
}
}
});
}
}
}
/// Binds a UDP socket on `127.0.0.1:{port}`; `0` asks the OS for any port.
async fn bind_loopback(port: u16) -> io::Result<UdpSocket> {
UdpSocket::bind((Ipv4Addr::LOCALHOST, port)).await
}
fn bind_error(port: u16) -> String {
format!("DNS service failed to bind 127.0.0.1:{port}")
}
/// Builds a SERVFAIL response from the original query bytes.
fn build_servfail(query: &[u8]) -> Option<Vec<u8>> {
if query.len() < 12 {
return None;
}
let mut response = query.to_vec();
// QR=1, RD preserved, RCODE=2 (SERVFAIL)
response[2] |= 0x80; // set QR
response[3] = (response[3] & 0xF0) | 0x02; // RCODE=SERVFAIL
// Zero answer/authority counts; keep question.
response[6] = 0x00;
response[7] = 0x00;
response[8] = 0x00;
response[9] = 0x00;
response[10] = 0x00;
response[11] = 0x00;
// Truncate to just header + question (strip any additional sections).
// For simplicity, return the full query as response — the counts are zeroed
// so extra bytes are harmless, and most DNS libraries ignore trailing data.
Some(response)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
/// Builds a minimal DNS query packet for a given domain name (A record, IN class).
fn build_dns_query(name: &str) -> Vec<u8> {
let mut packet = Vec::with_capacity(64);
// Header: ID=0x1234, QR=0, QDCOUNT=1
packet.extend_from_slice(&[0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00]);
packet.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
// Question: encode name labels
for label in name.split('.') {
packet.push(label.len() as u8);
packet.extend_from_slice(label.as_bytes());
}
packet.push(0x00); // root label
packet.extend_from_slice(&[0x00, 0x01]); // QTYPE = A
packet.extend_from_slice(&[0x00, 0x01]); // QCLASS = IN
packet
}
#[tokio::test]
async fn explicit_port_in_use_fails_startup() {
let blocker = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let port = blocker.local_addr().unwrap().port();
let result = DnsService::bind_requested(Some(port), port).await;
assert!(
result.is_err(),
"an explicitly requested port that is taken must fail, not fall back"
);
}
#[tokio::test]
async fn default_port_in_use_falls_back_to_an_os_port() {
let blocker = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let taken = blocker.local_addr().unwrap().port();
let service = DnsService::bind_requested(None, taken)
.await
.expect("a taken default port must not fail startup");
let bound = service.host_port().unwrap();
assert_ne!(bound, taken);
assert_ne!(bound, 0);
}
#[tokio::test]
async fn test_dns_local_resolution_roundtrip() {
let nm = Arc::new(NetworkManager::new(arcbox_net::NetConfig::default()));
let ip = std::net::IpAddr::V4(Ipv4Addr::new(172, 17, 0, 2));
nm.register_dns("my-nginx", ip);
let service = DnsService::bind_requested(Some(0), 0).await.unwrap();
let server_addr = ("127.0.0.1", service.host_port().unwrap());
let server_handle =
tokio::spawn(async move { service.run(nm, CancellationToken::new()).await });
// Send query from client.
let client = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let query = build_dns_query("my-nginx.arcbox.local");
client.send_to(&query, server_addr).await.unwrap();
let mut buf = [0u8; 512];
let (len, _) = tokio::time::timeout(
std::time::Duration::from_secs(2),
client.recv_from(&mut buf),
)
.await
.expect("DNS response timeout")
.unwrap();
let response = &buf[..len];
// Verify it's a response (QR=1) with RCODE=0 (no error) and ANCOUNT=1.
assert_eq!(response[2] & 0x80, 0x80, "QR bit should be set");
assert_eq!(response[3] & 0x0F, 0, "RCODE should be 0 (NoError)");
assert_eq!(response[7], 1, "ANCOUNT should be 1");
// Extract the A record IP from the answer section.
let answer_start = 12 + query.len() - 12; // skip header + question
let rdata_offset = answer_start + 2 + 2 + 2 + 4 + 2; // name_ptr + type + class + ttl + rdlen
let ip_bytes = &response[rdata_offset..rdata_offset + 4];
assert_eq!(ip_bytes, &[172, 17, 0, 2]);
server_handle.abort();
}
#[tokio::test]
async fn test_dns_nodata_for_unregistered_local() {
let nm = Arc::new(NetworkManager::new(arcbox_net::NetConfig::default()));
// Nothing registered: the query gets NODATA, not NXDOMAIN.
let service = DnsService::bind_requested(Some(0), 0).await.unwrap();
let server_addr = ("127.0.0.1", service.host_port().unwrap());
let server_handle =
tokio::spawn(async move { service.run(nm, CancellationToken::new()).await });
let client = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let query = build_dns_query("nonexistent.arcbox.local");
client.send_to(&query, server_addr).await.unwrap();
let mut buf = [0u8; 512];
let (len, _) = tokio::time::timeout(
std::time::Duration::from_secs(2),
client.recv_from(&mut buf),
)
.await
.expect("DNS response timeout")
.unwrap();
let response = &buf[..len];
// NODATA: QR=1, RCODE=0, no answer records.
assert_eq!(response[2] & 0x80, 0x80, "QR bit should be set");
assert_eq!(response[3] & 0x0F, 0, "RCODE should be 0 (NoError)");
assert_eq!(response[7], 0, "ANCOUNT should be 0");
server_handle.abort();
}
#[test]
fn test_build_servfail() {
let query = build_dns_query("example.com");
let response = build_servfail(&query).unwrap();
assert_eq!(response[2] & 0x80, 0x80, "QR bit should be set");
assert_eq!(response[3] & 0x0F, 2, "RCODE should be 2 (SERVFAIL)");
assert_eq!(response[7], 0, "ANCOUNT should be 0");
}
#[test]
fn test_build_servfail_too_short() {
assert!(build_servfail(&[0; 5]).is_none());
}
}