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