Skip to content

Commit 9ac1739

Browse files
committed
feat: support adding http client root certificates for self-signed HTTPS nodes
1 parent 7011839 commit 9ac1739

File tree

4 files changed

+127
-7
lines changed

4 files changed

+127
-7
lines changed

typesense/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ trybuild = "1.0.42"
4848
# native-only dev deps
4949
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
5050
tokio = { workspace = true}
51+
tokio-rustls = "0.26"
52+
rcgen = "0.14"
5153
wiremock = "0.6"
5254

5355
# wasm test deps
@@ -64,4 +66,4 @@ required-features = ["derive"]
6466

6567
[[test]]
6668
name = "client"
67-
path = "tests/client/mod.rs"
69+
path = "tests/client/mod.rs"

typesense/src/client/mod.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ impl Client {
210210
/// - **healthcheck_interval**: 60 seconds.
211211
/// - **retry_policy**: Exponential backoff with a maximum of 3 retries. (disabled on WASM)
212212
/// - **connection_timeout**: 5 seconds. (disabled on WASM)
213+
/// - **additional_root_certificates**: None. (not available on WASM)
213214
#[builder]
214215
pub fn new(
215216
/// The Typesense API key used for authentication.
@@ -235,6 +236,11 @@ impl Client {
235236
#[builder(default = Duration::from_secs(5))]
236237
/// The timeout for each individual network request.
237238
connection_timeout: Duration,
239+
240+
#[cfg(not(target_arch = "wasm32"))]
241+
#[builder(default = vec![])]
242+
/// The list of custom headers to add to each request.
243+
additional_root_certificates: Vec<reqwest::Certificate>,
238244
) -> Result<Self, &'static str> {
239245
let is_nearest_node_set = nearest_node.is_some();
240246

@@ -248,12 +254,16 @@ impl Client {
248254
.expect("Failed to build reqwest client");
249255

250256
#[cfg(not(target_arch = "wasm32"))]
251-
let http_client = ReqwestMiddlewareClientBuilder::new(
252-
reqwest::Client::builder()
253-
.timeout(connection_timeout)
254-
.build()
255-
.expect("Failed to build reqwest client"),
256-
)
257+
let http_client = ReqwestMiddlewareClientBuilder::new({
258+
let builder = reqwest::Client::builder().timeout(connection_timeout);
259+
let builder = additional_root_certificates
260+
.iter()
261+
.fold(builder, |builder, certificate| {
262+
builder.add_root_certificate(certificate.clone())
263+
});
264+
265+
builder.build().expect("Failed to build reqwest client")
266+
})
257267
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
258268
.build();
259269

typesense/tests/client/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ mod operations_test;
1010
mod presets_test;
1111
mod stemming_dictionaries_test;
1212
mod stopwords_test;
13+
#[cfg(not(target_arch = "wasm32"))]
14+
mod tls_certificate_test;
1315

1416
use std::time::Duration;
1517
use typesense::{Client, ExponentialBackoff};
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
use std::{
2+
net::{IpAddr, Ipv4Addr},
3+
sync::Arc,
4+
time::Duration,
5+
};
6+
use tokio::{
7+
io::{AsyncReadExt, AsyncWriteExt as _},
8+
net::TcpListener,
9+
};
10+
use tokio_rustls::{
11+
TlsAcceptor,
12+
rustls::{
13+
self, ServerConfig,
14+
pki_types::{CertificateDer, PrivateKeyDer},
15+
},
16+
};
17+
use typesense::ExponentialBackoff;
18+
19+
#[tokio::test]
20+
async fn test_tls_certificate() {
21+
rustls::crypto::aws_lc_rs::default_provider()
22+
.install_default()
23+
.expect("Failed to install crypto provider");
24+
25+
let api_key = "xxx-api-key";
26+
27+
// generate a self-signed key pair and build TLS config out of it
28+
let (cert, key) = generate_self_signed_cert();
29+
let tls_config = ServerConfig::builder()
30+
.with_no_client_auth()
31+
.with_single_cert(vec![cert.clone()], key)
32+
.expect("failed to build TLS config");
33+
34+
let localhost = IpAddr::V4(Ipv4Addr::LOCALHOST);
35+
let listener = TcpListener::bind((localhost, 0))
36+
.await
37+
.expect("Failed to bind to address");
38+
let server_addr = listener.local_addr().expect("Failed to get local address");
39+
40+
// spawn a handler which handles one /health request over a TLS connection
41+
let handler = tokio::spawn(mock_node_handler(listener, tls_config, api_key));
42+
43+
let client_cert = reqwest::Certificate::from_der(&cert)
44+
.expect("Failed to convert certificate to Certificate");
45+
let client = typesense::Client::builder()
46+
.nodes(vec![format!("https://localhost:{}", server_addr.port())])
47+
.api_key(api_key)
48+
.additional_root_certificates(vec![client_cert])
49+
.healthcheck_interval(Duration::from_secs(9001)) // we'll do a healthcheck manually
50+
.retry_policy(ExponentialBackoff::builder().build_with_max_retries(0)) // no retries
51+
.connection_timeout(Duration::from_secs(1)) // short
52+
.build()
53+
.expect("Failed to create Typesense client");
54+
55+
// request /health
56+
client
57+
.operations()
58+
.health()
59+
.await
60+
.expect("Failed to get collection health");
61+
62+
handler.await.expect("Failed to join handler");
63+
}
64+
65+
fn generate_self_signed_cert() -> (CertificateDer<'static>, PrivateKeyDer<'static>) {
66+
let pair = rcgen::generate_simple_self_signed(["localhost".into()])
67+
.expect("Failed to generate self-signed certificate");
68+
let cert = pair.cert.der().clone();
69+
let signing_key = pair.signing_key.serialize_der();
70+
let signing_key = PrivateKeyDer::try_from(signing_key)
71+
.expect("Failed to convert signing key to PrivateKeyDer");
72+
(cert, signing_key)
73+
}
74+
75+
async fn mock_node_handler(listener: TcpListener, tls_config: ServerConfig, api_key: &'static str) {
76+
let tls_acceptor = TlsAcceptor::from(Arc::new(tls_config));
77+
let (stream, _addr) = listener
78+
.accept()
79+
.await
80+
.expect("Failed to accept connection");
81+
let mut stream = tls_acceptor
82+
.accept(stream)
83+
.await
84+
.expect("Failed to accept TLS connection");
85+
86+
let mut buf = vec![0u8; 1024];
87+
stream
88+
.read(&mut buf[..])
89+
.await
90+
.expect("Failed to read request");
91+
let request = String::from_utf8(buf).expect("Failed to parse request as UTF-8");
92+
assert!(request.contains("/health"));
93+
assert!(request.contains(api_key));
94+
95+
// mock a /health response
96+
let response = r#"HTTP/1.1 200 OK\r\n\
97+
Content-Type: application/json;\r\n\
98+
Connection: close\r\n
99+
100+
{"ok": true}"#;
101+
stream
102+
.write_all(&response.as_bytes())
103+
.await
104+
.expect("Failed to write to stream");
105+
stream.shutdown().await.expect("Failed to shutdown stream");
106+
}

0 commit comments

Comments
 (0)