Skip to content

Commit 2214bd5

Browse files
committed
feat: add TLS configuration support to FFI engine
Add TlsConfig struct with support for custom CA certificates, insecure skip verify, and client certificates for mutual TLS. Fixes #1132
1 parent b2c3618 commit 2214bd5

File tree

4 files changed

+364
-0
lines changed

4 files changed

+364
-0
lines changed

flipt-engine-ffi/src/http.rs

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ use tokio_util::io::StreamReader;
1616
use fliptevaluation::error::Error;
1717
use fliptevaluation::models::source;
1818

19+
use crate::TlsConfig;
20+
use base64::prelude::BASE64_STANDARD;
21+
use base64::Engine as Base64Engine;
22+
1923
#[derive(Debug, Clone, Default, Deserialize)]
2024
#[cfg_attr(test, derive(PartialEq))]
2125
#[serde(rename_all = "snake_case")]
@@ -111,6 +115,7 @@ pub struct HTTPFetcherBuilder {
111115
request_timeout: Option<Duration>,
112116
update_interval: Duration,
113117
mode: FetchMode,
118+
tls_config: Option<TlsConfig>,
114119
}
115120

116121
#[derive(Deserialize)]
@@ -136,6 +141,7 @@ impl HTTPFetcherBuilder {
136141
request_timeout: None,
137142
update_interval: Duration::from_secs(120),
138143
mode: FetchMode::default(),
144+
tls_config: None,
139145
}
140146
}
141147

@@ -174,6 +180,11 @@ impl HTTPFetcherBuilder {
174180
self
175181
}
176182

183+
pub fn tls_config(mut self, tls_config: TlsConfig) -> Self {
184+
self.tls_config = Some(tls_config);
185+
self
186+
}
187+
177188
pub fn build(self) -> Result<HTTPFetcher, Error> {
178189
let retry_policy = ExponentialBackoff::builder()
179190
.retry_bounds(Duration::from_secs(1), Duration::from_secs(30))
@@ -205,6 +216,11 @@ impl HTTPFetcherBuilder {
205216
}
206217
}
207218

219+
// Apply TLS configuration if provided
220+
if let Some(tls_config) = &self.tls_config {
221+
client_builder = configure_tls(client_builder, tls_config)?;
222+
}
223+
208224
let client = client_builder
209225
.build()
210226
.map_err(|e| Error::Internal(format!("failed to create client: {e}")))?;
@@ -476,6 +492,65 @@ impl HTTPFetcher {
476492
}
477493
}
478494

495+
fn configure_tls(
496+
mut builder: reqwest::ClientBuilder,
497+
tls_config: &TlsConfig,
498+
) -> Result<reqwest::ClientBuilder, Error> {
499+
// Handle insecure mode
500+
if tls_config.insecure_skip_verify.unwrap_or(false) {
501+
builder = builder.danger_accept_invalid_certs(true);
502+
}
503+
504+
// Handle custom CA certificates
505+
if let Some(ca_cert_data) = &tls_config.ca_cert_data {
506+
let cert_bytes = BASE64_STANDARD
507+
.decode(ca_cert_data)
508+
.map_err(|e| Error::Internal(format!("Invalid CA cert data: {e}")))?;
509+
let cert = reqwest::Certificate::from_pem(&cert_bytes)
510+
.map_err(|e| Error::Internal(format!("Invalid CA certificate: {e}")))?;
511+
builder = builder.add_root_certificate(cert);
512+
} else if let Some(ca_cert_file) = &tls_config.ca_cert_file {
513+
let cert_bytes = std::fs::read(ca_cert_file)
514+
.map_err(|e| Error::Internal(format!("Failed to read CA cert file: {e}")))?;
515+
let cert = reqwest::Certificate::from_pem(&cert_bytes)
516+
.map_err(|e| Error::Internal(format!("Invalid CA certificate file: {e}")))?;
517+
builder = builder.add_root_certificate(cert);
518+
}
519+
520+
// Handle client certificates for mutual TLS
521+
if let (Some(cert_data), Some(key_data)) = (
522+
&tls_config.client_cert_data,
523+
&tls_config.client_key_data,
524+
) {
525+
let cert_bytes = BASE64_STANDARD
526+
.decode(cert_data)
527+
.map_err(|e| Error::Internal(format!("Invalid client cert data: {e}")))?;
528+
let key_bytes = BASE64_STANDARD
529+
.decode(key_data)
530+
.map_err(|e| Error::Internal(format!("Invalid client key data: {e}")))?;
531+
let mut combined = cert_bytes.clone();
532+
combined.extend_from_slice(&key_bytes);
533+
let identity = reqwest::Identity::from_pem(&combined)
534+
.map_err(|e| Error::Internal(format!("Invalid client certificate: {e}")))?;
535+
builder = builder.identity(identity);
536+
} else if let (Some(cert_file), Some(key_file)) = (
537+
&tls_config.client_cert_file,
538+
&tls_config.client_key_file,
539+
) {
540+
let cert_bytes = std::fs::read(cert_file)
541+
.map_err(|e| Error::Internal(format!("Failed to read client cert file: {e}")))?;
542+
let key_bytes = std::fs::read(key_file)
543+
.map_err(|e| Error::Internal(format!("Failed to read client key file: {e}")))?;
544+
let mut combined = cert_bytes.clone();
545+
combined.extend_from_slice(&key_bytes);
546+
let identity = reqwest::Identity::from_pem(&combined)
547+
.map_err(|e| Error::Internal(format!("Invalid client certificate files: {e}")))?;
548+
builder = builder.identity(identity);
549+
}
550+
551+
Ok(builder)
552+
}
553+
479554
#[cfg(test)]
480555
mod tests {
481556
use futures::FutureExt;
@@ -484,6 +559,10 @@ mod tests {
484559
use crate::http::Authentication;
485560
use crate::http::FetchMode;
486561
use crate::http::HTTPFetcherBuilder;
562+
use crate::http::configure_tls;
563+
use crate::TlsConfig;
564+
use base64::prelude::BASE64_STANDARD;
565+
use base64::Engine as Base64Engine;
487566
use tokio::sync::mpsc;
488567

489568
#[tokio::test]
@@ -774,4 +853,171 @@ mod tests {
774853

775854
assert_eq!(unwrapped_string, Authentication::JwtToken("secret".into()));
776855
}
856+
857+
#[test]
858+
fn test_tls_config_insecure_skip_verify() {
859+
let tls_config = TlsConfig {
860+
insecure_skip_verify: Some(true),
861+
ca_cert_file: None,
862+
ca_cert_data: None,
863+
client_cert_file: None,
864+
client_key_file: None,
865+
client_cert_data: None,
866+
client_key_data: None,
867+
};
868+
869+
let builder = reqwest::Client::builder();
870+
let result = configure_tls(builder, &tls_config);
871+
assert!(result.is_ok());
872+
}
873+
874+
#[test]
875+
fn test_tls_config_custom_ca_cert_data() {
876+
// Use the existing localhost.crt for testing
877+
let cert_pem = include_str!("testdata/localhost.crt");
878+
let cert_b64 = BASE64_STANDARD.encode(cert_pem);
879+
880+
let tls_config = TlsConfig {
881+
ca_cert_data: Some(cert_b64),
882+
insecure_skip_verify: None,
883+
ca_cert_file: None,
884+
client_cert_file: None,
885+
client_key_file: None,
886+
client_cert_data: None,
887+
client_key_data: None,
888+
};
889+
890+
let builder = reqwest::Client::builder();
891+
let result = configure_tls(builder, &tls_config);
892+
assert!(result.is_ok());
893+
}
894+
895+
#[test]
896+
fn test_tls_config_custom_ca_cert_file() {
897+
let tls_config = TlsConfig {
898+
ca_cert_file: Some("src/testdata/localhost.crt".to_string()),
899+
insecure_skip_verify: None,
900+
ca_cert_data: None,
901+
client_cert_file: None,
902+
client_key_file: None,
903+
client_cert_data: None,
904+
client_key_data: None,
905+
};
906+
907+
let builder = reqwest::Client::builder();
908+
let result = configure_tls(builder, &tls_config);
909+
assert!(result.is_ok());
910+
}
911+
912+
#[test]
913+
fn test_tls_config_client_certificates_data() {
914+
let cert_pem = include_str!("testdata/localhost.crt");
915+
let key_pem = include_str!("testdata/localhost.key");
916+
let cert_b64 = BASE64_STANDARD.encode(cert_pem);
917+
let key_b64 = BASE64_STANDARD.encode(key_pem);
918+
919+
let tls_config = TlsConfig {
920+
client_cert_data: Some(cert_b64),
921+
client_key_data: Some(key_b64),
922+
insecure_skip_verify: None,
923+
ca_cert_file: None,
924+
ca_cert_data: None,
925+
client_cert_file: None,
926+
client_key_file: None,
927+
};
928+
929+
let builder = reqwest::Client::builder();
930+
let result = configure_tls(builder, &tls_config);
931+
assert!(result.is_ok());
932+
}
933+
934+
#[test]
935+
fn test_tls_config_client_certificates_files() {
936+
let tls_config = TlsConfig {
937+
client_cert_file: Some("src/testdata/localhost.crt".to_string()),
938+
client_key_file: Some("src/testdata/localhost.key".to_string()),
939+
insecure_skip_verify: None,
940+
ca_cert_file: None,
941+
ca_cert_data: None,
942+
client_cert_data: None,
943+
client_key_data: None,
944+
};
945+
946+
let builder = reqwest::Client::builder();
947+
let result = configure_tls(builder, &tls_config);
948+
assert!(result.is_ok());
949+
}
950+
951+
#[test]
952+
fn test_tls_config_invalid_ca_cert_data() {
953+
let tls_config = TlsConfig {
954+
ca_cert_data: Some("invalid_base64".to_string()),
955+
insecure_skip_verify: None,
956+
ca_cert_file: None,
957+
client_cert_file: None,
958+
client_key_file: None,
959+
client_cert_data: None,
960+
client_key_data: None,
961+
};
962+
963+
let builder = reqwest::Client::builder();
964+
let result = configure_tls(builder, &tls_config);
965+
assert!(result.is_err());
966+
assert!(result.unwrap_err().to_string().contains("Invalid CA cert data"));
967+
}
968+
969+
#[test]
970+
fn test_tls_config_invalid_ca_cert_file() {
971+
let tls_config = TlsConfig {
972+
ca_cert_file: Some("nonexistent.crt".to_string()),
973+
insecure_skip_verify: None,
974+
ca_cert_data: None,
975+
client_cert_file: None,
976+
client_key_file: None,
977+
client_cert_data: None,
978+
client_key_data: None,
979+
};
980+
981+
let builder = reqwest::Client::builder();
982+
let result = configure_tls(builder, &tls_config);
983+
assert!(result.is_err());
984+
assert!(result.unwrap_err().to_string().contains("Failed to read CA cert file"));
985+
}
986+
987+
#[test]
988+
fn test_tls_config_combined_options() {
989+
let cert_pem = include_str!("testdata/localhost.crt");
990+
let cert_b64 = BASE64_STANDARD.encode(cert_pem);
991+
992+
let tls_config = TlsConfig {
993+
ca_cert_data: Some(cert_b64),
994+
insecure_skip_verify: Some(true),
995+
ca_cert_file: None,
996+
client_cert_file: None,
997+
client_key_file: None,
998+
client_cert_data: None,
999+
client_key_data: None,
1000+
};
1001+
1002+
let builder = reqwest::Client::builder();
1003+
let result = configure_tls(builder, &tls_config);
1004+
assert!(result.is_ok());
1005+
}
1006+
1007+
#[test]
1008+
fn test_tls_config_empty() {
1009+
let tls_config = TlsConfig {
1010+
insecure_skip_verify: None,
1011+
ca_cert_file: None,
1012+
ca_cert_data: None,
1013+
client_cert_file: None,
1014+
client_key_file: None,
1015+
client_cert_data: None,
1016+
client_key_data: None,
1017+
};
1018+
1019+
let builder = reqwest::Client::builder();
1020+
let result = configure_tls(builder, &tls_config);
1021+
assert!(result.is_ok());
1022+
}
7771023
}

flipt-engine-ffi/src/lib.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,25 @@ pub struct EngineOpts {
9999
reference: Option<String>,
100100
error_strategy: Option<ErrorStrategy>,
101101
snapshot: Option<String>,
102+
tls_config: Option<TlsConfig>,
103+
}
104+
105+
#[derive(Deserialize, Debug, PartialEq)]
106+
pub struct TlsConfig {
107+
/// Path to custom CA certificate file (PEM format)
108+
ca_cert_file: Option<String>,
109+
/// Raw CA certificate content (PEM format, base64 encoded for JSON)
110+
ca_cert_data: Option<String>,
111+
/// Skip certificate verification (insecure - for development only)
112+
insecure_skip_verify: Option<bool>,
113+
/// Client certificate file for mutual TLS (PEM format)
114+
client_cert_file: Option<String>,
115+
/// Client key file for mutual TLS (PEM format)
116+
client_key_file: Option<String>,
117+
/// Raw client certificate content (PEM format, base64 encoded for JSON)
118+
client_cert_data: Option<String>,
119+
/// Raw client key content (PEM format, base64 encoded for JSON)
120+
client_key_data: Option<String>,
102121
}
103122

104123
impl Default for EngineOpts {
@@ -114,6 +133,7 @@ impl Default for EngineOpts {
114133
fetch_mode: Some(FetchMode::default()),
115134
error_strategy: Some(ErrorStrategy::Fail),
116135
snapshot: None,
136+
tls_config: None,
117137
}
118138
}
119139
}
@@ -706,6 +726,10 @@ unsafe extern "C" fn _initialize_engine(opts: *const c_char) -> *mut c_void {
706726
fetcher_builder = fetcher_builder.reference(reference);
707727
}
708728

729+
if let Some(tls_config) = engine_opts.tls_config {
730+
fetcher_builder = fetcher_builder.tls_config(tls_config);
731+
}
732+
709733
let fetcher = fetcher_builder.build().unwrap();
710734

711735
let evaluator = Evaluator::new(&namespace);
@@ -1037,5 +1061,52 @@ mod tests {
10371061
assert_eq!(opts.fetch_mode, Some(FetchMode::default()));
10381062
assert_eq!(opts.error_strategy, Some(ErrorStrategy::Fail));
10391063
assert_eq!(opts.snapshot, None);
1064+
assert_eq!(opts.tls_config, None);
1065+
}
1066+
1067+
#[test]
1068+
fn test_engine_opts_with_tls_config() {
1069+
let json = r#"{
1070+
"url": "https://localhost:8443",
1071+
"tls_config": {
1072+
"ca_cert_file": "/path/to/ca.crt",
1073+
"insecure_skip_verify": true
1074+
}
1075+
}"#;
1076+
1077+
let opts: EngineOpts = serde_json::from_str(json).unwrap();
1078+
assert_eq!(opts.url, Some("https://localhost:8443".to_string()));
1079+
1080+
let tls_config = opts.tls_config.unwrap();
1081+
assert_eq!(tls_config.ca_cert_file, Some("/path/to/ca.crt".to_string()));
1082+
assert_eq!(tls_config.insecure_skip_verify, Some(true));
1083+
assert_eq!(tls_config.ca_cert_data, None);
1084+
assert_eq!(tls_config.client_cert_file, None);
1085+
assert_eq!(tls_config.client_key_file, None);
1086+
assert_eq!(tls_config.client_cert_data, None);
1087+
assert_eq!(tls_config.client_key_data, None);
1088+
}
1089+
1090+
#[test]
1091+
fn test_engine_opts_with_client_certificates() {
1092+
let json = r#"{
1093+
"url": "https://localhost:8443",
1094+
"tls_config": {
1095+
"client_cert_data": "Y2VydGRhdGE=",
1096+
"client_key_data": "a2V5ZGF0YQ=="
1097+
}
1098+
}"#;
1099+
1100+
let opts: EngineOpts = serde_json::from_str(json).unwrap();
1101+
assert_eq!(opts.url, Some("https://localhost:8443".to_string()));
1102+
1103+
let tls_config = opts.tls_config.unwrap();
1104+
assert_eq!(tls_config.client_cert_data, Some("Y2VydGRhdGE=".to_string()));
1105+
assert_eq!(tls_config.client_key_data, Some("a2V5ZGF0YQ==".to_string()));
1106+
assert_eq!(tls_config.insecure_skip_verify, None);
1107+
assert_eq!(tls_config.ca_cert_file, None);
1108+
assert_eq!(tls_config.ca_cert_data, None);
1109+
assert_eq!(tls_config.client_cert_file, None);
1110+
assert_eq!(tls_config.client_key_file, None);
10401111
}
10411112
}

0 commit comments

Comments
 (0)