Skip to main content

manycastr/worker/inbound/
dns.rs

1use crate::custom_module::manycastr::reply::ReplyData;
2use crate::custom_module::manycastr::{
3    Address, DiscoveryReply, MeasurementReply, Reply, TraceReply,
4};
5use crate::dns_identifier;
6use crate::net::{DNSAnswer, DNSRecord, TXTRecord};
7use crate::worker::inbound::ReplyMeta;
8
9/// Per-measurement context needed to validate incoming DNS replies.
10pub struct DnsContext {
11    pub is_chaos: bool,
12    pub sport: u16,
13    pub m_id: u32,
14    pub is_traceroute: bool,
15}
16
17/// Parse DNS packets into a Reply result.
18/// Filters out spoofed packets and only parses DNS replies valid for the current measurement.
19///
20/// # Arguments
21/// * `packet_bytes` - the bytes of the packet to parse
22/// * `meta` - received packet metadata (source address, TTL, kernel receive time)
23/// * `ctx` - per-measurement DNS context (sport, m_id, is_chaos)
24///
25/// # Returns
26/// * `Option<Reply>` - the received DNS reply (None if invalid)
27pub fn parse_dns(packet_bytes: &[u8], meta: ReplyMeta, ctx: &DnsContext) -> Option<Reply> {
28    let ReplyMeta { src, ttl, rx_time } = meta;
29    let DnsContext {
30        is_chaos,
31        sport,
32        m_id,
33        is_traceroute: traceroute,
34    } = *ctx;
35
36    // Obtain the DNS message and the reply's destination port (our source port).
37    let udp_bytes = if src.is_v6() {
38        packet_bytes
39    } else {
40        // Raw socket: IPv4 includes the IP header (skip 20 bytes)
41        packet_bytes.get(20..)?
42    };
43    if udp_bytes.len() < 8 {
44        return None;
45    }
46    let reply_dport = u16::from_be_bytes([udp_bytes[2], udp_bytes[3]]);
47    let dns_msg = &udp_bytes[8..];
48
49    // Verify our destination port (i.e. the probe's source port)
50    if reply_dport != sport {
51        return None;
52    }
53
54    // Verify 6-bit measurement identifier in the DNS transaction ID
55    if dns_msg.is_empty() || (dns_msg[0] >> 2) != dns_identifier(m_id) {
56        return None;
57    }
58
59    // The body length has to be large enough to contain a DNS A / TXT reply
60    if (!is_chaos & (dns_msg.len() < 66)) | (is_chaos & (dns_msg.len() < 10)) {
61        return None;
62    }
63
64    let (tx_time, tx_id, chaos, is_discovery, hop_ttl, session_id) = if !is_chaos {
65        let dns_result = parse_dns_a_record(dns_msg, src.is_v6(), m_id)?;
66
67        if (dns_result.probe_sport != reply_dport) | (dns_result.probe_dst != src) {
68            return None; // spoofed reply
69        }
70
71        (
72            dns_result.tx_time,
73            dns_result.tx_id,
74            None,
75            dns_result.is_discovery,
76            dns_result.hop_ttl,
77            dns_result.session_id,
78        )
79    } else {
80        // CHAOS replies only echo the 6-bit measurement identifier, no session ID
81        let (tx_time, tx_worker_id, chaos) = parse_chaos(dns_msg)?;
82        (tx_time, tx_worker_id, Some(chaos), false, None, 0)
83    };
84
85    if is_discovery {
86        // Discovery reply: identifies the catching worker (starts the trace, or --responsive).
87        Some(Reply {
88            reply_data: Some(ReplyData::Discovery(DiscoveryReply {
89                src: Some(src),
90                session_id,
91            })),
92        })
93    } else if traceroute {
94        // DNS trace reply from destination (DNS answer)
95        Some(Reply {
96            reply_data: Some(ReplyData::Trace(TraceReply {
97                hop_addr: Some(src),
98                ttl,
99                rtt: super::rtt_ms(rx_time, tx_time, super::TxEncoding::Micros),
100                tx_id,
101                trace_dst: Some(src),
102                hop_count: hop_ttl.unwrap_or(0) as u32,
103            })),
104        })
105    } else {
106        // CHAOS replies carry no transmit timestamp
107        let rtt = if is_chaos {
108            0.0
109        } else {
110            super::rtt_ms(rx_time, tx_time, super::TxEncoding::Micros)
111        };
112        Some(Reply {
113            reply_data: Some(ReplyData::Measurement(MeasurementReply {
114                src: Some(src),
115                ttl,
116                rtt,
117                tx_id,
118                chaos,
119                session_id,
120            })),
121        })
122    }
123}
124
125struct DnsResult {
126    tx_time: u64,
127    tx_id: u32,
128    probe_sport: u16,
129    probe_dst: Address,
130    is_discovery: bool,
131    /// Probe TTL encoded in the QNAME (UDP/DNS traceroute probes only; `None` otherwise).
132    hop_ttl: Option<u8>,
133    /// Session ID recovered from the QNAME-encoded probe ID.
134    session_id: u32,
135}
136
137/// Attempts to parse the DNS A record from a DNS payload body.
138///
139/// # Arguments
140/// * `packet_bytes` - the bytes of the packet to parse
141/// * `is_ipv6` - whether this is an IPv6 measurement
142/// * `m_id` - measurement ID to validate against the QNAME-encoded value
143///
144/// # Returns
145/// * `Option<DnsResult>` - the DNS result containing the DNS A record with the source port and source and destination addresses and whether it is a discovery packet
146///
147/// # Remarks
148/// The function returns None if the packet is too short to contain a DNS A record,
149/// or if the measurement ID encoded in the QNAME does not match the current measurement.
150fn parse_dns_a_record(packet_bytes: &[u8], is_ipv6: bool, m_id: u32) -> Option<DnsResult> {
151    let record = DNSRecord::from(packet_bytes);
152    let domain = record.domain; // example: '1679305276037913215.3226971181.16843009.0.4000.123456.google.com'
153    let parts: Vec<&str> = domain.split('.').collect();
154    // Our domains have at least 6 parts (tx_time, src, dst, tx_id, sport, probe_id, domain...)
155    if parts.len() < 6 {
156        return None;
157    }
158
159    let tx_time = parts[0].parse::<u64>().ok()?;
160    let probe_dst = if is_ipv6 {
161        Address::from(parts[2].parse::<u128>().ok()?)
162    } else {
163        Address::from(parts[2].parse::<u32>().ok()?)
164    };
165    let mut tx_id = parts[3].parse::<u32>().ok()?;
166    let probe_sport = parts[4].parse::<u16>().ok()?;
167    let pkt_probe_id = parts[5].parse::<u32>().ok()?;
168
169    // Verify measurement ID (upper half of the probe ID) matches
170    if crate::m_id_of(pkt_probe_id) != m_id {
171        return None;
172    }
173    let session_id = crate::session_id_of(pkt_probe_id);
174
175    let is_discovery = if tx_id > u16::MAX as u32 {
176        tx_id -= u16::MAX as u32;
177        true
178    } else {
179        false
180    };
181
182    // UDP/DNS traceroute probes carry the probe TTL as the 7th label (parts[6])
183    let hop_ttl = parts.get(6).and_then(|s| s.parse::<u8>().ok());
184
185    Some(DnsResult {
186        tx_time,
187        tx_id,
188        hop_ttl,
189        probe_sport,
190        probe_dst,
191        is_discovery,
192        session_id,
193    })
194}
195
196/// Attempts to parse the DNS Chaos record from a UDP payload body.
197///
198/// # Arguments
199/// * `packet_bytes` - the bytes of the packet to parse
200///
201/// # Returns
202/// * `Option<UdpPayload>` - the UDP payload containing the DNS Chaos record
203///
204/// # Remarks
205/// The function returns None if the packet is too short to contain a DNS Chaos record.
206fn parse_chaos(packet_bytes: &[u8]) -> Option<(u64, u32, String)> {
207    let record = DNSRecord::from(packet_bytes);
208
209    // 10 rightmost bits have the sender worker ID encoded
210    let tx_worker_id = (record.transaction_id & 0x03FF) as u32;
211
212    if record.answer == 0 {
213        return Some((0u64, tx_worker_id, "*".to_string()));
214    }
215
216    let chaos_data = TXTRecord::from(DNSAnswer::from(record.body.as_slice()).data.as_slice()).txt;
217
218    Some((0u64, tx_worker_id, chaos_data))
219}