nixfleet_control_plane/polling/
bootstrap_nonces_poll.rs

1//! Bootstrap-nonces poll: fetch + verify signed bootstrap-nonces.json,
2//! replace the in-memory `AllowedNoncesView` wholesale.
3
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::time::Duration;
8
9use anyhow::Result;
10use tokio::sync::RwLock;
11
12use crate::db::allowed_nonces::AllowedNoncesView;
13use crate::polling::poller::SignedArtifactPoller;
14use crate::polling::signed_fetch;
15
16pub const POLL_INTERVAL: Duration = Duration::from_secs(60);
17
18#[derive(Debug, Clone)]
19pub struct BootstrapNoncesSource {
20    pub artifact_url: String,
21    pub signature_url: String,
22    pub token_file: Option<PathBuf>,
23    pub trust_path: PathBuf,
24    pub freshness_window: Duration,
25}
26
27/// `bootstrap_nonces_primed` flips to `true` after the first successful
28/// verify + apply. The `/v1/*` ready gate consults this when
29/// `bootstrap_nonces_required` is set so the daemon won't serve agents
30/// until the full trust footprint is loaded.
31pub fn spawn(
32    cancel: tokio_util::sync::CancellationToken,
33    allowed_nonces: Arc<RwLock<AllowedNoncesView>>,
34    config: BootstrapNoncesSource,
35    bootstrap_nonces_primed: Arc<AtomicBool>,
36) -> tokio::task::JoinHandle<()> {
37    SignedArtifactPoller {
38        interval: POLL_INTERVAL,
39        label: "bootstrap_nonces",
40    }
41    .spawn(cancel, move |client| {
42        let allowed_nonces = Arc::clone(&allowed_nonces);
43        let config = config.clone();
44        let bootstrap_nonces_primed = Arc::clone(&bootstrap_nonces_primed);
45        async move {
46            let bn = poll_once(&client, &config).await?;
47            let entries = bn.bootstrap_nonces.len();
48            apply_verified_allowlist(&allowed_nonces, bn).await;
49            let was_primed = bootstrap_nonces_primed.swap(true, Ordering::AcqRel);
50            if !was_primed {
51                tracing::info!(
52                    target: "bootstrap_nonces",
53                    entries = entries,
54                    "bootstrap nonces primed: first verified allowlist applied",
55                );
56            }
57            Ok(())
58        }
59    })
60}
61
62/// Replace the in-memory view wholesale. The previous view is dropped.
63async fn apply_verified_allowlist(
64    allowed_nonces: &RwLock<AllowedNoncesView>,
65    bn: nixfleet_proto::BootstrapNonces,
66) {
67    let entries = bn.bootstrap_nonces.len();
68    let signed_at = bn.meta.signed_at;
69    let ci_commit = bn.meta.ci_commit.clone();
70    let view = AllowedNoncesView::from_artifact(bn);
71    let mut guard = allowed_nonces.write().await;
72    *guard = view;
73    drop(guard);
74    tracing::info!(
75        target: "bootstrap_nonces",
76        entries = entries,
77        signed_at = ?signed_at,
78        ci_commit = ?ci_commit,
79        "bootstrap-nonces poll: allowlist verified + applied",
80    );
81}
82
83async fn poll_once(
84    client: &reqwest::Client,
85    config: &BootstrapNoncesSource,
86) -> Result<nixfleet_proto::BootstrapNonces> {
87    let token = signed_fetch::read_token(config.token_file.as_deref())?;
88    let (artifact_bytes, signature_bytes) = signed_fetch::fetch_signed_pair(
89        client,
90        &config.artifact_url,
91        &config.signature_url,
92        token.as_deref(),
93    )
94    .await?;
95
96    let (trusted_keys, reject_before) =
97        signed_fetch::read_trust_roots(&config.trust_path, chrono::Utc::now())?;
98
99    nixfleet_reconciler::verify_bootstrap_nonces(
100        &artifact_bytes,
101        &signature_bytes,
102        &trusted_keys,
103        chrono::Utc::now(),
104        config.freshness_window,
105        reject_before,
106    )
107    .map(|v| v.into_inner())
108    .map_err(|e| anyhow::anyhow!("verify_bootstrap_nonces (bootstrap-nonces poll): {e:?}"))
109}