From b9eb1406bb8e4c07fa3e30faa036f182b824cce8 Mon Sep 17 00:00:00 2001 From: SamNet-dev <98samh@gmail.com> Date: Fri, 20 Mar 2026 09:20:38 -0500 Subject: [PATCH] feat(api): add POST /v1/users/{username}/reset-octets endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add endpoints to reset per-user octet counters without restarting the proxy, enabling external tools to implement periodic (monthly/daily) quota resets. New endpoints: - POST /v1/users/{username}/reset-octets — reset single user - POST /v1/users/reset-octets — reset all users Changes: - stats/mod.rs: add reset_user_octets() and reset_all_user_octets() - api/mod.rs: add route handlers for both endpoints - api/model.rs: add ResetOctetsResponse and ResetAllOctetsResponse - docs/API.md: document new endpoints Closes #510 --- Cargo.lock | 2 +- docs/API.md | 2 ++ src/api/mod.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/api/model.rs | 11 +++++++++++ src/stats/mod.rs | 23 +++++++++++++++++++++++ 5 files changed, 81 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 79e302f..5c01468 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2108,7 +2108,7 @@ dependencies = [ [[package]] name = "telemt" -version = "3.3.25" +version = "3.3.28" dependencies = [ "aes", "anyhow", diff --git a/docs/API.md b/docs/API.md index 9296aff..6b1bc4f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1058,6 +1058,8 @@ Link generation uses active config and enabled modes: | `PATCH /v1/users/{username}` | Partial update of provided fields only. Missing fields remain unchanged. Current implementation persists full config document on success. | | `POST /v1/users/{username}/rotate-secret` | Currently returns `404` in runtime route matcher; request schema is reserved for intended behavior. | | `DELETE /v1/users/{username}` | Deletes only specified user, removes this user from related optional `access.user_*` maps, blocks last-user deletion, and atomically updates only related `access.*` TOML tables. | +| `POST /v1/users/{username}/reset-octets` | Resets the per-user octet counters (`octets_from_client` and `octets_to_client`) to zero. Returns `{ "username": "...", "octets_reset": true }`. Useful for implementing periodic (monthly/daily) quota resets without restarting the proxy. | +| `POST /v1/users/reset-octets` | Resets octet counters for **all** tracked users. Returns `{ "users_reset": N }`. | All mutating endpoints: - Respect `read_only` mode. diff --git a/src/api/mod.rs b/src/api/mod.rs index 0e2edd4..8790a38 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -372,6 +372,19 @@ async fn handle( .await; Ok(success_response(StatusCode::OK, users, revision)) } + ("POST", "/v1/users/reset-octets") => { + let count = shared.stats.reset_all_user_octets(); + shared.runtime_events.record( + "api.users.reset_octets.ok", + format!("users_reset={}", count), + ); + let revision = current_revision(&shared.config_path).await?; + Ok(success_response( + StatusCode::OK, + model::ResetAllOctetsResponse { users_reset: count }, + revision, + )) + } ("POST", "/v1/users") => { if api_cfg.read_only { return Ok(error_response( @@ -523,6 +536,37 @@ async fn handle( ); return Ok(success_response(StatusCode::OK, data, revision)); } + // POST /v1/users/{username}/reset-octets + if method == Method::POST + && let Some(base_user) = user.strip_suffix("/reset-octets") + && !base_user.is_empty() + && !base_user.contains('/') + { + let found = shared.stats.reset_user_octets(base_user); + shared.runtime_events.record( + if found { "api.user.reset_octets.ok" } else { "api.user.reset_octets.not_found" }, + format!("username={}", base_user), + ); + if !found { + return Ok(error_response( + request_id, + ApiFailure::new( + StatusCode::NOT_FOUND, + "user_not_found", + &format!("No stats entry for user '{}'", base_user), + ), + )); + } + let revision = current_revision(&shared.config_path).await?; + return Ok(success_response( + StatusCode::OK, + model::ResetOctetsResponse { + username: base_user.to_string(), + octets_reset: true, + }, + revision, + )); + } if method == Method::POST { return Ok(error_response( request_id, diff --git a/src/api/model.rs b/src/api/model.rs index 91d83b2..7c23b23 100644 --- a/src/api/model.rs +++ b/src/api/model.rs @@ -458,6 +458,17 @@ pub(super) struct CreateUserRequest { pub(super) max_unique_ips: Option, } +#[derive(Serialize)] +pub(super) struct ResetOctetsResponse { + pub(super) username: String, + pub(super) octets_reset: bool, +} + +#[derive(Serialize)] +pub(super) struct ResetAllOctetsResponse { + pub(super) users_reset: usize, +} + #[derive(Deserialize)] pub(super) struct PatchUserRequest { pub(super) secret: Option, diff --git a/src/stats/mod.rs b/src/stats/mod.rs index 0df4dc0..6340bf8 100644 --- a/src/stats/mod.rs +++ b/src/stats/mod.rs @@ -1745,6 +1745,29 @@ impl Stats { .unwrap_or(0) } + + /// Reset per-user octet counters to zero (both from_client and to_client). + /// Used by the API to implement periodic quota resets without restarting the proxy. + pub fn reset_user_octets(&self, user: &str) -> bool { + if let Some(entry) = self.user_stats.get(user) { + entry.octets_from_client.store(0, Ordering::Relaxed); + entry.octets_to_client.store(0, Ordering::Relaxed); + true + } else { + false + } + } + + /// Reset octet counters for all tracked users. + pub fn reset_all_user_octets(&self) -> usize { + let mut count = 0; + for entry in self.user_stats.iter() { + entry.octets_from_client.store(0, Ordering::Relaxed); + entry.octets_to_client.store(0, Ordering::Relaxed); + count += 1; + } + count + } pub fn get_handshake_timeouts(&self) -> u64 { self.handshake_timeouts.load(Ordering::Relaxed) } pub fn get_upstream_connect_attempt_total(&self) -> u64 { self.upstream_connect_attempt_total.load(Ordering::Relaxed)