Skip to main content

mtop_client/dns/
resolv.rs

1use crate::core::MtopError;
2use std::fmt::Debug;
3use std::net::{IpAddr, SocketAddr};
4use std::str::FromStr;
5use std::time::Duration;
6use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
7
8const DEFAULT_PORT: u16 = 53;
9const MAX_NAMESERVERS: usize = 3;
10
11/// Configuration for a DNS client based on a parsed resolv.conf file.
12///
13/// Note that only the `nameserver` setting and a few `option`s are supported.
14#[derive(Debug, Default, Clone, Eq, PartialEq)]
15pub struct ResolvConf {
16    pub nameservers: Vec<SocketAddr>,
17    pub options: ResolvConfOptions,
18}
19
20/// Options to change the behavior of a DNS client based on a resolv.conf file.
21///
22/// Note that only a subset of options are supported.
23#[derive(Debug, Default, Clone, Eq, PartialEq)]
24pub struct ResolvConfOptions {
25    pub timeout: Option<Duration>,
26    pub attempts: Option<u8>,
27    pub rotate: Option<bool>,
28}
29
30/// Read settings for a DNS client from a resolv.conf configuration file.
31pub async fn config<R>(read: R) -> Result<ResolvConf, MtopError>
32where
33    R: AsyncRead + Send + Sync + Unpin + 'static,
34{
35    let mut lines = BufReader::new(read).lines();
36    let mut conf = ResolvConf::default();
37
38    while let Some(line) = lines.next_line().await? {
39        let line = line.trim();
40        if line.is_empty() || line.starts_with('#') {
41            continue;
42        }
43
44        let mut parts = line.split_whitespace();
45        let Some(key) = parts.next() else {
46            tracing::debug!(message = "skipping malformed resolv.conf line", line = line);
47            continue;
48        };
49
50        match Token::get(key) {
51            Some(Token::NameServer) => {
52                if conf.nameservers.len() < MAX_NAMESERVERS {
53                    conf.nameservers.push(parse_nameserver(line, parts)?);
54                }
55            }
56            Some(Token::Options) => {
57                for opt in parse_options(parts) {
58                    match opt {
59                        OptionsToken::Timeout(t) => {
60                            conf.options.timeout = Some(Duration::from_secs(u64::from(t)));
61                        }
62                        OptionsToken::Attempts(n) => {
63                            conf.options.attempts = Some(n);
64                        }
65                        OptionsToken::Rotate => {
66                            conf.options.rotate = Some(true);
67                        }
68                    }
69                }
70            }
71            None => {
72                tracing::debug!(
73                    message = "skipping unknown resolv.conf setting",
74                    setting = key,
75                    line = line
76                );
77            }
78        }
79    }
80
81    Ok(conf)
82}
83
84/// Parse a single nameserver IP address, adding a default port of 53, from a `nameserver`
85/// line in a resolv.conf file, returning an error if the address is malformed.
86fn parse_nameserver<'a>(line: &str, mut parts: impl Iterator<Item = &'a str>) -> Result<SocketAddr, MtopError> {
87    if let Some(part) = parts.next() {
88        part.parse::<IpAddr>()
89            .map(|ip| (ip, DEFAULT_PORT).into())
90            .map_err(|e| MtopError::configuration_cause(format!("malformed nameserver address '{}'", part), e))
91    } else {
92        Err(MtopError::configuration(format!(
93            "malformed nameserver configuration '{}'",
94            line
95        )))
96    }
97}
98
99/// Parse one or more options from an `option` line in a resolv.conf file, ignoring any
100/// malformed or unsupported options.
101fn parse_options<'a>(parts: impl Iterator<Item = &'a str>) -> Vec<OptionsToken> {
102    let mut out = Vec::new();
103
104    for part in parts {
105        let opt = match part.parse() {
106            Ok(o) => o,
107            Err(e) => {
108                tracing::debug!(message = "skipping unknown resolv.conf option", option = part, err = %e);
109                continue;
110            }
111        };
112
113        out.push(opt);
114    }
115    out
116}
117
118/// Top-level configuration setting in a resolv.conf file.
119///
120/// Note that only a subset of all possible settings are supported.
121#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
122enum Token {
123    NameServer,
124    Options,
125}
126
127impl Token {
128    fn get(s: &str) -> Option<Self> {
129        match s {
130            "nameserver" => Some(Self::NameServer),
131            "options" => Some(Self::Options),
132            _ => None,
133        }
134    }
135}
136
137/// Keyword or key-value pair associated with an option token.
138///
139/// Note that only a subset of all possible options are supported.
140#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
141enum OptionsToken {
142    Timeout(u8),
143    Attempts(u8),
144    Rotate,
145}
146
147impl OptionsToken {
148    const MAX_TIMEOUT: u8 = 30;
149    const MAX_ATTEMPTS: u8 = 5;
150
151    fn parse(line: &str, val: &str, max: u8) -> Result<u8, MtopError> {
152        let n: u8 = val
153            .parse()
154            .map_err(|e| MtopError::configuration_cause(format!("unable to parse {} value '{}'", line, val), e))?;
155
156        Ok(n.min(max))
157    }
158}
159
160impl FromStr for OptionsToken {
161    type Err = MtopError;
162
163    fn from_str(s: &str) -> Result<Self, Self::Err> {
164        if s == "rotate" {
165            Ok(Self::Rotate)
166        } else {
167            match s.split_once(':') {
168                Some(("timeout", v)) => Ok(Self::Timeout(Self::parse(s, v, Self::MAX_TIMEOUT)?)),
169                Some(("attempts", v)) => Ok(Self::Attempts(Self::parse(s, v, Self::MAX_ATTEMPTS)?)),
170                _ => Err(MtopError::configuration(format!("unknown option {}", s))),
171            }
172        }
173    }
174}
175
176#[cfg(test)]
177mod test {
178    use super::{OptionsToken, Token, config};
179    use crate::core::ErrorKind;
180    use crate::dns::{ResolvConf, ResolvConfOptions};
181    use std::io::{Cursor, Error as IOError, ErrorKind as IOErrorKind};
182    use std::pin::Pin;
183    use std::str::FromStr;
184    use std::task::{Context, Poll};
185    use std::time::Duration;
186    use tokio::io::{AsyncRead, ReadBuf};
187
188    #[test]
189    fn test_configuration() {
190        assert_eq!(Some(Token::NameServer), Token::get("nameserver"));
191        assert_eq!(Some(Token::Options), Token::get("options"));
192        assert_eq!(None, Token::get("invalid"));
193    }
194
195    #[test]
196    fn test_configuration_option_success() {
197        assert_eq!(OptionsToken::Rotate, OptionsToken::from_str("rotate").unwrap());
198        assert_eq!(OptionsToken::Timeout(3), OptionsToken::from_str("timeout:3").unwrap());
199        assert_eq!(OptionsToken::Attempts(4), OptionsToken::from_str("attempts:4").unwrap());
200    }
201
202    #[test]
203    fn test_configuration_option_limits() {
204        assert_eq!(OptionsToken::Timeout(30), OptionsToken::from_str("timeout:35").unwrap());
205        assert_eq!(
206            OptionsToken::Attempts(5),
207            OptionsToken::from_str("attempts:10").unwrap()
208        );
209    }
210
211    #[test]
212    fn test_configuration_option_error() {
213        assert!(OptionsToken::from_str("ndots:bad").is_err());
214        assert!(OptionsToken::from_str("timeout:bad").is_err());
215        assert!(OptionsToken::from_str("attempts:-5").is_err());
216    }
217
218    #[tokio::test]
219    async fn test_config_read_error() {
220        struct ErrAsyncRead;
221        impl AsyncRead for ErrAsyncRead {
222            fn poll_read(
223                self: Pin<&mut Self>,
224                _cx: &mut Context<'_>,
225                _buf: &mut ReadBuf<'_>,
226            ) -> Poll<std::io::Result<()>> {
227                Poll::Ready(Err(IOError::new(IOErrorKind::UnexpectedEof, "test error")))
228            }
229        }
230
231        let reader = ErrAsyncRead;
232        let res = config(reader).await.unwrap_err();
233        assert_eq!(ErrorKind::IO, res.kind());
234    }
235
236    #[tokio::test]
237    async fn test_config_no_content() {
238        let reader = Cursor::new(Vec::new());
239        let res = config(reader).await.unwrap();
240        assert_eq!(ResolvConf::default(), res);
241    }
242
243    #[tokio::test]
244    async fn test_config_all_comments() {
245        #[rustfmt::skip]
246        let reader = Cursor::new(concat!(
247            "# this is a comment\n",
248            "# another comment\n",
249        ));
250        let res = config(reader).await.unwrap();
251        assert_eq!(ResolvConf::default(), res);
252    }
253
254    #[tokio::test]
255    async fn test_config_all_unsupported() {
256        #[rustfmt::skip]
257        let reader = Cursor::new(concat!(
258            "scrambler 127.0.0.5\n",
259            "invalid directive\n",
260        ));
261        let res = config(reader).await.unwrap();
262        assert_eq!(ResolvConf::default(), res);
263    }
264
265    #[tokio::test]
266    async fn test_config_nameservers_search_invalid_options() {
267        #[rustfmt::skip]
268        let reader = Cursor::new(concat!(
269            "# this is a comment\n",
270            "nameserver 127.0.0.53\n",
271            "options casual-fridays:true\n",
272        ));
273
274        let expected = ResolvConf {
275            nameservers: vec!["127.0.0.53:53".parse().unwrap()],
276            options: ResolvConfOptions::default(),
277        };
278
279        let res = config(reader).await.unwrap();
280        assert_eq!(expected, res);
281    }
282
283    #[tokio::test]
284    async fn test_config_nameservers_search_no_options() {
285        #[rustfmt::skip]
286        let reader = Cursor::new(concat!(
287            "# this is a comment\n",
288            "nameserver 127.0.0.53\n",
289        ));
290
291        let expected = ResolvConf {
292            nameservers: vec!["127.0.0.53:53".parse().unwrap()],
293            options: ResolvConfOptions::default(),
294        };
295
296        let res = config(reader).await.unwrap();
297        assert_eq!(expected, res);
298    }
299
300    #[tokio::test]
301    async fn test_config_nameservers_search_options() {
302        #[rustfmt::skip]
303        let reader = Cursor::new(concat!(
304            "# this is a comment\n",
305            "nameserver 127.0.0.53\n",
306            "nameserver 127.0.0.54\n",
307            "nameserver 127.0.0.55\n",
308            "options ndots:3 attempts:5 timeout:10 rotate use-vc edns0\n",
309        ));
310
311        let expected = ResolvConf {
312            nameservers: vec![
313                "127.0.0.53:53".parse().unwrap(),
314                "127.0.0.54:53".parse().unwrap(),
315                "127.0.0.55:53".parse().unwrap(),
316            ],
317            options: ResolvConfOptions {
318                timeout: Some(Duration::from_secs(10)),
319                attempts: Some(5),
320                rotate: Some(true),
321            },
322        };
323
324        let res = config(reader).await.unwrap();
325        assert_eq!(expected, res);
326    }
327}