Skip to main content

didbot_serve/
operator_handle.rs

1//! The handle the operator goes by, for the homepage.
2//!
3//! The operator poll already reads the operator's DID document on every
4//! tick. Its first valid `alsoKnownAs` entry is the handle the operator
5//! claims, and the claim counts only once the handle resolves back to the
6//! same DID: atproto's bidirectional rule, the one
7//! `didbot_operator::names` applies on the command line. The answer is kept
8//! here until the next tick, so the page never waits on the network and a
9//! visitor cannot make this server fetch anything.
10//!
11//! The check runs in a task of its own, under [`LOOKUP_TIMEOUT`]. The tick
12//! that read the document goes on to the operator's claim without waiting
13//! for it: a slow resolver delays a name on a page, never the claim the
14//! grace window and the e-stop are driven by.
15//!
16//! The handle resolves through [`crate::handles`], asking the
17//! DNS-over-HTTPS resolver `[operator] dns_resolver` names. The HTTPS fetch
18//! refuses this deployment's own network, like every other fetch the poll
19//! makes to a host a document named.
20
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23
24use didbot_http::PrivateAddresses;
25use didbot_identity::DidDocument;
26use tracing::{debug, info};
27
28use crate::handles::{HandleDid, Network, LOOKUP_TIMEOUT};
29
30/// The operator's handle, as the last poll tick verified it.
31pub struct OperatorHandle {
32    forward: Box<dyn HandleDid>,
33    verified: Mutex<Option<String>>,
34    /// Whether a check is running, so a nudged tick never starts a second.
35    checking: AtomicBool,
36}
37
38impl OperatorHandle {
39    /// Resolves handles through `forward`. Holds no handle until a tick
40    /// verifies one.
41    pub fn new(forward: impl HandleDid + 'static) -> Self {
42        Self {
43            forward: Box::new(forward),
44            verified: Mutex::new(None),
45            checking: AtomicBool::new(false),
46        }
47    }
48
49    /// Resolves handles through [`Network`], asking `doh`.
50    pub fn over_network(doh: impl Into<String>) -> Self {
51        Self::new(Network::new(doh))
52    }
53
54    /// The handle the operator goes by, checked both ways, or `None`.
55    pub fn read(&self) -> Option<String> {
56        self.verified
57            .lock()
58            .unwrap_or_else(|poisoned| poisoned.into_inner())
59            .clone()
60    }
61
62    /// Starts [`Self::refresh`] in a task of its own and returns at once.
63    /// Does nothing while an earlier check is still running.
64    pub(crate) fn refresh_in_background(
65        self: &Arc<Self>,
66        document: DidDocument,
67        private: PrivateAddresses,
68    ) {
69        if self.checking.swap(true, Ordering::AcqRel) {
70            debug!("the last check of the operator's handle is still running");
71            return;
72        }
73        let this = self.clone();
74        tokio::spawn(async move {
75            this.refresh(&document, private).await;
76            this.checking.store(false, Ordering::Release);
77        });
78    }
79
80    /// Checks the handle `document` claims against where it points.
81    ///
82    /// A lookup that reached nobody, or did not answer within
83    /// [`LOOKUP_TIMEOUT`], keeps the handle already held, if the
84    /// document still claims it, so a resolver outage does not drop the
85    /// name from the page until the next tick. Anything else replaces it.
86    pub(crate) async fn refresh(&self, document: &DidDocument, private: PrivateAddresses) {
87        let held = self.read();
88        let verified = match document.handle() {
89            None => None,
90            Some(handle) => {
91                match tokio::time::timeout(LOOKUP_TIMEOUT, self.forward.did_for(handle, private))
92                    .await
93                    .unwrap_or_else(|_| Err(format!("no answer within {LOOKUP_TIMEOUT:?}")))
94                {
95                    Ok(points_at) => points_at
96                        .filter(|did| *did == document.id)
97                        .map(|_| handle.to_owned()),
98                    Err(error) => {
99                        debug!(handle, %error, "could not resolve the operator's handle");
100                        held.clone().filter(|held| held == handle)
101                    }
102                }
103            }
104        };
105        if verified != held {
106            info!(
107                operator = document.id,
108                claimed = document.handle(),
109                handle = verified.as_deref(),
110                "the operator's verified handle changed"
111            );
112        }
113        *self
114            .verified
115            .lock()
116            .unwrap_or_else(|poisoned| poisoned.into_inner()) = verified;
117    }
118}
119
120impl Default for OperatorHandle {
121    /// Over Cloudflare's resolver, the default `[operator] dns_resolver`.
122    fn default() -> Self {
123        Self::new(Network::default())
124    }
125}
126
127#[cfg(test)]
128pub(crate) mod tests {
129    use super::*;
130    use crate::handles::Forward;
131
132    const DID: &str = "did:plc:operatorexampleaaaaaaaaa";
133
134    /// A DID document claiming `handle`.
135    fn claiming(handle: &str) -> DidDocument {
136        serde_json::from_value(serde_json::json!({
137            "@context": ["https://www.w3.org/ns/did/v1"],
138            "id": DID,
139            "alsoKnownAs": [format!("at://{handle}")],
140        }))
141        .expect("a document")
142    }
143
144    /// Every handle points at `.0`, or nobody answers.
145    struct PointsAt(Result<Option<&'static str>, &'static str>);
146
147    impl HandleDid for PointsAt {
148        fn did_for<'a>(&'a self, _: &'a str, _: PrivateAddresses) -> Forward<'a> {
149            let answer = self
150                .0
151                .map(|did| did.map(str::to_owned))
152                .map_err(str::to_owned);
153            Box::pin(async move { answer })
154        }
155    }
156
157    /// A lookup that never answers.
158    pub(crate) struct Stalls;
159
160    impl HandleDid for Stalls {
161        fn did_for<'a>(&'a self, _: &'a str, _: PrivateAddresses) -> Forward<'a> {
162            Box::pin(std::future::pending())
163        }
164    }
165
166    /// A lookup that never answers ends at [`LOOKUP_TIMEOUT`] and counts as
167    /// an outage: the handle already verified stays.
168    #[tokio::test(start_paused = true)]
169    async fn a_lookup_that_never_answers_ends_at_the_timeout() {
170        let mut handle = OperatorHandle::new(PointsAt(Ok(Some(DID))));
171        handle
172            .refresh(&claiming("operator.example"), PrivateAddresses::Refused)
173            .await;
174        handle.forward = Box::new(Stalls);
175        let started = tokio::time::Instant::now();
176        handle
177            .refresh(&claiming("operator.example"), PrivateAddresses::Refused)
178            .await;
179        assert_eq!(started.elapsed(), LOOKUP_TIMEOUT);
180        assert_eq!(handle.read().as_deref(), Some("operator.example"));
181    }
182
183    /// The handle is shown only when it points back at the operator. A
184    /// document can claim any name; the name has to agree.
185    #[tokio::test]
186    async fn a_handle_counts_only_when_it_points_back() {
187        let held = OperatorHandle::new(PointsAt(Ok(Some(DID))));
188        held.refresh(&claiming("operator.example"), PrivateAddresses::Refused)
189            .await;
190        assert_eq!(held.read().as_deref(), Some("operator.example"));
191
192        let claimed = OperatorHandle::new(PointsAt(Ok(Some("did:plc:someoneelse"))));
193        claimed
194            .refresh(&claiming("operator.example"), PrivateAddresses::Refused)
195            .await;
196        assert_eq!(claimed.read(), None);
197    }
198
199    /// An outage keeps the handle a tick verified, but only while the
200    /// document still claims it, and a definite answer drops it.
201    #[tokio::test]
202    async fn an_outage_keeps_the_handle_already_verified() {
203        let mut handle = OperatorHandle::new(PointsAt(Ok(Some(DID))));
204        handle
205            .refresh(&claiming("operator.example"), PrivateAddresses::Refused)
206            .await;
207
208        handle.forward = Box::new(PointsAt(Err("unreachable")));
209        handle
210            .refresh(&claiming("operator.example"), PrivateAddresses::Refused)
211            .await;
212        assert_eq!(handle.read().as_deref(), Some("operator.example"));
213        handle
214            .refresh(&claiming("renamed.example"), PrivateAddresses::Refused)
215            .await;
216        assert_eq!(handle.read(), None);
217
218        handle.forward = Box::new(PointsAt(Ok(Some(DID))));
219        handle
220            .refresh(&claiming("operator.example"), PrivateAddresses::Refused)
221            .await;
222        handle.forward = Box::new(PointsAt(Ok(None)));
223        handle
224            .refresh(&claiming("operator.example"), PrivateAddresses::Refused)
225            .await;
226        assert_eq!(handle.read(), None);
227    }
228}