Skip to content

Commit 74a18b8

Browse files
feat: add mTLS client certificate support (#72)
1 parent 4a40106 commit 74a18b8

5 files changed

Lines changed: 217 additions & 49 deletions

File tree

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@ bytes = "1.6.0"
1414
prost = { version = "0.14.1", features = ["derive"] }
1515
prost-types = "0.14.1"
1616
rustls = "0.23.5"
17-
tokio = { version = "1.37.0", features = ["rt-multi-thread"] }
17+
tokio = { version = "1.37.0", features = ["rt-multi-thread", "fs"] }
1818
rustls-native-certs = "0.8.1"
1919
tonic = { version = "0.14", default-features = false, features = [
2020
"transport",
2121
"tls-ring",
2222
"tls-native-roots",
2323
] }
2424
rustls-pemfile = "1.0.4"
25-
reqwest = { version = "0.12.4", features = ["json"] }
25+
reqwest = { version = "0.12.4", features = ["json", "rustls-tls"] }
2626
serde = { version = "1.0", features = ["derive"] }
2727
serde_json = "1.0.116"
2828
chrono = { version = "0.4.41", features = ["serde"] }

src/client.rs

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use crate::util::{FibonacciBackoffBuilder, RetryError, retry};
44
use crate::{
55
config::{GenericError, SPICE_CLOUD_FLIGHT_ADDR, SPICE_LOCAL_FLIGHT_ADDR},
66
flight::{SqlFlightClient, is_connection_reset_generic_error},
7-
tls::{ensure_crypto_provider, new_tls_flight_channel},
7+
tls::{FlightChannelBuilder, ensure_crypto_provider, new_tls_flight_channel},
88
};
99
use arrow::record_batch::RecordBatch;
1010
use arrow_flight::error::FlightError;
@@ -408,6 +408,9 @@ pub struct SpiceClientBuilder {
408408
http_url: Option<String>,
409409
cache_control: Option<String>,
410410
max_retries: u32,
411+
tls_client_certificate_file: Option<String>,
412+
tls_client_key_file: Option<String>,
413+
tls_ca_certificate_file: Option<String>,
411414
}
412415

413416
impl Default for SpiceClientBuilder {
@@ -426,6 +429,9 @@ impl SpiceClientBuilder {
426429
http_url: None,
427430
cache_control: None,
428431
max_retries: MAX_RETRIES,
432+
tls_client_certificate_file: None,
433+
tls_client_key_file: None,
434+
tls_ca_certificate_file: None,
429435
}
430436
}
431437

@@ -483,21 +489,88 @@ impl SpiceClientBuilder {
483489
self
484490
}
485491

492+
/// Sets the path to a PEM-encoded client certificate file for mTLS.
493+
/// Must be used together with [`tls_client_key_file`](Self::tls_client_key_file).
494+
#[must_use]
495+
pub fn tls_client_certificate_file(mut self, path: &str) -> Self {
496+
self.tls_client_certificate_file = Some(path.to_string());
497+
self
498+
}
499+
500+
/// Sets the path to a PEM-encoded client private key file for mTLS.
501+
/// Must be used together with [`tls_client_certificate_file`](Self::tls_client_certificate_file).
502+
#[must_use]
503+
pub fn tls_client_key_file(mut self, path: &str) -> Self {
504+
self.tls_client_key_file = Some(path.to_string());
505+
self
506+
}
507+
508+
/// Sets the path to a custom CA certificate file for server verification.
509+
/// When set, this CA is used instead of the system certificate store.
510+
#[must_use]
511+
pub fn tls_ca_certificate_file(mut self, path: &str) -> Self {
512+
self.tls_ca_certificate_file = Some(path.to_string());
513+
self
514+
}
515+
486516
/// Builds the `SpiceClient` with the specified configuration.
487517
///
488518
/// ## Errors
489519
///
490520
/// - `Box<dyn Error + Send + Sync>` if flight channel creation fails
491521
pub async fn build(self) -> Result<SpiceClient, GenericError> {
492522
ensure_crypto_provider();
493-
let flight_channel = match self.flight_url {
494-
Some(url) => new_tls_flight_channel(&url).await?,
495-
None => new_tls_flight_channel(SPICE_LOCAL_FLIGHT_ADDR).await?,
496-
};
497523

498-
let http_client = self
499-
.http_url
500-
.map(|url| Arc::new(QueryHttpClient::new(&url, self.api_key.clone())));
524+
// Validate that client cert and key are either both set or both unset
525+
match (&self.tls_client_certificate_file, &self.tls_client_key_file) {
526+
(Some(_), None) => {
527+
return Err("tls_client_certificate_file is set but tls_client_key_file is missing; both must be provided together for mTLS".into());
528+
}
529+
(None, Some(_)) => {
530+
return Err("tls_client_key_file is set but tls_client_certificate_file is missing; both must be provided together for mTLS".into());
531+
}
532+
_ => {}
533+
}
534+
535+
let url = self
536+
.flight_url
537+
.as_deref()
538+
.unwrap_or(SPICE_LOCAL_FLIGHT_ADDR);
539+
540+
let mut channel_builder = FlightChannelBuilder::new(url);
541+
if let (Some(cert), Some(key)) =
542+
(&self.tls_client_certificate_file, &self.tls_client_key_file)
543+
{
544+
channel_builder = channel_builder.with_client_certificate(cert, key);
545+
}
546+
if let Some(ca) = &self.tls_ca_certificate_file {
547+
channel_builder = channel_builder.with_ca_certificate(ca);
548+
}
549+
let flight_channel = channel_builder.build().await?;
550+
551+
let http_client = if let Some(url) = self.http_url {
552+
let mut builder = reqwest::Client::builder();
553+
if let (Some(cert_path), Some(key_path)) =
554+
(&self.tls_client_certificate_file, &self.tls_client_key_file)
555+
{
556+
let cert_pem = tokio::fs::read(cert_path).await?;
557+
let key_pem = tokio::fs::read(key_path).await?;
558+
let identity = reqwest::Identity::from_pem(&[cert_pem, key_pem].concat())?;
559+
builder = builder.identity(identity);
560+
}
561+
if let Some(ca_path) = &self.tls_ca_certificate_file {
562+
let ca_pem = tokio::fs::read(ca_path).await?;
563+
let ca = reqwest::Certificate::from_pem(&ca_pem)?;
564+
builder = builder.add_root_certificate(ca);
565+
}
566+
Some(Arc::new(QueryHttpClient::with_client(
567+
builder.build()?,
568+
&url,
569+
self.api_key.clone(),
570+
)))
571+
} else {
572+
None
573+
};
501574

502575
Ok(SpiceClient {
503576
flight: Arc::new(SqlFlightClient::new(

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ mod client;
44
mod config;
55
mod flight;
66
pub mod query;
7-
mod tls;
7+
pub mod tls;
88
mod util;
99

1010
pub use client::Error as SpiceClientError;

src/query.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,8 +394,12 @@ pub(crate) struct QueryHttpClient {
394394

395395
impl QueryHttpClient {
396396
pub fn new(base_url: &str, api_key: Option<String>) -> Self {
397+
Self::with_client(reqwest::Client::new(), base_url, api_key)
398+
}
399+
400+
pub fn with_client(client: reqwest::Client, base_url: &str, api_key: Option<String>) -> Self {
397401
Self {
398-
client: reqwest::Client::new(),
402+
client,
399403
base_url: base_url.trim_end_matches('/').to_string(),
400404
api_key,
401405
}

0 commit comments

Comments
 (0)