//! Everything related to Gemini client except cert verification pub mod builder; pub mod response; #[cfg(test)] pub mod tests; pub use response::Response; #[cfg(feature = "hickory")] use crate::dns::DnsClient; #[cfg(feature = "hickory")] use hickory_client::rr::IntoName; #[cfg(feature = "hickory")] use std::net::SocketAddr; use crate::{ certs::{SelfsignedCertVerifier, ServerName}, error::*, into_url::IntoUrl, status::*, }; use builder::ClientBuilder; use std::sync::Arc; use tokio::{ io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, net::TcpStream, }; use tokio_rustls::{client::TlsStream, rustls, TlsConnector}; pub type ThisResponse = Response>>; pub struct Client { pub(crate) connector: TlsConnector, pub(crate) ss_verifier: Option>, #[cfg(feature = "hickory")] pub(crate) dns: Option, } impl Client { /// Construct a Client with a customized configuration, /// see [`ClientBuilder`] methods. pub fn builder() -> ClientBuilder { ClientBuilder::new() } } impl Client { /// Perform a Gemini request with the specified URL. /// Host and port (1965 by default) are parsed from `url` /// after scheme and userinfo checks. /// On success, [`Response`] is returned. /// /// Automatically follows redirections up to 5 times. /// To avoid this behavior, use [`Client::request_with_no_redirect`], /// this function is also called here under the hood. /// /// Returns an error if a scheme is not `gemini://` or /// a userinfo portion (`user:password@`) is present in the URL. /// To avoid this checks, most probably for proxying requests, /// use [`Client::request_with_host`]. /// /// # Errors /// - See [`Client::request_with_no_redirect`]. pub async fn request(&self, url: impl IntoUrl) -> Result { // first request let mut resp = self.request_with_no_redirect(url).await?; let mut i: u8 = 0; const MAX: u8 = 5; // repeat requests until we get a non-30 status // or hit the redirection depth limit loop { if resp.status().reply_type() == ReplyType::Redirect && i < MAX { resp = self.request_with_no_redirect(resp.message()).await?; i += 1; continue; } return Ok(resp); } } /// Perform a Gemini request with the specified URL /// **without** following redirections. /// Host and port (1965 by default) are parsed from `url` /// after scheme and userinfo checks. /// On success, [`Response`] is returned. /// /// # Errors /// - [`InvalidUrl::ParseError`] means that the given URL cannot be parsed. /// - [`InvalidUrl::SchemeNotGemini`] is returned when a scheme is not `gemini://`, /// for proxying requests use [`Client::request_with_host`]. /// - [`InvalidUrl::UserinfoPresent`] is returned when the given URL contains /// a userinfo portion (`user:password@`) -- it is forbidden by the Gemini specification. /// - See [`Client::request_with_host`] for the rest. pub async fn request_with_no_redirect( &self, url: impl IntoUrl, ) -> Result { let url = url.into_url()?; let host = url.host_str().ok_or(InvalidUrl::ConvertError)?; let port = url.port().unwrap_or(1965); self.request_with_host(url.as_str(), host, port).await } /// Perform a Gemini request with the specified host, port and URL. /// Non-`gemini://` URLs is OK if the remote server supports proxying. /// /// # Errors /// - [`InvalidUrl::ConvertError`] means that a hostname cannot be /// converted into [`pki_types::ServerName`]. /// - [`LibError::HostLookupError`] means that a DNS server returned no records, /// i. e. that domain does not exist. /// - [`LibError::DnsClientError`] (crate feature `hickory`) /// wraps a Hickory DNS client error related to a connection failure /// or an invalid DNS server response. /// - [`std::io::Error`] is returned in many cases: /// could not open a TCP connection, perform a TLS handshake, /// write to or read from the TCP stream. /// Check the ErrorKind and/or the inner error /// if you need to determine what exactly happened. /// - [`LibError::StatusOutOfRange`] means that a Gemini server returned /// an invalid status code (less than 10 or greater than 69). /// - [`LibError::DataNotUtf8`] is returned when metadata (the text after a status code) /// is not in UTF-8 and cannot be converted to a string without errors. pub async fn request_with_host( &self, url_str: &str, host: &str, port: u16, ) -> Result { let domain = ServerName::try_from(host) .map_err(|_| InvalidUrl::ConvertError)? .to_owned(); // TCP connection let stream = self.try_connect(host, port).await?; // TLS connection via tokio-rustls let stream = self.connector.connect(domain, stream).await?; // certificate verification if let Some(ssv) = &self.ss_verifier { let cert = stream .get_ref() .1 // rustls::ClientConnection .peer_certificates() .unwrap() // i think handshake already completed if we awaited on connector.connect? .first() .ok_or(rustls::Error::NoCertificatesPresented)?; if !ssv.verify(cert, host, port).await? { return Err(rustls::CertificateError::ApplicationVerificationFailure.into()); } } self.perform_io(url_str, stream).await } pub(crate) async fn perform_io( &self, url_str: &str, mut stream: IO, ) -> Result>, LibError> { // Write URL, then CRLF stream.write_all(url_str.as_bytes()).await?; stream.write_all(b"\r\n").await?; stream.flush().await?; let status = { let mut buf: [u8; 3] = [0, 0, 0]; // 2 digits, space stream.read_exact(&mut buf).await?; Status::parse_status(&buf)? }; let mut stream = BufReader::new(stream); let message = { let mut result: Vec = Vec::new(); let mut buf = [0u8]; // buffer for LF (\n) // reading message after status code // until CRLF (\r\n) loop { // until CR stream.read_until(b'\r', &mut result).await?; // now read next char... stream.read_exact(&mut buf).await?; if buf[0] == b'\n' { // ...and check if it's LF break; } else { // ...otherwise, CR is a part of message, not a CRLF terminator, // so append that one byte that's supposed to be LF (but not LF) // to the message buffer result.push(buf[0]); } } // trim last CR if result.last().is_some_and(|c| c == &b'\r') { result.pop(); } // Vec -> ASCII or UTF-8 String String::from_utf8(result)? }; Ok(Response::new(status, message, stream)) } async fn try_connect(&self, host: &str, port: u16) -> Result { let mut last_err: Option = None; #[cfg(feature = "hickory")] if let Some(dns) = &self.dns { let mut dns = dns.clone(); let name = host.into_name()?; for ip_addr in dns.query_ipv4(name.clone()).await? { match TcpStream::connect(SocketAddr::new(ip_addr, port)).await { Ok(stream) => { return Ok(stream); } Err(err) => { last_err = Some(err); } } } for ip_addr in dns.query_ipv6(name).await? { match TcpStream::connect(SocketAddr::new(ip_addr, port)).await { Ok(stream) => { return Ok(stream); } Err(err) => { last_err = Some(err); } } } if let Some(err) = last_err { return Err(err.into()); } return Err(LibError::HostLookupError); } for addr in tokio::net::lookup_host((host, port)).await? { match TcpStream::connect(addr).await { Ok(stream) => { return Ok(stream); } Err(err) => { last_err = Some(err); } } } if let Some(err) = last_err { return Err(err.into()); } Err(LibError::HostLookupError) } }