nixfleet_control_plane/polling/
revocations_poll.rs

1//! Revocations poll: fetch+verify signed revocations.json, replay into `cert_revocations`.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::time::Duration;
7
8use anyhow::Result;
9
10use crate::db::Db;
11use crate::polling::poller::SignedArtifactPoller;
12use crate::polling::signed_fetch;
13
14pub const POLL_INTERVAL: Duration = Duration::from_secs(60);
15
16#[derive(Debug, Clone)]
17pub struct RevocationsSource {
18    pub artifact_url: String,
19    pub signature_url: String,
20    pub token_file: Option<PathBuf>,
21    pub trust_path: PathBuf,
22    pub freshness_window: Duration,
23}
24
25/// `revocations_primed` flips to `true` after the first successful verify
26/// + apply. The `/v1/*` ready gate (#95) consults this when
27/// `revocations_required` is set so the daemon won't serve agents until
28/// the full trust footprint is loaded - preventing the rebuild-revives-
29/// revoked-certs window noted in #70.
30pub fn spawn(
31    cancel: tokio_util::sync::CancellationToken,
32    db: Arc<Db>,
33    config: RevocationsSource,
34    revocations_primed: Arc<AtomicBool>,
35) -> tokio::task::JoinHandle<()> {
36    SignedArtifactPoller {
37        interval: POLL_INTERVAL,
38        label: "revocations",
39    }
40    .spawn(cancel, move |client| {
41        let db = Arc::clone(&db);
42        let config = config.clone();
43        let revocations_primed = Arc::clone(&revocations_primed);
44        async move {
45            let revs = poll_once(&client, &config).await?;
46            apply_verified_revocations(&db, &revs);
47            let was_primed = revocations_primed.swap(true, Ordering::AcqRel);
48            if !was_primed {
49                tracing::info!(
50                    target: "revocations",
51                    entries = revs.revocations.len(),
52                    "revocations primed: first verified list applied",
53                );
54            }
55            Ok(())
56        }
57    })
58}
59
60/// Per-entry write failures log + continue; one bad row mustn't poison the rest.
61fn apply_verified_revocations(db: &Db, revs: &nixfleet_proto::Revocations) {
62    let n = revs.revocations.len();
63    let mut applied = 0usize;
64    for entry in &revs.revocations {
65        match db.revocations().revoke_cert(
66            &entry.hostname,
67            entry.not_before,
68            entry.reason.as_deref(),
69            entry.revoked_by.as_deref(),
70        ) {
71            Ok(()) => applied += 1,
72            Err(err) => tracing::warn!(
73                hostname = %entry.hostname,
74                error = %err,
75                "revocations poll: revoke_cert failed for entry",
76            ),
77        }
78    }
79    // Reconcile - delete any DB row whose hostname dropped out of the
80    // signed list, so operator-side de-revoke (entry removed from
81    // fleet.nix `revocations`) actually un-blocks the host on the next
82    // poll tick. Upsert-only would otherwise leave the row stale.
83    let keep: Vec<&str> = revs
84        .revocations
85        .iter()
86        .map(|e| e.hostname.as_str())
87        .collect();
88    let pruned = match db.revocations().retain_only(&keep) {
89        Ok(n) => n,
90        Err(err) => {
91            tracing::warn!(
92                error = %err,
93                "revocations poll: retain_only failed - DB may keep stale revocations",
94            );
95            0
96        }
97    };
98    tracing::info!(
99        target: "revocations",
100        entries = n,
101        applied = applied,
102        pruned = pruned,
103        signed_at = ?revs.meta.signed_at,
104        ci_commit = ?revs.meta.ci_commit,
105        "revocations poll: list verified",
106    );
107}
108
109async fn poll_once(
110    client: &reqwest::Client,
111    config: &RevocationsSource,
112) -> Result<nixfleet_proto::Revocations> {
113    let token = signed_fetch::read_token(config.token_file.as_deref())?;
114    let (artifact_bytes, signature_bytes) = signed_fetch::fetch_signed_pair(
115        client,
116        &config.artifact_url,
117        &config.signature_url,
118        token.as_deref(),
119    )
120    .await?;
121
122    let (trusted_keys, reject_before) =
123        signed_fetch::read_trust_roots(&config.trust_path, chrono::Utc::now())?;
124
125    nixfleet_reconciler::verify_revocations(
126        &artifact_bytes,
127        &signature_bytes,
128        &trusted_keys,
129        chrono::Utc::now(),
130        config.freshness_window,
131        reject_before,
132    )
133    .map(|v| v.into_inner())
134    .map_err(|e| anyhow::anyhow!("verify_revocations (revocations poll): {e:?}"))
135}