Compare commits

...

2 Commits

Author SHA1 Message Date
Saman 7bcaca914f
Merge b9eb1406bb into a95678988a 2026-03-21 23:01:57 +03:00
SamNet-dev b9eb1406bb feat(api): add POST /v1/users/{username}/reset-octets endpoint
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
2026-03-20 09:24:30 -05:00
4 changed files with 80 additions and 0 deletions

View File

@ -1060,6 +1060,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. | | `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. | | `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. | | `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: All mutating endpoints:
- Respect `read_only` mode. - Respect `read_only` mode.

View File

@ -372,6 +372,19 @@ async fn handle(
.await; .await;
Ok(success_response(StatusCode::OK, users, revision)) 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") => { ("POST", "/v1/users") => {
if api_cfg.read_only { if api_cfg.read_only {
return Ok(error_response( return Ok(error_response(
@ -523,6 +536,37 @@ async fn handle(
); );
return Ok(success_response(StatusCode::OK, data, revision)); 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 { if method == Method::POST {
return Ok(error_response( return Ok(error_response(
request_id, request_id,

View File

@ -459,6 +459,17 @@ pub(super) struct CreateUserRequest {
pub(super) max_unique_ips: Option<usize>, pub(super) max_unique_ips: Option<usize>,
} }
#[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)] #[derive(Deserialize)]
pub(super) struct PatchUserRequest { pub(super) struct PatchUserRequest {
pub(super) secret: Option<String>, pub(super) secret: Option<String>,

View File

@ -1745,6 +1745,29 @@ impl Stats {
.unwrap_or(0) .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_handshake_timeouts(&self) -> u64 { self.handshake_timeouts.load(Ordering::Relaxed) }
pub fn get_upstream_connect_attempt_total(&self) -> u64 { pub fn get_upstream_connect_attempt_total(&self) -> u64 {
self.upstream_connect_attempt_total.load(Ordering::Relaxed) self.upstream_connect_attempt_total.load(Ordering::Relaxed)