| 11 |
11 |
|
use crate::config::TlsConfig;
|
| 12 |
12 |
|
use crate::types::TlsStatus;
|
| 13 |
13 |
|
|
| 14 |
|
- |
/// Connect to host:port, complete TLS handshake, and extract leaf cert fields.
|
| 15 |
|
- |
#[instrument(skip_all)]
|
| 16 |
|
- |
pub async fn check_tls(target_name: &str, config: &TlsConfig) -> TlsStatus {
|
| 17 |
|
- |
let checked_at = chrono::Utc::now().to_rfc3339();
|
| 18 |
|
- |
let addr = format!("{}:{}", config.host, config.port);
|
|
14 |
+ |
/// Trust stores we probe against. Both are checked on every run: they answer
|
|
15 |
+ |
/// different questions and can legitimately disagree, so reporting only one
|
|
16 |
+ |
/// would hide a real failure mode.
|
|
17 |
+ |
///
|
|
18 |
+ |
/// - [`TrustStore::Webpki`] is the bundled Mozilla root set. It answers "does
|
|
19 |
+ |
/// this chain validate against the public web PKI?", independent of whatever
|
|
20 |
+ |
/// the monitoring host happens to trust.
|
|
21 |
+ |
/// - [`TrustStore::Platform`] is the OS trust store. It answers "does this chain
|
|
22 |
+ |
/// validate for a client on this machine?", which additionally reflects
|
|
23 |
+ |
/// enterprise roots, admin-installed CAs, and OS-level distrust decisions.
|
|
24 |
+ |
///
|
|
25 |
+ |
/// A chain trusted by one and rejected by the other is exactly the kind of drift
|
|
26 |
+ |
/// worth alerting on, so neither result is derived from the other.
|
|
27 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
28 |
+ |
enum TrustStore {
|
|
29 |
+ |
Webpki,
|
|
30 |
+ |
Platform,
|
|
31 |
+ |
}
|
|
32 |
+ |
|
|
33 |
+ |
/// The crypto provider backing both probes. `main` installs aws-lc-rs as the
|
|
34 |
+ |
/// process default; fall back to constructing it directly so the checks still
|
|
35 |
+ |
/// work if that install is ever removed or reordered.
|
|
36 |
+ |
fn crypto_provider() -> Arc<rustls::crypto::CryptoProvider> {
|
|
37 |
+ |
rustls::crypto::CryptoProvider::get_default()
|
|
38 |
+ |
.cloned()
|
|
39 |
+ |
.unwrap_or_else(|| Arc::new(rustls::crypto::aws_lc_rs::default_provider()))
|
|
40 |
+ |
}
|
|
41 |
+ |
|
|
42 |
+ |
/// Build a client config pinned to one trust store.
|
|
43 |
+ |
fn client_config(store: TrustStore) -> Result<rustls::ClientConfig, String> {
|
|
44 |
+ |
match store {
|
|
45 |
+ |
TrustStore::Webpki => {
|
|
46 |
+ |
let mut root_store = rustls::RootCertStore::empty();
|
|
47 |
+ |
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
|
48 |
+ |
Ok(rustls::ClientConfig::builder()
|
|
49 |
+ |
.with_root_certificates(root_store)
|
|
50 |
+ |
.with_no_client_auth())
|
|
51 |
+ |
}
|
|
52 |
+ |
TrustStore::Platform => {
|
|
53 |
+ |
let verifier = rustls_platform_verifier::Verifier::new(crypto_provider())
|
|
54 |
+ |
.map_err(|e| format!("platform verifier unavailable: {e}"))?;
|
|
55 |
+ |
Ok(rustls::ClientConfig::builder()
|
|
56 |
+ |
.dangerous()
|
|
57 |
+ |
.with_custom_certificate_verifier(Arc::new(verifier))
|
|
58 |
+ |
.with_no_client_auth())
|
|
59 |
+ |
}
|
|
60 |
+ |
}
|
|
61 |
+ |
}
|
|
62 |
+ |
|
|
63 |
+ |
/// Connect and handshake against one trust store, returning the peer chain.
|
|
64 |
+ |
///
|
|
65 |
+ |
/// Each probe opens its own connection: a rustls handshake consumes the stream,
|
|
66 |
+ |
/// and the verifier is fixed when the config is built, so the two stores cannot
|
|
67 |
+ |
/// share one.
|
|
68 |
+ |
async fn probe(
|
|
69 |
+ |
addr: &str,
|
|
70 |
+ |
server_name: rustls_pki_types::ServerName<'static>,
|
|
71 |
+ |
store: TrustStore,
|
|
72 |
+ |
) -> Result<Vec<rustls_pki_types::CertificateDer<'static>>, String> {
|
|
73 |
+ |
let tls_config = client_config(store)?;
|
| 19 |
74 |
|
|
| 20 |
|
- |
// TCP connect with timeout
|
| 21 |
75 |
|
let tcp = match tokio::time::timeout(
|
| 22 |
76 |
|
std::time::Duration::from_secs(10),
|
| 23 |
|
- |
TcpStream::connect(&addr),
|
|
77 |
+ |
TcpStream::connect(addr),
|
| 24 |
78 |
|
)
|
| 25 |
79 |
|
.await
|
| 26 |
80 |
|
{
|
| 27 |
81 |
|
Ok(Ok(stream)) => stream,
|
| 28 |
|
- |
Ok(Err(e)) => return tls_error(target_name, config, &checked_at, &format!("TCP connect failed: {e}")),
|
| 29 |
|
- |
Err(_) => return tls_error(target_name, config, &checked_at, "TCP connect timed out"),
|
|
82 |
+ |
Ok(Err(e)) => return Err(format!("TCP connect failed: {e}")),
|
|
83 |
+ |
Err(_) => return Err("TCP connect timed out".to_string()),
|
| 30 |
84 |
|
};
|
| 31 |
85 |
|
|
| 32 |
|
- |
// Build rustls config with webpki trust store
|
| 33 |
|
- |
let mut root_store = rustls::RootCertStore::empty();
|
| 34 |
|
- |
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
| 35 |
|
- |
let tls_config = rustls::ClientConfig::builder()
|
| 36 |
|
- |
.with_root_certificates(root_store)
|
| 37 |
|
- |
.with_no_client_auth();
|
| 38 |
|
- |
|
| 39 |
86 |
|
let connector = TlsConnector::from(Arc::new(tls_config));
|
| 40 |
|
- |
let server_name = match rustls_pki_types::ServerName::try_from(config.host.clone()) {
|
| 41 |
|
- |
Ok(name) => name,
|
| 42 |
|
- |
Err(e) => return tls_error(target_name, config, &checked_at, &format!("invalid server name: {e}")),
|
| 43 |
|
- |
};
|
| 44 |
|
- |
|
| 45 |
|
- |
// TLS handshake with timeout
|
| 46 |
87 |
|
let tls_stream = match tokio::time::timeout(
|
| 47 |
88 |
|
std::time::Duration::from_secs(10),
|
| 48 |
89 |
|
connector.connect(server_name, tcp),
|
| 50 |
91 |
|
.await
|
| 51 |
92 |
|
{
|
| 52 |
93 |
|
Ok(Ok(stream)) => stream,
|
| 53 |
|
- |
Ok(Err(e)) => return tls_error(target_name, config, &checked_at, &format!("TLS handshake failed: {e}")),
|
| 54 |
|
- |
Err(_) => return tls_error(target_name, config, &checked_at, "TLS handshake timed out"),
|
|
94 |
+ |
Ok(Err(e)) => return Err(format!("TLS handshake failed: {e}")),
|
|
95 |
+ |
Err(_) => return Err("TLS handshake timed out".to_string()),
|
| 55 |
96 |
|
};
|
| 56 |
97 |
|
|
| 57 |
|
- |
// Extract peer certificates
|
| 58 |
98 |
|
let (_io, client_conn) = tls_stream.into_inner();
|
| 59 |
|
- |
let certs = match client_conn.peer_certificates() {
|
| 60 |
|
- |
Some(certs) if !certs.is_empty() => certs,
|
| 61 |
|
- |
_ => return tls_error(target_name, config, &checked_at, "no peer certificates"),
|
|
99 |
+ |
match client_conn.peer_certificates() {
|
|
100 |
+ |
Some(certs) if !certs.is_empty() => {
|
|
101 |
+ |
Ok(certs.iter().map(|c| c.clone().into_owned()).collect())
|
|
102 |
+ |
}
|
|
103 |
+ |
_ => Err("no peer certificates".to_string()),
|
|
104 |
+ |
}
|
|
105 |
+ |
}
|
|
106 |
+ |
|
|
107 |
+ |
/// Connect to host:port and inspect the leaf cert, verifying the chain against
|
|
108 |
+ |
/// both the bundled web-PKI roots and the OS trust store. See [`TrustStore`].
|
|
109 |
+ |
#[instrument(skip_all)]
|
|
110 |
+ |
pub async fn check_tls(target_name: &str, config: &TlsConfig) -> TlsStatus {
|
|
111 |
+ |
let checked_at = chrono::Utc::now().to_rfc3339();
|
|
112 |
+ |
let addr = format!("{}:{}", config.host, config.port);
|
|
113 |
+ |
|
|
114 |
+ |
let server_name = match rustls_pki_types::ServerName::try_from(config.host.clone()) {
|
|
115 |
+ |
Ok(name) => name,
|
|
116 |
+ |
Err(e) => return tls_error(target_name, config, &checked_at, &format!("invalid server name: {e}")),
|
| 62 |
117 |
|
};
|
| 63 |
118 |
|
|
| 64 |
|
- |
// Parse the leaf (first) certificate
|
| 65 |
|
- |
parse_leaf_cert(target_name, config, &checked_at, certs[0].as_ref())
|
|
119 |
+ |
let (webpki_result, platform_result) = tokio::join!(
|
|
120 |
+ |
probe(&addr, server_name.clone(), TrustStore::Webpki),
|
|
121 |
+ |
probe(&addr, server_name, TrustStore::Platform),
|
|
122 |
+ |
);
|
|
123 |
+ |
|
|
124 |
+ |
// Cert details come from whichever probe completed a handshake. Prefer the
|
|
125 |
+ |
// web-PKI chain so the reported fields stay stable across hosts, but fall
|
|
126 |
+ |
// back to the platform chain — a cert trusted only by a private root still
|
|
127 |
+ |
// has an expiry worth tracking.
|
|
128 |
+ |
let certs = webpki_result
|
|
129 |
+ |
.as_ref()
|
|
130 |
+ |
.ok()
|
|
131 |
+ |
.or(platform_result.as_ref().ok());
|
|
132 |
+ |
|
|
133 |
+ |
let Some(certs) = certs else {
|
|
134 |
+ |
// Both failed. Surface both reasons rather than just the first.
|
|
135 |
+ |
let webpki_err = webpki_result.as_ref().err().map(String::as_str).unwrap_or("unknown");
|
|
136 |
+ |
let platform_err = platform_result.as_ref().err().map(String::as_str).unwrap_or("unknown");
|
|
137 |
+ |
let mut status = tls_error(
|
|
138 |
+ |
target_name,
|
|
139 |
+ |
config,
|
|
140 |
+ |
&checked_at,
|
|
141 |
+ |
&format!("web PKI: {webpki_err}; platform: {platform_err}"),
|
|
142 |
+ |
);
|
|
143 |
+ |
status.webpki_error = webpki_result.err();
|
|
144 |
+ |
status.platform_error = platform_result.err();
|
|
145 |
+ |
return status;
|
|
146 |
+ |
};
|
|
147 |
+ |
|
|
148 |
+ |
let mut status = parse_leaf_cert(target_name, config, &checked_at, certs[0].as_ref());
|
|
149 |
+ |
status.webpki_trusted = webpki_result.is_ok();
|
|
150 |
+ |
status.platform_trusted = platform_result.is_ok();
|
|
151 |
+ |
status.webpki_error = webpki_result.err();
|
|
152 |
+ |
status.platform_error = platform_result.err();
|
|
153 |
+ |
// `valid` keeps its established meaning for the health contract: trusted by
|
|
154 |
+ |
// the public web PKI and not expired.
|
|
155 |
+ |
status.valid = status.valid && status.webpki_trusted;
|
|
156 |
+ |
status
|
| 66 |
157 |
|
}
|
| 67 |
158 |
|
|
| 68 |
159 |
|
/// Parse DER-encoded leaf cert bytes into a TlsStatus.
|
| 110 |
201 |
|
issuer,
|
| 111 |
202 |
|
checked_at: checked_at.to_string(),
|
| 112 |
203 |
|
error: None,
|
|
204 |
+ |
// Trust results are filled in by `check_tls`, which owns both probes.
|
|
205 |
+ |
// Callers parsing a bare cert (tests, cached DER) get the conservative
|
|
206 |
+ |
// "not established" default rather than a fabricated pass.
|
|
207 |
+ |
webpki_trusted: false,
|
|
208 |
+ |
platform_trusted: false,
|
|
209 |
+ |
webpki_error: None,
|
|
210 |
+ |
platform_error: None,
|
| 113 |
211 |
|
}
|
| 114 |
212 |
|
}
|
| 115 |
213 |
|
|
| 126 |
224 |
|
issuer: String::new(),
|
| 127 |
225 |
|
checked_at: checked_at.to_string(),
|
| 128 |
226 |
|
error: Some(error.to_string()),
|
|
227 |
+ |
webpki_trusted: false,
|
|
228 |
+ |
platform_trusted: false,
|
|
229 |
+ |
webpki_error: None,
|
|
230 |
+ |
platform_error: None,
|
| 129 |
231 |
|
}
|
| 130 |
232 |
|
}
|
| 131 |
233 |
|
|
| 133 |
235 |
|
mod tests {
|
| 134 |
236 |
|
use super::*;
|
| 135 |
237 |
|
|
|
238 |
+ |
/// Live-network check that both trust stores are actually exercised and
|
|
239 |
+ |
/// report independently. Ignored by default so the suite stays offline; run
|
|
240 |
+ |
/// with `cargo test -- --ignored dual_trust` when touching the probe logic.
|
|
241 |
+ |
#[tokio::test]
|
|
242 |
+ |
#[ignore = "requires network access"]
|
|
243 |
+ |
async fn dual_trust_probes_agree_on_public_and_reject_bad_certs() {
|
|
244 |
+ |
let cfg = |host: &str| TlsConfig {
|
|
245 |
+ |
host: host.to_string(),
|
|
246 |
+ |
port: 443,
|
|
247 |
+ |
warn_days: 30,
|
|
248 |
+ |
};
|
|
249 |
+ |
|
|
250 |
+ |
// A normal public cert: trusted by the bundled web PKI and by the OS.
|
|
251 |
+ |
let good = check_tls("good", &cfg("makenot.work")).await;
|
|
252 |
+ |
assert!(good.webpki_trusted, "web PKI should trust makenot.work: {:?}", good.webpki_error);
|
|
253 |
+ |
assert!(good.platform_trusted, "OS store should trust makenot.work: {:?}", good.platform_error);
|
|
254 |
+ |
assert!(good.valid);
|
|
255 |
+ |
assert!(good.days_remaining > 0);
|
|
256 |
+ |
|
|
257 |
+ |
// An expired cert must be rejected by both, and each must say why
|
|
258 |
+ |
// rather than inheriting the other's verdict.
|
|
259 |
+ |
let expired = check_tls("expired", &cfg("expired.badssl.com")).await;
|
|
260 |
+ |
assert!(!expired.webpki_trusted);
|
|
261 |
+ |
assert!(!expired.platform_trusted);
|
|
262 |
+ |
assert!(expired.webpki_error.is_some());
|
|
263 |
+ |
assert!(expired.platform_error.is_some());
|
|
264 |
+ |
assert!(!expired.valid);
|
|
265 |
+ |
|
|
266 |
+ |
// A self-signed cert chains to no public or OS root.
|
|
267 |
+ |
let self_signed = check_tls("self-signed", &cfg("self-signed.badssl.com")).await;
|
|
268 |
+ |
assert!(!self_signed.webpki_trusted);
|
|
269 |
+ |
assert!(!self_signed.platform_trusted);
|
|
270 |
+ |
assert!(!self_signed.valid);
|
|
271 |
+ |
}
|
|
272 |
+ |
|
| 136 |
273 |
|
fn test_config() -> TlsConfig {
|
| 137 |
274 |
|
TlsConfig {
|
| 138 |
275 |
|
host: "example.com".to_string(),
|