mirror of
https://github.com/telemt/telemt.git
synced 2026-05-22 19:51:43 +03:00
Add GET /v1/users/quota endpoint
This commit is contained in:
@@ -68,7 +68,9 @@ use runtime_zero::{
|
|||||||
build_limits_effective_data, build_runtime_gates_data, build_security_posture_data,
|
build_limits_effective_data, build_runtime_gates_data, build_security_posture_data,
|
||||||
build_system_info_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_MAX_CONTROL_CONNECTIONS: usize = 1024;
|
||||||
const API_HTTP_CONNECTION_TIMEOUT: Duration = Duration::from_secs(15);
|
const API_HTTP_CONNECTION_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
@@ -504,6 +506,12 @@ async fn handle(
|
|||||||
.await;
|
.await;
|
||||||
Ok(success_response(StatusCode::OK, users, revision))
|
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") => {
|
("POST", "/v1/users") => {
|
||||||
if api_cfg.read_only {
|
if api_cfg.read_only {
|
||||||
return Ok(error_response(
|
return Ok(error_response(
|
||||||
|
|||||||
@@ -510,6 +510,19 @@ pub(super) struct ResetUserQuotaResponse {
|
|||||||
pub(super) last_reset_epoch_secs: u64,
|
pub(super) last_reset_epoch_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct UserQuotaListData {
|
||||||
|
pub(super) users: Vec<UserQuotaEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Deserialize)]
|
||||||
pub(super) struct CreateUserRequest {
|
pub(super) struct CreateUserRequest {
|
||||||
pub(super) username: String,
|
pub(super) username: String,
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ use super::config_store::{
|
|||||||
};
|
};
|
||||||
use super::model::{
|
use super::model::{
|
||||||
ApiFailure, CreateUserRequest, CreateUserResponse, PatchUserRequest, RotateSecretRequest,
|
ApiFailure, CreateUserRequest, CreateUserResponse, PatchUserRequest, RotateSecretRequest,
|
||||||
TlsDomainLink, UserInfo, UserLinks, is_valid_ad_tag, is_valid_user_secret, is_valid_username,
|
TlsDomainLink, UserInfo, UserLinks, UserQuotaEntry, UserQuotaListData, is_valid_ad_tag,
|
||||||
parse_optional_expiration, parse_patch_expiration, random_user_secret,
|
is_valid_user_secret, is_valid_username, parse_optional_expiration, parse_patch_expiration,
|
||||||
|
random_user_secret,
|
||||||
};
|
};
|
||||||
use super::patch::Patch;
|
use super::patch::Patch;
|
||||||
|
|
||||||
@@ -568,6 +569,33 @@ pub(super) async fn users_from_config(
|
|||||||
users
|
users
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_user_quota_list(cfg: &ProxyConfig, stats: &Stats) -> UserQuotaListData {
|
||||||
|
let mut names = cfg.access.users.keys().cloned().collect::<Vec<_>>();
|
||||||
|
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 {
|
fn empty_user_links() -> UserLinks {
|
||||||
UserLinks {
|
UserLinks {
|
||||||
classic: Vec::new(),
|
classic: Vec::new(),
|
||||||
@@ -959,4 +987,68 @@ mod tests {
|
|||||||
.any(|entry| entry.domain == "front-a.example.com")
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user