Skip to main content

wire/
relay_client.rs

1//! HTTP client for `wire-relay-server`.
2//!
3//! Sync wrapper around `reqwest::blocking` so CLI commands stay synchronous —
4//! the only async surface in the crate is `relay_server::serve`. Async clients
5//! land in v0.2 if a long-running daemon needs them.
6
7use anyhow::{Context, Result, anyhow};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11#[derive(Clone)]
12pub struct RelayClient {
13    base_url: String,
14    client: reqwest::blocking::Client,
15}
16
17#[derive(Debug, Serialize, Deserialize)]
18pub struct AllocateResponse {
19    pub slot_id: String,
20    pub slot_token: String,
21}
22
23#[derive(Debug, Deserialize)]
24pub struct PostEventResponse {
25    pub event_id: Option<String>,
26    pub status: String,
27}
28
29/// Env var: when set to a truthy value (`1`, `true`, `yes`), every TLS
30/// verification check on every wire HTTPS client is disabled. Intended
31/// as an emergency-only operator override for environments behind a
32/// TLS-intercepting middlebox (corporate proxy, AV product like Avast
33/// re-signing certs with its own root, captive portal). Prints a loud
34/// stderr banner on every send when active. **Do not set this in
35/// production.** Documented in THREAT_MODEL.md + README.
36pub const INSECURE_SKIP_TLS_ENV: &str = "WIRE_INSECURE_SKIP_TLS_VERIFY";
37
38fn insecure_skip_tls_verify() -> bool {
39    matches!(
40        std::env::var(INSECURE_SKIP_TLS_ENV)
41            .unwrap_or_default()
42            .to_ascii_lowercase()
43            .as_str(),
44        "1" | "true" | "yes" | "on"
45    )
46}
47
48/// v0.5.13: emit the loud-fail banner exactly once per process so we
49/// don't spam a hundred lines per `wire push`. Per-process `OnceLock`
50/// guards the emission. The banner goes to stderr; never stdout (we
51/// must not corrupt the `--json` machine-readable contract).
52fn maybe_emit_insecure_banner() {
53    static ONCE: std::sync::OnceLock<()> = std::sync::OnceLock::new();
54    if insecure_skip_tls_verify() {
55        ONCE.get_or_init(|| {
56            eprintln!(
57                "\x1b[1;31mwire: WARNING\x1b[0m {INSECURE_SKIP_TLS_ENV}=1 is set; TLS verification is DISABLED for all relay traffic. \
58                 MITM attacks against the relay path are undetectable in this mode. Unset to restore default trust validation."
59            );
60        });
61    }
62}
63
64/// Centralized builder for blocking HTTPS clients across wire. Loads
65/// the OS native trust store (rustls-tls-native-roots) so corporate
66/// proxies, AV cert-resign products, and on-prem CAs validate. Honors
67/// the [`INSECURE_SKIP_TLS_ENV`] escape hatch for the corporate-proxy
68/// emergency case.
69pub fn build_blocking_client(
70    timeout: Option<std::time::Duration>,
71) -> Result<reqwest::blocking::Client> {
72    let mut b = reqwest::blocking::Client::builder();
73    if let Some(t) = timeout {
74        b = b.timeout(t);
75    }
76    if insecure_skip_tls_verify() {
77        maybe_emit_insecure_banner();
78        b = b.danger_accept_invalid_certs(true);
79    }
80    b.build()
81        .with_context(|| "constructing reqwest blocking client")
82}
83
84/// Flatten an `anyhow::Error` source chain into a single human-readable
85/// transport-error line for the `reason` field in `wire push --json` and
86/// for stderr surfaces. Classifies the topmost cause (`TLS error`,
87/// `DNS error`, `connect timeout`, `read timeout`, `HTTP error`) so a
88/// silent failure no longer leaks past the user as a bare URL.
89///
90/// v0.5.13 rule 1 of the network-resilience doctrine — see issue #6.
91pub fn format_transport_error(err: &anyhow::Error) -> String {
92    let mut parts: Vec<String> = err.chain().map(|c| c.to_string()).collect();
93    // Heuristic classification — search the chain for the lowest-level
94    // descriptor and prefix the message so the reader sees the kind
95    // even when the topmost context is just the URL.
96    let lower = parts
97        .iter()
98        .map(|p| p.to_ascii_lowercase())
99        .collect::<Vec<_>>();
100    let class = if lower.iter().any(|p| {
101        p.contains("invalid peer certificate")
102            || p.contains("certificate verification")
103            || p.contains("unknownissuer")
104            || p.contains("certificate is not valid")
105            || p.contains("tls handshake")
106    }) {
107        Some("TLS error")
108    } else if lower.iter().any(|p| {
109        p.contains("dns error")
110            || p.contains("nodename nor servname")
111            || p.contains("failed to lookup address")
112    }) {
113        Some("DNS error")
114    } else if lower
115        .iter()
116        .any(|p| p.contains("operation timed out") || p.contains("deadline has elapsed"))
117    {
118        Some("timeout")
119    } else if lower
120        .iter()
121        .any(|p| p.contains("connection refused") || p.contains("connection reset"))
122    {
123        Some("connect error")
124    } else {
125        None
126    };
127    if let Some(c) = class {
128        parts.insert(0, c.to_string());
129    }
130    parts.join(": ")
131}
132
133impl RelayClient {
134    pub fn new(base_url: &str) -> Self {
135        let client = build_blocking_client(Some(std::time::Duration::from_secs(30)))
136            .expect("reqwest client construction is infallible with rustls + native roots");
137        Self {
138            base_url: base_url.trim_end_matches('/').to_string(),
139            client,
140        }
141    }
142
143    /// Allocate a fresh slot. Returns `(slot_id, slot_token)` — caller MUST
144    /// persist `slot_token` somewhere safe (mode 0600 file); it grants both
145    /// read and write access to the slot.
146    pub fn allocate_slot(&self, handle_hint: Option<&str>) -> Result<AllocateResponse> {
147        let body = serde_json::json!({"handle": handle_hint});
148        let resp = self
149            .client
150            .post(format!("{}/v1/slot/allocate", self.base_url))
151            .json(&body)
152            .send()
153            .with_context(|| format!("POST {}/v1/slot/allocate", self.base_url))?;
154        let status = resp.status();
155        if !status.is_success() {
156            let detail = resp.text().unwrap_or_default();
157            return Err(anyhow!("allocate failed: {status}: {detail}"));
158        }
159        Ok(resp.json()?)
160    }
161
162    /// POST a signed event to a slot. Caller passes the slot's bearer token
163    /// (the relay model in v0.1 is "shared slot token between paired peers" —
164    /// see iter 9 SPAKE2 for how this token gets exchanged).
165    pub fn post_event(
166        &self,
167        slot_id: &str,
168        slot_token: &str,
169        event: &Value,
170    ) -> Result<PostEventResponse> {
171        let body = serde_json::json!({"event": event});
172        let resp = self
173            .client
174            .post(format!("{}/v1/events/{slot_id}", self.base_url))
175            .bearer_auth(slot_token)
176            .json(&body)
177            .send()
178            .with_context(|| format!("POST {}/v1/events/{slot_id}", self.base_url))?;
179        let status = resp.status();
180        if !status.is_success() {
181            let detail = resp.text().unwrap_or_default();
182            return Err(anyhow!("post_event failed: {status}: {detail}"));
183        }
184        Ok(resp.json()?)
185    }
186
187    /// GET events from a slot. `since` is an event_id cursor (exclusive); pass
188    /// `None` for the full slot snapshot. `limit` defaults to 100, max 1000.
189    pub fn list_events(
190        &self,
191        slot_id: &str,
192        slot_token: &str,
193        since: Option<&str>,
194        limit: Option<usize>,
195    ) -> Result<Vec<Value>> {
196        let mut url = format!("{}/v1/events/{slot_id}", self.base_url);
197        let mut sep = '?';
198        if let Some(s) = since {
199            url.push(sep);
200            url.push_str(&format!("since={s}"));
201            sep = '&';
202        }
203        if let Some(n) = limit {
204            url.push(sep);
205            url.push_str(&format!("limit={n}"));
206        }
207        let resp = self
208            .client
209            .get(&url)
210            .bearer_auth(slot_token)
211            .send()
212            .with_context(|| format!("GET {url}"))?;
213        let status = resp.status();
214        if !status.is_success() {
215            let detail = resp.text().unwrap_or_default();
216            return Err(anyhow!("list_events failed: {status}: {detail}"));
217        }
218        Ok(resp.json()?)
219    }
220
221    /// R4 — probe slot attentiveness. Returns `(event_count, last_pull_at_unix)`
222    /// — the relay's view of the slot's owner's most recent poll. `None` for
223    /// `last_pull_at_unix` means the slot has not been pulled since relay
224    /// restart. Best-effort: any HTTP failure returns `Ok((0, None))` so the
225    /// caller's pre-flight check degrades to "no signal" rather than abort.
226    pub fn slot_state(&self, slot_id: &str, slot_token: &str) -> Result<(usize, Option<u64>)> {
227        let url = format!("{}/v1/slot/{slot_id}/state", self.base_url);
228        let resp = match self.client.get(&url).bearer_auth(slot_token).send() {
229            Ok(r) => r,
230            Err(_) => return Ok((0, None)),
231        };
232        if !resp.status().is_success() {
233            return Ok((0, None));
234        }
235        let v: Value = resp.json().unwrap_or(Value::Null);
236        let count = v.get("event_count").and_then(Value::as_u64).unwrap_or(0) as usize;
237        let last = v.get("last_pull_at_unix").and_then(Value::as_u64);
238        Ok((count, last))
239    }
240
241    pub fn responder_health_set(
242        &self,
243        slot_id: &str,
244        slot_token: &str,
245        record: &Value,
246    ) -> Result<Value> {
247        let resp = self
248            .client
249            .post(format!(
250                "{}/v1/slot/{slot_id}/responder-health",
251                self.base_url
252            ))
253            .bearer_auth(slot_token)
254            .json(record)
255            .send()
256            .with_context(|| {
257                format!("POST {}/v1/slot/{slot_id}/responder-health", self.base_url)
258            })?;
259        let status = resp.status();
260        if !status.is_success() {
261            let detail = resp.text().unwrap_or_default();
262            return Err(anyhow!("responder_health_set failed: {status}: {detail}"));
263        }
264        Ok(resp.json()?)
265    }
266
267    pub fn responder_health_get(&self, slot_id: &str, slot_token: &str) -> Result<Value> {
268        let resp = self
269            .client
270            .get(format!("{}/v1/slot/{slot_id}/state", self.base_url))
271            .bearer_auth(slot_token)
272            .send()
273            .with_context(|| format!("GET {}/v1/slot/{slot_id}/state", self.base_url))?;
274        let status = resp.status();
275        if !status.is_success() {
276            let detail = resp.text().unwrap_or_default();
277            return Err(anyhow!("responder_health_get failed: {status}: {detail}"));
278        }
279        let state: Value = resp.json()?;
280        Ok(state
281            .get("responder_health")
282            .cloned()
283            .unwrap_or(Value::Null))
284    }
285
286    pub fn healthz(&self) -> Result<bool> {
287        let resp = self
288            .client
289            .get(format!("{}/healthz", self.base_url))
290            .send()?;
291        Ok(resp.status().is_success())
292    }
293
294    /// Healthz pre-flight that surfaces the underlying reqwest error in its
295    /// own message. Use at every "is the relay reachable before we mutate
296    /// state" site. The three possible failure modes (network error, 5xx
297    /// from a reachable host, healthy) each get a distinct diagnostic line.
298    pub fn check_healthz(&self) -> anyhow::Result<()> {
299        match self.healthz() {
300            Ok(true) => Ok(()),
301            Ok(false) => anyhow::bail!(
302                "phyllis: silent line — {}/healthz returned non-200.\n\
303                 the host is reachable but the relay isn't returning ok. test:\n  \
304                 curl -v {}/healthz",
305                self.base_url,
306                self.base_url
307            ),
308            Err(e) => anyhow::bail!(
309                "phyllis: silent line — couldn't reach {}/healthz: {e:#}.\n\
310                 test reachability from this machine:\n  curl -v {}/healthz\n\
311                 if curl also fails, a sandbox / proxy / firewall is the usual cause.\n\
312                 (OpenShell sandbox? run `curl -fsSL https://wireup.net/openshell-policy.sh | bash -s <sandbox-name>` on the host first.)",
313                self.base_url,
314                self.base_url
315            ),
316        }
317    }
318
319    /// Open or join a pair-slot. Returns the relay-assigned `pair_id`.
320    /// `role` must be `"host"` or `"guest"`. The host calls first; the guest
321    /// uses the same `code_hash` and finds the existing slot.
322    pub fn pair_open(&self, code_hash: &str, msg_b64: &str, role: &str) -> Result<String> {
323        let body = serde_json::json!({"code_hash": code_hash, "msg": msg_b64, "role": role});
324        let resp = self
325            .client
326            .post(format!("{}/v1/pair", self.base_url))
327            .json(&body)
328            .send()?;
329        let status = resp.status();
330        if !status.is_success() {
331            let detail = resp.text().unwrap_or_default();
332            return Err(anyhow!("pair_open failed: {status}: {detail}"));
333        }
334        let v: Value = resp.json()?;
335        v.get("pair_id")
336            .and_then(Value::as_str)
337            .map(str::to_string)
338            .ok_or_else(|| anyhow!("pair_open response missing pair_id"))
339    }
340
341    /// Forget the pair-slot at this code_hash on the relay. Either side can call;
342    /// knowledge of the code is the only auth. Idempotent — succeeds even if the
343    /// slot doesn't exist. Use after a client crash mid-handshake so the host
344    /// doesn't stay locked out until TTL.
345    pub fn pair_abandon(&self, code_hash: &str) -> Result<()> {
346        let body = serde_json::json!({"code_hash": code_hash});
347        let resp = self
348            .client
349            .post(format!("{}/v1/pair/abandon", self.base_url))
350            .json(&body)
351            .send()?;
352        let status = resp.status();
353        if !status.is_success() {
354            let detail = resp.text().unwrap_or_default();
355            return Err(anyhow!("pair_abandon failed: {status}: {detail}"));
356        }
357        Ok(())
358    }
359
360    /// Read peer's SPAKE2 message + (eventually) sealed bootstrap from a pair-slot.
361    pub fn pair_get(
362        &self,
363        pair_id: &str,
364        as_role: &str,
365    ) -> Result<(Option<String>, Option<String>)> {
366        let resp = self
367            .client
368            .get(format!(
369                "{}/v1/pair/{pair_id}?as_role={as_role}",
370                self.base_url
371            ))
372            .send()?;
373        let status = resp.status();
374        if !status.is_success() {
375            let detail = resp.text().unwrap_or_default();
376            return Err(anyhow!("pair_get failed: {status}: {detail}"));
377        }
378        let v: Value = resp.json()?;
379        let peer_msg = v
380            .get("peer_msg")
381            .and_then(Value::as_str)
382            .map(str::to_string);
383        let peer_bootstrap = v
384            .get("peer_bootstrap")
385            .and_then(Value::as_str)
386            .map(str::to_string);
387        Ok((peer_msg, peer_bootstrap))
388    }
389
390    /// POST a sealed bootstrap payload to the pair-slot.
391    pub fn pair_bootstrap(&self, pair_id: &str, role: &str, sealed_b64: &str) -> Result<()> {
392        let body = serde_json::json!({"role": role, "sealed": sealed_b64});
393        let resp = self
394            .client
395            .post(format!("{}/v1/pair/{pair_id}/bootstrap", self.base_url))
396            .json(&body)
397            .send()?;
398        if !resp.status().is_success() {
399            let s = resp.status();
400            let detail = resp.text().unwrap_or_default();
401            return Err(anyhow!("pair_bootstrap failed: {s}: {detail}"));
402        }
403        Ok(())
404    }
405
406    /// Claim a `nick@<this-relay-domain>` handle (v0.5). Caller must hold
407    /// the bearer token for `slot_id`. FCFS on nick; same-DID re-claims OK.
408    ///
409    /// Back-compat wrapper around `handle_claim_v2` that omits the
410    /// `discoverable` field (relay defaults to discoverable on absence).
411    pub fn handle_claim(
412        &self,
413        nick: &str,
414        slot_id: &str,
415        slot_token: &str,
416        relay_url: Option<&str>,
417        card: &Value,
418    ) -> Result<Value> {
419        self.handle_claim_v2(nick, slot_id, slot_token, relay_url, card, None)
420    }
421
422    /// v0.5.19 (#9.1) variant accepting the optional `discoverable`
423    /// flag. `None` = relay default (= true, back-compat).
424    /// `Some(false)` = opt out of `/v1/handles` bulk listing while
425    /// keeping direct `.well-known/wire/agent` resolution working.
426    /// Relays older than v0.5.19 ignore the field — safe to always send.
427    pub fn handle_claim_v2(
428        &self,
429        nick: &str,
430        slot_id: &str,
431        slot_token: &str,
432        relay_url: Option<&str>,
433        card: &Value,
434        discoverable: Option<bool>,
435    ) -> Result<Value> {
436        let mut body = serde_json::json!({
437            "nick": nick,
438            "slot_id": slot_id,
439            "relay_url": relay_url,
440            "card": card,
441        });
442        if let Some(d) = discoverable {
443            body["discoverable"] = serde_json::json!(d);
444        }
445        let resp = self
446            .client
447            .post(format!("{}/v1/handle/claim", self.base_url))
448            .bearer_auth(slot_token)
449            .json(&body)
450            .send()
451            .with_context(|| format!("POST {}/v1/handle/claim", self.base_url))?;
452        let status = resp.status();
453        if !status.is_success() {
454            let detail = resp.text().unwrap_or_default();
455            return Err(anyhow!("handle_claim failed: {status}: {detail}"));
456        }
457        Ok(resp.json()?)
458    }
459
460    /// POST an intro (zero-paste pair-drop) event to a known nick's slot
461    /// without holding that slot's bearer token. Relay validates the event
462    /// is kind=1100 with an embedded signed agent-card; otherwise refuses.
463    pub fn handle_intro(&self, nick: &str, event: &Value) -> Result<Value> {
464        let body = serde_json::json!({"event": event});
465        let resp = self
466            .client
467            .post(format!("{}/v1/handle/intro/{nick}", self.base_url))
468            .json(&body)
469            .send()
470            .with_context(|| format!("POST {}/v1/handle/intro/{nick}", self.base_url))?;
471        let status = resp.status();
472        if !status.is_success() {
473            let detail = resp.text().unwrap_or_default();
474            return Err(anyhow!("handle_intro failed: {status}: {detail}"));
475        }
476        Ok(resp.json()?)
477    }
478
479    /// Resolve a handle on this relay via A2A v1.0 `.well-known/agent-card.json?handle=<nick>`.
480    /// Returns the parsed AgentCard JSON. Wire-served relays embed wire-native
481    /// fields (DID, slot_id, profile, raw card) under `extensions[0].params`.
482    /// Foreign A2A agents return their A2A card without wire ext — useful for
483    /// `wire whois` even when full mailbox pairing isn't possible.
484    pub fn well_known_agent_card_a2a(&self, handle: &str) -> Result<Value> {
485        let resp = self
486            .client
487            .get(format!("{}/.well-known/agent-card.json", self.base_url))
488            .query(&[("handle", handle)])
489            .send()
490            .with_context(|| {
491                format!(
492                    "GET {}/.well-known/agent-card.json?handle={handle}",
493                    self.base_url
494                )
495            })?;
496        let status = resp.status();
497        if !status.is_success() {
498            let detail = resp.text().unwrap_or_default();
499            return Err(anyhow!(
500                "well_known_agent_card_a2a failed: {status}: {detail}"
501            ));
502        }
503        Ok(resp.json()?)
504    }
505
506    /// Resolve a handle on this relay via `.well-known/wire/agent?handle=<nick>`.
507    /// Caller passes either the full `nick@domain` or just `<nick>` — the
508    /// server only uses the local part.
509    pub fn well_known_agent(&self, handle: &str) -> Result<Value> {
510        let resp = self
511            .client
512            .get(format!("{}/.well-known/wire/agent", self.base_url))
513            .query(&[("handle", handle)])
514            .send()
515            .with_context(|| {
516                format!(
517                    "GET {}/.well-known/wire/agent?handle={handle}",
518                    self.base_url
519                )
520            })?;
521        let status = resp.status();
522        if !status.is_success() {
523            let detail = resp.text().unwrap_or_default();
524            return Err(anyhow!("well_known_agent failed: {status}: {detail}"));
525        }
526        Ok(resp.json()?)
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn url_normalization_trims_trailing_slash() {
536        let c = RelayClient::new("http://example.com/");
537        assert_eq!(c.base_url, "http://example.com");
538        let c = RelayClient::new("http://example.com");
539        assert_eq!(c.base_url, "http://example.com");
540    }
541
542    #[test]
543    fn format_transport_error_classifies_tls() {
544        // Simulate the Avast/corp-proxy class from issue #6: reqwest wraps
545        // a rustls UnknownIssuer inside a hyper error inside a context URL.
546        let inner = anyhow!("invalid peer certificate: UnknownIssuer");
547        let middle: anyhow::Error = inner.context("hyper send");
548        let top = middle.context("POST https://relay.example/v1/events/abc");
549        let formatted = format_transport_error(&top);
550        assert!(
551            formatted.starts_with("TLS error:"),
552            "expected TLS class prefix, got: {formatted}"
553        );
554        assert!(
555            formatted.contains("UnknownIssuer"),
556            "lost root cause: {formatted}"
557        );
558        assert!(
559            formatted.contains("POST https://relay.example"),
560            "lost context URL: {formatted}"
561        );
562    }
563
564    #[test]
565    fn format_transport_error_classifies_timeout() {
566        let inner = anyhow!("operation timed out");
567        let top = inner.context("POST https://relay.example/v1/events/abc");
568        let formatted = format_transport_error(&top);
569        assert!(formatted.starts_with("timeout:"), "got: {formatted}");
570    }
571
572    #[test]
573    fn format_transport_error_classifies_dns() {
574        let inner = anyhow!("dns error: failed to lookup address");
575        let top = inner.context("POST https://relay.example/v1/events/abc");
576        let formatted = format_transport_error(&top);
577        assert!(formatted.starts_with("DNS error:"), "got: {formatted}");
578    }
579
580    #[test]
581    fn format_transport_error_falls_back_to_chain_join() {
582        // Unknown class → no prefix, just the joined chain. Behavior MUST
583        // still surface every cause (this is the loud-fail invariant).
584        let inner = anyhow!("Refused to connect for non-standard reason xyz");
585        let top = inner.context("POST https://relay.example/v1/events/abc");
586        let formatted = format_transport_error(&top);
587        assert!(formatted.contains("Refused to connect"));
588        assert!(formatted.contains("POST https://relay.example"));
589    }
590
591    #[test]
592    fn insecure_env_recognizes_truthy_values_and_default_off() {
593        // Process-global env var → must be one test, not two (otherwise
594        // parallel cargo-test threads race). Single test owns the var's
595        // lifecycle from "unset" through truthy values back to "unset".
596        use std::sync::{Mutex, OnceLock};
597        static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
598        let _lock = GUARD.get_or_init(|| Mutex::new(())).lock().unwrap();
599
600        // SAFETY: env mutation here is serialized by the GUARD mutex;
601        // other tests in this module do not touch INSECURE_SKIP_TLS_ENV.
602        unsafe {
603            std::env::remove_var(INSECURE_SKIP_TLS_ENV);
604        }
605        assert!(!insecure_skip_tls_verify(), "default must be secure");
606
607        for v in ["1", "true", "yes", "on", "TRUE", "Yes"] {
608            unsafe {
609                std::env::set_var(INSECURE_SKIP_TLS_ENV, v);
610            }
611            assert!(insecure_skip_tls_verify(), "value {v:?} should be truthy");
612        }
613        // Falsy / unset round-trip back to secure.
614        for v in ["0", "false", "no", "off", ""] {
615            unsafe {
616                std::env::set_var(INSECURE_SKIP_TLS_ENV, v);
617            }
618            assert!(
619                !insecure_skip_tls_verify(),
620                "value {v:?} must not enable insecure mode"
621            );
622        }
623        unsafe {
624            std::env::remove_var(INSECURE_SKIP_TLS_ENV);
625        }
626    }
627}