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, Serialize)]
60struct UsageReport<'a> {
61 #[serde(flatten)]
62 delta: &'a UsageDelta,
63 edge_id: &'a str,
64 agent_version: &'a str,
65 policy_etag: Option<String>,
70 uptime_secs: u64,
71}
72
73#[derive(Debug, Deserialize)]
75struct PolicyResp {
76 etag: String,
77 body: String,
78}
79
80#[derive(Debug, Deserialize)]
82struct QuotaResp {
83 over_quota: bool,
84 #[serde(default)]
85 reset_epoch: i64,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum LeaseVerdict {
91 Granted { lease_id: String },
94 Deferred {
96 bucket: String,
97 key: String,
98 retry_at_unix: i64,
99 },
100 Unmanaged,
103}
104
105#[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#[derive(Debug, Default)]
126pub struct QuotaState {
127 pub over_quota: AtomicBool,
129 pub reset_epoch: AtomicI64,
131}
132
133impl QuotaState {
134 pub fn blocked(&self) -> bool {
136 self.over_quota.load(Ordering::Relaxed)
137 }
138
139 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
150pub enum PullResult {
152 NotModified,
154 Policy { body: String, etag: String },
156}
157
158pub struct CpClient {
160 http: reqwest::Client,
161 edge_base: String,
163 token: String,
164 edge_id: String,
166 started_at: std::time::Instant,
170 policy_etag: arc_swap::ArcSwapOption<String>,
174}
175
176fn 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 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 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 pub fn set_policy_etag(&self, etag: &str) {
270 self.policy_etag.store(Some(Arc::new(etag.to_string())));
271 }
272
273 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 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 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 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 _ => LeaseVerdict::Unmanaged,
361 })
362 }
363
364 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 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
399async 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
407pub 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 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
450fn 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
458pub 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 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
513pub 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 last_lease: Arc<StdMutex<Option<serde_json::Value>>>,
562 }
563
564 async fn policy(headers: HeaderMap) -> axum::response::Response {
565 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 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 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 #[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, 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 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 assert!(CpClient::from_cfg(&ControlPlaneCfg::default())
706 .unwrap()
707 .is_none());
708 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 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 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 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 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 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 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}