use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone)]
pub struct RelayClient {
base_url: String,
client: reqwest::blocking::Client,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AllocateResponse {
pub slot_id: String,
pub slot_token: String,
}
#[derive(Debug, Deserialize)]
pub struct PostEventResponse {
pub event_id: Option<String>,
pub status: String,
}
pub const INSECURE_SKIP_TLS_ENV: &str = "WIRE_INSECURE_SKIP_TLS_VERIFY";
fn insecure_skip_tls_verify() -> bool {
matches!(
std::env::var(INSECURE_SKIP_TLS_ENV)
.unwrap_or_default()
.to_ascii_lowercase()
.as_str(),
"1" | "true" | "yes" | "on"
)
}
fn maybe_emit_insecure_banner() {
static ONCE: std::sync::OnceLock<()> = std::sync::OnceLock::new();
if insecure_skip_tls_verify() {
ONCE.get_or_init(|| {
eprintln!(
"\x1b[1;31mwire: WARNING\x1b[0m {INSECURE_SKIP_TLS_ENV}=1 is set; TLS verification is DISABLED for all relay traffic. \
MITM attacks against the relay path are undetectable in this mode. Unset to restore default trust validation."
);
});
}
}
pub fn build_blocking_client(
timeout: Option<std::time::Duration>,
) -> Result<reqwest::blocking::Client> {
let mut b = reqwest::blocking::Client::builder();
if let Some(t) = timeout {
b = b.timeout(t);
}
if insecure_skip_tls_verify() {
maybe_emit_insecure_banner();
b = b.danger_accept_invalid_certs(true);
}
b.build()
.with_context(|| "constructing reqwest blocking client")
}
pub fn format_transport_error(err: &anyhow::Error) -> String {
let mut parts: Vec<String> = err.chain().map(|c| c.to_string()).collect();
let lower = parts
.iter()
.map(|p| p.to_ascii_lowercase())
.collect::<Vec<_>>();
let class = if lower.iter().any(|p| {
p.contains("invalid peer certificate")
|| p.contains("certificate verification")
|| p.contains("unknownissuer")
|| p.contains("certificate is not valid")
|| p.contains("tls handshake")
}) {
Some("TLS error")
} else if lower
.iter()
.any(|p| p.contains("dns error") || p.contains("nodename nor servname") || p.contains("failed to lookup address"))
{
Some("DNS error")
} else if lower
.iter()
.any(|p| p.contains("operation timed out") || p.contains("deadline has elapsed"))
{
Some("timeout")
} else if lower
.iter()
.any(|p| p.contains("connection refused") || p.contains("connection reset"))
{
Some("connect error")
} else {
None
};
if let Some(c) = class {
parts.insert(0, c.to_string());
}
parts.join(": ")
}
impl RelayClient {
pub fn new(base_url: &str) -> Self {
let client = build_blocking_client(Some(std::time::Duration::from_secs(30)))
.expect("reqwest client construction is infallible with rustls + native roots");
Self {
base_url: base_url.trim_end_matches('/').to_string(),
client,
}
}
pub fn allocate_slot(&self, handle_hint: Option<&str>) -> Result<AllocateResponse> {
let body = serde_json::json!({"handle": handle_hint});
let resp = self
.client
.post(format!("{}/v1/slot/allocate", self.base_url))
.json(&body)
.send()
.with_context(|| format!("POST {}/v1/slot/allocate", self.base_url))?;
let status = resp.status();
if !status.is_success() {
let detail = resp.text().unwrap_or_default();
return Err(anyhow!("allocate failed: {status}: {detail}"));
}
Ok(resp.json()?)
}
pub fn post_event(
&self,
slot_id: &str,
slot_token: &str,
event: &Value,
) -> Result<PostEventResponse> {
let body = serde_json::json!({"event": event});
let resp = self
.client
.post(format!("{}/v1/events/{slot_id}", self.base_url))
.bearer_auth(slot_token)
.json(&body)
.send()
.with_context(|| format!("POST {}/v1/events/{slot_id}", self.base_url))?;
let status = resp.status();
if !status.is_success() {
let detail = resp.text().unwrap_or_default();
return Err(anyhow!("post_event failed: {status}: {detail}"));
}
Ok(resp.json()?)
}
pub fn list_events(
&self,
slot_id: &str,
slot_token: &str,
since: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<Value>> {
let mut url = format!("{}/v1/events/{slot_id}", self.base_url);
let mut sep = '?';
if let Some(s) = since {
url.push(sep);
url.push_str(&format!("since={s}"));
sep = '&';
}
if let Some(n) = limit {
url.push(sep);
url.push_str(&format!("limit={n}"));
}
let resp = self
.client
.get(&url)
.bearer_auth(slot_token)
.send()
.with_context(|| format!("GET {url}"))?;
let status = resp.status();
if !status.is_success() {
let detail = resp.text().unwrap_or_default();
return Err(anyhow!("list_events failed: {status}: {detail}"));
}
Ok(resp.json()?)
}
pub fn slot_state(&self, slot_id: &str, slot_token: &str) -> Result<(usize, Option<u64>)> {
let url = format!("{}/v1/slot/{slot_id}/state", self.base_url);
let resp = match self.client.get(&url).bearer_auth(slot_token).send() {
Ok(r) => r,
Err(_) => return Ok((0, None)),
};
if !resp.status().is_success() {
return Ok((0, None));
}
let v: Value = resp.json().unwrap_or(Value::Null);
let count = v.get("event_count").and_then(Value::as_u64).unwrap_or(0) as usize;
let last = v.get("last_pull_at_unix").and_then(Value::as_u64);
Ok((count, last))
}
pub fn responder_health_set(
&self,
slot_id: &str,
slot_token: &str,
record: &Value,
) -> Result<Value> {
let resp = self
.client
.post(format!(
"{}/v1/slot/{slot_id}/responder-health",
self.base_url
))
.bearer_auth(slot_token)
.json(record)
.send()
.with_context(|| {
format!("POST {}/v1/slot/{slot_id}/responder-health", self.base_url)
})?;
let status = resp.status();
if !status.is_success() {
let detail = resp.text().unwrap_or_default();
return Err(anyhow!("responder_health_set failed: {status}: {detail}"));
}
Ok(resp.json()?)
}
pub fn responder_health_get(&self, slot_id: &str, slot_token: &str) -> Result<Value> {
let resp = self
.client
.get(format!("{}/v1/slot/{slot_id}/state", self.base_url))
.bearer_auth(slot_token)
.send()
.with_context(|| format!("GET {}/v1/slot/{slot_id}/state", self.base_url))?;
let status = resp.status();
if !status.is_success() {
let detail = resp.text().unwrap_or_default();
return Err(anyhow!("responder_health_get failed: {status}: {detail}"));
}
let state: Value = resp.json()?;
Ok(state
.get("responder_health")
.cloned()
.unwrap_or(Value::Null))
}
pub fn healthz(&self) -> Result<bool> {
let resp = self
.client
.get(format!("{}/healthz", self.base_url))
.send()?;
Ok(resp.status().is_success())
}
pub fn check_healthz(&self) -> anyhow::Result<()> {
match self.healthz() {
Ok(true) => Ok(()),
Ok(false) => anyhow::bail!(
"phyllis: silent line — {}/healthz returned non-200.\n\
the host is reachable but the relay isn't returning ok. test:\n \
curl -v {}/healthz",
self.base_url,
self.base_url
),
Err(e) => anyhow::bail!(
"phyllis: silent line — couldn't reach {}/healthz: {e:#}.\n\
test reachability from this machine:\n curl -v {}/healthz\n\
if curl also fails, a sandbox / proxy / firewall is the usual cause.\n\
(OpenShell sandbox? run `curl -fsSL https://wireup.net/openshell-policy.sh | bash -s <sandbox-name>` on the host first.)",
self.base_url,
self.base_url
),
}
}
pub fn pair_open(&self, code_hash: &str, msg_b64: &str, role: &str) -> Result<String> {
let body = serde_json::json!({"code_hash": code_hash, "msg": msg_b64, "role": role});
let resp = self
.client
.post(format!("{}/v1/pair", self.base_url))
.json(&body)
.send()?;
let status = resp.status();
if !status.is_success() {
let detail = resp.text().unwrap_or_default();
return Err(anyhow!("pair_open failed: {status}: {detail}"));
}
let v: Value = resp.json()?;
v.get("pair_id")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| anyhow!("pair_open response missing pair_id"))
}
pub fn pair_abandon(&self, code_hash: &str) -> Result<()> {
let body = serde_json::json!({"code_hash": code_hash});
let resp = self
.client
.post(format!("{}/v1/pair/abandon", self.base_url))
.json(&body)
.send()?;
let status = resp.status();
if !status.is_success() {
let detail = resp.text().unwrap_or_default();
return Err(anyhow!("pair_abandon failed: {status}: {detail}"));
}
Ok(())
}
pub fn pair_get(
&self,
pair_id: &str,
as_role: &str,
) -> Result<(Option<String>, Option<String>)> {
let resp = self
.client
.get(format!(
"{}/v1/pair/{pair_id}?as_role={as_role}",
self.base_url
))
.send()?;
let status = resp.status();
if !status.is_success() {
let detail = resp.text().unwrap_or_default();
return Err(anyhow!("pair_get failed: {status}: {detail}"));
}
let v: Value = resp.json()?;
let peer_msg = v
.get("peer_msg")
.and_then(Value::as_str)
.map(str::to_string);
let peer_bootstrap = v
.get("peer_bootstrap")
.and_then(Value::as_str)
.map(str::to_string);
Ok((peer_msg, peer_bootstrap))
}
pub fn pair_bootstrap(&self, pair_id: &str, role: &str, sealed_b64: &str) -> Result<()> {
let body = serde_json::json!({"role": role, "sealed": sealed_b64});
let resp = self
.client
.post(format!("{}/v1/pair/{pair_id}/bootstrap", self.base_url))
.json(&body)
.send()?;
if !resp.status().is_success() {
let s = resp.status();
let detail = resp.text().unwrap_or_default();
return Err(anyhow!("pair_bootstrap failed: {s}: {detail}"));
}
Ok(())
}
pub fn handle_claim(
&self,
nick: &str,
slot_id: &str,
slot_token: &str,
relay_url: Option<&str>,
card: &Value,
) -> Result<Value> {
let body = serde_json::json!({
"nick": nick,
"slot_id": slot_id,
"relay_url": relay_url,
"card": card,
});
let resp = self
.client
.post(format!("{}/v1/handle/claim", self.base_url))
.bearer_auth(slot_token)
.json(&body)
.send()
.with_context(|| format!("POST {}/v1/handle/claim", self.base_url))?;
let status = resp.status();
if !status.is_success() {
let detail = resp.text().unwrap_or_default();
return Err(anyhow!("handle_claim failed: {status}: {detail}"));
}
Ok(resp.json()?)
}
pub fn handle_intro(&self, nick: &str, event: &Value) -> Result<Value> {
let body = serde_json::json!({"event": event});
let resp = self
.client
.post(format!("{}/v1/handle/intro/{nick}", self.base_url))
.json(&body)
.send()
.with_context(|| format!("POST {}/v1/handle/intro/{nick}", self.base_url))?;
let status = resp.status();
if !status.is_success() {
let detail = resp.text().unwrap_or_default();
return Err(anyhow!("handle_intro failed: {status}: {detail}"));
}
Ok(resp.json()?)
}
pub fn well_known_agent_card_a2a(&self, handle: &str) -> Result<Value> {
let resp = self
.client
.get(format!("{}/.well-known/agent-card.json", self.base_url))
.query(&[("handle", handle)])
.send()
.with_context(|| {
format!(
"GET {}/.well-known/agent-card.json?handle={handle}",
self.base_url
)
})?;
let status = resp.status();
if !status.is_success() {
let detail = resp.text().unwrap_or_default();
return Err(anyhow!(
"well_known_agent_card_a2a failed: {status}: {detail}"
));
}
Ok(resp.json()?)
}
pub fn well_known_agent(&self, handle: &str) -> Result<Value> {
let resp = self
.client
.get(format!("{}/.well-known/wire/agent", self.base_url))
.query(&[("handle", handle)])
.send()
.with_context(|| {
format!(
"GET {}/.well-known/wire/agent?handle={handle}",
self.base_url
)
})?;
let status = resp.status();
if !status.is_success() {
let detail = resp.text().unwrap_or_default();
return Err(anyhow!("well_known_agent failed: {status}: {detail}"));
}
Ok(resp.json()?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_normalization_trims_trailing_slash() {
let c = RelayClient::new("http://example.com/");
assert_eq!(c.base_url, "http://example.com");
let c = RelayClient::new("http://example.com");
assert_eq!(c.base_url, "http://example.com");
}
#[test]
fn format_transport_error_classifies_tls() {
let inner = anyhow!("invalid peer certificate: UnknownIssuer");
let middle: anyhow::Error = inner.context("hyper send");
let top = middle.context("POST https://relay.example/v1/events/abc");
let formatted = format_transport_error(&top);
assert!(
formatted.starts_with("TLS error:"),
"expected TLS class prefix, got: {formatted}"
);
assert!(formatted.contains("UnknownIssuer"), "lost root cause: {formatted}");
assert!(
formatted.contains("POST https://relay.example"),
"lost context URL: {formatted}"
);
}
#[test]
fn format_transport_error_classifies_timeout() {
let inner = anyhow!("operation timed out");
let top = inner.context("POST https://relay.example/v1/events/abc");
let formatted = format_transport_error(&top);
assert!(formatted.starts_with("timeout:"), "got: {formatted}");
}
#[test]
fn format_transport_error_classifies_dns() {
let inner = anyhow!("dns error: failed to lookup address");
let top = inner.context("POST https://relay.example/v1/events/abc");
let formatted = format_transport_error(&top);
assert!(formatted.starts_with("DNS error:"), "got: {formatted}");
}
#[test]
fn format_transport_error_falls_back_to_chain_join() {
let inner = anyhow!("Refused to connect for non-standard reason xyz");
let top = inner.context("POST https://relay.example/v1/events/abc");
let formatted = format_transport_error(&top);
assert!(formatted.contains("Refused to connect"));
assert!(formatted.contains("POST https://relay.example"));
}
#[test]
fn insecure_env_recognizes_truthy_values_and_default_off() {
use std::sync::{Mutex, OnceLock};
static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
let _lock = GUARD.get_or_init(|| Mutex::new(())).lock().unwrap();
unsafe {
std::env::remove_var(INSECURE_SKIP_TLS_ENV);
}
assert!(!insecure_skip_tls_verify(), "default must be secure");
for v in ["1", "true", "yes", "on", "TRUE", "Yes"] {
unsafe {
std::env::set_var(INSECURE_SKIP_TLS_ENV, v);
}
assert!(insecure_skip_tls_verify(), "value {v:?} should be truthy");
}
for v in ["0", "false", "no", "off", ""] {
unsafe {
std::env::set_var(INSECURE_SKIP_TLS_ENV, v);
}
assert!(
!insecure_skip_tls_verify(),
"value {v:?} must not enable insecure mode"
);
}
unsafe {
std::env::remove_var(INSECURE_SKIP_TLS_ENV);
}
}
}