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.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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}