1use 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#[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 pub blocked: u64,
42 pub waf_sqli: u64,
45 pub waf_xss: u64,
46 pub waf_path_traversal: u64,
47 pub waf_custom: u64,
48}
49
50#[derive(Debug, Deserialize)]
52struct PolicyResp {
53 etag: String,
54 body: String,
55}
56
57#[derive(Debug, Deserialize)]
59struct QuotaResp {
60 over_quota: bool,
61 #[serde(default)]
62 reset_epoch: i64,
63}
64
65#[derive(Debug, Default)]
69pub struct QuotaState {
70 pub over_quota: AtomicBool,
72 pub reset_epoch: AtomicI64,
74}
75
76impl QuotaState {
77 pub fn blocked(&self) -> bool {
79 self.over_quota.load(Ordering::Relaxed)
80 }
81
82 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
93pub enum PullResult {
95 NotModified,
97 Policy { body: String, etag: String },
99}
100
101pub struct CpClient {
103 http: reqwest::Client,
104 edge_base: String,
106 token: String,
107}
108
109impl CpClient {
110 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 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 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 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 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
213async 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
221pub 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
258fn 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
266pub 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 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
321pub 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 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 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 assert!(CpClient::from_cfg(&ControlPlaneCfg::default())
432 .unwrap()
433 .is_none());
434 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 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 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 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}