Skip to main content

didbot_serve/
handles.rs

1//! Forward handle resolution: the DID a handle points at.
2//!
3//! A handle resolves through the `_atproto` TXT record first, asked of a
4//! DNS-over-HTTPS resolver, and through
5//! `https://<handle>/.well-known/atproto-did` when DNS names no DID. The
6//! HTTPS fetch is pinned to the addresses it checked, and refuses this
7//! deployment's own network when the caller passes
8//! [`PrivateAddresses::Refused`].
9//!
10//! The operator poll checks the operator's handle through this, and
11//! `com.atproto.identity.resolveHandle` resolves a handle no account here
12//! holds through it.
13
14use std::future::Future;
15use std::pin::Pin;
16use std::time::Duration;
17
18use didbot_http::PrivateAddresses;
19
20/// What a forward lookup found: the DID a handle points at, `None` when
21/// whoever answered named none, or an error when nobody could be asked.
22pub type Forward<'a> = Pin<Box<dyn Future<Output = Result<Option<String>, String>> + Send + 'a>>;
23
24/// The DID a handle points at.
25///
26/// A trait so a test can resolve handles with no network.
27pub trait HandleDid: Send + Sync {
28    /// The DID `handle` resolves to.
29    fn did_for<'a>(&'a self, handle: &'a str, private: PrivateAddresses) -> Forward<'a>;
30}
31
32/// The forward lookup over the network.
33pub struct Network {
34    doh: String,
35}
36
37impl Network {
38    /// Asks `doh`, a DNS-over-HTTPS resolver answering the JSON form. The
39    /// resolver is this deployment's own configuration, so it may be on a
40    /// private address.
41    pub fn new(doh: impl Into<String>) -> Self {
42        Self { doh: doh.into() }
43    }
44
45    async fn txt(&self, handle: &str) -> Result<Option<String>, String> {
46        let name = format!("_atproto.{handle}");
47        let records = didbot_http::doh::lookup(
48            &self.doh,
49            &name,
50            didbot_http::doh::TXT,
51            PrivateAddresses::Allowed,
52        )
53        .await?;
54        let dids: Vec<&str> = records
55            .iter()
56            .filter_map(|record| record.strip_prefix("did="))
57            .collect();
58        // More than one DID is no answer: which one the handle means is
59        // not something a reader can pick.
60        Ok(match dids.as_slice() {
61            [did] => Some((*did).to_owned()),
62            _ => None,
63        })
64    }
65
66    async fn well_known(
67        &self,
68        handle: &str,
69        private: PrivateAddresses,
70    ) -> Result<Option<String>, String> {
71        let url = didbot_identity::atproto_did_url(handle, None).map_err(|e| e.to_string())?;
72        let parsed = url::Url::parse(&url).map_err(|error| error.to_string())?;
73        let client = didbot_http::pinned_client(&parsed, private)
74            .await
75            .map_err(|error| error.to_string())?;
76        let response = client
77            .get(parsed)
78            .send()
79            .await
80            .map_err(|error| format!("fetching {url}: {error}"))?;
81        if !response.status().is_success() {
82            return Ok(None);
83        }
84        let body = didbot_http::read_bounded(response, MAX_WELL_KNOWN_BYTES)
85            .await
86            .map_err(|error| format!("reading {url}: {error}"))?;
87        let body = didbot_http::body_text(body);
88        Ok(didbot_identity::parse_atproto_did(&body)
89            .ok()
90            .map(str::to_owned))
91    }
92}
93
94/// The most of an `/.well-known/atproto-did` body this reads. A DID is at
95/// most 2 KiB; the rest is room for whitespace.
96const MAX_WELL_KNOWN_BYTES: usize = 4 * 1024;
97
98impl HandleDid for Network {
99    fn did_for<'a>(&'a self, handle: &'a str, private: PrivateAddresses) -> Forward<'a> {
100        Box::pin(async move {
101            let txt = self.txt(handle).await;
102            if let Ok(Some(did)) = txt {
103                return Ok(Some(did));
104            }
105            let https = self.well_known(handle, private).await;
106            match (txt, https) {
107                (_, Ok(Some(did))) => Ok(Some(did)),
108                (Err(dns), Err(https)) => Err(format!("{dns}; {https}")),
109                _ => Ok(None),
110            }
111        })
112    }
113}
114
115impl Default for Network {
116    /// Over Cloudflare's resolver, the default `dns_resolver`.
117    fn default() -> Self {
118        Self::new(didbot_http::doh::CLOUDFLARE)
119    }
120}
121
122/// The most one forward lookup may take, DNS and HTTPS together, before it
123/// counts as reaching nobody.
124pub const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);