1use 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
29pub 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
48fn 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
64pub 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
84pub fn format_transport_error(err: &anyhow::Error) -> String {
92 let mut parts: Vec<String> = err.chain().map(|c| c.to_string()).collect();
93 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}