export bao client-cert not_before/not_after gauges and alert past 80% of lifetime
This commit is contained in:
parent
daea7a56be
commit
078696aaee
23 changed files with 604 additions and 53 deletions
|
|
@ -30,6 +30,8 @@ thiserror.workspace = true
|
|||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
vaultrs.workspace = true
|
||||
# `cert::validity` decodes a client certificate's PEM for its validity window.
|
||||
x509-cert.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
109
swarm-secret-client/src/cert.rs
Normal file
109
swarm-secret-client/src/cert.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! A client certificate's validity window, read from the PEM itself.
|
||||
//!
|
||||
//! The window is the certificate's own `notBefore`/`notAfter`, the same two
|
||||
//! timestamps the store checks at login, so a caller that asks "how much of
|
||||
//! its life is left" and the store never disagree about the answer.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use x509_cert::der::DecodePem as _;
|
||||
|
||||
use crate::{Error, client::ENV_CLIENT_CERT};
|
||||
|
||||
/// The gauge a service exports for its client certificate's `notBefore`, unix
|
||||
/// seconds. The alert rule `hh-bao-client-cert-lifetime` selects this name.
|
||||
pub const NOT_BEFORE_METRIC: &str = "bao.client_cert.not_before";
|
||||
/// The gauge for the certificate's `notAfter`, unix seconds.
|
||||
pub const NOT_AFTER_METRIC: &str = "bao.client_cert.not_after";
|
||||
|
||||
/// A certificate's validity window, `(notBefore, notAfter)` in unix seconds.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Certificate`] when `pem` is not one PEM-encoded X.509 certificate.
|
||||
pub fn validity(pem: &str) -> Result<(i64, i64), Error> {
|
||||
let cert = x509_cert::Certificate::from_pem(pem.as_bytes())
|
||||
.map_err(|e| Error::Certificate(e.to_string()))?;
|
||||
let validity = &cert.tbs_certificate.validity;
|
||||
let secs = |t: x509_cert::time::Time| {
|
||||
i64::try_from(t.to_unix_duration().as_secs()).unwrap_or(i64::MAX)
|
||||
};
|
||||
Ok((secs(validity.not_before), secs(validity.not_after)))
|
||||
}
|
||||
|
||||
/// [`validity`] of the client certificate file at `path` (the file
|
||||
/// [`ENV_CLIENT_CERT`] names).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Identity`] when the file cannot be read, [`Error::Certificate`]
|
||||
/// when it is not a certificate.
|
||||
pub fn file_validity(path: &Path) -> Result<(i64, i64), Error> {
|
||||
let pem = std::fs::read_to_string(path).map_err(|source| Error::Identity {
|
||||
var: ENV_CLIENT_CERT,
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
validity(&pem)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Self-signed, CN `hive-agent-atlas`, valid 2026-01-01T00:00:00Z to
|
||||
/// 2026-04-01T00:00:00Z. Its key was discarded.
|
||||
pub(crate) const CERT_PEM: &str = "-----BEGIN CERTIFICATE-----
|
||||
MIIBizCCATGgAwIBAgIUSFpuYmy1UoHvq/MYiyrAudRaWXcwCgYIKoZIzj0EAwIw
|
||||
GzEZMBcGA1UEAwwQaGl2ZS1hZ2VudC1hdGxhczAeFw0yNjAxMDEwMDAwMDBaFw0y
|
||||
NjA0MDEwMDAwMDBaMBsxGTAXBgNVBAMMEGhpdmUtYWdlbnQtYXRsYXMwWTATBgcq
|
||||
hkjOPQIBBggqhkjOPQMBBwNCAAR7p2eZAxjiE1RdsuSHE3CcTjbnd4swzfnmqUPW
|
||||
9NJN7mvOtqEzxCKfOSFjaIhhyShIQ41/V6vGi80EPDB+uR56o1MwUTAdBgNVHQ4E
|
||||
FgQUxgZFsN1VY3ODrPS1NUfY/x3EnmswHwYDVR0jBBgwFoAUxgZFsN1VY3ODrPS1
|
||||
NUfY/x3EnmswDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAnU9e
|
||||
WOioNTUNK9b6FignejpK5FpyrODUuH/iL4TqCh0CIAz6GzJeLebhRPvYlfVUofdV
|
||||
hWx3sOwmUgjkRQXoxY+p
|
||||
-----END CERTIFICATE-----
|
||||
";
|
||||
pub(crate) const NOT_BEFORE: i64 = 1_767_225_600;
|
||||
pub(crate) const NOT_AFTER: i64 = 1_775_001_600;
|
||||
|
||||
#[test]
|
||||
fn the_window_is_read_from_the_certificate() {
|
||||
assert_eq!(
|
||||
validity(CERT_PEM).expect("a well-formed certificate"),
|
||||
(NOT_BEFORE, NOT_AFTER)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn something_that_is_not_a_certificate_is_an_error() {
|
||||
assert!(matches!(
|
||||
validity("not a certificate"),
|
||||
Err(Error::Certificate(_))
|
||||
));
|
||||
assert!(validity("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_is_read_through_its_path() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("swarm-secret-client-cert-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("scratch dir");
|
||||
let path = dir.join("cert.pem");
|
||||
std::fs::write(&path, CERT_PEM).expect("write");
|
||||
assert_eq!(
|
||||
file_validity(&path).expect("readable"),
|
||||
(NOT_BEFORE, NOT_AFTER)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_names_the_variable_and_path() {
|
||||
let err = file_validity(Path::new("/nonexistent/cert.pem")).expect_err("no such file");
|
||||
assert!(
|
||||
matches!(&err, Error::Identity { var, path, .. }
|
||||
if *var == ENV_CLIENT_CERT && path == "/nonexistent/cert.pem"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@
|
|||
|
||||
pub mod acp;
|
||||
pub mod builder;
|
||||
pub mod cert;
|
||||
pub mod ci;
|
||||
pub mod ci_metrics;
|
||||
pub mod claude_login;
|
||||
|
|
@ -103,6 +104,10 @@ pub enum Error {
|
|||
/// identity needs. Names the field, never its value.
|
||||
#[error("the store issued a certificate whose {0} is empty")]
|
||||
IncompleteIssue(&'static str),
|
||||
|
||||
/// The text was not one PEM-encoded X.509 certificate.
|
||||
#[error("decoding the certificate: {0}")]
|
||||
Certificate(String),
|
||||
}
|
||||
|
||||
impl From<vaultrs::error::ClientError> for Error {
|
||||
|
|
|
|||
Loading…
Reference in a new issue