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
109        .iter()
110        .any(|p| p.contains("dns error") || p.contains("nodename nor servname") || p.contains("failed to lookup address"))
111    {
112        Some("DNS error")
113    } else if lower
114        .iter()
115        .any(|p| p.contains("operation timed out") || p.contains("deadline has elapsed"))
116    {
117        Some("timeout")
118    } else if lower
119        .iter()
120        .any(|p| p.contains("connection refused") || p.contains("connection reset"))
121    {
122        Some("connect error")
123    } else {
124        None
125    };
126    if let Some(c) = class {
127        parts.insert(0, c.to_string());
128    }
129    parts.join(": ")
130}
131
132impl RelayClient {
133    pub fn new(base_url: &str) -> Self {
134        let client = build_blocking_client(Some(std::time::Duration::from_secs(30)))
135            .expect("reqwest client construction is infallible with rustls + native roots");
136        Self {
137            base_url: base_url.trim_end_matches('/').to_string(),
138            client,
139        }
140    }
141
142    /// Allocate a fresh slot. Returns `(slot_id, slot_token)` — caller MUST
143    /// persist `slot_token` somewhere safe (mode 0600 file); it grants both
144    /// read and write access to the slot.
145    pub fn allocate_slot(&self, handle_hint: Option<&str>) -> Result<AllocateResponse> {
146        let body = serde_json::json!({"handle": handle_hint});
147        let resp = self
148            .client
149            .post(format!("{}/v1/slot/allocate", self.base_url))
150            .json(&body)
151            .send()
152            .with_context(|| format!("POST {}/v1/slot/allocate", self.base_url))?;
153        let status = resp.status();
154        if !status.is_success() {
155            let detail = resp.text().unwrap_or_default();
156            return Err(anyhow!("allocate failed: {status}: {detail}"));
157        }
158        Ok(resp.json()?)
159    }
160
161    /// POST a signed event to a slot. Caller passes the slot's bearer token
162    /// (the relay model in v0.1 is "shared slot token between paired peers" —
163    /// see iter 9 SPAKE2 for how this token gets exchanged).
164    pub fn post_event(
165        &self,
166        slot_id: &str,
167        slot_token: &str,
168        event: &Value,
169    ) -> Result<PostEventResponse> {
170        let body = serde_json::json!({"event": event});
171        let resp = self
172            .client
173            .post(format!("{}/v1/events/{slot_id}", self.base_url))
174            .bearer_auth(slot_token)
175            .json(&body)
176            .send()
177            .with_context(|| format!("POST {}/v1/events/{slot_id}", self.base_url))?;
178        let status = resp.status();
179        if !status.is_success() {
180            let detail = resp.text().unwrap_or_default();
181            return Err(anyhow!("post_event failed: {status}: {detail}"));
182        }
183        Ok(resp.json()?)
184    }
185
186    /// GET events from a slot. `since` is an event_id cursor (exclusive); pass
187    /// `None` for the full slot snapshot. `limit` defaults to 100, max 1000.
188    pub fn list_events(
189        &self,
190        slot_id: &str,
191        slot_token: &str,
192        since: Option<&str>,
193        limit: Option<usize>,
194    ) -> Result<Vec<Value>> {
195        let mut url = format!("{}/v1/events/{slot_id}", self.base_url);
196        let mut sep = '?';
197        if let Some(s) = since {
198            url.push(sep);
199            url.push_str(&format!("since={s}"));
200            sep = '&';
201        }
202        if let Some(n) = limit {
203            url.push(sep);
204            url.push_str(&format!("limit={n}"));
205        }
206        let resp = self
207            .client
208            .get(&url)
209            .bearer_auth(slot_token)
210            .send()
211            .with_context(|| format!("GET {url}"))?;
212        let status = resp.status();
213        if !status.is_success() {
214            let detail = resp.text().unwrap_or_default();
215            return Err(anyhow!("list_events failed: {status}: {detail}"));
216        }
217        Ok(resp.json()?)
218    }
219
220    /// R4 — probe slot attentiveness. Returns `(event_count, last_pull_at_unix)`
221    /// — the relay's view of the slot's owner's most recent poll. `None` for
222    /// `last_pull_at_unix` means the slot has not been pulled since relay
223    /// restart. Best-effort: any HTTP failure returns `Ok((0, None))` so the
224    /// caller's pre-flight check degrades to "no signal" rather than abort.
225    pub fn slot_state(&self, slot_id: &str, slot_token: &str) -> Result<(usize, Option<u64>)> {
226        let url = format!("{}/v1/slot/{slot_id}/state", self.base_url);
227        let resp = match self.client.get(&url).bearer_auth(slot_token).send() {
228            Ok(r) => r,
229            Err(_) => return Ok((0, None)),
230        };
231        if !resp.status().is_success() {
232            return Ok((0, None));
233        }
234        let v: Value = resp.json().unwrap_or(Value::Null);
235        let count = v.get("event_count").and_then(Value::as_u64).unwrap_or(0) as usize;
236        let last = v.get("last_pull_at_unix").and_then(Value::as_u64);
237        Ok((count, last))
238    }
239
240    pub fn responder_health_set(
241        &self,
242        slot_id: &str,
243        slot_token: &str,
244        record: &Value,
245    ) -> Result<Value> {
246        let resp = self
247            .client
248            .post(format!(
249                "{}/v1/slot/{slot_id}/responder-health",
250                self.base_url
251            ))
252            .bearer_auth(slot_token)
253            .json(record)
254            .send()
255            .with_context(|| {
256                format!("POST {}/v1/slot/{slot_id}/responder-health", self.base_url)
257            })?;
258        let status = resp.status();
259        if !status.is_success() {
260            let detail = resp.text().unwrap_or_default();
261            return Err(anyhow!("responder_health_set failed: {status}: {detail}"));
262        }
263        Ok(resp.json()?)
264    }
265
266    pub fn responder_health_get(&self, slot_id: &str, slot_token: &str) -> Result<Value> {
267        let resp = self
268            .client
269            .get(format!("{}/v1/slot/{slot_id}/state", self.base_url))
270            .bearer_auth(slot_token)
271            .send()
272            .with_context(|| format!("GET {}/v1/slot/{slot_id}/state", self.base_url))?;
273        let status = resp.status();
274        if !status.is_success() {
275            let detail = resp.text().unwrap_or_default();
276            return Err(anyhow!("responder_health_get failed: {status}: {detail}"));
277        }
278        let state: Value = resp.json()?;
279        Ok(state
280            .get("responder_health")
281            .cloned()
282            .unwrap_or(Value::Null))
283    }
284
285    pub fn healthz(&self) -> Result<bool> {
286        let resp = self
287            .client
288            .get(format!("{}/healthz", self.base_url))
289            .send()?;
290        Ok(resp.status().is_success())
291    }
292
293    /// Healthz pre-flight that surfaces the underlying reqwest error in its
294    /// own message. Use at every "is the relay reachable before we mutate
295    /// state" site. The three possible failure modes (network error, 5xx
296    /// from a reachable host, healthy) each get a distinct diagnostic line.
297    pub fn check_healthz(&self) -> anyhow::Result<()> {
298        match self.healthz() {
299            Ok(true) => Ok(()),
300            Ok(false) => anyhow::bail!(
301                "phyllis: silent line — {}/healthz returned non-200.\n\
302                 the host is reachable but the relay isn't returning ok. test:\n  \
303                 curl -v {}/healthz",
304                self.base_url,
305                self.base_url
306            ),
307            Err(e) => anyhow::bail!(
308                "phyllis: silent line — couldn't reach {}/healthz: {e:#}.\n\
309                 test reachability from this machine:\n  curl -v {}/healthz\n\
310                 if curl also fails, a sandbox / proxy / firewall is the usual cause.\n\
311                 (OpenShell sandbox? run `curl -fsSL https://wireup.net/openshell-policy.sh | bash -s <sandbox-name>` on the host first.)",
312                self.base_url,
313                self.base_url
314            ),
315        }
316    }
317
318    /// Open or join a pair-slot. Returns the relay-assigned `pair_id`.
319    /// `role` must be `"host"` or `"guest"`. The host calls first; the guest
320    /// uses the same `code_hash` and finds the existing slot.
321    pub fn pair_open(&self, code_hash: &str, msg_b64: &str, role: &str) -> Result<String> {
322        let body = serde_json::json!({"code_hash": code_hash, "msg": msg_b64, "role": role});
323        let resp = self
324            .client
325            .post(format!("{}/v1/pair", self.base_url))
326            .json(&body)
327            .send()?;
328        let status = resp.status();
329        if !status.is_success() {
330            let detail = resp.text().unwrap_or_default();
331            return Err(anyhow!("pair_open failed: {status}: {detail}"));
332        }
333        let v: Value = resp.json()?;
334        v.get("pair_id")
335            .and_then(Value::as_str)
336            .map(str::to_string)
337            .ok_or_else(|| anyhow!("pair_open response missing pair_id"))
338    }
339
340    /// Forget the pair-slot at this code_hash on the relay. Either side can call;
341    /// knowledge of the code is the only auth. Idempotent — succeeds even if the
342    /// slot doesn't exist. Use after a client crash mid-handshake so the host
343    /// doesn't stay locked out until TTL.
344    pub fn pair_abandon(&self, code_hash: &str) -> Result<()> {
345        let body = serde_json::json!({"code_hash": code_hash});
346        let resp = self
347            .client
348            .post(format!("{}/v1/pair/abandon", self.base_url))
349            .json(&body)
350            .send()?;
351        let status = resp.status();
352        if !status.is_success() {
353            let detail = resp.text().unwrap_or_default();
354            return Err(anyhow!("pair_abandon failed: {status}: {detail}"));
355        }
356        Ok(())
357    }
358
359    /// Read peer's SPAKE2 message + (eventually) sealed bootstrap from a pair-slot.
360    pub fn pair_get(
361        &self,
362        pair_id: &str,
363        as_role: &str,
364    ) -> Result<(Option<String>, Option<String>)> {
365        let resp = self
366            .client
367            .get(format!(
368                "{}/v1/pair/{pair_id}?as_role={as_role}",
369                self.base_url
370            ))
371            .send()?;
372        let status = resp.status();
373        if !status.is_success() {
374            let detail = resp.text().unwrap_or_default();
375            return Err(anyhow!("pair_get failed: {status}: {detail}"));
376        }
377        let v: Value = resp.json()?;
378        let peer_msg = v
379            .get("peer_msg")
380            .and_then(Value::as_str)
381            .map(str::to_string);
382        let peer_bootstrap = v
383            .get("peer_bootstrap")
384            .and_then(Value::as_str)
385            .map(str::to_string);
386        Ok((peer_msg, peer_bootstrap))
387    }
388
389    /// POST a sealed bootstrap payload to the pair-slot.
390    pub fn pair_bootstrap(&self, pair_id: &str, role: &str, sealed_b64: &str) -> Result<()> {
391        let body = serde_json::json!({"role": role, "sealed": sealed_b64});
392        let resp = self
393            .client
394            .post(format!("{}/v1/pair/{pair_id}/bootstrap", self.base_url))
395            .json(&body)
396            .send()?;
397        if !resp.status().is_success() {
398            let s = resp.status();
399            let detail = resp.text().unwrap_or_default();
400            return Err(anyhow!("pair_bootstrap failed: {s}: {detail}"));
401        }
402        Ok(())
403    }
404
405    /// Claim a `nick@<this-relay-domain>` handle (v0.5). Caller must hold
406    /// the bearer token for `slot_id`. FCFS on nick; same-DID re-claims OK.
407    pub fn handle_claim(
408        &self,
409        nick: &str,
410        slot_id: &str,
411        slot_token: &str,
412        relay_url: Option<&str>,
413        card: &Value,
414    ) -> Result<Value> {
415        let body = serde_json::json!({
416            "nick": nick,
417            "slot_id": slot_id,
418            "relay_url": relay_url,
419            "card": card,
420        });
421        let resp = self
422            .client
423            .post(format!("{}/v1/handle/claim", self.base_url))
424            .bearer_auth(slot_token)
425            .json(&body)
426            .send()
427            .with_context(|| format!("POST {}/v1/handle/claim", self.base_url))?;
428        let status = resp.status();
429        if !status.is_success() {
430            let detail = resp.text().unwrap_or_default();
431            return Err(anyhow!("handle_claim failed: {status}: {detail}"));
432        }
433        Ok(resp.json()?)
434    }
435
436    /// POST an intro (zero-paste pair-drop) event to a known nick's slot
437    /// without holding that slot's bearer token. Relay validates the event
438    /// is kind=1100 with an embedded signed agent-card; otherwise refuses.
439    pub fn handle_intro(&self, nick: &str, event: &Value) -> Result<Value> {
440        let body = serde_json::json!({"event": event});
441        let resp = self
442            .client
443            .post(format!("{}/v1/handle/intro/{nick}", self.base_url))
444            .json(&body)
445            .send()
446            .with_context(|| format!("POST {}/v1/handle/intro/{nick}", self.base_url))?;
447        let status = resp.status();
448        if !status.is_success() {
449            let detail = resp.text().unwrap_or_default();
450            return Err(anyhow!("handle_intro failed: {status}: {detail}"));
451        }
452        Ok(resp.json()?)
453    }
454
455    /// Resolve a handle on this relay via A2A v1.0 `.well-known/agent-card.json?handle=<nick>`.
456    /// Returns the parsed AgentCard JSON. Wire-served relays embed wire-native
457    /// fields (DID, slot_id, profile, raw card) under `extensions[0].params`.
458    /// Foreign A2A agents return their A2A card without wire ext — useful for
459    /// `wire whois` even when full mailbox pairing isn't possible.
460    pub fn well_known_agent_card_a2a(&self, handle: &str) -> Result<Value> {
461        let resp = self
462            .client
463            .get(format!("{}/.well-known/agent-card.json", self.base_url))
464            .query(&[("handle", handle)])
465            .send()
466            .with_context(|| {
467                format!(
468                    "GET {}/.well-known/agent-card.json?handle={handle}",
469                    self.base_url
470                )
471            })?;
472        let status = resp.status();
473        if !status.is_success() {
474            let detail = resp.text().unwrap_or_default();
475            return Err(anyhow!(
476                "well_known_agent_card_a2a failed: {status}: {detail}"
477            ));
478        }
479        Ok(resp.json()?)
480    }
481
482    /// Resolve a handle on this relay via `.well-known/wire/agent?handle=<nick>`.
483    /// Caller passes either the full `nick@domain` or just `<nick>` — the
484    /// server only uses the local part.
485    pub fn well_known_agent(&self, handle: &str) -> Result<Value> {
486        let resp = self
487            .client
488            .get(format!("{}/.well-known/wire/agent", self.base_url))
489            .query(&[("handle", handle)])
490            .send()
491            .with_context(|| {
492                format!(
493                    "GET {}/.well-known/wire/agent?handle={handle}",
494                    self.base_url
495                )
496            })?;
497        let status = resp.status();
498        if !status.is_success() {
499            let detail = resp.text().unwrap_or_default();
500            return Err(anyhow!("well_known_agent failed: {status}: {detail}"));
501        }
502        Ok(resp.json()?)
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn url_normalization_trims_trailing_slash() {
512        let c = RelayClient::new("http://example.com/");
513        assert_eq!(c.base_url, "http://example.com");
514        let c = RelayClient::new("http://example.com");
515        assert_eq!(c.base_url, "http://example.com");
516    }
517
518    #[test]
519    fn format_transport_error_classifies_tls() {
520        // Simulate the Avast/corp-proxy class from issue #6: reqwest wraps
521        // a rustls UnknownIssuer inside a hyper error inside a context URL.
522        let inner = anyhow!("invalid peer certificate: UnknownIssuer");
523        let middle: anyhow::Error = inner.context("hyper send");
524        let top = middle.context("POST https://relay.example/v1/events/abc");
525        let formatted = format_transport_error(&top);
526        assert!(
527            formatted.starts_with("TLS error:"),
528            "expected TLS class prefix, got: {formatted}"
529        );
530        assert!(formatted.contains("UnknownIssuer"), "lost root cause: {formatted}");
531        assert!(
532            formatted.contains("POST https://relay.example"),
533            "lost context URL: {formatted}"
534        );
535    }
536
537    #[test]
538    fn format_transport_error_classifies_timeout() {
539        let inner = anyhow!("operation timed out");
540        let top = inner.context("POST https://relay.example/v1/events/abc");
541        let formatted = format_transport_error(&top);
542        assert!(formatted.starts_with("timeout:"), "got: {formatted}");
543    }
544
545    #[test]
546    fn format_transport_error_classifies_dns() {
547        let inner = anyhow!("dns error: failed to lookup address");
548        let top = inner.context("POST https://relay.example/v1/events/abc");
549        let formatted = format_transport_error(&top);
550        assert!(formatted.starts_with("DNS error:"), "got: {formatted}");
551    }
552
553    #[test]
554    fn format_transport_error_falls_back_to_chain_join() {
555        // Unknown class → no prefix, just the joined chain. Behavior MUST
556        // still surface every cause (this is the loud-fail invariant).
557        let inner = anyhow!("Refused to connect for non-standard reason xyz");
558        let top = inner.context("POST https://relay.example/v1/events/abc");
559        let formatted = format_transport_error(&top);
560        assert!(formatted.contains("Refused to connect"));
561        assert!(formatted.contains("POST https://relay.example"));
562    }
563
564    #[test]
565    fn insecure_env_recognizes_truthy_values_and_default_off() {
566        // Process-global env var → must be one test, not two (otherwise
567        // parallel cargo-test threads race). Single test owns the var's
568        // lifecycle from "unset" through truthy values back to "unset".
569        use std::sync::{Mutex, OnceLock};
570        static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
571        let _lock = GUARD.get_or_init(|| Mutex::new(())).lock().unwrap();
572
573        // SAFETY: env mutation here is serialized by the GUARD mutex;
574        // other tests in this module do not touch INSECURE_SKIP_TLS_ENV.
575        unsafe {
576            std::env::remove_var(INSECURE_SKIP_TLS_ENV);
577        }
578        assert!(!insecure_skip_tls_verify(), "default must be secure");
579
580        for v in ["1", "true", "yes", "on", "TRUE", "Yes"] {
581            unsafe {
582                std::env::set_var(INSECURE_SKIP_TLS_ENV, v);
583            }
584            assert!(insecure_skip_tls_verify(), "value {v:?} should be truthy");
585        }
586        // Falsy / unset round-trip back to secure.
587        for v in ["0", "false", "no", "off", ""] {
588            unsafe {
589                std::env::set_var(INSECURE_SKIP_TLS_ENV, v);
590            }
591            assert!(
592                !insecure_skip_tls_verify(),
593                "value {v:?} must not enable insecure mode"
594            );
595        }
596        unsafe {
597            std::env::remove_var(INSECURE_SKIP_TLS_ENV);
598        }
599    }
600}