Skip to main content

edgeguard/
cp.rs

1//! Managed-mode client: talk to a remote control plane.
2//!
3//! Off by default. When `[control_plane]` is configured, the edge:
4//!   * **pulls its policy** (conditional `GET`, ETag/`304`) and hot-reloads it through the same
5//!     `build_runtime` + arc-swap path a local file edit uses;
6//!   * **reports usage** (requests + ingress/egress bytes) as periodic deltas;
7//!   * **forwards CSP reports** it receives to the control plane.
8//!
9//! This is a generic "pull config / report usage to a URL" client — it carries no control-plane
10//! logic; it just speaks the control plane's edge HTTP API with a per-tenant bearer token. Built
11//! on the same `reqwest` + rustls stack as the JWKS fetcher (`auth.rs`).
12
13use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
14use std::sync::Arc;
15use std::time::Duration;
16
17use anyhow::{Context, Result};
18use arc_swap::ArcSwap;
19use bytes::Bytes;
20use serde::{Deserialize, Serialize};
21use tokio::sync::watch;
22use tracing::{info, warn};
23
24use crate::config::{Config, ControlPlaneCfg};
25use crate::metrics::Metrics;
26use crate::proxy::Runtime;
27
28/// A usage delta reported to the control plane (matches its `/v3/edge/{id}/usage` wire shape).
29/// The LLM token fields (gateway L4) are only sent when the gateway is active; the control plane
30/// treats them as `#[serde(default)]`, so an older control plane simply ignores them.
31#[derive(Debug, Clone, Copy, Default, Serialize)]
32pub struct UsageDelta {
33    pub requests: u64,
34    pub ingress_bytes: u64,
35    pub egress_bytes: u64,
36    pub tokens_in: u64,
37    pub tokens_out: u64,
38    pub cost_micros: u64,
39    /// Requests the edge denied since the last report (a subset of `requests`). Serialized only; an
40    /// older control plane treats it as `#[serde(default)]` and ignores it.
41    pub blocked: u64,
42    /// WAF matches by rule class since the last report. Serialized only; an older control plane
43    /// treats them as `#[serde(default)]` and ignores them.
44    pub waf_sqli: u64,
45    pub waf_xss: u64,
46    pub waf_path_traversal: u64,
47    pub waf_custom: u64,
48}
49
50/// The body of a usage report: the metered delta plus this edge's heartbeat.
51///
52/// One flat object, because the control plane parses the delta and the heartbeat from the SAME
53/// body. That is deliberate on both sides — the heartbeat rides on a request the edge already
54/// makes on a timer, so the fleet registry costs no extra endpoint, no extra interval and no extra
55/// round trip.
56///
57/// Every heartbeat field is `#[serde(default)]` on the control plane, so an older control plane
58/// ignores them and an older edge that sends none of them is simply absent from the fleet view.
59#[derive(Debug, Serialize)]
60struct UsageReport<'a> {
61    #[serde(flatten)]
62    delta: &'a UsageDelta,
63    edge_id: &'a str,
64    agent_version: &'a str,
65    /// The ETag of the policy this edge currently has loaded, or `None` when it is running on local
66    /// configuration. The control plane compares it against what it serves to detect drift, so
67    /// "running local config" and "running a stale pull" must stay distinguishable — hence
68    /// `Option`, not an empty string.
69    policy_etag: Option<String>,
70    uptime_secs: u64,
71}
72
73/// The subset of the control plane's `PolicyDocument` the edge needs.
74#[derive(Debug, Deserialize)]
75struct PolicyResp {
76    etag: String,
77    body: String,
78}
79
80/// The subset of the control plane's `QuotaStatus` the edge needs to enforce a hard stop.
81#[derive(Debug, Deserialize)]
82struct QuotaResp {
83    over_quota: bool,
84    #[serde(default)]
85    reset_epoch: i64,
86}
87
88/// The control plane's answer to a lease request, flattened for the caller.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum LeaseVerdict {
91    /// The fleet has budget; the shared buckets are already debited. Report the outcome against
92    /// `lease_id` when the order finishes.
93    Granted { lease_id: String },
94    /// A shared CA bucket is spent. Nothing was debited and the order must not be sent.
95    Deferred {
96        bucket: String,
97        key: String,
98        retry_at_unix: i64,
99    },
100    /// The control plane keeps no books for this CA (or is too old to know about leases). The local
101    /// per-edge budget is the only guard, which is where things stood before leases existed.
102    Unmanaged,
103}
104
105/// The control plane's `POST /v3/edge/{id}/acme-lease` response.
106///
107/// Every field past `decision` is optional because the three decisions carry different payloads,
108/// and an edge must not fail to parse a shape a newer control plane grew a field on.
109#[derive(Debug, Deserialize)]
110struct LeaseResp {
111    decision: String,
112    #[serde(default)]
113    lease_id: Option<String>,
114    #[serde(default)]
115    bucket: Option<String>,
116    #[serde(default)]
117    key: Option<String>,
118    #[serde(default)]
119    retry_at_unix: Option<i64>,
120}
121
122/// Shared, hot-reload-surviving quota verdict the proxy enforces. The [`quota_loop`] writes it from
123/// the control plane's verdict; the proxy reads it per request. It lives in `AppState` (not the
124/// hot-swappable `Runtime`) so a policy reload never resets the enforcement state.
125#[derive(Debug, Default)]
126pub struct QuotaState {
127    /// `true` while the edge is over its quota — the proxy returns `429`.
128    pub over_quota: AtomicBool,
129    /// Unix second the quota resets (period rollover); `0` = unknown. The `Retry-After` hint.
130    pub reset_epoch: AtomicI64,
131}
132
133impl QuotaState {
134    /// Whether the proxy should currently hard-stop the edge's traffic.
135    pub fn blocked(&self) -> bool {
136        self.over_quota.load(Ordering::Relaxed)
137    }
138
139    /// The reset-epoch hint last reported by the control plane (`0` if none yet).
140    pub fn reset_epoch(&self) -> i64 {
141        self.reset_epoch.load(Ordering::Relaxed)
142    }
143
144    fn apply(&self, over_quota: bool, reset_epoch: i64) {
145        self.over_quota.store(over_quota, Ordering::Relaxed);
146        self.reset_epoch.store(reset_epoch, Ordering::Relaxed);
147    }
148}
149
150/// Outcome of a conditional policy pull.
151pub enum PullResult {
152    /// The edge's ETag still matched — nothing changed.
153    NotModified,
154    /// A new policy: its opaque TOML body and the new ETag.
155    Policy { body: String, etag: String },
156}
157
158/// Outbound client to a control plane's per-tenant edge API.
159pub struct CpClient {
160    http: reqwest::Client,
161    /// `{base}/v3/edge/{tenant}` prefix, already trimmed.
162    edge_base: String,
163    token: String,
164    /// Stable identifier for this edge process, reported on every usage POST.
165    edge_id: String,
166    /// When this process started, for the reported uptime. Uptime is what distinguishes "quiet
167    /// because idle" from "quiet because it keeps restarting" — a distinction `last_seen` alone
168    /// cannot make, because a crash-looping edge reports just as recently as a healthy one.
169    started_at: std::time::Instant,
170    /// The ETag of the policy currently applied. Written by [`poll_loop`] when it applies a new
171    /// policy, read by [`report_loop`] when it reports — the two run as independent tasks on
172    /// different intervals, so this is the seam between them.
173    policy_etag: arc_swap::ArcSwapOption<String>,
174}
175
176/// This edge's identity in the fleet view.
177///
178/// The hostname, because it is the name an operator already uses for the box and is stable across
179/// restarts for a VM or a StatefulSet pod. For a rolling Deployment each replacement is a new name
180/// and therefore a new row, with the old one ageing out — which is the honest answer: a rolled
181/// Deployment genuinely is a different set of processes.
182///
183/// Falls back to the pid rather than to a random value: a random id would make every restart a
184/// permanently distinct edge, and the fleet view would fill with ghosts.
185fn default_edge_id() -> String {
186    if let Ok(h) = std::env::var("HOSTNAME") {
187        let h = h.trim();
188        if !h.is_empty() {
189            return h.to_string();
190        }
191    }
192    if let Ok(h) = std::fs::read_to_string("/etc/hostname") {
193        let h = h.trim();
194        if !h.is_empty() {
195            return h.to_string();
196        }
197    }
198    format!("edge-{}", std::process::id())
199}
200
201impl CpClient {
202    /// Build the client if managed mode is enabled and configured; otherwise `None`. Fails fast on
203    /// an enabled-but-incomplete config so a misconfigured edge doesn't silently run unmanaged.
204    pub fn from_cfg(cfg: &ControlPlaneCfg) -> Result<Option<Arc<CpClient>>> {
205        if !cfg.enabled {
206            return Ok(None);
207        }
208        anyhow::ensure!(
209            !cfg.url.is_empty(),
210            "control_plane.url is required when enabled"
211        );
212        anyhow::ensure!(
213            !cfg.tenant_id.is_empty(),
214            "control_plane.tenant_id is required when enabled"
215        );
216        anyhow::ensure!(
217            !cfg.edge_token.is_empty(),
218            "control_plane.edge_token (or EDGEGUARD_CP_EDGE_TOKEN) is required when enabled"
219        );
220        let http = reqwest::Client::builder()
221            .timeout(Duration::from_secs(10))
222            .build()
223            .context("building control-plane HTTP client")?;
224        let edge_base = format!(
225            "{}/v3/edge/{}",
226            cfg.url.trim_end_matches('/'),
227            cfg.tenant_id
228        );
229        let edge_id = if cfg.edge_id.trim().is_empty() {
230            default_edge_id()
231        } else {
232            cfg.edge_id.trim().to_string()
233        };
234        info!(edge_id, "control-plane managed mode enabled");
235        Ok(Some(Arc::new(CpClient {
236            http,
237            edge_base,
238            token: cfg.edge_token.clone(),
239            edge_id,
240            started_at: std::time::Instant::now(),
241            policy_etag: arc_swap::ArcSwapOption::empty(),
242        })))
243    }
244
245    /// Conditional policy pull. `200` → `Policy`; `304` → `NotModified`; other statuses → `Err`.
246    pub async fn pull_policy(&self, etag: Option<&str>) -> Result<PullResult> {
247        let mut req = self
248            .http
249            .get(format!("{}/policy", self.edge_base))
250            .bearer_auth(&self.token);
251        if let Some(e) = etag {
252            req = req.header(reqwest::header::IF_NONE_MATCH, e);
253        }
254        let resp = req.send().await.context("pulling policy")?;
255        match resp.status() {
256            reqwest::StatusCode::NOT_MODIFIED => Ok(PullResult::NotModified),
257            s if s.is_success() => {
258                let doc: PolicyResp = resp.json().await.context("parsing policy document")?;
259                Ok(PullResult::Policy {
260                    body: doc.body,
261                    etag: doc.etag,
262                })
263            }
264            s => anyhow::bail!("control plane returned {s} for policy pull"),
265        }
266    }
267
268    /// Record the ETag of the policy now applied, so the next usage report carries it.
269    pub fn set_policy_etag(&self, etag: &str) {
270        self.policy_etag.store(Some(Arc::new(etag.to_string())));
271    }
272
273    /// Report a usage delta, with this edge's heartbeat on the same body.
274    pub async fn report_usage(&self, delta: &UsageDelta) -> Result<()> {
275        let report = UsageReport {
276            delta,
277            edge_id: &self.edge_id,
278            agent_version: env!("CARGO_PKG_VERSION"),
279            policy_etag: self.policy_etag.load().as_ref().map(|e| (**e).clone()),
280            uptime_secs: self.started_at.elapsed().as_secs(),
281        };
282        self.http
283            .post(format!("{}/usage", self.edge_base))
284            .bearer_auth(&self.token)
285            .json(&report)
286            .send()
287            .await
288            .context("reporting usage")?
289            .error_for_status()
290            .context("control plane rejected usage report")?;
291        Ok(())
292    }
293
294    /// Pull the tenant's current quota verdict (`over_quota` + `reset_epoch`). Any non-success
295    /// status is an error so the caller keeps the last verdict rather than acting on a partial read.
296    pub async fn pull_quota(&self) -> Result<(bool, i64)> {
297        let resp = self
298            .http
299            .get(format!("{}/quota", self.edge_base))
300            .bearer_auth(&self.token)
301            .send()
302            .await
303            .context("pulling quota")?
304            .error_for_status()
305            .context("control plane rejected quota poll")?;
306        let q: QuotaResp = resp.json().await.context("parsing quota verdict")?;
307        Ok((q.over_quota, q.reset_epoch))
308    }
309
310    /// Ask the control plane for permission to order a certificate for `domains`.
311    ///
312    /// This is the fleet-wide half of the ACME budget. The local ledger in
313    /// [`crate::acme_budget`] can only account limits that are per-edge; the CA's
314    /// per-registered-domain limit is shared across every edge under that domain, and only
315    /// something all of them talk to can count it. That is the control plane.
316    ///
317    /// **The edge sends raw identifiers and nothing else.** It does not compute or send a bucket
318    /// key: the control plane derives those, so that an edge cannot — by accident or otherwise —
319    /// key itself into a private bucket and opt out of the shared limit.
320    ///
321    /// Any error here means the caller falls back to the local budget; see [`LeaseVerdict`].
322    pub async fn acme_lease(
323        &self,
324        directory_url: &str,
325        domains: &[String],
326    ) -> Result<LeaseVerdict> {
327        let resp = self
328            .http
329            .post(format!("{}/acme-lease", self.edge_base))
330            .bearer_auth(&self.token)
331            .json(&serde_json::json!({
332                "directory_url": directory_url,
333                "domains": domains,
334                "edge_id": self.edge_id,
335            }))
336            .send()
337            .await
338            .context("requesting an ACME issuance lease")?;
339        let status = resp.status();
340        if status == reqwest::StatusCode::NOT_FOUND {
341            // A control plane older than this feature. Not an error worth logging on every renewal
342            // — it simply does not keep fleet books, which is the state everything was in before.
343            return Ok(LeaseVerdict::Unmanaged);
344        }
345        let resp = resp
346            .error_for_status()
347            .context("control plane rejected the lease request")?;
348        let body: LeaseResp = resp.json().await.context("parsing the lease decision")?;
349        Ok(match body.decision.as_str() {
350            "granted" => LeaseVerdict::Granted {
351                lease_id: body.lease_id.unwrap_or_default(),
352            },
353            "deferred" => LeaseVerdict::Deferred {
354                bucket: body.bucket.unwrap_or_else(|| "unknown".into()),
355                key: body.key.unwrap_or_default(),
356                retry_at_unix: body.retry_at_unix.unwrap_or(0),
357            },
358            // Including a decision word a newer control plane invented: an edge must not stop
359            // issuing certificates because it did not recognise a string.
360            _ => LeaseVerdict::Unmanaged,
361        })
362    }
363
364    /// Tell the control plane how a leased order ended. Best-effort: the lease is already debited
365    /// and is never refunded, so a lost report costs observability, not correctness. An unreported
366    /// lease is closed as consumed by the control plane once it expires.
367    pub async fn acme_lease_outcome(&self, lease_id: &str, outcome: &str) {
368        if lease_id.is_empty() {
369            return;
370        }
371        let res = self
372            .http
373            .post(format!("{}/acme-lease/{lease_id}/outcome", self.edge_base))
374            .bearer_auth(&self.token)
375            .json(&serde_json::json!({ "outcome": outcome }))
376            .send()
377            .await;
378        if let Err(e) = res {
379            warn!(error = %e, lease_id, "reporting the ACME lease outcome failed");
380        }
381    }
382
383    /// Forward a raw CSP report body (best-effort; errors are logged, never surfaced).
384    pub async fn forward_csp(&self, raw: &Bytes) {
385        let res = self
386            .http
387            .post(format!("{}/csp-report", self.edge_base))
388            .bearer_auth(&self.token)
389            .header(reqwest::header::CONTENT_TYPE, "application/json")
390            .body(raw.clone())
391            .send()
392            .await;
393        if let Err(e) = res {
394            warn!(error = %e, "forwarding CSP report to control plane failed");
395        }
396    }
397}
398
399/// Sleep for `dur`, returning early (`true`) if shutdown is signalled.
400async fn sleep_or_shutdown(rx: &mut watch::Receiver<bool>, dur: Duration) -> bool {
401    tokio::select! {
402        _ = tokio::time::sleep(dur) => *rx.borrow(),
403        _ = rx.changed() => true,
404    }
405}
406
407/// Background loop: poll the control plane for policy and hot-reload it through `build_runtime` +
408/// the arc-swap, exactly like a local file edit. A parse/build failure keeps the current policy.
409pub async fn poll_loop(
410    client: Arc<CpClient>,
411    base: Arc<Config>,
412    runtime: Arc<ArcSwap<Runtime>>,
413    interval: Duration,
414    mut shutdown: watch::Receiver<bool>,
415) {
416    let mut etag: Option<String> = None;
417    info!(?interval, "control-plane policy poller started");
418    loop {
419        match client.pull_policy(etag.as_deref()).await {
420            Ok(PullResult::NotModified) => {}
421            Ok(PullResult::Policy { body, etag: new }) => {
422                match apply_policy(&base, &body, &runtime) {
423                    Ok(()) => {
424                        // Publish to the report loop BEFORE updating our own cursor, so the next
425                        // usage report cannot claim an ETag this edge failed to apply. On the error
426                        // branch below neither is touched — an edge that rejected a policy must
427                        // keep reporting the one it is actually serving, or the drift figure would
428                        // say the fleet is current when it is not.
429                        client.set_policy_etag(&new);
430                        etag = Some(new);
431                        info!("applied policy from control plane");
432                    }
433                    Err(e) => warn!(
434                        error = format!("{e:#}"),
435                        "rejected control-plane policy; keeping current"
436                    ),
437                }
438            }
439            Err(e) => warn!(
440                error = format!("{e:#}"),
441                "policy pull failed; keeping current"
442            ),
443        }
444        if sleep_or_shutdown(&mut shutdown, interval).await {
445            break;
446        }
447    }
448}
449
450/// Overlay a pushed policy onto the local base config, rebuild the runtime, and swap it in.
451fn apply_policy(base: &Config, body: &str, runtime: &ArcSwap<Runtime>) -> Result<()> {
452    let merged = base.with_policy_from(body)?;
453    let rt = crate::build_runtime(Arc::new(merged))?;
454    runtime.store(Arc::new(rt));
455    Ok(())
456}
457
458/// Background loop: flush the usage accumulator to the control plane each period. On a failed
459/// report the drained delta is added back so billable usage isn't lost.
460pub async fn report_loop(
461    client: Arc<CpClient>,
462    metrics: Arc<Metrics>,
463    interval: Duration,
464    mut shutdown: watch::Receiver<bool>,
465) {
466    info!(?interval, "control-plane usage reporter started");
467    loop {
468        if sleep_or_shutdown(&mut shutdown, interval).await {
469            break;
470        }
471        let drained = metrics.drain_usage();
472        if drained.is_empty() {
473            continue;
474        }
475        if let Err(e) = client.report_usage(&UsageDelta::from(drained)).await {
476            warn!(
477                error = format!("{e:#}"),
478                "usage report failed; will retry next period"
479            );
480            metrics.restore_usage(&drained);
481        }
482    }
483    // Best-effort final flush on graceful shutdown so billable usage isn't lost.
484    let drained = metrics.drain_usage();
485    if !drained.is_empty() {
486        if let Err(e) = client.report_usage(&UsageDelta::from(drained)).await {
487            warn!(
488                error = format!("{e:#}"),
489                "final usage report on shutdown failed"
490            );
491        }
492    }
493}
494
495impl From<crate::metrics::DrainedUsage> for UsageDelta {
496    fn from(u: crate::metrics::DrainedUsage) -> Self {
497        UsageDelta {
498            requests: u.requests,
499            ingress_bytes: u.ingress_bytes,
500            egress_bytes: u.egress_bytes,
501            tokens_in: u.tokens_in,
502            tokens_out: u.tokens_out,
503            cost_micros: u.cost_micros,
504            blocked: u.blocked,
505            waf_sqli: u.waf_sqli,
506            waf_xss: u.waf_xss,
507            waf_path_traversal: u.waf_path_traversal,
508            waf_custom: u.waf_custom,
509        }
510    }
511}
512
513/// Background loop: poll the control plane for the tenant's quota verdict and publish it to the
514/// shared [`QuotaState`] the proxy enforces. A failed poll keeps the last verdict (fail-static), so
515/// a control-plane blip neither suddenly blocks nor suddenly unblocks the edge.
516pub async fn quota_loop(
517    client: Arc<CpClient>,
518    quota: Arc<QuotaState>,
519    interval: Duration,
520    mut shutdown: watch::Receiver<bool>,
521) {
522    info!(?interval, "control-plane quota poller started");
523    loop {
524        match client.pull_quota().await {
525            Ok((over_quota, reset_epoch)) => {
526                quota.apply(over_quota, reset_epoch);
527            }
528            Err(e) => warn!(
529                error = format!("{e:#}"),
530                "quota poll failed; keeping last verdict"
531            ),
532        }
533        if sleep_or_shutdown(&mut shutdown, interval).await {
534            break;
535        }
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use crate::config::ControlPlaneCfg;
543    use std::net::SocketAddr;
544    use std::sync::Mutex as StdMutex;
545
546    use axum::{
547        extract::State,
548        http::{HeaderMap, StatusCode},
549        response::IntoResponse,
550        routing::{get, post},
551        Json, Router,
552    };
553
554    const ETAG: &str = "\"abc123\"";
555
556    #[derive(Clone, Default)]
557    struct Stub {
558        last_usage: Arc<StdMutex<Option<serde_json::Value>>>,
559        /// The body of the last lease request, so a test can prove the edge asked at all — and
560        /// that it sent raw identifiers rather than a key it computed itself.
561        last_lease: Arc<StdMutex<Option<serde_json::Value>>>,
562    }
563
564    async fn policy(headers: HeaderMap) -> axum::response::Response {
565        // Conditional: a matching If-None-Match gets a 304.
566        if headers
567            .get(axum::http::header::IF_NONE_MATCH)
568            .and_then(|v| v.to_str().ok())
569            == Some(ETAG)
570        {
571            return StatusCode::NOT_MODIFIED.into_response();
572        }
573        (
574            [(axum::http::header::ETAG, ETAG)],
575            Json(serde_json::json!({
576                "version": 1, "etag": ETAG, "format": "toml",
577                "body": "[auth]\nmode = \"none\"\n", "updated_at": 0
578            })),
579        )
580            .into_response()
581    }
582
583    async fn usage(State(s): State<Stub>, body: axum::body::Bytes) -> StatusCode {
584        *s.last_usage.lock().unwrap() = serde_json::from_slice(&body).ok();
585        StatusCode::ACCEPTED
586    }
587
588    /// Always defers, so a test can tell "the edge asked" from "the edge ordered anyway".
589    async fn acme_lease(
590        State(s): State<Stub>,
591        body: axum::body::Bytes,
592    ) -> axum::response::Response {
593        *s.last_lease.lock().unwrap() = serde_json::from_slice(&body).ok();
594        Json(serde_json::json!({
595            "decision": "deferred",
596            "ca": "letsencrypt",
597            "bucket": "registered_domain",
598            "key": "example.com",
599            "retry_at_unix": 1_800_000_000_i64
600        }))
601        .into_response()
602    }
603
604    async fn quota() -> axum::response::Response {
605        // A trimmed QuotaStatus: the edge only reads over_quota + reset_epoch.
606        Json(serde_json::json!({
607            "over_quota": true, "reset_epoch": 1_782_864_000_i64
608        }))
609        .into_response()
610    }
611
612    async fn spawn_stub() -> (SocketAddr, Stub) {
613        let stub = Stub::default();
614        let app = Router::new()
615            .route("/v3/edge/t1/policy", get(policy))
616            .route("/v3/edge/t1/usage", post(usage))
617            .route("/v3/edge/t1/quota", get(quota))
618            .route("/v3/edge/t1/acme-lease", post(acme_lease))
619            .with_state(stub.clone());
620        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
621        let addr = listener.local_addr().unwrap();
622        tokio::spawn(async move {
623            let _ = axum::serve(listener, app).await;
624        });
625        (addr, stub)
626    }
627
628    fn client(addr: SocketAddr) -> Arc<CpClient> {
629        CpClient::from_cfg(&ControlPlaneCfg {
630            enabled: true,
631            url: format!("http://{addr}"),
632            tenant_id: "t1".into(),
633            edge_token: "tok".into(),
634            ..Default::default()
635        })
636        .unwrap()
637        .unwrap()
638    }
639
640    /// **A per-edge flag must not buy an exemption from a fleet-wide limit.**
641    ///
642    /// `tls.acme.budget_enabled` switches off this edge's own ledger. It must NOT also skip the
643    /// control plane's lease: the CA's per-registered-domain allowance is shared with every other
644    /// edge under that domain, so one box turning the flag off could otherwise spend the whole
645    /// fleet's week. Reported by CodeRabbit on PR #1014.
646    #[tokio::test]
647    async fn budget_enabled_false_still_takes_the_fleet_lease() {
648        use crate::acme::{DeferSource, Issuance};
649        use crate::config::{AcmeCfg, TlsCfg};
650
651        let (addr, stub) = spawn_stub().await;
652        let acme = AcmeCfg {
653            enabled: true,
654            accept_tos: true,
655            domains: vec!["www.example.com".into()],
656            budget_enabled: false, // the local ledger is OFF
657            cache_dir: std::env::temp_dir()
658                .join(format!("eg-lease-{}", std::process::id()))
659                .to_string_lossy()
660                .into_owned(),
661            ..AcmeCfg::default()
662        };
663        let tls = TlsCfg {
664            enabled: true,
665            cert_path: "/nonexistent/cert.pem".into(),
666            key_path: "/nonexistent/key.pem".into(),
667            acme: acme.clone(),
668            ..TlsCfg::default()
669        };
670
671        let issuance = crate::acme::obtain_certificate(&acme, &tls, Some(&client(addr)))
672            .await
673            .expect("a deferral is not an error");
674
675        match issuance {
676            Issuance::Deferred { source, bucket, .. } => {
677                assert_eq!(
678                    source,
679                    DeferSource::Fleet,
680                    "the deferral must come from the fleet, not the local ledger"
681                );
682                assert_eq!(bucket, "registered_domain");
683            }
684            other => panic!("budget_enabled=false bypassed the fleet lease: {other:?}"),
685        }
686
687        // And it asked with raw identifiers — never a bucket key of its own devising, which would
688        // let an edge key itself out of the shared bucket.
689        let asked = stub
690            .last_lease
691            .lock()
692            .unwrap()
693            .clone()
694            .expect("no lease request was sent");
695        assert_eq!(asked["domains"][0], "www.example.com");
696        assert!(
697            asked.get("group_key").is_none() && asked.get("set_key").is_none(),
698            "the edge must not send a computed bucket key: {asked}"
699        );
700    }
701
702    #[test]
703    fn disabled_or_incomplete_config() {
704        // Disabled -> no client.
705        assert!(CpClient::from_cfg(&ControlPlaneCfg::default())
706            .unwrap()
707            .is_none());
708        // Enabled but missing a token -> hard error (don't silently run unmanaged).
709        assert!(CpClient::from_cfg(&ControlPlaneCfg {
710            enabled: true,
711            url: "http://x".into(),
712            tenant_id: "t1".into(),
713            ..Default::default()
714        })
715        .is_err());
716    }
717
718    #[tokio::test]
719    async fn policy_pull_conditional() {
720        let (addr, _) = spawn_stub().await;
721        let c = client(addr);
722        // First pull (no ETag) returns the policy + its ETag.
723        match c.pull_policy(None).await.unwrap() {
724            PullResult::Policy { body, etag } => {
725                assert!(body.contains("mode = \"none\""));
726                assert_eq!(etag, ETAG);
727            }
728            _ => panic!("expected a policy"),
729        }
730        // Re-pull with the ETag -> 304 NotModified.
731        assert!(matches!(
732            c.pull_policy(Some(ETAG)).await.unwrap(),
733            PullResult::NotModified
734        ));
735    }
736
737    #[test]
738    fn the_report_body_carries_the_delta_and_the_heartbeat_in_one_flat_object() {
739        // The control plane parses UsageDelta and EdgeHeartbeat from the SAME body, so the two must
740        // serialize flat and side by side. A nested heartbeat would deserialize as an absent one on
741        // the control plane and the edge would silently never appear in the fleet view.
742        let delta = UsageDelta {
743            requests: 7,
744            egress_bytes: 11,
745            ..Default::default()
746        };
747        let report = UsageReport {
748            delta: &delta,
749            edge_id: "edge-a",
750            agent_version: "9.9.9",
751            policy_etag: Some("\"v2\"".into()),
752            uptime_secs: 42,
753        };
754        let v: serde_json::Value =
755            serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
756        assert_eq!(v["requests"], 7, "the delta must be flattened, not nested");
757        assert_eq!(v["egress_bytes"], 11);
758        assert_eq!(v["edge_id"], "edge-a");
759        assert_eq!(v["agent_version"], "9.9.9");
760        assert_eq!(v["policy_etag"], "\"v2\"");
761        assert_eq!(v["uptime_secs"], 42);
762    }
763
764    #[test]
765    fn an_edge_that_has_applied_no_policy_reports_a_null_etag() {
766        // Null and "" are not interchangeable here: the control plane treats a reported ETag as
767        // "pulled, possibly behind" and its absence as "running local config". Sending an empty
768        // string would make every locally-configured edge look drifted.
769        let delta = UsageDelta::default();
770        let report = UsageReport {
771            delta: &delta,
772            edge_id: "edge-a",
773            agent_version: "9.9.9",
774            policy_etag: None,
775            uptime_secs: 1,
776        };
777        let v: serde_json::Value =
778            serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
779        assert!(v["policy_etag"].is_null());
780    }
781
782    #[test]
783    fn an_auto_detected_edge_id_is_never_empty() {
784        // An empty id makes the heartbeat unreportable on the control-plane side, so the edge would
785        // vanish from the fleet view with nothing anywhere saying why.
786        assert!(!default_edge_id().trim().is_empty());
787    }
788
789    #[tokio::test]
790    async fn usage_report_posts_delta() {
791        let (addr, stub) = spawn_stub().await;
792        let c = client(addr);
793        c.report_usage(&UsageDelta {
794            requests: 3,
795            ingress_bytes: 100,
796            egress_bytes: 250,
797            tokens_in: 1_200,
798            tokens_out: 800,
799            cost_micros: 5_000,
800            blocked: 1,
801            ..Default::default()
802        })
803        .await
804        .unwrap();
805        let got = stub.last_usage.lock().unwrap().clone().unwrap();
806        assert_eq!(got["requests"], 3);
807        assert_eq!(got["tokens_in"], 1_200);
808        assert_eq!(got["cost_micros"], 5_000);
809        assert_eq!(got["ingress_bytes"], 100);
810        assert_eq!(got["egress_bytes"], 250);
811        assert_eq!(got["blocked"], 1);
812    }
813
814    #[tokio::test]
815    async fn quota_pull_returns_verdict() {
816        let (addr, _) = spawn_stub().await;
817        let c = client(addr);
818        let (over, reset) = c.pull_quota().await.unwrap();
819        assert!(over);
820        assert_eq!(reset, 1_782_864_000);
821    }
822
823    #[tokio::test]
824    async fn quota_loop_publishes_to_shared_state() {
825        let (addr, _) = spawn_stub().await;
826        let c = client(addr);
827        let state = Arc::new(QuotaState::default());
828        assert!(!state.blocked(), "starts permissive");
829
830        let (tx, rx) = watch::channel(false);
831        let st = state.clone();
832        let handle =
833            tokio::spawn(async move { quota_loop(c, st, Duration::from_millis(50), rx).await });
834
835        // Give the loop one poll, then assert the verdict landed, and shut it down.
836        for _ in 0..50 {
837            if state.blocked() {
838                break;
839            }
840            tokio::time::sleep(Duration::from_millis(10)).await;
841        }
842        assert!(
843            state.blocked(),
844            "verdict from the control plane should publish"
845        );
846        assert_eq!(state.reset_epoch(), 1_782_864_000);
847        let _ = tx.send(true);
848        let _ = handle.await;
849    }
850}