use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use clawft_types::config::MeshAdmissionMode;
use crate::mesh_admit::{
Admission, AdmissionGate, AdmitContext, Grant, HelloFailure, PeerClass, PeerLimits, Refusal,
VerifiedHello,
};
use crate::revocation::RevocationList;
pub const VERDICT_TTL: Duration = Duration::from_secs(300);
pub const DENY_TTL: Duration = Duration::from_secs(5);
const MAX_CACHE: usize = 1024;
const WARN_EVERY: Duration = Duration::from_secs(60);
const MAX_RECORDS: usize = 256;
#[derive(Debug, Clone)]
pub struct VerdictRequest {
pub node_id: String,
pub pubkey: [u8; 32],
pub class: PeerClass,
pub platform: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
Permit,
PermitAs(PeerClass),
Deny(String),
Unavailable(String),
}
#[async_trait]
pub trait VerdictSource: Send + Sync + 'static {
async fn verdict(&self, req: &VerdictRequest) -> Verdict;
fn is_open(&self) -> bool {
false
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct OpenVerdicts;
#[async_trait]
impl VerdictSource for OpenVerdicts {
async fn verdict(&self, _: &VerdictRequest) -> Verdict {
Verdict::Permit
}
fn is_open(&self) -> bool {
true
}
}
#[cfg(feature = "exochain")]
enum GateState {
Gate(Arc<dyn crate::gate::GateBackend>),
Open,
Closed,
}
#[cfg(feature = "exochain")]
#[derive(Default)]
pub struct GateVerdictSource {
gate: std::sync::OnceLock<GateState>,
}
#[cfg(feature = "exochain")]
impl GateVerdictSource {
pub fn late() -> Self {
Self::default()
}
pub fn bind(&self, gate: Arc<dyn crate::gate::GateBackend>) {
let _ = self.gate.set(GateState::Gate(gate));
}
pub fn bind_open(&self) {
let _ = self.gate.set(GateState::Open);
}
pub fn bind_closed(&self) {
let _ = self.gate.set(GateState::Closed);
}
}
#[cfg(feature = "exochain")]
#[async_trait]
impl VerdictSource for GateVerdictSource {
async fn verdict(&self, req: &VerdictRequest) -> Verdict {
use crate::gate::GateDecision;
match self.gate.get() {
None => Verdict::Unavailable("governance gate not ready".into()),
Some(GateState::Open) => Verdict::Permit,
Some(GateState::Closed) => Verdict::Unavailable(
"no governance gate; set kernel.mesh.admission_open_membership to allow".into(),
),
Some(GateState::Gate(g)) => {
let ctx = serde_json::json!({
"node_id": req.node_id,
"platform": req.platform,
"class": format!("{:?}", req.class),
});
match g.check(&req.node_id, "cluster.join", &ctx) {
GateDecision::Permit { .. } => Verdict::Permit,
GateDecision::Defer { reason } => Verdict::Deny(format!("deferred: {reason}")),
GateDecision::Deny { reason, .. } => Verdict::Deny(reason),
}
}
}
}
fn is_open(&self) -> bool {
matches!(self.gate.get(), Some(GateState::Open))
}
}
#[derive(Debug, Clone)]
pub struct AdmissionRecord {
pub node_id: Option<String>,
pub code: &'static str,
pub detail: String,
pub mode: MeshAdmissionMode,
pub admitted: bool,
}
type CacheKey = (String, [u8; 32], PeerClass, String);
pub struct CryptoGate {
genesis: [u8; 32],
revocations: Arc<RevocationList>,
verdicts: Arc<dyn VerdictSource>,
mode: MeshAdmissionMode,
ttl: Duration,
cache: Mutex<HashMap<CacheKey, (Instant, Verdict)>>,
records: Mutex<VecDeque<AdmissionRecord>>,
last_warn: Mutex<HashMap<String, Instant>>,
}
impl CryptoGate {
pub fn new(
genesis: [u8; 32],
revocations: Arc<RevocationList>,
verdicts: Arc<dyn VerdictSource>,
mode: MeshAdmissionMode,
) -> Self {
Self {
genesis,
revocations,
verdicts,
mode,
ttl: VERDICT_TTL,
cache: Mutex::new(HashMap::new()),
records: Mutex::new(VecDeque::new()),
last_warn: Mutex::new(HashMap::new()),
}
}
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl = ttl;
self
}
pub fn records(&self) -> Vec<AdmissionRecord> {
self.records.lock().unwrap().iter().cloned().collect()
}
pub fn cache_len(&self) -> usize {
self.cache.lock().unwrap().len()
}
fn warn_due(&self, key: &str) -> bool {
let mut w = self.last_warn.lock().unwrap();
if w.len() >= MAX_CACHE {
w.retain(|_, t| t.elapsed() < WARN_EVERY);
}
match w.get(key) {
Some(t) if t.elapsed() < WARN_EVERY => false,
_ => {
w.insert(key.to_owned(), Instant::now());
true
}
}
}
fn finish(&self, node_id: Option<&str>, grant: Grant, refusal: Refusal) -> Admission {
let admitted = self.mode != MeshAdmissionMode::Enforce;
if self.warn_due(&format!("{}/{}", node_id.unwrap_or("-"), refusal.code)) {
tracing::warn!(
node = node_id.unwrap_or("-"), code = refusal.code, detail = %refusal.detail,
mode = ?self.mode, admitted, "mesh admission failure"
);
}
let mut rec = self.records.lock().unwrap();
if rec.len() >= MAX_RECORDS {
rec.pop_front();
}
rec.push_back(AdmissionRecord {
node_id: node_id.map(str::to_owned),
code: refusal.code,
detail: refusal.detail.clone(),
mode: self.mode,
admitted,
});
if admitted {
Admission::Admit(Grant {
observed: Some(refusal),
trust_scope: false,
admitted: false,
..grant
})
} else {
Admission::Refuse(refusal)
}
}
async fn cached_verdict(&self, req: &VerdictRequest) -> Verdict {
let key: CacheKey = (req.node_id.clone(), req.pubkey, req.class, req.platform.clone());
if let Some((at, v)) = self.cache.lock().unwrap().get(&key) {
let ttl = if matches!(v, Verdict::Deny(_)) { DENY_TTL.min(self.ttl) } else { self.ttl };
if at.elapsed() < ttl {
return v.clone();
}
}
let v = self.verdicts.verdict(req).await;
if !matches!(v, Verdict::Unavailable(_)) {
let mut c = self.cache.lock().unwrap();
if c.len() >= MAX_CACHE {
let ttl = self.ttl;
c.retain(|_, (t, _)| t.elapsed() < ttl);
while c.len() >= MAX_CACHE {
let Some(oldest) = c.iter().min_by_key(|(_, (t, _))| *t).map(|(k, _)| k.clone())
else {
break;
};
c.remove(&oldest);
}
}
c.insert(key, (Instant::now(), v.clone()));
}
v
}
}
#[async_trait]
impl AdmissionGate for CryptoGate {
fn strict(&self) -> bool {
self.mode == MeshAdmissionMode::Enforce
}
fn is_revoked(&self, node_id: &str) -> bool {
self.mode == MeshAdmissionMode::Enforce && self.revocations.is_revoked(node_id)
}
async fn admit(&self, hello: &VerifiedHello, ctx: &AdmitContext) -> Admission {
if self.mode == MeshAdmissionMode::Off {
return Admission::Admit(Grant::open(ctx.class));
}
let enforce = self.mode == MeshAdmissionMode::Enforce;
let id = hello.node_id.as_str();
if !nested_peer_allowed(NESTED_PEERS.get().map(Vec::as_slice), id) {
return Admission::Refuse(Refusal { code: "nested_peer_not_granted", detail: "peer is outside the master grant".into() });
}
let base = Grant::open(ctx.class);
if hello.genesis_hash != self.genesis {
return self.finish(Some(id), base, Refusal {
code: "wrong_genesis",
detail: "hello genesis differs from the pinned cluster genesis".into(),
});
}
if self.revocations.is_revoked(id) {
return self.finish(Some(id), base, Refusal {
code: "revoked",
detail: "node id is on the revocation list".into(),
});
}
let req = VerdictRequest {
node_id: id.to_owned(),
pubkey: hello.pubkey,
class: ctx.class,
platform: hello.platform.clone(),
};
let class = match self.cached_verdict(&req).await {
Verdict::Permit => ctx.class,
Verdict::PermitAs(c) if enforce => c,
Verdict::PermitAs(_) => ctx.class,
Verdict::Deny(why) => {
return self.finish(Some(id), base, Refusal { code: "verdict_denied", detail: why });
}
Verdict::Unavailable(why) => {
return self.finish(Some(id), base, Refusal { code: "verdict_unavailable", detail: why });
}
};
Admission::Admit(Grant {
limits: if enforce && class == PeerClass::Leaf { PeerLimits::Leaf } else { PeerLimits::None },
class,
admitted: enforce,
trust_scope: enforce && class == PeerClass::Node,
observed: None,
})
}
async fn admit_unverified(&self, failure: &HelloFailure, ctx: &AdmitContext) -> Admission {
if self.mode == MeshAdmissionMode::Off {
return Admission::Admit(Grant::open(ctx.class));
}
let refusal = Refusal { code: failure.code(), detail: format!("{failure:?}") };
self.finish(None, Grant::open(ctx.class), refusal)
}
}
static NESTED_PEERS: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
pub fn install_nested_peer_ceiling(peers: Vec<String>) -> Result<(), String> {
NESTED_PEERS.set(peers).map_err(|_| "nested peer ceiling already installed".into())
}
fn nested_peer_allowed(ceiling: Option<&[String]>, id: &str) -> bool {
ceiling.is_none_or(|peers| peers.iter().any(|peer| peer == id))
}
pub fn nested_peer_ceiling_active() -> bool { NESTED_PEERS.get().is_some() }
#[cfg(test)]
mod nested_peer_tests {
use super::nested_peer_allowed;
#[test]
fn granted_peer_ceiling_never_becomes_open_membership() {
let peers = vec!["granted".to_owned()];
assert!(nested_peer_allowed(None, "ordinary-node"));
assert!(nested_peer_allowed(Some(&peers), "granted"));
assert!(!nested_peer_allowed(Some(&peers), "unregistered"));
assert!(!nested_peer_allowed(Some(&[]), "unregistered"));
}
}