mirror of
https://github.com/telemt/telemt.git
synced 2026-05-22 19:51:43 +03:00
Compare commits
6 Commits
3.4.11
...
9e877e45c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e877e45c9 | ||
|
|
0af64a4d0a | ||
|
|
f77e9b8881 | ||
|
|
25ca64de1b | ||
|
|
8895947414 | ||
|
|
7a284623d6 |
@@ -13,7 +13,7 @@ API runtime is configured in `[server.api]`.
|
||||
| `listen` | `string` (`IP:PORT`) | `0.0.0.0:9091` | API bind address. |
|
||||
| `whitelist` | `CIDR[]` | `127.0.0.0/8` | Source IP allowlist. Empty list means allow all. |
|
||||
| `auth_header` | `string` | `""` | Exact value for `Authorization` header. Empty disables header auth. |
|
||||
| `request_body_limit_bytes` | `usize` | `65536` | Maximum request body size. Must be `> 0`. |
|
||||
| `request_body_limit_bytes` | `usize` | `65536` | Maximum request body size. Must be within `[1, 1048576]`. |
|
||||
| `minimal_runtime_enabled` | `bool` | `true` | Enables runtime snapshot endpoints requiring ME pool read-lock aggregation. |
|
||||
| `minimal_runtime_cache_ttl_ms` | `u64` | `1000` | Cache TTL for minimal snapshots. `0` disables cache; valid range is `[0, 60000]`. |
|
||||
| `runtime_edge_enabled` | `bool` | `false` | Enables runtime edge endpoints with cached aggregation payloads. |
|
||||
@@ -26,7 +26,7 @@ API runtime is configured in `[server.api]`.
|
||||
|
||||
Runtime validation for API config:
|
||||
- `server.api.listen` must be a valid `IP:PORT`.
|
||||
- `server.api.request_body_limit_bytes` must be `> 0`.
|
||||
- `server.api.request_body_limit_bytes` must be within `[1, 1048576]`.
|
||||
- `server.api.minimal_runtime_cache_ttl_ms` must be within `[0, 60000]`.
|
||||
- `server.api.runtime_edge_cache_ttl_ms` must be within `[0, 60000]`.
|
||||
- `server.api.runtime_edge_top_n` must be within `[1, 1000]`.
|
||||
@@ -76,13 +76,14 @@ Requests are processed in this order:
|
||||
|
||||
Notes:
|
||||
- Whitelist is evaluated against the direct TCP peer IP (`SocketAddr::ip`), without `X-Forwarded-For` support.
|
||||
- `Authorization` check is exact string equality against configured `auth_header`.
|
||||
- `Authorization` check is exact constant-time byte equality against configured `auth_header`.
|
||||
|
||||
## Endpoint Matrix
|
||||
|
||||
| Method | Path | Body | Success | `data` contract |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `GET` | `/v1/health` | none | `200` | `HealthData` |
|
||||
| `GET` | `/v1/health/ready` | none | `200` or `503` | `HealthReadyData` |
|
||||
| `GET` | `/v1/system/info` | none | `200` | `SystemInfoData` |
|
||||
| `GET` | `/v1/runtime/gates` | none | `200` | `RuntimeGatesData` |
|
||||
| `GET` | `/v1/runtime/initialization` | none | `200` | `RuntimeInitializationData` |
|
||||
@@ -102,13 +103,50 @@ Notes:
|
||||
| `GET` | `/v1/runtime/me-selftest` | none | `200` | `RuntimeMeSelftestData` |
|
||||
| `GET` | `/v1/runtime/connections/summary` | none | `200` | `RuntimeEdgeConnectionsSummaryData` |
|
||||
| `GET` | `/v1/runtime/events/recent` | none | `200` | `RuntimeEdgeEventsData` |
|
||||
| `GET` | `/v1/stats/users/active-ips` | none | `200` | `UserActiveIps[]` |
|
||||
| `GET` | `/v1/stats/users` | none | `200` | `UserInfo[]` |
|
||||
| `GET` | `/v1/users` | none | `200` | `UserInfo[]` |
|
||||
| `POST` | `/v1/users` | `CreateUserRequest` | `201` | `CreateUserResponse` |
|
||||
| `POST` | `/v1/users` | `CreateUserRequest` | `201` or `202` | `CreateUserResponse` |
|
||||
| `GET` | `/v1/users/{username}` | none | `200` | `UserInfo` |
|
||||
| `PATCH` | `/v1/users/{username}` | `PatchUserRequest` | `200` | `UserInfo` |
|
||||
| `DELETE` | `/v1/users/{username}` | none | `200` | `string` (deleted username) |
|
||||
| `POST` | `/v1/users/{username}/rotate-secret` | `RotateSecretRequest` or empty body | `404` | `ErrorResponse` (`not_found`, current runtime behavior) |
|
||||
| `PATCH` | `/v1/users/{username}` | `PatchUserRequest` | `200` or `202` | `UserInfo` |
|
||||
| `DELETE` | `/v1/users/{username}` | none | `200` or `202` | `DeleteUserResponse` |
|
||||
| `POST` | `/v1/users/{username}/rotate-secret` | `RotateSecretRequest` or empty body | `200` or `202` | `CreateUserResponse` |
|
||||
| `POST` | `/v1/users/{username}/reset-quota` | empty body | `200` | `ResetUserQuotaResponse` |
|
||||
|
||||
## Endpoint Behavior
|
||||
|
||||
| Endpoint | Function |
|
||||
| --- | --- |
|
||||
| `GET /v1/health` | Returns basic API liveness and current `read_only` flag. |
|
||||
| `GET /v1/health/ready` | Returns readiness based on admission state and upstream health; returns `503` when not ready. |
|
||||
| `GET /v1/system/info` | Returns binary/build metadata, process uptime, config path/hash, and reload counters. |
|
||||
| `GET /v1/runtime/gates` | Returns admission, ME readiness, fallback/reroute, and startup gate state. |
|
||||
| `GET /v1/runtime/initialization` | Returns startup progress, ME initialization status, and per-component timeline. |
|
||||
| `GET /v1/limits/effective` | Returns effective timeout, upstream, ME, unique-IP, and TCP policy values after config defaults/resolution. |
|
||||
| `GET /v1/security/posture` | Returns current API/security/telemetry posture flags. |
|
||||
| `GET /v1/security/whitelist` | Returns configured API whitelist CIDRs. |
|
||||
| `GET /v1/stats/summary` | Returns compact core counters and classed failure counters. |
|
||||
| `GET /v1/stats/zero/all` | Returns zero-cost core, upstream, ME, pool, and desync counters. |
|
||||
| `GET /v1/stats/upstreams` | Returns upstream zero counters and, when enabled/available, runtime upstream health rows. |
|
||||
| `GET /v1/stats/minimal/all` | Returns cached minimal ME writer/DC/runtime/network-path snapshot. |
|
||||
| `GET /v1/stats/me-writers` | Returns cached ME writer coverage and per-writer status rows. |
|
||||
| `GET /v1/stats/dcs` | Returns cached per-DC endpoint/writer/load status rows. |
|
||||
| `GET /v1/runtime/me_pool_state` | Returns active/warm/pending/draining generation state, writer contour/health, and refill state. |
|
||||
| `GET /v1/runtime/me_quality` | Returns ME lifecycle counters, route-drop counters, family states, drain gate, and per-DC RTT/coverage. |
|
||||
| `GET /v1/runtime/upstream_quality` | Returns upstream policy/counters plus runtime upstream health rows when available. |
|
||||
| `GET /v1/runtime/nat_stun` | Returns NAT/STUN runtime flags, configured/live STUN servers, reflection cache, and backoff. |
|
||||
| `GET /v1/runtime/me-selftest` | Returns ME self-test state for KDF, time skew, IP family, PID, and SOCKS BND observations. |
|
||||
| `GET /v1/runtime/connections/summary` | Returns runtime-edge connection totals and top-N users by connections/throughput. |
|
||||
| `GET /v1/runtime/events/recent` | Returns recent API/runtime event records with optional `limit` query. |
|
||||
| `GET /v1/stats/users/active-ips` | Returns users that currently have non-empty active source-IP lists. |
|
||||
| `GET /v1/stats/users` | Alias of `GET /v1/users`; returns disk-first user views with runtime lag flag. |
|
||||
| `GET /v1/users` | Returns disk-first user views sorted by username. |
|
||||
| `POST /v1/users` | Creates a user and returns the effective user view plus secret. |
|
||||
| `GET /v1/users/{username}` | Returns one disk-first user view or `404` when absent. |
|
||||
| `PATCH /v1/users/{username}` | Updates selected per-user fields with JSON Merge Patch semantics. |
|
||||
| `DELETE /v1/users/{username}` | Deletes one user and related per-user access-map entries. |
|
||||
| `POST /v1/users/{username}/rotate-secret` | Rotates one user's secret and returns the effective secret. |
|
||||
| `POST /v1/users/{username}/reset-quota` | Resets one user's runtime quota counter and persists quota state. |
|
||||
|
||||
## Common Error Codes
|
||||
|
||||
@@ -118,7 +156,7 @@ Notes:
|
||||
| `401` | `unauthorized` | Missing/invalid `Authorization` when `auth_header` is configured. |
|
||||
| `403` | `forbidden` | Source IP is not allowed by whitelist. |
|
||||
| `403` | `read_only` | Mutating endpoint called while `read_only=true`. |
|
||||
| `404` | `not_found` | Unknown route, unknown user, or unsupported sub-route (including current `rotate-secret` route). |
|
||||
| `404` | `not_found` | Unknown route, unknown user, or unsupported sub-route. |
|
||||
| `405` | `method_not_allowed` | Unsupported method for `/v1/users/{username}` route shape. |
|
||||
| `409` | `revision_conflict` | `If-Match` revision mismatch. |
|
||||
| `409` | `user_exists` | User already exists on create. |
|
||||
@@ -132,11 +170,12 @@ Notes:
|
||||
| Case | Behavior |
|
||||
| --- | --- |
|
||||
| Path matching | Exact match on `req.uri().path()`. Query string does not affect route matching. |
|
||||
| Trailing slash | Not normalized. Example: `/v1/users/` is `404`. |
|
||||
| Trailing slash | Trimmed for route matching when path length is greater than 1. Example: `/v1/users/` matches `/v1/users`. |
|
||||
| Username route with extra slash | `/v1/users/{username}/...` is not treated as user route and returns `404`. |
|
||||
| `PUT /v1/users/{username}` | `405 method_not_allowed`. |
|
||||
| `POST /v1/users/{username}` | `404 not_found`. |
|
||||
| `POST /v1/users/{username}/rotate-secret` | `404 not_found` in current release due route matcher limitation. |
|
||||
| `POST /v1/users/{username}/rotate-secret/` | Trailing slash is trimmed and the route matches `rotate-secret`. |
|
||||
| `POST /v1/users/{username}/reset-quota/` | Trailing slash is trimmed and the route matches `reset-quota`. |
|
||||
|
||||
## Body and JSON Semantics
|
||||
|
||||
@@ -146,7 +185,7 @@ Notes:
|
||||
- Invalid JSON returns `400 bad_request` (`Invalid JSON body`).
|
||||
- `Content-Type` is not required for JSON parsing.
|
||||
- Unknown JSON fields are ignored by deserialization.
|
||||
- `PATCH` updates only provided fields and does not support explicit clearing of optional fields.
|
||||
- `PATCH` uses JSON Merge Patch semantics for optional per-user fields: omitted means unchanged, explicit `null` removes the config entry, and a non-null value sets it.
|
||||
- `If-Match` supports both quoted and unquoted values; surrounding whitespace is trimmed.
|
||||
|
||||
## Query Parameters
|
||||
@@ -166,17 +205,21 @@ Notes:
|
||||
| `max_tcp_conns` | `usize` | no | Per-user concurrent TCP limit. |
|
||||
| `expiration_rfc3339` | `string` | no | RFC3339 expiration timestamp. |
|
||||
| `data_quota_bytes` | `u64` | no | Per-user traffic quota. |
|
||||
| `rate_limit_up_bps` | `u64` | no | Per-user upload rate limit in bytes per second. |
|
||||
| `rate_limit_down_bps` | `u64` | no | Per-user download rate limit in bytes per second. |
|
||||
| `max_unique_ips` | `usize` | no | Per-user unique source IP limit. |
|
||||
|
||||
### `PatchUserRequest`
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `secret` | `string` | no | Exactly 32 hex chars. |
|
||||
| `user_ad_tag` | `string` | no | Exactly 32 hex chars. |
|
||||
| `max_tcp_conns` | `usize` | no | Per-user concurrent TCP limit. |
|
||||
| `expiration_rfc3339` | `string` | no | RFC3339 expiration timestamp. |
|
||||
| `data_quota_bytes` | `u64` | no | Per-user traffic quota. |
|
||||
| `max_unique_ips` | `usize` | no | Per-user unique source IP limit. |
|
||||
| `user_ad_tag` | `string|null` | no | Exactly 32 hex chars; `null` removes the per-user ad tag. |
|
||||
| `max_tcp_conns` | `usize|null` | no | Per-user concurrent TCP limit; `null` removes the per-user override. |
|
||||
| `expiration_rfc3339` | `string|null` | no | RFC3339 expiration timestamp; `null` removes the expiration. |
|
||||
| `data_quota_bytes` | `u64|null` | no | Per-user traffic quota; `null` removes the per-user quota. |
|
||||
| `rate_limit_up_bps` | `u64|null` | no | Per-user upload rate limit in bytes per second; `null` removes the upload direction limit. |
|
||||
| `rate_limit_down_bps` | `u64|null` | no | Per-user download rate limit in bytes per second; `null` removes the download direction limit. |
|
||||
| `max_unique_ips` | `usize|null` | no | Per-user unique source IP limit; `null` removes the per-user override. |
|
||||
|
||||
### `access.user_source_deny` via API
|
||||
- In current API surface, per-user deny-list is **not** exposed as a dedicated field in `CreateUserRequest` / `PatchUserRequest`.
|
||||
@@ -198,7 +241,7 @@ bob = ["198.51.100.42/32"]
|
||||
| --- | --- | --- | --- |
|
||||
| `secret` | `string` | no | Exactly 32 hex chars. If missing, generated automatically. |
|
||||
|
||||
Note: the request contract is defined, but the corresponding route currently returns `404` (see routing edge cases).
|
||||
An empty request body is accepted and generates a new secret automatically.
|
||||
|
||||
## Response Data Contracts
|
||||
|
||||
@@ -208,15 +251,33 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `status` | `string` | Always `"ok"`. |
|
||||
| `read_only` | `bool` | Mirrors current API `read_only` mode. |
|
||||
|
||||
### `HealthReadyData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `ready` | `bool` | `true` when admission is open and at least one upstream is healthy. |
|
||||
| `status` | `string` | `"ready"` or `"not_ready"`. |
|
||||
| `reason` | `string?` | `admission_closed` or `no_healthy_upstreams` when not ready. |
|
||||
| `admission_open` | `bool` | Current admission-gate state. |
|
||||
| `healthy_upstreams` | `usize` | Number of healthy upstream entries. |
|
||||
| `total_upstreams` | `usize` | Number of configured upstream entries. |
|
||||
|
||||
### `SummaryData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `uptime_seconds` | `f64` | Process uptime in seconds. |
|
||||
| `connections_total` | `u64` | Total accepted client connections. |
|
||||
| `connections_bad_total` | `u64` | Failed/invalid client connections. |
|
||||
| `connections_bad_by_class` | `ClassCount[]` | Failed/invalid connections grouped by class. |
|
||||
| `handshake_failures_by_class` | `ClassCount[]` | Handshake failures grouped by class. |
|
||||
| `handshake_timeouts_total` | `u64` | Handshake timeout count. |
|
||||
| `configured_users` | `usize` | Number of configured users in config. |
|
||||
|
||||
#### `ClassCount`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `class` | `string` | Failure class label. |
|
||||
| `total` | `u64` | Counter value for this class. |
|
||||
|
||||
### `SystemInfoData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -241,7 +302,12 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `conditional_cast_enabled` | `bool` | Whether conditional ME admission logic is enabled (`general.use_middle_proxy`). |
|
||||
| `me_runtime_ready` | `bool` | Current ME runtime readiness status used for conditional gate decisions. |
|
||||
| `me2dc_fallback_enabled` | `bool` | Whether ME -> direct fallback is enabled. |
|
||||
| `me2dc_fast_enabled` | `bool` | Whether fast ME -> direct fallback is enabled. |
|
||||
| `use_middle_proxy` | `bool` | Current transport mode preference. |
|
||||
| `route_mode` | `string` | Current route mode label from route runtime controller. |
|
||||
| `reroute_active` | `bool` | `true` when ME fallback currently routes new sessions to Direct-DC. |
|
||||
| `reroute_to_direct_at_epoch_secs` | `u64?` | Unix timestamp when current direct reroute began. |
|
||||
| `reroute_reason` | `string?` | `fast_not_ready_fallback` or `strict_grace_fallback` while reroute is active. |
|
||||
| `startup_status` | `string` | Startup status (`pending`, `initializing`, `ready`, `failed`, `skipped`). |
|
||||
| `startup_stage` | `string` | Current startup stage identifier. |
|
||||
| `startup_progress_pct` | `f64` | Startup progress percentage (`0..100`). |
|
||||
@@ -292,11 +358,13 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `upstream` | `EffectiveUpstreamLimits` | Effective upstream connect/retry limits. |
|
||||
| `middle_proxy` | `EffectiveMiddleProxyLimits` | Effective ME pool/floor/reconnect limits. |
|
||||
| `user_ip_policy` | `EffectiveUserIpPolicyLimits` | Effective unique-IP policy mode/window. |
|
||||
| `user_tcp_policy` | `EffectiveUserTcpPolicyLimits` | Effective per-user TCP connection policy. |
|
||||
|
||||
#### `EffectiveTimeoutLimits`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `client_handshake_secs` | `u64` | Client handshake timeout. |
|
||||
| `client_first_byte_idle_secs` | `u64` | First-byte idle timeout before protocol classification. |
|
||||
| `tg_connect_secs` | `u64` | Upstream Telegram connect timeout. |
|
||||
| `client_keepalive_secs` | `u64` | Client keepalive interval. |
|
||||
| `client_ack_secs` | `u64` | ACK timeout. |
|
||||
@@ -335,13 +403,20 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `writer_pick_mode` | `string` | Writer picker mode (`sorted_rr`, `p2c`). |
|
||||
| `writer_pick_sample_size` | `u8` | Candidate sample size for `p2c` picker mode. |
|
||||
| `me2dc_fallback` | `bool` | Effective ME -> direct fallback flag. |
|
||||
| `me2dc_fast` | `bool` | Effective fast fallback flag. |
|
||||
|
||||
#### `EffectiveUserIpPolicyLimits`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `global_each` | `usize` | Global per-user unique-IP limit applied when no per-user override exists. |
|
||||
| `mode` | `string` | Unique-IP policy mode (`active_window`, `time_window`, `combined`). |
|
||||
| `window_secs` | `u64` | Time window length used by unique-IP policy. |
|
||||
|
||||
#### `EffectiveUserTcpPolicyLimits`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `global_each` | `usize` | Global per-user concurrent TCP limit applied when no per-user override exists. |
|
||||
|
||||
### `SecurityPostureData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -445,6 +520,8 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| --- | --- | --- |
|
||||
| `counters` | `RuntimeMeQualityCountersData` | Key ME lifecycle/error counters. |
|
||||
| `route_drops` | `RuntimeMeQualityRouteDropData` | Route drop counters by reason. |
|
||||
| `family_states` | `RuntimeMeQualityFamilyStateData[]` | Per-family ME route/recovery state rows. |
|
||||
| `drain_gate` | `RuntimeMeQualityDrainGateData` | Current ME drain-gate decision state. |
|
||||
| `dc_rtt` | `RuntimeMeQualityDcRttData[]` | Per-DC RTT and writer coverage rows. |
|
||||
|
||||
#### `RuntimeMeQualityCountersData`
|
||||
@@ -466,6 +543,24 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `queue_full_base_total` | `u64` | Route drops in base-queue path. |
|
||||
| `queue_full_high_total` | `u64` | Route drops in high-priority queue path. |
|
||||
|
||||
#### `RuntimeMeQualityFamilyStateData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `family` | `string` | Address family label. |
|
||||
| `state` | `string` | Current family state label. |
|
||||
| `state_since_epoch_secs` | `u64` | Unix timestamp when current state began. |
|
||||
| `suppressed_until_epoch_secs` | `u64?` | Unix timestamp until suppression remains active. |
|
||||
| `fail_streak` | `u32` | Consecutive failure count. |
|
||||
| `recover_success_streak` | `u32` | Consecutive recovery success count. |
|
||||
|
||||
#### `RuntimeMeQualityDrainGateData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `route_quorum_ok` | `bool` | Whether route quorum condition allows drain. |
|
||||
| `redundancy_ok` | `bool` | Whether redundancy condition allows drain. |
|
||||
| `block_reason` | `string` | Current drain block reason label. |
|
||||
| `updated_at_epoch_secs` | `u64` | Unix timestamp of the latest gate update. |
|
||||
|
||||
#### `RuntimeMeQualityDcRttData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -728,11 +823,24 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `uptime_seconds` | `f64` | Process uptime. |
|
||||
| `connections_total` | `u64` | Total accepted connections. |
|
||||
| `connections_bad_total` | `u64` | Failed/invalid connections. |
|
||||
| `connections_bad_by_class` | `ClassCount[]` | Failed/invalid connections grouped by class. |
|
||||
| `handshake_failures_by_class` | `ClassCount[]` | Handshake failures grouped by class. |
|
||||
| `handshake_timeouts_total` | `u64` | Handshake timeouts. |
|
||||
| `accept_permit_timeout_total` | `u64` | Listener admission permit acquisition timeouts. |
|
||||
| `configured_users` | `usize` | Configured user count. |
|
||||
| `telemetry_core_enabled` | `bool` | Core telemetry toggle. |
|
||||
| `telemetry_user_enabled` | `bool` | User telemetry toggle. |
|
||||
| `telemetry_me_level` | `string` | ME telemetry level (`off|normal|verbose`). |
|
||||
| `conntrack_control_enabled` | `bool` | Whether conntrack control is enabled by policy. |
|
||||
| `conntrack_control_available` | `bool` | Whether conntrack control backend is currently available. |
|
||||
| `conntrack_pressure_active` | `bool` | Current conntrack pressure flag. |
|
||||
| `conntrack_event_queue_depth` | `u64` | Current conntrack close-event queue depth. |
|
||||
| `conntrack_rule_apply_ok` | `bool` | Last conntrack rule application state. |
|
||||
| `conntrack_delete_attempt_total` | `u64` | Conntrack delete attempts. |
|
||||
| `conntrack_delete_success_total` | `u64` | Successful conntrack deletes. |
|
||||
| `conntrack_delete_not_found_total` | `u64` | Conntrack delete misses. |
|
||||
| `conntrack_delete_error_total` | `u64` | Conntrack delete errors. |
|
||||
| `conntrack_close_event_drop_total` | `u64` | Dropped conntrack close events. |
|
||||
|
||||
#### `ZeroUpstreamData`
|
||||
| Field | Type | Description |
|
||||
@@ -819,6 +927,24 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `route_drop_queue_full_total` | `u64` | Route drops due to full queue (total). |
|
||||
| `route_drop_queue_full_base_total` | `u64` | Route drops in base queue mode. |
|
||||
| `route_drop_queue_full_high_total` | `u64` | Route drops in high queue mode. |
|
||||
| `d2c_batches_total` | `u64` | ME D->C batch flushes. |
|
||||
| `d2c_batch_frames_total` | `u64` | ME D->C frames included in batches. |
|
||||
| `d2c_batch_bytes_total` | `u64` | ME D->C payload bytes included in batches. |
|
||||
| `d2c_flush_reason_queue_drain_total` | `u64` | Flushes caused by queue drain. |
|
||||
| `d2c_flush_reason_batch_frames_total` | `u64` | Flushes caused by frame-count batch limit. |
|
||||
| `d2c_flush_reason_batch_bytes_total` | `u64` | Flushes caused by byte-count batch limit. |
|
||||
| `d2c_flush_reason_max_delay_total` | `u64` | Flushes caused by max-delay budget. |
|
||||
| `d2c_flush_reason_ack_immediate_total` | `u64` | Flushes caused by immediate ACK policy. |
|
||||
| `d2c_flush_reason_close_total` | `u64` | Flushes caused by close path. |
|
||||
| `d2c_data_frames_total` | `u64` | ME D->C data frames. |
|
||||
| `d2c_ack_frames_total` | `u64` | ME D->C ACK frames. |
|
||||
| `d2c_payload_bytes_total` | `u64` | ME D->C payload bytes. |
|
||||
| `d2c_write_mode_coalesced_total` | `u64` | Coalesced D->C writes. |
|
||||
| `d2c_write_mode_split_total` | `u64` | Split D->C writes. |
|
||||
| `d2c_quota_reject_pre_write_total` | `u64` | D->C quota rejects before write. |
|
||||
| `d2c_quota_reject_post_write_total` | `u64` | D->C quota rejects after write. |
|
||||
| `d2c_frame_buf_shrink_total` | `u64` | D->C frame-buffer shrink operations. |
|
||||
| `d2c_frame_buf_shrink_bytes_total` | `u64` | Bytes released by D->C frame-buffer shrink operations. |
|
||||
| `socks_kdf_strict_reject_total` | `u64` | SOCKS KDF strict rejects. |
|
||||
| `socks_kdf_compat_fallback_total` | `u64` | SOCKS KDF compat fallbacks. |
|
||||
| `endpoint_quarantine_total` | `u64` | Endpoint quarantine activations. |
|
||||
@@ -978,6 +1104,8 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `required_writers` | `usize` | Required writers based on current floor policy. |
|
||||
| `alive_writers` | `usize` | Writers currently alive. |
|
||||
| `coverage_pct` | `f64` | `alive_writers / required_writers * 100`. |
|
||||
| `fresh_alive_writers` | `usize` | Alive writers that match freshness requirements. |
|
||||
| `fresh_coverage_pct` | `f64` | `fresh_alive_writers / required_writers * 100`. |
|
||||
|
||||
#### `MeWriterStatus`
|
||||
| Field | Type | Description |
|
||||
@@ -992,6 +1120,12 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `bound_clients` | `usize` | Number of currently bound clients. |
|
||||
| `idle_for_secs` | `u64?` | Idle age in seconds if idle. |
|
||||
| `rtt_ema_ms` | `f64?` | RTT exponential moving average. |
|
||||
| `matches_active_generation` | `bool` | Whether this writer belongs to the active pool generation. |
|
||||
| `in_desired_map` | `bool` | Whether this writer's endpoint remains in desired topology. |
|
||||
| `allow_drain_fallback` | `bool` | Whether drain fallback is allowed for this writer. |
|
||||
| `drain_started_at_epoch_secs` | `u64?` | Unix timestamp when drain started. |
|
||||
| `drain_deadline_epoch_secs` | `u64?` | Unix timestamp of drain deadline. |
|
||||
| `drain_over_ttl` | `bool` | Whether drain has exceeded its TTL. |
|
||||
|
||||
### `DcStatusData`
|
||||
| Field | Type | Description |
|
||||
@@ -1016,6 +1150,8 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `floor_capped` | `bool` | `true` when computed floor target was capped by active limits. |
|
||||
| `alive_writers` | `usize` | Alive writers in this DC. |
|
||||
| `coverage_pct` | `f64` | `alive_writers / required_writers * 100`. |
|
||||
| `fresh_alive_writers` | `usize` | Fresh alive writers in this DC. |
|
||||
| `fresh_coverage_pct` | `f64` | `fresh_alive_writers / required_writers * 100`. |
|
||||
| `rtt_ms` | `f64?` | Aggregated RTT for DC. |
|
||||
| `load` | `usize` | Active client sessions bound to this DC. |
|
||||
|
||||
@@ -1029,10 +1165,13 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `username` | `string` | Username. |
|
||||
| `in_runtime` | `bool` | Whether current runtime config already contains this user. |
|
||||
| `user_ad_tag` | `string?` | Optional ad tag (32 hex chars). |
|
||||
| `max_tcp_conns` | `usize?` | Optional max concurrent TCP limit. |
|
||||
| `expiration_rfc3339` | `string?` | Optional expiration timestamp. |
|
||||
| `data_quota_bytes` | `u64?` | Optional data quota. |
|
||||
| `rate_limit_up_bps` | `u64?` | Optional upload rate limit in bytes per second. |
|
||||
| `rate_limit_down_bps` | `u64?` | Optional download rate limit in bytes per second. |
|
||||
| `max_unique_ips` | `usize?` | Optional unique IP limit. |
|
||||
| `current_connections` | `u64` | Current live connections. |
|
||||
| `active_unique_ips` | `usize` | Current active unique source IPs. |
|
||||
@@ -1042,12 +1181,25 @@ Note: the request contract is defined, but the corresponding route currently ret
|
||||
| `total_octets` | `u64` | Total traffic octets for this user. |
|
||||
| `links` | `UserLinks` | Active connection links derived from current config. |
|
||||
|
||||
### `UserActiveIps`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `username` | `string` | Username with at least one active tracked source IP. |
|
||||
| `active_ips` | `ip[]` | Active source IPs for this user. |
|
||||
|
||||
#### `UserLinks`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `classic` | `string[]` | Active `tg://proxy` links for classic mode. |
|
||||
| `secure` | `string[]` | Active `tg://proxy` links for secure/DD mode. |
|
||||
| `tls` | `string[]` | Active `tg://proxy` links for EE-TLS mode (for each host+TLS domain). |
|
||||
| `tls_domains` | `TlsDomainLink[]` | Extra TLS-domain links as explicit domain/link pairs for `censorship.tls_domains`. |
|
||||
|
||||
#### `TlsDomainLink`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `domain` | `string` | TLS domain represented by the link. |
|
||||
| `link` | `string` | `tg://proxy` link for this domain. |
|
||||
|
||||
Link generation uses active config and enabled modes:
|
||||
- Link port is `general.links.public_port` when configured; otherwise `server.port`.
|
||||
@@ -1067,13 +1219,27 @@ Link generation uses active config and enabled modes:
|
||||
| `user` | `UserInfo` | Created or updated user view. |
|
||||
| `secret` | `string` | Effective user secret. |
|
||||
|
||||
### `DeleteUserResponse`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `username` | `string` | Deleted username. |
|
||||
| `in_runtime` | `bool` | `true` when runtime config still contains the user and hot-reload has not applied deletion yet. |
|
||||
|
||||
### `ResetUserQuotaResponse`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `username` | `string` | User whose runtime quota counter was reset. |
|
||||
| `used_bytes` | `u64` | Current used bytes after reset; always `0` on success. |
|
||||
| `last_reset_epoch_secs` | `u64` | Unix timestamp of the reset operation. |
|
||||
|
||||
## Mutation Semantics
|
||||
|
||||
| Endpoint | Notes |
|
||||
| --- | --- |
|
||||
| `POST /v1/users` | Creates user, validates config, then atomically updates only affected `access.*` TOML tables (`access.users` always, plus optional per-user tables present in request). |
|
||||
| `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. |
|
||||
| `PATCH /v1/users/{username}` | Partial update of provided fields only. Missing fields remain unchanged; explicit `null` removes optional per-user entries. The write path updates only affected `access.*` TOML tables. |
|
||||
| `POST /v1/users/{username}/rotate-secret` | Replaces the user's secret with a provided valid 32-hex value or a generated value, then returns the effective secret in `CreateUserResponse`. |
|
||||
| `POST /v1/users/{username}/reset-quota` | Resets the runtime quota counter for the route username, persists quota state to `general.quota_state_path`, and does not modify user config. |
|
||||
| `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. |
|
||||
|
||||
All mutating endpoints:
|
||||
@@ -1082,6 +1248,12 @@ All mutating endpoints:
|
||||
- Return new `revision` after successful write.
|
||||
- Use process-local mutation lock + atomic write (`tmp + rename`) for config persistence.
|
||||
|
||||
Docker deployment note:
|
||||
- Mutating endpoints require `config.toml` to live inside a writable mounted directory.
|
||||
- Do not mount `config.toml` as a single bind-mounted file when API mutations are enabled; atomic `tmp + rename` writes can fail with `Device or resource busy`.
|
||||
- Mount the config directory instead, for example `./config:/etc/telemt:rw`, and start Telemt with `/etc/telemt/config.toml`.
|
||||
- A read-only single-file mount remains valid only for read-only deployments or when `[server.api].read_only=true`.
|
||||
|
||||
Delete path cleanup guarantees:
|
||||
- Config cleanup removes only the requested username keys.
|
||||
- Runtime unique-IP cleanup removes only this user's limiter and tracked IP state.
|
||||
@@ -1148,5 +1320,4 @@ When `general.use_middle_proxy=true` and `general.me2dc_fallback=true`:
|
||||
|
||||
## Known Limitations (Current Release)
|
||||
|
||||
- `POST /v1/users/{username}/rotate-secret` is currently unreachable in route matcher and returns `404`.
|
||||
- API runtime controls under `server.api` are documented as restart-required; hot-reload behavior for these fields is not strictly uniform in all change combinations.
|
||||
|
||||
@@ -254,6 +254,19 @@ docker compose down
|
||||
> - `docker-compose.yml` maps `./config.toml` to `/app/config.toml` (read-only)
|
||||
> - By default it publishes `443:443` and runs with dropped capabilities (only `NET_BIND_SERVICE` is added)
|
||||
> - If you really need host networking (usually only for some IPv6 setups) uncomment `network_mode: host`
|
||||
> - If you enable mutating Control API endpoints, mount a writable config directory instead of a single `config.toml` file. Telemt persists config changes with atomic `tmp + rename` writes, and a single bind-mounted file can fail with `Device or resource busy`.
|
||||
|
||||
Example writable config mount for Control API mutations:
|
||||
```yaml
|
||||
services:
|
||||
telemt:
|
||||
working_dir: /run/telemt
|
||||
volumes:
|
||||
- ./config:/etc/telemt:rw
|
||||
tmpfs:
|
||||
- /run/telemt:rw,mode=1777,size=4m
|
||||
command: /usr/local/bin/telemt /etc/telemt/config.toml
|
||||
```
|
||||
|
||||
**Run without Compose**
|
||||
```bash
|
||||
|
||||
@@ -7,7 +7,7 @@ use hyper::header::IF_MATCH;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::config::{ProxyConfig, RateLimitBps};
|
||||
|
||||
use super::model::ApiFailure;
|
||||
|
||||
@@ -18,6 +18,7 @@ pub(super) enum AccessSection {
|
||||
UserMaxTcpConns,
|
||||
UserExpirations,
|
||||
UserDataQuota,
|
||||
UserRateLimits,
|
||||
UserMaxUniqueIps,
|
||||
}
|
||||
|
||||
@@ -29,6 +30,7 @@ impl AccessSection {
|
||||
Self::UserMaxTcpConns => "access.user_max_tcp_conns",
|
||||
Self::UserExpirations => "access.user_expirations",
|
||||
Self::UserDataQuota => "access.user_data_quota",
|
||||
Self::UserRateLimits => "access.user_rate_limits",
|
||||
Self::UserMaxUniqueIps => "access.user_max_unique_ips",
|
||||
}
|
||||
}
|
||||
@@ -169,6 +171,15 @@ fn render_access_section(cfg: &ProxyConfig, section: AccessSection) -> Result<St
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserRateLimits => {
|
||||
let rows: BTreeMap<String, RateLimitBps> = cfg
|
||||
.access
|
||||
.user_rate_limits
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_rate_limit_body(&rows)?
|
||||
}
|
||||
AccessSection::UserMaxUniqueIps => {
|
||||
let rows: BTreeMap<String, usize> = cfg
|
||||
.access
|
||||
@@ -197,6 +208,7 @@ fn access_section_is_empty(cfg: &ProxyConfig, section: AccessSection) -> bool {
|
||||
AccessSection::UserMaxTcpConns => cfg.access.user_max_tcp_conns.is_empty(),
|
||||
AccessSection::UserExpirations => cfg.access.user_expirations.is_empty(),
|
||||
AccessSection::UserDataQuota => cfg.access.user_data_quota.is_empty(),
|
||||
AccessSection::UserRateLimits => cfg.access.user_rate_limits.is_empty(),
|
||||
AccessSection::UserMaxUniqueIps => cfg.access.user_max_unique_ips.is_empty(),
|
||||
}
|
||||
}
|
||||
@@ -206,6 +218,28 @@ fn serialize_table_body<T: Serialize>(value: &T) -> Result<String, ApiFailure> {
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e)))
|
||||
}
|
||||
|
||||
fn serialize_rate_limit_body(rows: &BTreeMap<String, RateLimitBps>) -> Result<String, ApiFailure> {
|
||||
let mut out = String::new();
|
||||
for (key, value) in rows {
|
||||
let key = serialize_toml_key(key)?;
|
||||
out.push_str(&format!(
|
||||
"{key} = {{ up_bps = {}, down_bps = {} }}\n",
|
||||
value.up_bps, value.down_bps
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn serialize_toml_key(key: &str) -> Result<String, ApiFailure> {
|
||||
let mut row = BTreeMap::new();
|
||||
row.insert(key.to_string(), 0_u8);
|
||||
let rendered = serialize_table_body(&row)?;
|
||||
rendered
|
||||
.split_once(" = ")
|
||||
.map(|(key, _)| key.to_string())
|
||||
.ok_or_else(|| ApiFailure::internal("failed to serialize TOML key"))
|
||||
}
|
||||
|
||||
fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> String {
|
||||
if let Some((start, end)) = find_toml_table_bounds(source, table_name) {
|
||||
let mut out = String::with_capacity(source.len() + replacement.len());
|
||||
@@ -285,3 +319,26 @@ fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
}
|
||||
write_result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn render_user_rate_limits_section() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.access.user_rate_limits.insert(
|
||||
"alice".to_string(),
|
||||
RateLimitBps {
|
||||
up_bps: 1024,
|
||||
down_bps: 2048,
|
||||
},
|
||||
);
|
||||
|
||||
let rendered = render_access_section(&cfg, AccessSection::UserRateLimits)
|
||||
.expect("section must render");
|
||||
|
||||
assert!(rendered.starts_with("[access.user_rate_limits]\n"));
|
||||
assert!(rendered.contains("alice = { up_bps = 1024, down_bps = 2048 }"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -473,6 +473,8 @@ pub(super) struct UserInfo {
|
||||
pub(super) max_tcp_conns: Option<usize>,
|
||||
pub(super) expiration_rfc3339: Option<String>,
|
||||
pub(super) data_quota_bytes: Option<u64>,
|
||||
pub(super) rate_limit_up_bps: Option<u64>,
|
||||
pub(super) rate_limit_down_bps: Option<u64>,
|
||||
pub(super) max_unique_ips: Option<usize>,
|
||||
pub(super) current_connections: u64,
|
||||
pub(super) active_unique_ips: usize,
|
||||
@@ -508,6 +510,19 @@ pub(super) struct ResetUserQuotaResponse {
|
||||
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)]
|
||||
pub(super) struct CreateUserRequest {
|
||||
pub(super) username: String,
|
||||
@@ -516,6 +531,8 @@ pub(super) struct CreateUserRequest {
|
||||
pub(super) max_tcp_conns: Option<usize>,
|
||||
pub(super) expiration_rfc3339: Option<String>,
|
||||
pub(super) data_quota_bytes: Option<u64>,
|
||||
pub(super) rate_limit_up_bps: Option<u64>,
|
||||
pub(super) rate_limit_down_bps: Option<u64>,
|
||||
pub(super) max_unique_ips: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -531,6 +548,10 @@ pub(super) struct PatchUserRequest {
|
||||
#[serde(default, deserialize_with = "patch_field")]
|
||||
pub(super) data_quota_bytes: Patch<u64>,
|
||||
#[serde(default, deserialize_with = "patch_field")]
|
||||
pub(super) rate_limit_up_bps: Patch<u64>,
|
||||
#[serde(default, deserialize_with = "patch_field")]
|
||||
pub(super) rate_limit_down_bps: Patch<u64>,
|
||||
#[serde(default, deserialize_with = "patch_field")]
|
||||
pub(super) max_unique_ips: Patch<usize>,
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,9 @@ mod tests {
|
||||
"secret": "00112233445566778899aabbccddeeff",
|
||||
"max_tcp_conns": 0,
|
||||
"max_unique_ips": null,
|
||||
"data_quota_bytes": 1024
|
||||
"data_quota_bytes": 1024,
|
||||
"rate_limit_up_bps": 4096,
|
||||
"rate_limit_down_bps": null
|
||||
}"#;
|
||||
let req: PatchUserRequest = serde_json::from_str(raw).expect("valid json");
|
||||
assert_eq!(
|
||||
@@ -124,6 +126,8 @@ mod tests {
|
||||
assert!(matches!(req.max_tcp_conns, Patch::Set(0)));
|
||||
assert!(matches!(req.max_unique_ips, Patch::Remove));
|
||||
assert!(matches!(req.data_quota_bytes, Patch::Set(1024)));
|
||||
assert!(matches!(req.rate_limit_up_bps, Patch::Set(4096)));
|
||||
assert!(matches!(req.rate_limit_down_bps, Patch::Remove));
|
||||
assert!(matches!(req.expiration_rfc3339, Patch::Unchanged));
|
||||
assert!(matches!(req.user_ad_tag, Patch::Unchanged));
|
||||
}
|
||||
|
||||
186
src/api/users.rs
186
src/api/users.rs
@@ -3,6 +3,7 @@ use std::net::IpAddr;
|
||||
use hyper::StatusCode;
|
||||
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::config::RateLimitBps;
|
||||
use crate::ip_tracker::UserIpTracker;
|
||||
use crate::stats::Stats;
|
||||
|
||||
@@ -13,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;
|
||||
|
||||
@@ -27,6 +29,8 @@ pub(super) async fn create_user(
|
||||
let touches_user_max_tcp_conns = body.max_tcp_conns.is_some();
|
||||
let touches_user_expirations = body.expiration_rfc3339.is_some();
|
||||
let touches_user_data_quota = body.data_quota_bytes.is_some();
|
||||
let touches_user_rate_limits =
|
||||
body.rate_limit_up_bps.is_some() || body.rate_limit_down_bps.is_some();
|
||||
let touches_user_max_unique_ips = body.max_unique_ips.is_some();
|
||||
|
||||
if !is_valid_username(&body.username) {
|
||||
@@ -91,6 +95,15 @@ pub(super) async fn create_user(
|
||||
.user_data_quota
|
||||
.insert(body.username.clone(), quota);
|
||||
}
|
||||
if touches_user_rate_limits {
|
||||
cfg.access.user_rate_limits.insert(
|
||||
body.username.clone(),
|
||||
RateLimitBps {
|
||||
up_bps: body.rate_limit_up_bps.unwrap_or(0),
|
||||
down_bps: body.rate_limit_down_bps.unwrap_or(0),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let updated_limit = body.max_unique_ips;
|
||||
if let Some(limit) = updated_limit {
|
||||
@@ -115,6 +128,9 @@ pub(super) async fn create_user(
|
||||
if touches_user_data_quota {
|
||||
touched_sections.push(AccessSection::UserDataQuota);
|
||||
}
|
||||
if touches_user_rate_limits {
|
||||
touched_sections.push(AccessSection::UserRateLimits);
|
||||
}
|
||||
if touches_user_max_unique_ips {
|
||||
touched_sections.push(AccessSection::UserMaxUniqueIps);
|
||||
}
|
||||
@@ -157,6 +173,8 @@ pub(super) async fn create_user(
|
||||
.then_some(cfg.access.user_max_tcp_conns_global_each)),
|
||||
expiration_rfc3339: None,
|
||||
data_quota_bytes: None,
|
||||
rate_limit_up_bps: body.rate_limit_up_bps.filter(|limit| *limit > 0),
|
||||
rate_limit_down_bps: body.rate_limit_down_bps.filter(|limit| *limit > 0),
|
||||
max_unique_ips: updated_limit,
|
||||
current_connections: 0,
|
||||
active_unique_ips: 0,
|
||||
@@ -181,6 +199,8 @@ pub(super) async fn patch_user(
|
||||
let touches_user_max_tcp_conns = !matches!(&body.max_tcp_conns, Patch::Unchanged);
|
||||
let touches_user_expirations = !matches!(&body.expiration_rfc3339, Patch::Unchanged);
|
||||
let touches_user_data_quota = !matches!(&body.data_quota_bytes, Patch::Unchanged);
|
||||
let touches_user_rate_limits = !matches!(&body.rate_limit_up_bps, Patch::Unchanged)
|
||||
|| !matches!(&body.rate_limit_down_bps, Patch::Unchanged);
|
||||
let touches_user_max_unique_ips = !matches!(&body.max_unique_ips, Patch::Unchanged);
|
||||
|
||||
if let Some(secret) = body.secret.as_ref()
|
||||
@@ -253,6 +273,31 @@ pub(super) async fn patch_user(
|
||||
cfg.access.user_data_quota.insert(user.to_string(), quota);
|
||||
}
|
||||
}
|
||||
if touches_user_rate_limits {
|
||||
let mut rate_limit = cfg
|
||||
.access
|
||||
.user_rate_limits
|
||||
.get(user)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
match body.rate_limit_up_bps {
|
||||
Patch::Unchanged => {}
|
||||
Patch::Remove => rate_limit.up_bps = 0,
|
||||
Patch::Set(limit) => rate_limit.up_bps = limit,
|
||||
}
|
||||
match body.rate_limit_down_bps {
|
||||
Patch::Unchanged => {}
|
||||
Patch::Remove => rate_limit.down_bps = 0,
|
||||
Patch::Set(limit) => rate_limit.down_bps = limit,
|
||||
}
|
||||
if rate_limit.up_bps == 0 && rate_limit.down_bps == 0 {
|
||||
cfg.access.user_rate_limits.remove(user);
|
||||
} else {
|
||||
cfg.access
|
||||
.user_rate_limits
|
||||
.insert(user.to_string(), rate_limit);
|
||||
}
|
||||
}
|
||||
// Capture how the per-user IP limit changed, so the in-memory ip_tracker
|
||||
// can be synced (set or removed) after the config is persisted.
|
||||
let max_unique_ips_change = match body.max_unique_ips {
|
||||
@@ -288,6 +333,9 @@ pub(super) async fn patch_user(
|
||||
if touches_user_data_quota {
|
||||
touched_sections.push(AccessSection::UserDataQuota);
|
||||
}
|
||||
if touches_user_rate_limits {
|
||||
touched_sections.push(AccessSection::UserRateLimits);
|
||||
}
|
||||
if touches_user_max_unique_ips {
|
||||
touched_sections.push(AccessSection::UserMaxUniqueIps);
|
||||
}
|
||||
@@ -355,6 +403,7 @@ pub(super) async fn rotate_secret(
|
||||
AccessSection::UserMaxTcpConns,
|
||||
AccessSection::UserExpirations,
|
||||
AccessSection::UserDataQuota,
|
||||
AccessSection::UserRateLimits,
|
||||
AccessSection::UserMaxUniqueIps,
|
||||
];
|
||||
let revision =
|
||||
@@ -414,6 +463,7 @@ pub(super) async fn delete_user(
|
||||
cfg.access.user_max_tcp_conns.remove(user);
|
||||
cfg.access.user_expirations.remove(user);
|
||||
cfg.access.user_data_quota.remove(user);
|
||||
cfg.access.user_rate_limits.remove(user);
|
||||
cfg.access.user_max_unique_ips.remove(user);
|
||||
|
||||
cfg.validate()
|
||||
@@ -424,6 +474,7 @@ pub(super) async fn delete_user(
|
||||
AccessSection::UserMaxTcpConns,
|
||||
AccessSection::UserExpirations,
|
||||
AccessSection::UserDataQuota,
|
||||
AccessSection::UserRateLimits,
|
||||
AccessSection::UserMaxUniqueIps,
|
||||
];
|
||||
let revision =
|
||||
@@ -485,6 +536,18 @@ pub(super) async fn users_from_config(
|
||||
.get(&username)
|
||||
.map(chrono::DateTime::<chrono::Utc>::to_rfc3339),
|
||||
data_quota_bytes: cfg.access.user_data_quota.get(&username).copied(),
|
||||
rate_limit_up_bps: cfg
|
||||
.access
|
||||
.user_rate_limits
|
||||
.get(&username)
|
||||
.map(|limit| limit.up_bps)
|
||||
.filter(|limit| *limit > 0),
|
||||
rate_limit_down_bps: cfg
|
||||
.access
|
||||
.user_rate_limits
|
||||
.get(&username)
|
||||
.map(|limit| limit.down_bps)
|
||||
.filter(|limit| *limit > 0),
|
||||
max_unique_ips: cfg
|
||||
.access
|
||||
.user_max_unique_ips
|
||||
@@ -506,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::<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 {
|
||||
UserLinks {
|
||||
classic: Vec::new(),
|
||||
@@ -758,6 +848,34 @@ mod tests {
|
||||
assert_eq!(alice.max_tcp_conns, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn users_from_config_reports_user_rate_limits() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.access.users.insert(
|
||||
"alice".to_string(),
|
||||
"0123456789abcdef0123456789abcdef".to_string(),
|
||||
);
|
||||
cfg.access.user_rate_limits.insert(
|
||||
"alice".to_string(),
|
||||
RateLimitBps {
|
||||
up_bps: 1024,
|
||||
down_bps: 0,
|
||||
},
|
||||
);
|
||||
|
||||
let stats = Stats::new();
|
||||
let tracker = UserIpTracker::new();
|
||||
|
||||
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
|
||||
let alice = users
|
||||
.iter()
|
||||
.find(|entry| entry.username == "alice")
|
||||
.expect("alice must be present");
|
||||
|
||||
assert_eq!(alice.rate_limit_up_bps, Some(1024));
|
||||
assert_eq!(alice.rate_limit_down_bps, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn users_from_config_marks_runtime_membership_when_snapshot_is_provided() {
|
||||
let mut disk_cfg = ProxyConfig::default();
|
||||
@@ -869,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user