remote.rsannotatedremote.rssource424 lines · 19.0 KB · raw
1use std::time::{Duration, Instant};
2
3use log::{debug, error, info, trace, warn};
4use serde::de::DeserializeOwned;
5
6use whiskers_core::{Age, Direction, EmbedError, Embedder, Guard, GuardError, Household, IconChooser, IconError, IconId, MemorySnapshot, RankError, Ranker, Verdict};
7
8use whiskers_core::wire::{CheckReply, CheckRequest, EmbedReply, EmbedRequest, IconReply, IconRequest, RankReply, RankRequest};
9
10/// The `Guard` the tablet uses: asks `whiskersd` over a private network.
11pub struct RemoteGuard {
12    base_url: String,
13    agent: ureq::Agent,
14}
15
16impl RemoteGuard {
17    pub fn new(base_url: impl Into<String>) -> Self {
18        debug!("remote guard client, timeout 15 s");
19        let agent = ureq::Agent::config_builder()
20            .timeout_global(Some(Duration::from_secs(15)))
21            .http_status_as_error(false)
22            .build()
23            .into();
24        Self { base_url: base_url.into().trim_end_matches('/').to_owned(), agent }
25    }
26}
27
28impl Guard for RemoteGuard {
29    fn check(&self, direction: Direction, age: Age, text: &str) -> Result<Verdict, GuardError> {
30        debug!("remote guard check {direction:?} for age {}: {} chars", age.years(), text.len());
31        let started = Instant::now();
32        let body = serde_json::to_string(&CheckRequest { direction, age: Some(age), text: text.to_owned() }).map_err(|e| {
33            error!("guard request does not serialize: {e}");
34            GuardError(e.to_string())
35        })?;
36        let mut resp = self
37            .agent
38            .post(format!("{}/check", self.base_url))
39            .header("content-type", "application/json")
40            .send(body)
41            .map_err(|e| {
42                error!("guard service unreachable after {} ms: {e}", started.elapsed().as_millis());
43                GuardError(e.to_string())
44            })?;
45        let status = resp.status();
46        let text = resp.body_mut().read_to_string().map_err(|e| {
47            error!("guard reply unreadable (status {status}): {e}");
48            GuardError(e.to_string())
49        })?;
50        info!("guard service answered {status} in {} ms, {} bytes", started.elapsed().as_millis(), text.len());
51        if !status.is_success() {
52            error!("guard service refused with {status}");
53            return Err(GuardError(format!("guard service answered {status}")));
54        }
55        match serde_json::from_str::<CheckReply>(&text).map_err(|e| {
56            error!("guard reply does not parse: {e}");
57            GuardError(e.to_string())
58        })? {
59            CheckReply::Verdict(v) => Ok(v),
60            CheckReply::Unavailable(why) => {
61                warn!("guard service could not decide: {why}");
62                Err(GuardError(why))
63            }
64        }
65    }
66}
67
68/// POST `body` as JSON and read the reply, with a timeout of its own.
69fn post(base: &str, path: &str, body: String, seconds: u64) -> Result<String, String> {
70    let started = Instant::now();
71    debug!("POST {path}: {} byte body, timeout {seconds} s", body.len());
72    let agent: ureq::Agent = ureq::Agent::config_builder()
73        .timeout_global(Some(Duration::from_secs(seconds)))
74        .http_status_as_error(false)
75        .build()
76        .into();
77    let mut resp = agent
78        .post(format!("{}{path}", base.trim_end_matches('/')))
79        .header("content-type", "application/json")
80        .send(body)
81        .map_err(|e| {
82            warn!("POST {path} failed after {} ms: {e}", started.elapsed().as_millis());
83            e.to_string()
84        })?;
85    let status = resp.status();
86    let text = resp.body_mut().read_to_string().map_err(|e| {
87        warn!("POST {path} body unreadable (status {status}): {e}");
88        e.to_string()
89    })?;
90    if !status.is_success() {
91        warn!("POST {path} answered {status} in {} ms", started.elapsed().as_millis());
92        return Err(format!("service answered {status}"));
93    }
94    debug!("POST {path} answered {status} in {} ms, {} bytes", started.elapsed().as_millis(), text.len());
95    Ok(text)
96}
97
98/// Reads a service reply as `T`, saying in the log which reply it was when it does not parse.
99fn parse<T: DeserializeOwned>(what: &str, text: &str) -> Result<T, String> {
100    serde_json::from_str(text).map_err(|e| {
101        warn!("{what} reply ({} bytes) does not parse: {e}", text.len());
102        e.to_string()
103    })
104}
105
106/// The `Embedder` the tablet uses: the service turns text into vectors. Searching memory is
107/// on the way to every answer, so this gives up quickly (the search then degrades).
108pub struct RemoteEmbedder {
109    base_url: String,
110}
111
112impl RemoteEmbedder {
113    pub fn new(base_url: impl Into<String>) -> Self {
114        Self { base_url: base_url.into() }
115    }
116}
117
118impl Embedder for RemoteEmbedder {
119    fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedError> {
120        debug!("remote embed of {} text(s)", texts.len());
121        let body = serde_json::to_string(&EmbedRequest { texts: texts.to_vec() }).map_err(|e| EmbedError(e.to_string()))?;
122        let text = post(&self.base_url, "/embed", body, 6).map_err(EmbedError)?;
123        match parse::<EmbedReply>("embed", &text).map_err(EmbedError)? {
124            EmbedReply::Vectors(v) if v.len() == texts.len() => Ok(v),
125            EmbedReply::Vectors(v) => {
126                warn!("embed: asked for {} vectors, got {}", texts.len(), v.len());
127                Err(EmbedError(format!("asked for {} vectors, got {}", texts.len(), v.len())))
128            }
129            EmbedReply::Unavailable(why) => {
130                warn!("embed: service unavailable: {why}");
131                Err(EmbedError(why))
132            }
133        }
134    }
135}
136
137/// The `Ranker` the tablet uses: the service asks Jev which memories fit the message best.
138pub struct RemoteRanker {
139    base_url: String,
140}
141
142impl RemoteRanker {
143    pub fn new(base_url: impl Into<String>) -> Self {
144        Self { base_url: base_url.into() }
145    }
146}
147
148impl Ranker for RemoteRanker {
149    fn rank(&self, query: &str, candidates: &[String]) -> Result<Vec<f32>, RankError> {
150        debug!("remote rank of {} candidate(s)", candidates.len());
151        let body = serde_json::to_string(&RankRequest { query: query.to_owned(), candidates: candidates.to_vec() })
152            .map_err(|e| RankError(e.to_string()))?;
153        let text = post(&self.base_url, "/rerank", body, 8).map_err(RankError)?;
154        match parse::<RankReply>("rerank", &text).map_err(RankError)? {
155            RankReply::Probabilities(p) if p.len() == candidates.len() => Ok(p),
156            RankReply::Probabilities(p) => {
157                warn!("rerank: asked about {} candidates, got {}", candidates.len(), p.len());
158                Err(RankError(format!("asked about {} candidates, got {}", candidates.len(), p.len())))
159            }
160            RankReply::Unavailable(why) => {
161                warn!("rerank: service unavailable: {why}");
162                Err(RankError(why))
163            }
164        }
165    }
166}
167
168/// The `IconChooser` a device uses: the service shortlists by meaning and Jev chooses. Slow (an embedding and a
169/// Jev question, the first time all the names too), so it is only ever called from the background memory work.
170pub struct RemoteIcons {
171    base_url: String,
172}
173
174impl RemoteIcons {
175    pub fn new(base_url: impl Into<String>) -> Self {
176        Self { base_url: base_url.into() }
177    }
178}
179
180impl IconChooser for RemoteIcons {
181    fn choose(&self, text: &str) -> Result<Option<IconId>, IconError> {
182        debug!("remote icon choice for a fact of {} chars", text.len());
183        let body = serde_json::to_string(&IconRequest { text: text.to_owned() }).map_err(|e| IconError(e.to_string()))?;
184        let reply = post(&self.base_url, "/icon", body, 30).map_err(IconError)?;
185        match parse::<IconReply>("icon", &reply).map_err(IconError)? {
186            IconReply::Icon(icon) => Ok(Some(icon)),
187            IconReply::NoneFits => Ok(None),
188            IconReply::Unavailable(why) => {
189                warn!("icon: service unavailable: {why}");
190                Err(IconError(why))
191            }
192        }
193    }
194}
195
196/// Keeps this device's memory and the grown-ups' choices in step with the service's copy: each
197/// call sends what this device has and returns what the service now has, to be merged here.
198pub struct RemoteSync {
199    base_url: String,
200}
201
202impl RemoteSync {
203    pub fn new(base_url: impl Into<String>) -> Self {
204        trace!("remote sync client");
205        Self { base_url: base_url.into() }
206    }
207
208    pub fn memory(&self, mine: &MemorySnapshot) -> Result<MemorySnapshot, String> {
209        info!("sync memory: sending {} facts, {} forgotten", mine.facts.len(), mine.forgotten.len());
210        let body = serde_json::to_string(mine).map_err(|e| e.to_string())?;
211        let theirs: MemorySnapshot = parse("memory sync", &post(&self.base_url, "/memory/sync", body, 15)?)?;
212        info!("sync memory: received {} facts, {} forgotten", theirs.facts.len(), theirs.forgotten.len());
213        Ok(theirs)
214    }
215
216    pub fn household(&self, mine: &Household) -> Result<Household, String> {
217        info!("sync household: sending the document");
218        let body = serde_json::to_string(mine).map_err(|e| e.to_string())?;
219        parse("household sync", &post(&self.base_url, "/household/sync", body, 10)?)
220    }
221}
222
223impl RemoteSync {
224    /// Which of these pictures the service does not have yet.
225    pub fn pictures_missing(&self, ids: &[String]) -> Result<Vec<String>, String> {
226        debug!("sync pictures: asking which of {} are missing", ids.len());
227        let body = serde_json::json!({ "ids": ids }).to_string();
228        let reply: serde_json::Value = parse("picture missing", &post(&self.base_url, "/picture/missing", body, 10)?)?;
229        let missing: Vec<String> =
230            reply["missing"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default();
231        debug!("sync pictures: {} missing on the service", missing.len());
232        Ok(missing)
233    }
234
235    pub fn put_picture(&self, id: &str, bytes: &[u8]) -> Result<(), String> {
236        use base64::Engine as _;
237        debug!("sync pictures: putting {id} ({} bytes)", bytes.len());
238        let body = serde_json::json!({ "id": id, "data_base64": base64::engine::general_purpose::STANDARD.encode(bytes) }).to_string();
239        post(&self.base_url, "/picture/put", body, 30).map(|_| ())
240    }
241
242    /// The picture's bytes, or `None` if the service does not have it.
243    pub fn get_picture(&self, id: &str) -> Result<Option<Vec<u8>>, String> {
244        use base64::Engine as _;
245        debug!("sync pictures: getting {id}");
246        let body = serde_json::json!({ "id": id }).to_string();
247        let reply: serde_json::Value = parse("picture get", &post(&self.base_url, "/picture/get", body, 30)?)?;
248        match reply["data_base64"].as_str() {
249            Some(d) => base64::engine::general_purpose::STANDARD.decode(d).map(Some).map_err(|e| {
250                warn!("sync pictures: {id} arrived as bad base64: {e}");
251                e.to_string()
252            }),
253            None => {
254                debug!("sync pictures: the service does not have {id}");
255                Ok(None)
256            }
257        }
258    }
259}
260
261impl RemoteSync {
262    pub fn chat(&self, mine: &whiskers_core::ChatState) -> Result<whiskers_core::ChatState, String> {
263        info!("sync chat: sending version {}", mine.version);
264        let body = serde_json::to_string(mine).map_err(|e| e.to_string())?;
265        let theirs: whiskers_core::ChatState = parse("chat sync", &post(&self.base_url, "/chat/sync", body, 10)?)?;
266        info!("sync chat: received version {}", theirs.version);
267        Ok(theirs)
268    }
269
270    /// How many log lines the service has received from this device.
271    pub fn journal_have(&self, device: &str) -> Result<usize, String> {
272        debug!("sync journal: asking how many lines the service has from {device}");
273        let body = serde_json::json!({ "device": device, "from": 0, "lines": [] }).to_string();
274        let r: serde_json::Value = parse("journal have", &post(&self.base_url, "/journal/push", body, 10)?)?;
275        r["have"].as_u64().map(|n| n as usize).ok_or_else(|| {
276            warn!("sync journal: the reply has no count");
277            "no count in the reply".to_owned()
278        })
279    }
280
281    /// Sends this device's log lines from number `from` on. Returns how many the service now has
282    /// from this device, which is `from + lines.len()` unless it was not where this thought it was.
283    pub fn journal_push(&self, device: &str, from: usize, lines: &[String]) -> Result<usize, String> {
284        info!("sync journal: pushing {} line(s) from line {from} for {device}", lines.len());
285        let body = serde_json::json!({ "device": device, "from": from, "lines": lines }).to_string();
286        let r: serde_json::Value = parse("journal push", &post(&self.base_url, "/journal/push", body, 30)?)?;
287        let have = r["have"].as_u64().map(|n| n as usize).ok_or_else(|| {
288            warn!("sync journal: the reply has no count");
289            "no count in the reply".to_owned()
290        })?;
291        if have != from + lines.len() {
292            warn!("sync journal: the service has {have} lines from {device}, expected {}", from + lines.len());
293        }
294        Ok(have)
295    }
296
297    /// The one log, from line `have` on: lines of the form `{"device":..,"entry":{..}}`, every
298    /// device's together, in the order the service received them.
299    pub fn journal_pull(&self, have: usize) -> Result<Pulled, String> {
300        info!("sync journal: pulling from line {have}");
301        let body = serde_json::json!({ "have": have }).to_string();
302        let r: serde_json::Value = parse("journal pull", &post(&self.base_url, "/journal/pull", body, 30)?)?;
303        let pulled = Pulled {
304            from: r["from"].as_u64().unwrap_or(0) as usize,
305            total: r["total"].as_u64().unwrap_or(0) as usize,
306            lines: r["lines"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_owned)).collect()).unwrap_or_default(),
307        };
308        info!("sync journal: pulled {} line(s) from {} of {}", pulled.lines.len(), pulled.from, pulled.total);
309        Ok(pulled)
310    }
311}
312
313/// A slice of the one log: `lines` start at line number `from`, and the whole log is `total` lines.
314pub struct Pulled {
315    pub from: usize,
316    pub total: usize,
317    pub lines: Vec<String>,
318}
319
320/// What asking an address "are you the Whiskers service?" found.
321#[derive(Clone, Copy, Debug, PartialEq, Eq)]
322pub enum ServiceProbe {
323    /// It answered the way `whiskersd` does.
324    Reachable,
325    /// Something answered, but not like the Whiskers service (another program, or another port).
326    NotWhiskers,
327    /// Nothing answered in time: the machine is off or away, the port is wrong, or the network is not joined.
328    NotReachable,
329}
330
331/// Asks the service at `address` whether it is there. The service has no route of its own for this, so it uses
332/// `POST /usage`: it changes nothing, it is what the parents' screen already reads, and only `whiskersd` answers
333/// it with a `voice` section. Blocking, and never longer than about twelve seconds. Logs lengths and the
334/// status, never the address.
335pub fn probe_service(address: &whiskers_core::ServiceAddress) -> ServiceProbe {
336    let started = Instant::now();
337    let agent: ureq::Agent = ureq::Agent::config_builder()
338        .timeout_global(Some(Duration::from_secs(12)))
339        .timeout_connect(Some(Duration::from_secs(5)))
340        .http_status_as_error(false)
341        .build()
342        .into();
343    let sent = agent.post(format!("{}/usage", address.url())).header("content-type", "application/json").send("{}");
344    let mut resp = match sent {
345        Ok(r) => r,
346        Err(e) => {
347            info!("service probe: no answer after {} ms ({})", started.elapsed().as_millis(), error_kind(&e));
348            return ServiceProbe::NotReachable;
349        }
350    };
351    let status = resp.status();
352    let body = resp.body_mut().read_to_string().unwrap_or_default();
353    let looks_right = status.is_success() && serde_json::from_str::<serde_json::Value>(&body).is_ok_and(|v| v.get("voice").is_some_and(|x| x.is_object()));
354    info!("service probe: answered {status} in {} ms, {} bytes, whiskers = {looks_right}", started.elapsed().as_millis(), body.len());
355    if looks_right { ServiceProbe::Reachable } else { ServiceProbe::NotWhiskers }
356}
357
358/// The kind of a transport failure, without its text (which can quote the address).
359fn error_kind(e: &ureq::Error) -> &'static str {
360    match e {
361        ureq::Error::Timeout(_) => "timeout",
362        ureq::Error::Io(_) | ureq::Error::ConnectionFailed => "connection failed",
363        ureq::Error::HostNotFound => "host not found",
364        _ => "other",
365    }
366}
367
368#[cfg(test)]
369mod probe_tests {
370    use super::*;
371    use std::io::{Read, Write};
372    use std::net::TcpListener;
373
374    /// Serves one canned HTTP reply on a local port and returns the address to ask.
375    fn serve_once(status: &str, body: &'static str) -> whiskers_core::ServiceAddress {
376        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
377        let port = listener.local_addr().unwrap().port();
378        let status = status.to_owned();
379        std::thread::spawn(move || {
380            if let Ok((mut s, _)) = listener.accept() {
381                // Read the whole request (headers, then the declared body) before answering, or closing
382                // the socket with bytes unread resets the connection under the client.
383                let mut got = Vec::new();
384                let mut chunk = [0u8; 1024];
385                loop {
386                    let n = s.read(&mut chunk).unwrap_or(0);
387                    got.extend_from_slice(&chunk[..n]);
388                    let text = String::from_utf8_lossy(&got).into_owned();
389                    if let Some(end) = text.find("\r\n\r\n") {
390                        let want = text[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").and_then(|v| v.trim().parse::<usize>().ok())).unwrap_or(0);
391                        if got.len() >= end + 4 + want {
392                            break;
393                        }
394                    }
395                    if n == 0 {
396                        break;
397                    }
398                }
399                let _ = write!(s, "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len());
400            }
401        });
402        whiskers_core::ServiceAddress::parse(&format!("127.0.0.1:{port}")).unwrap()
403    }
404
405    #[test]
406    fn the_whiskers_service_is_reachable() {
407        let a = serve_once("200 OK", r#"{"voice":{"configured":false},"tokens":{"used":0}}"#);
408        assert_eq!(probe_service(&a), ServiceProbe::Reachable);
409    }
410
411    #[test]
412    fn another_program_is_not_whiskers() {
413        assert_eq!(probe_service(&serve_once("200 OK", "<html>hello</html>")), ServiceProbe::NotWhiskers);
414        assert_eq!(probe_service(&serve_once("404 Not Found", "{}")), ServiceProbe::NotWhiskers);
415        assert_eq!(probe_service(&serve_once("500 Internal Server Error", r#"{"voice":{}}"#)), ServiceProbe::NotWhiskers);
416    }
417
418    #[test]
419    fn nothing_listening_is_not_reachable() {
420        let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
421        let a = whiskers_core::ServiceAddress::parse(&format!("127.0.0.1:{port}")).unwrap();
422        assert_eq!(probe_service(&a), ServiceProbe::NotReachable);
423    }
424}