use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use arc_swap::ArcSwap;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use tracing::{info, warn};
use crate::config::{Config, ControlPlaneCfg};
use crate::metrics::Metrics;
use crate::proxy::Runtime;
#[derive(Debug, Clone, Copy, Default, Serialize)]
pub struct UsageDelta {
pub requests: u64,
pub ingress_bytes: u64,
pub egress_bytes: u64,
pub tokens_in: u64,
pub tokens_out: u64,
pub cost_micros: u64,
pub blocked: u64,
pub waf_sqli: u64,
pub waf_xss: u64,
pub waf_path_traversal: u64,
pub waf_custom: u64,
}
#[derive(Debug, Serialize)]
struct UsageReport<'a> {
#[serde(flatten)]
delta: &'a UsageDelta,
edge_id: &'a str,
agent_version: &'a str,
policy_etag: Option<String>,
uptime_secs: u64,
}
#[derive(Debug, Deserialize)]
struct PolicyResp {
etag: String,
body: String,
}
#[derive(Debug, Deserialize)]
struct QuotaResp {
over_quota: bool,
#[serde(default)]
reset_epoch: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LeaseVerdict {
Granted { lease_id: String },
Deferred {
bucket: String,
key: String,
retry_at_unix: i64,
},
Unmanaged,
}
#[derive(Debug, Deserialize)]
struct LeaseResp {
decision: String,
#[serde(default)]
lease_id: Option<String>,
#[serde(default)]
bucket: Option<String>,
#[serde(default)]
key: Option<String>,
#[serde(default)]
retry_at_unix: Option<i64>,
}
#[derive(Debug, Default)]
pub struct QuotaState {
pub over_quota: AtomicBool,
pub reset_epoch: AtomicI64,
}
impl QuotaState {
pub fn blocked(&self) -> bool {
self.over_quota.load(Ordering::Relaxed)
}
pub fn reset_epoch(&self) -> i64 {
self.reset_epoch.load(Ordering::Relaxed)
}
fn apply(&self, over_quota: bool, reset_epoch: i64) {
self.over_quota.store(over_quota, Ordering::Relaxed);
self.reset_epoch.store(reset_epoch, Ordering::Relaxed);
}
}
pub enum PullResult {
NotModified,
Policy { body: String, etag: String },
}
pub struct CpClient {
http: reqwest::Client,
edge_base: String,
token: String,
edge_id: String,
started_at: std::time::Instant,
policy_etag: arc_swap::ArcSwapOption<String>,
}
fn default_edge_id() -> String {
if let Ok(h) = std::env::var("HOSTNAME") {
let h = h.trim();
if !h.is_empty() {
return h.to_string();
}
}
if let Ok(h) = std::fs::read_to_string("/etc/hostname") {
let h = h.trim();
if !h.is_empty() {
return h.to_string();
}
}
format!("edge-{}", std::process::id())
}
impl CpClient {
pub fn from_cfg(cfg: &ControlPlaneCfg) -> Result<Option<Arc<CpClient>>> {
if !cfg.enabled {
return Ok(None);
}
anyhow::ensure!(
!cfg.url.is_empty(),
"control_plane.url is required when enabled"
);
anyhow::ensure!(
!cfg.tenant_id.is_empty(),
"control_plane.tenant_id is required when enabled"
);
anyhow::ensure!(
!cfg.edge_token.is_empty(),
"control_plane.edge_token (or EDGEGUARD_CP_EDGE_TOKEN) is required when enabled"
);
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.context("building control-plane HTTP client")?;
let edge_base = format!(
"{}/v3/edge/{}",
cfg.url.trim_end_matches('/'),
cfg.tenant_id
);
let edge_id = if cfg.edge_id.trim().is_empty() {
default_edge_id()
} else {
cfg.edge_id.trim().to_string()
};
info!(edge_id, "control-plane managed mode enabled");
Ok(Some(Arc::new(CpClient {
http,
edge_base,
token: cfg.edge_token.clone(),
edge_id,
started_at: std::time::Instant::now(),
policy_etag: arc_swap::ArcSwapOption::empty(),
})))
}
pub async fn pull_policy(&self, etag: Option<&str>) -> Result<PullResult> {
let mut req = self
.http
.get(format!("{}/policy", self.edge_base))
.bearer_auth(&self.token);
if let Some(e) = etag {
req = req.header(reqwest::header::IF_NONE_MATCH, e);
}
let resp = req.send().await.context("pulling policy")?;
match resp.status() {
reqwest::StatusCode::NOT_MODIFIED => Ok(PullResult::NotModified),
s if s.is_success() => {
let doc: PolicyResp = resp.json().await.context("parsing policy document")?;
Ok(PullResult::Policy {
body: doc.body,
etag: doc.etag,
})
}
s => anyhow::bail!("control plane returned {s} for policy pull"),
}
}
pub fn set_policy_etag(&self, etag: &str) {
self.policy_etag.store(Some(Arc::new(etag.to_string())));
}
pub async fn report_usage(&self, delta: &UsageDelta) -> Result<()> {
let report = UsageReport {
delta,
edge_id: &self.edge_id,
agent_version: env!("CARGO_PKG_VERSION"),
policy_etag: self.policy_etag.load().as_ref().map(|e| (**e).clone()),
uptime_secs: self.started_at.elapsed().as_secs(),
};
self.http
.post(format!("{}/usage", self.edge_base))
.bearer_auth(&self.token)
.json(&report)
.send()
.await
.context("reporting usage")?
.error_for_status()
.context("control plane rejected usage report")?;
Ok(())
}
pub async fn pull_quota(&self) -> Result<(bool, i64)> {
let resp = self
.http
.get(format!("{}/quota", self.edge_base))
.bearer_auth(&self.token)
.send()
.await
.context("pulling quota")?
.error_for_status()
.context("control plane rejected quota poll")?;
let q: QuotaResp = resp.json().await.context("parsing quota verdict")?;
Ok((q.over_quota, q.reset_epoch))
}
pub async fn acme_lease(
&self,
directory_url: &str,
domains: &[String],
) -> Result<LeaseVerdict> {
let resp = self
.http
.post(format!("{}/acme-lease", self.edge_base))
.bearer_auth(&self.token)
.json(&serde_json::json!({
"directory_url": directory_url,
"domains": domains,
"edge_id": self.edge_id,
}))
.send()
.await
.context("requesting an ACME issuance lease")?;
let status = resp.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Ok(LeaseVerdict::Unmanaged);
}
let resp = resp
.error_for_status()
.context("control plane rejected the lease request")?;
let body: LeaseResp = resp.json().await.context("parsing the lease decision")?;
Ok(match body.decision.as_str() {
"granted" => LeaseVerdict::Granted {
lease_id: body.lease_id.unwrap_or_default(),
},
"deferred" => LeaseVerdict::Deferred {
bucket: body.bucket.unwrap_or_else(|| "unknown".into()),
key: body.key.unwrap_or_default(),
retry_at_unix: body.retry_at_unix.unwrap_or(0),
},
_ => LeaseVerdict::Unmanaged,
})
}
pub async fn acme_lease_outcome(&self, lease_id: &str, outcome: &str) {
if lease_id.is_empty() {
return;
}
let res = self
.http
.post(format!("{}/acme-lease/{lease_id}/outcome", self.edge_base))
.bearer_auth(&self.token)
.json(&serde_json::json!({ "outcome": outcome }))
.send()
.await;
if let Err(e) = res {
warn!(error = %e, lease_id, "reporting the ACME lease outcome failed");
}
}
pub async fn forward_csp(&self, raw: &Bytes) {
let res = self
.http
.post(format!("{}/csp-report", self.edge_base))
.bearer_auth(&self.token)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(raw.clone())
.send()
.await;
if let Err(e) = res {
warn!(error = %e, "forwarding CSP report to control plane failed");
}
}
}
async fn sleep_or_shutdown(rx: &mut watch::Receiver<bool>, dur: Duration) -> bool {
tokio::select! {
_ = tokio::time::sleep(dur) => *rx.borrow(),
_ = rx.changed() => true,
}
}
pub async fn poll_loop(
client: Arc<CpClient>,
base: Arc<Config>,
runtime: Arc<ArcSwap<Runtime>>,
interval: Duration,
mut shutdown: watch::Receiver<bool>,
) {
let mut etag: Option<String> = None;
info!(?interval, "control-plane policy poller started");
loop {
match client.pull_policy(etag.as_deref()).await {
Ok(PullResult::NotModified) => {}
Ok(PullResult::Policy { body, etag: new }) => {
match apply_policy(&base, &body, &runtime) {
Ok(()) => {
client.set_policy_etag(&new);
etag = Some(new);
info!("applied policy from control plane");
}
Err(e) => warn!(
error = format!("{e:#}"),
"rejected control-plane policy; keeping current"
),
}
}
Err(e) => warn!(
error = format!("{e:#}"),
"policy pull failed; keeping current"
),
}
if sleep_or_shutdown(&mut shutdown, interval).await {
break;
}
}
}
fn apply_policy(base: &Config, body: &str, runtime: &ArcSwap<Runtime>) -> Result<()> {
let merged = base.with_policy_from(body)?;
let rt = crate::build_runtime(Arc::new(merged))?;
runtime.store(Arc::new(rt));
Ok(())
}
pub async fn report_loop(
client: Arc<CpClient>,
metrics: Arc<Metrics>,
interval: Duration,
mut shutdown: watch::Receiver<bool>,
) {
info!(?interval, "control-plane usage reporter started");
loop {
if sleep_or_shutdown(&mut shutdown, interval).await {
break;
}
let drained = metrics.drain_usage();
if drained.is_empty() {
continue;
}
if let Err(e) = client.report_usage(&UsageDelta::from(drained)).await {
warn!(
error = format!("{e:#}"),
"usage report failed; will retry next period"
);
metrics.restore_usage(&drained);
}
}
let drained = metrics.drain_usage();
if !drained.is_empty() {
if let Err(e) = client.report_usage(&UsageDelta::from(drained)).await {
warn!(
error = format!("{e:#}"),
"final usage report on shutdown failed"
);
}
}
}
impl From<crate::metrics::DrainedUsage> for UsageDelta {
fn from(u: crate::metrics::DrainedUsage) -> Self {
UsageDelta {
requests: u.requests,
ingress_bytes: u.ingress_bytes,
egress_bytes: u.egress_bytes,
tokens_in: u.tokens_in,
tokens_out: u.tokens_out,
cost_micros: u.cost_micros,
blocked: u.blocked,
waf_sqli: u.waf_sqli,
waf_xss: u.waf_xss,
waf_path_traversal: u.waf_path_traversal,
waf_custom: u.waf_custom,
}
}
}
pub async fn quota_loop(
client: Arc<CpClient>,
quota: Arc<QuotaState>,
interval: Duration,
mut shutdown: watch::Receiver<bool>,
) {
info!(?interval, "control-plane quota poller started");
loop {
match client.pull_quota().await {
Ok((over_quota, reset_epoch)) => {
quota.apply(over_quota, reset_epoch);
}
Err(e) => warn!(
error = format!("{e:#}"),
"quota poll failed; keeping last verdict"
),
}
if sleep_or_shutdown(&mut shutdown, interval).await {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ControlPlaneCfg;
use std::net::SocketAddr;
use std::sync::Mutex as StdMutex;
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
const ETAG: &str = "\"abc123\"";
#[derive(Clone, Default)]
struct Stub {
last_usage: Arc<StdMutex<Option<serde_json::Value>>>,
last_lease: Arc<StdMutex<Option<serde_json::Value>>>,
}
async fn policy(headers: HeaderMap) -> axum::response::Response {
if headers
.get(axum::http::header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
== Some(ETAG)
{
return StatusCode::NOT_MODIFIED.into_response();
}
(
[(axum::http::header::ETAG, ETAG)],
Json(serde_json::json!({
"version": 1, "etag": ETAG, "format": "toml",
"body": "[auth]\nmode = \"none\"\n", "updated_at": 0
})),
)
.into_response()
}
async fn usage(State(s): State<Stub>, body: axum::body::Bytes) -> StatusCode {
*s.last_usage.lock().unwrap() = serde_json::from_slice(&body).ok();
StatusCode::ACCEPTED
}
async fn acme_lease(
State(s): State<Stub>,
body: axum::body::Bytes,
) -> axum::response::Response {
*s.last_lease.lock().unwrap() = serde_json::from_slice(&body).ok();
Json(serde_json::json!({
"decision": "deferred",
"ca": "letsencrypt",
"bucket": "registered_domain",
"key": "example.com",
"retry_at_unix": 1_800_000_000_i64
}))
.into_response()
}
async fn quota() -> axum::response::Response {
Json(serde_json::json!({
"over_quota": true, "reset_epoch": 1_782_864_000_i64
}))
.into_response()
}
async fn spawn_stub() -> (SocketAddr, Stub) {
let stub = Stub::default();
let app = Router::new()
.route("/v3/edge/t1/policy", get(policy))
.route("/v3/edge/t1/usage", post(usage))
.route("/v3/edge/t1/quota", get(quota))
.route("/v3/edge/t1/acme-lease", post(acme_lease))
.with_state(stub.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
(addr, stub)
}
fn client(addr: SocketAddr) -> Arc<CpClient> {
CpClient::from_cfg(&ControlPlaneCfg {
enabled: true,
url: format!("http://{addr}"),
tenant_id: "t1".into(),
edge_token: "tok".into(),
..Default::default()
})
.unwrap()
.unwrap()
}
#[tokio::test]
async fn budget_enabled_false_still_takes_the_fleet_lease() {
use crate::acme::{DeferSource, Issuance};
use crate::config::{AcmeCfg, TlsCfg};
let (addr, stub) = spawn_stub().await;
let acme = AcmeCfg {
enabled: true,
accept_tos: true,
domains: vec!["www.example.com".into()],
budget_enabled: false, cache_dir: std::env::temp_dir()
.join(format!("eg-lease-{}", std::process::id()))
.to_string_lossy()
.into_owned(),
..AcmeCfg::default()
};
let tls = TlsCfg {
enabled: true,
cert_path: "/nonexistent/cert.pem".into(),
key_path: "/nonexistent/key.pem".into(),
acme: acme.clone(),
..TlsCfg::default()
};
let issuance = crate::acme::obtain_certificate(&acme, &tls, Some(&client(addr)))
.await
.expect("a deferral is not an error");
match issuance {
Issuance::Deferred { source, bucket, .. } => {
assert_eq!(
source,
DeferSource::Fleet,
"the deferral must come from the fleet, not the local ledger"
);
assert_eq!(bucket, "registered_domain");
}
other => panic!("budget_enabled=false bypassed the fleet lease: {other:?}"),
}
let asked = stub
.last_lease
.lock()
.unwrap()
.clone()
.expect("no lease request was sent");
assert_eq!(asked["domains"][0], "www.example.com");
assert!(
asked.get("group_key").is_none() && asked.get("set_key").is_none(),
"the edge must not send a computed bucket key: {asked}"
);
}
#[test]
fn disabled_or_incomplete_config() {
assert!(CpClient::from_cfg(&ControlPlaneCfg::default())
.unwrap()
.is_none());
assert!(CpClient::from_cfg(&ControlPlaneCfg {
enabled: true,
url: "http://x".into(),
tenant_id: "t1".into(),
..Default::default()
})
.is_err());
}
#[tokio::test]
async fn policy_pull_conditional() {
let (addr, _) = spawn_stub().await;
let c = client(addr);
match c.pull_policy(None).await.unwrap() {
PullResult::Policy { body, etag } => {
assert!(body.contains("mode = \"none\""));
assert_eq!(etag, ETAG);
}
_ => panic!("expected a policy"),
}
assert!(matches!(
c.pull_policy(Some(ETAG)).await.unwrap(),
PullResult::NotModified
));
}
#[test]
fn the_report_body_carries_the_delta_and_the_heartbeat_in_one_flat_object() {
let delta = UsageDelta {
requests: 7,
egress_bytes: 11,
..Default::default()
};
let report = UsageReport {
delta: &delta,
edge_id: "edge-a",
agent_version: "9.9.9",
policy_etag: Some("\"v2\"".into()),
uptime_secs: 42,
};
let v: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
assert_eq!(v["requests"], 7, "the delta must be flattened, not nested");
assert_eq!(v["egress_bytes"], 11);
assert_eq!(v["edge_id"], "edge-a");
assert_eq!(v["agent_version"], "9.9.9");
assert_eq!(v["policy_etag"], "\"v2\"");
assert_eq!(v["uptime_secs"], 42);
}
#[test]
fn an_edge_that_has_applied_no_policy_reports_a_null_etag() {
let delta = UsageDelta::default();
let report = UsageReport {
delta: &delta,
edge_id: "edge-a",
agent_version: "9.9.9",
policy_etag: None,
uptime_secs: 1,
};
let v: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
assert!(v["policy_etag"].is_null());
}
#[test]
fn an_auto_detected_edge_id_is_never_empty() {
assert!(!default_edge_id().trim().is_empty());
}
#[tokio::test]
async fn usage_report_posts_delta() {
let (addr, stub) = spawn_stub().await;
let c = client(addr);
c.report_usage(&UsageDelta {
requests: 3,
ingress_bytes: 100,
egress_bytes: 250,
tokens_in: 1_200,
tokens_out: 800,
cost_micros: 5_000,
blocked: 1,
..Default::default()
})
.await
.unwrap();
let got = stub.last_usage.lock().unwrap().clone().unwrap();
assert_eq!(got["requests"], 3);
assert_eq!(got["tokens_in"], 1_200);
assert_eq!(got["cost_micros"], 5_000);
assert_eq!(got["ingress_bytes"], 100);
assert_eq!(got["egress_bytes"], 250);
assert_eq!(got["blocked"], 1);
}
#[tokio::test]
async fn quota_pull_returns_verdict() {
let (addr, _) = spawn_stub().await;
let c = client(addr);
let (over, reset) = c.pull_quota().await.unwrap();
assert!(over);
assert_eq!(reset, 1_782_864_000);
}
#[tokio::test]
async fn quota_loop_publishes_to_shared_state() {
let (addr, _) = spawn_stub().await;
let c = client(addr);
let state = Arc::new(QuotaState::default());
assert!(!state.blocked(), "starts permissive");
let (tx, rx) = watch::channel(false);
let st = state.clone();
let handle =
tokio::spawn(async move { quota_loop(c, st, Duration::from_millis(50), rx).await });
for _ in 0..50 {
if state.blocked() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(
state.blocked(),
"verdict from the control plane should publish"
);
assert_eq!(state.reset_epoch(), 1_782_864_000);
let _ = tx.send(true);
let _ = handle.await;
}
}