Add CORS support and response caching to aggregate endpoints
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 1m19s

- Introduced CORS configuration options in config.example.yaml, allowing specification of allowed origins for cross-origin requests.
- Enhanced the aggregate handler to support response caching with a configurable TTL, improving performance for repeated requests.
- Updated the aggregate API to return a structured response indicating whether any upstream requests failed, enhancing error handling and response clarity.
- Modified documentation in AGGREGATE.md and README.md to reflect the new CORS and caching features.
- Added tests to validate the new functionality in the aggregate handler.
This commit is contained in:
Denozordec
2026-03-30 10:02:16 +07:00
parent 2a8390e687
commit 04c257a84e
14 changed files with 1071 additions and 142 deletions
+51 -4
View File
@@ -1,11 +1,28 @@
# Агрегирующие эндпоинты шлюза (`/api/agg/`)
Шлюз **telemt-api** опрашивает несколько upstream [Telemt Control API](API.md) (`GET /v1/stats/users` на каждом сервере из конфигурации) и отдаёт сводные JSON-ответы в формате `{"ok": true, "data": ...}`.
Шлюз **telemt-api** опрашивает несколько upstream [Telemt Control API](API.md) и отдаёт сводные JSON-ответы.
- Большинство маршрутов агрегации используют **`GET /v1/stats/users`** на каждом сервере из конфигурации.
- **`GET /api/agg/fleet-status`** дополнительно вызывает на каждом upstream **`GET /v1/health`** и **`GET /v1/system/info`** (параллельно по серверам).
**Единицы трафика в агрегатах:** поля `*_megabytes` — это **двоичные мегабайты (MiB)**, 1 MiB = 1024² октетов (как у Telemt в ответе считаются октеты, шлюз делит на MiB для удобства).
Доступ к **одному** инстансу по-прежнему через прокси: `GET /api/{alias}/…` (например `/api/gt1/v1/stats/users`) — там по-прежнему `total_octets` как в [API.md](API.md).
## Успешный ответ (общий контракт)
```json
{
"ok": true,
"data": {},
"generated_at": "2026-03-30T12:00:00.000000000Z",
"partial": true
}
```
- **`generated_at`** — UTC, RFC3339Nano; время формирования ответа шлюза.
- **`partial`** — присутствует и равно `true`, если **хотя бы один** upstream в этом запросе завершился с ошибкой (HTTP не 200, сеть, `ok: false` в теле и т.д.), либо для `fleet-status` — если не оба подзапроса (health и system/info) успешны для какой-либо ноды. Если все вызовы успешны, поле **`partial` не включается**.
## Маршруты
| Метод | Путь | Описание |
@@ -13,38 +30,66 @@
| GET | `/api/agg/summary` | Сводка по флоту, список опросов upstream, `fleet_total_megabytes` / `fleet_total_connections`. Два топа (размер задаётся `top_n`): **`top_users`** — самые «прожорливые» по суммарному трафику (MiB) по всем серверам; **`top_users_by_unique_ips`** — по максимальному `active_unique_ips` среди серверов для пользователя (как в Telemt, снимок). |
| GET | `/api/agg/traffic` | Трафик по каждому пользователю в разрезе серверов: `servers.<alias>.total_megabytes`. |
| GET | `/api/agg/unique-ips` | Уникальные IP по пользователю: на каких серверах IP есть в active/recent списках снимка. При **`geoip.enabled`** в конфиге — из City: `country_code`, `country_name`, `city_name`; при наличии ASN-БД — `asn`, `as_organization` (см. [GEOIP.md](GEOIP.md)); отключить гео для запроса: `?geo=false`. |
| GET | `/api/agg/users` | Объединённый список пользователей с `by_server` и суммарным `total_megabytes`. |
| GET | `/api/agg/users` | Объединённый список пользователей с `by_server`, суммарным `total_megabytes` и **смерженными лимитами** (см. ниже). |
| GET | `/api/agg/user/{username}` | Один пользователь в том же формате, что элементы `/api/agg/users` (без списка всех). Имя в пути: `[A-Za-z0-9_.-]+`. Ответ **`404`**, если пользователь не найден ни на одном успешном upstream. |
| GET | `/api/agg/fleet-status` | По каждому алиасу: параллельно health + system/info; в `data.servers[]` — статусы подзапросов и тела `health` / `system_info` при успехе. См. [AGGREGATE_OPENAPI.yaml](AGGREGATE_OPENAPI.yaml). |
Все методы — **GET**; действует тот же whitelist, что и для остального API шлюза.
### Слияние лимитов в `users` и `user/…`
Поля в строке пользователя (кроме счётчиков и `by_server`):
| Поле | Политика |
| --- | --- |
| `user_ad_tag` | Первое непустое значение при обходе серверов в **лексикографическом порядке алиаса**. |
| `expiration_rfc3339` | **Самая ранняя** дата среди заданных на серверах (по разбору RFC3339 / RFC3339Nano). |
| `max_tcp_conns`, `data_quota_bytes`, `max_unique_ips` | **Минимум** среди заданных на серверах (самый строгий лимит). |
Ссылки `links` при `include_links=true` по-прежнему берутся из **первой успешной** записи по пользователю (как раньше).
## Query-параметры
| Параметр | Где | Значение |
| --- | --- | --- |
| `aliases` | все | Список алиасов через запятую (например `gt1,gt2`). Если не задан — см. `aggregate.include_aliases` в YAML или все серверы из `servers`. |
| `top_n` | `summary` | Размер топа пользователей (по умолчанию `10`, максимум `1000`). |
| `include_links` | `users` | `true` — добавить сгенерированные `tg://proxy` ссылки (берётся первая успешная запись по пользователю). |
| `include_links` | `users`, `user/…` | `true` — добавить сгенерированные `tg://proxy` ссылки (берётся первая успешная запись по пользователю). |
| `min_total_megabytes` | `users` | Порог суммарного трафика пользователя в MiB (строго больше 0). |
| `min_total_octets` | `users` | Устаревший вариант порога в октетах (если задан `min_total_megabytes`, он приоритетнее). |
## Конфигурация (опционально)
```yaml
# SPA на другом origin: список разрешённых Origin или "*" (без учётных данных cookie к шлюзу).
cors_allowed_origins:
- "http://localhost:5173"
aggregate:
include_aliases:
- gt1
- gt2
# Кэш только для успешных (HTTP 200) ответов /api/agg/*, ключ = путь + query. 0 = выкл. Макс. 60000 мс.
cache_ttl_ms: 2000
```
Если блок отсутствует или `include_aliases` пуст, по умолчанию участвуют **все** записи `servers`.
Если блок `aggregate` отсутствует или `include_aliases` пуст, по умолчанию участвуют **все** записи `servers`.
Имя алиаса **`agg`** в `servers` запрещено (зарезервировано под префикс `/api/agg/`).
### CORS
Если задан непустой **`cors_allowed_origins`**, шлюз для подходящего заголовка **`Origin`** добавляет заголовки CORS и отвечает на **`OPTIONS`** кодом **204** без тела (preflight). Совпадение: точное равенство строки origin или `"*"`. Whitelist IP по-прежнему применяется **до** обработки запроса.
## Ограничения
- **Один и тот же `username` на разных серверах** может соответствовать разным учётным записям; агрегатор сопоставляет строки по имени — учитывайте при интерпретации сумм.
- У Telemt в `UserInfo` **нет** поля «IP последний раз подключался к серверу X». В `unique-ips` поле `primary_server` заполняется **только** если ровно один сервер видит IP в `active_unique_ips_list` на момент запроса; иначе `primary_server` отсутствует или несколько серверов в списках — это снимок, не история.
## Машиночитаемый контракт
Черновик схемы OpenAPI 3 для `/api/agg/*`: **[AGGREGATE_OPENAPI.yaml](AGGREGATE_OPENAPI.yaml)** (удобно для генерации типов на фронтенде).
## Примеры
```bash
@@ -52,4 +97,6 @@ curl -sS "http://127.0.0.1:8080/api/agg/summary"
curl -sS "http://127.0.0.1:8080/api/agg/traffic?aliases=gt1,gt2"
curl -sS "http://127.0.0.1:8080/api/agg/unique-ips"
curl -sS "http://127.0.0.1:8080/api/agg/users?include_links=false&min_total_megabytes=1"
curl -sS "http://127.0.0.1:8080/api/agg/fleet-status"
curl -sS "http://127.0.0.1:8080/api/agg/user/myuser?aliases=gt1"
```
+297
View File
@@ -0,0 +1,297 @@
openapi: 3.0.3
info:
title: Telemt API gateway — aggregate API
description: >
Эндпоинты под префиксом /api/agg на шлюзе telemt-api.
Базовый URL задаётся listen шлюза (например http://127.0.0.1:8080).
version: 1.0.0
paths:
/api/agg/summary:
get:
summary: Сводка флота и топы пользователей
parameters:
- $ref: '#/components/parameters/aliases'
- name: top_n
in: query
schema: { type: integer, minimum: 1, maximum: 1000, default: 10 }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/AggEnvelopeSummary' }
/api/agg/traffic:
get:
summary: Трафик по пользователям и серверам
parameters:
- $ref: '#/components/parameters/aliases'
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/AggEnvelopeTrafficRows' }
/api/agg/unique-ips:
get:
summary: Уникальные IP с привязкой к серверам (и опционально GeoIP)
parameters:
- $ref: '#/components/parameters/aliases'
- name: geo
in: query
description: 'false — не обогащать GeoIP'
schema: { type: string, enum: ['false', 'true'] }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/AggEnvelopeUniqueIPs' }
/api/agg/users:
get:
summary: Список пользователей с merge по серверам
parameters:
- $ref: '#/components/parameters/aliases'
- name: include_links
in: query
schema: { type: string, enum: ['true', 'false'] }
- name: min_total_megabytes
in: query
schema: { type: number, format: float }
- name: min_total_octets
in: query
schema: { type: string }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/AggEnvelopeUsersRows' }
/api/agg/user/{username}:
get:
summary: Один пользователь (тот же объект, что в users[])
parameters:
- name: username
in: path
required: true
schema:
type: string
pattern: '^[A-Za-z0-9_.-]+$'
- $ref: '#/components/parameters/aliases'
- name: include_links
in: query
schema: { type: string, enum: ['true', 'false'] }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/AggEnvelopeUsersRow' }
'404':
description: Пользователь не найден ни на одном upstream
/api/agg/fleet-status:
get:
summary: Health + system/info по всем выбранным серверам
parameters:
- $ref: '#/components/parameters/aliases'
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/AggEnvelopeFleetStatus' }
components:
parameters:
aliases:
name: aliases
in: query
description: Список алиасов через запятую
schema: { type: string }
schemas:
AggSuccessBase:
type: object
required: [ok, data, generated_at]
properties:
ok: { type: boolean, enum: [true] }
generated_at: { type: string, format: date-time }
partial: { type: boolean, description: true если часть upstream недоступна }
AggEnvelopeSummary:
allOf:
- $ref: '#/components/schemas/AggSuccessBase'
- type: object
properties:
data:
type: object
description: SummaryData (см. реализацию / доку AGGREGATE.md)
AggEnvelopeTrafficRows:
allOf:
- $ref: '#/components/schemas/AggSuccessBase'
- type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/TrafficRow' }
AggEnvelopeUniqueIPs:
allOf:
- $ref: '#/components/schemas/AggSuccessBase'
- type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/UniqueIPsRow' }
AggEnvelopeUsersRows:
allOf:
- $ref: '#/components/schemas/AggSuccessBase'
- type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/UsersRow' }
AggEnvelopeUsersRow:
allOf:
- $ref: '#/components/schemas/AggSuccessBase'
- type: object
properties:
data: { $ref: '#/components/schemas/UsersRow' }
AggEnvelopeFleetStatus:
allOf:
- $ref: '#/components/schemas/AggSuccessBase'
- type: object
properties:
data: { $ref: '#/components/schemas/FleetStatusData' }
TrafficRow:
type: object
properties:
username: { type: string }
servers:
type: object
additionalProperties:
$ref: '#/components/schemas/TrafficServerStats'
TrafficServerStats:
type: object
properties:
total_megabytes: { type: number, format: float }
current_connections: { type: integer, format: int64 }
revision: { type: string }
UniqueIPsRow:
type: object
properties:
username: { type: string }
ips:
type: array
items: { $ref: '#/components/schemas/IPAssignments' }
IPAssignments:
type: object
properties:
ip: { type: string }
active_on_servers:
type: array
items: { type: string }
recent_on_servers:
type: array
items: { type: string }
primary_server: { type: string, nullable: true }
country_code: { type: string, nullable: true }
country_name: { type: string, nullable: true }
city_name: { type: string, nullable: true }
asn: { type: integer, format: int64, nullable: true }
as_organization: { type: string, nullable: true }
UserLinks:
type: object
properties:
classic:
type: array
items: { type: string }
secure:
type: array
items: { type: string }
tls:
type: array
items: { type: string }
UsersRow:
type: object
properties:
username: { type: string }
total_megabytes: { type: number, format: float }
by_server:
type: object
additionalProperties:
$ref: '#/components/schemas/TrafficServerStats'
links: { $ref: '#/components/schemas/UserLinks' }
active_unique_ips: { type: integer, format: int64 }
recent_unique_ips: { type: integer, format: int64 }
user_ad_tag: { type: string, nullable: true }
max_tcp_conns: { type: integer, format: int64, nullable: true }
expiration_rfc3339: { type: string, nullable: true }
data_quota_bytes: { type: integer, format: int64, nullable: true }
max_unique_ips: { type: integer, format: int64, nullable: true }
HealthData:
type: object
properties:
status: { type: string }
read_only: { type: boolean }
SystemInfoData:
type: object
properties:
version: { type: string }
target_arch: { type: string }
target_os: { type: string }
build_profile: { type: string }
git_commit: { type: string, nullable: true }
build_time_utc: { type: string, nullable: true }
rustc_version: { type: string, nullable: true }
process_started_at_epoch_secs: { type: integer, format: int64 }
uptime_seconds: { type: number, format: float }
config_path: { type: string }
config_hash: { type: string }
config_reload_count: { type: integer, format: int64 }
last_config_reload_epoch_secs: { type: integer, format: int64, nullable: true }
FleetServerStatus:
type: object
properties:
alias: { type: string }
ok: { type: boolean }
health_ok: { type: boolean }
health_http_status: { type: integer }
health_latency_ms: { type: integer, format: int64 }
health_error: { type: string }
health_revision: { type: string }
health: { $ref: '#/components/schemas/HealthData' }
system_info_ok: { type: boolean }
system_info_http_status: { type: integer }
system_info_latency_ms: { type: integer, format: int64 }
system_info_error: { type: string }
system_info_revision: { type: string }
system_info: { $ref: '#/components/schemas/SystemInfoData' }
FleetStatusData:
type: object
properties:
servers:
type: array
items: { $ref: '#/components/schemas/FleetServerStatus' }
servers_total: { type: integer }
servers_all_ok: { type: integer }
servers_failed: { type: integer }