Skip to content

Commit cd3f993

Browse files
authored
Add support for native root certs (#1782)
There currently is no way to connect to a given gRPC endpoint using TLS without manually providing a CA certificate file, certificate file, and/or key file. This causes issues when using managed certificates where it is not possible to connect via TLS to a secured endpoint unless the relevant secrets are distributed across workers and manually accessed via the config. The proposed changes add an option for the ClientTlsConfig of `use_native_roots`. This will ensure that tonic will use the root native certs for it's tls config. If the field use_native_roots is present ca, cert, and key files will be ignored if provided but are not required. This adds the tonic tls-native-roots feature. The ca_file field in the `nativelink_config::stores::ClientTlsConfig` is no longer required.
1 parent d53363d commit cd3f993

File tree

6 files changed

+234
-7
lines changed

6 files changed

+234
-7
lines changed

Cargo.lock

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nativelink-config/src/stores.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -858,16 +858,28 @@ pub enum StoreType {
858858
#[derive(Serialize, Deserialize, Debug, Clone)]
859859
pub struct ClientTlsConfig {
860860
/// Path to the certificate authority to use to validate the remote.
861-
#[serde(deserialize_with = "convert_string_with_shellexpand")]
862-
pub ca_file: String,
861+
///
862+
/// Default: None
863+
#[serde(default, deserialize_with = "convert_optional_string_with_shellexpand")]
864+
pub ca_file: Option<String>,
863865

864866
/// Path to the certificate file for client authentication.
865-
#[serde(deserialize_with = "convert_optional_string_with_shellexpand")]
867+
///
868+
/// Default: None
869+
#[serde(default, deserialize_with = "convert_optional_string_with_shellexpand")]
866870
pub cert_file: Option<String>,
867871

868872
/// Path to the private key file for client authentication.
869-
#[serde(deserialize_with = "convert_optional_string_with_shellexpand")]
873+
///
874+
/// Default: None
875+
#[serde(default, deserialize_with = "convert_optional_string_with_shellexpand")]
870876
pub key_file: Option<String>,
877+
878+
/// If set the client will use the native roots for TLS connections.
879+
///
880+
/// Default: false
881+
#[serde(default)]
882+
pub use_native_roots: Option<bool>,
871883
}
872884

873885
#[derive(Serialize, Deserialize, Debug, Clone)]

nativelink-util/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ rust_test_suite(
100100
"tests/proto_stream_utils_test.rs",
101101
"tests/resource_info_test.rs",
102102
"tests/retry_test.rs",
103+
"tests/tls_utils_test.rs",
103104
],
104105
compile_data = [
105106
"tests/data/SekienAkashita.jpg",
@@ -124,6 +125,7 @@ rust_test_suite(
124125
"@crates//:rand",
125126
"@crates//:serde_json",
126127
"@crates//:sha2",
128+
"@crates//:tempfile",
127129
"@crates//:tokio",
128130
"@crates//:tokio-stream",
129131
"@crates//:tokio-util",

nativelink-util/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ rand = { version = "0.9.0", default-features = false, features = [
5151
rlimit = { version = "0.10.2", default-features = false }
5252
serde = { version = "1.0.219", default-features = false }
5353
sha2 = { version = "0.10.8", default-features = false }
54+
tempfile = { version = "3.20.0", default-features = false }
5455
tokio = { version = "1.44.1", features = [
5556
"fs",
5657
"io-util",
@@ -62,6 +63,7 @@ tokio-stream = { version = "0.1.17", features = [
6263
], default-features = false }
6364
tokio-util = { version = "0.7.14" }
6465
tonic = { version = "0.13.0", features = [
66+
"tls-native-roots",
6567
"tls-ring",
6668
"transport",
6769
], default-features = false }

nativelink-util/src/tls_utils.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use nativelink_config::stores::{ClientTlsConfig, GrpcEndpoint};
1616
use nativelink_error::{Code, Error, make_err, make_input_err};
1717
use tonic::transport::Uri;
18+
use tracing::warn;
1819

1920
pub fn load_client_config(
2021
config: &Option<ClientTlsConfig>,
@@ -23,8 +24,24 @@ pub fn load_client_config(
2324
return Ok(None);
2425
};
2526

27+
if config.use_native_roots == Some(true) {
28+
if config.ca_file.is_some() {
29+
warn!("Native root certificates are being used, all certificate files will be ignored");
30+
}
31+
return Ok(Some(
32+
tonic::transport::ClientTlsConfig::new().with_native_roots(),
33+
));
34+
}
35+
36+
let Some(ca_file) = &config.ca_file else {
37+
return Err(make_err!(
38+
Code::Internal,
39+
"CA certificate must be provided if not using native root certificates"
40+
));
41+
};
42+
2643
let read_config = tonic::transport::ClientTlsConfig::new().ca_certificate(
27-
tonic::transport::Certificate::from_pem(std::fs::read_to_string(&config.ca_file)?),
44+
tonic::transport::Certificate::from_pem(std::fs::read_to_string(ca_file)?),
2845
);
2946
let config = if let Some(client_certificate) = &config.cert_file {
3047
let Some(client_key) = &config.key_file else {
@@ -93,6 +110,11 @@ pub fn endpoint_from(
93110
.tls_config(tls_config)
94111
.map_err(|e| make_input_err!("Setting mTLS configuration: {e:?}"))?
95112
} else {
113+
if endpoint.scheme_str() == Some("https") {
114+
return Err(make_input_err!(
115+
"The scheme of {endpoint} is https or grpcs, but no TLS configuration was provided"
116+
));
117+
}
96118
tonic::transport::Endpoint::from(endpoint)
97119
};
98120

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright 2025 The NativeLink Authors. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use nativelink_config::stores::ClientTlsConfig;
16+
use nativelink_error::Error;
17+
use nativelink_macro::nativelink_test;
18+
use nativelink_util::tls_utils::{endpoint_from, load_client_config};
19+
use tempfile::NamedTempFile;
20+
21+
#[nativelink_test]
22+
async fn test_load_client_config_none() -> Result<(), Error> {
23+
let config = load_client_config(&None)?;
24+
assert!(config.is_none());
25+
Ok(())
26+
}
27+
28+
#[nativelink_test]
29+
async fn test_load_client_config_native_roots() -> Result<(), Error> {
30+
let config = load_client_config(&Some(ClientTlsConfig {
31+
use_native_roots: Some(true),
32+
ca_file: None,
33+
cert_file: None,
34+
key_file: None,
35+
}))?;
36+
assert!(config.is_some());
37+
Ok(())
38+
}
39+
40+
#[nativelink_test]
41+
async fn test_load_client_config_missing_ca() -> Result<(), Error> {
42+
let result = load_client_config(&Some(ClientTlsConfig {
43+
use_native_roots: None,
44+
ca_file: None,
45+
cert_file: None,
46+
key_file: None,
47+
}));
48+
assert!(matches!(
49+
result,
50+
Err(e) if e.to_string().contains("CA certificate must be provided")
51+
));
52+
Ok(())
53+
}
54+
55+
#[nativelink_test]
56+
async fn test_load_client_config_cert_without_key() -> Result<(), Error> {
57+
let temp_file = NamedTempFile::new()?;
58+
let result = load_client_config(&Some(ClientTlsConfig {
59+
use_native_roots: None,
60+
ca_file: Some(temp_file.path().to_str().unwrap().to_string()),
61+
cert_file: Some("tls.crt".to_string()),
62+
key_file: None,
63+
}));
64+
assert!(matches!(
65+
result,
66+
Err(e) if e.to_string().contains("Client certificate specified, but no key")
67+
));
68+
Ok(())
69+
}
70+
71+
#[nativelink_test]
72+
async fn test_load_client_config_key_without_cert() -> Result<(), Error> {
73+
let temp_file = NamedTempFile::new()?;
74+
let result = load_client_config(&Some(ClientTlsConfig {
75+
use_native_roots: None,
76+
ca_file: Some(temp_file.path().to_str().unwrap().to_string()),
77+
cert_file: None,
78+
key_file: Some("tls.key".to_string()),
79+
}));
80+
assert!(matches!(
81+
result,
82+
Err(e) if e.to_string().contains("Client key specified, but no certificate")
83+
));
84+
Ok(())
85+
}
86+
87+
#[nativelink_test]
88+
async fn test_load_client_config_with_cert_files() -> Result<(), Error> {
89+
let temp_file = NamedTempFile::new()?;
90+
let config = load_client_config(&Some(ClientTlsConfig {
91+
use_native_roots: None,
92+
ca_file: Some(temp_file.path().to_str().unwrap().to_string()),
93+
cert_file: Some(temp_file.path().to_str().unwrap().to_string()),
94+
key_file: Some(temp_file.path().to_str().unwrap().to_string()),
95+
}))?;
96+
assert!(config.is_some());
97+
Ok(())
98+
}
99+
100+
#[nativelink_test]
101+
async fn test_endpoint_from_http() -> Result<(), Error> {
102+
let endpoint = endpoint_from("http://localhost:50051", None)?;
103+
assert_eq!(endpoint.uri().scheme_str(), Some("http"));
104+
assert_eq!(endpoint.uri().host(), Some("localhost"));
105+
assert_eq!(endpoint.uri().port_u16(), Some(50051));
106+
Ok(())
107+
}
108+
109+
#[nativelink_test]
110+
async fn test_endpoint_from_https_with_tls() -> Result<(), Error> {
111+
let tls_config = load_client_config(&Some(ClientTlsConfig {
112+
use_native_roots: Some(true),
113+
ca_file: None,
114+
cert_file: None,
115+
key_file: None,
116+
}))?;
117+
let endpoint = endpoint_from("https://example.com", tls_config)?;
118+
assert_eq!(endpoint.uri().scheme_str(), Some("https"));
119+
assert_eq!(endpoint.uri().host(), Some("example.com"));
120+
Ok(())
121+
}
122+
123+
#[nativelink_test]
124+
async fn test_endpoint_from_grpcs_with_tls() -> Result<(), Error> {
125+
let tls_config = load_client_config(&Some(ClientTlsConfig {
126+
use_native_roots: Some(true),
127+
ca_file: None,
128+
cert_file: None,
129+
key_file: None,
130+
}))?;
131+
let endpoint = endpoint_from("grpcs://example.com", tls_config)?;
132+
assert_eq!(endpoint.uri().scheme_str(), Some("https"));
133+
assert_eq!(endpoint.uri().host(), Some("example.com"));
134+
Ok(())
135+
}
136+
137+
#[nativelink_test]
138+
async fn test_endpoint_from_https_without_tls() -> Result<(), Error> {
139+
let result = endpoint_from("https://example.com", None);
140+
assert!(matches!(
141+
result,
142+
Err(e) if e.to_string().contains("is https or grpcs, but no TLS configuration was provided")
143+
));
144+
Ok(())
145+
}
146+
147+
#[nativelink_test]
148+
async fn test_endpoint_from_http_with_tls() -> Result<(), Error> {
149+
let tls_config = load_client_config(&Some(ClientTlsConfig {
150+
use_native_roots: Some(true),
151+
ca_file: None,
152+
cert_file: None,
153+
key_file: None,
154+
}))?;
155+
let result = endpoint_from("http://example.com:8080", tls_config);
156+
assert!(matches!(
157+
result,
158+
Err(e) if e.to_string().contains("but the scheme is not https or grpcs")
159+
));
160+
Ok(())
161+
}
162+
163+
#[nativelink_test]
164+
async fn test_endpoint_from_invalid_uri() -> Result<(), Error> {
165+
let result = endpoint_from("not a valid uri", None);
166+
assert!(matches!(
167+
result,
168+
Err(e) if e.to_string().contains("Unable to parse endpoint")
169+
));
170+
Ok(())
171+
}
172+
173+
#[nativelink_test]
174+
async fn test_endpoint_from_missing_authority() -> Result<(), Error> {
175+
let tls_config = load_client_config(&Some(ClientTlsConfig {
176+
use_native_roots: Some(true),
177+
ca_file: None,
178+
cert_file: None,
179+
key_file: None,
180+
}))?;
181+
let result = endpoint_from("/path/no/authority", tls_config);
182+
assert!(matches!(
183+
result,
184+
Err(e) if e.to_string().contains("Unable to determine authority of endpoint")
185+
));
186+
Ok(())
187+
}

0 commit comments

Comments
 (0)