commit 91f289c64774556dcb5ffb26846e7e46f4eafcdc Author: Denozordec Date: Sun Mar 29 22:51:02 2026 +0700 Init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..287d583 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +.cursor +docs +*.md +!README.md +.gitea +docker-compose*.yml +deploy diff --git a/.gitea/workflows/docker.yaml b/.gitea/workflows/docker.yaml new file mode 100644 index 0000000..80a486d --- /dev/null +++ b/.gitea/workflows/docker.yaml @@ -0,0 +1,61 @@ +name: ci + +on: + push: + branches: [main, master] + tags: ["v*"] + pull_request: + +env: + GO_VERSION: "1.22" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Test + run: go mod tidy && go test ./... + + docker: + needs: test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build image + run: docker build -t telemt-api:ci . + + - name: Push to registry + if: github.event_name == 'push' + env: + REGISTRY_IMAGE: ${{ secrets.REGISTRY_IMAGE }} + REGISTRY_URL: ${{ secrets.REGISTRY_URL }} + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -e + if [ -z "$REGISTRY_IMAGE" ] || [ -z "$REGISTRY_URL" ] || [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_PASSWORD" ]; then + echo "Registry secrets not set (REGISTRY_IMAGE, REGISTRY_URL, REGISTRY_USER, REGISTRY_PASSWORD) — push skipped." + exit 0 + fi + echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" -u "$REGISTRY_USER" --password-stdin + docker tag telemt-api:ci "${REGISTRY_IMAGE}:sha-${GITHUB_SHA}" + docker push "${REGISTRY_IMAGE}:sha-${GITHUB_SHA}" + if [ "${GITHUB_REF}" = "refs/heads/main" ] || [ "${GITHUB_REF}" = "refs/heads/master" ]; then + docker tag telemt-api:ci "${REGISTRY_IMAGE}:latest" + docker push "${REGISTRY_IMAGE}:latest" + fi + if echo "${GITHUB_REF}" | grep -q '^refs/tags/'; then + TAG="${GITHUB_REF#refs/tags/}" + docker tag telemt-api:ci "${REGISTRY_IMAGE}:${TAG}" + docker push "${REGISTRY_IMAGE}:${TAG}" + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1398916 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/gateway +/gateway.exe +*.exe +*.test +.env +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b8fa3bb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.22-bookworm AS build +WORKDIR /src +COPY go.mod ./ +COPY cmd/ ./cmd/ +COPY internal/ ./internal/ +RUN go mod tidy && go mod download +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/gateway ./cmd/gateway + +# Alpine: non-root + wget for HEALTHCHECK (distroless has no shell/wget). +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates wget \ + && addgroup -S gateway -g 65532 \ + && adduser -S -u 65532 -G gateway gateway +COPY --from=build /out/gateway /gateway +USER gateway:gateway +EXPOSE 8080 +ENV CONFIG_PATH=/etc/telemt-gateway/config.yaml +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O- http://127.0.0.1:8080/health >/dev/null || exit 1 +ENTRYPOINT ["/gateway"] diff --git a/cmd/gateway/main.go b/cmd/gateway/main.go new file mode 100644 index 0000000..6cc5886 --- /dev/null +++ b/cmd/gateway/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/telemt/telemt-api/internal/config" + "github.com/telemt/telemt-api/internal/server" +) + +func main() { + log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + + cfgPath := os.Getenv("CONFIG_PATH") + if cfgPath == "" { + cfgPath = "/etc/telemt-gateway/config.yaml" + } + cfg, err := config.Load(cfgPath) + if err != nil { + log.Error("config load failed", "path", cfgPath, "err", err) + os.Exit(1) + } + parsed, err := cfg.Parse() + if err != nil { + log.Error("config parse failed", "err", err) + os.Exit(1) + } + + gw, err := server.NewGateway(parsed, log) + if err != nil { + log.Error("gateway init failed", "err", err) + os.Exit(1) + } + + srv := &http.Server{ + Addr: parsed.Config.Listen, + Handler: gw.Handler(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 0, + WriteTimeout: 0, + IdleTimeout: 120 * time.Second, + } + + go func() { + log.Info("listening", "addr", parsed.Config.Listen) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error("server error", "err", err) + os.Exit(1) + } + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + log.Info("shutdown signal") + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _ = gw.Shutdown(ctx) + if err := srv.Shutdown(ctx); err != nil { + log.Error("shutdown error", "err", err) + } +} diff --git a/config.compose.yaml b/config.compose.yaml new file mode 100644 index 0000000..63c5c27 --- /dev/null +++ b/config.compose.yaml @@ -0,0 +1,9 @@ +# Пример для docker compose: без whitelist (только для локальной проверки). +listen: ":8080" +allow_all: true +whitelist_cidrs: [] +trusted_proxies: [] +servers: + - alias: main_srv + # Telemt на хосте Windows/macOS/Linux: + base_url: http://host.docker.internal:9091 diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..8bddd04 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,31 @@ +# Telemt API gateway — copy to config.yaml and mount into the container. +# +# Semantics: client calls GET /api/{alias}/health +# forwarded to GET {base_url}/v1/health + +listen: ":8080" + +# If true, IP whitelist is not enforced (development only). +allow_all: false + +# CIDR allowlist when allow_all is false. Empty list denies all clients. +whitelist_cidrs: + - "127.0.0.1/32" + - "::1/128" + # Docker bridge (adjust to your environment): + # - "172.16.0.0/12" + +# When the direct TCP peer is in these CIDRs, client IP for whitelist is taken +# from X-Forwarded-For (first hop) or X-Real-IP (e.g. behind nginx). +# trusted_proxies: +# - "10.0.0.0/8" +trusted_proxies: [] + +servers: + - alias: main_srv + base_url: http://127.0.0.1:9091 + # Default path prefix on upstream (Telemt Control API uses /v1). + path_prefix: /v1 + # Optional: name of environment variable whose value is sent as Authorization + # to this upstream (exact string, Telemt auth_header semantics). + # authorization_env: TELEMT_API_AUTH diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cdf42cc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + gateway: + build: . + image: telemt-api-gateway:local + ports: + - "8080:8080" + volumes: + - ./config.compose.yaml:/etc/telemt-gateway/config.yaml:ro + environment: + CONFIG_PATH: /etc/telemt-gateway/config.yaml diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..3f582e6 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,1187 @@ +# Telemt Control API + +## Контекст репозитория `telemt-api` + +Этот репозиторий предназначен для клиентских обёрток, SDK, контрактных тестов и вспомогательной документации вокруг **control-plane** HTTP API процесса [Telemt](https://github.com/telemt/telemt) (видимость рантайма и управление пользователями/конфигурацией). Данные MTProto в область API не входят. + +Ниже перенесена официальная спецификация из upstream. При расхождениях с поведением конкретной сборки сервера ориентируйтесь на версию бинарника (`GET /v1/system/info` → `version`, `git_commit`) и при необходимости сверяйте [исходный `docs/API.md`](https://github.com/telemt/telemt/blob/main/docs/API.md) в ветке `main`. + +**Синхронизация с upstream:** снимок с `https://raw.githubusercontent.com/telemt/telemt/main/docs/API.md` (ветка `main`, дата фиксации копии в этом репозитории: 2026-03-29). При обновлении Telemt выполняйте diff против raw-файла и переносите изменения в этот документ. + +--- + +# Telemt Control API + +## Purpose +Control-plane HTTP API for runtime visibility and user/config management. +Data-plane MTProto traffic is out of scope. + +## Runtime Configuration +API runtime is configured in `[server.api]`. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `enabled` | `bool` | `false` | Enables REST API listener. | +| `listen` | `string` (`IP:PORT`) | `127.0.0.1:9091` | API bind address. | +| `whitelist` | `CIDR[]` | `127.0.0.1/32, ::1/128` | 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`. | +| `minimal_runtime_enabled` | `bool` | `false` | 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. | +| `runtime_edge_cache_ttl_ms` | `u64` | `1000` | Cache TTL for runtime edge summary payloads. `0` disables cache. | +| `runtime_edge_top_n` | `usize` | `10` | Top-N rows for runtime edge leaderboard payloads. | +| `runtime_edge_events_capacity` | `usize` | `256` | Ring-buffer size for `/v1/runtime/events/recent`. | +| `read_only` | `bool` | `false` | Disables mutating endpoints. | + +`server.admin_api` is accepted as an alias for backward compatibility. + +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.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]`. +- `server.api.runtime_edge_events_capacity` must be within `[16, 4096]`. + +## Protocol Contract + +| Item | Value | +| --- | --- | +| Transport | HTTP/1.1 | +| Content type | `application/json; charset=utf-8` | +| Prefix | `/v1` | +| Optimistic concurrency | `If-Match: ` on mutating requests (optional) | +| Revision format | SHA-256 hex of current `config.toml` content | + +### Success Envelope +```json +{ + "ok": true, + "data": {}, + "revision": "sha256-hex" +} +``` + +### Error Envelope +```json +{ + "ok": false, + "error": { + "code": "machine_code", + "message": "human-readable" + }, + "request_id": 1 +} +``` + +## Request Processing Order + +Requests are processed in this order: +1. `api_enabled` gate (`503 api_disabled` if disabled). +2. Source IP whitelist gate (`403 forbidden`). +3. `Authorization` header gate when configured (`401 unauthorized`). +4. Route and method matching (`404 not_found` or `405 method_not_allowed`). +5. `read_only` gate for mutating routes (`403 read_only`). +6. Request body read/limit/JSON decode (`413 payload_too_large`, `400 bad_request`). +7. Business validation and config write path. + +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`. + +## Endpoint Matrix + +| Method | Path | Body | Success | `data` contract | +| --- | --- | --- | --- | --- | +| `GET` | `/v1/health` | none | `200` | `HealthData` | +| `GET` | `/v1/system/info` | none | `200` | `SystemInfoData` | +| `GET` | `/v1/runtime/gates` | none | `200` | `RuntimeGatesData` | +| `GET` | `/v1/runtime/initialization` | none | `200` | `RuntimeInitializationData` | +| `GET` | `/v1/limits/effective` | none | `200` | `EffectiveLimitsData` | +| `GET` | `/v1/security/posture` | none | `200` | `SecurityPostureData` | +| `GET` | `/v1/security/whitelist` | none | `200` | `SecurityWhitelistData` | +| `GET` | `/v1/stats/summary` | none | `200` | `SummaryData` | +| `GET` | `/v1/stats/zero/all` | none | `200` | `ZeroAllData` | +| `GET` | `/v1/stats/upstreams` | none | `200` | `UpstreamsData` | +| `GET` | `/v1/stats/minimal/all` | none | `200` | `MinimalAllData` | +| `GET` | `/v1/stats/me-writers` | none | `200` | `MeWritersData` | +| `GET` | `/v1/stats/dcs` | none | `200` | `DcStatusData` | +| `GET` | `/v1/runtime/me_pool_state` | none | `200` | `RuntimeMePoolStateData` | +| `GET` | `/v1/runtime/me_quality` | none | `200` | `RuntimeMeQualityData` | +| `GET` | `/v1/runtime/upstream_quality` | none | `200` | `RuntimeUpstreamQualityData` | +| `GET` | `/v1/runtime/nat_stun` | none | `200` | `RuntimeNatStunData` | +| `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` | none | `200` | `UserInfo[]` | +| `GET` | `/v1/users` | none | `200` | `UserInfo[]` | +| `POST` | `/v1/users` | `CreateUserRequest` | `201` | `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) | + +## Common Error Codes + +| HTTP | `error.code` | Trigger | +| --- | --- | --- | +| `400` | `bad_request` | Invalid JSON, validation failures, malformed request body. | +| `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). | +| `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. | +| `409` | `last_user_forbidden` | Attempt to delete last configured user. | +| `413` | `payload_too_large` | Body exceeds `request_body_limit_bytes`. | +| `500` | `internal_error` | Internal error (I/O, serialization, config load/save). | +| `503` | `api_disabled` | API disabled in config. | + +## Routing and Method Edge Cases + +| 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`. | +| 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. | + +## Body and JSON Semantics + +- Request body is read only for mutating routes that define a body contract. +- Body size limit is enforced during streaming read (`413 payload_too_large`). +- Invalid transport body frame returns `400 bad_request` (`Invalid request body`). +- 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. +- `If-Match` supports both quoted and unquoted values; surrounding whitespace is trimmed. + +## Query Parameters + +| Endpoint | Query | Behavior | +| --- | --- | --- | +| `GET /v1/runtime/events/recent` | `limit=` | Optional. Invalid/missing value falls back to default `50`. Effective value is clamped to `[1, 1000]` and additionally bounded by ring-buffer capacity. | + +## Request Contracts + +### `CreateUserRequest` +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `username` | `string` | yes | `[A-Za-z0-9_.-]`, length `1..64`. | +| `secret` | `string` | no | Exactly 32 hex chars. If missing, generated automatically. | +| `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. | + +### `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. | + +### `RotateSecretRequest` +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `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). + +## Response Data Contracts + +### `HealthData` +| Field | Type | Description | +| --- | --- | --- | +| `status` | `string` | Always `"ok"`. | +| `read_only` | `bool` | Mirrors current API `read_only` mode. | + +### `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. | +| `handshake_timeouts_total` | `u64` | Handshake timeout count. | +| `configured_users` | `usize` | Number of configured users in config. | + +### `SystemInfoData` +| Field | Type | Description | +| --- | --- | --- | +| `version` | `string` | Binary version (`CARGO_PKG_VERSION`). | +| `target_arch` | `string` | Target architecture (`std::env::consts::ARCH`). | +| `target_os` | `string` | Target OS (`std::env::consts::OS`). | +| `build_profile` | `string` | Build profile (`PROFILE` env when available). | +| `git_commit` | `string?` | Optional commit hash from build env metadata. | +| `build_time_utc` | `string?` | Optional build timestamp from build env metadata. | +| `rustc_version` | `string?` | Optional compiler version from build env metadata. | +| `process_started_at_epoch_secs` | `u64` | Process start time as Unix epoch seconds. | +| `uptime_seconds` | `f64` | Process uptime in seconds. | +| `config_path` | `string` | Active config file path used by runtime. | +| `config_hash` | `string` | SHA-256 hash of current config content (same value as envelope `revision`). | +| `config_reload_count` | `u64` | Number of successfully observed config updates since process start. | +| `last_config_reload_epoch_secs` | `u64?` | Unix epoch seconds of the latest observed config reload; null/absent before first reload. | + +### `RuntimeGatesData` +| Field | Type | Description | +| --- | --- | --- | +| `accepting_new_connections` | `bool` | Current admission-gate state for new listener accepts. | +| `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. | +| `use_middle_proxy` | `bool` | Current transport mode preference. | +| `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`). | + +### `RuntimeInitializationData` +| Field | Type | Description | +| --- | --- | --- | +| `status` | `string` | Startup status (`pending`, `initializing`, `ready`, `failed`, `skipped`). | +| `degraded` | `bool` | Whether runtime is currently in degraded mode. | +| `current_stage` | `string` | Current startup stage identifier. | +| `progress_pct` | `f64` | Overall startup progress percentage (`0..100`). | +| `started_at_epoch_secs` | `u64` | Process start timestamp (Unix seconds). | +| `ready_at_epoch_secs` | `u64?` | Timestamp when startup reached ready state; absent until ready. | +| `total_elapsed_ms` | `u64` | Elapsed startup duration in milliseconds. | +| `transport_mode` | `string` | Startup transport mode (`middle_proxy` or `direct`). | +| `me` | `RuntimeInitializationMeData` | ME startup substate snapshot. | +| `components` | `RuntimeInitializationComponentData[]` | Per-component startup timeline and status. | + +#### `RuntimeInitializationMeData` +| Field | Type | Description | +| --- | --- | --- | +| `status` | `string` | ME startup status (`pending`, `initializing`, `ready`, `failed`, `skipped`). | +| `current_stage` | `string` | Current ME startup stage identifier. | +| `progress_pct` | `f64` | ME startup progress percentage (`0..100`). | +| `init_attempt` | `u32` | Current ME init attempt counter. | +| `retry_limit` | `string` | Retry limit (`"unlimited"` or numeric string). | +| `last_error` | `string?` | Last ME initialization error text when present. | + +#### `RuntimeInitializationComponentData` +| Field | Type | Description | +| --- | --- | --- | +| `id` | `string` | Startup component identifier. | +| `title` | `string` | Human-readable component title. | +| `status` | `string` | Component status (`pending`, `running`, `ready`, `failed`, `skipped`). | +| `started_at_epoch_ms` | `u64?` | Component start timestamp in Unix milliseconds. | +| `finished_at_epoch_ms` | `u64?` | Component finish timestamp in Unix milliseconds. | +| `duration_ms` | `u64?` | Component duration in milliseconds. | +| `attempts` | `u32` | Attempt counter for this component. | +| `details` | `string?` | Optional short status details text. | + +### `EffectiveLimitsData` +| Field | Type | Description | +| --- | --- | --- | +| `update_every_secs` | `u64` | Effective unified updater interval. | +| `me_reinit_every_secs` | `u64` | Effective ME periodic reinit interval. | +| `me_pool_force_close_secs` | `u64` | Effective stale-writer force-close timeout. | +| `timeouts` | `EffectiveTimeoutLimits` | Effective timeout policy snapshot. | +| `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. | + +#### `EffectiveTimeoutLimits` +| Field | Type | Description | +| --- | --- | --- | +| `client_handshake_secs` | `u64` | Client handshake timeout. | +| `tg_connect_secs` | `u64` | Upstream Telegram connect timeout. | +| `client_keepalive_secs` | `u64` | Client keepalive interval. | +| `client_ack_secs` | `u64` | ACK timeout. | +| `me_one_retry` | `u8` | Fast retry count for single-endpoint ME DC. | +| `me_one_timeout_ms` | `u64` | Fast retry timeout per attempt for single-endpoint ME DC. | + +#### `EffectiveUpstreamLimits` +| Field | Type | Description | +| --- | --- | --- | +| `connect_retry_attempts` | `u32` | Upstream connect retry attempts. | +| `connect_retry_backoff_ms` | `u64` | Upstream retry backoff delay. | +| `connect_budget_ms` | `u64` | Total connect wall-clock budget across retries. | +| `unhealthy_fail_threshold` | `u32` | Consecutive fail threshold for unhealthy marking. | +| `connect_failfast_hard_errors` | `bool` | Whether hard errors skip additional retries. | + +#### `EffectiveMiddleProxyLimits` +| Field | Type | Description | +| --- | --- | --- | +| `floor_mode` | `string` | Effective floor mode (`static` or `adaptive`). | +| `adaptive_floor_idle_secs` | `u64` | Adaptive floor idle threshold. | +| `adaptive_floor_min_writers_single_endpoint` | `u8` | Adaptive floor minimum for single-endpoint DCs. | +| `adaptive_floor_min_writers_multi_endpoint` | `u8` | Adaptive floor minimum for multi-endpoint DCs. | +| `adaptive_floor_recover_grace_secs` | `u64` | Adaptive floor recovery grace period. | +| `adaptive_floor_writers_per_core_total` | `u16` | Target total writers-per-core budget in adaptive mode. | +| `adaptive_floor_cpu_cores_override` | `u16` | Manual CPU core override (`0` means auto-detect). | +| `adaptive_floor_max_extra_writers_single_per_core` | `u16` | Extra per-core adaptive headroom for single-endpoint DCs. | +| `adaptive_floor_max_extra_writers_multi_per_core` | `u16` | Extra per-core adaptive headroom for multi-endpoint DCs. | +| `adaptive_floor_max_active_writers_per_core` | `u16` | Active writer cap per CPU core. | +| `adaptive_floor_max_warm_writers_per_core` | `u16` | Warm writer cap per CPU core. | +| `adaptive_floor_max_active_writers_global` | `u32` | Global active writer cap. | +| `adaptive_floor_max_warm_writers_global` | `u32` | Global warm writer cap. | +| `reconnect_max_concurrent_per_dc` | `u32` | Max concurrent reconnects per DC. | +| `reconnect_backoff_base_ms` | `u64` | Reconnect base backoff. | +| `reconnect_backoff_cap_ms` | `u64` | Reconnect backoff cap. | +| `reconnect_fast_retry_count` | `u32` | Number of fast retries before standard backoff strategy. | +| `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. | + +#### `EffectiveUserIpPolicyLimits` +| Field | Type | Description | +| --- | --- | --- | +| `mode` | `string` | Unique-IP policy mode (`active_window`, `time_window`, `combined`). | +| `window_secs` | `u64` | Time window length used by unique-IP policy. | + +### `SecurityPostureData` +| Field | Type | Description | +| --- | --- | --- | +| `api_read_only` | `bool` | Current API read-only state. | +| `api_whitelist_enabled` | `bool` | Whether whitelist filtering is active. | +| `api_whitelist_entries` | `usize` | Number of configured whitelist CIDRs. | +| `api_auth_header_enabled` | `bool` | Whether `Authorization` header validation is active. | +| `proxy_protocol_enabled` | `bool` | Global PROXY protocol accept setting. | +| `log_level` | `string` | Effective log level (`debug`, `verbose`, `normal`, `silent`). | +| `telemetry_core_enabled` | `bool` | Core telemetry toggle. | +| `telemetry_user_enabled` | `bool` | Per-user telemetry toggle. | +| `telemetry_me_level` | `string` | ME telemetry level (`silent`, `normal`, `debug`). | + +### `SecurityWhitelistData` +| Field | Type | Description | +| --- | --- | --- | +| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | +| `enabled` | `bool` | `true` when whitelist has at least one CIDR entry. | +| `entries_total` | `usize` | Number of whitelist CIDR entries. | +| `entries` | `string[]` | Whitelist CIDR entries as strings. | + +### `RuntimeMePoolStateData` +| Field | Type | Description | +| --- | --- | --- | +| `enabled` | `bool` | Runtime payload availability. | +| `reason` | `string?` | `source_unavailable` when ME pool snapshot is unavailable. | +| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | +| `data` | `RuntimeMePoolStatePayload?` | Null when unavailable. | + +#### `RuntimeMePoolStatePayload` +| Field | Type | Description | +| --- | --- | --- | +| `generations` | `RuntimeMePoolStateGenerationData` | Active/warm/pending/draining generation snapshot. | +| `hardswap` | `RuntimeMePoolStateHardswapData` | Hardswap state flags. | +| `writers` | `RuntimeMePoolStateWriterData` | Writer total/contour/health counters. | +| `refill` | `RuntimeMePoolStateRefillData` | In-flight refill counters by DC/family. | + +#### `RuntimeMePoolStateGenerationData` +| Field | Type | Description | +| --- | --- | --- | +| `active_generation` | `u64` | Active pool generation id. | +| `warm_generation` | `u64` | Warm pool generation id. | +| `pending_hardswap_generation` | `u64` | Pending hardswap generation id (`0` when none). | +| `pending_hardswap_age_secs` | `u64?` | Age of pending hardswap generation in seconds. | +| `draining_generations` | `u64[]` | Distinct generation ids currently draining. | + +#### `RuntimeMePoolStateHardswapData` +| Field | Type | Description | +| --- | --- | --- | +| `enabled` | `bool` | Hardswap feature toggle. | +| `pending` | `bool` | `true` when pending generation is non-zero. | + +#### `RuntimeMePoolStateWriterData` +| Field | Type | Description | +| --- | --- | --- | +| `total` | `usize` | Total writer rows in snapshot. | +| `alive_non_draining` | `usize` | Alive writers excluding draining ones. | +| `draining` | `usize` | Writers marked draining. | +| `degraded` | `usize` | Non-draining degraded writers. | +| `contour` | `RuntimeMePoolStateWriterContourData` | Counts by contour state. | +| `health` | `RuntimeMePoolStateWriterHealthData` | Counts by health bucket. | + +#### `RuntimeMePoolStateWriterContourData` +| Field | Type | Description | +| --- | --- | --- | +| `warm` | `usize` | Writers in warm contour. | +| `active` | `usize` | Writers in active contour. | +| `draining` | `usize` | Writers in draining contour. | + +#### `RuntimeMePoolStateWriterHealthData` +| Field | Type | Description | +| --- | --- | --- | +| `healthy` | `usize` | Non-draining non-degraded writers. | +| `degraded` | `usize` | Non-draining degraded writers. | +| `draining` | `usize` | Draining writers. | + +#### `RuntimeMePoolStateRefillData` +| Field | Type | Description | +| --- | --- | --- | +| `inflight_endpoints_total` | `usize` | Total in-flight endpoint refill operations. | +| `inflight_dc_total` | `usize` | Number of distinct DC+family keys with refill in flight. | +| `by_dc` | `RuntimeMePoolStateRefillDcData[]` | Per-DC refill rows. | + +#### `RuntimeMePoolStateRefillDcData` +| Field | Type | Description | +| --- | --- | --- | +| `dc` | `i16` | Telegram DC id. | +| `family` | `string` | Address family label (`V4`, `V6`). | +| `inflight` | `usize` | In-flight refill operations for this row. | + +### `RuntimeMeQualityData` +| Field | Type | Description | +| --- | --- | --- | +| `enabled` | `bool` | Runtime payload availability. | +| `reason` | `string?` | `source_unavailable` when ME pool snapshot is unavailable. | +| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | +| `data` | `RuntimeMeQualityPayload?` | Null when unavailable. | + +#### `RuntimeMeQualityPayload` +| Field | Type | Description | +| --- | --- | --- | +| `counters` | `RuntimeMeQualityCountersData` | Key ME lifecycle/error counters. | +| `route_drops` | `RuntimeMeQualityRouteDropData` | Route drop counters by reason. | +| `dc_rtt` | `RuntimeMeQualityDcRttData[]` | Per-DC RTT and writer coverage rows. | + +#### `RuntimeMeQualityCountersData` +| Field | Type | Description | +| --- | --- | --- | +| `idle_close_by_peer_total` | `u64` | Peer-initiated idle closes. | +| `reader_eof_total` | `u64` | Reader EOF events. | +| `kdf_drift_total` | `u64` | KDF drift detections. | +| `kdf_port_only_drift_total` | `u64` | KDF port-only drift detections. | +| `reconnect_attempt_total` | `u64` | Reconnect attempts. | +| `reconnect_success_total` | `u64` | Successful reconnects. | + +#### `RuntimeMeQualityRouteDropData` +| Field | Type | Description | +| --- | --- | --- | +| `no_conn_total` | `u64` | Route drops with no connection mapping. | +| `channel_closed_total` | `u64` | Route drops because destination channel is closed. | +| `queue_full_total` | `u64` | Route drops due queue backpressure (aggregate). | +| `queue_full_base_total` | `u64` | Route drops in base-queue path. | +| `queue_full_high_total` | `u64` | Route drops in high-priority queue path. | + +#### `RuntimeMeQualityDcRttData` +| Field | Type | Description | +| --- | --- | --- | +| `dc` | `i16` | Telegram DC id. | +| `rtt_ema_ms` | `f64?` | RTT EMA for this DC. | +| `alive_writers` | `usize` | Alive writers currently mapped to this DC. | +| `required_writers` | `usize` | Target writer floor for this DC. | +| `coverage_pct` | `f64` | `alive_writers / required_writers * 100`. | + +### `RuntimeUpstreamQualityData` +| Field | Type | Description | +| --- | --- | --- | +| `enabled` | `bool` | Runtime payload availability. | +| `reason` | `string?` | `source_unavailable` when upstream runtime snapshot is unavailable. | +| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | +| `policy` | `RuntimeUpstreamQualityPolicyData` | Effective upstream policy values. | +| `counters` | `RuntimeUpstreamQualityCountersData` | Upstream connect counters. | +| `summary` | `RuntimeUpstreamQualitySummaryData?` | Aggregate runtime health summary. | +| `upstreams` | `RuntimeUpstreamQualityUpstreamData[]?` | Per-upstream runtime rows. | + +#### `RuntimeUpstreamQualityPolicyData` +| Field | Type | Description | +| --- | --- | --- | +| `connect_retry_attempts` | `u32` | Upstream connect retry attempts. | +| `connect_retry_backoff_ms` | `u64` | Upstream retry backoff delay. | +| `connect_budget_ms` | `u64` | Total connect wall-clock budget. | +| `unhealthy_fail_threshold` | `u32` | Consecutive fail threshold for unhealthy marking. | +| `connect_failfast_hard_errors` | `bool` | Whether hard errors skip retries. | + +#### `RuntimeUpstreamQualityCountersData` +| Field | Type | Description | +| --- | --- | --- | +| `connect_attempt_total` | `u64` | Total connect attempts. | +| `connect_success_total` | `u64` | Successful connects. | +| `connect_fail_total` | `u64` | Failed connects. | +| `connect_failfast_hard_error_total` | `u64` | Fail-fast hard errors. | + +#### `RuntimeUpstreamQualitySummaryData` +| Field | Type | Description | +| --- | --- | --- | +| `configured_total` | `usize` | Total configured upstream entries. | +| `healthy_total` | `usize` | Upstreams currently healthy. | +| `unhealthy_total` | `usize` | Upstreams currently unhealthy. | +| `direct_total` | `usize` | Direct-route upstream entries. | +| `socks4_total` | `usize` | SOCKS4 upstream entries. | +| `socks5_total` | `usize` | SOCKS5 upstream entries. | +| `shadowsocks_total` | `usize` | Shadowsocks upstream entries. | + +#### `RuntimeUpstreamQualityUpstreamData` +| Field | Type | Description | +| --- | --- | --- | +| `upstream_id` | `usize` | Runtime upstream index. | +| `route_kind` | `string` | `direct`, `socks4`, `socks5`, `shadowsocks`. | +| `address` | `string` | Upstream address (`direct` literal for direct route kind, `host:port` only for proxied upstreams). | +| `weight` | `u16` | Selection weight. | +| `scopes` | `string` | Configured scope selector. | +| `healthy` | `bool` | Current health flag. | +| `fails` | `u32` | Consecutive fail counter. | +| `last_check_age_secs` | `u64` | Seconds since last health update. | +| `effective_latency_ms` | `f64?` | Effective latency score used by selector. | +| `dc` | `RuntimeUpstreamQualityDcData[]` | Per-DC runtime rows. | + +#### `RuntimeUpstreamQualityDcData` +| Field | Type | Description | +| --- | --- | --- | +| `dc` | `i16` | Telegram DC id. | +| `latency_ema_ms` | `f64?` | Per-DC latency EMA. | +| `ip_preference` | `string` | `unknown`, `prefer_v4`, `prefer_v6`, `both_work`, `unavailable`. | + +### `RuntimeNatStunData` +| Field | Type | Description | +| --- | --- | --- | +| `enabled` | `bool` | Runtime payload availability. | +| `reason` | `string?` | `source_unavailable` when shared STUN state is unavailable. | +| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | +| `data` | `RuntimeNatStunPayload?` | Null when unavailable. | + +#### `RuntimeNatStunPayload` +| Field | Type | Description | +| --- | --- | --- | +| `flags` | `RuntimeNatStunFlagsData` | NAT probe runtime flags. | +| `servers` | `RuntimeNatStunServersData` | Configured/live STUN server lists. | +| `reflection` | `RuntimeNatStunReflectionBlockData` | Reflection cache data for v4/v6. | +| `stun_backoff_remaining_ms` | `u64?` | Remaining retry backoff (milliseconds). | + +#### `RuntimeNatStunFlagsData` +| Field | Type | Description | +| --- | --- | --- | +| `nat_probe_enabled` | `bool` | Current NAT probe enable state. | +| `nat_probe_disabled_runtime` | `bool` | Runtime disable flag due failures/conditions. | +| `nat_probe_attempts` | `u8` | Configured NAT probe attempt count. | + +#### `RuntimeNatStunServersData` +| Field | Type | Description | +| --- | --- | --- | +| `configured` | `string[]` | Configured STUN server entries. | +| `live` | `string[]` | Runtime live STUN server entries. | +| `live_total` | `usize` | Number of live STUN entries. | + +#### `RuntimeNatStunReflectionBlockData` +| Field | Type | Description | +| --- | --- | --- | +| `v4` | `RuntimeNatStunReflectionData?` | IPv4 reflection data. | +| `v6` | `RuntimeNatStunReflectionData?` | IPv6 reflection data. | + +#### `RuntimeNatStunReflectionData` +| Field | Type | Description | +| --- | --- | --- | +| `addr` | `string` | Reflected public endpoint (`ip:port`). | +| `age_secs` | `u64` | Reflection value age in seconds. | + +### `RuntimeMeSelftestData` +| Field | Type | Description | +| --- | --- | --- | +| `enabled` | `bool` | Runtime payload availability. | +| `reason` | `string?` | `source_unavailable` when ME pool is unavailable. | +| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | +| `data` | `RuntimeMeSelftestPayload?` | Null when unavailable. | + +#### `RuntimeMeSelftestPayload` +| Field | Type | Description | +| --- | --- | --- | +| `kdf` | `RuntimeMeSelftestKdfData` | KDF EWMA health state. | +| `timeskew` | `RuntimeMeSelftestTimeskewData` | Date-header skew health state. | +| `ip` | `RuntimeMeSelftestIpData` | Interface IP family classification. | +| `pid` | `RuntimeMeSelftestPidData` | Process PID marker (`one|non-one`). | +| `bnd` | `RuntimeMeSelftestBndData` | SOCKS BND.ADDR/BND.PORT health state. | + +#### `RuntimeMeSelftestKdfData` +| Field | Type | Description | +| --- | --- | --- | +| `state` | `string` | `ok` or `error` based on EWMA threshold. | +| `ewma_errors_per_min` | `f64` | EWMA KDF error rate per minute. | +| `threshold_errors_per_min` | `f64` | Threshold used for `error` decision. | +| `errors_total` | `u64` | Total source errors (`kdf_drift + socks_kdf_strict_reject`). | + +#### `RuntimeMeSelftestTimeskewData` +| Field | Type | Description | +| --- | --- | --- | +| `state` | `string` | `ok` or `error` (`max_skew_secs_15m > 60` => `error`). | +| `max_skew_secs_15m` | `u64?` | Maximum observed skew in the last 15 minutes. | +| `samples_15m` | `usize` | Number of skew samples in the last 15 minutes. | +| `last_skew_secs` | `u64?` | Latest observed skew value. | +| `last_source` | `string?` | Latest skew source marker. | +| `last_seen_age_secs` | `u64?` | Age of the latest skew sample. | + +#### `RuntimeMeSelftestIpData` +| Field | Type | Description | +| --- | --- | --- | +| `v4` | `RuntimeMeSelftestIpFamilyData?` | IPv4 interface probe result; absent when unknown. | +| `v6` | `RuntimeMeSelftestIpFamilyData?` | IPv6 interface probe result; absent when unknown. | + +#### `RuntimeMeSelftestIpFamilyData` +| Field | Type | Description | +| --- | --- | --- | +| `addr` | `string` | Detected interface IP. | +| `state` | `string` | `good`, `bogon`, or `loopback`. | + +#### `RuntimeMeSelftestPidData` +| Field | Type | Description | +| --- | --- | --- | +| `pid` | `u32` | Current process PID. | +| `state` | `string` | `one` when PID=1, otherwise `non-one`. | + +#### `RuntimeMeSelftestBndData` +| Field | Type | Description | +| --- | --- | --- | +| `addr_state` | `string` | `ok`, `bogon`, or `error`. | +| `port_state` | `string` | `ok`, `zero`, or `error`. | +| `last_addr` | `string?` | Latest observed SOCKS BND address. | +| `last_seen_age_secs` | `u64?` | Age of latest BND sample. | + +### `RuntimeEdgeConnectionsSummaryData` +| Field | Type | Description | +| --- | --- | --- | +| `enabled` | `bool` | Endpoint availability under `runtime_edge_enabled`. | +| `reason` | `string?` | `feature_disabled` or `source_unavailable`. | +| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | +| `data` | `RuntimeEdgeConnectionsSummaryPayload?` | Null when unavailable. | + +#### `RuntimeEdgeConnectionsSummaryPayload` +| Field | Type | Description | +| --- | --- | --- | +| `cache` | `RuntimeEdgeConnectionCacheData` | Runtime edge cache metadata. | +| `totals` | `RuntimeEdgeConnectionTotalsData` | Connection totals block. | +| `top` | `RuntimeEdgeConnectionTopData` | Top-N leaderboard blocks. | +| `telemetry` | `RuntimeEdgeConnectionTelemetryData` | Telemetry-policy flags for counters. | + +#### `RuntimeEdgeConnectionCacheData` +| Field | Type | Description | +| --- | --- | --- | +| `ttl_ms` | `u64` | Configured cache TTL in milliseconds. | +| `served_from_cache` | `bool` | `true` when payload is served from cache. | +| `stale_cache_used` | `bool` | `true` when stale cache is used because recompute is busy. | + +#### `RuntimeEdgeConnectionTotalsData` +| Field | Type | Description | +| --- | --- | --- | +| `current_connections` | `u64` | Current global live connections. | +| `current_connections_me` | `u64` | Current live connections routed through ME. | +| `current_connections_direct` | `u64` | Current live connections routed through direct path. | +| `active_users` | `usize` | Users with `current_connections > 0`. | + +#### `RuntimeEdgeConnectionTopData` +| Field | Type | Description | +| --- | --- | --- | +| `limit` | `usize` | Effective Top-N row count. | +| `by_connections` | `RuntimeEdgeConnectionUserData[]` | Users sorted by current connections. | +| `by_throughput` | `RuntimeEdgeConnectionUserData[]` | Users sorted by cumulative octets. | + +#### `RuntimeEdgeConnectionUserData` +| Field | Type | Description | +| --- | --- | --- | +| `username` | `string` | Username. | +| `current_connections` | `u64` | Current live connections for user. | +| `total_octets` | `u64` | Cumulative (`client->proxy + proxy->client`) octets. | + +#### `RuntimeEdgeConnectionTelemetryData` +| Field | Type | Description | +| --- | --- | --- | +| `user_enabled` | `bool` | Per-user telemetry enable flag. | +| `throughput_is_cumulative` | `bool` | Always `true` in current implementation. | + +### `RuntimeEdgeEventsData` +| Field | Type | Description | +| --- | --- | --- | +| `enabled` | `bool` | Endpoint availability under `runtime_edge_enabled`. | +| `reason` | `string?` | `feature_disabled` when endpoint is disabled. | +| `generated_at_epoch_secs` | `u64` | Snapshot generation timestamp. | +| `data` | `RuntimeEdgeEventsPayload?` | Null when unavailable. | + +#### `RuntimeEdgeEventsPayload` +| Field | Type | Description | +| --- | --- | --- | +| `capacity` | `usize` | Effective ring-buffer capacity. | +| `dropped_total` | `u64` | Count of dropped oldest events due capacity pressure. | +| `events` | `ApiEventRecord[]` | Recent events in chronological order. | + +#### `ApiEventRecord` +| Field | Type | Description | +| --- | --- | --- | +| `seq` | `u64` | Monotonic sequence number. | +| `ts_epoch_secs` | `u64` | Event timestamp (Unix seconds). | +| `event_type` | `string` | Event kind identifier. | +| `context` | `string` | Context text (truncated to implementation-defined max length). | + +### `ZeroAllData` +| Field | Type | Description | +| --- | --- | --- | +| `generated_at_epoch_secs` | `u64` | Snapshot time (Unix epoch seconds). | +| `core` | `ZeroCoreData` | Core counters and telemetry policy snapshot. | +| `upstream` | `ZeroUpstreamData` | Upstream connect counters/histogram buckets. | +| `middle_proxy` | `ZeroMiddleProxyData` | ME protocol/health counters. | +| `pool` | `ZeroPoolData` | ME pool lifecycle counters. | +| `desync` | `ZeroDesyncData` | Frame desync counters. | + +#### `ZeroCoreData` +| Field | Type | Description | +| --- | --- | --- | +| `uptime_seconds` | `f64` | Process uptime. | +| `connections_total` | `u64` | Total accepted connections. | +| `connections_bad_total` | `u64` | Failed/invalid connections. | +| `handshake_timeouts_total` | `u64` | Handshake 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`). | + +#### `ZeroUpstreamData` +| Field | Type | Description | +| --- | --- | --- | +| `connect_attempt_total` | `u64` | Total upstream connect attempts. | +| `connect_success_total` | `u64` | Successful upstream connects. | +| `connect_fail_total` | `u64` | Failed upstream connects. | +| `connect_failfast_hard_error_total` | `u64` | Fail-fast hard errors. | +| `connect_attempts_bucket_1` | `u64` | Connect attempts resolved in 1 try. | +| `connect_attempts_bucket_2` | `u64` | Connect attempts resolved in 2 tries. | +| `connect_attempts_bucket_3_4` | `u64` | Connect attempts resolved in 3-4 tries. | +| `connect_attempts_bucket_gt_4` | `u64` | Connect attempts requiring more than 4 tries. | +| `connect_duration_success_bucket_le_100ms` | `u64` | Successful connects <=100 ms. | +| `connect_duration_success_bucket_101_500ms` | `u64` | Successful connects 101-500 ms. | +| `connect_duration_success_bucket_501_1000ms` | `u64` | Successful connects 501-1000 ms. | +| `connect_duration_success_bucket_gt_1000ms` | `u64` | Successful connects >1000 ms. | +| `connect_duration_fail_bucket_le_100ms` | `u64` | Failed connects <=100 ms. | +| `connect_duration_fail_bucket_101_500ms` | `u64` | Failed connects 101-500 ms. | +| `connect_duration_fail_bucket_501_1000ms` | `u64` | Failed connects 501-1000 ms. | +| `connect_duration_fail_bucket_gt_1000ms` | `u64` | Failed connects >1000 ms. | + +### `UpstreamsData` +| Field | Type | Description | +| --- | --- | --- | +| `enabled` | `bool` | Runtime upstream snapshot availability according to API config. | +| `reason` | `string?` | `feature_disabled` or `source_unavailable` when runtime snapshot is unavailable. | +| `generated_at_epoch_secs` | `u64` | Snapshot generation time. | +| `zero` | `ZeroUpstreamData` | Always available zero-cost upstream counters block. | +| `summary` | `UpstreamSummaryData?` | Runtime upstream aggregate view, null when unavailable. | +| `upstreams` | `UpstreamStatus[]?` | Per-upstream runtime status rows, null when unavailable. | + +#### `UpstreamSummaryData` +| Field | Type | Description | +| --- | --- | --- | +| `configured_total` | `usize` | Total configured upstream entries. | +| `healthy_total` | `usize` | Upstreams currently marked healthy. | +| `unhealthy_total` | `usize` | Upstreams currently marked unhealthy. | +| `direct_total` | `usize` | Number of direct upstream entries. | +| `socks4_total` | `usize` | Number of SOCKS4 upstream entries. | +| `socks5_total` | `usize` | Number of SOCKS5 upstream entries. | +| `shadowsocks_total` | `usize` | Number of Shadowsocks upstream entries. | + +#### `UpstreamStatus` +| Field | Type | Description | +| --- | --- | --- | +| `upstream_id` | `usize` | Runtime upstream index. | +| `route_kind` | `string` | Upstream route kind: `direct`, `socks4`, `socks5`, `shadowsocks`. | +| `address` | `string` | Upstream address (`direct` for direct route kind, `host:port` for Shadowsocks). Authentication fields are intentionally omitted. | +| `weight` | `u16` | Selection weight. | +| `scopes` | `string` | Configured scope selector string. | +| `healthy` | `bool` | Current health flag. | +| `fails` | `u32` | Consecutive fail counter. | +| `last_check_age_secs` | `u64` | Seconds since the last health-check update. | +| `effective_latency_ms` | `f64?` | Effective upstream latency used by selector. | +| `dc` | `UpstreamDcStatus[]` | Per-DC latency/IP preference snapshot. | + +#### `UpstreamDcStatus` +| Field | Type | Description | +| --- | --- | --- | +| `dc` | `i16` | Telegram DC id. | +| `latency_ema_ms` | `f64?` | Per-DC latency EMA value. | +| `ip_preference` | `string` | Per-DC IP family preference: `unknown`, `prefer_v4`, `prefer_v6`, `both_work`, `unavailable`. | + +#### `ZeroMiddleProxyData` +| Field | Type | Description | +| --- | --- | --- | +| `keepalive_sent_total` | `u64` | ME keepalive packets sent. | +| `keepalive_failed_total` | `u64` | ME keepalive send failures. | +| `keepalive_pong_total` | `u64` | Keepalive pong responses received. | +| `keepalive_timeout_total` | `u64` | Keepalive timeout events. | +| `rpc_proxy_req_signal_sent_total` | `u64` | RPC proxy activity signals sent. | +| `rpc_proxy_req_signal_failed_total` | `u64` | RPC proxy activity signal failures. | +| `rpc_proxy_req_signal_skipped_no_meta_total` | `u64` | Signals skipped due to missing metadata. | +| `rpc_proxy_req_signal_response_total` | `u64` | RPC proxy signal responses received. | +| `rpc_proxy_req_signal_close_sent_total` | `u64` | RPC proxy close signals sent. | +| `reconnect_attempt_total` | `u64` | ME reconnect attempts. | +| `reconnect_success_total` | `u64` | Successful reconnects. | +| `handshake_reject_total` | `u64` | ME handshake rejects. | +| `handshake_error_codes` | `ZeroCodeCount[]` | Handshake rejects grouped by code. | +| `reader_eof_total` | `u64` | ME reader EOF events. | +| `idle_close_by_peer_total` | `u64` | Idle closes initiated by peer. | +| `route_drop_no_conn_total` | `u64` | Route drops due to missing bound connection. | +| `route_drop_channel_closed_total` | `u64` | Route drops due to closed channel. | +| `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. | +| `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. | +| `kdf_drift_total` | `u64` | KDF drift detections. | +| `kdf_port_only_drift_total` | `u64` | KDF port-only drift detections. | +| `hardswap_pending_reuse_total` | `u64` | Pending hardswap reused events. | +| `hardswap_pending_ttl_expired_total` | `u64` | Pending hardswap TTL expiry events. | +| `single_endpoint_outage_enter_total` | `u64` | Entered single-endpoint outage mode. | +| `single_endpoint_outage_exit_total` | `u64` | Exited single-endpoint outage mode. | +| `single_endpoint_outage_reconnect_attempt_total` | `u64` | Reconnect attempts in outage mode. | +| `single_endpoint_outage_reconnect_success_total` | `u64` | Reconnect successes in outage mode. | +| `single_endpoint_quarantine_bypass_total` | `u64` | Quarantine bypasses in outage mode. | +| `single_endpoint_shadow_rotate_total` | `u64` | Shadow writer rotations. | +| `single_endpoint_shadow_rotate_skipped_quarantine_total` | `u64` | Shadow rotations skipped because of quarantine. | +| `floor_mode_switch_total` | `u64` | Total floor mode switches. | +| `floor_mode_switch_static_to_adaptive_total` | `u64` | Static -> adaptive switches. | +| `floor_mode_switch_adaptive_to_static_total` | `u64` | Adaptive -> static switches. | + +#### `ZeroCodeCount` +| Field | Type | Description | +| --- | --- | --- | +| `code` | `i32` | Handshake error code. | +| `total` | `u64` | Events with this code. | + +#### `ZeroPoolData` +| Field | Type | Description | +| --- | --- | --- | +| `pool_swap_total` | `u64` | Pool swap count. | +| `pool_drain_active` | `u64` | Current active draining pools. | +| `pool_force_close_total` | `u64` | Forced pool closes by timeout. | +| `pool_stale_pick_total` | `u64` | Stale writer picks for binding. | +| `writer_removed_total` | `u64` | Writer removals total. | +| `writer_removed_unexpected_total` | `u64` | Unexpected writer removals. | +| `refill_triggered_total` | `u64` | Refill triggers. | +| `refill_skipped_inflight_total` | `u64` | Refill skipped because refill already in-flight. | +| `refill_failed_total` | `u64` | Refill failures. | +| `writer_restored_same_endpoint_total` | `u64` | Restores on same endpoint. | +| `writer_restored_fallback_total` | `u64` | Restores on fallback endpoint. | + +#### `ZeroDesyncData` +| Field | Type | Description | +| --- | --- | --- | +| `secure_padding_invalid_total` | `u64` | Invalid secure padding events. | +| `desync_total` | `u64` | Desync events total. | +| `desync_full_logged_total` | `u64` | Fully logged desync events. | +| `desync_suppressed_total` | `u64` | Suppressed desync logs. | +| `desync_frames_bucket_0` | `u64` | Desync frames bucket 0. | +| `desync_frames_bucket_1_2` | `u64` | Desync frames bucket 1-2. | +| `desync_frames_bucket_3_10` | `u64` | Desync frames bucket 3-10. | +| `desync_frames_bucket_gt_10` | `u64` | Desync frames bucket >10. | + +### `MinimalAllData` +| Field | Type | Description | +| --- | --- | --- | +| `enabled` | `bool` | Whether minimal runtime snapshots are enabled by config. | +| `reason` | `string?` | `feature_disabled` or `source_unavailable` when applicable. | +| `generated_at_epoch_secs` | `u64` | Snapshot generation time. | +| `data` | `MinimalAllPayload?` | Null when disabled; fallback payload when source unavailable. | + +#### `MinimalAllPayload` +| Field | Type | Description | +| --- | --- | --- | +| `me_writers` | `MeWritersData` | ME writer status block. | +| `dcs` | `DcStatusData` | DC aggregate status block. | +| `me_runtime` | `MinimalMeRuntimeData?` | Runtime ME control snapshot. | +| `network_path` | `MinimalDcPathData[]` | Active IP path selection per DC. | + +#### `MinimalMeRuntimeData` +| Field | Type | Description | +| --- | --- | --- | +| `active_generation` | `u64` | Active pool generation. | +| `warm_generation` | `u64` | Warm pool generation. | +| `pending_hardswap_generation` | `u64` | Pending hardswap generation. | +| `pending_hardswap_age_secs` | `u64?` | Pending hardswap age in seconds. | +| `hardswap_enabled` | `bool` | Hardswap mode toggle. | +| `floor_mode` | `string` | Writer floor mode. | +| `adaptive_floor_idle_secs` | `u64` | Idle threshold for adaptive floor. | +| `adaptive_floor_min_writers_single_endpoint` | `u8` | Minimum writers for single-endpoint DC in adaptive mode. | +| `adaptive_floor_min_writers_multi_endpoint` | `u8` | Minimum writers for multi-endpoint DC in adaptive mode. | +| `adaptive_floor_recover_grace_secs` | `u64` | Grace period for floor recovery. | +| `adaptive_floor_writers_per_core_total` | `u16` | Target total writers-per-core budget in adaptive mode. | +| `adaptive_floor_cpu_cores_override` | `u16` | CPU core override (`0` means auto-detect). | +| `adaptive_floor_max_extra_writers_single_per_core` | `u16` | Extra single-endpoint writers budget per core. | +| `adaptive_floor_max_extra_writers_multi_per_core` | `u16` | Extra multi-endpoint writers budget per core. | +| `adaptive_floor_max_active_writers_per_core` | `u16` | Active writer cap per core. | +| `adaptive_floor_max_warm_writers_per_core` | `u16` | Warm writer cap per core. | +| `adaptive_floor_max_active_writers_global` | `u32` | Global active writer cap. | +| `adaptive_floor_max_warm_writers_global` | `u32` | Global warm writer cap. | +| `adaptive_floor_cpu_cores_detected` | `u32` | Runtime-detected CPU cores. | +| `adaptive_floor_cpu_cores_effective` | `u32` | Effective core count used for adaptive caps. | +| `adaptive_floor_global_cap_raw` | `u64` | Raw global cap before clamping. | +| `adaptive_floor_global_cap_effective` | `u64` | Effective global cap after clamping. | +| `adaptive_floor_target_writers_total` | `u64` | Current adaptive total writer target. | +| `adaptive_floor_active_cap_configured` | `u64` | Configured global active cap. | +| `adaptive_floor_active_cap_effective` | `u64` | Effective global active cap. | +| `adaptive_floor_warm_cap_configured` | `u64` | Configured global warm cap. | +| `adaptive_floor_warm_cap_effective` | `u64` | Effective global warm cap. | +| `adaptive_floor_active_writers_current` | `u64` | Current active writers count. | +| `adaptive_floor_warm_writers_current` | `u64` | Current warm writers count. | +| `me_keepalive_enabled` | `bool` | ME keepalive toggle. | +| `me_keepalive_interval_secs` | `u64` | Keepalive period. | +| `me_keepalive_jitter_secs` | `u64` | Keepalive jitter. | +| `me_keepalive_payload_random` | `bool` | Randomized keepalive payload toggle. | +| `rpc_proxy_req_every_secs` | `u64` | Period for RPC proxy request signal. | +| `me_reconnect_max_concurrent_per_dc` | `u32` | Reconnect concurrency per DC. | +| `me_reconnect_backoff_base_ms` | `u64` | Base reconnect backoff. | +| `me_reconnect_backoff_cap_ms` | `u64` | Max reconnect backoff. | +| `me_reconnect_fast_retry_count` | `u32` | Fast retry attempts before normal backoff. | +| `me_pool_drain_ttl_secs` | `u64` | Pool drain TTL. | +| `me_pool_force_close_secs` | `u64` | Hard close timeout for draining writers. | +| `me_pool_min_fresh_ratio` | `f32` | Minimum fresh ratio before swap. | +| `me_bind_stale_mode` | `string` | Stale writer bind policy. | +| `me_bind_stale_ttl_secs` | `u64` | Stale writer TTL. | +| `me_single_endpoint_shadow_writers` | `u8` | Shadow writers for single-endpoint DCs. | +| `me_single_endpoint_outage_mode_enabled` | `bool` | Outage mode toggle for single-endpoint DCs. | +| `me_single_endpoint_outage_disable_quarantine` | `bool` | Quarantine behavior in outage mode. | +| `me_single_endpoint_outage_backoff_min_ms` | `u64` | Outage mode min reconnect backoff. | +| `me_single_endpoint_outage_backoff_max_ms` | `u64` | Outage mode max reconnect backoff. | +| `me_single_endpoint_shadow_rotate_every_secs` | `u64` | Shadow rotation interval. | +| `me_deterministic_writer_sort` | `bool` | Deterministic writer ordering toggle. | +| `me_writer_pick_mode` | `string` | Writer picker mode (`sorted_rr`, `p2c`). | +| `me_writer_pick_sample_size` | `u8` | Candidate sample size for `p2c` picker mode. | +| `me_socks_kdf_policy` | `string` | Current SOCKS KDF policy mode. | +| `quarantined_endpoints_total` | `usize` | Total quarantined endpoints. | +| `quarantined_endpoints` | `MinimalQuarantineData[]` | Quarantine details. | + +#### `MinimalQuarantineData` +| Field | Type | Description | +| --- | --- | --- | +| `endpoint` | `string` | Endpoint (`ip:port`). | +| `remaining_ms` | `u64` | Remaining quarantine duration. | + +#### `MinimalDcPathData` +| Field | Type | Description | +| --- | --- | --- | +| `dc` | `i16` | Telegram DC identifier. | +| `ip_preference` | `string?` | Runtime IP family preference. | +| `selected_addr_v4` | `string?` | Selected IPv4 endpoint for this DC. | +| `selected_addr_v6` | `string?` | Selected IPv6 endpoint for this DC. | + +### `MeWritersData` +| Field | Type | Description | +| --- | --- | --- | +| `middle_proxy_enabled` | `bool` | `false` when minimal runtime is disabled or source unavailable. | +| `reason` | `string?` | `feature_disabled` or `source_unavailable` when not fully available. | +| `generated_at_epoch_secs` | `u64` | Snapshot generation time. | +| `summary` | `MeWritersSummary` | Coverage/availability summary. | +| `writers` | `MeWriterStatus[]` | Per-writer statuses. | + +#### `MeWritersSummary` +| Field | Type | Description | +| --- | --- | --- | +| `configured_dc_groups` | `usize` | Number of configured DC groups. | +| `configured_endpoints` | `usize` | Total configured ME endpoints. | +| `available_endpoints` | `usize` | Endpoints currently available. | +| `available_pct` | `f64` | `available_endpoints / configured_endpoints * 100`. | +| `required_writers` | `usize` | Required writers based on current floor policy. | +| `alive_writers` | `usize` | Writers currently alive. | +| `coverage_pct` | `f64` | `alive_writers / required_writers * 100`. | + +#### `MeWriterStatus` +| Field | Type | Description | +| --- | --- | --- | +| `writer_id` | `u64` | Runtime writer identifier. | +| `dc` | `i16?` | DC id if mapped. | +| `endpoint` | `string` | Endpoint (`ip:port`). | +| `generation` | `u64` | Pool generation owning this writer. | +| `state` | `string` | Writer state (`warm`, `active`, `draining`). | +| `draining` | `bool` | Draining flag. | +| `degraded` | `bool` | Degraded flag. | +| `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. | + +### `DcStatusData` +| Field | Type | Description | +| --- | --- | --- | +| `middle_proxy_enabled` | `bool` | `false` when minimal runtime is disabled or source unavailable. | +| `reason` | `string?` | `feature_disabled` or `source_unavailable` when not fully available. | +| `generated_at_epoch_secs` | `u64` | Snapshot generation time. | +| `dcs` | `DcStatus[]` | Per-DC status rows. | + +#### `DcStatus` +| Field | Type | Description | +| --- | --- | --- | +| `dc` | `i16` | Telegram DC id. | +| `endpoints` | `string[]` | Endpoints in this DC (`ip:port`). | +| `endpoint_writers` | `DcEndpointWriters[]` | Active writer counts grouped by endpoint. | +| `available_endpoints` | `usize` | Endpoints currently available in this DC. | +| `available_pct` | `f64` | `available_endpoints / endpoints_total * 100`. | +| `required_writers` | `usize` | Required writer count for this DC. | +| `floor_min` | `usize` | Floor lower bound for this DC. | +| `floor_target` | `usize` | Floor target writer count for this DC. | +| `floor_max` | `usize` | Floor upper bound for this DC. | +| `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`. | +| `rtt_ms` | `f64?` | Aggregated RTT for DC. | +| `load` | `usize` | Active client sessions bound to this DC. | + +#### `DcEndpointWriters` +| Field | Type | Description | +| --- | --- | --- | +| `endpoint` | `string` | Endpoint (`ip:port`). | +| `active_writers` | `usize` | Active writers currently mapped to endpoint. | + +### `UserInfo` +| Field | Type | Description | +| --- | --- | --- | +| `username` | `string` | Username. | +| `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. | +| `max_unique_ips` | `usize?` | Optional unique IP limit. | +| `current_connections` | `u64` | Current live connections. | +| `active_unique_ips` | `usize` | Current active unique source IPs. | +| `active_unique_ips_list` | `ip[]` | Current active unique source IP list. | +| `recent_unique_ips` | `usize` | Unique source IP count inside the configured recent window. | +| `recent_unique_ips_list` | `ip[]` | Recent-window unique source IP list. | +| `total_octets` | `u64` | Total traffic octets for this user. | +| `links` | `UserLinks` | Active connection links derived from current config. | + +#### `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). | + +Link generation uses active config and enabled modes: +- Link port is `general.links.public_port` when configured; otherwise `server.port`. +- If `general.links.public_host` is non-empty, it is used as the single link host override. +- If `public_host` is not set, hosts are resolved from `server.listeners` in order: + `announce` -> `announce_ip` -> listener bind `ip`. +- For wildcard listener IPs (`0.0.0.0` / `::`), startup-detected external IP of the same family is used when available. +- Listener-derived hosts are de-duplicated while preserving first-seen order. +- If multiple hosts are resolved, API returns links for all resolved hosts in every enabled mode. +- If no host can be resolved from listeners, fallback is startup-detected `IPv4 -> IPv6`. +- Final compatibility fallback uses `listen_addr_ipv4`/`listen_addr_ipv6` when routable, otherwise `"UNKNOWN"`. +- User rows are sorted by `username` in ascending lexical order. + +### `CreateUserResponse` +| Field | Type | Description | +| --- | --- | --- | +| `user` | `UserInfo` | Created or updated user view. | +| `secret` | `string` | Effective user secret. | + +## 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. | +| `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: +- Respect `read_only` mode. +- Accept optional `If-Match` for optimistic concurrency. +- Return new `revision` after successful write. +- Use process-local mutation lock + atomic write (`tmp + rename`) for config persistence. + +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. + +## Runtime State Matrix + +| Endpoint | `minimal_runtime_enabled=false` | `minimal_runtime_enabled=true` + source unavailable | `minimal_runtime_enabled=true` + source available | +| --- | --- | --- | --- | +| `/v1/stats/minimal/all` | `enabled=false`, `reason=feature_disabled`, `data=null` | `enabled=true`, `reason=source_unavailable`, fallback `data` with disabled ME blocks | `enabled=true`, `reason` omitted, full payload | +| `/v1/stats/me-writers` | `middle_proxy_enabled=false`, `reason=feature_disabled` | `middle_proxy_enabled=false`, `reason=source_unavailable` | `middle_proxy_enabled=true`, runtime snapshot | +| `/v1/stats/dcs` | `middle_proxy_enabled=false`, `reason=feature_disabled` | `middle_proxy_enabled=false`, `reason=source_unavailable` | `middle_proxy_enabled=true`, runtime snapshot | +| `/v1/stats/upstreams` | `enabled=false`, `reason=feature_disabled`, `summary/upstreams` omitted, `zero` still present | `enabled=true`, `reason=source_unavailable`, `summary/upstreams` omitted, `zero` present | `enabled=true`, `reason` omitted, `summary/upstreams` present, `zero` present | + +`source_unavailable` conditions: +- ME endpoints: ME pool is absent (for example direct-only mode or failed ME initialization). +- Upstreams endpoint: non-blocking upstream snapshot lock is unavailable at request time. + +Additional runtime endpoint behavior: + +| Endpoint | Disabled by feature flag | `source_unavailable` condition | Normal mode | +| --- | --- | --- | --- | +| `/v1/runtime/me_pool_state` | No | ME pool snapshot unavailable | `enabled=true`, full payload | +| `/v1/runtime/me_quality` | No | ME pool snapshot unavailable | `enabled=true`, full payload | +| `/v1/runtime/upstream_quality` | No | Upstream runtime snapshot unavailable | `enabled=true`, full payload | +| `/v1/runtime/nat_stun` | No | STUN shared state unavailable | `enabled=true`, full payload | +| `/v1/runtime/me-selftest` | No | ME pool unavailable => `enabled=false`, `reason=source_unavailable` | `enabled=true`, full payload | +| `/v1/runtime/connections/summary` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Recompute lock contention with no cache entry => `enabled=true`, `reason=source_unavailable` | `enabled=true`, full payload | +| `/v1/runtime/events/recent` | `runtime_edge_enabled=false` => `enabled=false`, `reason=feature_disabled` | Not used in current implementation | `enabled=true`, full payload | + +## ME Fallback Behavior Exposed Via API + +When `general.use_middle_proxy=true` and `general.me2dc_fallback=true`: +- Startup does not block on full ME pool readiness; initialization can continue in background. +- Runtime initialization payload can expose ME stage `background_init` until pool becomes ready. +- Admission/routing decision uses two readiness grace windows for "ME not ready" periods: + `80s` before first-ever readiness is observed (startup grace), + `6s` after readiness has been observed at least once (runtime failover timeout). +- While in fallback window breach, new sessions are routed via Direct-DC; when ME becomes ready, routing returns to Middle mode for new sessions. + +## Serialization Rules + +- Success responses always include `revision`. +- Error responses never include `revision`; they include `request_id`. +- Optional fields with `skip_serializing_if` are omitted when absent. +- Nullable payload fields may still be `null` where contract uses `?` (for example `UserInfo` option fields). +- For `/v1/stats/upstreams`, authentication details of SOCKS upstreams are intentionally omitted. +- `ip[]` fields are serialized as JSON string arrays (for example `"1.2.3.4"`, `"2001:db8::1"`). + +## Operational Notes + +| Topic | Details | +| --- | --- | +| API startup | API listener is spawned only when `[server.api].enabled=true`. | +| `listen` port `0` | API spawn is skipped when parsed listen port is `0` (treated as disabled bind target). | +| Bind failure | Failed API bind logs warning and API task exits (no auto-retry loop). | +| ME runtime status endpoints | `/v1/stats/me-writers`, `/v1/stats/dcs`, `/v1/stats/minimal/all` require `[server.api].minimal_runtime_enabled=true`; otherwise they return disabled payload with `reason=feature_disabled`. | +| Upstream runtime endpoint | `/v1/stats/upstreams` always returns `zero`, but runtime fields (`summary`, `upstreams`) require `[server.api].minimal_runtime_enabled=true`. | +| Restart requirements | `server.api` changes are restart-required for predictable behavior. | +| Hot-reload nuance | A pure `server.api`-only config change may not propagate through watcher broadcast; a mixed change (with hot fields) may propagate API flags while still warning that restart is required. | +| Runtime apply path | Successful writes are picked up by existing config watcher/hot-reload path. | +| Exposure | Built-in TLS/mTLS is not provided. Use loopback bind + reverse proxy if needed. | +| Pagination | User list currently has no pagination/filtering. | +| Serialization side effect | Updated TOML table bodies are re-serialized on write. Endpoints that persist full config can still rewrite broader formatting/comments. | + +## 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. + +--- + +## Пометки для разработки в этом репозитории + +- **Цели:** HTTP-клиент с явными типами ответов/ошибок; по желанию генерация типов из таблиц контрактов ниже; интеграционные тесты против живого `telemt` с `[server.api].enabled = true` и известным `listen`. +- **Версионирование:** в README или в коде клиента фиксируйте минимальную проверенную версию сервера; ломающие изменения контракта сначала ищите в upstream `docs/API.md` и в release notes Telemt. +- **Ловушки при проксировании:** whitelist API проверяет **IP прямого TCP-пира**, заголовок `X-Forwarded-For` **не** участвует. За reverse proxy обычно нужен `listen` на loopback и доверенный прокси только на localhost, либо явная настройка whitelist под цепочку (см. операционные замечания в спецификации). +- **`rotate-secret`:** в текущем релизе маршрут `POST /v1/users/{username}/rotate-secret` **недостижим** (ответ `404`); не закладывайте на него продуктовую логику до исправления в upstream. +- **Оптимистичная конкуренция:** для мутаций опционально `If-Match: `; `revision` — SHA-256 hex содержимого `config.toml` (совпадает с `config_hash` в `SystemInfoData` при успешных ответах). +- **`read_only` и флаги рантайма:** часть полей ответов зависит от `minimal_runtime_enabled`, `runtime_edge_enabled` и доступности ME/upstream снимков; клиент должен корректно обрабатывать `enabled`, `reason` и `null` в данных. +- **Запись конфига:** успешные `PATCH` могут перезаписать широкий фрагмент TOML (комментарии/форматирование); учитывайте при CI и ручном редактировании конфига на диске. + +## Подсказки для работы (и для ассистента) + +Краткий чеклист для реализации запросов и автоматизации: + +| Действие | Деталь | +| --- | --- | +| Базовый URL | `http:///v1`; по умолчанию в конфиге часто `127.0.0.1:9091`. | +| Префикс | Все пути относительно `/v1`; **без** нормализации trailing slash (`/v1/users/` → `404`). | +| Успех | JSON: `ok: true`, поле `data`, всегда `revision` (hex SHA-256 конфига). | +| Ошибка | JSON: `ok: false`, `error: { code, message }`, `request_id`; поля **`revision` нет**. | +| Авторизация | Если в конфиге задан `auth_header`, заголовок `Authorization` должен **точно** совпасть с этой строкой (не Bearer-логика, а literal match). | +| Тело запроса | Для мутаций — лимит `request_body_limit_bytes`; `Content-Type` для JSON **не обязателен**; неизвестные поля JSON игнорируются. | +| Отключено | `503` + `api_disabled`, если API выключен; порт `0` в `listen` — слушатель не поднимается. | +| Только чтение | Мутации при `read_only=true` → `403` + `read_only`. | + +**Мутации (тело / метод):** + +| Метод | Путь | Body | +| --- | --- | --- | +| `POST` | `/v1/users` | `CreateUserRequest` | +| `PATCH` | `/v1/users/{username}` | `PatchUserRequest` | +| `DELETE` | `/v1/users/{username}` | нет | +| `POST` | `/v1/users/{username}/rotate-secret` | сейчас не использовать (ожидаемо `404`) | + +При написании кода ассистенту: перед добавлением эндпоинта сверять имя пути и метод с таблицей «Endpoint Matrix» выше; для опциональных query — раздел «Query Parameters»; для семантики флагов ME/upstream — «Runtime State Matrix» и таблицы `feature_disabled` / `source_unavailable`. \ No newline at end of file diff --git a/docs/GATEWAY_RUN.md b/docs/GATEWAY_RUN.md new file mode 100644 index 0000000..6323af7 --- /dev/null +++ b/docs/GATEWAY_RUN.md @@ -0,0 +1,159 @@ +# Запуск Telemt API Gateway (Docker) + +Оглавление: + +1. [Назначение](#назначение) +2. [Требования](#требования) +3. [Минимальная конфигурация](#минимальная-конфигурация) +4. [Переменные окружения](#переменные-окружения) +5. [Сборка образа](#сборка-образа) +6. [Запуск через Docker CLI](#запуск-через-docker-cli) +7. [Запуск через Docker Compose](#запуск-через-docker-compose) +8. [Проверка](#проверка) +9. [Обновление и CI/CD](#обновление-и-cicd) +10. [Устранение неполадок](#устранение-неполадок) + +## Назначение + +Шлюз — это один HTTP‑вход для нескольких экземпляров [Telemt Control API](API.md): + +- **Белый список IP** (CIDR): кто может обращаться к шлюзу (кроме `GET /health`, см. ниже). +- **Маршрутизация по alias**: клиент вызывает `GET /api/{alias}/health`, шлюз проксирует на `{base_url}/v1/health` у соответствующего сервера. +- **Метрики Prometheus**: `GET /metrics` (под тем же правилом whitelist, что и API). +- **Доверенные прокси**: если прямой TCP‑peer входит в `trusted_proxies`, для проверки whitelist берётся первый адрес из `X-Forwarded-For` или `X-Real-IP`. + +Эндпоинты и контракт ответов бэкенда описаны в [API.md](API.md). + +## Требования + +- Установленные [Docker](https://docs.docker.com/get-docker/) и при необходимости [Docker Compose](https://docs.docker.com/compose/) v2. +- Для локальной сборки из исходников: [Go 1.22+](https://go.dev/dl/) (опционально, если не используете только готовый образ из registry). + +## Минимальная конфигурация + +Скопируйте [config.example.yaml](../config.example.yaml) в свой `config.yaml` и отредактируйте. + +Минимальный рабочий фрагмент для **разработки** (без проверки IP): + +```yaml +listen: ":8080" +allow_all: true +servers: + - alias: main_srv + base_url: http://127.0.0.1:9091 +``` + +Минимальный фрагмент для **продакшена** (только перечисленные сети/хосты): + +```yaml +listen: ":8080" +allow_all: false +whitelist_cidrs: + - "203.0.113.10/32" + - "10.0.0.0/8" +servers: + - alias: main_srv + base_url: http://telemt-internal:9091 +``` + +Правила: + +- При `allow_all: false` и **пустом** `whitelist_cidrs` доступ будет **закрыт для всех** (кроме `GET /health`). +- `GET /health` на шлюзе **не** проверяется по whitelist — так проще настроить Docker `HEALTHCHECK` и оркестраторы. +- Поле `path_prefix` по умолчанию равно `/v1` (префикс Telemt Control API). + +Опционально для бэкенда с включённым `auth_header` в Telemt задайте в конфиге имя переменной окружения, значение которой будет отправлено как заголовок `Authorization` на этот upstream: + +```yaml +servers: + - alias: main_srv + base_url: http://telemt:9091 + authorization_env: TELEMT_API_AUTH +``` + +Значение должно **точно** совпадать с настроенным в Telemt `auth_header` (см. [API.md](API.md)). + +## Переменные окружения + +| Переменная | Описание | +|----------------|----------| +| `CONFIG_PATH` | Путь к YAML внутри контейнера. По умолчанию: `/etc/telemt-gateway/config.yaml`. | +| `TELEMT_API_AUTH` | Пример: секрет для `authorization_env` в конфиге (имя может быть любым). | + +## Сборка образа + +В каталоге репозитория: + +```powershell +docker build -t telemt-api-gateway:local . +``` + +## Запуск через Docker CLI + +Пример для PowerShell (подставьте путь к своему `config.yaml`): + +```powershell +docker run -d --name telemt-gateway ` + -p 8080:8080 ` + -v "C:\path\to\config.yaml:/etc/telemt-gateway/config.yaml:ro" ` + -e CONFIG_PATH=/etc/telemt-gateway/config.yaml ` + telemt-api-gateway:local +``` + +Проверка: + +```powershell +Invoke-WebRequest -Uri http://127.0.0.1:8080/health -UseBasicParsing +Invoke-WebRequest -Uri http://127.0.0.1:8080/api/main_srv/health -UseBasicParsing +``` + +Второй запрос проксируется на `{base_url}/v1/health` для alias `main_srv`. + +Остановка и удаление: + +```powershell +docker stop telemt-gateway +docker rm telemt-gateway +``` + +## Запуск через Docker Compose + +В репозитории есть [docker-compose.yml](../docker-compose.yml) и пример [config.compose.yaml](../config.compose.yaml) с `allow_all: true` и `base_url: http://host.docker.internal:9091` (Telemt на хосте). + +```powershell +docker compose up -d --build +docker compose logs -f gateway +docker compose down +``` + +На старых Linux‑хостах, где нет `host.docker.internal`, замените `base_url` на IP хоста или добавьте сервис Telemt в тот же `docker-compose` и укажите его DNS‑имя. + +## Проверка + +| Сценарий | Ожидание | +|----------|----------| +| `GET /health` | `200`, JSON `{"status":"ok"}` | +| Разрешённый IP, корректный alias | ответ бэкенда (например `200` для `/v1/health`) | +| IP не в whitelist | `403`, JSON с `code: forbidden` | +| Неизвестный alias | `404`, JSON с `code: not_found` | +| Бэкенд недоступен | `502`, JSON с `code: bad_gateway` | +| `GET /metrics` | текст метрик Prometheus (при разрешённом IP) | + +## Обновление и CI/CD + +- **Образ**: пересоберите тег или подтяните новый из registry, затем `docker compose up -d --build` или `docker stop` / `docker run ...` с тем же volume конфига. +- **Конфиг**: отредактируйте файл на хосте и перезапустите контейнер (шлюз не перечитывает конфиг на лету). +- **Gitea Actions**: workflow [.gitea/workflows/docker.yaml](../.gitea/workflows/docker.yaml) выполняет `go test` и собирает Docker‑образ. Для пуша в registry задайте secrets: + + - `REGISTRY_IMAGE` — полное имя образа без тега, например `git.example.com/owner/telemt-api-gateway` + - `REGISTRY_URL` — хост registry, например `git.example.com` + - `REGISTRY_USER` / `REGISTRY_PASSWORD` + + Если secrets не заданы, образ только собирается в runner без push. + +## Устранение неполадок + +- **`403 forbidden` с хоста при `allow_all: false`**: добавьте CIDR клиента в `whitelist_cidrs`. Запросы из контейнера к самому себе идут с `127.0.0.1` — при необходимости добавьте `127.0.0.1/32`. +- **За reverse proxy**: укажите CIDR прокси в `trusted_proxies`, иначе whitelist видит IP прокси, а не клиента. +- **`502 bad_gateway`**: проверьте `base_url`, DNS в Docker‑сети и то, что Telemt слушает API (`[server.api].enabled=true` и корректный `listen`). +- **Сборка Go без Docker**: выполните `go mod tidy && go test ./...` в корне репозитория. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..06d772a --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/telemt/telemt-api + +go 1.22 + +require ( + github.com/prometheus/client_golang v1.20.5 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..64d6ba5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,145 @@ +package config + +import ( + "fmt" + "net/netip" + "net/url" + "os" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +var aliasRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +// Config is the gateway YAML configuration. +type Config struct { + Listen string `yaml:"listen"` + AllowAll bool `yaml:"allow_all"` + WhitelistCIDRs []string `yaml:"whitelist_cidrs"` + TrustedProxies []string `yaml:"trusted_proxies"` + Servers []Server `yaml:"servers"` +} + +// Server maps a URL alias to an upstream base URL. +type Server struct { + Alias string `yaml:"alias"` + BaseURL string `yaml:"base_url"` + PathPrefix string `yaml:"path_prefix"` + AuthorizationEnv string `yaml:"authorization_env"` +} + +// Load reads and validates configuration from path. +func Load(path string) (*Config, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + var c Config + if err := yaml.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("parse yaml: %w", err) + } + if err := c.Validate(); err != nil { + return nil, err + } + return &c, nil +} + +// Validate checks required fields and formats. +func (c *Config) Validate() error { + if c.Listen == "" { + c.Listen = ":8080" + } + seen := make(map[string]struct{}) + for i := range c.Servers { + s := &c.Servers[i] + if s.Alias == "" { + return fmt.Errorf("servers[%d]: alias is required", i) + } + if !aliasRe.MatchString(s.Alias) { + return fmt.Errorf("servers[%d]: alias %q must match %s", i, s.Alias, aliasRe.String()) + } + if _, ok := seen[s.Alias]; ok { + return fmt.Errorf("duplicate alias %q", s.Alias) + } + seen[s.Alias] = struct{}{} + if s.BaseURL == "" { + return fmt.Errorf("servers[%d]: base_url is required", i) + } + u, err := url.Parse(s.BaseURL) + if err != nil || u.Scheme == "" || u.Host == "" { + return fmt.Errorf("servers[%d]: invalid base_url %q", i, s.BaseURL) + } + if s.PathPrefix == "" { + s.PathPrefix = "/v1" + } + s.PathPrefix = strings.TrimSuffix(s.PathPrefix, "/") + if !strings.HasPrefix(s.PathPrefix, "/") { + s.PathPrefix = "/" + s.PathPrefix + } + } + if len(c.Servers) == 0 { + return fmt.Errorf("at least one server entry is required") + } + for i, s := range c.WhitelistCIDRs { + if _, err := netip.ParsePrefix(strings.TrimSpace(s)); err != nil { + return fmt.Errorf("whitelist_cidrs[%d]: %w", i, err) + } + } + for i, s := range c.TrustedProxies { + if _, err := netip.ParsePrefix(strings.TrimSpace(s)); err != nil { + return fmt.Errorf("trusted_proxies[%d]: %w", i, err) + } + } + return nil +} + +// Parsed holds compiled CIDR lists and server map. +type Parsed struct { + Config *Config + Whitelist []netip.Prefix + Trusted []netip.Prefix + ByAlias map[string]*Server + AuthByAlias map[string]string // non-empty Authorization value per alias +} + +// Parse compiles CIDRs and resolves authorization from environment. +func (c *Config) Parse() (*Parsed, error) { + var wl []netip.Prefix + for _, s := range c.WhitelistCIDRs { + p, err := netip.ParsePrefix(strings.TrimSpace(s)) + if err != nil { + return nil, err + } + wl = append(wl, p) + } + var tr []netip.Prefix + for _, s := range c.TrustedProxies { + p, err := netip.ParsePrefix(strings.TrimSpace(s)) + if err != nil { + return nil, err + } + tr = append(tr, p) + } + by := make(map[string]*Server, len(c.Servers)) + auth := make(map[string]string) + for i := range c.Servers { + s := &c.Servers[i] + by[s.Alias] = s + if s.AuthorizationEnv != "" { + v := os.Getenv(s.AuthorizationEnv) + if v == "" { + return nil, fmt.Errorf("server %q: env %q is empty or unset", s.Alias, s.AuthorizationEnv) + } + auth[s.Alias] = v + } + } + return &Parsed{ + Config: c, + Whitelist: wl, + Trusted: tr, + ByAlias: by, + AuthByAlias: auth, + }, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..fb117ca --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadExample(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "cfg.yaml") + if err := os.WriteFile(p, []byte(` +listen: ":0" +allow_all: true +servers: + - alias: main_srv + base_url: http://127.0.0.1:9091 +`), 0644); err != nil { + t.Fatal(err) + } + c, err := Load(p) + if err != nil { + t.Fatal(err) + } + if c.Listen != ":0" { + t.Fatalf("listen: %q", c.Listen) + } + _, err = c.Parse() + if err != nil { + t.Fatal(err) + } +} + +func TestValidateDuplicateAlias(t *testing.T) { + c := &Config{ + Servers: []Server{ + {Alias: "a", BaseURL: "http://x:1"}, + {Alias: "a", BaseURL: "http://y:2"}, + }, + } + if err := c.Validate(); err == nil { + t.Fatal("expected error") + } +} diff --git a/internal/proxy/reverse.go b/internal/proxy/reverse.go new file mode 100644 index 0000000..ed2d261 --- /dev/null +++ b/internal/proxy/reverse.go @@ -0,0 +1,33 @@ +package proxy + +import ( + "net/http" + "net/http/httputil" + "net/url" + "strings" +) + +// NewReverseProxy builds a reverse proxy to target base URL with path rewriting: +// stripPrefix (/api/{alias}) + pathPrefix (/v1) + remainder. +func NewReverseProxy(target *url.URL, stripPrefix, pathPrefix string, setAuth string) *httputil.ReverseProxy { + proxy := httputil.NewSingleHostReverseProxy(target) + orig := proxy.Director + proxy.Director = func(req *http.Request) { + orig(req) + p := req.URL.Path + if strings.HasPrefix(p, stripPrefix) { + rest := strings.TrimPrefix(p, stripPrefix) + rest = strings.TrimPrefix(rest, "/") + if rest == "" { + req.URL.Path = pathPrefix + } else { + req.URL.Path = pathPrefix + "/" + rest + } + req.URL.RawPath = "" + } + if setAuth != "" { + req.Header.Set("Authorization", setAuth) + } + } + return proxy +} diff --git a/internal/proxy/reverse_test.go b/internal/proxy/reverse_test.go new file mode 100644 index 0000000..e46caae --- /dev/null +++ b/internal/proxy/reverse_test.go @@ -0,0 +1,26 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestReverseProxyPathRewrite(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/health" { + t.Fatalf("path %q", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + up, _ := url.Parse(srv.URL) + rp := NewReverseProxy(up, "/api/main_srv", "/v1", "") + req := httptest.NewRequest(http.MethodGet, "/api/main_srv/health", nil) + rec := httptest.NewRecorder() + rp.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } +} diff --git a/internal/server/clientip.go b/internal/server/clientip.go new file mode 100644 index 0000000..813e98a --- /dev/null +++ b/internal/server/clientip.go @@ -0,0 +1,65 @@ +package server + +import ( + "net" + "net/http" + "net/netip" + "strings" +) + +// ClientIP returns the client address for access control, using X-Forwarded-For / +// X-Real-IP only when the direct peer is in trusted CIDRs. +func ClientIP(r *http.Request, trusted []netip.Prefix) netip.Addr { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + peer, err := netip.ParseAddr(host) + if err != nil { + return netip.Addr{} + } + if !containsIP(trusted, peer) { + return peer + } + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if a, err := netip.ParseAddr(p); err == nil { + return a + } + } + } + if xr := strings.TrimSpace(r.Header.Get("X-Real-IP")); xr != "" { + if a, err := netip.ParseAddr(xr); err == nil { + return a + } + } + return peer +} + +func containsIP(prefixes []netip.Prefix, addr netip.Addr) bool { + for _, p := range prefixes { + if p.Contains(addr) { + return true + } + } + return false +} + +// Allowed reports whether addr matches whitelist rules. +func Allowed(addr netip.Addr, allowAll bool, whitelist []netip.Prefix) bool { + if !addr.IsValid() { + return false + } + if allowAll { + return true + } + if len(whitelist) == 0 { + return false + } + return containsIP(whitelist, addr) +} diff --git a/internal/server/clientip_test.go b/internal/server/clientip_test.go new file mode 100644 index 0000000..9a0d465 --- /dev/null +++ b/internal/server/clientip_test.go @@ -0,0 +1,34 @@ +package server + +import ( + "net/http" + "net/netip" + "testing" +) + +func TestAllowed(t *testing.T) { + p, _ := netip.ParsePrefix("127.0.0.1/32") + a := netip.MustParseAddr("127.0.0.1") + if !Allowed(a, false, []netip.Prefix{p}) { + t.Fatal("expected allowed") + } + if Allowed(a, false, nil) { + t.Fatal("empty whitelist should deny") + } + if !Allowed(a, true, nil) { + t.Fatal("allow_all") + } +} + +func TestClientIPTrustedXFF(t *testing.T) { + trusted, _ := netip.ParsePrefix("10.0.0.1/32") + r := &http.Request{ + Header: http.Header{}, + RemoteAddr: "10.0.0.1:12345", + } + r.Header.Set("X-Forwarded-For", "203.0.113.5, 10.0.0.1") + ip := ClientIP(r, []netip.Prefix{trusted}) + if ip.String() != "203.0.113.5" { + t.Fatalf("got %v", ip) + } +} diff --git a/internal/server/gateway.go b/internal/server/gateway.go new file mode 100644 index 0000000..8f5d182 --- /dev/null +++ b/internal/server/gateway.go @@ -0,0 +1,221 @@ +package server + +import ( + "context" + "encoding/json" + "log/slog" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" + + "github.com/telemt/telemt-api/internal/config" + "github.com/telemt/telemt-api/internal/proxy" +) + +// Gateway serves health, metrics, and proxied API routes. +type Gateway struct { + parsed *config.Parsed + proxies map[string]*httputil.ReverseProxy + log *slog.Logger + transport *http.Transport + promHandler http.Handler +} + +// NewGateway builds handlers and reverse proxies from parsed config. +func NewGateway(p *config.Parsed, log *slog.Logger) (*Gateway, error) { + t := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: 64, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ResponseHeaderTimeout: 120 * time.Second, + } + g := &Gateway{ + parsed: p, + proxies: make(map[string]*httputil.ReverseProxy), + log: log, + transport: t, + promHandler: promhttp.Handler(), + } + for i := range p.Config.Servers { + s := &p.Config.Servers[i] + u, err := url.Parse(s.BaseURL) + if err != nil { + return nil, err + } + auth := p.AuthByAlias[s.Alias] + strip := "/api/" + s.Alias + rp := proxy.NewReverseProxy(u, strip, s.PathPrefix, auth) + rp.Transport = t + rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + log.Error("upstream error", "alias", s.Alias, "err", err) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": false, + "error": map[string]string{"code": "bad_gateway", "message": "upstream unreachable"}, + }) + } + g.proxies[s.Alias] = rp + } + return g, nil +} + +// Handler returns the root HTTP handler with middleware. +func (g *Gateway) Handler() http.Handler { + var h http.Handler = http.HandlerFunc(g.serve) + h = g.withWhitelist(h) + h = g.withAccessLog(h) + h = g.withMetrics(h) + return h +} + +func (g *Gateway) withWhitelist(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + next.ServeHTTP(w, r) + return + } + ip := ClientIP(r, g.parsed.Trusted) + if !Allowed(ip, g.parsed.Config.AllowAll, g.parsed.Whitelist) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": false, + "error": map[string]string{"code": "forbidden", "message": "source address not allowed"}, + }) + return + } + next.ServeHTTP(w, r) + }) +} + +func (g *Gateway) withAccessLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rid := r.Header.Get("X-Request-Id") + if rid == "" { + rid = randomID() + r.Header.Set("X-Request-Id", rid) + } + w.Header().Set("X-Request-Id", rid) + start := time.Now() + lw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(lw, r) + g.log.Info("request", + "request_id", rid, + "method", r.Method, + "path", r.URL.Path, + "status", lw.status, + "duration_ms", time.Since(start).Milliseconds(), + "remote", r.RemoteAddr, + ) + }) +} + +func (g *Gateway) withMetrics(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + next.ServeHTTP(w, r) + return + } + httpInFlight.Inc() + start := time.Now() + alias := routeAlias(r.URL.Path) + lw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + defer observeRequest(r.Method, alias, lw.status, start) + next.ServeHTTP(lw, r) + }) +} + +func routeAlias(path string) string { + const pfx = "/api/" + if !strings.HasPrefix(path, pfx) { + if path == "/metrics" { + return "metrics" + } + return "_" + } + rest := strings.TrimPrefix(path, pfx) + if rest == "" { + return "_" + } + i := strings.IndexByte(rest, '/') + if i < 0 { + return rest + } + return rest[:i] +} + +func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"}) + return + case "/metrics": + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + g.promHandler.ServeHTTP(w, r) + return + } + const prefix = "/api/" + if !strings.HasPrefix(r.URL.Path, prefix) { + http.NotFound(w, r) + return + } + trim := strings.TrimPrefix(r.URL.Path, prefix) + if trim == "" { + http.NotFound(w, r) + return + } + var alias string + if i := strings.IndexByte(trim, '/'); i >= 0 { + alias = trim[:i] + } else { + alias = trim + } + if alias == "" { + http.NotFound(w, r) + return + } + rp, ok := g.proxies[alias] + if !ok { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": false, + "error": map[string]string{"code": "not_found", "message": "unknown alias"}, + }) + return + } + rp.ServeHTTP(w, r) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (s *statusWriter) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +// Shutdown idle connections on the shared transport. +func (g *Gateway) Shutdown(ctx context.Context) error { + g.transport.CloseIdleConnections() + return nil +} diff --git a/internal/server/metrics.go b/internal/server/metrics.go new file mode 100644 index 0000000..0db6300 --- /dev/null +++ b/internal/server/metrics.go @@ -0,0 +1,31 @@ +package server + +import ( + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + httpInFlight = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "telemt_gateway_http_in_flight", + Help: "Current requests being served.", + }) + httpRequests = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "telemt_gateway_http_requests_total", + Help: "HTTP requests by status, method, alias.", + }, []string{"code", "method", "alias"}) + httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "telemt_gateway_http_request_duration_seconds", + Help: "Request duration in seconds.", + Buckets: prometheus.DefBuckets, + }, []string{"method", "alias"}) +) + +func observeRequest(method, alias string, status int, started time.Time) { + httpInFlight.Dec() + httpRequests.WithLabelValues(strconv.Itoa(status), method, alias).Inc() + httpDuration.WithLabelValues(method, alias).Observe(time.Since(started).Seconds()) +} diff --git a/internal/server/rand_id.go b/internal/server/rand_id.go new file mode 100644 index 0000000..0b61a47 --- /dev/null +++ b/internal/server/rand_id.go @@ -0,0 +1,14 @@ +package server + +import ( + "crypto/rand" + "encoding/hex" +) + +func randomID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "unknown" + } + return hex.EncodeToString(b[:]) +}