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 subset of the control plane's `PolicyDocument` the edge needs.
51#[derive(Debug, Deserialize)]
52struct PolicyResp {
53    etag: String,
54    body: String,
55}
56
57/// The subset of the control plane's `QuotaStatus` the edge needs to enforce a hard stop.
58#[derive(Debug, Deserialize)]
59struct QuotaResp {
60    over_quota: bool,
61    #[serde(default)]
62    reset_epoch: i64,
63}
64
65/// Shared, hot-reload-surviving quota verdict the proxy enforces. The [`quota_loop`] writes it from
66/// the control plane's verdict; the proxy reads it per request. It lives in `AppState` (not the
67/// hot-swappable `Runtime`) so a policy reload never resets the enforcement state.
68#[derive(Debug, Default)]
69pub struct QuotaState {
70    /// `true` while the edge is over its quota — the proxy returns `429`.
71    pub over_quota: AtomicBool,
72    /// Unix second the quota resets (period rollover); `0` = unknown. The `Retry-After` hint.
73    pub reset_epoch: AtomicI64,
74}
75
76impl QuotaState {
77    /// Whether the proxy should currently hard-stop the edge's traffic.
78    pub fn blocked(&self) -> bool {
79        self.over_quota.load(Ordering::Relaxed)
80    }
81
82    /// The reset-epoch hint last reported by the control plane (`0` if none yet).
83    pub fn reset_epoch(&self) -> i64 {
84        self.reset_epoch.load(Ordering::Relaxed)
85    }
86
87    fn apply(&self, over_quota: bool, reset_epoch: i64) {
88        self.over_quota.store(over_quota, Ordering::Relaxed);
89        self.reset_epoch.store(reset_epoch, Ordering::Relaxed);
90    }
91}
92
93/// Outcome of a conditional policy pull.
94pub enum PullResult {
95    /// The edge's ETag still matched — nothing changed.
96    NotModified,
97    /// A new policy: its opaque TOML body and the new ETag.
98    Policy { body: String, etag: String },
99}
100
101/// Outbound client to a control plane's per-tenant edge API.
102pub struct CpClient {
103    http: reqwest::Client,
104    /// `{base}/v3/edge/{tenant}` prefix, already trimmed.
105    edge_base: String,
106    token: String,
107}
108
109impl CpClient {
110    /// Build the client if managed mode is enabled and configured; otherwise `None`. Fails fast on
111    /// an enabled-but-incomplete config so a misconfigured edge doesn't silently run unmanaged.
112    pub fn from_cfg(cfg: &ControlPlaneCfg) -> Result<Option<Arc<CpClient>>> {
113        if !cfg.enabled {
114            return Ok(None);
115        }
116        anyhow::ensure!(
117            !cfg.url.is_empty(),
118            "control_plane.url is required when enabled"
119        );
120        anyhow::ensure!(
121            !cfg.tenant_id.is_empty(),
122            "control_plane.tenant_id is required when enabled"
123        );
124        anyhow::ensure!(
125            !cfg.edge_token.is_empty(),
126            "control_plane.edge_token (or EDGEGUARD_CP_EDGE_TOKEN) is required when enabled"
127        );
128        let http = reqwest::Client::builder()
129            .timeout(Duration::from_secs(10))
130            .build()
131            .context("building control-plane HTTP client")?;
132        let edge_base = format!(
133            "{}/v3/edge/{}",
134            cfg.url.trim_end_matches('/'),
135            cfg.tenant_id
136        );
137        Ok(Some(Arc::new(CpClient {
138            http,
139            edge_base,
140            token: cfg.edge_token.clone(),
141        })))
142    }
143
144    /// Conditional policy pull. `200` → `Policy`; `304` → `NotModified`; other statuses → `Err`.
145    pub async fn pull_policy(&self, etag: Option<&str>) -> Result<PullResult> {
146        let mut req = self
147            .http
148            .get(format!("{}/policy", self.edge_base))
149            .bearer_auth(&self.token);
150        if let Some(e) = etag {
151            req = req.header(reqwest::header::IF_NONE_MATCH, e);
152        }
153        let resp = req.send().await.context("pulling policy")?;
154        match resp.status() {
155            reqwest::StatusCode::NOT_MODIFIED => Ok(PullResult::NotModified),
156            s if s.is_success() => {
157                let doc: PolicyResp = resp.json().await.context("parsing policy document")?;
158                Ok(PullResult::Policy {
159                    body: doc.body,
160                    etag: doc.etag,
161                })
162            }
163            s => anyhow::bail!("control plane returned {s} for policy pull"),
164        }
165    }
166
167    /// Report a usage delta.
168    pub async fn report_usage(&self, delta: &UsageDelta) -> Result<()> {
169        self.http
170            .post(format!("{}/usage", self.edge_base))
171            .bearer_auth(&self.token)
172            .json(delta)
173            .send()
174            .await
175            .context("reporting usage")?
176            .error_for_status()
177            .context("control plane rejected usage report")?;
178        Ok(())
179    }
180
181    /// Pull the tenant's current quota verdict (`over_quota` + `reset_epoch`). Any non-success
182    /// status is an error so the caller keeps the last verdict rather than acting on a partial read.
183    pub async fn pull_quota(&self) -> Result<(bool, i64)> {
184        let resp = self
185            .http
186            .get(format!("{}/quota", self.edge_base))
187            .bearer_auth(&self.token)
188            .send()
189            .await
190            .context("pulling quota")?
191            .error_for_status()
192            .context("control plane rejected quota poll")?;
193        let q: QuotaResp = resp.json().await.context("parsing quota verdict")?;
194        Ok((q.over_quota, q.reset_epoch))
195    }
196
197    /// Forward a raw CSP report body (best-effort; errors are logged, never surfaced).
198    pub async fn forward_csp(&self, raw: &Bytes) {
199        let res = self
200            .http
201            .post(format!("{}/csp-report", self.edge_base))
202            .bearer_auth(&self.token)
203            .header(reqwest::header::CONTENT_TYPE, "application/json")
204            .body(raw.clone())
205            .send()
206            .await;
207        if let Err(e) = res {
208            warn!(error = %e, "forwarding CSP report to control plane failed");
209        }
210    }
211}
212
213/// Sleep for `dur`, returning early (`true`) if shutdown is signalled.
214async fn sleep_or_shutdown(rx: &mut watch::Receiver<bool>, dur: Duration) -> bool {
215    tokio::select! {
216        _ = tokio::time::sleep(dur) => *rx.borrow(),
217        _ = rx.changed() => true,
218    }
219}
220
221/// Background loop: poll the control plane for policy and hot-reload it through `build_runtime` +
222/// the arc-swap, exactly like a local file edit. A parse/build failure keeps the current policy.
223pub async fn poll_loop(
224    client: Arc<CpClient>,
225    base: Arc<Config>,
226    runtime: Arc<ArcSwap<Runtime>>,
227    interval: Duration,
228    mut shutdown: watch::Receiver<bool>,
229) {
230    let mut etag: Option<String> = None;
231    info!(?interval, "control-plane policy poller started");
232    loop {
233        match client.pull_policy(etag.as_deref()).await {
234            Ok(PullResult::NotModified) => {}
235            Ok(PullResult::Policy { body, etag: new }) => {
236                match apply_policy(&base, &body, &runtime) {
237                    Ok(()) => {
238                        etag = Some(new);
239                        info!("applied policy from control plane");
240                    }
241                    Err(e) => warn!(
242                        error = format!("{e:#}"),
243                        "rejected control-plane policy; keeping current"
244                    ),
245                }
246            }
247            Err(e) => warn!(
248                error = format!("{e:#}"),
249                "policy pull failed; keeping current"
250            ),
251        }
252        if sleep_or_shutdown(&mut shutdown, interval).await {
253            break;
254        }
255    }
256}
257
258/// Overlay a pushed policy onto the local base config, rebuild the runtime, and swap it in.
259fn apply_policy(base: &Config, body: &str, runtime: &ArcSwap<Runtime>) -> Result<()> {
260    let merged = base.with_policy_from(body)?;
261    let rt = crate::build_runtime(Arc::new(merged))?;
262    runtime.store(Arc::new(rt));
263    Ok(())
264}
265
266/// Background loop: flush the usage accumulator to the control plane each period. On a failed
267/// report the drained delta is added back so billable usage isn't lost.
268pub async fn report_loop(
269    client: Arc<CpClient>,
270    metrics: Arc<Metrics>,
271    interval: Duration,
272    mut shutdown: watch::Receiver<bool>,
273) {
274    info!(?interval, "control-plane usage reporter started");
275    loop {
276        if sleep_or_shutdown(&mut shutdown, interval).await {
277            break;
278        }
279        let drained = metrics.drain_usage();
280        if drained.is_empty() {
281            continue;
282        }
283        if let Err(e) = client.report_usage(&UsageDelta::from(drained)).await {
284            warn!(
285                error = format!("{e:#}"),
286                "usage report failed; will retry next period"
287            );
288            metrics.restore_usage(&drained);
289        }
290    }
291    // Best-effort final flush on graceful shutdown so billable usage isn't lost.
292    let drained = metrics.drain_usage();
293    if !drained.is_empty() {
294        if let Err(e) = client.report_usage(&UsageDelta::from(drained)).await {
295            warn!(
296                error = format!("{e:#}"),
297                "final usage report on shutdown failed"
298            );
299        }
300    }
301}
302
303impl From<crate::metrics::DrainedUsage> for UsageDelta {
304    fn from(u: crate::metrics::DrainedUsage) -> Self {
305        UsageDelta {
306            requests: u.requests,
307            ingress_bytes: u.ingress_bytes,
308            egress_bytes: u.egress_bytes,
309            tokens_in: u.tokens_in,
310            tokens_out: u.tokens_out,
311            cost_micros: u.cost_micros,
312            blocked: u.blocked,
313            waf_sqli: u.waf_sqli,
314            waf_xss: u.waf_xss,
315            waf_path_traversal: u.waf_path_traversal,
316            waf_custom: u.waf_custom,
317        }
318    }
319}
320
321/// Background loop: poll the control plane for the tenant's quota verdict and publish it to the
322/// shared [`QuotaState`] the proxy enforces. A failed poll keeps the last verdict (fail-static), so
323/// a control-plane blip neither suddenly blocks nor suddenly unblocks the edge.
324pub async fn quota_loop(
325    client: Arc<CpClient>,
326    quota: Arc<QuotaState>,
327    interval: Duration,
328    mut shutdown: watch::Receiver<bool>,
329) {
330    info!(?interval, "control-plane quota poller started");
331    loop {
332        match client.pull_quota().await {
333            Ok((over_quota, reset_epoch)) => {
334                quota.apply(over_quota, reset_epoch);
335            }
336            Err(e) => warn!(
337                error = format!("{e:#}"),
338                "quota poll failed; keeping last verdict"
339            ),
340        }
341        if sleep_or_shutdown(&mut shutdown, interval).await {
342            break;
343        }
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::config::ControlPlaneCfg;
351    use std::net::SocketAddr;
352    use std::sync::Mutex as StdMutex;
353
354    use axum::{
355        extract::State,
356        http::{HeaderMap, StatusCode},
357        response::IntoResponse,
358        routing::{get, post},
359        Json, Router,
360    };
361
362    const ETAG: &str = "\"abc123\"";
363
364    #[derive(Clone, Default)]
365    struct Stub {
366        last_usage: Arc<StdMutex<Option<serde_json::Value>>>,
367    }
368
369    async fn policy(headers: HeaderMap) -> axum::response::Response {
370        // Conditional: a matching If-None-Match gets a 304.
371        if headers
372            .get(axum::http::header::IF_NONE_MATCH)
373            .and_then(|v| v.to_str().ok())
374            == Some(ETAG)
375        {
376            return StatusCode::NOT_MODIFIED.into_response();
377        }
378        (
379            [(axum::http::header::ETAG, ETAG)],
380            Json(serde_json::json!({
381                "version": 1, "etag": ETAG, "format": "toml",
382                "body": "[auth]\nmode = \"none\"\n", "updated_at": 0
383            })),
384        )
385            .into_response()
386    }
387
388    async fn usage(State(s): State<Stub>, body: axum::body::Bytes) -> StatusCode {
389        *s.last_usage.lock().unwrap() = serde_json::from_slice(&body).ok();
390        StatusCode::ACCEPTED
391    }
392
393    async fn quota() -> axum::response::Response {
394        // A trimmed QuotaStatus: the edge only reads over_quota + reset_epoch.
395        Json(serde_json::json!({
396            "over_quota": true, "reset_epoch": 1_782_864_000_i64
397        }))
398        .into_response()
399    }
400
401    async fn spawn_stub() -> (SocketAddr, Stub) {
402        let stub = Stub::default();
403        let app = Router::new()
404            .route("/v3/edge/t1/policy", get(policy))
405            .route("/v3/edge/t1/usage", post(usage))
406            .route("/v3/edge/t1/quota", get(quota))
407            .with_state(stub.clone());
408        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
409        let addr = listener.local_addr().unwrap();
410        tokio::spawn(async move {
411            let _ = axum::serve(listener, app).await;
412        });
413        (addr, stub)
414    }
415
416    fn client(addr: SocketAddr) -> Arc<CpClient> {
417        CpClient::from_cfg(&ControlPlaneCfg {
418            enabled: true,
419            url: format!("http://{addr}"),
420            tenant_id: "t1".into(),
421            edge_token: "tok".into(),
422            ..Default::default()
423        })
424        .unwrap()
425        .unwrap()
426    }
427
428    #[test]
429    fn disabled_or_incomplete_config() {
430        // Disabled -> no client.
431        assert!(CpClient::from_cfg(&ControlPlaneCfg::default())
432            .unwrap()
433            .is_none());
434        // Enabled but missing a token -> hard error (don't silently run unmanaged).
435        assert!(CpClient::from_cfg(&ControlPlaneCfg {
436            enabled: true,
437            url: "http://x".into(),
438            tenant_id: "t1".into(),
439            ..Default::default()
440        })
441        .is_err());
442    }
443
444    #[tokio::test]
445    async fn policy_pull_conditional() {
446        let (addr, _) = spawn_stub().await;
447        let c = client(addr);
448        // First pull (no ETag) returns the policy + its ETag.
449        match c.pull_policy(None).await.unwrap() {
450            PullResult::Policy { body, etag } => {
451                assert!(body.contains("mode = \"none\""));
452                assert_eq!(etag, ETAG);
453            }
454            _ => panic!("expected a policy"),
455        }
456        // Re-pull with the ETag -> 304 NotModified.
457        assert!(matches!(
458            c.pull_policy(Some(ETAG)).await.unwrap(),
459            PullResult::NotModified
460        ));
461    }
462
463    #[tokio::test]
464    async fn usage_report_posts_delta() {
465        let (addr, stub) = spawn_stub().await;
466        let c = client(addr);
467        c.report_usage(&UsageDelta {
468            requests: 3,
469            ingress_bytes: 100,
470            egress_bytes: 250,
471            tokens_in: 1_200,
472            tokens_out: 800,
473            cost_micros: 5_000,
474            blocked: 1,
475            ..Default::default()
476        })
477        .await
478        .unwrap();
479        let got = stub.last_usage.lock().unwrap().clone().unwrap();
480        assert_eq!(got["requests"], 3);
481        assert_eq!(got["tokens_in"], 1_200);
482        assert_eq!(got["cost_micros"], 5_000);
483        assert_eq!(got["ingress_bytes"], 100);
484        assert_eq!(got["egress_bytes"], 250);
485        assert_eq!(got["blocked"], 1);
486    }
487
488    #[tokio::test]
489    async fn quota_pull_returns_verdict() {
490        let (addr, _) = spawn_stub().await;
491        let c = client(addr);
492        let (over, reset) = c.pull_quota().await.unwrap();
493        assert!(over);
494        assert_eq!(reset, 1_782_864_000);
495    }
496
497    #[tokio::test]
498    async fn quota_loop_publishes_to_shared_state() {
499        let (addr, _) = spawn_stub().await;
500        let c = client(addr);
501        let state = Arc::new(QuotaState::default());
502        assert!(!state.blocked(), "starts permissive");
503
504        let (tx, rx) = watch::channel(false);
505        let st = state.clone();
506        let handle =
507            tokio::spawn(async move { quota_loop(c, st, Duration::from_millis(50), rx).await });
508
509        // Give the loop one poll, then assert the verdict landed, and shut it down.
510        for _ in 0..50 {
511            if state.blocked() {
512                break;
513            }
514            tokio::time::sleep(Duration::from_millis(10)).await;
515        }
516        assert!(
517            state.blocked(),
518            "verdict from the control plane should publish"
519        );
520        assert_eq!(state.reset_epoch(), 1_782_864_000);
521        let _ = tx.send(true);
522        let _ = handle.await;
523    }
524}