Skip to content

Commit ce00f88

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

File tree

4 files changed

+123
-7
lines changed

4 files changed

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

0 commit comments

Comments
 (0)