fm: add certificate diagnosis engine for expiring silo TLS certificates - #11238
fm: add certificate diagnosis engine for expiring silo TLS certificates#11238smklein wants to merge 9 commits into
Conversation
Nexus serves each silo's external API with the silo's certificate whose leaf not_after is latest (ExternalEndpoint::best_certificate). When that certificate is about to expire or has expired, no later-expiring replacement is installed, and until now nothing told the operator. Add a fault management diagnosis engine that predicts the same choice and opens a case per silo when the best certificate is inside a configurable warning window or already expired. The case carries one fact (BestCertificateExpiring or BestCertificateExpired) and requests a silo.certificate.expiring or silo.certificate.expired alert whenever that fact changes. The case closes once a later-expiring certificate is installed or the silo is removed. Silos with no certificates open no case. Like best_certificate, the engine ignores not_before. The warning window is a new FmConfig setting, certificate_expiry_warning_days (default 30, at most 3650), settable via omdb nexus fm-config set --certificate-expiry-warning-days. Supporting changes: - omicron-certificates: leaf_validity parses a PEM chain's leaf not_before/not_after into chrono timestamps. - nexus-types: DiagnosisEngineKind::Certificate, CertificateFact payloads, the ObservedSiloCertificates analysis input, the two alert classes with V0 payloads, and the FmConfig setting. - Schema version 300 (fm-certificate-de): diagnosis_engine and alert_class enum values, the fm_fact_certificate table, and the fm_config column with its CHECK constraint. - nexus-fm: Input carries the FmConfig and observed silo certificates; the fm_analysis task loads them from the silo and certificate tables.
| pub comment: String, | ||
|
|
||
| /// The silo this fact is about. | ||
| pub silo_id: Uuid, |
There was a problem hiding this comment.
Silos and certs don't have a strongly-typed UUID (yet), they probably should
A fact's comment is recorded when the fact is added and carried forward unchanged, so a relative phrase like "in 10days" or "1day ago" goes stale as sitreps advance. Record the absolute not_after only.
| Ok(ParsedCertificateCase { silo_id, fact: kept, duplicate_facts }) | ||
| } | ||
|
|
||
| pub(super) fn analyze(builder: &mut SitrepBuilder<'_>) -> anyhow::Result<()> { |
There was a problem hiding this comment.
A lot of this function is coping with "observing parent cases, reconciliation of facts". I'll annotate where the more important "new case opened, alert created" stuff happens.
| for silo in silos.iter() { | ||
| let Some((desired, best)) = desired_fact(silo, reference_time, window) |
There was a problem hiding this comment.
This is where we flag certs which are "expired, or about to be"
|
|
||
| let carried = parent.and_then(|(_, p)| p.fact.as_ref()); | ||
| if carried.map(|(_, fact)| fact) == Some(&desired) { | ||
| continue; |
There was a problem hiding this comment.
we bail here if the fact was the same as the parent, so below this, the fact is new - meaning we need a new alert.
| name: best.name.clone(), | ||
| not_after: best.not_after, | ||
| }; | ||
| let alert_result = match &desired { |
There was a problem hiding this comment.
... and this is where we actually create alerts
| // License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| // file, You can obtain one at https://mozilla.org/MPL/2.0/. | ||
|
|
||
| //! Silo TLS certificate alert types. |
There was a problem hiding this comment.
So, as an FYI, these alerts only go out to fleet-level operators. This isn't a new decision in this PR, it's sorta how alerts work generally.
However, certificate management is silo-scoped.
I bring this up because we don't send these alerts to "silo admins" right now - we send them to "fleet admins", who presumably will need to coordinate with silo admins to get the certificates patched.
This does technically expose certificate names within a silo to the fleet operator, but that SEEMS okay to me?
There was a problem hiding this comment.
Yeah. I think we should probably be tracking the work needed to have silo-scoped (and also project-scoped) alerts. We punted on that from the initial implementation, but I think there are going to be a bunch of cases where we want them...and probably the same for Active Problems, to the extent that I'm wondering if I should rethink some of what I wrote in RFD 699 about authz.
In the meantime we should probably put together a ticket for having silo/project alerts so we can reference this there.
| /// from the Unix epoch. | ||
| /// | ||
| /// `Asn1TimeRef` offers no direct conversion to a Unix timestamp, but | ||
| /// `ASN1_TIME_diff` can compute the (days, seconds) difference between two |
There was a problem hiding this comment.
oh i guess it's the name of the C function that Asn1Time::diff is a binding to.
| let epoch = Asn1Time::from_unix(0).map_err(CertificateError::Unexpected)?; | ||
| // `diff` computes `time - epoch`, split into whole days and the | ||
| // remaining seconds. | ||
| let diff = epoch.diff(time).map_err(CertificateError::Unexpected)?; |
There was a problem hiding this comment.
hm, do we know the errors that diff would return? is it possible that any of these would be time out of range-y?
| // `diff` computes `time - epoch`, split into whole days and the | ||
| // remaining seconds. | ||
| let diff = epoch.diff(time).map_err(CertificateError::Unexpected)?; | ||
| let secs = i64::from(diff.days) * SECS_PER_DAY + i64::from(diff.secs); |
There was a problem hiding this comment.
should we use checked/saturating arithmetic here, since the cert is user controlled and might be overflowy?
| } | ||
|
|
||
| #[test] | ||
| fn test_leaf_validity_reads_leaf_certificate() { |
There was a problem hiding this comment.
this feels like it might perhaps wanna be a proptest someday.
| // Pin the leaf's validity window to exact second offsets from the | ||
| // Unix epoch. The root and intermediate certificates in the chain | ||
| // keep rcgen's default (much wider) window, so a correct result | ||
| // proves we read the leaf and not some other link in the chain. |
| let config = self | ||
| .config | ||
| .ok_or(InvalidInputs::MissingInput { name: "config" })?; |
There was a problem hiding this comment.
i feel like this should maybe be its own error variant so that it can be turned into the "waiting for config" analysis status in the bg task
| // Expiry is judged against the input's deterministic "now"; see | ||
| // `Input::reference_time`. | ||
| let reference_time = input.reference_time(); |
There was a problem hiding this comment.
thanks for adding this, i had been thinking we were gonna want something like this for a while now. (i was just gonna call it input.now(), but maybe your name is better!)
|
|
||
| /// Summarizes the inputs to sitrep analysis. | ||
| #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] | ||
| pub struct InputReport { |
There was a problem hiding this comment.
i feel like the analysis' reference time belongs here too? interpreting the analysis results will almost certainly want to know what time we thought "now" was when we were doing stuff
| // | ||
| // The fault management certificate diagnosis engine | ||
| // (`nexus_fm::diagnosis::certificate`) predicts this choice from the | ||
| // same rule, so that it alerts when the certificate we will actually | ||
| // serve is expiring or expired. If the rule here changes, the engine | ||
| // (and `ObservedSiloCertificates::best_certificate`, which it uses) | ||
| // must change with it. |
There was a problem hiding this comment.
this really makes me feel like there ought to be a function someplace that both this code and FM could share, rather than "you have to remember to update the other thing too lol"...
|
(i should add that it's neat to see DEs for simple, but high-impact purely software/config issues starting to be written! thanks for doing this!) |
The certificate diagnosis engine's loader accepted any certificate whose PEM parsed, but ExternalEndpoints::new also drops certificates whose key rustls cannot load. Such a certificate could be chosen as the silo's best and hide an expiring one that is served. Run the loader's rows through TlsCertificate::try_from so both sides agree.
The "silo not found" warning was wrapped without a trailing backslash, leaving a run of interior whitespace in the text omdb prints.
ObservedSiloCertificates::best_certificate claimed callers depended only on not_after, but the engine stores the chosen certificate's id in its fact and re-alerts when it changes. Break ties toward the greatest id, document why, and test that equal-expiry certificates carry the fact unchanged across sitreps.
TlsCertificate now carries the leaf validity as a DateTime, so the served-certificate rule can be the same max_by_key expression the certificate diagnosis engine uses, instead of a hand-rolled loop over Asn1Time partial_cmp. Drop the parsed X509 field, which had no other readers left.
Both certificate fact kinds carry the same payload, so the nullable columns plus two identical per-kind CHECK constraints were NOT NULL spelled twice. Declare them NOT NULL, drop the Option fields and the unreachable missing-column error path in the Diesel model.
The loader hand-rolled the same pagination loop the datastore helper already provides, with an ad-hoc batch size. Use the helper for silos and the standard SQL_BATCH_SIZE for certificates.
parse_case returns NoFacts when a case has no facts, so a successfully parsed case always has one. Store it directly and derive the silo id from it instead of threading an Option through the reconcile loop.
Nexus serves each silo's external API with the silo's certificate whose leaf
not_afteris latest (ExternalEndpoint::best_certificate). When that certificate is about to expire or has expired, nothing told the operator.This adds a fault management diagnosis engine (
nexus/fm/src/diagnosis/certificate.rs) that opens a case per silo when the best certificate is inside a configurable warning window or already expired. The case carries one fact,BestCertificateExpiringorBestCertificateExpired, and requests asilo.certificate.expiringorsilo.certificate.expiredalert whenever that fact changes: entering the window, passingnot_after, or a different (still expiring or expired) certificate becoming the best one. Unchanged input requests nothing. The case closes once a later-expiring certificate is installed or the silo is removed.The warning window is a new
FmConfigsetting,certificate_expiry_warning_days(default 30, at most 3650). This value can be modified withomdb nexus fm-config set --certificate-expiry-warning-days <N>.Supporting changes:
omicron-certificates:leaf_validityparses a PEM chain's leafnot_before/not_afterintochronotimestamps.nexus-types:DiagnosisEngineKind::Certificate, theCertificateFactpayloads, theObservedSiloCertificatesanalysis input, the two alert classes with V0 payloads, theFmConfigsetting, and an input report section listing observed silos and certificates.schema/crdb/fm-certificate-de):diagnosis_engineandalert_classenum values, thefm_fact_certificatetable (typed columns, one table per engine, as for disks and sagas), and thefm_configcolumn with its CHECK constraint.nexus-fm:Inputnow carries theFmConfigand the observed silo certificates (both required inputs). Thefm_analysistask loads them from the same silo and certificate rows theexternal_endpointstask reads.Not in scope, as follow-ups:
not_beforehandling (to be added tobest_certificateand the engine together), alerting on silos with no certificates at all, and exposingnot_afterin theexternal_endpointstask status.