Skip to main content

kernoid_client/
srv.rs

1// SPDX-License-Identifier: MIT
2//! Finding the JMAP server of a domain through DNS (RFC 8620, 2.2): the SRV records `_jmap._tcp.<domain>`, asked of
3//! the system's name servers (`/etc/resolv.conf`) over UDP – over TCP when the answer was cut. Messages are written
4//! and read by hickory-proto; no own DNS parsing.
5
6use std::{
7    net::{IpAddr, SocketAddr},
8    time::Duration,
9};
10
11use tokio::{
12    io::{AsyncReadExt, AsyncWriteExt},
13    net::{TcpStream, UdpSocket},
14    time::timeout,
15};
16
17use hickory_proto::{
18    op::{Message, Query},
19    rr::{Name, RData, RecordType},
20};
21
22/// A server an SRV record names.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Target {
25    pub host: String,
26    pub port: u16,
27    pub priority: u16,
28    pub weight: u16,
29}
30
31impl Target {
32    /// Where its session is: `https://host[:port]/.well-known/jmap`.
33    pub fn session_url(&self) -> String {
34        let host = if self.host.contains(':') { format!("[{}]", self.host) } else { self.host.clone() };
35        if self.port == 443 {
36            format!("https://{host}/.well-known/jmap")
37        } else {
38            format!("https://{host}:{}/.well-known/jmap", self.port)
39        }
40    }
41}
42
43/// The name servers of `/etc/resolv.conf`'s text.
44pub fn name_servers(resolv_conf: &str) -> Vec<IpAddr> {
45    resolv_conf
46        .lines()
47        .filter_map(|line| line.trim().strip_prefix("nameserver"))
48        .filter_map(|rest| rest.split_whitespace().next())
49        .filter_map(|address| address.split('%').next()?.parse().ok())
50        .collect()
51}
52
53/// The question for `_jmap._tcp.<domain>`.
54pub fn query(domain: &str, id: u16) -> Result<Vec<u8>, String> {
55    let name = Name::from_ascii(format!("_jmap._tcp.{}.", domain.trim_end_matches('.'))).map_err(|e| e.to_string())?;
56    let mut message = Message::query();
57    message.metadata.id = id;
58    message.metadata.recursion_desired = true;
59    message.add_query(Query::query(name, RecordType::SRV));
60    message.to_vec().map_err(|e| e.to_string())
61}
62
63/// The targets of an answer, in the order to try them: by priority, then the heavier first (RFC 2782 without the
64/// random pick – there is one client). `None` if it was cut and needs TCP; `.` as target means no service.
65pub fn targets(answer: &[u8], id: u16) -> Result<Option<Vec<Target>>, String> {
66    let message = Message::from_vec(answer).map_err(|e| e.to_string())?;
67    if message.metadata.id != id {
68        return Err("an answer to another question".into());
69    }
70    if message.metadata.truncation {
71        return Ok(None);
72    }
73    let mut out: Vec<Target> = message
74        .answers
75        .iter()
76        .filter_map(|record| match &record.data {
77            RData::SRV(srv) => Some(Target {
78                host: srv.target.to_utf8().trim_end_matches('.').to_owned(),
79                port: srv.port,
80                priority: srv.priority,
81                weight: srv.weight,
82            }),
83            _ => None,
84        })
85        .filter(|target| !target.host.is_empty())
86        .collect();
87    out.sort_by_key(|target| (target.priority, std::cmp::Reverse(target.weight)));
88    Ok(Some(out))
89}
90
91/// Waits at most three seconds for a step of a question.
92async fn within<T>(step: impl Future<Output = std::io::Result<T>>) -> Result<T, String> {
93    timeout(Duration::from_secs(3), step).await.map_err(|_| "no answer".to_owned())?.map_err(|e| e.to_string())
94}
95
96async fn ask(server: IpAddr, question: &[u8], id: u16) -> Result<Vec<Target>, String> {
97    let address = SocketAddr::new(server, 53);
98    let bind: SocketAddr = if server.is_ipv4() { ([0, 0, 0, 0], 0).into() } else { ([0u16; 8], 0).into() };
99    let socket = UdpSocket::bind(bind).await.map_err(|e| e.to_string())?;
100    socket.send_to(question, address).await.map_err(|e| e.to_string())?;
101    let mut buffer = vec![0; 4096];
102    let (read, from) = within(socket.recv_from(&mut buffer)).await?;
103    if from != address {
104        return Err("an answer from elsewhere".into());
105    }
106    if let Some(targets) = targets(&buffer[..read], id)? {
107        return Ok(targets);
108    }
109    // Cut: once more over TCP, with the length before each message (RFC 1035, 4.2.2).
110    let mut stream = within(TcpStream::connect(address)).await?;
111    let length = u16::try_from(question.len()).map_err(|e| e.to_string())?;
112    let mut message = length.to_be_bytes().to_vec();
113    message.extend_from_slice(question);
114    within(stream.write_all(&message)).await?;
115    let mut length = [0; 2];
116    within(stream.read_exact(&mut length)).await?;
117    let mut answer = vec![0; u16::from_be_bytes(length) as usize];
118    within(stream.read_exact(&mut answer)).await?;
119    targets(&answer, id)?.ok_or_else(|| "the answer was cut over TCP too".into())
120}
121
122/// The JMAP servers of a domain, best first; empty if it names none.
123pub async fn lookup(domain: &str) -> Result<Vec<Target>, String> {
124    let resolv_conf = tokio::fs::read_to_string("/etc/resolv.conf").await.unwrap_or_default();
125    let servers = name_servers(&resolv_conf);
126    if servers.is_empty() {
127        return Err("no name server in /etc/resolv.conf".into());
128    }
129    let id = u16::try_from(std::process::id() % 65_536).unwrap_or(1) ^ 0x5a5a;
130    let question = query(domain, id)?;
131    let mut last = String::new();
132    for server in servers {
133        match ask(server, &question, id).await {
134            Ok(targets) => return Ok(targets),
135            Err(error) => last = error,
136        }
137    }
138    Err(last)
139}
140
141#[cfg(test)]
142mod tests {
143    use hickory_proto::rr::{Record, rdata::SRV};
144
145    use super::*;
146
147    #[test]
148    fn resolv_conf() {
149        let text = "# x\nnameserver 192.0.2.1\nnameserver fe80::1%en0\nsearch example\nnameserver nonsense\n";
150        let servers = name_servers(text);
151        assert_eq!(servers, ["192.0.2.1".parse::<IpAddr>().unwrap(), "fe80::1".parse().unwrap()]);
152    }
153
154    #[test]
155    fn questions_and_answers() {
156        let question = query("firma.example", 7).unwrap();
157        let mut answer = Message::from_vec(&question).unwrap();
158        assert_eq!(answer.queries[0].name().to_utf8(), "_jmap._tcp.firma.example.");
159        answer.metadata.message_type = hickory_proto::op::MessageType::Response;
160        let name = Name::from_ascii("_jmap._tcp.firma.example.").unwrap();
161        for (priority, weight, host) in
162            [(20, 0, "b.firma.example."), (10, 1, "a.firma.example."), (10, 5, "c.firma.example.")]
163        {
164            let srv = SRV::new(priority, weight, 8443, Name::from_ascii(host).unwrap());
165            answer.answers.push(Record::from_rdata(name.clone(), 300, RData::SRV(srv)));
166        }
167        let found = targets(&answer.to_vec().unwrap(), 7).unwrap().unwrap();
168        let hosts: Vec<_> = found.iter().map(|t| t.host.as_str()).collect();
169        assert_eq!(hosts, ["c.firma.example", "a.firma.example", "b.firma.example"]);
170        assert_eq!(found[0].session_url(), "https://c.firma.example:8443/.well-known/jmap");
171        assert!(targets(&answer.to_vec().unwrap(), 8).is_err());
172        answer.metadata.truncation = true;
173        assert_eq!(targets(&answer.to_vec().unwrap(), 7).unwrap(), None);
174    }
175
176    /// Asks the real DNS: `cargo test -- --ignored srv` (needs the network).
177    #[tokio::test]
178    #[ignore = "needs the network"]
179    async fn live() {
180        let found = lookup("fastmail.com").await.unwrap();
181        assert!(!found.is_empty(), "fastmail.com publishes _jmap._tcp");
182        println!("{found:?}");
183    }
184}