tokio_gemini/client/mod.rs
1//! Everything related to Gemini client except cert verification
2
3pub mod builder;
4pub mod response;
5
6#[cfg(test)]
7pub mod tests;
8
9pub use response::Response;
10
11#[cfg(feature = "hickory")]
12use crate::dns::DnsClient;
13#[cfg(feature = "hickory")]
14use hickory_client::rr::IntoName;
15#[cfg(feature = "hickory")]
16use std::net::SocketAddr;
17
18use crate::{
19 certs::{SelfsignedCertVerifier, ServerName},
20 error::*,
21 into_url::IntoUrl,
22 status::*,
23};
24use builder::ClientBuilder;
25
26use std::sync::Arc;
27
28use tokio::{
29 io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
30 net::TcpStream,
31};
32use tokio_rustls::{client::TlsStream, rustls, TlsConnector};
33
34pub type ThisResponse = Response<BufReader<TlsStream<TcpStream>>>;
35
36pub struct Client {
37 pub(crate) connector: TlsConnector,
38 pub(crate) ss_verifier: Option<Arc<dyn SelfsignedCertVerifier>>,
39 #[cfg(feature = "hickory")]
40 pub(crate) dns: Option<DnsClient>,
41}
42
43impl Client {
44 /// Construct a Client with a customized configuration,
45 /// see [`ClientBuilder`] methods.
46 pub fn builder() -> ClientBuilder {
47 ClientBuilder::new()
48 }
49}
50
51impl Client {
52 /// Perform a Gemini request with the specified URL.
53 /// Host and port (1965 by default) are parsed from `url`
54 /// after scheme and userinfo checks.
55 /// On success, [`Response`] is returned.
56 ///
57 /// Automatically follows redirections up to 5 times.
58 /// To avoid this behavior, use [`Client::request_with_no_redirect`],
59 /// this function is also called here under the hood.
60 ///
61 /// Returns an error if a scheme is not `gemini://` or
62 /// a userinfo portion (`user:password@`) is present in the URL.
63 /// To avoid this checks, most probably for proxying requests,
64 /// use [`Client::request_with_host`].
65 ///
66 /// # Errors
67 /// - See [`Client::request_with_no_redirect`].
68 pub async fn request(&self, url: impl IntoUrl) -> Result<ThisResponse, LibError> {
69 // first request
70 let mut resp = self.request_with_no_redirect(url).await?;
71
72 let mut i: u8 = 0;
73 const MAX: u8 = 5;
74
75 // repeat requests until we get a non-30 status
76 // or hit the redirection depth limit
77 loop {
78 if resp.status().reply_type() == ReplyType::Redirect && i < MAX {
79 resp = self.request_with_no_redirect(resp.message()).await?;
80 i += 1;
81 continue;
82 }
83 return Ok(resp);
84 }
85 }
86
87 /// Perform a Gemini request with the specified URL
88 /// **without** following redirections.
89 /// Host and port (1965 by default) are parsed from `url`
90 /// after scheme and userinfo checks.
91 /// On success, [`Response`] is returned.
92 ///
93 /// # Errors
94 /// - [`InvalidUrl::ParseError`] means that the given URL cannot be parsed.
95 /// - [`InvalidUrl::SchemeNotGemini`] is returned when a scheme is not `gemini://`,
96 /// for proxying requests use [`Client::request_with_host`].
97 /// - [`InvalidUrl::UserinfoPresent`] is returned when the given URL contains
98 /// a userinfo portion (`user:password@`) -- it is forbidden by the Gemini specification.
99 /// - See [`Client::request_with_host`] for the rest.
100 pub async fn request_with_no_redirect(
101 &self,
102 url: impl IntoUrl,
103 ) -> Result<ThisResponse, LibError> {
104 let url = url.into_url()?;
105
106 let host = url.host_str().ok_or(InvalidUrl::ConvertError)?;
107 let port = url.port().unwrap_or(1965);
108
109 self.request_with_host(url.as_str(), host, port).await
110 }
111
112 /// Perform a Gemini request with the specified host, port and URL.
113 /// Non-`gemini://` URLs is OK if the remote server supports proxying.
114 ///
115 /// # Errors
116 /// - [`InvalidUrl::ConvertError`] means that a hostname cannot be
117 /// converted into [`pki_types::ServerName`].
118 /// - [`LibError::HostLookupError`] means that a DNS server returned no records,
119 /// i. e. that domain does not exist.
120 /// - [`LibError::DnsClientError`] (crate feature `hickory`)
121 /// wraps a Hickory DNS client error related to a connection failure
122 /// or an invalid DNS server response.
123 /// - [`std::io::Error`] is returned in many cases:
124 /// could not open a TCP connection, perform a TLS handshake,
125 /// write to or read from the TCP stream.
126 /// Check the ErrorKind and/or the inner error
127 /// if you need to determine what exactly happened.
128 /// - [`LibError::StatusOutOfRange`] means that a Gemini server returned
129 /// an invalid status code (less than 10 or greater than 69).
130 /// - [`LibError::DataNotUtf8`] is returned when metadata (the text after a status code)
131 /// is not in UTF-8 and cannot be converted to a string without errors.
132 pub async fn request_with_host(
133 &self,
134 url_str: &str,
135 host: &str,
136 port: u16,
137 ) -> Result<ThisResponse, LibError> {
138 let domain = ServerName::try_from(host)
139 .map_err(|_| InvalidUrl::ConvertError)?
140 .to_owned();
141
142 // TCP connection
143 let stream = self.try_connect(host, port).await?;
144 // TLS connection via tokio-rustls
145 let stream = self.connector.connect(domain, stream).await?;
146
147 // certificate verification
148 if let Some(ssv) = &self.ss_verifier {
149 let cert = stream
150 .get_ref()
151 .1 // rustls::ClientConnection
152 .peer_certificates()
153 .unwrap() // i think handshake already completed if we awaited on connector.connect?
154 .first()
155 .ok_or(rustls::Error::NoCertificatesPresented)?;
156
157 if !ssv.verify(cert, host, port).await? {
158 return Err(rustls::CertificateError::ApplicationVerificationFailure.into());
159 }
160 }
161
162 self.perform_io(url_str, stream).await
163 }
164
165 pub(crate) async fn perform_io<IO: AsyncReadExt + AsyncWriteExt + Unpin>(
166 &self,
167 url_str: &str,
168 mut stream: IO,
169 ) -> Result<Response<BufReader<IO>>, LibError> {
170 // Write URL, then CRLF
171 stream.write_all(url_str.as_bytes()).await?;
172 stream.write_all(b"\r\n").await?;
173 stream.flush().await?;
174
175 let status = {
176 let mut buf: [u8; 3] = [0, 0, 0]; // 2 digits, space
177 stream.read_exact(&mut buf).await?;
178 Status::parse_status(&buf)?
179 };
180
181 let mut stream = BufReader::new(stream);
182
183 let message = {
184 let mut result: Vec<u8> = Vec::new();
185 let mut buf = [0u8]; // buffer for LF (\n)
186
187 // reading message after status code
188 // until CRLF (\r\n)
189 loop {
190 // until CR
191 stream.read_until(b'\r', &mut result).await?;
192 // now read next char...
193 stream.read_exact(&mut buf).await?;
194 if buf[0] == b'\n' {
195 // ...and check if it's LF
196 break;
197 } else {
198 // ...otherwise, CR is a part of message, not a CRLF terminator,
199 // so append that one byte that's supposed to be LF (but not LF)
200 // to the message buffer
201 result.push(buf[0]);
202 }
203 }
204
205 // trim last CR
206 if result.last().is_some_and(|c| c == &b'\r') {
207 result.pop();
208 }
209
210 // Vec<u8> -> ASCII or UTF-8 String
211 String::from_utf8(result)?
212 };
213
214 Ok(Response::new(status, message, stream))
215 }
216
217 async fn try_connect(&self, host: &str, port: u16) -> Result<TcpStream, LibError> {
218 let mut last_err: Option<std::io::Error> = None;
219
220 #[cfg(feature = "hickory")]
221 if let Some(dns) = &self.dns {
222 let mut dns = dns.clone();
223 let name = host.into_name()?;
224
225 for ip_addr in dns.query_ipv4(name.clone()).await? {
226 match TcpStream::connect(SocketAddr::new(ip_addr, port)).await {
227 Ok(stream) => {
228 return Ok(stream);
229 }
230 Err(err) => {
231 last_err = Some(err);
232 }
233 }
234 }
235
236 for ip_addr in dns.query_ipv6(name).await? {
237 match TcpStream::connect(SocketAddr::new(ip_addr, port)).await {
238 Ok(stream) => {
239 return Ok(stream);
240 }
241 Err(err) => {
242 last_err = Some(err);
243 }
244 }
245 }
246
247 if let Some(err) = last_err {
248 return Err(err.into());
249 }
250
251 return Err(LibError::HostLookupError);
252 }
253
254 for addr in tokio::net::lookup_host((host, port)).await? {
255 match TcpStream::connect(addr).await {
256 Ok(stream) => {
257 return Ok(stream);
258 }
259 Err(err) => {
260 last_err = Some(err);
261 }
262 }
263 }
264
265 if let Some(err) = last_err {
266 return Err(err.into());
267 }
268
269 Err(LibError::HostLookupError)
270 }
271}