use std::num::NonZeroU32;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use governor::clock::Clock;
use governor::middleware::NoOpMiddleware;
use governor::state::keyed::DefaultKeyedStateStore;
use governor::state::{InMemoryState, NotKeyed};
use governor::{Quota, RateLimiter};
use metrics::counter;
use vta_config::ServerConfig;
#[allow(deprecated)] use vta_sdk::trust_tasks::{
TASK_VAULT_RELEASE_0_1, TASK_VAULT_RELEASE_0_2, TASK_WEBVH_DIDS_CREATE_1_0,
TASK_WEBVH_DIDS_CREATE_1_1, TASK_WEBVH_DIDS_DELETE_1_0,
};
const MAX_WEIGHT: u32 = 10;
const WARN_EVERY_SECS: u64 = 60;
const SWEEP_EVERY: u64 = 4096;
const UNAUTHENTICATED_SCOPE: &str = "operations-unauthenticated";
#[derive(Clone, Debug, Default)]
pub struct BudgetClock(Option<Arc<ManualTime>>);
#[derive(Debug)]
struct ManualTime {
start: Instant,
elapsed_nanos: AtomicU64,
}
impl BudgetClock {
pub fn manual() -> Self {
Self(Some(Arc::new(ManualTime {
start: Instant::now(),
elapsed_nanos: AtomicU64::new(0),
})))
}
pub fn advance(&self, by: Duration) {
if let Some(t) = &self.0 {
let nanos = u64::try_from(by.as_nanos()).unwrap_or(u64::MAX);
t.elapsed_nanos.fetch_add(nanos, Ordering::Relaxed);
}
}
}
impl Clock for BudgetClock {
type Instant = Instant;
fn now(&self) -> Instant {
match &self.0 {
None => Instant::now(),
Some(t) => t.start + Duration::from_nanos(t.elapsed_nanos.load(Ordering::Relaxed)),
}
}
}
type DirectLimiter = RateLimiter<NotKeyed, InMemoryState, BudgetClock, NoOpMiddleware<Instant>>;
type KeyedLimiter =
RateLimiter<String, DefaultKeyedStateStore<String>, BudgetClock, NoOpMiddleware<Instant>>;
#[allow(deprecated)]
pub(crate) fn weight(type_uri: &str) -> u32 {
let bare = type_uri.split('#').next().unwrap_or(type_uri);
match bare {
TASK_VAULT_RELEASE_0_1 | TASK_VAULT_RELEASE_0_2 => 2,
TASK_WEBVH_DIDS_CREATE_1_0 | TASK_WEBVH_DIDS_CREATE_1_1 | TASK_WEBVH_DIDS_DELETE_1_0 => {
MAX_WEIGHT
}
_ => 1,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Caller<'a> {
Client(&'a str),
Unauthenticated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Scope {
Appliance,
Subject,
Unauthenticated,
}
impl Scope {
pub(crate) fn name(self) -> &'static str {
match self {
Scope::Appliance => vta_sdk::rate_limit::OPERATIONS_LIMITER,
Scope::Subject => vta_sdk::rate_limit::OPERATIONS_SUBJECT_LIMITER,
Scope::Unauthenticated => UNAUTHENTICATED_SCOPE,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Refused {
pub(crate) scope: Scope,
pub(crate) retry_after: Duration,
}
impl Refused {
pub(crate) fn retry_after_secs(self) -> u64 {
crate::routes::rate_limit::ceil_secs(self.retry_after).max(1)
}
}
fn quota(per_sec: Option<u32>, burst: Option<u32>) -> Option<Quota> {
let per_sec = NonZeroU32::new(per_sec?)?;
let burst = burst.unwrap_or(per_sec.get()).max(MAX_WEIGHT);
let burst = NonZeroU32::new(burst).expect("burst is at least MAX_WEIGHT");
Some(Quota::per_second(per_sec).allow_burst(burst))
}
fn refusal(
checked: Result<Result<(), governor::NotUntil<Instant>>, governor::InsufficientCapacity>,
clock: &BudgetClock,
scope: Scope,
) -> Result<(), Refused> {
match checked {
Ok(Ok(())) => Ok(()),
Ok(Err(not_until)) => Err(Refused {
scope,
retry_after: not_until.wait_time_from(clock.now()),
}),
Err(_) => Err(Refused {
scope,
retry_after: Duration::from_secs(1),
}),
}
}
#[derive(Default)]
struct Warning {
refused_since: AtomicU64,
last_secs: AtomicU64,
}
impl Warning {
fn note(&self, scope: Scope) {
let count = self.refused_since.fetch_add(1, Ordering::Relaxed) + 1;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let last = self.last_secs.load(Ordering::Relaxed);
if now.saturating_sub(last) >= WARN_EVERY_SECS
&& self
.last_secs
.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
self.refused_since.fetch_sub(count, Ordering::Relaxed);
tracing::warn!(
scope = scope.name(),
refused = count,
"operation budget exhausted: refusing Trust Tasks (see [server] \
operation_rate_limit_* and docs/02-vta/rate-limiting.md)"
);
}
}
}
pub struct OperationBudget {
clock: BudgetClock,
appliance: Option<DirectLimiter>,
per_subject: KeyedLimiter,
unauthenticated: DirectLimiter,
subject_charges: AtomicU64,
appliance_warning: Warning,
subject_warning: Warning,
unauthenticated_warning: Warning,
}
impl OperationBudget {
pub fn from_config(server: &ServerConfig) -> Self {
Self::with_clock(server, BudgetClock::default())
}
pub fn with_clock(server: &ServerConfig, clock: BudgetClock) -> Self {
let per_subject_per_sec = server.operation_per_subject_per_sec();
let unauthenticated_per_sec = server.operation_unauthenticated_per_sec();
let bounded = |per_sec: u32| {
quota(Some(per_sec.max(1)), None).expect("a rate of at least 1 has a quota")
};
tracing::info!(
appliance_per_sec = server.operation_rate_limit_per_sec,
per_subject_per_sec,
unauthenticated_per_sec,
"operation budgets in force for Trust Tasks"
);
Self {
appliance: quota(
server.operation_rate_limit_per_sec,
server.operation_rate_limit_burst,
)
.map(|q| RateLimiter::direct_with_clock(q, clock.clone())),
per_subject: RateLimiter::new(
bounded(per_subject_per_sec),
DefaultKeyedStateStore::default(),
clock.clone(),
),
unauthenticated: RateLimiter::direct_with_clock(
bounded(unauthenticated_per_sec),
clock.clone(),
),
clock,
subject_charges: AtomicU64::default(),
appliance_warning: Warning::default(),
subject_warning: Warning::default(),
unauthenticated_warning: Warning::default(),
}
}
pub(crate) fn charge(&self, caller: Caller<'_>, type_uri: &str) -> Result<(), Refused> {
let cost = NonZeroU32::new(weight(type_uri)).expect("every weight is at least 1");
let result = match caller {
Caller::Client(did) => {
self.charge_subject(did, cost)
.and_then(|()| match &self.appliance {
None => Ok(()),
Some(limiter) => {
refusal(limiter.check_n(cost), &self.clock, Scope::Appliance)
}
})
}
Caller::Unauthenticated => refusal(
self.unauthenticated.check_n(cost),
&self.clock,
Scope::Unauthenticated,
),
};
if let Err(refused) = result {
counter!("vta_operation_budget_refused_total", "scope" => refused.scope.name())
.increment(1);
match refused.scope {
Scope::Appliance => &self.appliance_warning,
Scope::Subject => &self.subject_warning,
Scope::Unauthenticated => &self.unauthenticated_warning,
}
.note(refused.scope);
}
result
}
fn charge_subject(&self, did: &str, cost: NonZeroU32) -> Result<(), Refused> {
let limiter = &self.per_subject;
if self.subject_charges.fetch_add(1, Ordering::Relaxed) % SWEEP_EVERY == SWEEP_EVERY - 1 {
limiter.retain_recent();
limiter.shrink_to_fit();
}
refusal(
limiter.check_key_n(&did.to_string(), cost),
&self.clock,
Scope::Subject,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use vta_sdk::trust_tasks::TASK_KEYS_SIGN_0_1;
const ALICE: Caller<'static> = Caller::Client("did:key:z6MkAlice");
const BOB: Caller<'static> = Caller::Client("did:key:z6MkBob");
fn server(appliance: Option<u32>, per_subject: Option<u32>, anon: Option<u32>) -> ServerConfig {
let mut s = ServerConfig::default();
s.operation_rate_limit_per_sec = appliance;
s.operation_rate_limit_per_subject_per_sec = per_subject;
s.operation_rate_limit_unauthenticated_per_sec = anon;
s
}
fn budget(per_sec: u32, burst: Option<u32>) -> OperationBudget {
let mut s = server(Some(per_sec), None, None);
s.operation_rate_limit_burst = burst;
frozen(&s)
}
fn frozen(s: &ServerConfig) -> OperationBudget {
OperationBudget::with_clock(s, BudgetClock::manual())
}
#[test]
fn an_unsized_vta_still_bounds_each_client_and_strangers() {
let b = frozen(&ServerConfig::default());
let per_subject = vta_config::DEFAULT_OPERATION_RATE_LIMIT_PER_SUBJECT_PER_SEC;
for i in 0..per_subject {
assert_eq!(b.charge(ALICE, TASK_KEYS_SIGN_0_1), Ok(()), "alice {i}");
}
let refused = b.charge(ALICE, TASK_KEYS_SIGN_0_1).unwrap_err();
assert_eq!(refused.scope, Scope::Subject);
for i in 0..per_subject {
assert_eq!(b.charge(BOB, TASK_KEYS_SIGN_0_1), Ok(()), "bob {i}");
}
let unauthenticated = vta_config::DEFAULT_OPERATION_RATE_LIMIT_UNAUTHENTICATED_PER_SEC;
for i in 0..unauthenticated {
assert_eq!(
b.charge(Caller::Unauthenticated, TASK_KEYS_SIGN_0_1),
Ok(()),
"stranger {i}"
);
}
let refused = b
.charge(Caller::Unauthenticated, TASK_KEYS_SIGN_0_1)
.unwrap_err();
assert_eq!(refused.scope, Scope::Unauthenticated);
}
#[test]
fn a_refused_budget_has_room_again_once_its_wait_has_passed() {
let clock = BudgetClock::manual();
let b = OperationBudget::with_clock(&server(Some(1), None, None), clock.clone());
for _ in 0..10 {
b.charge(ALICE, TASK_KEYS_SIGN_0_1).unwrap();
}
let refused = b.charge(ALICE, TASK_KEYS_SIGN_0_1).unwrap_err();
assert_eq!(refused.retry_after, Duration::from_secs(1));
clock.advance(refused.retry_after);
assert_eq!(b.charge(ALICE, TASK_KEYS_SIGN_0_1), Ok(()));
assert!(
b.charge(ALICE, TASK_KEYS_SIGN_0_1).is_err(),
"one token, not two"
);
}
#[test]
fn the_burst_defaults_to_one_seconds_worth() {
let b = budget(20, None);
for i in 0..20 {
assert_eq!(b.charge(ALICE, TASK_KEYS_SIGN_0_1), Ok(()), "sign {i}");
}
assert!(
b.charge(ALICE, TASK_KEYS_SIGN_0_1).is_err(),
"the 21st is refused"
);
}
#[test]
fn a_release_costs_two_and_a_did_create_ten() {
let b = budget(20, Some(20));
for i in 0..10 {
assert_eq!(
b.charge(ALICE, TASK_VAULT_RELEASE_0_2),
Ok(()),
"release {i}"
);
}
assert!(
b.charge(ALICE, TASK_KEYS_SIGN_0_1).is_err(),
"ten releases spend 20"
);
let b = budget(20, Some(20));
assert_eq!(b.charge(ALICE, TASK_WEBVH_DIDS_CREATE_1_1), Ok(()));
assert_eq!(b.charge(ALICE, TASK_WEBVH_DIDS_DELETE_1_0), Ok(()));
assert!(
b.charge(ALICE, TASK_KEYS_SIGN_0_1).is_err(),
"a create and a delete spend 20"
);
}
#[test]
fn the_burst_is_never_below_the_heaviest_task() {
let b = budget(1, Some(1));
assert_eq!(
b.charge(ALICE, TASK_WEBVH_DIDS_CREATE_1_0),
Ok(()),
"an idle appliance serves a DID create whatever the budget"
);
}
#[test]
fn a_refusal_names_its_bucket_and_when_to_come_back() {
let b = budget(10, Some(10));
for _ in 0..10 {
b.charge(ALICE, TASK_KEYS_SIGN_0_1).unwrap();
}
let refused = b.charge(ALICE, TASK_WEBVH_DIDS_CREATE_1_0).unwrap_err();
assert_eq!(refused.scope, Scope::Appliance);
assert!(refused.retry_after <= Duration::from_secs(1), "{refused:?}");
assert_eq!(refused.retry_after_secs(), 1);
assert_eq!(
Refused {
scope: Scope::Appliance,
retry_after: Duration::from_millis(1_200)
}
.retry_after_secs(),
2,
"rounded up"
);
}
#[test]
fn one_client_cannot_take_another_clients_share() {
let b = frozen(&server(Some(100), Some(10), None));
for _ in 0..10 {
b.charge(ALICE, TASK_KEYS_SIGN_0_1).unwrap();
}
let refused = b.charge(ALICE, TASK_KEYS_SIGN_0_1).unwrap_err();
assert_eq!(refused.scope, Scope::Subject);
for i in 0..10 {
assert_eq!(b.charge(BOB, TASK_KEYS_SIGN_0_1), Ok(()), "bob {i}");
}
}
#[test]
fn a_subjects_refused_excess_leaves_the_appliance_budget_alone() {
let b = frozen(&server(Some(20), Some(10), None));
for _ in 0..50 {
let _ = b.charge(ALICE, TASK_KEYS_SIGN_0_1);
}
for i in 0..10 {
assert_eq!(b.charge(BOB, TASK_KEYS_SIGN_0_1), Ok(()), "bob {i}");
}
}
#[test]
fn unauthenticated_peers_have_their_own_bound() {
let b = frozen(&server(Some(10), None, Some(10)));
for _ in 0..10 {
b.charge(Caller::Unauthenticated, TASK_KEYS_SIGN_0_1)
.unwrap();
}
let refused = b
.charge(Caller::Unauthenticated, TASK_KEYS_SIGN_0_1)
.unwrap_err();
assert_eq!(refused.scope, Scope::Unauthenticated);
for i in 0..10 {
assert_eq!(b.charge(ALICE, TASK_KEYS_SIGN_0_1), Ok(()), "alice {i}");
}
}
#[test]
fn a_fragment_does_not_change_the_weight() {
assert_eq!(weight(&format!("{TASK_VAULT_RELEASE_0_2}#request")), 2);
}
}