//! Minimal blocking HTTP/HTTPS client — `std::net::TcpStream` + openssl for TLS. //! Modules that talk to external APIs (account registration, captcha //! verification, …) use this from a **worker thread** and deliver the result back //! to the core as an [`crate::ircd::Event`], so a slow or hung endpoint never //! blocks the main loop. use std::io::{Read, Write}; use std::net::TcpStream; use std::time::Duration; use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode}; /// Largest response body we'll buffer. A hostile or compromised endpoint could /// otherwise stream unbounded data and OOM the worker thread (and, via mimalloc, /// the whole process). const MAX_RESPONSE: u64 = 4 * 1024 * 1024; /// POST `body` to `url` with `content_type` and extra `headers`. Blocking. /// Returns `(status_code, response_body)` or an error string. `verify` turns on /// TLS certificate + hostname verification for https (the safe default); pass /// `false` only for a trusted private endpoint with a self-signed cert. pub fn post( url: &str, content_type: &str, body: &str, headers: &[(String, String)], timeout: Duration, verify: bool, ) -> Result<(u16, String), String> { let (scheme, rest) = url.split_once("://").ok_or("bad url (no scheme)")?; let (hostport, path) = match rest.split_once('/') { Some((hp, p)) => (hp, format!("/{p}")), None => (rest, "/".to_string()), }; let https = scheme.eq_ignore_ascii_case("https"); let (host, port): (&str, u16) = match hostport.rsplit_once(':') { Some((h, p)) => (h, p.parse().unwrap_or(if https { 443 } else { 80 })), None => (hostport, if https { 443 } else { 80 }), }; let mut req = format!( "POST {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: echoIRCd\r\nAccept: */*\r\n\ Content-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n", body.len() ); for (k, v) in headers { req.push_str(&format!("{k}: {v}\r\n")); } req.push_str("\r\n"); req.push_str(body); let stream = TcpStream::connect((host, port)).map_err(|e| e.to_string())?; stream.set_read_timeout(Some(timeout)).ok(); stream.set_write_timeout(Some(timeout)).ok(); let raw = if https { let mut b = SslConnector::builder(SslMethod::tls()).map_err(|e| e.to_string())?; // Default: keep the connector's secure verification (cert chain + hostname, // applied by connect(host, ..)). Only downgrade when the operator opts out // for a trusted reverse-proxy / self-signed endpoint. if !verify { b.set_verify(SslVerifyMode::NONE); } let connector = b.build(); let mut tls = connector.connect(host, stream).map_err(|e| e.to_string())?; tls.write_all(req.as_bytes()).map_err(|e| e.to_string())?; let mut buf = Vec::new(); let _ = tls.take(MAX_RESPONSE).read_to_end(&mut buf); // bounded; close => EOF buf } else { let mut s = stream; s.write_all(req.as_bytes()).map_err(|e| e.to_string())?; let mut buf = Vec::new(); let _ = s.take(MAX_RESPONSE).read_to_end(&mut buf); buf }; let resp = String::from_utf8_lossy(&raw).into_owned(); let (head, rbody) = resp.split_once("\r\n\r\n").unwrap_or((resp.as_str(), "")); let status = head .lines() .next() .and_then(|l| l.split_whitespace().nth(1)) .and_then(|c| c.parse::().ok()) .unwrap_or(0); let chunked = head .to_ascii_lowercase() .contains("transfer-encoding: chunked"); let out = if chunked { dechunk(rbody) } else { rbody.to_string() }; Ok((status, out)) } /// Decode an HTTP/1.1 chunked body (best effort). Used for both outbound response /// bodies here and inbound request bodies in the RPC httpd. pub fn dechunk(body: &str) -> String { // Work on bytes, not the &str: the chunk size is an attacker-supplied byte // count and may land mid-UTF-8-character, so str slicing would panic. let mut out: Vec = Vec::new(); let mut rest = body.as_bytes(); while let Some(nl) = rest.windows(2).position(|w| w == b"\r\n") { let size = std::str::from_utf8(&rest[..nl]) .ok() .and_then(|s| usize::from_str_radix(s.trim().split(';').next().unwrap_or("0"), 16).ok()) .unwrap_or(0); let after = &rest[nl + 2..]; if size == 0 || after.len() < size { out.extend_from_slice(&after[..after.len().min(size)]); break; } out.extend_from_slice(&after[..size]); rest = after[size..].strip_prefix(b"\r\n").unwrap_or(&after[size..]); } String::from_utf8_lossy(&out).into_owned() } /// application/x-www-form-urlencoded escape of a single value. pub fn urlencode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(b as char) } b' ' => out.push('+'), _ => out.push_str(&format!("%{b:02X}")), } } out } /// Pull a top-level `"key": value` out of a flat JSON object — dependency-free, /// good enough for the small, known API responses these modules parse. pub fn json_str(body: &str, key: &str) -> Option { let pat = format!("\"{key}\""); let start = body.find(&pat)? + pat.len(); let after_colon = body[start..].find(':')? + start + 1; let rest = body[after_colon..].trim_start(); if let Some(r) = rest.strip_prefix('"') { let mut out = String::new(); let mut chars = r.chars(); while let Some(ch) = chars.next() { match ch { '\\' => { if let Some(n) = chars.next() { out.push(n); } } '"' => return Some(out), _ => out.push(ch), } } None } else { let end = rest.find([',', '}', '\n']).unwrap_or(rest.len()); Some(rest[..end].trim().to_string()) } }