diff --git a/src/api/mod.rs b/src/api/mod.rs index dd0dbf2..c61c59b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -68,7 +68,9 @@ use runtime_zero::{ build_limits_effective_data, build_runtime_gates_data, build_security_posture_data, build_system_info_data, }; -use users::{create_user, delete_user, patch_user, rotate_secret, users_from_config}; +use users::{ + build_user_quota_list, create_user, delete_user, patch_user, rotate_secret, users_from_config, +}; const API_MAX_CONTROL_CONNECTIONS: usize = 1024; const API_HTTP_CONNECTION_TIMEOUT: Duration = Duration::from_secs(15); @@ -504,6 +506,12 @@ async fn handle( .await; Ok(success_response(StatusCode::OK, users, revision)) } + ("GET", "/v1/users/quota") => { + let revision = current_revision(&shared.config_path).await?; + let disk_cfg = load_config_from_disk(&shared.config_path).await?; + let data = build_user_quota_list(&disk_cfg, shared.stats.as_ref()); + Ok(success_response(StatusCode::OK, data, revision)) + } ("POST", "/v1/users") => { if api_cfg.read_only { return Ok(error_response( diff --git a/src/api/model.rs b/src/api/model.rs index abb5d74..76758d9 100644 --- a/src/api/model.rs +++ b/src/api/model.rs @@ -510,6 +510,19 @@ pub(super) struct ResetUserQuotaResponse { pub(super) last_reset_epoch_secs: u64, } +#[derive(Serialize)] +pub(super) struct UserQuotaListData { + pub(super) users: Vec, +} + +#[derive(Serialize)] +pub(super) struct UserQuotaEntry { + pub(super) username: String, + pub(super) data_quota_bytes: u64, + pub(super) used_bytes: u64, + pub(super) last_reset_epoch_secs: u64, +} + #[derive(Deserialize)] pub(super) struct CreateUserRequest { pub(super) username: String, diff --git a/src/api/users.rs b/src/api/users.rs index e60a4e8..24815fc 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -14,8 +14,9 @@ use super::config_store::{ }; use super::model::{ ApiFailure, CreateUserRequest, CreateUserResponse, PatchUserRequest, RotateSecretRequest, - TlsDomainLink, UserInfo, UserLinks, is_valid_ad_tag, is_valid_user_secret, is_valid_username, - parse_optional_expiration, parse_patch_expiration, random_user_secret, + TlsDomainLink, UserInfo, UserLinks, UserQuotaEntry, UserQuotaListData, is_valid_ad_tag, + is_valid_user_secret, is_valid_username, parse_optional_expiration, parse_patch_expiration, + random_user_secret, }; use super::patch::Patch; @@ -568,6 +569,33 @@ pub(super) async fn users_from_config( users } +pub(super) fn build_user_quota_list(cfg: &ProxyConfig, stats: &Stats) -> UserQuotaListData { + let mut names = cfg.access.users.keys().cloned().collect::>(); + names.sort(); + + let snapshot = stats.user_quota_snapshot(); + let mut users = Vec::with_capacity(names.len()); + for username in names { + let Some(&data_quota_bytes) = cfg.access.user_data_quota.get(&username) else { + continue; + }; + if data_quota_bytes == 0 { + continue; + } + let (used_bytes, last_reset_epoch_secs) = snapshot + .get(&username) + .map(|entry| (entry.used_bytes, entry.last_reset_epoch_secs)) + .unwrap_or((0, 0)); + users.push(UserQuotaEntry { + username, + data_quota_bytes, + used_bytes, + last_reset_epoch_secs, + }); + } + UserQuotaListData { users } +} + fn empty_user_links() -> UserLinks { UserLinks { classic: Vec::new(), @@ -959,4 +987,68 @@ mod tests { .any(|entry| entry.domain == "front-a.example.com") ); } + + #[test] + fn build_user_quota_list_skips_users_without_positive_quota_and_sorts_by_username() { + let mut cfg = ProxyConfig::default(); + cfg.access.users.insert( + "alice".to_string(), + "0123456789abcdef0123456789abcdef".to_string(), + ); + cfg.access.users.insert( + "bob".to_string(), + "fedcba9876543210fedcba9876543210".to_string(), + ); + cfg.access.users.insert( + "carol".to_string(), + "aaaabbbbccccddddeeeeffff00001111".to_string(), + ); + // alice has a positive quota and should be listed. + cfg.access + .user_data_quota + .insert("alice".to_string(), 1 << 20); + // bob has no quota entry at all (None) — should be skipped. + // carol has an explicit zero quota — should be skipped. + cfg.access.user_data_quota.insert("carol".to_string(), 0); + + let stats = Stats::new(); + // Charge some traffic against alice; carol gets traffic too but should + // still be filtered out by the quota check. + let alice_stats = stats.get_or_create_user_stats_handle("alice"); + stats.quota_charge_post_write(&alice_stats, 4096); + let carol_stats = stats.get_or_create_user_stats_handle("carol"); + stats.quota_charge_post_write(&carol_stats, 99); + + let data = build_user_quota_list(&cfg, &stats); + + assert_eq!(data.users.len(), 1); + let entry = &data.users[0]; + assert_eq!(entry.username, "alice"); + assert_eq!(entry.data_quota_bytes, 1 << 20); + assert_eq!(entry.used_bytes, 4096); + assert_eq!(entry.last_reset_epoch_secs, 0); + } + + #[test] + fn build_user_quota_list_orders_multiple_users_by_username_ascending() { + let mut cfg = ProxyConfig::default(); + for name in ["charlie", "alice", "bob"] { + cfg.access.users.insert( + name.to_string(), + "0123456789abcdef0123456789abcdef".to_string(), + ); + cfg.access.user_data_quota.insert(name.to_string(), 1 << 30); + } + + let stats = Stats::new(); + let data = build_user_quota_list(&cfg, &stats); + + let names: Vec<&str> = data.users.iter().map(|e| e.username.as_str()).collect(); + assert_eq!(names, vec!["alice", "bob", "charlie"]); + for entry in &data.users { + assert_eq!(entry.used_bytes, 0); + assert_eq!(entry.last_reset_epoch_secs, 0); + assert_eq!(entry.data_quota_bytes, 1 << 30); + } + } }